20 Februar 2025

Portierung von C#-Code nach C++: Intelligente Zeiger

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:

  1. Referenzzählung für Objekte verwenden – beispielsweise über intelligente Zeiger;
  2. Eine Garbage Collector-Implementierung für C++ verwenden – beispielsweise Boehm GC;
  3. Statische Analyse verwenden, um die Punkte zu bestimmen, an denen Objekte gelöscht werden sollten.

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.

Arten von intelligenten Zeigern

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:

  1. Ein Mechanismus ist erforderlich, um die zirkuläre Referenz zu unterbrechen, in diesem Fall durch die Verwendung einer schwachen Referenz für Node::document.
  2. Es muss eine Möglichkeit geben, 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.
  3. Die Umwandlung von 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.

Templates

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:

  1. Von der Semantik von MyContainer<MyClass> zur Semantik von MyContainer<intrusive_ptr<MyClass>> wechseln:
auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
  1. Für jede Template-Klasse zwei Spezialisierungen erstellen: eine für Fälle, in denen der Typargument ein Werttyp ist, und eine für Fälle, in denen es sich um einen Referenztyp handelt:
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.

Verwandte Nachrichten

Verwandte Videos

In Verbindung stehende Artikel