Une idée savante qu'on eu très tôt les programmeurs, c'est de ne plus manipuler des pointeurs "nus" (comme int*, float*, A**, etc.), mais des pointeurs encapsulés dans une classe à qui l'on confierait le travail d'assurer sa bonne gestion. Une instance d'une telle classe, allouée statiquement, serait alors en mesure de libérer à sa destruction automatiquement la mémoire allouée, ou encore d'empêcher les tentatives d'accès aux données de la zone pointée si celle-ci n'est pas allouée, etc. On parle de pointeur intelligent.
Une méthode plus générale : la RAII
Ce que j'ai décrit plus haut n'est qu'une application pratique parmi d'autres d'un principe très répandu en programmation orientée objet : RAII, pour "Ressource Acquisition Is Initialisation". Très simplement, ce principe nous dit que pour chaque acquisition de ressource que l'on fait, on crée aussi un objet qui va garantir sa bonne gestion. Cet objet encapsulera la ressource et nous donnera une autre interface, plus adaptée, une interface de plus haut niveau. C'est un idiome de programmation qui permet de faire du code plus fiable, plus maintenable, plus compréhensible, plus sécurisée et plus simple.
L'utilité de la RAII trouve sa source dans la destruction automatique des objets alloués sur la pile : chaque objet alloué statiquement sera libéré, son destructeur sera appelé et ceci même en cas d'exception. On écrira l'instruction de libération de la ressource dans le destructeur et on est certain que la ressource sera libérée, sans besoin d'un appel à delete ou close() etc. Si l'acquisition échoue dans le constructeur, l'objet RAII en tiendra compte pour la suite. Cela implique donc aussi qu'on n'aura pas besoin de contrôler immédiatement de manière barbante si l'allocation d'une ressource s'est faite correctement.
Vous connaissez tous au moins une classe de la STL qui utilise ce principe. Il s'agit de std::fstream. On vous présente toujours cette classe comme offrant un système simple et sécurisé pour manipuler les fichiers en C++. Ce qu'on oublie de dire en général, c'est que c'est totalement basé sur de la RAII : la fichier est ouvert à la construction d'un objet fstream (qui sera son représentant), alloué statiquement, et il est automatiquement fermé à la destruction de cet objet. L'approche RAII se retrouve encore ailleurs : std::vector, std::string, etc. Et elle constitue la base de la technique du pointeur intelligent.
Zoom
Pourquoi parle-t-on de pointeur intelligent ? En quoi sont-ils intelligents ?
Premièrement, ils libèrent la mémoire sans qu'on le demande explicitement et ce quand ils partent du principe qu'on en a plus besoin. Dans la même logique que std::fstream, on crée un objet statiquement représentant une zone mémoire allouée dynamiquement. En disséquant un peu, cela se présente comme un objet encapsulant un pointeur sur cette zone. On se sert alors de la libération statique de cet objet pour mettre en œuvre une libération dynamique.
Deuxièmement, ils permettent d'adopter un autre point de vue sur les entités allouées. Quand l'on travaille avec un pointeur nu, on voit un pointeur et on se dit que ce n'est qu'une variable stockant une valeur qui est une adresse d'une autre variable. Mais quand on voit dans un code une déclaration et une utilisation d'un pointeur intelligent, on voit un objet en lui-même, l'entité allouée dynamiquement prend ainsi forme et se confond dans l'entité du pointeur ; un peu comme pour un objet std::fstream duquel on dirait "voilà notre fichier".
Troisièmement, le pointeur contrôle ce que l'on veut faire et assure avant tout la sécurité du code. Le problème de l'allocation dynamique gérée "à la main", c'est souvent la copie de pointeurs ou l'accès à des données non-allouées. On peut alors très imaginer un pointeur intelligent qui mettra en place un système sécurisé de copie ou qui empêchera l'utilisateur d'accéder à une zone non-allouée.
L'objet de ce concept RAII (c'est-à-dire le pointeur intelligent) a ainsi un double rôle : il doit ajouter ou au moins préserver la sémantique du code. Autrement dit, à la vue du code, on doit être en mesure de comprendre simplement ce que l'on fait ou veut faire, et au moins tout aussi simplement qu'avec les pointeurs nus. Deuxièmement, cet objet doit s'occuper de tous les tracas de l'allocation dynamique à notre place. Les deux sont très liés : en étant "intelligents", ces pointeurs nous évitent d'écrire du code qui aurait résolu (peut-être mal) autrement les problèmes, code souvent indigeste qu'on peut donc s'épargner. On ajoute de la lisibilité, de la sémantique.
La STL a évidemment (comme toujours) pensé à nous et nous propose un type de pointeur intelligent que je vais présenter : std::auto_ptr. Malheureusement, auto_ptr souffre d'un problème (que nous allons voir tout de suite), ce qui fait qu'on le déconseille très souvent. Boost également propose toute une panoplie de pointeurs intelligents, chacun pour un usage différent. Je vais vous présenter le plus connu d'entre eux, boost::shared_ptr, et ses avantages par rapport à std::auto_ptr. Cela dit en passant, si vous n'avez pas encore installé Boost, c'est le moment de le faire, on ne fait plus grand-chose sans ce framework.
En C++, ça donne quoi ?
En C++, un pointeur intelligent est donc un objet. Le type de cet objet est quasiment toujours une instance d'une classe template, c'est-à-dire ici une classe qui prend le type de l'objet (au sens large) alloué dynamiquement en paramètre template. D'un point de vue de méta-programmation template, en créant un pointeur intelligent, on écrit d'abord une classe similaire à la classe template en remplaçant son paramètre par le type de la donnée que l'on va vouloir pointer. std::auto_ptr fonctionne de cette manière, boost::shared_ptr également (et d'autres).
Un détail surprend souvent les débutants : le paramètre template n'est pas le type du pointeur "nu" que l'on aurait eu à la place. En se disant qu'ils veulent un pointeur intelligent sur un int par exemple, ils (ou peut-être vous si vous êtes débutant) écrivent intuitivement "pointeur_intelligent < int* >", pourtant ceci n'aura pas l'effet attendu. En effet, il ne faut pas considérer un pointeur intelligent comme un objet encapsulant un pointeur, mais plutot comme un vrai pointeur dont on précise le type de la donnée pointée. Dans ce cas, "pointeur_intelligent < int >" est donc correct.
Un autre détail d'ordre "C++" : l'allocation dynamique est tout de même faite en-dehors du pointeur intelligent ; ce n'est pas lui qui s'en charge, il se contente de récupérer un pointeur sur une zone déjà allouée ou NULL.
Première exemple : std::auto_ptr
La STL fourni donc un pointeur intelligent standard : std::auto_ptr, défini dans le fichier <memory>. Il s'agit donc d'une classe template qui prend en paramètre le type traité. Le constructeur de std::auto_ptr prend un pointeur "nu" en paramètre qui pointera sur une zone que l'on aura pris soin d'allouer. Généralement, on retrouve donc l'allocation dynamique directement en tant qu'expression dans les paramètres du constructeur lors de la déclaration du pointeur intelligent.
std::auto_ptr considère qu'il manipule une zone allouée avec l'opérateur new : ne vous permettez donc pas de lui envoyer une zone allouée avec new[] car il tentera tôt ou tard d'y appliquer l'opérateur delete. Il y a d'autres pointeurs intelligents qui sont faits pour cela, en particulier un que je mentionnerai plus loin. Parce que l'allocation à proprement parler n'est pas gérée par la classe, cette dernière n'est pas susceptible de lancer une exception (une exception std::bad_alloc ou encore une exception lancée depuis le constructeur de l'objet alloué dynamiquement aurait été possible sinon).
std::auto_ptr surcharge les opérateurs classiques que l'on peut appliquer aux pointeurs nus : operator*, operator->, operator=, etc. mais aussi la conversion vers d'autres types de pointeurs. La sémantique d'un vrai pointeur est donc gardée sauf qu'on a ajouté de l' "intelligence". Exemple sans intérêt :
Code : C++ | #include <memory>
// du code...
std::auto_ptr < std::string > ptr(new std::string("hello"));
std::cout << *ptr << std::endl; // affiche hello
std::cout << ptr -> at(1) << std::endl; // affiche e
*ptr += " world !";
// du code...
|
Et on n'écrira aucune libération de mémoire, c'est entièrement l'objet ptr qui s'en occupera à sa destruction. Tout est bien beau, mais quel est alors le problème de std::auto_ptr que j'ai mentionné plus haut ? Pour le comprendre, il faut comprendre la stratégie de std::auto_ptr pour gérer les copies. En effet, la plupart des problèmes que l'on rencontre avec les pointeurs nus existent parce qu'il y a eu copie de pointeur. C'est donc principalement au niveau des copies de pointeurs intelligents qu'il faut ajouter justement de l' "intelligence". Par exemple, si l'on part du principe que la libération de mémoire est faite à coup sûr dans le destructeur, est-il seulement raisonnable de laisser la possibilité de copier un pointeur intelligent ? Si l'on est aussi restrictif, on garde beaucoup de problèmes qu'on souhaiterait pourtant régler.
Plusieurs approches sont possibles pour résoudre le problème, mais l'idée de base c'est d'adopter un certain point de vue sur les copies. std::auto_ptr considère que chaque pointeur intelligent est un propriétaire unique d'une zone allouée dynamiquement. Ce type considère les copies simplement comme un changement de propriétaire. On ne libère la mémoire dans le destructeur que si l'objet en question est propriétaire de quelque chose, ce qu'il n'est donc plus forcément (ou n'a jamais été si le pointeur n'a jamais été initialisé). Le pointeur copié est donc en quelque sort "destitué" de son titre de propriétaire et ne libèrera rien, ça sera à la copie de s'en charger.
Le problème de std::auto_ptr est donc là : on est certain que la mémoire allouée sera libérée tôt ou tard, mais peut-être ... trop tôt. Prenez par exemple le code suivant :
Code : C++ | std::auto_ptr < int > ptr(new int(42));
f(ptr);
*ptr = 36;
|
f() est une fonction qui prend en paramètre un pointeur intelligent std::auto_ptr. En supposant qu'il n'y a pas passage par référence, il y aura une copie lors de l'envoi de ptr à cette fonction. Autrement dit, c'est le pointeur intelligent que l'on retrouvera dans f() qui sera le propriétaire de l'entier alloué à la ligne 1. Conséquence : ptr n'en est plus le propriétaire et n'y a donc plus droit d'accès. La ligne 3 n'a donc aucun sens, d'autant plus qu'au moment où l'on exécute cette instruction, le pointeur intelligent propriétaire interne à f() aura déjà libéré l'entier. Si l'on est pas à l'aise avec l'approche de std::auto_ptr, il vaut donc mieux éviter de l'utiliser pour ne pas tomber dans de pièges subtils de ce genre.
Deuxième exemple : boost::shared_ptr
Comment résoudre ce problème de copie et de propriétaire unique ? Une astuce possible serait d'autoriser les propriétaires multiples et à la fois de garantir qu'aucun de ces propriétaires ne sera invalidés mais que la zone allouée soit tout de même libérée à coup sûr.
L'idée est de trouver une autre approche sur la copie de pointeur intelligent : déjà on ne parlera plus de propriétaire mais de référence sur une zone allouée, mais en plus une copie ne sera plus un changement de propriétaire mais la création d'une nouvelle référence sur la zone allouée. Par opposition, la destruction d'un pointeur intelligent est la suppression d'une référence sur cette zone. Cette approche est très simple et pourtant très puissante : en gardant trace quelque part du nombre de références sur chaque zone allouée dynamiquement, on peut aisément déterminer quand libérer la mémoire sans qu'il y ait d'éventuelles retombées plus tard.
Concrètement, on teste dans le destructeur d'un pointeur intelligent s'il est le dernier objet à pointer sur la zone qu'il réfère. Si oui, il peut tranquillement la libérer (et même doit, car normalement personne ne pourra le faire après lui), sinon, il se contente de décrémenter le nombre de références sur cette zone, puisque lui sera alors en moins.
Ingénieux ? Oui, très. Avec cette approche, on résout non seulement tous les problèmes des pointeurs "nus" mais également le principal problème que l'on reproche à std::auto_ptr. C'est en fait un principe répandu et très connu appelé de manière transparente : "comptage de référence". Ce principe possède néanmoins le défaut de devoir stocker en plus de l'objet RAII le nombre de références sur chaque zone allouée dynamiquement et contrôlée par un tel système.
Cette fois-ci, c'est Boost qui nous propose une classe pour gérer un tel pointeur intelligent : boost::shared_ptr ("pointeur partagé"). En réalité, Boost propose plusieurs pointeurs intelligents comme dit, chacun servant à un usage spécifique (boost::weak_ptr, boost::scoped_ptr, boost::unique_ptr, etc.). Cependant, boost::shared_ptr est celui que l'on peut utiliser le plus généralement. Pour ce faire, il faut déjà commencer par inclure le fichier boost/shared_ptr.hpp ou boost/smart_ptr/shared_ptr.hpp. Pour ce qui est du reste, un pointeur intelligent de type boost::shared_ptr s'utilise avec la même sémantique qu'un objet std::auto_ptr.
Code : C++ | boost::shared_ptr < int > ptr(new int(42));
f(ptr);
*ptr = 36; // affectation cohérente et autorisée
|
Boost pense à tout. Non seulement c'est simple, mais en plus maintenant on peut dormir sur nos deux oreilles. Si l'on veut appliquer la même stratégie pour gérer de manière sécurisée des pointeurs sur des zones allouées avec new[], on peut par exemple envisager d'utiliser boost::shared_array. Cependant, ne vous précipitez pas : pour les tableaux dynamiques, on préfèrera si possible toujours std::vector ou plus spécifiquement std::string.