Smart pointers

  • Your native language: with Chrome, right-click on the page and select « Translate into … »

  • English version:

L’origine du problème

Pourquoi évoquer les pointeurs dans un cours de C++ moderne, alors que le langage s’efforce justement de les masquer ?

Prenons un exemple simple : une collection d’objets appartenant à une même hiérarchie de classes. Imaginons une classe mère A et une classe fille B. Si l’on écrit vector<A> pour stocker des objets de type A et B, un problème apparaît. En effet, un vector<A> ne peut contenir que des objets de type A. Le compilateur acceptera de stocker un objet de type B, mais uniquement en le tronquant pour le convertir vers le type A, démarche plutôt expéditive !

La solution consiste à utiliser un vector de pointeurs vers des objets de type A, le type de la classe mère. Dans ce cas précis, lorsqu’un pointeur est utilisé, le langage vérifie dynamiquement à l’exécution le type réel de l’objet.

Ainsi, tant que vous utilisez l’héritage, vous ne pouvez pas bannir complètement les pointeurs. En revanche, dans l’esprit du C++ moderne, il est recommandé de les gérer via des smart pointers. Ceux-ci combinent la performance du C++ avec une gestion mémoire plus sûre, comparable à celle d’un garbage collector en Java ou C#.

Les shared_ptr

Nous rappelons que :

Les mots-clefs new et delete SONT INTERDITS

Les fonctions malloc et free SONT INTERDITES

Si les opérations new/delete ne sont pas autorisées, alors comment que faire ? Nous allons vous présenter une nouvelle approche appelée pointeurs intelligents (smart pointers). Nous nous concentrons plus particulièrement sur les shared pointers qui allient l’efficacité des pointeurs avec la sécurité d’un garbage collector. En effet, un shared_ptr est un pointeur doublé d’un compteur d’utilisation (use count) vers l’entité désignée. Lorsque ce compteur atteint 0, alors le shared_ptr déclenche la destruction de l’objet pointé comme le ferait un garbage collector.

Shared_ptr comme arguments

Pour instancier un objet de type T et l’associer à un shared pointer p, vous devez utiliser la syntaxe suivante :

shared_ptr<T> p = make_shared<T> (paramètres du constructeur);
ou
auto p = make_shared<T>(paramètres du constructeur);

Le mot clef auto permet de simplifier la syntaxe des instanciations. Pour accéder aux membres de l’objet associé au smart pointeur, il faut utiliser l’opérateur flèche -> i.e. la notation classique des pointeurs. Voici un exemple ci-dessous :

#include <iostream>
#include <memory>
using namespace std;

struct Point
{
        int x, y;
        Point(int a, int b) : x(a), y(b) {  }
        void Aff() { cout << "(" << x << "," << y << ")"; }
};

int main()
{
        shared_ptr<Point> p1 = make_shared<Point>(1, 2);
        p1->Aff();

        auto p2 = make_shared<Point>(4, 5);
        p2->Aff();
}

>> (1,2)
>> (4,5)

Avertissement

Nous vous demandons d’utiliser les shared_ptr car ils constituent le bagage minimum d’un programmeur C++ moderne.

Est-ce que les shared_ptr résolvent tous les problèmes de libération de mémoire ? Malheureusement non. Par exemple, nous pouvons citer le cas des références cycliques : un objet K ayant un shared_ptr vers un objet M contenant lui-même un shared_ptr vers l’objet K. Le mécanisme des shared_ptr reste impuissant car chaque compteur d’utilisation ne pourra pas atteindre 0 à cause du cycle. Nous vous conseillons d’éviter cette situation.

Shared_ptr et arguments

Voici un exemple qui indique comment un shared_ptr se comporte lors des appels de fonction :

#include <iostream>
#include <memory>
using namespace std;

struct Point
{
        int x, y;
        Point(int a, int b) : x(a), y(b) { cout << "Point created\n"; }
        ~Point()                         { cout << "Point destroyed\n"; }
        void Print()                     { cout << "(" << x << "," << y << ")" << endl; }
};

void test_1(shared_ptr<Point> & p1)
{       // pass by reference
 5      cout << "test_1 use count : " << p1.use_count() << "\n";
}

void test_2(shared_ptr<Point> p2)
{       // pass by copy
 7      cout << "test_2 use count : " << p2.use_count() << "\n";
}

int main()
{
 1      auto p = make_shared<Point>(1, 2); // instantiation
 2      p->Print();
 3      cout << "use count : " << p.use_count() << "\n";
 4      test_1(p);
 6      test_2(p);
 8      cout << "use count : " << p.use_count() << "\n";
}

 1      >> Point created
 2      >> (1,2)
 3      >> use count : 1
 5      >> test_1 use count : 1
 7      >> test_2 use count : 2
 8      >> use count : 1
 9      >> Point destroyed

Description ligne à ligne :

  • 1 : auto p = make_shared<Point>(1, 2) : crée un objet de type Point et un shared_ptr p pointant sur cet objet.

  • 2 : p->Aff(); : affiche les coordonnées (x,y) du point en question.

  • 3 : cout << « use count » : un seul shared_ptr point vers l’objet Point, le compteur est à 1.

  • 4 : test_1(p) : appel de la fonction test_1, le shared_ptr est passé par référence.

  • 5 : cout << « test_1 », il n’y a pas eu de nouveau pointeur créé, le compteur est toujours égal à 1.

  • 6 : test_2(p) : appel de la fonction test_2, le shared_ptr est passé par copie, un nouveau shared_ptr p2 est créé et désigne le même objet.

  • 7 : cout << « test_2 », les deux shared_ptr p et p2 pointent vers le même objet, le compteur vaut donc 2.

  • 8 : cout << « use count », au retour dans la fonction test_2, le shared_ptr p2 a été détruit, le compteur retombe à 1.

  • 9 : fin de la fonction main(), le shared_ptr p est détruit, le compteur de tombe à 0, et l’objet Point est détruit automatiquement.

Shared_ptr et vector

Voici un exemple qui indique comment un shared_ptr se comporte avec des vector :

#include <iostream>
#include <memory>
#include <vector>
using namespace std;

struct Point
{
          int x, y;
          Point(int a, int b) : x(a), y(b) { cout << "Point created  : "; Print(); }
          ~Point()                         { cout << "Point destroyed: "; Print(); }
          void Print()                     { cout << "(" << x << "," << y << ")" << endl; }
};

int main()
{
          vector<shared_ptr<Point>> L;

        1 L.push_back(make_shared<Point>(1,2));
        1 L.push_back(make_shared<Point>(3,4));
        2 L.push_back(L[0]);

        3 cout << "count L[0] : " << L[0].use_count() << "\n";
        4 cout << "count L[1] : " << L[1].use_count() << "\n";

        5 cout << "L[1] destroyed" << endl;
        6 L.erase(L.begin()+1);

        7 cout << "End of program" << endl;
}

1 >> Point created  : (1,2)
1 >> Point created  : (3,4)
3 >> use count L[0] : 2
4 >> count L[1] : 1
5 >> L[1] destroyed
6 >> Point destroyed: (3,4)
7 >> End of program
  >> Point destroyed: (1,2)

Commentaires :

  • 1 : Deux objets Point sont créés et insérés dans un vector L.

  • 2 : Le shared_ptr L[0] est copié pour créer un 3ème élément dans la liste L.

  • 3 : Le compteur d’utilisation vaut 2 car il y a deux shared_ptr pointant vers le même objet : L[0] et L[2].

  • 4 : Le compteur de L[1] vaut 1 car il y a un unique shared_ptr pointant vers cet objet.

  • 5 : La ligne : L.erase(L.begin()+1) efface le shared_ptr L[1], le compteur tombe à 0.

  • 6 : Par conséquent le Point : (3,4) est détruit dans la foulée : Point destroyed : (3,4)

  • 7 : La fonction main se termine, la liste L est détruite ainsi que les shared_ptr qu’elle contient.

  • 8 : Le compteur d’utilisation vers l’objet Point(1,2) tombe à 0 et cet objet est automatiquement détruit : Point destroyed : (1,2).

Quizzz

  • Syntaxe pour créer un shared pointer sur un objet de type T avec comme paramètres de construction 3 et 5. Ne pas écrire le ;

  • Depuis un shared pointer p, pour accéder au paramètre a, dois-je écrire p.a ?

  • Quelle est la valeur affichée par le code suivant :

    void test(shared_ptr<Point> p)  {  p->Aff();  }
    
    int main()
    {
            shared_ptr<Point> p = make_shared<Point>(1,2);
    
            for (int i = 0 ; i < 3 ; i++)   test(p);
    
            cout << p.use_count();
    
            return 0;
    }
    
  • Un shared pointer permet de libérer l’objet pointé dès que son compteur d’utilisation vaut -1.