Introduction aux pointeurs en C++ - Déclaration et interêts
Chaque variable de votre programme est située quelque part dans la mémoire. Elles ont donc toutes une adresse unique qui identifie l'endroit où elles sont stockées. Ces adresses dépendent de l'endroit où votre programme est chargé en mémoire lorsque vous l'exécutez, elles peuvent donc varier d'une exécution à l'autre. Un pointeur est une variable qui peut stocker l'adresse d'une autre variable, d'un élément de données situé ailleurs en mémoire.
Comme vous le savez, un entier a une représentation différente d'une valeur à virgule flottante, et le nombre d'octets occupés par une donnée dépend de sa nature. Ainsi, pour utiliser une donnée stockée à l'adresse contenue dans un pointeur, il faut connaître le type de cette donnée. Si un pointeur n'était rien d'autre que l'adresse d'une donnée arbitraire, il ne serait pas si intéressant. Sans connaître le type de données, un pointeur n'est pas d'une grande utilité. Chaque pointeur pointe donc vers un type particulier de données à cette adresse.
Création de pointeurs
Les pointeurs sont déclarés comme n'importe quelle autre variable, sauf qu'un astérisque (*) est placé entre le type de données et le nom du pointeur. Le type de données utilisé détermine le type de mémoire vers lequel il pointera. Il est possible de créer plus d'un pointeur dans la même instruction en utilisant l'opérateur virgule. L'astérisque doit alors être placé avant chaque identifiant et non après le type. Vous pouvez définir des pointeurs vers n'importe quel type, y compris les types que vous définissez.
Syntaxe
int* a {}; // pointeur sur un entier int *a {}; // même chose int *a {}, *b {}, *c {}; // plusieurs pointeurs
Comme l'initialisateur ({}) est vide, l'instruction initialise les variables pointeur (a, b et c) avec l'équivalent pointeur de zéro, qui est une adresse spéciale qui ne pointe vers rien. Cette valeur de pointeur spéciale s'écrit nullptr, et vous pouvez la spécifier explicitement comme valeur initiale :
Vous n'êtes pas obligé d'initialiser un pointeur lorsque vous le définissez, mais il est imprudent de ne pas le faire. Les pointeurs non initialisés sont plus dangereux que les variables ordinaires qui ne sont pas initialisées. En règle générale, vous devriez toujours initialiser un pointeur lorsque vous le définissez. Si vous ne pouvez pas encore lui donner sa valeur prévue, initialisez le pointeur à nullptr.
Exemple 1
int* a {nullptr}
Quel que soit le type ou la taille des données auxquelles un pointeur fait référence, la taille de la variable pointeur elle-même sera toujours la même. Pour être précis, toutes les variables pointeurs d'une plate-forme donnée auront la même taille. La taille des variables de type pointeur dépend uniquement de la quantité de mémoire adressable de votre plate-forme cible. Pour savoir quelle est cette taille, vous pouvez exécuter ce petit programme :
Exemple 2
#include <iostream> using namespace std; int main(void){ cout << sizeof(double) << " > " << sizeof(char16_t) << std::endl; cout << sizeof(double*) << " == " << sizeof(char16_t*) << std::endl; return 0; }
8 > 2 8 == 8
L'opérateur d'adressage
L'opérateur d'adressage, &, est un opérateur unaire qui permet d'obtenir l'adresse d'une variable. Vous pourriez définir une variable, nombre, et un pointeur, pnombre, initialisé avec l'adresse de nombre avec ces instructions :
Exemple 3
long nombre {12345L}; long* pnombre {&nombre};
&nomber produit l'adresse de nombre, donc pnombre a cette adresse comme valeur initiale. pnombre peut stocker l'adresse de toute variable de type long, donc vous pouvez écrire l'affectation suivante :
Exemple 4
long note {1454L}; long* pnombre {& note};
L'opérateur & peut être appliqué à une variable de n'importe quel type, mais vous ne pouvez stocker l'adresse que dans un pointeur du type approprié. Si vous voulez stocker l'adresse d'une variable double, par exemple, le pointeur doit avoir été déclaré comme type double*, qui est "pointeur vers double".
Naturellement, vous pouvez aussi demander au compilateur de déduire le type pour vous en utilisant le mot-clé auto :
Exemple 5
auto pnombre {&nombre};
Nous vous recommandons d'utiliser auto* ici pour qu'il soit clair dès la déclaration qu'il s'agit d'un pointeur. En utilisant auto*, vous définissez une variable d'un type de pointeur déduit par le compilateur :
Exemple 6
auto* pnombre {&nombre};
Une variable déclarée avec auto* ne peut être initialisée qu'avec une valeur de type pointeur. L'initialiser avec une valeur de tout autre type entraînera une erreur de compilation.
Prendre l'adresse d'une variable et la stocker dans un pointeur, c'est très bien, mais ce qui est vraiment intéressant, c'est la façon dont vous pouvez l'utiliser. Il est fondamental d'accéder aux données de l'emplacement mémoire vers lequel pointe le pointeur, ce que l'on fait à l'aide de l'opérateur d'indirection.
L'opérateur d'indirection (déréférencement)
L'application de l'opérateur d'indirection, *, à un pointeur permet d'accéder au contenu de l'emplacement mémoire vers lequel il pointe. Le nom d'opérateur d'indirection vient du fait que l'on accède aux données "indirectement". L'opérateur d'indirection est également souvent appelé opérateur de déréférencement, et le processus d'accès aux données de l'emplacement mémoire pointé par un pointeur est appelé déréférencement du pointeur. Pour accéder aux données à l'adresse contenue dans le pointeur pnombre, vous utilisez l'expression *pnombre.
Exemple 7
#include <iostream> using namespace std; int main(void){ int nombre {10}; int* pnombre = &nombre; cout << "Adresse de nombre : " << pnombre << endl; cout << "Valeur de nombre : " << *pnombre << endl; return 0; }
Adresse de nombre : 0x16f99f398 Valeur de nombre : 10
Cet exemple démontre que l'utilisation d'un pointeur déréférencé est identique à l'utilisation de la variable vers laquelle il pointe. Vous pouvez utiliser un pointeur déréférencé dans une expression de la même manière que la variable d'origine.
Pointer vers un pointeur
Parfois, il peut être utile d'avoir un pointeur qui peut pointer vers un autre pointeur. Pour ce faire, il suffit de déclarer un pointeur avec deux astérisques, puis de lui attribuer l'adresse du pointeur qu'il va référencer. De cette façon, lorsque l'adresse stockée dans le premier pointeur change, le deuxième pointeur peut suivre ce changement.
Syntaxe
int** r = &pnombre; // pointeur vers p (attribue l'adresse de p)
La référence au second pointeur donne maintenant l'adresse du premier pointeur. Le déréférencement du second pointeur donne l'adresse de la variable, et le déréférencer à nouveau donne la valeur de la variable.
Exemple 8
#include <iostream> using namespace std; int main(void){ int nombre {10}; int* pnombre = &nombre; int** r = &pnombre; cout << "Adresse de pnombre : " << r << endl; cout << "Adresse de nombre : " << *r << endl; cout << "Valeur de nombre : " << **r << endl; return 0; }
Adresse de pnombre : 0x16d6c7390 Adresse de nombre : 0x16d6c7398 Valeur de nombre : 10
Pourquoi utiliser des pointeurs ?
Une question qui vient généralement à l'esprit à ce stade est "Pourquoi utiliser des pointeurs ?". Après tout, prendre l'adresse d'une variable que vous connaissez déjà et la coller dans un pointeur afin de pouvoir la déréférencer plus tard semble être une surcharge dont vous pouvez vous passer. Il y a plusieurs raisons pour lesquelles les pointeurs sont importants :
- Allouer de la mémoire pour de nouvelles variables de manière dynamique, c'est-à-dire pendant l'exécution du programme. Cela permet à un programme d'adapter son utilisation de la mémoire en fonction de l'entrée. Vous pouvez créer de nouvelles variables pendant l'exécution de votre programme, au fur et à mesure de vos besoins. Lorsque vous allouez une nouvelle mémoire, celle-ci est identifiée par son adresse, vous avez donc besoin d'un pointeur pour l'enregistrer.
- Vous pouvez également utiliser la notation pointeur pour opérer sur des données stockées dans un tableau. Cette notation est totalement équivalente à la notation classique des tableaux, ce qui vous permet de choisir la notation la mieux adaptée à l'occasion. En général, comme son nom l'indique, la notation des tableaux est plus pratique lorsqu'il s'agit de manipuler des tableaux, mais la notation des pointeurs a également ses mérites.
- Lorsque vous définissez vos propres fonctions, les pointeurs sont largement utilisés pour permettre à une fonction d'accéder à de grands blocs de données qui sont définis en dehors de la fonction.
- Les pointeurs sont fondamentaux pour permettre au polymorphisme de fonctionner. Le polymorphisme est peut-être la capacité la plus importante fournie par l'approche orientée objet de la programmation.
Allocation dynamique
L'une des principales utilisations des pointeurs est l'allocation de la mémoire pendant l'exécution, appelée allocation dynamique. Dans les exemples précédents, les programmes ne disposaient que de la quantité de mémoire déclarée pour les variables au moment de la compilation. C'est ce que l'on appelle l'allocation statique, et ces variables sont stockées sur la pile. Si de la mémoire supplémentaire est nécessaire au moment de l'exécution, il faut utiliser l'opérateur new. Cet opérateur permet d'allouer dynamiquement de la mémoire, à laquelle on ne peut accéder que par des pointeurs et qui est stockée sur la pile. L'opérateur new prend comme argument soit un type de données primitif, soit un type d'objet, et renvoie un pointeur vers la mémoire allouée, à condition que la mémoire disponible soit suffisante.
Syntaxe
int* pnombre = new int; // allocation dynamique
Une chose importante à savoir sur l'allocation dynamique est que la mémoire allouée ne sera pas libérée comme le reste de la mémoire du programme lorsqu'elle ne sera plus nécessaire. Au lieu de cela, elle doit être libérée manuellement avec le mot-clé delete. Cela vous permet de contrôler la durée de vie d'un objet alloué dynamiquement, mais cela signifie également que vous êtes responsable de sa suppression lorsqu'il n'est plus nécessaire. Si vous oubliez de supprimer la mémoire qui a été allouée avec le mot-clé new, le programme aura des fuites de mémoire, car cette mémoire restera allouée jusqu'à ce que le programme s'arrête.
Syntaxe
delete pnombre; // libérer la mémoire allouée
Cela garantit que la mémoire pourra être utilisée ultérieurement par une autre variable. Si vous n'utilisez pas delete et que vous stockez une adresse différente dans pnombre, il sera impossible de libérer la mémoire d'origine car l'accès à l'adresse aura été perdu. La mémoire sera conservée pour être utilisée par votre programme jusqu'à la fin de celui-ci. Bien entendu, vous ne pourrez pas l'utiliser car vous n'aurez plus l'adresse. Notez que l'opérateur delete libère la mémoire mais ne modifie pas le pointeur. Après l'exécution de l'instruction précédente, pnombre contient toujours l'adresse de la mémoire qui a été allouée, mais la mémoire est maintenant libre et peut être allouée immédiatement à autre chose. Un pointeur qui contient une telle adresse erronée est parfois appelé un pointeur suspendu. Le déréférencement d'un pointeur suspendu peut créer un désastre, donc vous devriez prendre l'habitude de toujours réinitialiser un pointeur lorsque vous libérez la mémoire vers laquelle il pointe, comme ceci :
Syntaxe
delete pnombre; // libérer la mémoire allouée pnombre = nullptr; // Réinitialiser le pointeur
Maintenant, pnombre ne pointe plus vers rien. Le pointeur ne peut pas être utilisé pour accéder à la mémoire qui a été libérée. L'utilisation d'un pointeur contenant nullptr pour stocker ou récupérer des données mettra immédiatement fin au programme, ce qui est préférable au programme qui fonctionne de manière imprévisible avec des données non valides.