Reglas para traducir código de C# a C++: Miembros de clase y estructuras de control

En este artículo, exploraremos cómo nuestro traductor convierte miembros de clase, variables, campos, operadores y estructuras de control de C#. También abordaremos el uso de la biblioteca de soporte del traductor para la conversión correcta de tipos del .NET Framework a C++.

Miembros de la clase

Los métodos de clase se mapean directamente en C++. Esto también se aplica a los métodos estáticos y constructores. En algunos casos, puede aparecer código adicional – por ejemplo, para emular llamadas a constructores estáticos. Los métodos de extensión y operadores se traducen en métodos estáticos y se llaman explícitamente. Los finalizadores se convierten en destructores.

Los campos de instancia de C# se convierten en campos de instancia de C++. Los campos estáticos también permanecen sin cambios, excepto en casos donde el orden de inicialización es importante – esto se implementa traduciendo dichos campos como singletons.

Las propiedades se dividen en un método getter y un método setter, o solo uno si falta el segundo método. Para las auto-propiedades, también se agrega un campo de valor privado. Las propiedades estáticas se dividen en un getter estático y un setter. Los indexadores se procesan utilizando la misma lógica.

Los eventos se traducen en campos, cuyo tipo corresponde a la especialización requerida de System::Event. La traducción en forma de tres métodos (add, remove e invoke) sería más correcta y, además, permitiría soportar eventos abstractos y virtuales. Posiblemente, en el futuro, llegaremos a tal modelo, pero en este momento la opción de la clase Event cubre completamente nuestras necesidades.

El siguiente ejemplo ilustra las reglas anteriores:

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

Resultado de la traducción de C++ (código insignificante eliminado):

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

Variables y campos

Los campos constantes y estáticos se traducen en campos estáticos, constantes estáticas (en algunos casos – constexpr), o en métodos estáticos que proporcionan acceso a un singleton. Los campos de instancia de C# se convierten en campos de instancia de C++. Cualquier inicializador complejo se traslada a los constructores, y a veces es necesario agregar explícitamente constructores predeterminados donde no existían en C#. Las variables de pila se pasan tal cual. Los argumentos del método también se pasan tal cual, excepto que tanto los argumentos ref como out se convierten en referencias (afortunadamente, la sobrecarga en ellos está prohibida).

Los tipos de campos y variables se reemplazan por sus equivalentes en C++. En la mayoría de los casos, dichos equivalentes son generados por el propio traductor a partir del código fuente de C#. Los tipos de bibliotecas, incluidos los tipos de .NET Framework y algunos otros, están escritos por nosotros en C++ y forman parte de la biblioteca de soporte del traductor, que se suministra junto con los productos convertidos. var se traduce en auto, excepto en casos donde se necesita una indicación de tipo explícita para suavizar las diferencias en el comportamiento.

Además, los tipos de referencia se envuelven en SmartPtr. Los tipos de valor se sustituyen tal cual. Dado que los argumentos de tipo pueden ser tipos de valor o de referencia, también se sustituyen tal cual, pero cuando se instancian, los argumentos de referencia se envuelven en SharedPtr. Así, List<int> se traduce como List<int32_t>, pero List<Object> se convierte en List<SmartPtr<Object>>. En algunos casos excepcionales, los tipos de referencia se traducen como tipos de valor. Por ejemplo, nuestra implementación de System::String se basa en el tipo UnicodeString de ICU y está optimizada para el almacenamiento en pila.

Para ilustrar, traduzcamos la siguiente clase:

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

Después de la traducción, toma la siguiente forma (se elimina el código insignificante):

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

Estructuras de control

La similitud de las principales estructuras de control jugó a nuestro favor. Operadores como if, else, switch, while, do-while, for, try-catch, return, break y continue se transfieren mayormente tal cual. La excepción en esta lista es quizás solo el switch, que requiere un par de tratamientos especiales. En primer lugar, C# permite su uso con el tipo de cadena – en C++ generamos una secuencia de if-else if en este caso. En segundo lugar, la adición relativamente reciente de la capacidad de hacer coincidir la expresión comprobada con una plantilla de tipo – que, sin embargo, también se despliega fácilmente en una secuencia de if.

Las construcciones que no están presentes en C++ son de interés. Así, el operador using garantiza la llamada del método Dispose() al salir del contexto. En C++, emulamos este comportamiento creando un objeto guardián en la pila, que llama al método requerido en su destructor. Sin embargo, antes de eso, es necesario capturar la excepción que lanzó el código que era el cuerpo de using, y almacenar el exception_ptr en el campo del guardián – si Dispose() no lanza su excepción, la que almacenamos será relanzada. Este es justo ese caso raro cuando el lanzamiento de una excepción desde un destructor está justificado y no es un error. El bloque finally se traduce según un esquema similar, solo que en lugar del método Dispose(), se llama a una función lambda, en la que el traductor envolvió su cuerpo.

Otro operador que no está presente en C# y que nos vemos obligados a emular es foreach. Inicialmente, lo tradujimos en un while equivalente, llamando al método MoveNext() del enumerador, que es universal pero bastante lento. Dado que la mayoría de las implementaciones en C++ de contenedores .NET utilizan estructuras de datos STL, hemos llegado a usar sus iteradores originales donde sea posible, convirtiendo foreach en for basado en rango. En casos donde los iteradores originales no están disponibles (por ejemplo, el contenedor está implementado en C# puro), se utilizan iteradores envoltorios, que trabajan internamente con enumeradores. Anteriormente, la elección del método de iteración correcto era responsabilidad de una función externa, escrita usando la técnica SFINAE, ahora estamos cerca de tener las versiones correctas de los métodos begin-end en todos los contenedores, incluidos los traducidos.

Operadores

Al igual que con las estructuras de control, la mayoría de los operadores (al menos aritméticos, lógicos y de asignación) no requieren un procesamiento especial. Sin embargo, hay un punto sutil: en C#, el orden de evaluación de las partes de una expresión es determinista, mientras que en C++ puede haber un comportamiento indefinido en algunos casos. Por ejemplo, el siguiente código traducido se comporta de manera diferente después de la compilación por diferentes herramientas:

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

Afortunadamente, tales problemas son bastante raros. Tenemos planes de enseñar al traductor a lidiar con tales momentos, pero debido a la complejidad del análisis que identifica expresiones con efectos secundarios, esto aún no se ha implementado.

Sin embargo, incluso los operadores más simples requieren un procesamiento especial cuando se aplican a propiedades. Como se mostró anteriormente, las propiedades se dividen en métodos getter y setter, y el traductor tiene que insertar las llamadas necesarias dependiendo del contexto:

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

En la primera línea, el reemplazo resultó ser trivial. En la segunda, fue necesario usar el envoltorio setter_add_wrap, asegurando que la función GetObj() se llame solo una vez, y el resultado de concatenar la llamada a get_Property() y el literal de cadena se pasa no solo al método set_Property() (que devuelve void), sino también más adelante para su uso en la expresión. El mismo enfoque se aplica al acceder a indexadores.

Los operadores de C# que no están en C++: as, is, typeof, default, ??, ?., y así sucesivamente, se emulan utilizando funciones de la biblioteca de soporte del traductor. En casos donde es necesario evitar la doble evaluación de argumentos, por ejemplo, para no desplegar GetObj()?.Invoke() en GetObj() ? GetObj().Invoke() : nullptr, se utiliza un enfoque similar al mostrado anteriormente.

El operador de acceso a miembros (.) puede ser reemplazado por un equivalente de C++ dependiendo del contexto: el operador de resolución de ámbito (::) o la “flecha” (->). Tal reemplazo no es requerido al acceder a miembros de estructuras.

Artículos relacionados: