20 février 2025

Portage du code C# vers C++ : Pointeurs intelligents

Lors du développement d'un traducteur de code de C# vers Java, il n'y a pas de problèmes liés à la suppression des objets inutilisés : Java fournit un mécanisme de garbage collection suffisamment similaire à celui de C#, et le code traduit utilisant des classes se compile et fonctionne simplement. C++ est une autre histoire. Il est clair que le mappage des références vers des pointeurs bruts ne donnera pas les résultats souhaités, car un tel code traduit ne supprimera rien. Par ailleurs, les développeurs C#, habitués à travailler dans un environnement avec GC, continueront à écrire du code qui crée de nombreux objets temporaires.

Pour garantir la suppression opportune des objets dans le code converti, nous avons dû choisir parmi trois options :

  1. Utiliser le comptage de références pour les objets – par exemple, via des pointeurs intelligents ;
  2. Utiliser une implémentation de garbage collector pour C++ – par exemple, Boehm GC ;
  3. Utiliser une analyse statique pour déterminer les points où les objets doivent être supprimés.

La troisième option a été immédiatement écartée : la complexité des algorithmes dans les bibliothèques portées s'est avérée prohibitive. De plus, l'analyse statique devrait s'étendre au code client utilisant ces bibliothèques converties.

La deuxième option est également apparue peu pratique : puisque nous portions des bibliothèques plutôt que des applications, l'imposition d'un garbage collector introduirait des contraintes sur le code client utilisant ces bibliothèques. Les expériences dans cette direction ont été jugées infructueuses.

Ainsi, nous en sommes arrivés à la dernière option restante – l'utilisation de pointeurs intelligents avec comptage de références, ce qui est assez typique pour C++. Cela signifiait, à son tour, que pour résoudre le problème des références circulaires, nous devrions utiliser des références faibles en plus des références fortes.

Types de pointeurs intelligents

Il existe plusieurs types bien connus de pointeurs intelligents :

  • shared_ptr pourrait sembler être le choix le plus évident, mais il présente un inconvénient majeur : il stocke le compteur de références sur le tas, séparé de l'objet, même lors de l'utilisation de enable_shared_from_this. L'allocation et la libération de mémoire pour le compteur de références sont des opérations relativement coûteuses.
  • intrusive_ptr est meilleur à cet égard, car avoir un champ inutilisé de 4/8 octets dans la structure est un moindre mal par rapport à la surcharge d'une allocation supplémentaire pour chaque objet temporaire.

Considérons maintenant le code C# suivant :

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

Lors de l'utilisation de intrusive_ptr, ce code serait traduit en quelque chose comme ceci :

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

Ici, trois problèmes deviennent immédiatement apparents :

  1. Un mécanisme est nécessaire pour rompre la référence circulaire, en faisant de Node::document une référence faible dans ce cas.
  2. Il doit y avoir un moyen de convertir this en un intrusive_ptr (analogue à shared_from_this). Si nous commençons à modifier les signatures des méthodes (par exemple, en faisant accepter Node* à Document::Prepare au lieu de intrusive_ptr<Node>), des problèmes surviendront lors de l'appel des mêmes méthodes avec des objets déjà construits ou lors de la gestion de la durée de vie des objets.
  3. La conversion de this en un intrusive_ptr pendant la construction de l'objet, suivie de la décrémentation du compteur de références à zéro (comme cela se produit, par exemple, dans le constructeur de Node lors de la sortie de Document::Prepare), ne doit pas supprimer immédiatement l'objet partiellement construit, qui n'a pas encore de références externes.

Le premier problème a été résolu manuellement, car même un humain a souvent du mal à déterminer laquelle de plusieurs références devrait être faible. Dans certains cas, il n'y a pas de réponse claire, ce qui nécessite des modifications du code C#.
Par exemple, dans un projet, il y avait une paire de classes : “action d'impression” et “paramètres de l'action d'impression”. Le constructeur de chaque classe créait l'objet jumelé et établissait des références bidirectionnelles. Il est clair que transformer l'une de ces références en une référence faible briserait le scénario d'utilisation. Finalement, nous avons décidé d'utiliser l'attribut [CppWeakPtr], indiquant au traducteur que le champ correspondant devrait contenir une référence faible au lieu d'une référence forte.

Le deuxième problème est facilement résolu si intrusive_ptr permet la conversion à partir d'un pointeur brut, ce que this est. L'implémentation de Boost offre cette capacité.

Enfin, le troisième problème a été résolu en introduisant une variable locale de type RAII guard dans le constructeur. Ce garde incrémente le compteur de références de l'objet actuel lors de sa création et le décrémente lors de sa destruction. Il est important de noter que la décrémentation du compteur de références à zéro dans le garde ne supprime pas l'objet protégé.

Avec ces changements, le code avant et après la traduction ressemble à ceci :

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

Ainsi, tant qu'une implémentation de intrusive_ptr répond à nos exigences et est complétée par une classe jumelée weak_intrusive_ptr, elle suffira. Cette dernière doit s'appuyer sur un compteur de références situé sur le tas en dehors de l'objet. Étant donné que la création de références faibles est une opération relativement rare par rapport à la création d'objets temporaires, la séparation du compteur de références en un compteur fort (à l'intérieur de l'objet) et un compteur faible (à l'extérieur de l'objet) a permis d'améliorer les performances dans du code réel.

Templates

La situation devient considérablement plus complexe car nous devons traduire du code pour des classes et méthodes génériques, où les paramètres de type peuvent être soit des types valeur, soit des types référence. Par exemple, considérons le code C# suivant :

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

Une approche de portage directe donne le résultat suivant :

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

Il est clair que ce code ne se comportera pas de la même manière que l'original, car lors de l'instanciation de MyContainer<MyClass>, l'objet field passe du tas au champ de MyContainer, brisant la sémantique de copie des références. En même temps, la placement de la structure MyStruct dans le champ est tout à fait correcte, car elle correspond au comportement de C#.

Cette situation peut être résolue de deux manières :

  1. Passer de la sémantique de MyContainer<MyClass> à la sémantique de MyContainer<intrusive_ptr<MyClass>> :
auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
  1. Créer deux spécialisations pour chaque classe template : une pour les cas où l'argument de type est un type valeur, et une autre pour les cas où il s'agit d'un type référence :
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;
    }
};

Outre la verbosité, qui croît de manière exponentielle avec chaque paramètre de type supplémentaire, la deuxième approche présente l'inconvénient que chaque contexte utilisant MyContainer<T> doit savoir si T est un type valeur ou un type référence, ce qui est souvent indésirable. Par exemple, lorsque nous voulons minimiser le nombre d'en-têtes inclus ou masquer complètement des informations sur certains types internes.
De plus, le choix du type de référence (forte ou faible) ne peut être fait qu'une seule fois par conteneur. Cela signifie qu'il devient impossible d'avoir à la fois une List de références fortes et une List de références faibles, même si le code des produits convertis nécessite les deux variantes.

Compte tenu de ces facteurs, il a été décidé de porter MyContainer<MyClass> en utilisant la sémantique de MyContainer<System::SharedPtr<MyClass>> ou MyContainer<System::WeakPtr<MyClass>> pour les références faibles. Comme les bibliothèques les plus populaires ne fournissent pas de pointeurs avec les caractéristiques requises, nous avons développé nos propres implémentations, nommées System::SharedPtr – une référence forte utilisant un compteur de références dans l'objet – et System::WeakPtr – une référence faible utilisant un compteur de références externe à l'objet. La fonction System::MakeObject est responsable de la création d'objets dans le style de std::make_shared.

Nouvelles connexes

Vidéos associées

Articles liés