Pourquoi des pointeurs intelligents?

Un ancien étudiant à moi, Yan Paquette, m'a écrit ce petit mot à l'été 2011 (je me suis permis des retouches mineures de langue, Yan; j'espère que tu ne m'en voudras pas trop) :

« Bonjour Pat, ça va bien?

Question pour le fun, rien d'urgent.

Je travaille dans une application avec plus d'un million de lignes de code. Une des sources principales des problèmes sont les pointeurs (non initialisés, dont on oublie de détruire le contenu pointé, etc.).

Nous utilisons à quelques endroits des auto_ptr et des shared_ptr. Leur définition est donnée par la bibliothèque standard <memory>. Certaines personnes ne jurent que par eux et ont bannis les pointeurs bruts de leur vie. Personnellement, je n'ai pas peur des pointeurs bruts et je crois être assez rigoureux concernant leur utilisation.

Ma question est la suivante : sachant qu'il n'est pas grave d'attendre la fin de la portée pour détruire le pointeur, est-ce que je fais une erreur de ne pas les utiliser religieusement ou devrais-je plutôt continuer d'utiliser des pointeurs bruts?

Merci et bonne journée! »

Ce qui suit est, en gros, ma réponse. Merci à Yan de m'avoir donné l'excuse d'écrire ce petit truc!


Les pointeurs intelligents, au début, ça ressemblait à une mode aux yeux des profanes, mais on sait maintenant qu'il y a des problèmes pour lesquels sans eux, il y aurait des risques de fuite en cas d'exceptions. Ils comblent un réel besoin. La réponse courte à ta question est donc oui, utilise-les (en choisissant le bon outil pour résoudre le bon problème, évidemment).

Plus en détail, maintenant. Pour saisir les tenants et aboutissants de ce type d'outil, c'est une bonne idée de comprendre ce que ça fait, à quoi ça sert, mais surtout quels problèmes ça règle. Comprendre comment c'est fait, c'est plus subtil (tu peux regarder ../../Sources/SmattPointer.html pour un exemple un peu simple et ../../Liens/Langages-programmation--Liens.html#langage_Cplusplus-technique-pointeurs-intelligents pour quelques liens).

Imaginons un cas comme celui-ci :

#include <algorithm>
#include <string>
using namespace std;
template <class T>
   class Guidi {
      T *p, *q;
   public:
      Guidi(T *p, T*q) : p{ p }, q{ q } {
      }
      void swap(Guidi &g) noexcept {
         using std::swap;
         swap(p, g.p);
         swap(q, g.q);
      }
      Guidi(const Guidi &g)
         : p{ g.p? new T{ *(g.p) } : nullptr },
           q{ g.q? new T{ *(g.q) } : nullptr }
      {
      }
      Guidi &operator=(const Guidi &g) {
         Guidi{ g }.swap(*this);
         return *this;
      }
      ~Guidi() {
         delete p;
         delete q;
      }
      // divers services sur p et q
   };
string* creer(const char *texte) {
   return texte? new string{ texte } : nullptr;
}
int main() {
   Guidi<string> g{ creer("Yo"), creer("man!") };
}

Ce programme est-il à risque de fuites? à première vue, il semble que non, puisque Guidi implémente la Sainte-Trinité et que delete nullptr; est inoffensif.

Pourtant, on a un réel risque de fuite ici. En fait, Guidi en fait trop : il est responsable de deux ressources plutôt que d'une seule. Imagine que lors de la construction de g dans main(), le premier appel à creer() fonctionne (on ne sait pas si c'est celui de gauche ou si c'est celui de droite; ça dépend de l'implémentation) mais que le second échoue (que le new string lève une exception, disons). Alors g ne serait pas construit, mais serait devenu responsable du string* qui lui aurait été suppléé.

Dans ce cas, puisque g n'a pas été construit, il ne sera pas détruit. Ses attributs p et q seront éliminés (c'est la mécanique normale de C++ qui veut ça) mais les pointés, qui sont sous la responsabilité de g, ne le seront pas. Tu vois, on a un hic fondamental ici. Une string, on s'en fout un peu, mais imagine que T soit une connexion à une BD...

C'est ici que ça prend un pointeur intelligent. Avec un truc comme std::shared_ptr ou std::unique_ptr (parce que std::auto_ptr est déprécié pour C++ 11, ayant une sémantique de copie un peu suspecte), on peut s'assurer que chaque pointeur intelligent soit responsable d'un seul pointeur brut. Ainsi, en réécrivant Guidi comme suit :

#include <algorithm>
#include <memory>
#include <string>
using namespace std;
template <class T>
   class Guidi {
      shared_ptr<T> p, q;
   public:
      Guidi(shared_ptr<T> p, shared_ptr<T> q) : p{ p }, q{ q } {
      }
      // tiens: tout le reste disparaît :)
      // divers services sur p et q
   };
shared_ptr<string> creer(const char *texte) {
   return make_shared<string>(texte? texte : "");
}
int main() {
   Guidi<string> g{ creer("Yo"), creer("man!") };
}

Remarquez les nombreux avantages :

C'est pas mal. Le type auto_ptr avait été standardisé malgré ses défauts parce que certaines manoeuvres de programmation ne pouvaient pas être Exception-Safe autrement. Maintenant qu'on est plus habiles, on peut faire mieux (unique_ptr est pas mal, shared_ptr est un petit bijou, et j'en fais différentes sortes au DDJV, par exemple un pointeur qui duplique son pointé par clonage si le pointé est un dérivé de Clonable et qui duplique par copie dans le cas contraire).

Le principe clé est de faire en sorte que si un objet est responsable d'une donnée, alors mieux vaut s'assurer qu'il ne soit responsable que de cette donnée. Si un objet a plusieurs responsabilités, alors divise-les entre plusieurs objets, idéalement génériques. C'est ce qu'on nomme le principe Dimov, en l'honneur de Peter Dimov qui l'aurait sinon énoncé, du moins formalisé et popularisé.

Alternative – modifier le code client?

On pourrait se demander pourquoi il ne serait pas « simplement » possible de régler le problème présenté dans l'exemple en créant au préalable les paramètres (avec une « lasagne de try catch », pour paraphraser quelqu'un de célèbre ). En fait, oui, il est possible de changer le code client pour régler le bogue dans le code de l'objet, mais est-ce comme ça qu'on veut travailler? Imaginez :

Correctif dans le code client (pointeurs bruts) Correctif dans la classe (pointeur intelligent)
#include <iostream>
int main() {
   using namespace std;
   string *p = creer();
   string *q;
   try {
      q = creer();
   } catch (...) {
       delete p;
       cerr << "Zut! Un problème bête!" << endl;
       return -1;
   }
   Guidi<string> g{ p, q };
}
int main() {
   Guidi<string> g{ creer("Yo"), creer("man!") };
}

Le code de Guidi avec pointeurs bruts et le code de Guidi avec pointeurs intelligents ne sont pas répétés ici par souci d'économie.

Vous imaginez les risques de faire des erreurs dans le code client si ce dernier doit être responsable de la validité des paramètres et de la gestion des exceptions pour éviter les problèmes dans le code des objets? Et de répéter ce genre de code à chaque utilisation de Guidi pour un type T donné? Ouf!

Il y a un tas de subtilités dans ce code, et on fait tout ça parce que l'objet est un peu déficient (il ne peut assurer sa propre sécurité face aux exceptions dû à un design déficient). C'est pas fin, ça, et ça va à l'encontre du principe d'encapsulation, où l'objet est responsable de sa propre intégrité et se comporte comme un bon citoyen du système.

Motivation philosophique (bref)

Sur le plan plus philosophique, un pointeur intelligent représente, de manière non-intrusive, une sémantique de gestion du pointeur brut, qu'il s'agisse d'une sémantique de duplication du pointé (copier, cloner, avec un truc comme SmattPtr), de partage (sémantique de référence, sans responsables; sémantique de partage, avec std::shared_ptr ou un équivalent), de propriété unique (avec std::auto_ptr, de C++ 98, ou encore avec std::unique_ptr qui le remplace dans C++ 11, ou un équivalent de votre cru).

Puisque chaque pointeur intelligent décrit (entre autres) une sémantique pour la Sainte-Trinité (construction par copie, affectation, destruction), un tel pointeur libère les classes qui l'utilise de la responsabilité de gérer ces opérations pour lui. Cela explique que Guidi avec pointeurs intelligents soit a priori beaucoup plus simple (en plus d'être beaucoup plus robuste) que Guidi avec pointeurs bruts.

Enfin, notez que, malgré ses défauts, C++ montre sa beauté ici : il est en effet possible de définir, avec les outils du langage, des enrichissements à certains des concepts les plus bruts et les plus primitifs qui y sont proposés, et ceà coût véritablement minimal. Coder un pointeur intelligent est un travail plutôt ardu (c'est un outil qu'il faut rédiger avec grand soin), mais l'utiliser est naturel.

Le cas de unique_ptr

Le type unique_ptr<T> est du pur bonbon. Quelques-unes de ses principales caractéristiques techniques suivent.

Par défaut, un unique_ptr<T> occupe le même espace en mémoire qu'un T*. Conséquemment, pour les cas typiques, il n'y a pas de coût en espace pour utiliser un unique_ptr plutôt qu'un pointeur brut.

unique_ptr<string> p;
string *q;
assert(sizeof(p)==sizeof(q));

Il est incopiable mais est déplaçable, ce qui signifie qu'on peut utiliser des conteneurs de unique_ptr<T>.

vector<unique_ptr<string>> v; // legal

Lorsqu'il est défini sur un type scalaire, il permet d'utiliser les opérateurs * et ->, mais lorsqu'il est défini sur un tableau, il expose plutôt l'opérateur []. Dans les deux cas, il utilise par défaut le bon opérateur pour détruire le pointé dont il est responsable (delete pour un scalaire, delete[] pour un tableau).

unique_ptr<string> p { new string {"J'aime mon prof"} };
cout << p->size() << endl;
unique_ptr<string[]> q { new string[10] };
q[3] = "J'aime mon prof";

Un unique_ptr est un excellent Sink-Only, au sens d'un paramètre fourni par l'appelant et consommé par l'appelé mais qui ne reviendra jamais. Pensez par exemple au constructeur d'un objet qui prend en charge un pointé et en devient seul et unique responsable, comme dans le cas à droite.

Dans cet exemple, le code client doit explicitement confier un unique_ptr<Dessinable3D> à un Obj3D pour que le code compile. Ainsi, ceci est illégal, parce qu'insuffisamment explicite (plus concrètement : l'usager sait-il ce qu'il fait?) :

Obj3D obj{ new Avion };

... et il en va de même pour ceci (une bonne chose, car l'accepter serait catastrophique!) :

Avion av;
Obj3D obj{ &av };

...alors que ceci, plus explicite, est légal :

Obj3D obj{make_unique<Avion>()};

Ceci force le code client à expliciter son intention

struct Dessinable3D {
   virtual void draw( /* ... */ ) const = 0;
   virtual ~Dessinable3D() = default;
};
class Avion : public Dessinable3D {
   // ...
public:
   Avion();
   void draw( /* ... */ ) const override;
   // ...
};
class Obj3D {
   unique_ptr<Dessinable3D> p;
public:
  Obj3D(unique_ptr<Dessinable3D> p) :p{std::move(p)} {
  }
  // ...
};

Il est possible de déposer un pointeur sur un type enfant dans un unique_ptr d'un type parent, ce qui permet d'utiliser unique_ptr à des fins polymorphiques.

class Base {
   virtual void f();
   virtual ~Base() = default;
};
class Derive : public Base {
   void f() override;
};
// ...
unique_ptr<Base> creer() {
   return unique_ptr<Base>{ new Derive };
}
// ...
int main() {
   auto p = creer();
   p->f(); // polymorphisme!
}

Il est possible de déterminer un autre comportement que celui par défaut lors de la finalisation du pointé.

class X {
   ~X() = default; // private
public:
   void ouille() const { delete this; }
};
auto meurs = [](const X *p) {
   p->ouille();
};
int main() {
   unique_ptr<X, decltype(meurs)> p { new X, meurs };
}

Enfin, unique_ptr est idéal pour écrire une fonction de fabrication, du fait qu'il peut être déplacé dans un autre unique_ptr, mais aussi dans un shared_ptr (si les types de pointés sont compatibles, dans les deux cas, de la même manière qu'avec des pointeurs bruts). Merci à Scott Meyers pour cette intuition.

class X { /* ... */ };
class Y : public X { /* ... */ };
// présumons X::X() public et Y::Y() public
template <class T, class ... Args>
   unique_ptr<T> creer(Args && ... args) {
      return unique_ptr<T> { new T (forward<Args>(args)...) };
   }
// ...
int main() {
   auto p = creer<X>();
   shared_ptr<X> = creer<Y>();
}

Flexible et diablement efficace.

Pour plus de conseils, voir http://herbsutter.com/2013/06/05/gotw-91-solution-smart-pointer-parameters/ par Herb Sutter.

Si vous doutez de l'impact positif d'un pointeur intelligent sur votre code, voici un comparatif simple, à l'aide d'une classe SimpleArray<T> qui modélise un tableau alloué dynamiquement, en version simplifiée. Notez que je n'ai écrit que les fonctions dites « spéciales », donc le constructeur par défaut, la Sainte-Trinité et le mouvement, le reste n'étant pas pertinent au propos. Ainsi :

Pour une présentation sur le sain usage d'un std::shared_ptr, voir http://exceptionsafecode.com/slides/svcc/2013/shared_ptr.pdf par Ahmed Charles en 2013.

Bien utiliser un pointeur intelligent

Comme le fait remarquer Herb Sutter dans http://herbsutter.com/gotw/_102/, il faut appliquer le principe de Dimov, soit, mais il faut le faire avec intelligence. Ainsi, plutôt que d'écrire ce qui suit :

class X {
public:
   X(int);
   // etc.
};
#include <memory>
int main() {
   using std::shared_ptr;
   shared_ptr<X> p{ new X{ 3 } };
}

...mieux vaut écrire ceci :

class X {
public:
   X(int);
   // etc.
};
#include <memory>
int main() {
   using std::make_shared;
   auto p = make_shared<X>(3);
}

Deux raisons sous-tendent cette recommandation.

La première est associée à la performance au sens large : créer un shared_ptr<X> à partir d'un X* nouvellement alloué force la mécanique à opérer naïvement, prenant un pointeur existant, allouant dynamiquement un compteur du nombre de références sur ce X (donc deux new, en fait) et gérant les deux espaces distincts par la suite.

En passant par make_shared<X>(int) (ou, de manière générale, par make_shared<T>(a0,a1,...,an) pour instancier un shared_ptr<T> à l'aide du constructeur T::T(a0,a1,...,an)), il devient possible d'allouer un seul bloc suffisamment grand pour contenir à la fois le compteur de références et le X*, économisant de l'espace mémoire et réduisant la fragmentation de la mémoire. En pratique, ceci n'a que des avantages.

La seconde en est une de sécurité. Reprenons une classe semblable à Guidi, plus haut, mais avec des shared_ptr<X> :

Risqué Correct
class X {
public:
   X(int);
   // ...
};
#include <memory>
using std::shared_ptr;
class Y {
   shared_ptr<X> p, q;
public:
   Y(shared_ptr<X> p, shared_ptr<X> q) : p{ p }, q{ q } {
   }
   // ...
};
int main() {
   Y y{ shared_ptr<X>{ new X{ 3 } }, shared_ptr<X>{ new X{ 4 } } };
}
class X {
public:
   X(int);
   // ...
};
#include <memory>
using std::shared_ptr;
using std::make_shared;
class Y {
   shared_ptr<X> p, q;
public:
   Y(shared_ptr<X> p, shared_ptr<X> q) : p{ p }, q{ q } {
   }
   // ...
};
int main() {
   Y y{ make_shared<X>(3), make_shared<X>(4) };
}

Ici, l'exemple « risqué » pose problème du fait qu'il est possible à un compilateur de résoudre les deux appels à new d'abord, puis d'initialiser les shared_ptr<X> par la suite (nous serions alors en violation du principe Dimov). Dans ce cas, si le second des deux new échoue, nous avons une vilaine fuite de ressources.

En passant par make_shared<T>(...), chacune des deux fonctions sera résolue indépendamment de l'autre, et l'objet retourné sera chaque fois un shared_ptr<T> pleinement construit et sécuritaire.

Bien que le standard C++ 11 ait négligé de le faire, il est sage pour cette raison d'implémenter un make_unique<T>(...) pour instancier un unique_ptr<T>. D'ici l'arrivée de C++ 14, mieux vaut prendre le temps d'en implémenter un vous-mêmes.

Obtenir le pointeur brut sous-jacent à un pointeur intelligent

Ce qui suit est de Howard Hinnant, et permet entre autres de faire un pont entre les pointeurs intelligents et quelques services « pointus », dont les allocateurs, qui doivent parfois opérer sur des pointeurs bruts.

Supposons que nous ayons un type T, susceptible d'être un pointeur brut ou un pointeur intelligent. Supposons que le pointeur intelligent puisse encapsuler un autre pointeur intelligent. Enfin, supposons que nous souhaitions obtenir le type de pointeur terminal, de telle sorte que, à titre d'exemple :

Un bon truc, proposé par Howard Hinnant, est d'avoir recours à l'opérateur -> de manière peu conventionnelle. Rappelons que l'opérateur ->, si surchargé, rappelle l'opérateur -> sur le type retourné jusqu'à ce qu'un type primitif (par exemple string*) soit rencontré. Voici en gros ce qu'il suggère :

template <class T>
   T* to_raw_pointer(T *p) noexcept {
      return p;
   }
template <class P>
   typename std::pointer_traits<P>::element_type*
      to_raw_pointer(P p) noexcept {
      return to_raw_pointer(p.operator->());
   }

Même un pointeur intelligent un peu bête peut être utile

Même un pointeur intelligent un peu bête peut être utile. Imaginez que nous souhaitions exprimer le concept de pointeur non-responsable du pointé, tout simplement. Un pointeur brut ferait alors le travail... à ceci près qu'il est possible de lui appliquer delete par accident! Heureusement, il est simple de concevoir un « pointeur intelligent » un peu bête, qui ne fait rien mais prive le code client de ce risque :

#ifndef OBSERVER_PTR_H
#define OBSERVER_PTR_H
#include <utility>
#include <cassert>
template <class T>
   class observer_ptr {
      T *p;
   public:
      constexpr observer_ptr(T *p) noexcept : p{ p } {
      }
      T& operator*() noexcept {
         return *p;
      }
      const T& operator*() const noexcept {
         return *p;
      }
      T* operator->() noexcept {
         return p;
      }
      const T* operator->() const noexcept {
         return p;
      }
      constexpr bool operator==(const observer_ptr &autre) const {
         return p == autre.p;
      }
      constexpr bool operator!=(const observer_ptr &autre) const {
         return !(*this == autre);
      }
      template <class U>
         constexpr bool operator==(const observer_ptr<U> &autre) const {
            return p == &*autre;
         }
      template <class U>
         constexpr bool operator!=(const observer_ptr<U> &autre) const {
            return !(*this == autre);
         }
      template <class U>
         constexpr bool operator==(const U *q) const {
            return p == q;
         }
      template <class U>
         constexpr bool operator!=(const U *q) const {
            return !(*this == q);
         }
      void swap(observer_ptr &autre) {
         using std::swap;
         swap(p, autre.p);
      }
      constexpr operator bool() const noexcept {
         return p != nullptr;
      }
      observer_ptr(observer_ptr &&autre) : p{ autre.p } {
         autre.p = nullptr;
      }
      observer_ptr& operator=(observer_ptr &&autre) { // unsafe for swap(a,b)
         assert(this != &autre);
         p = autre.p;
         autre.p = nullptr;
         return *this;
      }
      observer_ptr(const observer_ptr&) = default;
      observer_ptr& operator=(const observer_ptr&) = default;
      ~observer_ptr() = default;
   };
namespace std {
   template <class T> void swap(observer_ptr<T> &a, observer_ptr<T> &b) {
      a.swap(b);
   }
}
#endif

Les choses les plus simples sont parfois les meilleures.

Lectures complémentaires

Pour un comparatif du code généré pour un unique_ptr<int>, un shared_ptr<int> et une gestion manuelle de l'allocation dynamique d'un int, voyez ce comparatif proposé par Jason Turner.

Ce petit texte ne couvre pas exhaustivement la question de la raison motivant le recours aux pointeurs intelligents, mais peut aider à démarrer votre réflexion. Pour en savoir plus, voir aussi :


Valid XHTML 1.0 Transitional

CSS Valide !