Regeln für die Übersetzung von Code von C# nach C++: Klassenmitglieder und Kontrollstrukturen

In diesem Artikel werden wir erkunden, wie unser Übersetzer Klassenmitglieder, Variablen, Felder, Operatoren und C#-Kontrollstrukturen in C++ umwandelt. Wir werden auch auf die Verwendung der Übersetzer-Supportbibliothek zur korrekten Konvertierung von .NET Framework-Typen in C++ eingehen.

Klassenmitglieder

Klassenmethoden werden direkt auf C++ abgebildet. Dies gilt auch für statische Methoden und Konstruktoren. In einigen Fällen kann zusätzlicher Code auftauchen –- zum Beispiel, um Aufrufe von statischen Konstruktoren zu emulieren. Erweiterungsmethoden und Operatoren werden in statische Methoden übersetzt und explizit aufgerufen. Finalisierer werden zu Destruktoren.

C#-Instanzfelder werden zu C++-Instanzfeldern. Statische Felder bleiben ebenfalls unverändert, außer in Fällen, in denen die Initialisierungsreihenfolge wichtig ist –- dies wird durch die Übersetzung solcher Felder als Singletons implementiert.

Eigenschaften werden in eine Getter-Methode und eine Setter-Methode aufgeteilt, oder nur eine, wenn die zweite Methode fehlt. Bei Auto-Eigenschaften wird auch ein privates Wertfeld hinzugefügt. Statische Eigenschaften werden in einen statischen Getter und Setter aufgeteilt. Indexer werden mit derselben Logik verarbeitet.

Ereignisse werden in Felder übersetzt, deren Typ der erforderlichen Spezialisierung von System::Event entspricht. Eine Übersetzung in Form von drei Methoden (add, remove und invoke) wäre korrekter und würde außerdem die Unterstützung abstrakter und virtueller Ereignisse ermöglichen. Möglicherweise werden wir in Zukunft zu einem solchen Modell kommen, aber derzeit deckt die Option der Event-Klasse unsere Anforderungen vollständig ab.

Das folgende Beispiel veranschaulicht die oben genannten Regeln:

public abstract class Generic<T>
{
    private T m_value;
    public Generic(T value)
    {
        m_value = value;
    }
    ~Generic()
    {
        m_value = default(T);
    }
    public string Property { get; set; }
    public abstract int Property2 { get; }
    public T this[int index]
    {
        get
        {
            return index == 0 ? m_value : default(T);
        }
        set
        {
            if (index == 0)
                m_value = value;
            else
                throw new ArgumentException();
        }
    }
    public event Action<int, int> IntIntEvent;
}

C++-Übersetzungsergebnis (unbedeutender Code entfernt):

template<typename T>
class Generic : public System::Object
{
public:
    System::String get_Property()
    {
        return pr_Property;
    }
    void set_Property(System::String value)
    {
        pr_Property = value;
    }
    
    virtual int32_t get_Property2() = 0;
    
    Generic(T value) : m_value(T())
    {
        m_value = value;
    }
    
    T idx_get(int32_t index)
    {
        return index == 0 ? m_value : System::Default<T>();
    }
    void idx_set(int32_t index, T value)
    {
        if (index == 0)
        {
            m_value = value;
        }
        else
        {
            throw System::ArgumentException();
        }
    }
    
    System::Event<void(int32_t, int32_t)> IntIntEvent;
    
    virtual ~Generic()
    {
        m_value = System::Default<T>();
    }

private:
    T m_value;
    System::String pr_Property;
};

Variablen und Felder

Konstante und statische Felder werden in statische Felder, statische Konstanten (in einigen Fällen als constexpr) oder in statische Methoden übersetzt, die den Zugriff auf einen Singleton ermöglichen. C#-Instanzfelder werden in C++-Instanzfelder umgewandelt. Komplexe Initialisierer werden in Konstruktoren verschoben, und manchmal ist es notwendig, explizit Standardkonstruktoren hinzuzufügen, wenn sie in C# nicht vorhanden waren. Stapelvariablen werden unverändert übergeben. Methodenargumente werden ebenfalls unverändert übergeben, außer dass sowohl ref- als auch out-Argumente zu Referenzen werden (zum Glück ist eine Überladung mit ihnen verboten).

Die Typen von Feldern und Variablen werden durch ihre C+±Äquivalente ersetzt. In den meisten Fällen werden solche Äquivalente vom Übersetzer selbst aus dem C#-Quellcode generiert. Bibliothekstypen, einschließlich .NET Framework-Typen und einige andere, werden von uns in C++ geschrieben und sind Teil der Übersetzer-Supportbibliothek, die zusammen mit den konvertierten Produkten geliefert wird. var wird in auto übersetzt, außer in Fällen, in denen eine explizite Typangabe erforderlich ist, um Unterschiede im Verhalten auszugleichen.

Darüber hinaus werden Referenztypen in SmartPtr eingewickelt. Werttypen werden unverändert ersetzt. Da Typargumente entweder Wert- oder Referenztypen sein können, werden sie ebenfalls unverändert ersetzt. Bei der Instanziierung werden Referenzargumente jedoch in SharedPtr eingewickelt. So wird beispielsweise List<int> als List<int32_t> übersetzt, während List<Object> zu List<SmartPtr<Object>> wird. In einigen Ausnahmefällen werden Referenztypen als Werttypen übersetzt. Unsere Implementierung von System::String basiert beispielsweise auf dem UnicodeString-Typ von ICU und ist für die Stapelspeicherung optimiert.

Um dies zu veranschaulichen, übersetzen wir die folgende Klasse:

public class Variables
{
    public int m_int;
    private string m_string = new StringBuilder().Append("foobazz").ToString();
    private Regex m_regex = new Regex("foo|bar");
    public object Foo(int a, out int b)
    {
        b = a + m_int;
        return m_regex.Match(m_string);
    }
}

Nach der Übersetzung ergibt sich folgende Form (unbedeutender Code entfernt):

class Variables : public System::Object
{
public:
    int32_t m_int;
    System::SharedPtr<System::Object> Foo(int32_t a, int32_t& b);
    Variables();
private:
    System::String m_string;
    System::SharedPtr<System::Text::RegularExpressions::Regex> m_regex;
};
System::SharedPtr<System::Object> Variables::Foo(int32_t a, int32_t& b)
{
    b = a + m_int;
    return m_regex->Match(m_string);
}
Variables::Variables()
    : m_int(0)
    , m_regex(System::MakeObject<System::Text::RegularExpressions::Regex>(u"foo|bar"))
{
    this->m_string = System::MakeObject<System::Text::StringBuilder>()->
        Append(u"foobazz")->ToString();
}

Steuerstrukturen

Die Ähnlichkeit der Hauptsteuerstrukturen spielt uns in die Hände. Solche Operatoren wie if, else, switch, while, do-while, for, try-catch, return, break und continue werden größtenteils unverändert übertragen. Die Ausnahme in dieser Liste ist vielleicht nur das switch, das ein paar spezielle Behandlungen erfordert. Erstens erlaubt C# seine Verwendung mit dem String-Typ – in C++ generieren wir in diesem Fall eine Sequenz von if-else if. Zweitens ermöglicht die relativ neue Möglichkeit, den geprüften Ausdruck an einen Typvorlage anzupassen – was jedoch auch leicht in eine Sequenz von if entfaltet werden kann.

Konstruktionen, die in C++ nicht vorhanden sind, sind interessant. So garantiert der using-Operator den Aufruf der Dispose()-Methode beim Verlassen des Kontexts. In C++ emulieren wir dieses Verhalten, indem wir ein Wächterobjekt auf dem Stapel erstellen, das die erforderliche Methode in seinem Destruktor aufruft. Davor ist es jedoch notwendig, die Exception abzufangen, die vom Code geworfen wurde, der den Körper von using war, und den exception_ptr im Feld des Wächters zu speichern – wenn Dispose() keine Exception wirft, wird die von uns gespeicherte Exception erneut geworfen. Dies ist nur dieser seltene Fall, in dem das Werfen einer Exception aus einem Destruktor gerechtfertigt ist und kein Fehler darstellt. Der finally-Block wird nach einem ähnlichen Schema übersetzt, nur anstelle der Dispose()-Methode wird eine Lambda-Funktion aufgerufen, in die der Übersetzer ihren Körper eingewickelt hat.

Ein weiterer Operator, der in C# nicht vorhanden ist und den wir gezwungen sind zu emulieren, ist foreach. Anfangs haben wir ihn in ein äquivalentes while übersetzt, das die MoveNext()-Methode des Enumerators aufruft. Diese Methode ist universell, aber recht langsam. Da die meisten C++-Implementierungen von .NET-Containern STL-Datenstrukturen verwenden, verwenden wir, wo möglich, ihre ursprünglichen Iteratoren und konvertieren foreach in einen range-basierten for-Loop. In Fällen, in denen die ursprünglichen Iteratoren nicht verfügbar sind (zum Beispiel wenn der Container in reinem C# implementiert ist), werden Wrapper-Iteratoren verwendet, die intern mit Enumeratoren arbeiten. Früher lag die Wahl der richtigen Iterationsmethode in der Verantwortung einer externen Funktion, die die SFINAE-Technik verwendete. Jetzt sind wir jedoch nahe daran, die korrekten Versionen der begin-end-Methoden in allen Containern zu haben, einschließlich der übersetzten.

Operatoren

Wie bei Kontrollstrukturen erfordern die meisten Operatoren (zumindest arithmetische, logische und Zuweisungsoperatoren) keine besondere Verarbeitung. Es gibt jedoch einen subtilen Punkt: In C# ist die Auswertungsreihenfolge von Teilen eines Ausdrucks deterministisch, während es in C++ in einigen Fällen zu undefiniertem Verhalten kommen kann. Zum Beispiel verhält sich der folgende übersetzte Code nach der Kompilierung durch verschiedene Tools unterschiedlich:

auto offset32 = block[i++] + block[i++] * 256 + block[i++] * 256 * 256 +
    block[i++] * 256 * 256 * 256;

Glücklicherweise sind solche Probleme recht selten. Wir haben Pläne, den Übersetzer so zu schulen, dass er mit solchen Momenten umgehen kann. Aufgrund der Komplexität der Analyse, die Ausdrücke mit Nebeneffekten identifiziert, wurde dies jedoch noch nicht umgesetzt.

Selbst die einfachsten Operatoren erfordern jedoch eine besondere Verarbeitung, wenn sie auf Eigenschaften angewendet werden. Wie oben gezeigt, werden Eigenschaften in Getter und Setter aufgeteilt, und der Übersetzer muss die erforderlichen Aufrufe abhängig vom Kontext einfügen:

obj1.Property = obj2.Property;
string s = GetObj().Property += "suffix";
obj1->set_Property(obj2->get_Property());
System::String s = System::setter_add_wrap(static_cast<MyClass*>(GetObj().GetPointer()),
    &MyClass::get_Property, &MyClass::set_Property, u"suffix")

In der ersten Zeile erwies sich der Ersatz als trivial. In der zweiten Zeile war es notwendig, den setter_add_wrap-Wrapper zu verwenden, um sicherzustellen, dass die Funktion GetObj() nur einmal aufgerufen wird. Das Ergebnis der Verkettung des Aufrufs von get_Property() und des Zeichenliteral wird nicht nur an die Methode set_Property() (die void zurückgibt) übergeben, sondern auch weiterhin im Ausdruck verwendet. Der gleiche Ansatz wird beim Zugriff auf Indexer angewendet.

C#-Operatoren, die in C++ nicht vorhanden sind, wie as, is, typeof, default, ??, ?., werden mithilfe von Funktionen aus der Übersetzer-Supportbibliothek emuliert. In Fällen, in denen eine doppelte Auswertung von Argumenten vermieden werden muss, z. B. um GetObj()?.Invoke() nicht in GetObj() ? GetObj().Invoke() : nullptr zu entfalten, wird ein ähnlicher Ansatz wie der oben gezeigte verwendet.

Der Memberzugriffsoperator (.) kann je nach Kontext durch einen äquivalenten Operator aus C++ ersetzt werden: den Bereichsauflösungsoperator (::) oder den “Pfeil” (->). Ein solcher Ersatz ist nicht erforderlich, wenn auf Mitglieder von Strukturen zugegriffen wird.

In Verbindung stehende Artikel