27 marzo 2025

Portando código C# a C++: La implementación de SmartPtr

Desde el principio, la tarea implicaba portar varios proyectos que contenían hasta varios millones de líneas de código. Esencialmente, la especificación técnica para el traductor se reducía a la frase "asegurar que todo esto se porte y se ejecute correctamente en C++". El trabajo de los responsables de lanzar productos C++ implica traducir el código, ejecutar pruebas, preparar paquetes de lanzamiento, etc. Los problemas encontrados generalmente caen en una de varias categorías:

  1. El código no se traduce a C++ - el traductor termina con un error.
  2. El código se traduce a C++, pero no compila.
  3. El código compila, pero no enlaza.
  4. El código enlaza y se ejecuta, pero las pruebas fallan o ocurren fallos en tiempo de ejecución.
  5. Las pruebas pasan, pero surgen problemas durante su ejecución que no están directamente relacionados con la funcionalidad del producto. Ejemplos incluyen: fugas de memoria, rendimiento deficiente, etc.

La progresión a través de esta lista es de arriba abajo; por ejemplo, sin resolver los problemas de compilación en el código traducido, es imposible verificar su funcionalidad y rendimiento. En consecuencia, muchos problemas de larga data solo se descubrieron durante las etapas posteriores del trabajo en el proyecto CodePorting.Translator Cs2Cpp.

Inicialmente, al corregir fugas de memoria simples causadas por dependencias circulares entre objetos, aplicamos el atributo CppWeakPtr a los campos, lo que resultaba en campos de tipo WeakPtr. Mientras WeakPtr pudiera convertirse a SharedPtr llamando al método lock() o implícitamente (lo cual es sintácticamente más conveniente), esto no causaba problemas. Sin embargo, más tarde también tuvimos que hacer débiles las referencias contenidas dentro de los contenedores, usando una sintaxis especial para el atributo CppWeakPtr, y esto llevó a un par de sorpresas desagradables.

La primera señal de problemas con nuestro enfoque adoptado fue que, desde la perspectiva de C++, MyContainer<SharedPtr<MyClass>> y MyContainer<WeakPtr<MyClass>> son dos tipos diferentes. En consecuencia, no pueden almacenarse en la misma variable, pasarse al mismo método, devolverse desde él, etc. El atributo, originalmente destinado únicamente a gestionar cómo se almacenan las referencias en los campos de los objetos, comenzó a aparecer en contextos cada vez más extraños, afectando a valores de retorno, argumentos, variables locales, etc. El código del traductor responsable de manejarlo se volvía más complejo día a día.

El segundo problema también fue algo que no habíamos anticipado. Para los programadores de C#, resultó natural tener una única colección asociativa por objeto, que contuviera tanto referencias únicas a objetos propiedad del objeto actual e inaccesibles de otra manera, como referencias a objetos padre. Esto se hizo para optimizar las operaciones de lectura de ciertos formatos de archivo, pero para nosotros, significaba que la misma colección podía contener tanto referencias fuertes como débiles. El tipo de puntero dejó de ser el determinante final de su modo de operación.

Tipo de referencia como parte del estado del puntero

Claramente, estos dos problemas no podían resolverse dentro del paradigma existente, y los tipos de puntero fueron reconsiderados. El resultado de este enfoque revisado fue la clase SmartPtr, que presenta un método set_Mode() que acepta uno de dos valores: SmartPtrMode::Shared y SmartPtrMode::Weak. Todos los constructores de SmartPtr aceptan estos mismos valores. En consecuencia, cada instancia de puntero puede existir en uno de dos estados:

  1. Referencia fuerte: el contador de referencias está encapsulado dentro del objeto;
  2. Referencia débil: el contador de referencias es externo al objeto.

El cambio entre modos puede ocurrir en tiempo de ejecución y en cualquier momento. El contador de referencias débiles no se crea hasta que existe al menos una referencia débil al objeto.

La lista completa de características soportadas por nuestro puntero es la siguiente:

  1. Almacenamiento de referencias fuertes: gestión del ciclo de vida del objeto mediante conteo de referencias.
  2. Almacenamiento de referencias débiles para un objeto.
  3. Semántica intrusive_ptr: cualquier número de punteros creados para el mismo objeto compartirá un único contador de referencias.
  4. Desreferenciación y el operador flecha (->): para acceder al objeto apuntado.
  5. Un conjunto completo de constructores y operadores de asignación.
  6. Separación del objeto apuntado y el objeto con conteo de referencias (constructor de alias): dado que las bibliotecas de nuestros clientes trabajan con documentos, a menudo es necesario que un puntero a un elemento del documento mantenga vivo todo el documento.
  7. Un conjunto completo de conversiones (casts).
  8. Un conjunto completo de operaciones de comparación.
  9. Asignación y eliminación de punteros: operan sobre tipos incompletos.
  10. Un conjunto de métodos para verificar y cambiar el estado del puntero: modo de alias, modo de almacenamiento de referencia, conteo de referencias del objeto, etc.

La clase SmartPtr es plantillada (template) y no contiene métodos virtuales. Está estrechamente acoplada con la clase System::Object, que maneja el almacenamiento del contador de referencias, y funciona exclusivamente con sus clases derivadas.

Existen desviaciones del comportamiento típico de los punteros:

  1. El movimiento (constructor de movimiento, operador de asignación de movimiento) no cambia el estado completo; conserva el tipo de referencia (débil/fuerte).
  2. El acceso a un objeto a través de una referencia débil no requiere bloqueo (crear una referencia fuerte temporal), porque un enfoque donde el operador flecha devuelve un objeto temporal degrada severamente el rendimiento para las referencias fuertes.

Para mantener la compatibilidad con el código antiguo, el tipo SharedPtr se convirtió en un alias para SmartPtr. La clase WeakPtr ahora hereda de SmartPtr, sin añadir campos, y simplemente sobrescribe los constructores para crear siempre referencias débiles.

Los contenedores ahora siempre se portan con semántica MyContainer<SmartPtr<MyClass>>, y el tipo de referencias almacenadas se elige en tiempo de ejecución. Para los contenedores escritos manualmente basados en estructuras de datos STL (principalmente contenedores del espacio de nombres System), el tipo de referencia predeterminado se establece mediante un asignador personalizado, permitiendo aún cambiar el modo para elementos individuales del contenedor. Para los contenedores traducidos, el código necesario para cambiar el modo de almacenamiento de referencia es generado por el traductor.

Las desventajas de esta solución incluyen principalmente un rendimiento reducido durante las operaciones de creación, copia y eliminación de punteros, ya que se añade una verificación obligatoria del tipo de referencia al conteo de referencias habitual. Las cifras específicas dependen en gran medida de la estructura de la prueba. Actualmente se está discutiendo la generación de código más óptimo en lugares donde se garantiza que el tipo de puntero no cambiará.

Preparando el código para la traducción

Nuestro método de portación requiere colocar manualmente atributos en el código fuente C# para marcar dónde las referencias deben ser débiles. El código donde estos atributos no están correctamente colocados causará fugas de memoria y, en algunos casos, otros errores después de la traducción. El código con atributos se ve algo así:

struct S {
    MyClass s; // Referencia fuerte al objeto

    [CppWeakPtr]
    MyClass w; // Referencia débil al objeto

    MyContainer<MyClass> s_s; // Referencia fuerte a un contenedor de referencias fuertes

    [CppWeakPtr]
    MyContainer<MyClass> w_s; // Referencia débil a un contenedor de referencias fuertes

    [CppWeakPtr(0)]
    MyContainer<MyClass> s_w; // Referencia fuerte a un contenedor de referencias débiles

    [CppWeakPtr(1)]
    Dictionary<MyClass, MyClass> s_s_w; // Referencia fuerte a un contenedor donde las claves se almacenan por referencias fuertes y los valores por referencias débiles

    [CppWeakPtr, CppWeakPtr(0)]
    Dictionary<MyClass, MyClass> w_w_s; // Referencia débil a un contenedor donde las claves se almacenan por referencias débiles y los valores por referencias fuertes
}

En algunos casos, es necesario llamar manualmente al constructor de alias de la clase SmartPtr o a su método que establece el tipo de referencia almacenada. Intentamos evitar editar el código portado, ya que dichos cambios deben reaplicarse después de cada ejecución del traductor. En su lugar, nuestro objetivo es mantener dicho código dentro del fuente C#. Tenemos dos formas de lograr esto:

  1. Podemos declarar un método de servicio en el código C# que no hace nada, y durante la traducción, reemplazarlo con un equivalente escrito manualmente que realice la operación necesaria:
class Service {
    public static void SetWeak<T>(T arg) {}
}
class Service {
public:
    template <typename T> static void SetWeak(SmartPtr<T> &arg)
    {
        arg.set_Mode(SmartPtrMode::Weak);
    }
};
  1. Podemos colocar comentarios con formato especial en el código C#, que el traductor convierte en código C++:
class MyClass {
    private Dictionary<string, object> data;
    public void Add(string key, object value)
    {
        data.Add(key, value);
        //CPPCODE: if (key == u"Parent") data->data()[key].set_Mode(SmartPtrMode::Weak);
    }
}

Aquí, el método data() en System::Collections::Generic::Dictionary devuelve una referencia al std::unordered_map subyacente de este contenedor.

Noticias relacionadas

Videos relacionados

Artículos relacionados