Loupe

De C# à C++ : Gestion automatique de la mémoire

En c#, quand on alloue dynamiquement un objet (new myClass()), l’objet retourné n’est pas directement une adresse mémoire, mais un handle vers cet adresse. Cet handle est géré par le garbage collector qui est donc capable de reconnaître quand un objet n’est plus référencé nulle-part, et qui peut donc décider de le désallouer.

En C++, il n’y a pas de Garbage Collector. Lorsque l’on alloue dynamiquement un objet, la valeur retournée est une adresse mémoire, et le runtime n’en sait pas plus que cela. Résultat, il n’y a personne pour rammasser vos poubelles pour vous : il faut donc décider soi-même de libérer la mémoire en appelant l’operateur “delete”.

Enfin ca, c’est la théorie, car le principe du Garbage Collector n’est pas le seul système de gestion automatique de la mémoire, il y’a des solutions beaucoup moins coûteuses en terme de performance (et de mémoire supplémentaire consommée) qui demandent à peine plus réflexion, et qui offrent beaucoup plus de flexibilité. Depuis C++11, 3 templates très utiles ont fait leur apparition dans la librairies standards et permettent une gestion automatique de la mémoire : std::unique_ptr<>, std::shared_ptr<> et std::weak_ptr<> (Ces templates sont inspirés des smart pointers inclus dans la librairies Boost).

Qu’est-ce qu’un Smart Pointer ?

Un Smart Pointer est un objet encapsulant un pointeur vers un objet (ou tableau d’objets) alloué dynamiquement et prenant la responsabilité de détruire l’objet au “bon moment”. Ce “bon moment” dépendant du choix du type de Smart Pointer fait par le développeur. Ces smart pointers surchargent généralement beaucoup d’opérateurs (opérateur d’assignation, d’égalité, de déréférence,…) afin d’être manipulable comme des pointeurs “classiques”, et peuvent être construits de différentes manières (par copie, par movement, à partir d’un pointeur vers l’objet à encapsuler…).

Les 2 principaux types de Smart Pointers sont:

  • Ceux adaptés au principe de “unique ownership” (le temps de vie d’un objet alloué dynamiquement est lié au temps de vie d’un unique autre objet le “possédant”)
  • Ceux adaptés au principe de “shared ownership” (le temps de vie d’un objet alloué dynamiquement est lié au temps de vie de plusieurs autres objets le “possédant”. Le dernier “possesseur” détruit provoque la destruction de l’objet possédé).

Unique ownership et unique_ptr<>

Le principe de Unique Ownership est très simple : un seul “owner” a pour mission de définir le temps de vie de l’objet nouvellement créé. Si cet “owner” est détruit (par une sortie de scope, ou parce qu’il a été explicitement détruit via l’opérateur delete), l’objet est détruit avec. Le template std::unique_ptr<T> a exactement ce comportement : on le construit en lui passant un pointeur vers l’objet dynamiquement alloué. Lorsque le unique_ptr est détruit, l’opérateur delete est automatiquement appelé :

#include "stdafx.h"
#include <memory> // contient la déclaration de unique_ptr et shared_ptr
#include <iostream>

class MyClass{
private:
public:
    void DoSomething(){
        std::cout<<"Do something\n";
    }
    MyClass(){
        std::cout << "Constructor called\n";
    }
    ~MyClass(){
        std::cout << "Destructor called\n";
    }
};

int _tmain(int argc, _TCHAR* argv[])
{
    {
        std::unique_ptr<MyClass> pMyClass(new MyClass());
        pMyClass->DoSomething();
    } // le unique_ptr sort du scope, il est donc détruit et appelle delete sur l'instance de MyClass
    return 0;
}

Ce programme produit la sortie suivante:

Constructor called
Do something
Destructor called

Notez que bien que l’objet ait été instancié dynamiquement, aucun delete “manuel” n’a été nécessaire.

unique_ptr peut bien sûr être utilisé comme variable membre d’une classe. Son destructeur sera automatiquement appelé par le destructeur de la classe qui le possède, et on peut donc facilement créer des graphes d’objets complexes alloués dynamiquement, qui se détruisent quand l’objet “racine” du graphe est détruit (et ce sans garbage collection).

Le unique_ptr possède des caractéristiques assez intéressantes:

  • il surcharge l’opérateur “->” afin de se manipuler comme un pointeur classique
  • il possède une méthode “get()” permettant de récupérer le pointeur sous-jacent afin de le passer à une fonction prenant un T* par exemple
  • il ne peut pas être copié
  • il peut être construit par “déplacement”

Les 2 premiers points sont assez simple à comprendre, mais je vais tenter d’expliquer le pourquoi des 2 suivants. Un unique_ptr<T> ne peut pas être copié car son utilisation présume que l’on veut appliquer le principe de “unique ownership”. Hors si l’on pouvait copier un unique_ptr vers un autre, on se retrouverait avec 2 unique_ptr possédant le même pointeur! Cela va a l’encontre de ce que l’on veut réaliser, et en plus, cela pose un problème technique : l’opérateur delete serait appelé 2 fois (une fois pour chaque destruction de unique_ptr).

Cependant, unique_ptr<> peut être construit par “déplacement”. Ce nouveau type de construction (apparu avec C++11) permet a un unique_ptr de “voler” l’ownership d’un autre unique_ptr. Par exemple le code suivant:

std::unique_ptr<MyClass> pMyClass(new MyClass());
MyClass* rawPtr = pMyClass.get();
std::cout<<"pMyClass pointer address : " << rawPtr << "\n";
std::unique_ptr<MyClass> pMyMoved(std::move(pMyClass));
rawPtr = pMyClass.get();
std::cout<<"pMyClass pointer address : " << rawPtr<< "\n";
rawPtr = pMyMoved.get();
std::cout<<"pMyMoved pointer address : " << rawPtr<< "\n";

produit le résultat:

Constructor called
pMyClass pointer address : 0060C058
pMyClass pointer address : 00000000
pMyMoved pointer address : 0060C058
Destructor called

On voit clairement qu’après la construction par déplacement, pMyClass n’a plus la “possession” du pointeur, il a été réinitialisé à null, alors que pMyMoved a “volé” cette possession. Cela ouvre la possibilité de créer des factory renvoyant un unique_ptr<> que l’on va pouvoir déplacer facilement vers une variable membre ou locale.

Shared ownership et shared_ptr<>

Les smart pointers de la librairie standard permettent également d’appliquer le principe de “Shared ownership” grâce au template shared_ptr<T>. Ainsi un shared_ptr est construit à partir d’un pointeur vers un objet dynamiquement alloué et peut-être copié autant de fois que l’on veut. Quand la dernière copie vivante est détruite, l’objet “possédé” est détruit:

class Agent{
private:
    std::shared_ptr<MyClass> _pMyClass;
public:
    Agent() :_pMyClass(new MyClass)
    {}
    Agent(const std::shared_ptr<MyClass>& existingPMyClass) : _pMyClass(existingPMyClass){

    }

    std::shared_ptr<MyClass> getPMyClass(){
        return _pMyClass;
    }

};

int _tmain(int argc, _TCHAR* argv[])
{
    std::unique_ptr<Agent> agent1(new Agent()); // le constructeur par défaut crée un shared_ptr -> 1 shared_ptr vivant
    std::unique_ptr<Agent> agent2(new Agent(agent1->getPMyClass())); // copie du shared_ptr -> 2 shared_ptr vivant
    agent1.reset(nullptr); // le shared_ptr d'agent1 est détruit avec agent1 -> 1 shared_ptr vivant
    agent2->getPMyClass()->DoSomething(); 
    agent2.reset(nullptr); // le shared_ptr d'agent2 est détruit avec agent2 -> 0 shared_ptr vivant, l'instance de MyClass est détruite
    return 0;
}

shared_ptr<T> est donc un template très puissant permettant de s’assurer qu’un objet est toujours vivant tant qu’au moins un autre objet le référence.

shared_ptr<T> fonctionne par comptage de référence. Ce compteur est incrémenté à chaque copie, et décrémenté à chaque destruction. Quand le compteur tombe à 0, l’objet possédé est détruit. De prime abord, cela ressemble à ce que l’on obtient avec un Garbage Collector, à une différence importante prêt : la détection des “cycles de références orphelins”.

shared_ptr<T> et les cycles de références orphelins

Tout d’abord, qu’est-ce qu’un cycle de référence orphelin ? Pour cela on va partir d’un exemple très simple, qui provoque un leak à cause d’un cycle de référence orphelin :

class TreeNode{
private:
    std::shared_ptr<TreeNode> _parent;
    std::vector<std::shared_ptr<TreeNode>> _children;
public:
    TreeNode() : _parent(nullptr){
        std::cout << "constructed root\n";
    }
    TreeNode(const std::shared_ptr<TreeNode>& parent) : _parent(parent){
        std::cout << "constructed child\n";
    }
    ~TreeNode(){
        std::cout<<"destructed node\n";
    }
    void AddChild(const std::shared_ptr<TreeNode>& child){
        _children.push_back(child);
    }
};

int _tmain(int argc, _TCHAR* argv[])
{
    std::shared_ptr<TreeNode> root(new TreeNode());
    root->AddChild(std::shared_ptr<TreeNode>(new TreeNode(root)));
    root->AddChild(std::shared_ptr<TreeNode>(new TreeNode(root)));

    root = nullptr;

    return 0;
}

Ce programme produit le résultat suivant:

constructed root
constructed child
constructed child

Aucun destructeur n’a été appelé. La raison est que même si on détruit la variable “root” dans la fonction main, comme les shared_ptr “parent” de ses enfants continuent de pointer vers lui, le compteur ne tombe jamais à 0 (et du coup, il continue de pointer vers ses enfants qui eux même ne sont pas détruits). C’est ce que j’appelle un cycle de références orphelins : ce cycle est constitué d’objet “s’inter-possédant”, mais aucun d’entre eux n’est référencé en dehors du cycle. C’est très mal !

Pour briser un tel cycle, la librairie standard fournit un template std::weak_ptr<T> qui permet de garder la trace d’un shared_ptr<T> sans incrémenter son compteur. La méthode “lock” de weak_ptr<T> permet d’obtenir une copie du shared_ptr<T> qui sera nulle si l’objet a été détruit entre temps, ou qui augmentera temporairement le compteur de référence le temps de son utilisation.

Ainsi le code suivant permet de briser le cycle :

class TreeNode{
private:
    std::weak_ptr<TreeNode> _parent; // les enfants n'augmentent pas en permanence le nombre de référence à leur parent
    std::vector<std::shared_ptr<TreeNode>> _children;
public:
    TreeNode() {
        _parent = std::shared_ptr<TreeNode>(nullptr);
        std::cout << "constructed root\n";
    }
    TreeNode(const std::shared_ptr<TreeNode>& parent) {
        _parent = parent;
        std::cout << "constructed child\n";
    }
    ~TreeNode(){
        std::cout<<"destructed node\n";
    }
    void AddChild(const std::shared_ptr<TreeNode>& child){
        _children.push_back(child);
    }
    // pour manipuler le parent d'un node, il faut obtenir un shared_ptr vers le parent. cela se fait très simplement:
    std::shared_ptr<TreeNode> getParent(){
        return _parent.lock(); // le shared_ptr retourné contient un pointeur null si le TreeNode parent a déjà été détruit
    }
};

int _tmain(int argc, _TCHAR* argv[])
{
    std::shared_ptr<TreeNode> root(new TreeNode());
    root->AddChild(std::shared_ptr<TreeNode>(new TreeNode(root)));
    root->AddChild(std::shared_ptr<TreeNode>(new TreeNode(root)));

    root = nullptr;

    return 0;
}

et le résultat produit est bien :

constructed root
constructed child
constructed child
destructed node
destructed node
destructed node

 

Conclusion

C++ ne fournit pas de Garbage Collector pour gérer automatiquement le temps de vie des objets dynamiquement alloués. Cependant grâce aux smart pointers inclus dans la librairies standards C++11, on peut utiliser d’autres mécanismes comparables permettant d’écrire des applications très complexes sans jamais avoir besoin d’écrire un “delete” à la main. Attention toutefois au cas des cycles de références et de bien penser à briser ces cycles en utilisant le template weak_ptr<T>.

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus