20 febrero 2025
Al desarrollar un traductor de código de C# a Java, no existen problemas con la eliminación de objetos no utilizados: Java proporciona un mecanismo de recolección de basura lo suficientemente similar al de C#, y el código traducido que utiliza clases simplemente se compila y funciona. C++ es otra historia. Está claro que mapear referencias a punteros crudos no producirá los resultados deseados, ya que este tipo de código traducido no eliminará nada. Mientras tanto, los desarrolladores de C#, acostumbrados a trabajar en un entorno con GC (recolección de basura), continuarán escribiendo código que crea muchos objetos temporales.
Para asegurar la eliminación oportuna de objetos en el código convertido, tuvimos que elegir entre tres opciones:
La tercera opción fue descartada de inmediato: la complejidad de los algoritmos en las bibliotecas portadas resultó prohibitiva. Además, el análisis estático tendría que extenderse al código cliente que utiliza estas bibliotecas convertidas.
La segunda opción también parecía poco práctica: dado que estábamos portando bibliotecas en lugar de aplicaciones, imponer un recolector de basura introduciría restricciones en el código cliente que utiliza dichas bibliotecas. Los experimentos en esta dirección fueron considerados infructuosos.
Por lo tanto, llegamos a la última opción restante: usar punteros inteligentes con conteo de referencias, lo cual es bastante común en C++. Esto, a su vez, significaba que para resolver el problema de las referencias circulares, necesitaríamos utilizar referencias débiles además de las fuertes.
Existen varios tipos bien conocidos de punteros inteligentes:
shared_ptr
podría parecer la opción más obvia, pero tiene una desventaja significativa: almacena el contador de referencias en el montículo, separado del objeto, incluso cuando se usa enable_shared_from_this
. La asignación y liberación de memoria para el contador de referencias es una operación relativamente costosa.intrusive_ptr
es mejor en este aspecto, ya que tener un campo sin usar de 4/8 bytes dentro de la estructura es un mal menor en comparación con la sobrecarga de una asignación adicional para cada objeto temporal.Ahora, consideremos el siguiente código en C#:
class Document
{
private Node node;
public Document()
{
node = new Node(this);
}
public void Prepare(Node n) { ... }
}
class Node
{
private Document document;
public Node(Document d)
{
document = d;
d.Prepare(this);
}
}
Al usar intrusive_ptr
, este código se traduciría en algo como lo siguiente:
class Document : public virtual System::Object
{
intrusive_ptr<Node> node;
public:
Document()
{
node = make_shared_intrusive<Node>(this);
}
void Prepare(intrusive_ptr<Node> n) { ... }
};
class Node : public virtual System::Object
{
intrusive_ptr<Document> document;
public:
Node(intrusive_ptr<Document> d)
{
document = d;
d->Prepare(this);
}
};
Aquí, tres problemas se hacen evidentes de inmediato:
Node::document
sea una referencia débil.this
en un intrusive_ptr
(análogo a shared_from_this
). Si en cambio comenzamos a cambiar las firmas de los métodos (por ejemplo, haciendo que Document::Prepare
acepte Node*
en lugar de intrusive_ptr<Node>
), surgirán problemas al llamar a los mismos métodos con objetos ya construidos o al gestionar las duraciones de los objetos.this
en un intrusive_ptr
durante la construcción del objeto, seguido de decrementar el contador de referencias a cero (como ocurre, por ejemplo, en el constructor de Node
al salir de Document::Prepare
), no debe eliminar inmediatamente el objeto parcialmente construido, que aún no tiene referencias externas.El primer problema se abordó manualmente, ya que incluso una persona a menudo tiene dificultades para determinar cuál de varias referencias debería ser débil. En algunos casos, no hay una respuesta clara, lo que requiere cambios en el código de C#.
Por ejemplo, en un proyecto, había un par de clases: “acción de impresión” y “parámetros de acción de impresión”. El constructor de cada uno creaba el objeto emparejado y establecía referencias bidireccionales. Claramente, convertir una de estas referencias en débil rompería el escenario de uso. Finalmente, decidimos usar el atributo [CppWeakPtr]
, instruyendo al traductor que el campo correspondiente debe contener una referencia débil en lugar de una fuerte.
El segundo problema se resuelve fácilmente si intrusive_ptr
permite la conversión desde un puntero crudo, como lo es this
. La implementación de Boost proporciona esta capacidad.
Finalmente, el tercer problema se resolvió introduciendo una variable local de tipo RAII guard en el constructor. Este guardia incrementa el contador de referencias del objeto actual al crearse y lo decrementa al destruirse. Es importante destacar que decrementar el contador de referencias a cero dentro del guardia no elimina el objeto protegido.
Con estos cambios, el código antes y después de la traducción se ve aproximadamente así:
class Document
{
private Node node;
public Document()
{
node = new Node(this);
}
public void Prepare(Node n) { ... }
}
class Node
{
[CppWeakPtr] private Document document;
public Node(Document d)
{
document = d;
d.Prepare(this);
}
}
class Document : public virtual System::Object
{
intrusive_ptr<Node> node;
public:
Document()
{
System::Details::ThisProtector guard(this);
node = make_shared_intrusive<Node>(this);
}
void Prepare(intrusive_ptr<Node> n) { ... }
};
class Node : public virtual System::Object
{
weak_intrusive_ptr<Document> document;
public:
Node(intrusive_ptr<Document> d)
{
System::Details::ThisProtector guard(this);
document = d;
d->Prepare(this);
}
};
Así, siempre que cualquier implementación de intrusive_ptr
cumpla con nuestros requisitos y esté complementada por una clase weak_intrusive_ptr
emparejada, será suficiente. Esta última debe depender de un contador de referencias ubicado en el montículo fuera del objeto. Dado que crear referencias débiles es una operación relativamente rara en comparación con la creación de objetos temporales, separar el contador de referencias en uno fuerte (dentro del objeto) y otro débil (fuera del objeto) brindó un aumento de rendimiento en código del mundo real.
La situación se complica significativamente porque necesitamos traducir código para clases y métodos genéricos, donde los parámetros de tipo pueden ser tipos de valor o tipos de referencia. Por ejemplo, considere el siguiente código en C#:
class MyContainer<T>
{
public T field;
public void Set(T val)
{
field = val;
}
}
class MyClass {}
struct MyStruct {}
var a = new MyContainer<MyClass>();
var b = new MyContainer<MyStruct>();
Un enfoque de traducción directa produce el siguiente resultado:
template <typename T> class MyContainer : public virtual System::Object
{
public:
T field;
void Set(T val)
{
field = val;
}
};
class MyClass : public virtual System::Object {};
class MyStruct : public System::Object {};
auto a = make_shared_intrusive<MyContainer<MyClass>>();
auto b = make_shared_intrusive<MyContainer<MyStruct>>();
Claramente, este código no se comportará de la misma manera que el original, porque al instanciar MyContainer<MyClass>
, el objeto field
se mueve del montículo al campo MyContainer
, rompiendo la semántica de copia de referencias. Al mismo tiempo, colocar la estructura MyStruct
en el campo es completamente correcto, ya que se alinea con el comportamiento de C#.
Esta situación puede resolverse de dos maneras:
MyContainer<MyClass>
a la semántica de MyContainer<intrusive_ptr<MyClass>>
:auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
template <typename T, bool is_T_reference_type = is_reference_type_v<T>> class MyContainer : public virtual System::Object
{
public:
T field;
void Set(T val)
{
field = val;
}
};
template <typename T> class MyContainer<T, true> : public virtual System::Object
{
public:
intrusive_ptr<T> field;
void Set(intrusive_ptr<T> val)
{
field = val;
}
};
Además de la verbosidad, que crece exponencialmente con cada parámetro de tipo adicional, el segundo enfoque tiene el inconveniente de que cada contexto que use MyContainer<T>
debe saber si T
es un tipo de valor o un tipo de referencia, lo cual a menudo es indeseable. Por ejemplo, cuando queremos minimizar el número de encabezados incluidos u ocultar completamente la información sobre ciertos tipos internos.
Además, la elección del tipo de referencia (fuerte o débil) solo puede hacerse una vez por contenedor. Esto significa que se vuelve imposible tener tanto una List
de referencias fuertes como una List
de referencias débiles, incluso cuando el código de los productos convertidos requiere ambas variantes.
Considerando estos factores, se decidió portar MyContainer<T>
utilizando la semántica de MyContainer<intrusive_ptr<T>>
o MyContainer<weak_intrusive_ptr<T>>
para referencias débiles. Dado que las bibliotecas más populares no proporcionan punteros con las características requeridas, desarrollamos nuestras propias implementaciones, llamadas System::SharedPtr
—una referencia fuerte que utiliza un contador de referencias dentro del objeto—y System::WeakPtr
—una referencia débil que utiliza un contador de referencias externo. La función System::MakeObject
es responsable de crear objetos en el estilo de std::make_shared
.