28 diciembre 2024
Nuestro framework, CodePorting.Translator Cs2Cpp, permite la publicación de bibliotecas desarrolladas para la plataforma .NET en C++. En este artículo, discutiremos cómo logramos conciliar los modelos de memoria de estos dos lenguajes y garantizar el correcto funcionamiento del código traducido en un entorno no gestionado.
Aprenderá sobre los smart pointers que utilizamos y por qué tuvimos que desarrollar nuestras propias implementaciones para ellos. Además, cubriremos el proceso de preparación del código C# para su portabilidad desde la perspectiva de la gestión del ciclo de vida de los objetos, algunos de los desafíos que encontramos y los métodos de diagnóstico específicos que debemos utilizar en nuestro trabajo.
El código C# se ejecuta en un entorno gestionado con recolección de basura. Para los propósitos de la traducción, esto significa principalmente que un programador de C#, a diferencia de un desarrollador de C++, está liberado de la necesidad de devolver al sistema la memoria del heap asignada que ya no se utiliza. Esto lo realiza el recolector de basura (GC), un componente del entorno CLR que verifica periódicamente qué objetos siguen utilizándose en el programa y limpia aquellos que ya no tienen referencias activas.
Se considera que una referencia es activa si está:
El algoritmo del recolector de basura se describe generalmente como el recorrido del grafo de objetos, comenzando desde las referencias ubicadas en el stack y en los campos estáticos, y luego eliminando todo lo que no se alcanzó en la etapa anterior. Para proteger contra la invalidación del grafo de referencias durante la operación del GC, se implementa un mecanismo de stop-the-world. Esta descripción puede estar simplificada, pero es suficiente para nuestros propósitos.
A diferencia de los smart pointers, el enfoque de recolección de basura está libre del problema de las referencias cruzadas o cíclicas. Si dos objetos se refieren entre sí, posiblemente a través de varios objetos intermedios, esto no impide que el GC los elimine cuando todo el grupo, conocido como isla de aislamiento, ya no tiene referencias activas. Por lo tanto, los programadores de C# no tienen prejuicios contra la vinculación de objetos en cualquier momento y en cualquier combinación.
Los tipos de datos en C# se dividen en tipos por referencia y por valor. Las instancias de tipos por valor siempre se ubican en el stack, en la memoria estática o directamente en los campos de estructuras y objetos. En contraste, las instancias de tipos por referencia siempre se crean en el heap, mientras que en el stack, en la memoria estática y en los campos solo se almacenan sus referencias (direcciones). Los tipos por valor incluyen estructuras, valores aritméticos primitivos y referencias. Los tipos por referencia incluyen clases y, históricamente, delegados. Las únicas excepciones a esta regla son los casos de boxing, donde una estructura u otro tipo por valor se copia en el heap para su uso en un contexto específico.
El momento de la destrucción del objeto no está definido; el lenguaje solo garantiza que no ocurrirá antes de que se elimine la última referencia activa al objeto. Si en el código convertido el objeto se destruye inmediatamente después de eliminar la última referencia activa, esto no interrumpirá el funcionamiento del programa.
Otro punto importante está relacionado con el soporte de tipos y métodos genéricos en C#. C# permite escribir genéricos una vez y luego usarlos con parámetros tanto de tipo por referencia como por valor. Como se mostrará más adelante, este punto resulta ser significativo.
Algunas palabras sobre cómo mapeamos los tipos de C# a C++. Dado que el framework CodePorting.Translator Cs2Cpp está diseñado para portar bibliotecas y no aplicaciones, un requisito importante para nosotros es reproducir la API del proyecto original de .NET con la mayor precisión posible. Por lo tanto, convertimos las clases, interfaces y estructuras de C# en clases de C++ que heredan los tipos base correspondientes.
Por ejemplo, considere el siguiente código:
interface I1 {}
interface I2 {}
interface I3 : I2 {}
class A {}
class B : A, I1 {}
class C : B, I2 {}
class D : C, I3 {}
class Generic<T> { public T value; }
struct S {}
Se traducirá de la siguiente manera:
class I1 : public virtual System::Object {};
class I2 : public virtual System::Object {};
class I3 : public virtual I2 {};
class A : public virtual System::Object {};
class B : public A, public virtual I1 {};
class C : public B, public virtual I2 {};
class D : public C, public virtual I3 {};
template <typename T> class Generic { public: T value; };
class S : public System::Object {};
La clase System::Object
es una clase del sistema declarada en la biblioteca de soporte del traductor, externa al proyecto convertido. Las clases e interfaces heredan de ella virtualmente para evitar el problema del diamante. Las estructuras en el código C++ traducido heredan de System::Object
, mientras que en C# heredan de él a través de System.ValueType
, pero esta herencia adicional se elimina para fines de optimización. Los tipos y métodos genéricos se traducen en clases y métodos plantilla, respectivamente.
Las instancias de los tipos traducidos deben adherirse a las mismas garantías proporcionadas por C#. Las instancias de clases deben crearse en el heap, y su tiempo de vida debe estar determinado por el tiempo de vida de las referencias activas a ellas. Las instancias de estructuras deben crearse en el stack, excepto en los casos de boxing. Los delegados son un caso especial y relativamente trivial que queda fuera del alcance de este artículo.
Hemos considerado cómo el modelo de gestión de memoria en C# afecta el proceso de conversión de código a C++. En la siguiente parte del artículo, discutiremos cómo se tomó la decisión sobre el método de gestión del tiempo de vida de los objetos asignados en el heap y cómo se implementó este enfoque.