Un pointeur est une variable contenant l'adresse d'une autre variable d'un type donné.
Il s'agit d'une technique de programmation très puissante, permettant de définir des structures dynamiques, c'est-à-dire qui évolue au cours du temps (par opposition aux tableaux par exemple qui sont des structures de données statiques, dont la taille est figée à la définition).
Comme nous l'avons vu, un pointeur est une variable qui permet de stocker une adresse, il est donc nécessaire de comprendre ce qu'est une adresse.
Lorsque l'on exécute un programme, celui-ci est stocké en mémoire, cela signifie que d'une part le code à exécuter est stocké, mais aussi que chaque variable que l'on a défini à une zone de mémoire qui lui est réservée, et la taille de cette zone correspond au type de variable que l'on a déclaré. En réalité la mémoire est constituée de plein de petites cases de 8 bits (un octet). Une variable, selon son type (donc sa taille), va ainsi occuper une ou plusieurs de ces cases (une variable de type char occupera une seule case, tandis qu'une variable de type long occupera 4 cases consécutives).
Chacune de ces « cases » (appelées blocs) est identifiée par un numéro. Ce numéro s'appelle adresse.
On peut donc accéder à une variable de 2 façons :
- grâce à son nom
- grâce à l'adresse du premier bloc alloué à la variable
Il suffit donc de stocker l'adresse de la variable dans un pointeur (il est prévu pour cela) afin de pouvoir accéder à celle-ci (on dit que l'on « pointe vers la variable »).
Le schéma ci-dessus montre par exemple par quel mécanisme il est possible de faire pointer une variable (de type pointeur) vers une autre. Ici le pointeur stocké à l'adresse 24 pointe vers une variable stockée à l'adresse 253 (les valeurs sont bien évidemment arbitraires).
struct A{
int _a;
A(int __a){_a = __a;}
}
{
// pointeur sur un objet de type A
// le new créé l'objet en question
A* a = new A(1)
// acces aux éléments de A
std::cout << a->_a << std::endl; // affiche '1'
// en fin de $, le pointeur est détruit mais pas l'objet
// il faut donc le détruire également
delete a;
}Les pointeurs ont un grand nombre d'intérêts, comme par exemple :
- Ils permettent de manipuler de façon simple des données pouvant être importantes (au lieu de passer à une fonction un élément très grand (en taille) on pourra par exemple lui fournir un pointeur vers cet élément...)
- Les tableaux ne permettent de stocker qu'un nombre fixé d'éléments de même type. En stockant des pointeurs dans les cases d'un tableau, il sera possible de stocker des éléments de taille diverse, et même de rajouter des éléments au tableau en cours d'utilisation (c'est la notion de tableau dynamique qui est très étroitement liée à celle de pointeur)
Toute mémoire allouée dans vos programmes avec new doit être libérée à un moment ou un autre avec delete. Cette bonne règle de programmation peut vite devenir contraignante et parfois difficile à mettre en œuvre dans la pratique.
Exemple de code pouvant poser problème :
#include <stdio.h> /* printf */
#include <assert.h> /* assert */
void Test1()
{
int * ptr = new int;
// ... du code
// renvoi toujours une exception
assert(false);
// ... du code
// tout s'est bien passé, libérer la mémoire
// mais dans notre cas on ne passe pas par là
// donc le pointeur n'est pas déférencé
delete ptr;
}Dans ce cas, on obtient une fuite mémoire car le pointeur n'est pas désaloué.
Pour solutionner le problème, il faudrait faire ce code :
// Test1 est exception safe sans utilisation de pointeur intelligent
void Test1()
{
int * ptr = new int;
try
{
// ... du code
assert(false); // renvoi toujours une exception
// ... du code
}
catch ( ... ) // gestion approximative...
{
// libérer le pointeur
delete ptr;
// relancer l'exception
throw;
}
// tout s'est bien passé, libérer la mémoire
delete ptr;
}Comme on peut le voir, cette gestion des exceptions est assez polémique, et surtout elle est totalement inélégante. Pensez qu'il faudrait procéder ainsi partout où il y a un new ! De plus, cette gestion peut rapidement être compliquée avec la complexité du code.
C'est pourquoi le C++ (depuis la version C++03) propose des pointeurs "intelligent" (smart pointers en anglais).
Les pointeurs intelligents sont des objets se comportant comme des pointeurs classiques (mimétisme dans la syntaxe et certaines sémantiques), mais qui offrent en plus des fonctionnalités intéressantes permettant une gestion quasi automatique de la mémoire (en particulier de sa libération). Leur syntaxe est très proche de celle des pointeurs classiques (grâce à la surcharge des opérateurs *, ->, etc.), mais ils utilisent en interne divers mécanismes (comptage de références) qui permettent de déceler qu'un objet n'est plus utilisé, auquel cas le pointeur intelligent se charge de le détruire ce qui permet d'éviter les fuites de mémoire.
Utiliser des pointeurs intelligents est généralement une très bonne idée, en particulier lors de l'écriture de code susceptible d'être interrompu par des exceptions (soit presque tout le temps !). Si tel est le cas, ceux-ci ne manqueront pas de libérer la mémoire qui leur est associée lors de leur destruction (suite à une exception ou non), ce qu'il n'est pas possible d'assurer sans multiplier les blocs try...catch dans son code.
Les pointeurs intelligent de la stl :
- std::auto_ptr<> (C++03 - deprécié depuis C++11)
- std::unique_ptr<> (C++11)
- std::shared_ptr<> (C++11)
- std::weak_ptr<> (C++11)
A noter que ce sont tous des templates.
Exemple de code avec un pointeur intelligent :
#include <memory> // pour std::unique_ptr
// Test2 est exception safe en utilisant un pointeur intelligent
void Test2()
{
// std::unique_ptr est la classe pointeur intelligent standard
std::unique_ptr<int> smart_ptr1 (new int());
// autre façon de construire le pointeur (depuis C++11)
std::unique_ptr<int> smart_ptr2 = std::make_unique<int>();
// code pouvant lever une exception (rien d'autre !)
// pas de problème de fuite mémoire
}La nouvelle version est tout aussi sûre, mais elle est bien plus triviale à mettre en place.
Cependant attention, les pointeurs intelligents n'échappent pas à la règle que ce qui a été alloué avec new doit être libéré avec delete et ce qui a été alloué avec new [] doit l'être avec delete []. L'intérêt des pointeurs intelligent provient de l'appel du delete du pointeur stocké par l'objet lors de sa destruction.
Il s'agit d'un pointeur intelligent apparu en C++03 et le premier proposé par la stl.
Exemple d'utilisation de l'auto_ptr<> :
#include <memory>
{
std::auto_ptr < std::string > ptr(new std::string("hello")); // construction sur un pointeur
std::cout << *ptr << std::endl; // affiche hello
std::cout << ptr->at(1) << std::endl; // affiche e
*ptr += " world !";
std::cout << *ptr << std::endl; // affiche hello world
std::cout << ptr.get() << std::endl; // renvoie l'addresse de l'objet pointé
ptr.release(); // relache le pointeur ( ptr.get() renvoie nullptr après )
} // fin de {}, pas de problème, l'auto_ptr gère la destruction
Mais ce pointeur contient une faille ! C'est pourquoi il est aujourd'hui déprécié.
Contrairement à beaucoup de pointeurs intelligents, std::auto_ptr n'utilise pas un mécanisme de comptage de références afin de partager un même objet pointé par plusieurs pointeurs intelligents (l'objet est détruit lorsque plus aucun pointeur ne l'utilise). std::auto_ptr utilise un mécanisme plus simple, mais plus risqué : il n'y a qu'un seul propriétaire de l'objet pointé et ce dernier est détruit lorsque son propriétaire l'est. Si une copie est effectuée d'un auto_ptr vers un autre, il y a transfert de propriété, c'est-à-dire que la possession de l'objet pointé sera transmise du premier au second, et le premier deviendra alors un pointeur invalide (NULL).
faille de l'auto_ptr<> :
include <memory>
int Test()
{
// ptr1 est le propriétaire
std::auto_ptr<int> ptr1( new int );
{
// ptr2 devient le propriétaire
std::auto_ptr<int> ptr2 = ptr1;
} // ici ptr2 est détruit, le int est libéré !
// ptr1 pointe désormais vers nullptr
*ptr1 = 10; // bim ! seg fault...
}
Simple à éviter. Pas si sur... et surtout potentiellement à votre insu :
include <memory>
// Ptr est passé par valeur, donc a priori aucun risque que le
// paramètre donné lors de l'appel ne soit modifié
void Test( std::auto_ptr<MaClasse> Ptr )
{
// Mais ce qui va réellement se passer, c'est que Ptr va prendre
// le contrôle de l'objet pointé, et le détruire à la fin de la fonction !
}
MaClasse* Ptr1 = new MaClasse;
Test( std::auto_ptr<MaClasse>( Ptr1 ) );
// Ici Ptr1 ne pointe plus sur une zone valide, il a été détruit suite à l'appel
std::auto_ptr<MaClasse> Ptr2( new MaClasse );
Test( Ptr2 );
// Ici Ptr2 ne pointe plus sur la donnée originale
// il pointe sur NULL et la donnée a été détruite dans la fonction
Ce pointeur a le même fonctionnement que auto_ptr, mais corrigé de sa faille de sécurité. Cela en interdisant la duplication (pas de copie possible). Le pointeur est donc unique (d'ou son nom) !
std::unique_ptr<A> a1 = std::make_unique<A>();
std::unique_ptr<A> a2; // basé sur nullptr
a2 = a1; //erreur de compilation car copie impossible
std::unique_ptr<A> a3 (a2); //erreur de compilation car copie impossible
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.
// 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; // renvoie shared
std::cout << "sharedptr1 address = " << sharedptr1.get() << std::endl; // renvoie l'adresse de l'objet
std::cout << "counter = " << sharedptr1.use_count() << std::endl; // renvoie 1
{
// 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; // renvoie shared
std::cout << "sharedptr2 address = " << sharedptr2.get() << std::endl; // renvoie l'adresse de l'objet
std::cout << "counter = " << sharedptr2.use_count() << std::endl; // renvoie 2
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; // renvoie 1
// destruction de sharedptr1 : la memoire est desallouee car le dernier shared ptr a ete detruit
Comme 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é.
Ces pointeurs ont un intérêt si l'ont souhaite les partager entre plusieurs objets. Ils sont néanmoins un peu moins performent car nécessitent un peu plus de mémoire.
Le std::weak_ptr est un pointeur intelligent qui détient une référence non-propriétaire («faible») à un objet qui est géré par std::shared_ptr. En d'autre termes, std::weak_ptr permet de transmettre la mémoire allouée à un std::shared_ptr sans que "celui qui le reçoit" ne doive forcément en réclamer la propriété. Le compteur de std::shared_ptr ne sera donc pas modifié.
Cela veut dire que je peux :
- transmettre un std::shared_ptr sous la forme d'un std::weak_ptr
- faire en sorte que tous les copropriétaires du pointeur renoncent à leur propriété (ce qui provoque la libération de la mémoire allouée au pointeur) et
- essayer d'imposer un nouveau propriétaire à partir du std::weak_ptr que j'ai obtenu
Evidemment, dans une telle situation, je risque de me retrouver avec un pointeur qui pointe vers une adresse invalide. Par chance, std::weak_ptr est tout à fait capable de dire si la mémoire vers laquelle il pointe est encore valide de deux manières différentes :
- je peux savoir si la mémoire allouée existe encore au travers de la fonction expired(), qui renvoie false si elle est toujours valide et true si elle a été libérée
- je peux connaître le nombre actuel des propriétaires du shared_ptr d'origine au travers de la fonction use_count.
Il doit être converti en std::shared_ptr afin d'accéder à l'objet référencé.
Un example d'utilisation :
#include <iostream>
#include <memory>
std::weak_ptr<int> gw; //declaration d'un weak_ptr (vide)
void f()
{
if (!gw.expired()) {
std::cout << "gw is valid\n";
}
else {
std::cout << "gw is expired\n";
}
}
int main()
{
{
auto sp = std::make_shared<int>(42);
gw = sp;
f(); // affiche "gw is valid"
}
f(); // affiche "gw is expired"
}
Le premier appel de f() affichera "gw is valid", car le propriétaire légal du std::shared_ptr associé à gw est le bloc d'instruction de la fonction main. Les règles de portées font que sp est détruit lorsque l'on arrive à l'accolade fermante de ce bloc d'instruction. Si bien que gw pointe désormais vers une adresse invalide. Et c'est la raison pour laquelle le deuxième appel à f() va indiquer "gw is expired".
Ce pointeur est plus rarement utilisé.
