Règles de traduction du code de C# à C++ : Création d'objets et appels de méthodes

Parfois, le comportement du code écrit en C# et en C++ peut différer. Examinons de plus près comment CodePorting.Translator Cs2Cpp gère ces différences et assure la correction de la traduction du code. Nous apprendrons également comment la conversion des tests unitaires est réalisée.

Création et Initialisation d'Objets

Pour créer des objets de type référence, nous utilisons la fonction MakeObject (analogue à std::make_shared), qui crée un objet en utilisant l'opérateur new et l'enveloppe immédiatement dans un SharedPtr. L'utilisation de cette fonction nous a permis d'éviter les problèmes introduits par les pointeurs bruts, mais elle a créé un problème de droits d'accès : puisqu'elle est en dehors de toutes les classes, elle n'avait pas accès aux constructeurs privés. Déclarer cette fonction comme amie des classes avec des constructeurs non publics aurait permis de créer de telles classes dans tous les contextes. En conséquence, la version externe de cette fonction a été limitée à l'utilisation avec des constructeurs publics, et des méthodes MakeObject statiques ont été ajoutées pour les constructeurs non publics, ayant le même niveau d'accès et les mêmes arguments que le constructeur proxy.

Les programmeurs C# utilisent souvent des initialiseurs de propriétés dans le cadre de l'expression de création d'objet. La syntaxe correspondante doit être enveloppée dans des fonctions lambda car autrement, il n'est pas possible d'écrire les initialiseurs dans le cadre d'une seule expression :

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;
    }());

Appels, Délégués et Méthodes Anonymes

Les appels de méthodes sont transférés tels quels. Lorsqu'on traite des méthodes surchargées, il est parfois nécessaire de caster explicitement les types d'arguments, car les règles de résolution des surcharges en C++ diffèrent de celles en C#. Considérez, par exemple, le code suivant :

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");
    }
}

Après traduction, cela ressemble à ceci :

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"));
    }
};

Note : Les appels de méthodes à Foo et Bar à l'intérieur de la méthode Call sont rédigés différemment. C'est parce que, sans un appel explicite au constructeur de System::String, la surcharge de Bar qui accepte un bool serait appelée, car ce type de casting a une priorité plus élevée selon les règles du C++. Dans le cas de la méthode Foo, il n'y a pas une telle ambiguïté, et le traducteur génère un code plus simple.

Un autre exemple de comportement différent entre C# et C++ est l'expansion de modèle. En C#, la substitution de paramètres de type se produit en temps d'exécution et n'affecte pas la résolution des appels au sein des méthodes génériques. En C++, la substitution d'arguments de modèle se produit au moment de la compilation, donc le comportement du C# doit être émulé. Par exemple, considérez le code suivant :

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");
}

Ici, il est important de noter la spécification explicite des arguments de modèle lors de l'appel à Foo et Bar. Dans le premier cas, cela est nécessaire car sinon, lors de l'instanciation de la version pour T=System::String, la version non-modèle serait appelée, ce qui diffère du comportement du C#. Dans le second cas, l'argument est nécessaire car sinon, il serait déduit en fonction du type du littéral de chaîne. Généralement, le traducteur doit presque toujours spécifier explicitement les arguments de modèle pour éviter un comportement inattendu.

Dans de nombreux cas, le traducteur doit générer des appels explicites là où il n'y en a pas en C# – cela concerne principalement les méthodes d'accès aux propriétés et aux indexeurs. Les appels aux constructeurs de types de référence sont enveloppés dans MakeObject, comme indiqué ci-dessus.

En .NET, il existe des méthodes qui prennent en charge la surcharge par le nombre et le type d'arguments grâce à la syntaxe params, en spécifiant object comme type d'argument, ou les deux à la fois – par exemple, de telles surcharges existent pour StringBuilder.Append() et Console.WriteLine(). Le transfert direct de telles constructions montre une mauvaise performance en raison du boxing et de la création de tableaux temporaires. Dans de tels cas, nous ajoutons une surcharge qui accepte un nombre variable d'arguments de types arbitraires en utilisant des modèles variadiques, et nous traduisons les arguments tels quels, sans cast de type et sans fusion dans des tableaux. En conséquence, la performance de tels appels est améliorée.

Les délégués sont traduits en spécialisations du modèle MulticastDelegate, qui contient généralement un conteneur d'instances de std::function à l'intérieur. Leur invocation, stockage et affectation sont effectués de manière triviale. Les méthodes anonymes sont transformées en fonctions lambda.

Lors de la création de fonctions lambda, il est nécessaire d'étendre la durée de vie des variables et des arguments capturés, ce qui complique le code, donc le traducteur ne fait cela que là où la fonction lambda a une chance de survivre au contexte environnant. Ce comportement (prolonger la durée de vie des variables, capture par référence ou par valeur) peut également être contrôlé manuellement pour obtenir un code plus optimal.

Exceptions

L'émulation du comportement de C# en termes de gestion des exceptions est assez non triviale. Cela est dû au fait que les exceptions en C# et en C++ se comportent différemment :

  • En C#, les exceptions sont créées sur le tas et sont supprimées par le ramasse-miettes.
  • En C++, les exceptions sont copiées entre la pile et une zone de mémoire dédiée à différents moments.

Cela présente une contradiction. Si les types d'exception de C# sont traduits comme des types de référence, en travaillant avec eux via des pointeurs bruts (throw new ArgumentException), cela pourrait conduire à des fuites de mémoire ou à des problèmes significatifs pour déterminer leurs points de suppression. S'ils sont traduits comme des types de référence mais possédés via un pointeur intelligent (throw SharedPtr<ArgumentException>(MakeObject<ArgumentException>())), l'exception ne peut pas être attrapée par son type de base car SharedPtr<ArgumentException> n'hérite pas de SharedPtr<Exception>. Cependant, si les objets d'exception sont placés sur la pile, ils seront correctement attrapés par le type de base, mais lorsqu'ils sont sauvegardés dans une variable du type de base, les informations sur le type final seront tronquées.

Pour résoudre ce problème, nous avons créé un type spécial de pointeur intelligent ExceptionWrapper. Sa caractéristique clé est que si la classe ArgumentException hérite de Exception, alors ExceptionWrapper<ArgumentException> hérite également de ExceptionWrapper<Exception>. Les instances de ExceptionWrapper sont utilisées pour gérer la durée de vie des instances de classes d'exception, et la troncature du type ExceptionWrapper ne conduit pas à la troncature du type Exception associé. Le lancement des exceptions est géré par une méthode virtuelle, remplacée par les descendants de Exception, qui crée un ExceptionWrapper paramétré par le type d'exception final et le lance. La nature virtuelle permet de lancer le type correct d'exception, même si le type ExceptionWrapper a été tronqué plus tôt, et le lien entre l'objet d'exception et ExceptionWrapper empêche les fuites de mémoire.

Tests

L'une des forces de notre cadre est la capacité de traduire non seulement le code source mais aussi les tests unitaires pour celui-ci.

Les programmeurs C# utilisent les cadres NUnit et xUnit. Le traducteur convertit les exemples de test correspondants en GoogleTest, en remplaçant la syntaxe des vérifications et en appelant des méthodes marquées avec le drapeau Test ou Fact des fonctions de test respectives. Les tests sans arguments et les données d'entrée comme TestCase ou TestCaseData sont pris en charge. Un exemple de classe de test traduite est fourni ci-dessous.

[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

Articles liés :