27 März 2025

Portierung von C#-Code nach C++: Die SmartPtr-Implementierung

Von Anfang an bestand die Aufgabe darin, mehrere Projekte mit bis zu mehreren Millionen Codezeilen zu portieren. Im Wesentlichen lief die technische Spezifikation für den Translator auf den Satz hinaus: “Stellen Sie sicher, dass all dies portiert wird und in C++ korrekt läuft”. Die Arbeit derjenigen, die für die Veröffentlichung von C++-Produkten verantwortlich sind, umfasst das Übersetzen des Codes, das Ausführen von Tests, die Vorbereitung von Release-Paketen und so weiter. Die dabei auftretenden Probleme fallen typischerweise in eine von mehreren Kategorien:

  1. Der Code lässt sich nicht nach C++ übersetzen – der Translator bricht mit einem Fehler ab.
  2. Der Code wird nach C++ übersetzt, aber er kompiliert nicht.
  3. Der Code kompiliert, aber er lässt sich nicht linken.
  4. Der Code lässt sich linken und ausführen, aber Tests schlagen fehl oder es treten Laufzeitabstürze auf.
  5. Tests sind erfolgreich, aber während ihrer Ausführung treten Probleme auf, die nicht direkt mit der Funktionalität des Produkts zusammenhängen. Beispiele hierfür sind: Speicherlecks, schlechte Leistung usw.

Die Abarbeitung dieser Liste erfolgt von oben nach unten – zum Beispiel ist es ohne die Behebung von Kompilierungsproblemen im übersetzten Code unmöglich, dessen Funktionalität und Leistung zu überprüfen. Folglich wurden viele langjährige Probleme erst in den späteren Phasen der Arbeit am Projekt CodePorting.Translator Cs2Cpp entdeckt.

Anfänglich, bei der Behebung einfacher Speicherlecks, die durch zirkuläre Abhängigkeiten zwischen Objekten verursacht wurden, wendeten wir das CppWeakPtr-Attribut auf Felder an, was zu Feldern vom Typ WeakPtr führte. Solange WeakPtr durch Aufruf der lock()-Methode oder implizit (was syntaktisch bequemer ist) in SharedPtr konvertiert werden konnte, verursachte dies keine Probleme. Später mussten wir jedoch auch Referenzen, die in Containern enthalten sind, mithilfe einer speziellen Syntax für das CppWeakPtr-Attribut schwach machen, und dies führte zu einigen unangenehmen Überraschungen.

Das erste Anzeichen für Probleme mit unserem Ansatz war, dass aus C++-Sicht MyContainer<SharedPtr<MyClass>> und MyContainer<WeakPtr<MyClass>> zwei verschiedene Typen sind. Folglich können sie nicht in derselben Variablen gespeichert, an dieselbe Methode übergeben, von ihr zurückgegeben werden und so weiter. Das Attribut, das ursprünglich nur dazu gedacht war, die Speicherung von Referenzen in Objektfeldern zu steuern, tauchte in immer seltsameren Kontexten auf und beeinflusste Rückgabewerte, Argumente, lokale Variablen usw. Der Translator-Code, der dafür verantwortlich war, wurde von Tag zu Tag komplexer.

Das zweite Problem war ebenfalls etwas, womit wir nicht gerechnet hatten. Für C#-Programmierer stellte es sich als natürlich heraus, eine einzige assoziative Sammlung pro Objekt zu haben, die sowohl eindeutige Referenzen auf Objekte enthielt, die dem aktuellen Objekt gehören und anderweitig unzugänglich sind, als auch Referenzen auf übergeordnete Objekte. Dies geschah, um Leseoperationen aus bestimmten Dateiformaten zu optimieren, aber für uns bedeutete es, dass dieselbe Sammlung sowohl starke als auch schwache Referenzen enthalten konnte. Der Zeigertyp war nicht mehr der alleinige Bestimmungsfaktor für seinen Betriebsmodus.

Referenztyp als Teil des Zeigerzustands

Offensichtlich konnten diese beiden Probleme nicht innerhalb des bestehenden Paradigmas gelöst werden, und die Zeigertypen wurden überdacht. Das Ergebnis dieses überarbeiteten Ansatzes war die SmartPtr-Klasse, die eine set_Mode()-Methode bietet, die einen von zwei Werten akzeptiert: SmartPtrMode::Shared und SmartPtrMode::Weak. Alle SmartPtr-Konstruktoren akzeptieren dieselben Werte. Folglich kann jede Zeigerinstanz in einem von zwei Zuständen existieren:

  1. Starke Referenz: Der Referenzzähler ist im Objekt gekapselt;
  2. Schwache Referenz: Der Referenzzähler ist extern zum Objekt.

Das Umschalten zwischen den Modi kann zur Laufzeit und zu jedem beliebigen Zeitpunkt erfolgen. Der Zähler für schwache Referenzen wird erst erstellt, wenn mindestens eine schwache Referenz auf das Objekt existiert.

Die vollständige Liste der von unserem Zeiger unterstützten Funktionen lautet wie folgt:

  1. Speicherung starker Referenzen: Lebensdauerverwaltung von Objekten mittels Referenzzählung.
  2. Speicherung schwacher Referenzen für ein Objekt.
  3. intrusive_ptr-Semantik: Beliebig viele Zeiger, die für dasselbe Objekt erstellt werden, teilen sich einen einzigen Referenzzähler.
  4. Dereferenzierung und Pfeiloperator (->): Für den Zugriff auf das Objekt, auf das gezeigt wird.
  5. Ein vollständiger Satz von Konstruktoren und Zuweisungsoperatoren.
  6. Trennung des Objekts, auf das gezeigt wird, und des Objekts mit Referenzzählung (Aliasing-Konstruktor): Da die Bibliotheken unserer Kunden mit Dokumenten arbeiten, ist es oft notwendig, dass ein Zeiger auf ein Dokumentenelement das gesamte Dokument am Leben erhält.
  7. Ein vollständiger Satz von Casts.
  8. Ein vollständiger Satz von Vergleichsoperationen.
  9. Zuweisung und Löschung von Zeigern: Arbeiten mit unvollständigen Typen.
  10. Eine Reihe von Methoden zur Überprüfung und Änderung des Zeigerzustands: Aliasing-Modus, Referenzspeichermodus, Referenzanzahl des Objekts usw.

Die SmartPtr-Klasse ist als Template implementiert und enthält keine virtuellen Methoden. Sie ist eng mit der System::Object-Klasse gekoppelt, die die Speicherung des Referenzzählers übernimmt, und arbeitet ausschließlich mit deren abgeleiteten Klassen.

Es gibt Abweichungen vom typischen Zeigerverhalten:

  1. Verschieben (Move-Konstruktor, Move-Zuweisungsoperator) ändert nicht den gesamten Zustand; es behält den Referenztyp (schwach/stark) bei.
  2. Der Zugriff auf ein Objekt über eine schwache Referenz erfordert kein Locking (Erstellen einer temporären starken Referenz), da ein Ansatz, bei dem der Pfeiloperator ein temporäres Objekt zurückgibt, die Leistung für starke Referenzen stark beeinträchtigt.

Um die Kompatibilität mit altem Code aufrechtzuerhalten, wurde der Typ SharedPtr zu einem Alias für SmartPtr. Die Klasse WeakPtr erbt nun von SmartPtr, fügt keine Felder hinzu und überschreibt lediglich die Konstruktoren, um immer schwache Referenzen zu erstellen.

Container werden nun immer mit MyContainer<SmartPtr<MyClass>>-Semantik portiert, und die Art der gespeicherten Referenzen wird zur Laufzeit ausgewählt. Für Container, die manuell auf Basis von STL-Datenstrukturen geschrieben wurden (hauptsächlich Container aus dem System-Namespace), wird der Standard-Referenztyp mithilfe eines benutzerdefinierten Allokators festgelegt, wobei der Modus weiterhin für einzelne Containerelemente geändert werden kann. Für übersetzte Container wird der notwendige Code zum Umschalten des Referenzspeichermodus vom Translator generiert.

Die Nachteile dieser Lösung umfassen hauptsächlich eine reduzierte Leistung bei Erstellungs-, Kopier- und Löschoperationen von Zeigern, da zur üblichen Referenzzählung eine obligatorische Überprüfung des Referenztyps hinzugefügt wird. Spezifische Zahlen hängen stark von der Teststruktur ab. Derzeit laufen Diskussionen über das Generieren von optimalerem Code an Stellen, an denen sich der Zeigertyp garantiert nicht ändert.

Vorbereitung des Codes für die Übersetzung

Unsere Portierungsmethode erfordert das manuelle Platzieren von Attributen im C#-Quellcode, um zu markieren, wo Referenzen schwach sein sollen. Code, in dem diese Attribute nicht korrekt platziert sind, wird nach der Übersetzung Speicherlecks und in einigen Fällen andere Fehler verursachen. Code mit Attributen sieht etwa so aus:

struct S {
    MyClass s; // Starke Referenz auf Objekt

    [CppWeakPtr]
    MyClass w; // Schwache Referenz auf Objekt

    MyContainer<MyClass> s_s; // Starke Referenz auf einen Container mit starken Referenzen

    [CppWeakPtr]
    MyContainer<MyClass> w_s; // Schwache Referenz auf einen Container mit starken Referenzen

    [CppWeakPtr(0)]
    MyContainer<MyClass> s_w; // Starke Referenz auf einen Container mit schwachen Referenzen

    [CppWeakPtr(1)]
    Dictionary<MyClass, MyClass> s_s_w; // Starke Referenz auf einen Container, bei dem Schlüssel durch starke und Werte durch schwache Referenzen gespeichert werden

    [CppWeakPtr, CppWeakPtr(0)]
    Dictionary<MyClass, MyClass> w_w_s; // Schwache Referenz auf einen Container, bei dem Schlüssel durch schwache und Werte durch starke Referenzen gespeichert werden
}

In einigen Fällen ist es notwendig, manuell den Aliasing-Konstruktor der SmartPtr-Klasse oder deren Methode aufzurufen, die den gespeicherten Referenztyp festlegt. Wir versuchen, das Bearbeiten des portierten Codes zu vermeiden, da solche Änderungen nach jedem Translator-Lauf erneut angewendet werden müssen. Stattdessen zielen wir darauf ab, solchen Code im C#-Quellcode zu belassen. Dafür haben wir zwei Möglichkeiten:

  1. Wir können eine Dienstmethode im C#-Code deklarieren, die nichts tut, und sie während der Übersetzung durch ein manuell geschriebenes Äquivalent ersetzen, das die notwendige Operation durchführt:
class Service {
    public static void SetWeak<T>(T arg) {}
}
class Service {
public:
    template <typename T> static void SetWeak(SmartPtr<T> &arg)
    {
        arg.set_Mode(SmartPtrMode::Weak);
    }
};
  1. Wir können speziell formatierte Kommentare im C#-Code platzieren, die der Translator in C++-Code umwandelt:
class MyClass {
    private Dictionary<string, object> data;
    public void Add(string key, object value)
    {
        data.Add(key, value);
        //CPPCODE: if (key == u"Parent") data->data()[key].set_Mode(SmartPtrMode::Weak);
    }
}

Hier gibt die data()-Methode in System::Collections::Generic::Dictionary eine Referenz auf die zugrunde liegende std::unordered_map dieses Containers zurück.

Verwandte Nachrichten

Verwandte Videos

In Verbindung stehende Artikel