Regeln für die Übersetzung von Code von C# nach C++: Objekterstellung und Methodenaufrufe

Manchmal kann sich das Verhalten von in C# und C++ geschriebenem Code unterscheiden. Schauen wir uns genauer an, wie CodePorting.Translator Cs2Cpp mit solchen Unterschieden umgeht und die Korrektheit der Codeübersetzung sicherstellt. Wir werden auch lernen, wie die Umwandlung von Unit-Tests durchgeführt wird.

Objekterstellung und Initialisierung

Für die Erstellung von Objekten vom Referenztyp verwenden wir die Funktion MakeObject (analog zu std::make_shared), die ein Objekt mit dem new-Operator erstellt und es sofort in einen SharedPtr einwickelt. Die Verwendung dieser Funktion hat es uns ermöglicht, Probleme zu vermeiden, die durch Rohzeiger eingeführt wurden. Allerdings hat sie ein Zugriffsrechtsproblem verursacht: Da sie außerhalb aller Klassen liegt, hatte sie keinen Zugriff auf private Konstruktoren. Durch die Deklaration dieser Funktion als Freund von Klassen mit nicht öffentlichen Konstruktoren wäre es möglich gewesen, solche Klassen in allen Kontexten zu erstellen. Als Ergebnis wurde die externe Version dieser Funktion auf die Verwendung mit öffentlichen Konstruktoren beschränkt, und für nicht öffentliche Konstruktoren wurden statische MakeObject-Methoden hinzugefügt, die den gleichen Zugriffslevel und die gleichen Argumente wie der proximierte Konstruktor haben.

C#-Programmierer verwenden oft Property-Initialisierer als Teil des Objekterstellungsausdrucks. Die entsprechende Syntax muss in Lambda-Funktionen eingewickelt werden, da es sonst nicht möglich ist, die Initialisierer als Teil eines einzelnen Ausdrucks zu schreiben:

Foo(new MyClass() { Property1 = "abc", Property2 = 1, Field1 = 3.14 });
Foo([&]{ auto tmp_0 = System::MakeObject<MyClass>();
        tmp_0->set_Property1(u"abc");
        tmp_0->set_Property2(1);
        tmp_0->Field1 = 3.14;
        return tmp_0;
    }());

Aufrufe, Delegaten und anonyme Methoden

Methodenaufrufe werden unverändert übertragen. Bei der Arbeit mit überladenen Methoden ist es manchmal notwendig, Argumenttypen explizit zu casten, da die Regeln zur Auflösung von Überladungen in C++ von denen in C# abweichen. Betrachten Sie zum Beispiel den folgenden Code:

class MyClass<T>
{
    public void Foo(string s) { }
    public void Bar(string s) { }
    public void Bar(bool b) { }
    public void Call()
    {
        Foo("abc");
        Bar("def");
    }
}

Nach der Übersetzung sieht es so aus:

template<typename T>
class MyClass : public System::Object
{
public:
    void Foo(System::String s)
    {
        CODEPORTING_UNUSED(s);
    }
    void Bar(System::String s)
    {
        CODEPORTING_UNUSED(s);
    }
    void Bar(bool b)
    {
        CODEPORTING_UNUSED(b);
    }
    void Call()
    {
        Foo(u"abc");
        Bar(System::String(u"def"));
    }
};

Hinweis: Die Methodenaufrufe von Foo und Bar innerhalb der Methode Call sind unterschiedlich geschrieben. Dies liegt daran, dass ohne einen expliziten Aufruf des System::String-Konstruktors die Überladung von Bar, die einen bool akzeptiert, aufgerufen würde, da solche Typumwandlungen gemäß den C+±Regeln eine höhere Priorität haben. Im Fall der Methode Foo gibt es keine solche Mehrdeutigkeit, und der Übersetzer generiert einfacheren Code.

Ein weiteres Beispiel für unterschiedliches Verhalten von C# und C++ ist die Template-Expansion. In C# erfolgt die Typparameter-Substitution zur Laufzeit und beeinflusst nicht die Auflösung von Aufrufen innerhalb generischer Methoden. In C++ erfolgt die Template-Argument-Substitution zur Kompilierzeit, daher muss das C#-Verhalten emuliert werden. Betrachten Sie zum Beispiel den folgenden Code:

class GenericMethods
{
    public void Foo<T>(T value) { }
    public void Foo(string s) { }
    public void Bar<T>(T value)
    {
        Foo(value);
    }
    public void Call()
    {
        Bar("abc");
    }
}
class GenericMethods : public System::Object
{
public:
    template <typename T>
    void Foo(T value)
    {
        CODEPORTING_UNUSED(value);
    }
    void Foo(System::String s);
    template <typename T>
    void Bar(T value)
    {
        Foo<T>(value);
    }
    void Call();
};
void GenericMethods::Foo(System::String s)
{
}
void GenericMethods::Call()
{
    Bar<System::String>(u"abc");
}

Hier ist es wichtig, die explizite Angabe der Template-Argumente beim Aufruf von Foo und Bar zu beachten. Im ersten Fall ist dies notwendig, da andernfalls bei der Instanziierung der Version für T=System::String die nicht-templatierte Version aufgerufen würde, was sich vom C#-Verhalten unterscheidet. Im zweiten Fall wird das Argument benötigt, da es andernfalls basierend auf dem Typ des Zeichenliteral abgeleitet würde. Im Allgemeinen muss der Übersetzer fast immer explizit Template-Argumente angeben, um unerwartetes Verhalten zu vermeiden.

In vielen Fällen muss der Übersetzer explizite Aufrufe generieren, wo in C# keine vorhanden sind – dies betrifft hauptsächlich Zugriffsmethoden für Eigenschaften und Indizes. Aufrufe von Konstruktoren von Referenztypen werden in MakeObject eingewickelt, wie oben gezeigt.

In .NET gibt es Methoden, die Überladung nach Anzahl und Typ der Argumente durch die params-Syntax unterstützen, indem object als Argumenttyp oder beides gleichzeitig angegeben wird – zum Beispiel existieren solche Überladungen für StringBuilder.Append() und Console.WriteLine(). Die direkte Übertragung solcher Konstruktionen zeigt aufgrund von Boxing und der Erstellung temporärer Arrays eine schlechte Leistung. In solchen Fällen fügen wir eine Überladung hinzu, die eine variable Anzahl von Argumenten beliebiger Typen mit variadischen Vorlagen akzeptiert, und wir übersetzen die Argumente so, wie sie sind, ohne Typumwandlungen und Zusammenführung in Arrays. Dadurch wird die Leistung solcher Aufrufe verbessert.

Delegates werden in Spezialisierungen des MulticastDelegate-Templates übersetzt, das normalerweise einen Container von std::function-Instanzen enthält. Ihre Aufrufe, Speicherung und Zuweisung erfolgen trivial. Anonyme Methoden werden in Lambda-Funktionen umgewandelt.

Bei der Erstellung von Lambda-Funktionen ist es notwendig, die Lebensdauer der erfassten Variablen und Argumente zu verlängern, was den Code kompliziert. Daher macht der Übersetzer dies nur dort, wo die Lambda-Funktion eine Chance hat, den umgebenden Kontext zu überleben. Dieses Verhalten (Verlängerung der Lebensdauer von Variablen, Erfassung per Referenz oder Wert) kann auch manuell gesteuert werden, um einen optimaleren Code zu erhalten.

Ausnahmen

Die Emulation des C#-Verhaltens in Bezug auf die Fehlerbehandlung ist recht komplex. Dies liegt daran, dass Ausnahmen in C# und C++ unterschiedlich funktionieren:

  • In C# werden Ausnahmen im Heap erstellt und vom Garbage Collector gelöscht.
  • In C++ werden Ausnahmen zu verschiedenen Zeitpunkten zwischen dem Stapel und einem dedizierten Speicherbereich kopiert.

Dies stellt einen Widerspruch dar. Wenn C#-Ausnahmetypen als Referenztypen übersetzt werden und über Rohzeiger (throw new ArgumentException) darauf zugegriffen wird, würde dies zu Speicherlecks oder erheblichen Problemen bei der Bestimmung ihrer Löschpunkte führen. Wenn sie als Referenztypen übersetzt werden, aber über einen Smart Pointer im Besitz sind (throw SharedPtr<ArgumentException>(MakeObject<ArgumentException>())), kann die Ausnahme nicht von ihrem Basistyp abgefangen werden, da SharedPtr<ArgumentException> nicht von SharedPtr<Exception> erbt. Wenn Ausnahmeobjekte jedoch auf dem Stapel platziert werden, werden sie vom Basistyp korrekt abgefangen, aber beim Speichern in einer Variablen des Basistyps wird die Information über den endgültigen Typ abgeschnitten.

Um dieses Problem zu lösen, haben wir einen speziellen Typ von Smart-Pointer namens ExceptionWrapper erstellt. Sein Hauptmerkmal ist, dass wenn die Klasse ArgumentException von Exception erbt, dann erbt ExceptionWrapper<ArgumentException> auch von ExceptionWrapper<Exception>. Instanzen von ExceptionWrapper werden verwendet, um die Lebensdauer von Ausnahmeklasseninstanzen zu verwalten, und das Kürzen des ExceptionWrapper-Typs führt nicht zum Kürzen des zugehörigen Exception-Typs. Das Werfen von Ausnahmen wird von einer virtuellen Methode behandelt, die von den Nachkommen von Exception überschrieben wird und ein ExceptionWrapper parametrisiert nach dem endgültigen Ausnahmetyp erstellt und es wirft. Die virtuelle Natur ermöglicht das korrekte Werfen des Ausnahmetyps, selbst wenn der ExceptionWrapper-Typ zuvor gekürzt wurde, und die Verknüpfung zwischen dem Ausnahmeobjekt und ExceptionWrapper verhindert Speicherlecks.

Tests

Eine der Stärken unseres Frameworks besteht darin, dass es nicht nur den Quellcode, sondern auch die Modultests dafür übersetzen kann.

C#-Programmierer verwenden die NUnit- und xUnit-Frameworks. Der Übersetzer wandelt die entsprechenden Testbeispiele in GoogleTest um, ersetzt die Syntax der Überprüfungen und ruft Methoden auf, die mit dem Test- oder Fact-Flag aus den entsprechenden Testfunktionen markiert sind. Sowohl tests ohne Argumente als auch Eingabedaten wie TestCase oder TestCaseData werden unterstützt. Ein Beispiel für eine übersetzte Testklasse wird unten angegeben.

[TestFixture]
class MyTestCase
{
    [Test]
    public void Test1()
    {
        Assert.AreEqual(2*2, 4);
    }
    [TestCase("123")]
    [TestCase("abc")]
    public void Test2(string s)
    {
        Assert.NotNull(s);
    }
}
class MyTestCase : public System::Object
{
public:
    void Test1();
    void Test2(System::String s);
};

namespace gtest_test
{

class MyTestCase : public ::testing::Test
{
protected:
    static System::SharedPtr<::ClassLibrary1::MyTestCase> s_instance;
    
public:
    static void SetUpTestCase()
    {
        s_instance = System::MakeObject<::ClassLibrary1::MyTestCase>();
    };
    
    static void TearDownTestCase()
    {
        s_instance = nullptr;
    };
};

System::SharedPtr<::ClassLibrary1::MyTestCase> MyTestCase::s_instance;

} // namespace gtest_test

void MyTestCase::Test1()
{
    ASSERT_EQ(2 * 2, 4);
}

namespace gtest_test
{

TEST_F(MyTestCase, Test1)
{
    s_instance->Test1();
}

} // namespace gtest_test

void MyTestCase::Test2(System::String s)
{
    ASSERT_FALSE(System::TestTools::IsNull(s));
}

namespace gtest_test
{

using MyTestCase_Test2_Args = System::MethodArgumentTuple<decltype(
    &ClassLibrary1::MyTestCase::Test2)>::type;

struct MyTestCase_Test2 : public MyTestCase, public ClassLibrary1::MyTestCase,
    public ::testing::WithParamInterface<MyTestCase_Test2_Args>
{
    static std::vector<ParamType> TestCases()
    {
        return
        {
            std::make_tuple(u"123"),
            std::make_tuple(u"abc"),
        };
    }
};

TEST_P(MyTestCase_Test2, Test)
{
    const auto& params = GetParam();
    ASSERT_NO_FATAL_FAILURE(s_instance->Test2(std::get<0>(params)));
}

INSTANTIATE_TEST_SUITE_P(, MyTestCase_Test2, 
    ::testing::ValuesIn(MyTestCase_Test2::TestCases()));

} // namespace gtest_test

In Verbindung stehende Artikel