![]() |
ApprentissageProgrammationCpp
..
|
Dans cette partie nous allons voir LE truc du c++ qui fait peur à tous les apprentis programmeurs lorsqu'ils se lancent dans ce langage. La notion de pointeur ! Mais c'est quoi un pointeur ? Avant de répondre à cette question revenons un peu aux fondamentaux.
Nous avons vu il y a longtemps maintenant que la mémoire vive de votre ordinateur est organisée en une multitude de cases chaque case faisant huit bits ou 1 octet (1 byte en anglais). Lorsque l'on crée une variable en C++
, par exemple un double
que l'on appellerait monDouble
, caché derrière il y a un processus d'allocation mémoire qui vient prendre dans la RAM le nombre de cases nécessaire pour stocker un double
, à savoir 64 bits donc 8 octets. La valeur de notre variable monDouble
est alors stockée dans ces 8 octets. Pour finir lorsque dans le code on manipule la variable monDouble
on récupère bien la valeur stockée par exemple 42.0
.
Donc pour le moment l'aspect gestion de la mémoire est en réalité totalement transparent pour vous n'est ce pas ? Si on regarde un peu plus dans le détail. Nous pouvons en C++
demander à afficher l'adresse mémoire d'une variable, c'est-à-dire l'adresse de la case en mémoire où commence le stockage de la valeur associée à la variable. Pour cela il suffit de préfixer le nom de la variable par le symbole &
.
Cela nous retourne alors à l'exécution le message suivant :
Ainsi nous pouvons voir que notre variable monDouble
correspond à la case mémoire numérotée 0x7fffe281b678
. Vous pourriez me dire c'est bien joli mais je vois pas bien ce que je vais en faire ... Et pour le moment je ne peux qu'être d'accord avec vous !
Mais nous allons voir très bientôt que l'objectif ne va pas être d'afficher des adresses de variables mais de stocker ces adresses dans des variables différentes. C'est ce qu'on va appeler des pointeurs.
Ok mais à quoi ça va me servir de stocker l'adresse d'une variable dans une autre variable ? Et bien à plein de chose en fait. Par exemple cela nous permettra de partager, entre différents endroits du code, un même morceau de RAM permettant de s'affranchir de faire du passage d'argument compliqué. De plus l'autre énorme intérêt des pointeurs est de nous permettre de faire de l'allocation dynamique de la mémoire. C'est à dire que l'espace mémoire ne sera alloué qu'à l'exécution de notre programme suivant une logique interne propre à notre code faisant ainsi que tout n'est pas nécessairement alloué suivant les options d'utilisation.
Et enfin l'autre intérêt que nous verrons dans le chapitre suivant est que la manipulation de pointeurs dans le cadre de la programmation orientée objet, permet d'utiliser le concept de polymorphisme qui permet de faire passer un type pour un autre sous certaines conditions.
Comme beaucoup de chose en C++
il existe maintenant deux manières de faire des pointeurs : (i) la version historique qui vient du C
; (ii) la version moderne introduite par la norme 2011 du C++
. Nous allons ici voir les deux car la version historique est encore majoritairement utilisée dans les codes que l'on peut trouver en ligne, il est donc impératif de bien la comprendre.
En C++
historique la définition d'un pointeur, i.e. d'une variable stockant une adresse mémoire se fait en déclarant notre pointeur comme étant du type
Par exemple pour créer un pointeur vers un double
:
Vous pouvez déjà remarquer qu'il n'existe pas un type pointeur mais il y en a autant que de types pointés. Pourquoi ? Alors oui c'est vrai que stricto sensu une adresse c'est une adresse peu importe que cette dernière soit associée à un double
ou un int
par exemple. Mais dans les faits ce n'est pas la même chose car nous le verrons très rapidement il existe un mécanisme qui permet à partir d'un pointeur d'obtenir la valeur pointée, c'est ce qu'on appelle le déréférencement de pointeur. Or pour, à partir de l'adresse d'une case mémoire, récupérer une valeur il faut savoir combien d'octet nous devons prendre en compte et ce nombre d'octet est en réalité intrinsèquement lié au type de la valeur pointée !
Lorsque l'on crée un pointeur vers un entier il faut faire attention que le pointeur, comme une variable standard, n'est pas initialisé. Par exemple :
A l'exécution nous obtenons le résultat suivant :
Donc notre pointeur pointe soit-disant vers une case mémoire. Mais en réalité ce qui a été fait c'est qu'il a juste été mis une adresse random dans le pointeur. Donc à l'usage cela peut s'avérer extrêmement risqué et conduire au fameux segfault. Pour cela il est donc impératif d'initialiser un pointeur dès sa déclaration. Si l'on souhaite l'initialiser pour indiquer que pour le moment il ne pointe vers rien de valide il existe les mots clés NULL
ou nullptr
depuis C++11
permettant de faire pointer le pointeur vers l'adresse spécifique 0x0
.
ou bien
L'intérêt d'initialiser ses pointeurs à NULL
ou nullptr
est qu'il est alors possible de tester dans le code si un pointeur pointe vers quelque chose ou pas.
Maintenant que l'on sait créer un pointeur il serait sympathique que l'on soit capable de le faire pointer vers quelque chose ! Pour cela c'est très simple en fait. Nous avons vu quelques lignes plus haut qu'en préfixant un nom de variable par le symbole &
nous obtenions l'adresse mémoire de cette variable. Et bien c'est cette adresse que nous allons ranger dans notre pointeur !
Pour vérifier il nous suffit de regarder les adresses mémoires :
ce qui nous donne à l'exécution
Bien évidemment nous pourrions tout à fait initialiser directement le pointeur avec l'adresse à pointer
Maintenant que nous savons créer un pointeur et le faire pointer vers une zone mémoire il serait pratique de pouvoir accéder à la valeur contenue dans la zone mémoire pointée n'est ce pas ? Et bien c'est possible sans grande difficulté. Il suffit pour cela de déréférencer le pointeur, c'est à dire demander gentiment à interpréter le contenu de la zone mémoire comme le type correspondant au pointeur. Cela se fait en préfixant le pointeur du symbole *
. Par exemple :
A l'usage cela donne :
Lorsque l'on commence à jouer avec les pointeurs il faut faire attention à une chose: le segfault. Segfault est la contraction de Segmentation Fault
. C'est l'erreur qui apparaît lorsque qu'un programme tente d'accéder à de la mémoire qui ne lui est pas allouée. Lorsqu'on commence à mettre des pointeurs partout on joue donc avec la mémoire et si on est pas un petit peu rigoureux on se retrouve très très rapidement dans le cas où un segfault peut pointer le bout de son nez.
Considérons par exemple le code suivant :
A votre avis l'exécution de ce code se passe-t-elle bien ou pas ? Naturellement non ça se passe mal :
A présent nous allons voir ce que le C++
moderne nous propose comme alternative aux pointeurs classiques. Tout d'abord nous pouvons nous poser la question de pourquoi proposer une alternative ? Qu'est ce qu'il peut bien manquer aux pointeurs qui nécessiterait une autre manière de faire. C'est très simple: les pointeurs classiques fonctionnent très bien mais ont le gros défaut qu'il est nécessaire, une fois qu'on a fait une allocation mémoire avec un new
, de bien penser à faire le delete
associé sinon on a des fuites mémoires. Or parfois il peut s'avérer compliqué de bien identifier à quel moment il faut faire le delete
pour libérer la mémoire sans courir le risque de provoquer un segfault
....
La norme c++11
offre donc une surcouche aux pointeurs qui permet de ne plus se préoccuper des delete
, c'est génial je sais ! Le principe est extrêmement simple en réalité, il suffit d'avoir en interne pour chaque zone mémoire un compteur permettant de savoir à chaque instant le nombre de pointeurs pointant vers la zone et lorsque ce compteur tombe à 0 et bien là le delete
est fait automatiquement !
En pratique le standard c++11
offre deux encapsulations des pointeurs :
std::unique_ptr
std::shared_ptr
La distinction entre les deux est très simple, comme indiqué dans le nom le unique_ptr
va servir à encapsuler un pointeur qui pointe vers une zone mémoire qui ne peut pas être partagée. Tandis que le shared_ptr
lui, correspond à un pointeur vers une zone mémoire partagée entre plusieurs shared_ptr
. Pour utiliser ces deux types spécifiques, il faut tout d'abord l'include de la librairie memory
La déclaration d'un std::unique_ptr
se fait simplement en suivant la syntaxe suivante :
Par exemple pour déclarer un pointeur de type entier il suffit de procéder de la manière suivante :
Le premier intérêt du unique_ptr
par rapport à un pointeur nu est que même si on ne l'initialize pas au moment de sa déclaration, le C++
moderne fait le travail pour nous car notre pointeur est initialisé automatique à nullptr
. Par exemple :
Pour le moment nous avons donc un std::unique_ptr
mais il ne pointe vers rien donc ne nous sert pas à grand chose. Si nous voulons allouer de la mémoire à ce pointeur nous pourrions, comme avec les pointeurs historiques, utiliser le mot clé new
cependant le C++
moderne nous offre un autre outil avec la fonction std::make_unique
qui cache en réalité l'allocation mémoire via un new
et l'encapsule directement dans un std::unique_ptr
. Par exemple pour allouer de la mémoire à notre pointeur d'entier nous pouvons procéder de la manière suivante :
Evidemment nous pouvons faire l'allocation au moment de la déclaration du std::unique_ptr
pour cela il suffit de faire :
La fonction std::make_unique
peut prendre des arguments en entrée suivant le contexte. Par exemple std::unique_ptr
peut nous servir à encapsuler un tableau alloué dynamiquement. Dans ce cas au moment de l'allocation il faut fournir à la fonction std::make_unique
la taille du tableau à allouer. Par exemple pour créer un tableau de 10 entiers :
Remarque : personnellement je ne recommande pas d'utiliser des tableaux de ce genre, je vous invite plutôt à utiliser des std::vector
qui sont beaucoup plus sympathique à utiliser.
Maintenant entrons dans le vif du sujet avec la subtilité des std::unique_ptr
à savoir le fait qu'ils soient uniques. Cela implique une petite bizarrerie dans le fonctionnement qui est que je ne peux pas écrire le code suivant :
En effet l'opération d'affectation d'un std::unique_ptr
par un autre std::unique_ptr
est bloquée. Pourquoi ? Et bien simplement pour être sûr que l'on a pas deux std::unique_ptr
pointant vers la même zone mémoire. Bon ok mais c'est pas très grave vous vous dites. Alors en réalité si car le code suivant n'est pas plus valide :
Avec la fonction do_something
:
Et bien là ca commence à devenir gênant un peu non ? En tout cas moi je trouve que oui car avoir un pointeur que je ne peux pas passer dans un autre scope je ne vois pas bien l'intérêt. Mais pas de panique !! Il y a bien évidemment un moyen de faire ce qu'on veut. Ce moyen c'est de dire explicitement que l'on transfère la propriété de la mémoire pointée à un autre pointeur. Cela se fait en utilisant la fonction std::move
. Par exemple :
En utilisant le std::move
ici nous avons explicitement spécifié que la mémoire pointée par ptrInt
devient la propriété de ptr2
. De ce fait le pointeur ptrInt
est alors automatiquement invalidé et pointe alors vers nullptr
.
Et c'est exactement le même principe si on veut appeler la fonction do_something
:
Nous allons maintenant voir les std::shared_ptr
à la différence des std::unique_ptr
ils n'imposent aucune restriction sur le nombre de pointeurs sur une même zone mémoire. En ce sens les std::shared_ptr
sont similaires aux pointeurs nus classiques du C++
old-school.
Pour déclarer un std::shared_ptr
la démarche est similaire à celle que l'on vient de voir pour les std::unique_ptr
à savoir
Par exemple pour créer un pointeur partagé vers un entier :
Comme pour le std::unique_ptr
, à la déclaration le pointeur est initialisé à nullptr
pour prévenir tout usage dangereux. Pour allouer une zone mémoire à notre pointeur nous pourrions là encore utiliser le mot-clé new
mais le C++
moderne nous met à disposition la fonction std::make_shared
exactement sur le même principe que la fonction std::make_unique
. Par exemple :
Là où il existe les différences entre std::shared_ptr
et std::unique_ptr
commence. C'est tout d'abord dans la fonction use_count
disponible dans le std::shared_ptr
. L'intérêt de cette fonction est de nous retourner le nombre de pointeurs pointant actuellement vers la zone mémoire pointée. Par exemple :
Pour le moment nous n'avons qu'un pointeur vers la zone mémoire de ptr
, donc ptr
. Maintenant nous allons déclarer un second pointeur qui va faire référence à la même zone mémoire, pour cela on utilise simplement l'opérateur d'affectation =
.
Si nous regardons alors les valeurs retournées par la fonction use_count
sur ptr
et ptr2
nous obtenons :
Nous avons maintenant deux pointeurs ptr
et ptr2
qui pointent vers la zone mémoire. Si maintenant nous passons l'un de nos pointeurs en argument de la fonction suivante par exemple :
Nous obtenons alors la sortie suivante :
En effet nous avions déjà ptr
et ptr2
qui pointaient vers la zone mémoire, or le passage d'argument se faisant ici par copie nous avons donc dans le scope local de la fonction do_something
un troisième pointeur qui sera automatiquement détruit à la sortie de la fonction, mais la zone mémoire associée est préservée puisque le compteur de pointage n'arrive pas à 0.
Pour finir ce premier aperçu des pointeurs, pas d'inquiétude nous reviendrons dessus dans le chapitre suivant sur les classes car c'est là que les pointeurs vont avoir un réel intérêt, nous allons juste mettre un petit warning pour la suite. Nous venons de voir qu'il existe deux approches pour manipuler les pointeurs
new
jusqu'au delete
!C++11
qui se base sur les std::unique_ptr
et std::shared_ptr
.La règle que vous devez retenir et appliquer, est qu'une fois que vous avez choisi une des deux façons de faire il faut vous y tenir et ne surtout pas mélanger les deux ! Perso je vous conseillerais plutôt d'utiliser la syntaxe moderne à base de std::unique_ptr
ou std::shared_ptr
.
Dans les faits nous pouvons très bien mélanger les deux approches car il est possible depuis un std::shared_ptr
de récupérer le pointeur nu encaspulé à l'aide de la méthode get
et inversement il est possible de créer un std::shared_ptr
à partir d'un pointeur nu. Ci-dessous deux exemples de mélange des genres qui provoquent tous les deux une erreur à l'exécution de type double free
, c'est à dire qu'une même zone de la mémoire est désallouée deux fois !