II - 3. Les pointeurs en C++
A. Le type "pointeur" en C++
a. Qu'est ce qu'est un pointeur ?
Pour comprendre les pointeurs, nous allons avant déchiqueter un autre type que nous avons déjà vu : la référence. Une référence est un moyen d'accéder à une variable en copiant son adresse mémoire et en y accédant directement via elle. Pour rappel, nous en avons parler lors du cours sur les variables, juste ici. Toutes modification à la référence seront appliquées à la variable référencée (d'où le nom) via l'adresse mémoire. Il n'y aucun moyen de modifier ou d'utiliser l'adresse mémoire en elle même (sans toucher à la variable référencée), la rendant particulièrement stable, mais aussi assez limité. Donc, la variable référencée doit l'être à la création de la référence, et ne sera pas modifiable après. Par exemple, une utilité des références et de référencer en tant qu'attribut de classe une variable global hors de la classe, et de l'utiliser quand même via la référence. Dans ce cas, comme nous l'avons déjà vu, la référence doit être passée via le constructeur et initialisée dans la classe avec la technique :
La référence est, en fait, un type précis de pointeur. En effet, un pointeur est un moyen d'accéder à une variable en copiant son adresse mémoire et en y accédant directement via elle. Si vous êtes attentif, vous observerez que cette définition est exactement la même que celle de la référence. En effet, la seule différence entre le pointeur et la référence est que, avec le pointeur, on peut agir sur l'adresse mémoire en elle même (sans toucher à la variable référencée). En fait, le pointeur représente plus une manipulation de l'adresse mémoire, qu'une manipulation de la variable pointée (on va utiliser ce terme ici, plutôt que référencée), bien que les deux soit possibles. Une variable "pointeur" peut pointer vers une autre variable n'importe quel type, selon sa déclaration, qui se fait avec un "*", comme ça :
Si vous utilisez le pointeur directement, vous ne ferez rien sur la variable. Pour accéder à une simple référence vers la variable (et l'utiliser très simplement), vous devez rajouter "*" devant le nom du pointeur, comme ça :
b. Utiliser un pointeur
Modifier la variable pointée, c'est bien. Modifier le pointeur directement, c'est mieux. En effet, bien que cela soit impossible pour les références, on peut modifier plusieurs choses sur le pointeur sans altérer la variable pointée. Par exemple, on peut modifier la variable pointée dans le pointeur, en modifiant l'adresse dans le pointeur. Pour cela, c'est très simple :
On peut aussi utiliser des opérations arithmétiques sur des pointeurs. Cependant, cela n'est pas conseillé dans toutes les situations. Cependant, une situation sécurisée est assez simple permettant ces opérations est l'utilisation d'itérateurs. Nous avons déjà vu ce terme lors du cours sur les structures de données, juste ici. En fait, un itérateur est une sorte de pointeur utilisée dans une structure de donnée, qui pointe vers un élément de la structure. Chaque structures a son propre type d'itérateur, définit dans la classe de la structure en question (par exemple, pour vector, on doit y accéder avec std::vector<Type_Dans_Le_Vecteur>::iterator), avec les mêmes membres que le type "iterator". Cependant, leur utilisation pour les structures simples est complétèment inutile, assez complexe, et déconseillée (c'est comme utiliser les méthodes en moins bien). Il est plus intéressant de les utiliser dans des structures plus complexes, comme dans les map. Par exemple, les itérateurs de map ne pointent pas seulement vers une valeur, mais vers un objet (nommé une pair) contenant la clé et la valeur d'une itération du map à une certaine position (le premier élément ajouté est le premier itérateur, le deuxième est le deuxième itérateur...). C'est là que les opérations arithmétiques rentrent en jeu. Voici un petit exemple d'utilisation d'opérations sur les itérateurs :
Avec ces définitions des pointeurs, on peut donc modifier la variable pointée facilement. Cependant, comme nous l'avons vu lors du cours sur les variables, certaines variables peuvent être constantes, et ne sont pas modifiables. Il n'est pas possible d'utiliser un simple pointeur pour ces variables. Cependant, nous pouvons définir un pointeur qui pointe vers une variable constante, de cette façon :
c. Les dangers des pointeurs
Vous l'aurez peut être deviné, mais les pointeurs peuvent causer des bugs, voir des crashs dans votre programme. Les erreurs de pointeurs sont, en général, appelées les erreurs de segmentation (ou segmantation fault). L'erreur de segmentation la moins problèmatique est la modification d'une variable via un pointeur pointant vers 0 (donc, ne pointant vers rien). Cela provoque un simple crash, sans autre incidents inatendus. Un moyen d'éviter cette erreur est de rajouter une comparaison à 0 avant l'utilisation, et d'éviter l'utilisation si la valeur du pointeur est de 0 :
Cependant, vous pouvez faire pire avec ces pointeurs. En effet, même si le pointeur n'est pas vide, la variable pointée par le pointeur peut causer des problèmes. Le premier bug possible est d'accéder / modifier à la mauvaise variable. Dans ce cas, deux comportements sont possibles. Si l'adresse pointée est hors des adresses possibles dans le programme, dans les systèmes d'exploitation moderne, le programme plante. Dans les systèmes d'exploitation peu sécuriser, vous pouvez agir comme vous voulez sur cette adresse, bousillant la donnée s'y trouvant (pouvant être une image d'un autre programme, comme votre mot de passe pour le système). Dans l'autre cas, l'adresse pointée est dans les adresses accessibles au programme, bousillant toutes les variables du programme se trouvant là. Avec les syntaxes que nous avons vu, ce cas de figure peut arriver, par exemple, quand le pointeur pointe vers une variable, et que l'on utilise le pointeur hors de la zone de définition de cette variable (donc, quand la variable a été supprimée). Le pointeur pointe vers cette adresse lorsque qu'elle existait, or, après sa suppression, une autre variable peut remplacer la variable voulu, sans que le pointeur ne le sache. Faites donc bien attention à éviter ce genre d'erreur, surtout que le programme ne plante pas toujours avec ce genre d'erreurs.
Une autre erreur possible est une utilisation incontrôlée des types avec les pointeurs. En effet, des pointeurs vers différents types peuvent être interprétés de la même façon par le compiler, créant des erreurs de segmentation avec l'accès au variable. Cependant, pour qu'une erreur de ce genre arrive, il faut pouvoir convertir un pointeur pointant vers un type en un pointeur convertissant vers un autre type, ce qui n'est pas nécessaire pour l'instant. Le risque peut en valoir le coût dans certains cas, lorsque l'on veut stocker une adresse mémoire d'une variable, sans connaître son type précis. En général, on crée une classe parent, qui servira de type pour stocker les variables, qui sera la base de classes, utilisant la technique d'abstraction pour fonctionner de manière plus spécialisé. Cependant, quand ce n'est pas possible, il faut recourir à une conversion, pour utiliser la variable avec un type spécifique, via les pointeurs. Reprenons l'exemple des classes "Person" et "Developer", vu au cours sur les classes et objets. Imagineons une methode "std::string language()" pour "Developer", qui retourne le language utilisé par le développeur, mais inutilisable dans "Person". Réalisons une conversion :
B. Les mémoires "stack" et "heap"
a. Qu'est ce que sont les mémoires "stack" et "heap" ?
Le compiler est un outil particulièrement complexe, tellement qu'il nous permet de définir toutes les variables que l'on veut sans problème. Pour comprendre cette partie, nous allons le décortiquer, grâce à Compiler Explorer. Prenons un très simple code, comme :
Maintenant, imagineons que nous voulons rajouter des variables dans la mémoire, pendant l'exécution du programme (comme ajouter un élement à une liste). Avec cette définition de la mémoire stack, on ne peut pas rajouter cette variable dans la mémoire stack, car sa taille est fixe. Heuresement, il existe un autre type de mémoire, utilisable librement, sans contrainte de mémoire fixe. Pour cela, il faut accéder à une mémoire, nommée la mémoire "heap" (tas en français). Cependant, elle n'est pas dans la mémoire définie spécialement dans le programme, donc on ne pourra pas y accéder via "rbp". On ne pourra y accéder que via son adresse mémoire. Nous n'allons pas voir d'exemples en Assembleur, car il nous seront inutile ici.
b. Utiliser le heap en C++
Pour créer une variable dans le "heap" en C++, il faut utiliser un constructeur primaire du C++ : le constructeur "new", qu'il faut spécifier avant le type a créer. Ce constructeur retourne l'adresse mémoire de l'espace crée dans le "heap", stockable dans un simple pointeur. Voici un exemple d'utilisation :
Imagineons que nous voulons une énorme quantité de données stockée dans un simple tableau d'octets pour une fonction. Pour des raisons de sécurité, on va le déclarer dans le "heap", car la taille fixe du "stack" la rend vulnérable aux tableaux trops louds. Écrivons la fonction :
c. Utiliser le heap via les pointeurs partagés
Le meilleur moyen d'utiliser les variables dans le "heap" est d'utiliser ce que l'on appelle les pointeurs partagé, ou "shared pointers" en anglais. Les pointeurs partagés consistent en des pointeurs pouvant communiquer entre eux pour savoir quand et comment utiliser "delete" sur une variable, vous épargnant cette tâche. Pour les utiliser, il faut inclure dans votre programme C++ le fichier <memory>. La documentation du ficher est disponible juste ici. Nous allons nous intéresser au type std::shared_ptr, dans l'espace de nom "std", mais aussi un peu à std::unique_ptr et std::weak_ptr.
Comme les pointeurs classiques, un "shared_ptr" pointe vers un type en particulier, que l'on peut spécifier comme avec les vectors (entre chevrons). Voici comment définir un "shared_ptr" :
Comme pour les pointeurs, vous ne pouvez pas manipuler directement la variable pointée via le "shared_ptr". Pour accéder à un simple pointeur vers la variable, vous devez utiliser la méthode "get()". Vous pouvez aussi utiliser l'opérateur "->" directement sur lui, pour accéder à la variable directement. Pour changer l'adresse vers laquelle pointe le pointeur, vous devez utiliser la méthode "reset(nouvelle_adresse)" (si l'adresse ne vient pas d'un autre "shared_ptr"), "reset" sans paramètres (pour pointer vers 0) ou l'opérateur "=" vers un autre "shared_ptr" (l'adresse vient d'un autre "shared_ptr", pour les synchroniser). Vous pouvez même avoir le nombre d'autres "shared_ptr" pointant vers la même variable, grâce à la méthode "use_count()". Voici quelques exemples d'utilisation :
Pour finir, on va jeter un petit coup d'oeil aux pointeurs "weak_ptr" et "unique_ptr". Un "weak_ptr" est un "shared_ptr", qui ne compte pas dans la détection de "shared_ptr", lors de la suppression de l'un d'eux. Donc, il ne permet pas de garder une variable dans le temps (la variable peut être supprimer même si lui ne l'est pas), bien qu'il soit informé de sa destruction (grâce à la méthode "expired()"). À l'inverse, un "unique_ptr" est un "shared_ptr", qui n'est pas possible de copier (et donc, qui n'est pas partagé), malgré la suppression de la variable en même temps que sa suppression à lui. Pour en créer un, il faut utiliser la fonction "make_unique<type>()", et pas "make_shared<type>()".