20 Februar 2025
Bei der Entwicklung eines Code-Übersetzers von C# nach Java gibt es keine Probleme mit dem Löschen nicht verwendeter Objekte: Java bietet einen Garbage Collection-Mechanismus, der dem in C# ausreichend ähnlich ist, und der übersetzte Code, der Klassen verwendet, lässt sich einfach kompilieren und ausführen. C++ ist ein anderes Kapitel. Es ist offensichtlich, dass das Abbilden von Referenzen auf rohe Zeiger nicht die gewünschten Ergebnisse liefern wird, da ein solcher übersetzter Code nichts löschen würde. Gleichzeitig werden C#-Entwickler, die an die Arbeit in einer GC-Umgebung gewöhnt sind, weiterhin Code schreiben, der viele temporäre Objekte erzeugt.
Um sicherzustellen, dass Objekte im konvertierten Code rechtzeitig gelöscht werden, mussten wir zwischen drei Optionen wählen:
Die dritte Option wurde sofort verworfen: Die Komplexität der Algorithmen in den portierten Bibliotheken erwies sich als zu hoch. Zusätzlich müsste die statische Analyse auch auf den Client-Code ausgeweitet werden, der diese konvertierten Bibliotheken verwendet.
Die zweite Option erschien ebenfalls unpraktisch: Da wir Bibliotheken und keine Anwendungen portierten, würde die Verwendung eines Garbage Collectors Einschränkungen für den Client-Code mit sich bringen, der diese Bibliotheken verwendet. Experimente in dieser Richtung wurden als erfolglos eingestuft.
Somit blieb nur die letzte Option – die Verwendung von intelligenten Zeigern mit Referenzzählung, was in C++ durchaus üblich ist. Dies bedeutete wiederum, dass wir zur Lösung des Problems zirkulärer Referenzen schwache Referenzen zusätzlich zu starken Referenzen verwenden mussten.
Es gibt mehrere bekannte Arten von intelligenten Zeigern:
shared_ptr
mag wie die naheliegendste Wahl erscheinen, hat jedoch einen erheblichen Nachteil: Es speichert den Referenzzähler auf dem Heap, getrennt vom Objekt, selbst bei Verwendung von enable_shared_from_this
. Die Speicherzuweisung und -freigabe für den Referenzzähler ist eine relativ teure Operation.intrusive_ptr
ist in dieser Hinsicht besser, da ein ungenutztes 4/8-Byte-Feld innerhalb der Struktur ein geringeres Übel im Vergleich zum Overhead einer zusätzlichen Speicherzuweisung für jedes temporäre Objekt darstellt.Betrachten wir nun den folgenden C#-Code:
class Document
{
private Node node;
public Document()
{
node = new Node(this);
}
public void Prepare(Node n) { ... }
}
class Node
{
private Document document;
public Node(Document d)
{
document = d;
d.Prepare(this);
}
}
Bei Verwendung von intrusive_ptr
würde dieser Code etwa wie folgt übersetzt werden:
class Document : public virtual System::Object
{
intrusive_ptr<Node> node;
public:
Document()
{
node = make_shared_intrusive<Node>(this);
}
void Prepare(intrusive_ptr<Node> n) { ... }
};
class Node : public virtual System::Object
{
intrusive_ptr<Document> document;
public:
Node(intrusive_ptr<Document> d)
{
document = d;
d->Prepare(this);
}
};
Hier werden drei Probleme sofort deutlich:
Node::document
.this
in einen intrusive_ptr
umzuwandeln (analog zu shared_from_this
). Wenn wir stattdessen Methodensignaturen ändern (z. B. Document::Prepare
so anpassen, dass es Node*
anstelle von intrusive_ptr<Node>
akzeptiert), treten Probleme auf, wenn dieselben Methoden mit bereits konstruierten Objekten aufgerufen werden oder die Lebensdauer von Objekten verwaltet wird.this
in einen intrusive_ptr
während der Objektkonstruktion, gefolgt von der Verringerung des Referenzzählers auf null (wie es beispielsweise im Node
-Konstruktor beim Verlassen von Document::Prepare
geschieht), darf nicht zur sofortigen Löschung des teilweise konstruierten Objekts führen, auf das noch keine externen Referenzen existieren.Das erste Problem wurde manuell gelöst, da selbst ein Mensch oft Schwierigkeiten hat, zu bestimmen, welche von mehreren Referenzen schwach sein sollte. In einigen Fällen gibt es keine klare Antwort, was Änderungen am C#-Code erfordert.
In einem Projekt gab es beispielsweise ein Paar von Klassen: „Druckaktion“ und „Druckaktionsparameter“. Der Konstruktor jeder Klasse erstellte das zugehörige Objekt und stellte bidirektionale Referenzen her. Offensichtlich würde das Umwandeln einer dieser Referenzen in eine schwache Referenz das Nutzungsszenario zerstören. Letztendlich entschieden wir uns für das Attribut [CppWeakPtr]
, das dem Übersetzer anweist, dass das entsprechende Feld eine schwache Referenz anstelle einer starken enthalten soll.
Das zweite Problem lässt sich leicht lösen, wenn intrusive_ptr
die Umwandlung von einem rohen Zeiger, wie this
, zulässt. Die Boost-Implementierung bietet diese Möglichkeit.
Schließlich wurde das dritte Problem durch die Einführung einer lokalen RAII-Guard-Variable im Konstruktor gelöst. Diese Guard erhöht den Referenzzähler des aktuellen Objekts bei der Erstellung und verringert ihn bei der Zerstörung. Wichtig ist, dass die Verringerung des Referenzzählers auf null innerhalb der Guard nicht zur Löschung des geschützten Objekts führt.
Mit diesen Änderungen sieht der Code vor und nach der Übersetzung etwa so aus:
class Document
{
private Node node;
public Document()
{
node = new Node(this);
}
public void Prepare(Node n) { ... }
}
class Node
{
[CppWeakPtr] private Document document;
public Node(Document d)
{
document = d;
d.Prepare(this);
}
}
class Document : public virtual System::Object
{
intrusive_ptr<Node> node;
public:
Document()
{
System::Details::ThisProtector guard(this);
node = make_shared_intrusive<Node>(this);
}
void Prepare(intrusive_ptr<Node> n) { ... }
};
class Node : public virtual System::Object
{
weak_intrusive_ptr<Document> document;
public:
Node(intrusive_ptr<Document> d)
{
System::Details::ThisProtector guard(this);
document = d;
d->Prepare(this);
}
};
Somit reicht jede Implementierung von intrusive_ptr
, die unseren Anforderungen entspricht und durch eine gepaarte weak_intrusive_ptr
-Klasse ergänzt wird, aus. Letztere muss sich auf einen Referenzzähler stützen, der sich außerhalb des Objekts auf dem Heap befindet. Da die Erstellung schwacher Referenzen im Vergleich zur Erstellung temporärer Objekte eine relativ seltene Operation ist, führte die Trennung des Referenzzählers in einen starken (innerhalb des Objekts) und einen schwachen (außerhalb des Objekts) zu einer Leistungssteigerung im realen Code.
Die Situation wird erheblich komplizierter, da wir Code für generische Klassen und Methoden übersetzen müssen, bei denen Typparameter sowohl Werttypen als auch Referenztypen sein können. Betrachten wir beispielsweise den folgenden C#-Code:
class MyContainer<T>
{
public T field;
public void Set(T val)
{
field = val;
}
}
class MyClass {}
struct MyStruct {}
var a = new MyContainer<MyClass>();
var b = new MyContainer<MyStruct>();
Ein direkter Übersetzungsansatz ergibt folgendes Ergebnis:
template <typename T> class MyContainer : public virtual System::Object
{
public:
T field;
void Set(T val)
{
field = val;
}
};
class MyClass : public virtual System::Object {};
class MyStruct : public System::Object {};
auto a = make_shared_intrusive<MyContainer<MyClass>>();
auto b = make_shared_intrusive<MyContainer<MyStruct>>();
Offensichtlich wird dieser Code nicht dasselbe Verhalten wie das Original zeigen, da beim Instanziieren von MyContainer<MyClass>
das field
-Objekt vom Heap in das MyContainer
-Feld verschoben wird, wodurch die Semantik des Referenzkopierens gebrochen wird. Gleichzeitig ist die Platzierung der MyStruct
-Struktur im Feld völlig korrekt, da sie dem Verhalten von C# entspricht.
Diese Situation kann auf zwei Arten gelöst werden:
MyContainer<MyClass>
zur Semantik von MyContainer<intrusive_ptr<MyClass>>
wechseln:auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
template <typename T, bool is_T_reference_type = is_reference_type_v<T>> class MyContainer : public virtual System::Object
{
public:
T field;
void Set(T val)
{
field = val;
}
};
template <typename T> class MyContainer<T, true> : public virtual System::Object
{
public:
intrusive_ptr<T> field;
void Set(intrusive_ptr<T> val)
{
field = val;
}
};
Neben der Wortgewaltigkeit, die mit jedem zusätzlichen Typparameter exponentiell wächst, hat der zweite Ansatz den Nachteil, dass jeder Kontext, der MyContainer<T>
verwendet, wissen muss, ob T
ein Werttyp oder ein Referenztyp ist, was oft unerwünscht ist. Zum Beispiel, wenn wir die Anzahl der einzubindenden Header minimieren oder Informationen über bestimmte interne Typen vollständig verbergen möchten.
Zusätzlich kann die Wahl des Referenztyps (stark oder schwach) nur einmal pro Container getroffen werden. Dies bedeutet, dass es unmöglich wird, sowohl eine List
starker Referenzen als auch eine List
schwacher Referenzen zu haben, obwohl der Code der konvertierten Produkte beide Varianten erfordert.
Unter Berücksichtigung dieser Faktoren wurde beschlossen, MyContainer<MyClass>
mit der Semantik von MyContainer<System::SharedPtr<MyClass>>
oder MyContainer<System::WeakPtr<MyClass>>
für schwache Referenzen zu portieren. Da die gängigsten Bibliotheken keine Zeiger mit den erforderlichen Eigenschaften bieten, entwickelten wir unsere eigenen Implementierungen, die als System::SharedPtr
– eine starke Referenz mit einem Referenzzähler im Objekt – und System::WeakPtr
– eine schwache Referenz mit einem externen Referenzzähler – bezeichnet wurden. Die Funktion System::MakeObject
ist für die Erstellung von Objekten im Stil von std::make_shared
verantwortlich.