logo le blog invivoo blanc

Introduction à la gestion automatique de la mémoire en C++11

10 septembre 2018 | C++ | 2 comments

A la différence du Java ou du C#, la mémoire allouée dynamiquement sur le tas n’est pas libérée de manière automatique en C++. Par conséquent, chaque utilisation de l’opérateur new doit être suivi d’un appel explicite à l’opérateur delete. Ce principe semble à première vue assez simple à respecter. Cependant, en pratique, au sein d’applications contenant des milliers de lignes de code, des oublis ou des erreurs peuvent se glisser. Cela engendre ainsi des fuites mémoires.
Pour simplifier cette gestion dynamique de la mémoire, le standard introduit dans la révision de 2011 : les smart pointers.

A l’origine, il y avait le RAII

Afin de mieux comprendre le concept de « pointeurs intelligents », nous allons dans un premier temps nous intéresser à une technique de programmation : « Resource Acquisition Is Initialization » ou RAII en abrégé.
Celle-ci consiste à lier une ressource (mutex, mémoire, fichier…) à un objet instancié sur la pile lors de son initialisation. En effet, la ressource est acquise dans le constructeur de l’objet et est libérée dans le destructeur. Ainsi, à la sortie du scope de l’objet, le destructeur étant systématiquement appelé, la libération de la ressource est garantie.
Essayons à travers un exemple d’illustrer cette technique.

void process()
{
  MyObject *myObject = new MyObject ();
  // traitement
  delete myObject;
}

Dans cet exemple, nous avons une fonction process qui instancie dynamiquement un objet myObject, qu’il va utiliser lors de son traitement et le libérer tout à la fin.

En quoi cette implémentation peut-elle être problématique ? Tout simplement, si pour une raison ou une autre on sort de la fonction pendant le traitement (une levée d’exceptions ou un return par exemple), la mémoire allouée dynamiquement pour instancier l’objet ne sera jamais libérée.

Appliquons la technique RAII et voyons si cela améliore les choses.

Pour cela, on crée une classe Manager :

Class Manager
{
      public :
      Manager(MyObject *o) : obj_(o) {} 
      ~Manager() {  delete o;   }
      private:
       MyObject *obj_;
} ;

La classe Manager acquiert la ressource de type MyObject* dans son constructeur et la libère dans son destructeur.
Maintenant, modifions la fonction process pour prendre en compte notre nouvelle classe Manager.

void process()
{
  MyObject *myObject = new MyObject ();
  Manager myManager(myObject);
  // traitement
}

Comme la classe Manager se charge de la gestion de notre ressource myObject nous n’avons plus besoin d’appeler explicitement l’opérateur delete à la sortie de la fonction.
A la différence de la première implémentation, si par exemple une exception est générée à l’intérieur du traitement, le destructeur de myManager sera automatiquement appelé et par conséquent la mémoire allouée libérée.
A travers cet exemple assez simple, nous avons en réalité introduit le thème des pointeurs intelligents.

Les smart pointers

Les smart pointers ou pointeurs intelligents sont tout simplement une des applications de la technique RAII que l’on a traité plus haut. Ce sont des types de données qui permettent de libérer automatiquement la mémoire allouée dynamiquement. Ils ressemblent à un pointeur classique dans leurs utilisations : opérateur * pour accéder à l’objet pointé et opérateur -> pour accéder au contenu de l’objet.

Le C++ 11 a introduit 3 types de pointeurs intelligents qui sont tous trois implémentés à l’aide de templates : unique_ptr, shared_ptr, weak_ptr.
Ils sont tous trois définis dans le header.

Le unique pointer

Comme son nom l’indique, il se distingue par le fait qu’une seule instance de unique_ptr peut avoir accès à la zone mémoire allouée. Une fois que cette instance est détruite (sortie de scope), la mémoire est libérée sans l’appel explicite à l’opérateur delete.
La copie et l’assignement sont interdite et génèreront une erreur de compilation.

int main()
{
  // creation d'un unique_ptr qui va gerer la memoire allouee dynamiquement 
  std::unique_ptr<std::string> uniqueptr1(new std::string("unique"));
  
  // erreurs de compilation car on essaye de copier uniqueptr1
  std::unique_ptr<std::string> uniqueptr2(uniqueptr1);
  std::unique_ptr<std::string> uniqueptr3 = uniqueptr1;

  return 0;
}

Cependant, le transfert de propriété est possible grâce à la fonction std::move().

int main()
{
  // creation d'un unique_ptr qui va gerer la memoire allouee dynamiquement 
  std::unique_ptr<std::string> uniqueptr1(new std::string("unique"));
  
  std::cout << "1 : " << *uniqueptr1 << std::endl;

  // transfert de propriete de uniqueptr1 vers uniqueptr2
  std::unique_ptr<std::string> uniqueptr2(std::move(uniqueptr1));
  if(uniqueptr1.get() == nullptr)
    std::cout << "2 : empty uniqueptr1" << std::endl;
  std::cout << "3 : " << *uniqueptr2 << std::endl;

       
       // uniqueptr1 et uniqueptr2 sont détruits à la sortie du main 
       // la destruction de uniqueptr2 entraine la deallocation de la string ‘unique’
  return 0;
}

Code 1

On voit dans l’exemple au-dessus qu’une fois le transfert effectué, uniqueptr1 est réinitialisé tandis que uniqueptr2 possède l’accès à la string « unique » allouée dynamiquement.

Notez qu’il est également possible de revenir à un pointeur classique à partir de la méthode release(). Attention, une fois cette méthode appelée il est de la responsabilité de l’utilisateur d’appeler explicitement l’opérateur delete car la mémoire ne sera plus désallouée automatiquement.

 int main()
{
  // creation d'un unique_ptr qui va gerer la memoire allouee dynamiquement 
  std::unique_ptr<std::string> uniqueptr1(new std::string("unique"));
  
  std::cout << "1 : " << *uniqueptr1 << std::endl;

  // liberation de uniqueptr1 
  std::string *ptr = uniqueptr1.release();
  if(uniqueptr1.get() == nullptr)
    std::cout << "2 : empty uniqueptr1" << std::endl;

  std::cout << "3 : " << *ptr << std::endl;

  // attention la memoire doit etre desallouee manuellement desormais
  delete ptr;

  return 0;
}

code 2

Le shared pointer

A la différence de l’unique pointer, le shared pointer permet de partager la gestion de la mémoire allouée entre différentes instances. Il existe un compteur interne de références permettant de connaitre le nombre de shared pointers qui partagent la ressource. La mémoire est libérée uniquement lorsque la dernière instance propriétaire est détruite.

int main()
{
  // creation du shared ptr a partir d'une allocation dynamique de la string 'shared'
  std::shared_ptr<std::string> sharedptr1(new std::string("shared"));
  std::cout << "sharedptr1 value = " << *sharedptr1 << std::endl;
  std::cout << "sharedptr1 address = " << sharedptr1.get() << std::endl;
  std::cout << "counter = " << sharedptr1.use_count() << std::endl;

  {
    // creation d'un second shared_ptr qui va partager la propriete 
    std::shared_ptr<std::string> sharedptr2(sharedptr1);
    std::cout << "sharedptr2 value = " << *sharedptr2 << std::endl;
    std::cout << "sharedptr2 address = " << sharedptr2.get() << std::endl;
    std::cout << "counter = " << sharedptr2.use_count() << std::endl;
    std::cout << "destruction de sharedptr2" << std::endl;

    // sharedptr2 va etre detruit mais la memoire n'est pas encore desallouee 
  }

  std::cout << "counter = " << sharedptr1.use_count() << std::endl;
    // destruction de sharedptr1 : la memoire est desallouee car le dernier shared ptr a ete detruit

  return 0;
}

code 3Comme on peut le voir dans l’exemple ci-dessus, les deux shared pointers pointent bien sûr la même adresse mémoire.

A la création de sharedptr1, le compteur interne est de 1. Celui-ci est incrémenté à la création de sharedptr2. Nous remarquons par contre qu’à la destruction de sharedptr2 celui-ci est effectivement décrémenté.

Notons que le transfert de propriété avec std ::move fonctionne également avec le std ::shared_ptr.

int main()
{
  // creation du shared ptr a partir d'une allocation dynamique de la string 'shared'
  std::shared_ptr<std::string> sharedptr1(new std::string("shared"));
  std::cout << "sharedptr1 value = " << *sharedptr1 << std::endl;
  std::cout << "sharedptr1 address = " << sharedptr1.get() << std::endl;
  std::cout << "counter = " << sharedptr1.use_count() << std::endl;

  {
    // transfert de propriete entre sharedptr1 et sharedptr2 
    std::shared_ptr<std::string> sharedptr2(std::move(sharedptr1));
    std::cout << "sharedptr2 value = " << *sharedptr2 << std::endl;
    std::cout << "sharedptr2 address = " << sharedptr2.get() << std::endl;
    std::cout << "counter = " << sharedptr2.use_count() << std::endl;
    std::cout << "destruction de sharedptr2" << std::endl;

    // sharedptr2 va etre detruit la mémoire est desallouee  
  }

  return 0;
}

code 4En utilisant la fonction std ::move(), on constate que le compteur n’est plus incrémenté à la création de sharedptr2 et la mémoire est désallouée lorsque celui-ci est détruit.

Le weak pointer

Le unique pointer met en avant la notion de propriété exclusive, le shared pointer lui la notion de propriété partagée, avec comme point commun la gestion de la désallocation de la mémoire.
Le weak pointer lui a un fonctionnement particulier. En effet, au contraire des deux autres pointeurs intelligents qui peuvent s’utiliser de manière indépendante, le weak pointer s’utilise en complément du shared pointer.
Ainsi le code suivant produit une erreur de compilation :

std::weak_ptr<std::string> weakptr(new std::string("weak")); // erreur de compilation

En effet, il va pointer sur un objet géré par un shared pointer mais n’impacte pas la durée de vie de celui-ci. Une des propriétés importantes des unique pointers est qu’il n’impacte pas le compteur de références des shared pointers.

Pour utiliser le weak pointer il est nécessaire d’instancier un shared pointer avec l’objet dont on souhaite automatiser la désallocation mémoire.

int main()
{
  

  // instanciation d'un shared ptr 
  std::shared_ptr<std::string> sharedptr(new std::string("sharedPtr"));
  std::cout << "reference count: " << sharedptr.use_count()  << std::endl;

  // instanciation d'un weak ptr a partir du shared ptr 
  std::weak_ptr<std::string> weakptr(sharedptr);
  std::cout << "reference count: " << sharedptr.use_count() << std::endl;
  
  std::shared_ptr<std::string> sharedptr2(sharedptr);
  std::cout << "reference count: " << sharedptr.use_count() << std::endl;
    

  return 0;
}

code 5Comme on peut le voir sur l’exemple ci-dessus, le weak pointer prend directement en paramètre de son constructeur le shared pointer en charge de la string « sharedPtr » allouée dynamiquement.

Remarquons que le compteur de référence des shared pointers n’est pas incrémenté lorsque notre weak pointer est instancié au contraire de la deuxième instance de std ::shared_ptr sharedptr2.

Une fois le weak pointer instancié, comment accède-t-on à notre objet ? Le weak pointer ne permet pas d’accéder à l’objet pointé et ses attributs/méthodes à travers les opérateurs * et -> au contraire des deux autres pointeurs intelligents vus plus haut.

En effet, il faut repasser par un shared pointer avec l’aide de la méthode lock() afin de pouvoir accéder à notre string.

int main()
{
  
  // instanciation d'un shared ptr 
  std::shared_ptr<std::string> sharedptr(new std::string("sharedPtr"));
  std::cout << "reference count: " << sharedptr.use_count()  << std::endl;
  std::cout << "sharedptr value: " << *sharedptr << std::endl;

  // instanciation d'un weak ptr a partir du shared ptr 
  std::weak_ptr<std::string> weakptr(sharedptr);
  std::cout << "reference count: " << sharedptr.use_count() << std::endl;
  std::shared_ptr<std::string> wp_shared_ptr = weakptr.lock();
  std::cout << "reference count: " << sharedptr.use_count() << std::endl;
  std::cout << "weakptr value: " << *wp_shared_ptr << std::endl;
  
  std::shared_ptr<std::string> sharedptr2(sharedptr);
  std::cout << "reference count: " << sharedptr.use_count() << std::endl;
  std::cout << "sharedptr2 value: " << *sharedptr2 << std::endl;
    

  return 0;
}

code 6Notons que si l’instanciation d’un weak pointer n’incrémente pas le compteur de références des shared pointers, le passage d’un weak pointer à un shared pointer à travers la méthode lock() est bien prise en compte.

On peut par conséquent se demander : à quoi sert un weak pointer puisqu’il doit passer par un shared pointer afin d’accéder à l’objet pointé ? Pourquoi ne pas utiliser uniquement des shared pointers ?

Pour comprendre cela, nous allons nous intéresser à un des problèmes les plus communs engendré par une mauvaise utilisation des shared pointers : les références circulaires.

Les références circulaires

Pour illustrer cette problématique, étudions l’exemple ci-dessous :

class Etudiant
{
public:
  Etudiant(const std::string& nom) : nom_(nom) 
  {
    std::cout << "Construction Etudiant " << nom << std::endl;
  }

  ~Etudiant() 
  { 
    std::cout << "Destruction Etudiant " << nom_ << std::endl;
  }

  void creerBinome(std::shared_ptr<Etudiant> & binome)
  {
    this->binome_ = binome;
  }

private:
  std::string nom_;
  std::shared_ptr<Etudiant> binome_;
};


int main()
{
  std::shared_ptr<Etudiant> etudiant1(new Etudiant("Marc"));
  std::shared_ptr<Etudiant> etudiant2(new Etudiant("Lionel"));

  etudiant1->creerBinome(etudiant2);
}

code 7Dans notre exemple, nous sommes dans une classe où les étudiants travaillent par pair. On associe chaque étudiant à son binôme.

Créons ainsi une classe Etudiant qui a pour attributs :

  •          nom_ : std ::string contenant le nom de l’étudiant ;
  •          binome_ : std ::shared_ptr qui pointe vers le binôme associé à l’étudiant.

Cette classe possède également une méthode creerBinome() qui permet d’affecter le binôme d’un étudiant.

Pour l’instant, nous affectons à l’étudiant Marc, son binôme Lionel.

On a bien dans ce cas de figure les deux constructeurs qui sont appelés mais aussi les deux destructeurs. Cependant, si on regarde de plus près, on constate que l’ordre des appels n’est pas habituel. En effet, on pourrait s’attendre à avoir selon l’ordre d’instanciations :

  • Construction Etudiant Marc
  • Construction Etudiant Lionel
  • Destruction Etudiant Lionel
  • Destruction Etudiant Marc

Souvenez vous que nous utilisons des shared pointers et que ceux-ci ne libèrent la mémoire que lorsque le dernier shared pointer pointant sur la ressource est détruit.

Ajoutons quelques logs pour mieux comprendre.

int main()
{
  std::shared_ptr<Etudiant> etudiant1(new Etudiant("Marc"));
  std::shared_ptr<Etudiant> etudiant2(new Etudiant("Lionel"));

  etudiant1->creerBinome(etudiant2);
  std::cout << "Compteur etudiant1: " << etudiant1.use_count() << std::endl;
  std::cout << "Compteur etudiant2: " << etudiant2.use_count() << std::endl;
}

code 8On constate qu’il existe deux smart pointers pointant sur l’étudiant Lionel mais un seul sur l’étudiant Marc. En effet, le second smart pointer vers Lionel correspond à l’attribut binome_ que nous avons initialisé avec la méthode creerBinome().
Ainsi une fois sortie du scope du main, nous avons :

  1. On détruit le shared pointer etudiant1
  2. Le compteur de référence pour l’étudiant Marc est à 0
  3. On appelle le destructeur de Etudiant Marc et on détruit l’attribut binome_
  4. Le compteur de référence pour l’étudiant Lionel est à 0
  5. On appelle le destructeur de Etudiant Lionel

Affectons maintenant à Lionel son binôme Marc.

int main()
{
  std::shared_ptr<Etudiant> etudiant1(new Etudiant("Marc"));
  std::shared_ptr<Etudiant> etudiant2(new Etudiant("Lionel"));

  etudiant1->creerBinome(etudiant2);
  etudiant2->creerBinome(etudiant1);
  std::cout << "Compteur etudiant1: " << etudiant1.use_count() << std::endl;
  std::cout << "Compteur etudiant2: " << etudiant2.use_count() << std::endl;

}

code 9Nous constatons qu’aucun des deux destructeurs ne sont plus appelés. Que se passe-t-il au juste ?
Les deux compteurs sont maintenant à 2. En effet, nous avons affecté Lionel pour binôme à Marc et vice versa. Cela se traduit par un nouveau shared pointer qui va pointer sur Etudiant Marc et un autre sur Etudiant Lionel.
Ainsi en sortant du scope du main, les deux compteurs vont être à 1 et aucun à 0. Ce qui explique l’absence des appels aux destructeurs. C’est le phénomène de références circulaires.

Comment résoudre le problème des références circulaires ?

Pour résoudre ce problème, il suffirait de trouver un moyen de ne pas incrémenter le compteur de références de nos shared pointers.  Ceci afin que les dernières instances soient détruites à la sortie du main.
Nous avons vu plus haut que justement le weak pointer permettait de pointer un objet gérer par un shared pointer. Cela sans impacter son compteur de références.
Remplaçons notre attribut binome_ de type shared_ptr par un weak_ptr.

class Etudiant
{
public:
  Etudiant(const std::string& nom) : nom_(nom) 
  {
    std::cout << "Construction Etudiant " << nom << std::endl;
  }

  ~Etudiant() 
  { 
    std::cout << "Destruction Etudiant " << nom_ << std::endl;
  }

  void creerBinome(std::shared_ptr<Etudiant> & binome)
  {
    this->binome_ = binome;
  }

private:
  std::string nom_;
  std::weak_ptr<Etudiant> binome_;
};


int main()
{
  std::shared_ptr<Etudiant> etudiant1(new Etudiant("Marc"));
  std::shared_ptr<Etudiant> etudiant2(new Etudiant("Lionel"));

  etudiant1->creerBinome(etudiant2);
  etudiant2->creerBinome(etudiant1);
  std::cout << "Compteur etudiant1: " << etudiant1.use_count() << std::endl;
  std::cout << "Compteur etudiant2: " << etudiant2.use_count() << std::endl;
}

code 11On voit que les deux destructeurs sont désormais appelés. Ainsi nous avons pu très facilement contourner le problème des références circulaires en utilisant les propriétés des unique pointers.

Nous avons vu dans cet article, une nouvelle manière, plus sûre, de gérer la mémoire allouée dynamiquement. Ceci à l’aide des pointeurs intelligents introduits dans la révision C++11. Cependant, malgré la fonctionnalité de désallocation automatique, l’usage des smart pointers requiert tout de même de la vigilance. En effet, puisque les appels à l’opérateur delete ne sont plus explicites, nous ne sommes pas à l’abris des cas de références circulaires ou de mauvais accès à une ressource désallouée.