16 avril 2025
Une fois le code traduit et compilé avec succès, nous rencontrons souvent des problèmes à l'exécution, notamment liés à la gestion de la mémoire, qui ne sont pas typiques de l'environnement C# avec son ramasse-miettes. Dans cet article, nous allons examiner des problèmes spécifiques de gestion de la mémoire, tels que les références circulaires et la suppression prématurée d'objets, et montrer comment notre approche aide à les détecter et à les résoudre.
En C#, le ramasse-miettes peut gérer correctement les références circulaires en détectant et en supprimant les groupes d'objets inaccessibles. Cependant, en C++, les pointeurs intelligents utilisent le comptage de références. Si deux objets se référencent mutuellement avec des références fortes (SharedPtr
), leurs compteurs de références n'atteindront jamais zéro, même s'il n'y a plus de références externes vers eux depuis le reste du programme. Cela conduit à une fuite de mémoire, car les ressources occupées par ces objets ne sont jamais libérées.
Considérons un exemple typique :
class Document {
private Element root;
public Document()
{
root = new Element(this); // Document référence Element
}
}
class Element {
private Document owner;
public Element(Document doc)
{
owner = doc; // Element référence en retour Document
}
}
Ce code est converti en ce qui suit :
class Document : public Object {
SharedPtr<Element> root;
public:
Document()
{
root = MakeObject<Element>(this);
}
}
class Element {
SharedPtr<Document> owner; // Référence forte
public:
Element(SharedPtr<Document> doc)
{
owner = doc;
}
}
Ici, l'objet Document
contient un SharedPtr
vers Element
, et l'objet Element
contient un SharedPtr
vers Document
. Un cycle de références fortes est créé. Même si la variable qui détenait initialement le pointeur vers Document
sort de portée, les compteurs de références pour les deux objets resteront à 1 en raison des références mutuelles. Les objets ne seront jamais supprimés.
Ce problème est résolu en définissant l'attribut CppWeakPtr
sur l'un des champs impliqués dans le cycle, par exemple, sur le champ Element.owner
. Cet attribut demande au traducteur d'utiliser une référence faible WeakPtr
pour ce champ, ce qui n'incrémente pas le compteur de références fortes.
class Document {
private Element root;
public Document()
{
root = new Element(this);
}
}
class Element {
[CppWeakPtr] private Document owner;
public Element(Document doc)
{
owner = doc;
}
}
Le code C++ résultant :
class Document : public Object {
SharedPtr<Element> root; // Référence forte
public:
Document()
{
root = MakeObject<Element>(this);
}
}
class Element {
WeakPtr<Document> owner; // Maintenant, c'est une référence faible
public:
Element(SharedPtr<Document> doc)
{
owner = doc;
}
}
Maintenant, Element
détient une référence faible vers Document
, brisant le cycle. Lorsque le dernier SharedPtr<Document>
externe disparaît, l'objet Document
est supprimé. Cela déclenche la suppression du champ root
(SharedPtr<Element>
), ce qui décrémente le compteur de références de Element
. S'il n'y avait pas d'autres références fortes vers Element
, il est également supprimé.
Ce problème survient si un objet est passé via SharedPtr
à un autre objet ou méthode pendant sa construction, avant qu'une référence forte “permanente” ne soit établie vers lui. Dans ce cas, le SharedPtr
temporaire créé lors de l'appel du constructeur pourrait être la seule référence. S'il est détruit après la fin de l'appel, le compteur de références atteint zéro, entraînant un appel immédiat au destructeur et la suppression de l'objet pas encore entièrement construit.
Considérons un exemple :
class Document {
private Element root;
public Document()
{
root = new Element(this);
}
public void Prepare(Element elm)
{
...
}
}
class Element {
public Element(Document doc)
{
doc.Prepare(this);
}
}
Le traducteur produit ce qui suit :
class Document : public Object {
SharedPtr<Element> root;
public:
Document()
{
ThisProtector guard(this); // Protection contre la suppression prématurée
root = MakeObject<Element>(this);
}
void Prepare(SharedPtr<Element> elm)
{
...
}
}
class Element {
public:
Element(SharedPtr<Document> doc)
{
ThisProtector guard(this); // Protection contre la suppression prématurée
doc->Prepare(this);
}
}
En entrant dans la méthode Document::Prepare
, un objet SharedPtr
temporaire est créé, qui pourrait alors supprimer l'objet Element
incomplètement construit car aucune référence forte ne subsiste vers lui. Comme montré dans l'article précédent, ce problème est résolu en ajoutant une variable locale ThisProtector guard
au code du constructeur de Element
. Le traducteur le fait automatiquement. Le constructeur de l'objet guard
incrémente le compteur de références fortes pour this
de un, et son destructeur le décrémente à nouveau, sans provoquer la suppression de l'objet.
Considérons une situation où le constructeur d'un objet lève une exception après que certains de ses champs ont déjà été créés et initialisés, lesquels pourraient à leur tour contenir des références fortes vers l'objet en cours de construction.
class Document {
private Element root;
public Document()
{
root = new Element(this);
throw new Exception("Failed to construct Document object");
}
}
class Element {
private Document owner;
public Element(Document doc)
{
owner = doc;
}
}
Après conversion, nous obtenons :
class Document : public Object {
SharedPtr<Element> root;
public:
Document()
{
ThisProtector guard(this);
root = MakeObject<Element>(this);
throw Exception(u"Failed to construct Document object");
}
}
class Element {
SharedPtr<Document> owner;
public:
Element(SharedPtr<Document> doc)
{
ThisProtector guard(this);
owner = doc;
}
}
Après que l'exception est levée dans le constructeur du document et que l'exécution quitte le code du constructeur, le déroulement de la pile commence, incluant la suppression des champs de l'objet Document
incomplètement construit. Ceci, à son tour, mène à la suppression du champ Element::owner
, qui contient une référence forte vers l'objet en cours de suppression. Cela résulte en la suppression d'un objet qui est déjà en cours de déconstruction, conduisant à diverses erreurs d'exécution.
Définir l'attribut CppWeakPtr
sur le champ Element.owner
résout ce problème. Cependant, tant que les attributs ne sont pas placés, le débogage de telles applications est difficile en raison d'arrêts imprévisibles. Pour simplifier le dépannage, il existe un mode de compilation de débogage spécial où le compteur de références interne de l'objet est déplacé vers le tas et complété par un indicateur. Cet indicateur n'est défini qu'une fois l'objet entièrement construit – au niveau de la fonction MakeObject
, après la sortie du constructeur. Si le pointeur est détruit avant que l'indicateur ne soit défini, l'objet n'est pas supprimé.
class Node {
public Node next;
}
class Node : public Object {
public:
SharedPtr<Node> next;
}
La suppression de chaînes d'objets se fait récursivement, ce qui peut entraîner un débordement de pile si la chaîne est longue – plusieurs milliers d'objets ou plus. Ce problème est résolu en ajoutant un finaliseur, traduit en destructeur, qui supprime la chaîne par itération.
Corriger le problème des références circulaires est simple – ajouter un attribut au code C#. La mauvaise nouvelle est que le développeur responsable de la publication du produit pour C++ ne sait généralement pas quelle référence spécifique devrait être faible, ni même qu'un cycle existe.
Pour faciliter la recherche de cycles, nous avons développé un ensemble d'outils qui fonctionnent de manière similaire. Ils reposent sur deux mécanismes internes : un registre global des objets et l'extraction d'informations sur les champs de référence d'un objet.
Le registre global contient une liste des objets existant actuellement. Le constructeur de la classe System::Object
place une référence à l'objet courant dans ce registre, et le destructeur l'en retire. Naturellement, le registre n'existe que dans un mode de compilation de débogage spécial, afin de ne pas affecter les performances du code converti en mode release.
Les informations sur les champs de référence d'un objet peuvent être extraites en appelant la fonction virtuelle GetSharedMembers()
, déclarée au niveau de System::Object
. Cette fonction retourne une liste complète des pointeurs intelligents détenus dans les champs de l'objet et leurs objets cibles. Dans le code de la bibliothèque, cette fonction est écrite manuellement, tandis que dans le code généré, elle est intégrée par le traducteur.
Il existe plusieurs façons de traiter les informations fournies par ces mécanismes. Basculer entre elles se fait en utilisant les options de traducteur appropriées et/ou des constantes de préprocesseur.
SharedPtr
parcourt les références entre les objets, en commençant par l'objet dont il gère la durée de vie, et affiche des informations sur tous les cycles détectés – tous les cas où l'objet de départ peut être atteint à nouveau en suivant les références fortes.Un autre outil de débogage utile est la vérification qu'après l'appel du constructeur d'un objet par la fonction MakeObject
, le compteur de références fortes pour cet objet est égal à zéro. Si ce n'est pas le cas, cela indique un problème potentiel – un cycle de références, un comportement indéfini si une exception est levée, etc.
Malgré l'inadéquation fondamentale entre les systèmes de types C# et C++, nous avons réussi à construire un système de pointeurs intelligents qui permet au code converti de s'exécuter avec un comportement proche de l'original. En même temps, la tâche n'a pas été résolue de manière entièrement automatique. Nous avons créé des outils qui simplifient considérablement la recherche de problèmes potentiels.