Logo de SAASF

II - 3. Les pointeurs en C++

Parmis tous les types fondamentaux en C++, il ne nous en reste plus qu'un à étudier en profondeur : les pointeurs. Ce type est un cauchemar pour les étudiants en informatique, car il est l'un des plus difficile à utiliser dans sa totalité.
Contenu

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 :

Constructeur(type variable_reference): reference(variable_reference) {}

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 :

int age = 50;
int* pointeur_vers_age = &age;
int *pointeur_vers_age = &age; // La position du "*" n'importe pas ici
Cependant, avec les références, le compiler pouvait aller chercher l'adresse lui même. Là, il faut lui attribuer l'adresse de la variable vous même. Pour accéder à l'adresse d'une variable, il faut rajouter un "&" devant. Vous pouvez connaître cette adresse grâce à "std::cout" (la valeur sera affiché en héxadécimal) :
int age = 50;
int* pointeur_vers_age = &age;
std::cout << "Adresse de la variable : " << pointeur_vers_age << std::endl;
Il est à noter qu'un pointeur, quel qu'il soit, occupe 8 octets dans la mémoire.

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 :

int age = 50;
int* pointeur_vers_age = &age;
std::cout << "Adresse de la variable : " << pointeur_vers_age << ", valeur dans la variable : " << *pointeur_vers_age << std::endl;

*pointeur_vers_age = 75;
std::cout << "Nouvelle valeur de la variable : " << *pointeur_vers_age << std::endl;
Si vous pointez vers une instance de classe (comme la classe "Developer" vu au dernier cours), vous pouvez accéder aux méthodes via le pointeur avec l'aide d'un opérateur : "->" (vous avez juste à remplacer l'opérateur "." par celui ci) :
Developer dev = Developer(35, "Javascript");
Developer* pointeur_dev = &dev;
std::cout << pointeur_dev->job() << std::endl;
std::cout << (*pointeur_dev).job() << std::endl; // Fonctionne aussi (mais presque jamais utilisé)

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 :

int age = 50;
int taille = 180;

int* pointeur_vers_age = &age;
std::cout << "Adresse de la variable : " << pointeur_vers_age << ", valeur de la variable : " << *pointeur_vers_age << std::endl;
// Affiche une adresse et 50

pointeur_vers_age = &taille;
std::cout << "Adresse de la variable : " << pointeur_vers_age << ", valeur de la variable : " << *pointeur_vers_age << std::endl;
// Affiche une autre adresse (pas très loin de la première) et 180
D'ailleurs, une valeur générale pour les pointeurs est la valeur "0". Un pointeur de valeur 0 est un pointeur ne pointant vers rien. Par exemple, si vous créez un pointeur qui pointera vers une variable après sa définition, sa valeur doit être de 0 entre la définition du pointeur et l'attribution de la bonne valeur. TOUJOURS assigner 0 aux pointeurs ne pointant vers rien, car, si vous ne le faites pas, des erreurs très problématiques peuvent apparaître.

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 :

// On crée un map, et on le rempli un peu
std::map<std::string, int> age;
age["Lucas"] = 14;
age["Corentin"] = 27;
age["Thierry"] = 56;

// On accéde à tous les itérateurs du map en incrémentant un itérateur, et on affiche leur contenu
for(std::map<std::string, int>::iterator it = age.begin();it!=age.end();it++) {
        //Dans un pair de map, "first" représente la clé et "second" la valeur
        std::cout << it->first << " : " << it->second << std::endl;
}
En effet, on peut le deviner, mais "age.begin()" représente le premier itérateur, "age.begin() + 1" le deuxième, "age.begin() + 2" le troisième... Effectuer une addition avec un pointeur permet d'accéder à l'adresse mémoire juste après celle dans le pointeur. Plus précisément, si le premier pointeur pointe vers un objet de taille X, son addition amène à l'objet X octets après celui là. Donc, dans le cas des itérateurs utilisés dans les structures de données, les itérateurs sont structurés de telle manière que l'addition d'un itérateur amène au prochain itérateur de la structure. Si vous trouvez le fonctionnement interne des itérateurs complexe, ne vous inquiétez pas, cela ne nous servira à rien pour l'instant. Seul l'utilisation externe nous intéresse ici.

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 :

// On crée une variable constante et un pointeur vers cette variable en ajoutant "const" avant
const int taille = 180;
const int* pointeur_vers_taille = &taille;
Vous pouvez aussi définir un pointeur comme ça, même si la variable pointée n'est pas constante (vous ne pourrez pas la modifier avec le pointeur). Avec cette méthode, nous pouvons toujours modifier l'adresse dans le pointeur comme nous l'avons vu (seul la valeur à l'adresse dans le pointeur n'est pas modifiable). Si vous voulez rendre les modifications de la variable pointée possible, mais les modifications de l'adresse contenu imposse, il faut changer le "const" de place :
// On crée une variable et un pointeur vers cette variable qui ne pointera jamais autre part que la variable
int taille = 180;
int* const pointeur_vers_taille = &taille;

// Vous pouvez aussi combiner les deux (adresse constante et variable pointée constante)
const int* const pointeur_constant_vers_taille = &taille;

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 :

int* pointeur_vers_taille = 0;
if(pointeur_vers_taille == 0) {
        std::cout << "Modification impossible" << std::endl;
}
else {
        *pointeur_vers_taille = 170;
}

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 :

// Création d'un développeur
Person dev = Developer(34, "C"); // Possible selon le principe de polymorphisme, vu au derniers cours
Person* person = &dev;

// On réinterprète le pointeur "Person" vers un pointeur de "Developer" avec "reinterpret_cast"
// Non-problématique car le pointeur pointe vers une variable crée avec "Developer"
Developer* dev_pointeur = reinterpret_cast<Developer*>(person);
std::cout << dev_pointeur->language() << std::endl;
Bon, dans ce cas là, la conversion est complètement inutile, puisque l'on peut directement crée le "Developer" dans une variable déclaré "Developer", au lieu de s'embêter à faire ça. Cependant, si nous n'avons accès qu'à une version de "Developer" (ou de n'import quelle autre objet) via une adresse de pointeur "Person*", cette technique fonctionne. Cependant, faites bien attention de ne pas vous tromper de type, pour ne pas provoquer d'erreurs. D'ailleurs, un pointeur ne pointant vers aucun type (mais contenant quand même une adresse de n'importe quel type) est un pointeur de type "void*", ou la conversion est obligatoire pour l'utilisation (à utiliser avec précaution).

Contenu

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 :

int main() {
        char variable_1[128];
        int variable_2 = 8;

        return 0;
}
Laissons ce code être converti en assembleur. Ici, 4 lignes d'assembleur nous intéressent :
push       rbp
mov        rbp, rsp
sub        rsp, 24
mov        DWORD PTR [rbp-4], 8
Pour comprendre ce que l'on doit comprendre, il va falloir vulgariser, parce que nous ne sommes pas dans un cours d'assembleur. La première ligne permet d'utiliser la mémoire facilement dans la fonction main, via le mot clé "rbp". En fait, "rbp" représente un moyen d'accéder à la mémoire que la fonction va utiliser. Cependant, si l'on crée une autre fonction, on se rend compte que "rbp" est aussi utilisé dans cette fonction. En fait, le système d'exploitation va permettre à tout le programme d'utiliser une même partie de la mémoire, accessible via "rbp". D'ailleurs, l'instruction "mov" est l'instruction responsable de l'attribution de la variable "variable_2", via "rbp" ("variable_1" est aussi définie, mais comme aucune valeur ne lui est attribuée, alors pas besoin de "mov", juste d'augmenter le nombre après "sub"). Cette mémoire a une taille fixe, défini grâce à l'instruction "sub" du code assembleur. La taille précise de la mémoire est de 128 octets, au quel on additionne le nombre d'octets après l'instruction "sub" (un peu plus que le nombre d'octets nécessaire au programme, pour éviter les bugs). Calculer la taille précise de la mémoire utilisable ici est une perte de temps (et un exercice extrêmement complexe, à cause de la volatilité du language assembleur), puisque ce qui nous intéresse c'est le terme de fixe. Cette mémoire fixe utilisable partout dans le programme est nommé la mémoire "stack" (stack pour "pile", car les données sont empilées dans le programme). Sa taille et son contenu sont définis pendant la compilation (si vous rajoutez une variable dans le code, la prochaine compilation va le prendre en compte, et redéfinir la taille fixe).

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 :

int* pointeur = new int(8);
Il faut obligatoirement utiliser l'assignation avec la technique du "= type(parametre)" pour utiliser new. Grâce à ça, nous pouvons commencer à manipuler des variables dans le "heap" en C++. D'ailleurs, c'est la technique utiliser dans les vecteurs pour rajouter des élements. En parlant de structures de données, vous pouvez aussi définir de simples tableaux dans le "heap", avec l'opérateur new[taille_de_la_structure]. Voici un petit exemple :
int* pointeur_vers_tableau = new int[8];
Vous devrez cependant définir les valeurs dans le tableau vous même après sa création. De plus, le pointeur renvoyé est, en fait, un itérateur vers le premier élément du tableau dans le "heap", utilisable ici comme un tableau.
int* pointeur_vers_tableau = new int[8];
pointeur_vers_tableau[3] = 45; // Fonctionne parfaitement ici
D'ailleurs, au cours sur les variable, présent juste ici, nous avons parlé du type text "char*". Ce type s'utilise exactement comme cela, avec des "char" à la place des "int" (et aussi que le compiler peut convertir un texte en guillemet directement en "char*").

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 :

void datas() {
    int datas_size = 100000000;
    char* datas = new char[datas_size];
    for(int i = 0;i<datas_size;i++) datas[i] = 1; // On utilise chaque espace du tableau
    std::cout << "On a crée un tableau de 100 MO de données." << std::endl;
}
Si nous regardons dans le gestionnaire de ressource, pendant que le programme est dans la fonction (donc, un très bref moment), la mémoire consommée par le programme devrait augmenter de au moins 100 MO. Cependant, après en être sortie, ces 100 MO n'ont pas disparus. La raison est assez simple : là où la mémoire dans le "stack" se supprime toute seul, la mémoire dans le "heap" ne se supprime pas toute seule. Il faut donc le faire vous même, avec un opérateur appliqué sur le pointeur contenant l'objet stocké dans le "heap" : le pointeur "delete" (vous devez placer le pointeur après "delete"). On dit qu'on libère la mémoire. Le système d'exploitation effectue automatiquement cette tâche à la fermeture du programme pour la mémoire "heap", mais la place n'est pas accessible pendant l'exécution du programme. Cependant, dans le cas des tableaux (comme nous venons de faire), il faut utiliser l'opérateur "delete[]". Voici une bonne utilisation de "delete" :
void datas() {
    int* datas_size = new int(100000000);
    char* datas = new char[*datas_size];
    for(int i = 0;i<*datas_size;i++) datas[i] = 1; // On utilise chaque espace du tableau
    std::cout << "On a crée un tableau de 100 MO de données." << std::endl;

    delete datas_size; // Libération de la mémoire
    delete[] datas;
}
Le contrôle de cette opérateur est cependant très peu sécurisé, et faire n'importe quoi avec est très facile. Faites donc attention à ne pas supprimer une variable dans le "heap" déjà supprimée, ou un pointeur pointant vers 0, ou un pointeur vers une variable dans le "stack". Cela pourrait causer des erreurs. Cependant, il existe un moyen pour éviter la complexe planification de la suppression des pointeurs : laisser une classe s'en charger.

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" :

std::shared_ptr<int> pointeur_partage = std::make_shared<int>(8);
La fonction "make_shared" fonctionne de manière similaire à "new", aux exceptions qu'elle utilise un type entre chevrons, et qu'elle retourne un shared_ptr à copier dans "pointeur_partage". La notion de copie peut paraître peu intéressante, mais elle fait cependant toute la force des pointeurs partagés. C'est la copie entre "shared_ptr" qui permet de les connecter, pour supprimer la variable au bon moment. En fait, la variable est supprimée quand un "shared_ptr" ne détecte que plus aucun autre "shared_ptr" ne pointe vers elle. Cette détection se fait grâce à la copie d'un premier "shared_ptr", vers tous les autres.

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 :

std::shared_ptr<int> pointeur_partage_1 = std::make_shared<int>(8);
std::shared_ptr<int> pointeur_partage_2 = pointeur_partage_1;
std::cout << pointeur_partage_1.use_count() << std::endl; // Affiche "2"

pointeur_partage_2.reset(); // Met "pointeur_partage_2" à 0
std::cout << pointeur_partage_1.use_count() << std::endl; // Affiche "1"

pointeur_partage_1.reset(new int(4)); // Met "pointeur_partage_1" à une nouvelle variable (et supprime la première)
std::cout << *pointeur_partage_1.get() << " : " << pointeur_partage_1.use_count() << std::endl; // Affiche "4 : 1"
Lors de la destruction des pointeurs partagés, la mémoire est libérée correctement.

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>()".

Maintenant que nous pouvons utiliser les pointeurs et les différentes formes de mémoires, nous pourrons magner des objets de plus en plus complexes, comme des fenêtres graphiques, des structures de données, et même des images.