La notion de pointeur est une notion essentielle du langage C, qui permet de manipuler explicitement la mémoire et d'avoir une conscience précise de ce qui se passe en mémoire. Il s'agit également d'une notion un peu délicate à prendre en main. Nous essayerons, donc, de poser les choses en détail.
1. Introduction et rappel : adresse d'une variable
En langage C, vous savez qu'on peut accéder à l'adresse mémoire
de n'importe quelle variable
avec l'opérateur éperluette &
:
int main() {
float x = 1.2; // la variable x est créée à une certaine adresse
// affiche 1.2000
printf("Valeur de la variable x %f\n", x);
// affiche, en hexadécimal, l'adresse à laquelle la variable i existe en mémoire
printf("Adresse de la variable x %p\n", &x);
// Rappel : dans la chaîne de format passée à la fonction printf(),
// %p permet de formater une adresse en hexa
}
Dans ce code, la variable x
- est créée quelque part en mémoire, à une certaine adresse (dans la zone mémoire appelée pile)
- est de type
float
: elle donc occupe donc 4 octets en mémoire et permet de stocker un nombre flottant. - a pour valeur
1.2
Et (&x)
(lire "adresse de x"):
- est de type
adresse d'un float
-
a pour valeur l'adresse de la variable
x
. Cette adresse où la variable va vivre en mémoire est choisie par le système, mais le développeur peut y accéder.
Puisqu'on peut accéder à l'adresse de n'importe quelle variable, pourquoi ne pourrait-on pas stocker cette adresse dans une autre variable ? Eh bien, c'est précisément ce que permet de faire une variable pointeur.
2. Déclarer et initialiser une variable pointeur
- Un pointeur est une variable dont la valeur est une adresse mémoire - typiquement l'adresse d'une autre variable.
- Le type d'une variable pointeur est "adresse du type de la variable pointée".
De plus :
- On dit que le pointeur "pointe" la case mémoire dont l'adresse est stockée dans le pointeur
- A partir du pointeur, il va être possible d'accéder à la case mémoire (à la variable) pointée.
Un pointeur est typé par le type de la chose pointée.
Un "pointeur" (sans plus de précision) ne veut pas dire grand chose.
On parlera par exemple
de "pointeur sur un int
",
ou de "pointeur sur float
", etc.
Pour déclarer un pointeur (une variable pointeur), on fait précéder le nom de la variable pointeur
du symbole étoile *
.
La syntaxe est donc :
[TYPE_DE_LA_CHOSE_POINTEE] * nom_choisi_pour_ma_variable_pointeur ;
// crée une variable nommée nom_choisi_pour_ma_variable_pointeur,
// de type "pointeur sur TYPE_DE_LA_CHOSE_POINTEE".
// Exemple :
float * p; // p est un pointeur - sur - float
Un second code, un peu plus complet (mais toujours pas bien utile, vous avez raison...) :
int main() {
float x = 1.2;
float * pointeur_sur_un_float; // Création d'une variable pointeur.
// Le nom de cette variable est pointeur_sur_un_float
// Le type de cette variable est "pointeur - sur - float".
// Comme toute variable déclarée, cette variable existera quelque part en mémoire.
// Pour le moment, on ne l'a pas initialisée : sa valeur est, comme d'habitude, aléatoire.
pointeur_sur_un_float = &x; // Affecte l'adresse de x à la variable pointeur_sur_un_float
// Desormais, la valeur de la variable pointeur_sur_un_float est l'adresse de la variable x.
// On dit que pointeur_sur_un_float "pointe" x
// Affichons deux fois l'adresse de x :
printf("Adresse de la variable x : %p\n", &x);
printf("Adresse de la variable x : %p\n", pointeur_sur_un_float);
int i = 1;
int * p; // p est un pointeur sur un int
p = &i; // on affecte l'adresse de i à la variable p
// Desormais, la valeur de la variable p est l'adresse de la variable i.
// On dit que p "pointe" i
double *p1, *p2, x;
// déclaration de 3 variables.
// p1 et p2 sont deux pointeurs - sur - double.
// x est un double.
return 0;
}
Supposons que j'ai un classeur de 100 pochettes transparentes. Les pochettes sont numérotées de 0 à 99. Dans chaque pochette, on peut glisser un nombre.
Dans la pochette numéro 5, je peux glisser le nombre 3.14.
Dans la pochette numéro 1, je peux glisser le numéro de la pochette précédente : 5.
Dans cette analogie :
- Le classeur, toutes ses pochettes <=> la mémoire du processeur.
- Une pochette <=> une variable C. La pochette est à un certaine adresse dans le classeur (son numéro) et contient à tout instant une certaine valeur.
- La pochette numéro 5 <=> une variable contenant une valeur "utile" par exemple de type
double
- La pochette numéro 1 <=> un pointeur. En effet, la pochette numéro 5 contient non pas un nombre quelconque, mais un nombre qui représente le numéro (l'adresse) d'une autre pochette.
- La pochette numéro 1 contient le numéro de la pochette numéro 5 <=> une variable pointeur contient l'adresse d'une autre variable.
Une pointeur est une variable comme une autre. Elle occupe un certain nombre d'octets en mémoire, pour stocker l'adresse de la variable pointée.
Combien une variable pointeur occupe-t-elle d'octets en mémoire ?
Une première remarque est que cela ne dépend pas
du type de la variable pointée.
Qu'on déclare un pointeur - sur - char
char * p1;
ou un pointeur - sur - double
double * p2;
,
dans tous les cas, le pointeur ne stockera
qu'une adresse mémoire.
La taille occupée par un pointeur
et donc toujours la même dans votre programme,
quelle que soit la chose pointée.
Elle dépend, par contre, de la machine pour laquelle votre programme est compilé puis sur laquelle il est exécuté.
Sur un ordinateur récent (architecture 64 bits, telle que X86_64) équipé d'un système d'exploitation 64 bits, comme ceux de l'école par exemple, une adresse mémoire est codée sur 8 octets. Un pointeur occupe donc 8 octets en mémoire (64 bits).
Jusque dans les années 2000, la mémoire adressable était souvent limitée à 4 Giga octets, soit 2 puissance 32 octets (architecture i386 par exemple). Une adresse mémoire était codée sur 4 octets et une variable pointeur occupait 4 octets.
Sur un microcontrôleur, ce peut être encore différent...
Une petite précision maintenant...
Dans la section précédente, on a dit que l'instruction
float * ptr;
déclare une variable nommée ptr
qui est de type pointeur - sur - float
.
Cette formulation n'est en fait pas tout à fait précise.
Il serait plus adéquat de dire que l'instruction float * ptr;
déclare une variable nommée ptr
telle que (*ptr)
soit de type float
(ce qui implique bien sûr que ptr
est
bien de type pointeur - sur - float
).
En effet, cette seconde formulation permet de mieux comprendre ce qui se passe lorsqu'on déclare plusieurs variables sur une même ligne, comme dans l'exemple suivant :
double *p1, x=29.8, y, *p2 = &x;
Cette ligne déclare 4 variables : p1, x, y
et p2
,
telles que
*p1
, x
,
y
et et *p2
sont de type double.
Autrement dit : x
et y
sont deux variables de type double
,
et p1
et p2
deux variables de type
pointeur - sur - double
.
Remarque : avec cette ligne, seules les variables x
et p2
ont été initialisées :
x
vaut 29.8
et p2
pointe la variable x
.
A l'inverse, y
et p1
ne sont
initialisées : leur valeur est "aléatoire".
Ce qui est vraiment très mal, comme expliqué dans la section 4.
3. Opérateur d'indirection *
: accéder à la case mémoire pointée
L'opérateur d'indirection (ou "de déréférencement") de pointeur, noté étoile
*
,
permet d'accéder à l'adresse pointée.
Ainsi, si unPointeur
est une variable pointeur, alors
* unPointeur
-EST- la case mémoire pointée par le pointeur.
En conséquence...
Si on écrit :
double x = 87.;
double *ptr = &x; // ptr est un pointeur qui pointe la variable x
alors on a les équivalences suivantes :
&x <=> ptr // ptr vaut l'adresse de x
x <=> *ptr // *ptr est la même chose que x
Ainsi donc, on peut utiliser une variable pointeur pour manipuler la variable pointée, comme dans les exemples suivants.
Et voici un code exemple un peu plus complet :
int main() {
int k = 4;
int * p1 ; // la variable p1 est un pointeur - sur - entier
p1 = &k; // p1 pointe désormais la variable k
// donc : *p1 <=> k et p1 <=> &k
printf("Valeur de k %d\n", k); // affiche 4
// On peut aussi afficher la valeur de k en utilisant le pointeur :
printf("Valeur de k, en utilisant le pointeur %d\n", *p1); // affiche 4 aussi !
// On peut aussi utiliser le pointeur pour changer la valeur de k :
*p1 = 89; // Affecte 89 à la chose pointée par p1... c'est à dire à la variable k
printf("Valeur de k %d\n", k); // affiche 89
printf("Valeur de k %d\n", *p1); // affiche aussi 89
double x;
printf("donnez la valeur de x\n");
scanf("%lf", &x);
printf("valeur de x %lf\n", x); // bon... affiche la valeur de x, entrée par l'utilisateur
double *px = &x ; // px est un pointeur qui pointe x
printf("donnez une nouvelle valeur pour x\n");
scanf("%lf", px); // puisque px pointe x, alors px <=> &x.
// Donc cette ligne va mettre dans x la nouvelle valeur donnée par l'utilisateur.
// On affiche 2 fois la nouvelle valeur de x, 2eme valeur entrée par l'utilisateur
printf("valeur de x %lf\n", x);
printf("valeur de x %lf\n", *px);
}
On reprend nos petites pochettes.
Dans la pochette numéro 5, je glisse le nombre 67.7.
Dans la pochette numéro 1, je glisse le numéro de la pochette précédente, c'est à dire 5 : la pochette 1 "pointe" la pochette 5.
Je donne le classeur à Paulette, et lui demande de retrouver le nombre secret, en lui indiquant que la pochette 1 contient le numéro de la pochette qui contient ce nombre secret. Pour retrouver ce nombre, Paulette :
- Ouvre la pochette 1 et lit le numéro (l'adresse) de l'autre pochette : 5.
- Elle se rend à la pochette numéro 5 et l'ouvre
- Elle lit le nombre dans la pochette 5.
Je donne le classeur à René, et lui dit de mettre 3.14 à la place du nombre secret, sachant que la pochette 1 contient le numéro de la pochette qui contient ce nombre secret. Pour cela, Paulette :
- Ouvre la pochette 1 et lit le numéro (l'adresse) de l'autre pochette : c'est 5.
- Elle se rend à la pochette 5 et y glisse 3.14
Dans ces deux actions, on pourrait dire que que Paulette et René ont "déréfencé" la pochette (le pointeur) numéro (situé à l'adresse) 1.
4. Initialisation d'un pointeur et mot clé NULL
Comme n'importe quelle variable en C, une variable pointeur n'est pas automatiquement initialisée lors de sa décalaration.
Un pointeur non intialisé aura pour valeur une adresse "aléatoire" : il pointe n'importe où en mémoire !
Le développeur a, comme pour toute variable, la responsabilité d'initialiser explicitement les pointeurs qu'il déclare.
Pour initialiser un pointeur, plusieurs possibilités :
-
à l'adresse d'une variable déjà déclarée :
int * ptr = &unEntier;
-
à une adresse obtenue lors d'une
allocation dynamique
de la mémoire :
int * ptr = calloc(3, sizeof(int));
(cf. la fiche XXXXXXX allocation dynamique à venir) -
dans le cas où on travaille avec du hardware,
à l'adresse d'un registre précisé dans la
documentation du fabriquant :
int * ptr = 0xFF0598AB; // où la documentation indique que ce registre, par exemple, permet d'allumer une diode d'une carte PCI
-
enfin, lorsqu'on ne sait pas encore où le pointeur va pointeur,
on l'initialisera toujours à la valeur
NULL
:
La valeur de l'identifieur NULL
signifie "adresse invalide".
Il convient de l'utiliser lorsqu'un pointeur ne pointe "nul part" :
int * unPointeurQuOnSaitPasEncoreOuQuIlPointe = NULL ; // un pointeur valant NULL,
// <=> qui ne pointe nul part
Considérons le code suivant :
int main() {
int i = 8, j = 4;
int * p1 ; // la variable p1 est un pointeur sur entier.
// p1 n'a pas été initalisé => sa valeur est aléatoire.
// Donc p1 pointe n'importe où en mémoire !
*p1 = 87; // on utilise le pointeur non initialisé => danger !!
printf("i %d ; j %d", i, j);
}
Lorsqu'on compile ce code, le compilateur est très content : du point de vue des types et de la syntaxe, tout va bien.
Lorsqu'on exécute le binaire compilé, ça se complique... Le comportement de ce programme est en fait aléatoire :
-
Le plus souvent, la valeur (aléatoire) de p1 fait que p1 pointe une
adresse à laquelle notre programme n'a pas le droit d'accéder.
En conséquence,
ligne 5, lorsque la ligne
*p1 = 87;
cherche à affecter 87 à cette adresse, le programme va s'arrêter subitement avec une erreur de typeSegmentation fault
(erreur d'accès mémoire : tentative d'accès à une case mémoire interdite). -
Parfois, la valeur (aléatoire) de p1 sera l'adresse d'une autre variable,
par exemple l'adresse de
i
ou dej
, ou encore une adresse à laquelle votre programme a le droit d'accéder mais que vous ne gérez pas explictement dans votre code (l'adresse du pointeur de pile par exemple). Dans ce cas, ligne 5,*p1 = 87;
, va affecter 87 à cette adresse. Et donc, sans même vous en rendre compte ni que vous le maitrisiez, la valeur de la variablei
, ou de la variablej
, ou d'une autre case mémoire utilisée par votre programme, va changer ! Et là, c'est la catastrophe... Car plus tard (peut-être des heures plus tard) votre programme va exploiter cette variable dont la valeur a aléatoirement été modifiée.
Un tel comportement aléatoire du programme du fait de pointeurs pointant n'importe où (on parle de "soupe de pointeurs" dans le jargon des développeurs) doit devenir vôtre bête noire.
On écrira donc, si on ne sait pas (encore) où pointe notre pointeur :
int main() {
int i = 8, j = 4;
int * p1 = NULL; // on initialise p1 à NULL tant qu'on ne sait pas où le faire pointer
// ... de telle sorte qu'on évite le comportement aléatoire :
// si on a le malheur de déréférencer le pointeur p1
// avant de le faire "pointer quelque part",
// on est sûr qu'une segmentation fault se produit :
*p1 = 87; // => arrêt immédiat et systématique du programme
// au lieu d'un comportement aléatoire. Ouf !
printf("i %d ; j %d", i, j);
}
Pour finir :
Lorsque vous utilisez un pointeur, il est essentiel
que vous ayez à tout instant conscience de ce qu'il pointe :
soit rien (il convient qu'il valle NULL
en ce cas),
soit une case mémoire valide.
5. Convention de nommage
Dans notre grande série "utiliser des noms signifiants pour vos variables et fonctions", aujourd'hui, nous conseillons fortement de :
Préfixer le nom d'un pointeur par
ptr
,
p_
,
p
,
(ou autre chose de votre invention)
lors de sa déclaration.
En effet, même si un pointeur est somme toute une variable comme les autres (si ce n'est que sa valeur est une adresse, au lieu d'une bonne vieille valeur entière, d'un float, d'un Koala ou d'autre chose...), l'expérience montre qu'il est fort utile que le nom du pointeur indique au premier coup d'oeuil le fait que c'est un pointeur.
On n'écrira donc PAS, par exemple :
int main() {
int i = 8;
int * j = &i; // j point i
*j = 89; // affecte 89 à la chose pointée par j, donc à i.
}
mais plutôt :
int main() {
int i = 8;
int * ptr = &i; // "ptr" dénote bien le fait que la variable est un pointeur
*ptr = 89; // affecte 89 à la chose pointée par ptr, donc à i.
// ou encore :
// int * p = &i;
// int * p_i = &i;
// int * p1 = &i;
// etc.
}
S'entrainer
/**
* Une jolie fonction ppale
*/
int main() {
int i = 12;
int j = 13;
int * p = &i;
*p = 0;
p = &j;
*p = 1;
j = 56;
printf("A %d B %d C %d\n", i, j, *p);
}
Ce code...
A 0 B 56 C 1
A 0 B 56 C 56
/**
* Bidouillons avec des pointeurs
*/
int main() {
double * ptr;
double x;
double y;
*ptr = 3.14;
x = *ptr;
printf("A %lf B %lf\n", x, *ptr);
}
Ce code...
A 3.14000 B 3.14000
A 3.14000 B 3.14000
.
Le pointeur ptr
n'est pas initialisé.
Lorsqu'on lance le programme, sa valeur (une adresse) est donc aléatoire :
il pointe n'importe où en mémoire.
Parfois (... le plus souvent dans ce cas simple...), il pointera une case mémoire invalide, à laquelle votre programme n'a pas le droit d'accéder. A la ligne 8, lorsqu'on accède à la case mémoire pointée (ici : pour en changer la valeur), il se produit alors une erreur de segmentation.
Mais il est possible aussi que la valeur aléatoire du pointeur non initialisé soit, aléatoirement, une adresse valide. Par exemple celle de la variable x, ou celle de la variable y, ou celle du pointeur lui-même, ou une adresse d'une autre partie de la zone mémoire allouée au programme par le système d'exploitation. Dans ce cas, le programme semble fonctionner correctement... Et là, c'est le drame : il vous semble que tout va bien... alors que votre code a un bon-gros-bug-qui-tâche, qui fera "planter" le programme un jour ou l'autre !
Les variables sont créées en mémoire. Le pointeur
unPtr
est une variable comme les autres : elle existe quelque part en mémoire.La variable
i
(unint
) occupe 4 octets.La variable
unPtr
(un pointeur) occupe la taille d'une adresse mémoire. Sur un ordinateur récent, c'est 8 octets.i
vaut 5.La variable
unPtr
n'a par contre pas été initialisée. Sa valeur dépend, comme d'habitude, des bits qui étaient allumés à cet endroit en mémoire, au moment de la création de la variable. On dit qu'elle est "aléatoire" (par exemple, peut-être, 0x0000ff00).Ces deux appels de
printf
affichent donc, par exemple, dans le Terminal :On affecte à
unPtr
l'adresse de la variablei
.Dès lors, on dit que
unPtr
pointe la variablei
.Pour symboliser le fait que
unPtr
pointei
, on dessine usuellement une flèche allant de la case mémoire deunPtr
vers la case mémoire dei
.Vérifions que la variable
unPtr
stocke bien désormais l'adresse de la variablei
, en affichant tout cela dans le Terminal :unPtr
est une variable... qui existe quelque part en mémoire. Rien n'empèche donc d'afficher l'adresse deunPtr
si ça nous amuse ! Cette ligne affiche donc dans le terminal, dans notre cas :