Reglas para traducir código de C# a C++: Creación de objetos y llamadas a métodos

A veces, el comportamiento del código escrito en C# y C++ puede diferir. Vamos a examinar más de cerca cómo CodePorting.Translator Cs2Cpp maneja estas diferencias y asegura la corrección de la traducción del código. También aprenderemos cómo se lleva a cabo la conversión de las pruebas unitarias.

Creación e Inicialización de Objetos

Para crear objetos de tipo referencia, utilizamos la función MakeObject (análoga a std::make_shared), que crea un objeto utilizando el operador new y lo envuelve inmediatamente en un SharedPtr. El uso de esta función nos ha permitido evitar problemas introducidos por punteros crudos, pero ha creado un problema de derechos de acceso: dado que está fuera de todas las clases, no tenía acceso a constructores privados. Declarar esta función como amiga de clases con constructores no públicos habría hecho posible crear dichas clases en todos los contextos. Como resultado, la versión externa de esta función se limitó al uso con constructores públicos, y se agregaron métodos estáticos MakeObject para constructores no públicos, teniendo el mismo nivel de acceso y los mismos argumentos que el constructor proxy.

Los programadores de C# a menudo usan inicializadores de propiedades como parte de la expresión de creación de objetos. La sintaxis correspondiente tiene que envolverse en funciones lambda porque de otra manera, no es posible escribir los inicializadores como parte de una sola expresión:

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

Llamadas, Delegados y Métodos Anónimos

Las llamadas a métodos se transfieren tal cual. Al tratar con métodos sobrecargados, a veces es necesario realizar un casting explícito de los tipos de argumentos, ya que las reglas para resolver sobrecargas en C++ difieren de las de C#. Considera, por ejemplo, el siguiente código:

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

Después de la traducción, se ve así:

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

Nota: Las llamadas a los métodos Foo y Bar dentro del método Call se escriben de manera diferente. Esto se debe a que, sin una llamada explícita al constructor de System::String, se llamaría a la sobrecarga de Bar que acepta un bool, ya que dicho casting de tipo tiene una prioridad más alta según las reglas de C++. En el caso del método Foo, no hay tal ambigüedad, y el traductor genera un código más simple.

Otro ejemplo de comportamiento diferente entre C# y C++ es la expansión de plantillas. En C#, la sustitución de parámetros de tipo ocurre en tiempo de ejecución y no afecta la resolución de llamadas dentro de métodos genéricos. En C++, la sustitución de argumentos de plantilla ocurre en tiempo de compilación, por lo que se debe emular el comportamiento de C#. Por ejemplo, considera el siguiente código:

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

Aquí, es importante notar la especificación explícita de argumentos de plantilla al llamar a Foo y Bar. En el primer caso, esto es necesario porque de lo contrario, al instanciar la versión para T=System::String, se llamaría a la versión no plantilla, lo cual difiere del comportamiento de C#. En el segundo caso, se necesita el argumento porque de lo contrario, se deduciría en base al tipo del literal de cadena. Generalmente, el traductor casi siempre tiene que especificar explícitamente los argumentos de plantilla para evitar comportamientos inesperados.

En muchos casos, el traductor tiene que generar llamadas explícitas donde no las hay en C# – esto concierne principalmente a métodos de acceso a propiedades e indexadores. Las llamadas a constructores de tipos de referencia se envuelven en MakeObject, como se muestra arriba.

En .NET, hay métodos que admiten la sobrecarga por el número y tipo de argumentos a través de la sintaxis params, especificando object como el tipo de argumento, o ambos a la vez – por ejemplo, existen tales sobrecargas para StringBuilder.Append() y Console.WriteLine(). La transferencia directa de tales construcciones muestra un rendimiento deficiente debido al boxing y la creación de matrices temporales. En tales casos, agregamos una sobrecarga que acepta un número variable de argumentos de tipos arbitrarios utilizando plantillas variádicas, y traducimos los argumentos tal cual, sin conversiones de tipo y sin fusionar en matrices. Como resultado, se mejora el rendimiento de tales llamadas.

Los delegados se traducen en especializaciones de la plantilla MulticastDelegate, que típicamente contiene un contenedor de instancias de std::function en su interior. Su invocación, almacenamiento y asignación se llevan a cabo de manera trivial. Los métodos anónimos se convierten en funciones lambda.

Al crear funciones lambda, es necesario extender la vida útil de las variables y argumentos capturados, lo que complica el código, por lo que el traductor hace esto solo donde la función lambda tiene la oportunidad de sobrevivir al contexto circundante. Este comportamiento (extender la vida útil de las variables, captura por referencia o por valor) también puede ser controlado manualmente para obtener un código más óptimo.

Excepciones

Emular el comportamiento de C# en términos de manejo de excepciones es bastante no trivial. Esto se debe a que las excepciones en C# y C++ se comportan de manera diferente:

  • En C#, las excepciones se crean en el montón y son eliminadas por el recolector de basura.
  • En C++, las excepciones se copian entre la pila y un área de memoria dedicada en diferentes momentos.

Esto presenta una contradicción. Si los tipos de excepción de C# se traducen como tipos de referencia, trabajando con ellos a través de punteros crudos (throw new ArgumentException), podría llevar a fugas de memoria o problemas significativos con la determinación de sus puntos de eliminación. Si se traducen como tipos de referencia pero son propiedad de un puntero inteligente (throw SharedPtr<ArgumentException>(MakeObject<ArgumentException>())), la excepción no puede ser capturada por su tipo base porque SharedPtr<ArgumentException> no hereda de SharedPtr<Exception>. Sin embargo, si los objetos de excepción se colocan en la pila, serán capturados correctamente por el tipo base, pero cuando se guardan en una variable del tipo base, la información sobre el tipo final se truncará.

Para resolver este problema, creamos un tipo especial de puntero inteligente ExceptionWrapper. Su característica clave es que si la clase ArgumentException hereda de Exception, entonces ExceptionWrapper<ArgumentException> también hereda de ExceptionWrapper<Exception>. Las instancias de ExceptionWrapper se utilizan para gestionar la vida útil de las instancias de clases de excepción, y truncar el tipo ExceptionWrapper no conduce a la truncación del tipo Exception asociado. El lanzamiento de excepciones se maneja mediante un método virtual, sobrescrito por los descendientes de Exception, que crea un ExceptionWrapper parametrizado por el tipo de excepción final y lo lanza. La naturaleza virtual permite lanzar el tipo correcto de excepción, incluso si el tipo ExceptionWrapper se truncó anteriormente, y el vínculo entre el objeto de excepción y ExceptionWrapper evita fugas de memoria.

Pruebas

Una de las fortalezas de nuestro marco es la capacidad de traducir no solo el código fuente sino también las pruebas unitarias para él.

Los programadores de C# utilizan los marcos NUnit y xUnit. El traductor convierte los ejemplos de prueba correspondientes a GoogleTest, reemplazando la sintaxis de las comprobaciones y llamando a métodos marcados con la bandera Test o Fact de las respectivas funciones de prueba. Se admiten tanto pruebas sin argumentos como datos de entrada como TestCase o TestCaseData. A continuación, se proporciona un ejemplo de una clase de prueba traducida.

[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

Artículos relacionados: