La notion de pointeur est très importante en C, elle va vous permettre
de comprendre le fonctionnement d’un programme, de programmer de façon
davantage propre et performante, et surtout de concevoir des
programmes que vous ne pourriez pas mettre au point sans cela.
Une des premières choses à comprendre quand on programme, c’est qu’une
variable est un emplacement de la mémoire dans lequel vous allez
placer une valeur. En programmant, vous utilisez son nom pour
y lire ou écrire une valeur. Mais ce qui se passe au coeur de la
machine est quelque peu plus complexe, les noms que l’on donne aux variables
servent à masquer cette complexité.
Pour le compilateur, une variable est un emplacement dans la mémoire,
cet emplacement est identifié par une adresse mémoire. Une
adresse est aussi une valeur, mais cette valeur sert seulement
à spécifier un emplacement dans la mémoire. Lorsque vous utilisez le
nom d’une variable, le compilateur le remplace par une adresse, et
manipule la variable en utilisant son adresse.
Ce système est ouvert, dans le sens où vous pouvez décider d’utiliser
l’adresse d’une variable au lieu d’utiliser son nom. Pour ce faire, on
utilise des pointeurs.
Un pointeur est une variable qui contient l’adresse mémoire d’une autre variable.
T* est le type d’une variable contenant l’adresse mémoire d’une variable de type T. Si une variable p de type T* contient l’adresse mémoire d’une variable x de type T, on dit alors que p pointe vers x (ou bien sur x). &x est l’adresse mémoire de la variable x. Exposons cela dans un exemple,
#include<stdio.h> int main() { int x = 3; int* p; p = &x; return 0; }
x est de type int. p est de type int*, c’est à dire de type pointeur de int, p est donc faite pour contenir l’adresse mémoire d’un int. &x est l’adresse mémoire de la variable x, et l’affectation p = &x place l’adresse mémoire de x dans le pointeur p. A partir de cette affectation, p pointe sur x.
La chaîne de format d’une adresse mémoire est ”%X”. On affiche donc une adresse mémoire (très utile pour débugger :-) comme dans l’exemple ci-dessous,
#include<stdio.h> int main() { int x = 3; int* p; p = &x; printf("p contient la valeur %X, qui n'est autre que l'adresse %X de x\n", p, &x); return 0; }
Le lecteur impatient se demande probablement à quoi peuvent servir
toutes ces étoiles ? Quel peut bien être l’intérêt des pointeurs ?
Si p pointe sur x, alors il est possible d’accéder à la valeur de
x en passant par p. Pour le compilateur, *p est la variable
pointée par p, cela signifie que l’on peut, pour le moment du moins,
utiliser indifférement *p ou x. Ce sont deux façons de se référer
à la même variable, on appelle cela de
l’aliasing. Explicitons cela sur un exemple,
#include<stdio.h> int main() { int x = 3; int* p; p = &x; printf("x = %d\n", x); *p = 4; printf("x = %d\n", x); return 0; }
L’affectation p=&x fait pointer p sur x. A partir de ce moment, *p peut être utilisé pour désigner la variable x. De ce fait, l’affectation x=4 peut aussi être écrite *p=4. Toutes les modifications opérées sur *p seront répercutées sur la variable pointée par p. Donc ce programme affiche
x = 3 x = 4
Qu’affiche, à votre avis, le programe suivant ?
#include<stdio.h> int main() { int x = 3; int y = 5; int* p; p = &x; printf("x = %d\n", x); *p = 4; printf("x = %d\n", x); p = &y; printf("*p = %d\n", *p); *p = *p + 1; printf("y = %d\n", y); return 0; }
x est initialisé à 3 et y est initialisé à 5. L’affectation p
= &x fait pointer p sur x, donc *p et x sont deux écritures
différentes de la même variable. Le premier printf affiche la valeur
de x : plus précisément
x = 3
. Ensuite, l’affectation *p =
4 place dans la valeur pointée par p, à savoir x, la valeur
4. Donc le deuxième printf affiche
x = 4
. L’affectation
p = &y fait maintenant pointer p sur y, donc la valeur de *p
est la valeur de la variable pointée y. le troisième printf
affiche donc
*p = 5
. N’oubliez pas que comme p pointe sur
y, alors *p et y sont deux alias pour la même variable, de ce
fait, l’instruction *p = *p + 1 peut tout à fait s’écrire y = y +
1. Cette instruction place donc dans y la valeur 6, le quatrième
printf affiche donc
y = 6
. Ce programme affiche donc :
x = 3 x = 4 *p = 5 y = 6
Il est possible d’aller plus loin dans l’utilisation des pointeurs en les employant pour manipuler des tableaux. Tout d’abord, éclaircissons quelques points.
Les éléments d’un tableau sont juxtaposés dans la mémoire. Autrement
dit, ils sont placés les uns à coté des autres. Sur le plan de
l’adressage, cela a une conséquence fort intuitive. Sachant qu’un
int occupe 2 octets en mémoire et que &T[0] est l’adresse
mémoire du premier élément du tableau T, quelle est l’adresse
mémoire de T[1] ? La réponse est la plus simple : &T[0] + 2 (ne
l’écrivez jamais ainsi dans un programme, vous verrez pourquoi plus
loin dans le cours...). Cela signifie que si l’on connait l’adresse
d’un élément d’un tableau (le premier en l’occurrence), il devient
possible de retrouver les adresses de tous les autres éléments de ce
tableau.
J’ai par ailleurs, une assez mauvaise surprise pour vous. Vous
utilisez sans le savoir des pointeurs depuis que vous utilisez des
tableaux. Etant donné la déclaration int T[50], vous conviendrez
que les désignations {T[0], …, T[49]} permettent de se
référer aux 50 éléments du tableau T. Mais vous est-il déjà
arrivé, en passant un tableau en paramètre, d’écrire T sans écrire
d’indice entre crochets ? Par exemple,
#include<stdio.h> void initTab(int K[], int n) { int i; for(i = 0 ; i < n ; i++) K[i] = i + 1; } void afficheTab(int K[], int n) { int i; for(i = 0 ; i < n ; i++) printf("%d\n", K[i]); } int main() { int T[50]; initTab(T, 50); afficheTab(T, 50); return 0; }
Vous remarquez que lorsque l’on passe un tableau en paramètre à un sous-programme, on mentionne seulement son nom. En fait, le nom d’un tableau, T dans l’exemple ci-avant, est l’adresse mémoire du premier élément de ce tableau. Donc, T est une variable contenant une adresse mémoire, T est par conséquent un pointeur. Lorsque dans un sous-programme auquel on a passé un tableau en paramètre, on mentionne un indice, par exemple K[i], le compilateur calcule l’adresse du i-ème élément de K pour lire ou écrire à cette adresse.
Commençons par observer l’exemple suivant :
#include<stdio.h> int main() { char t[10]; char* p; t[0] = 'a'; p = t; printf("le premier element du tableau est %c.\n", *p); return 0; }
La variable t contient l’adresse mémoire du premier élément du tableau t. p est un pointeur de char, donc l’affectation p = t place dans p l’adresse mémoire du premier élément du tableau t. Comme p pointe vers t[0], on peut indifférement utiliser *p ou t[0]. Donc ce programme affiche
le premier element du tableau est a.
Tout d’abord, je tiens à rappeler qu’une variable de type char occupe un octet en mémoire. Considérons maintenant les déclarations
char t[10] ; |
et
char* p = t ; |
Nous savons que si p pointe vers t[0], il est donc aisé d’accéder au premier élément de t en utilisant le pointeur p. Mais comment accéder aux autres éléments de t ? Par exemple T[1] ? Souvenez-vous que les éléments d’un tableau sont juxtaposés, dans l’ordre, dans la mémoire. Par conséquent, si p est l’adresse mémoire du premier élément du tableau t, alors (p + 1) est l’adresse mémoire du deuxième élément de ce tableau. Vous êtes conviendrez que *p est la variable dont l’adresse mémoire est contenue dans p. Il est possible, plus généralement, d’écrire *(p + 1) pour désigner la variable dont l’adresse mémoire est (p + 1), c’est à dire (la valeur contenue dans p) + 1. Illustrons cela dans un exemple, le programme
#include<stdio.h> int main() { char t[10]; char* p; t[1] = 'b'; p = t; printf("le deuxieme element du tableau est %c.\n", *(p+1)); return 0; }
affiche
le deuxième element du tableau est b.
En effet, p+1 est l’adresse mémoire du deuxième élément de t, il est donc possible d’utiliser indifférement *(p+1) et t[1]. Plus généralement, on peut utiliser *(p + i) à la place de t[i]. En effet, (p + i) est l’adresse du i-ème élément de t, et *(p + i) est la variable dont l’adresse mémoire est (p + i). Par exemple,
#include<stdio.h> #define N 26 int main() { char t[N]; char v = 'A'; char* p; int i; p = t; /* initialisation du tableau*/ for (i = 0 ; i < N ; i++) *(p + i) = v++; /* affichage du tableau*/ for(i = 0 ; i < N ; i++) printf("%c ", *(p + i)); printf("\n"); return 0; }
Ou encore, en utilisant des sous-programmes,
#include<stdio.h> #define N 26 void initTab(char* k, int n) { int i; int v = 'A'; for(i = 0 ; i < n ; i++, k++, v++) *k = v; } void afficheTab(char* k, int n) { int i; for(i = 0 ; i < n ; i++, k++) printf("%c ", *k); printf("\n"); } int main() { char t[N]; initTab(t, N); afficheTab(t, N); return 0; }
Ces deux sous-programmes affichent
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
Supposons que le tableau t contienne des int, sachant qu’un int
occupe 2 octets en mémoire. Est-ce que (t + 1) est l’adresse du
deuxième élément de t ?
Mathématiquement, la réponse est non,
l’adresse du deuxième élément est t + (la taille d′un int) = (t +
2). Etant donné un tableau p d’éléments occupant chacun n octets
en mémoire, l’adresse du i-ème élément est alors p + i × n.
Cependant, la pondération systématique de l’indice par la taille
occupée en mémoire par chaque élément est d’une part une lourdeur dont
on se passerait volontier, et d’autre part une source d’erreurs et de
bugs. Pour y remédier, le compilateur prend cette partie du travail en
charge,on ne pondérera donc pas les indices ! Cela signifie,
plus explicitement, que quel que soit le type des éléments du tableau
p, l’adresse mémoire du i-ème élément de p est p + i. On le
vérifie expérimentalement en exécutant le programme suivant :
#include<stdio.h> #define N 30 void initTab(int* k, int n) { int i; *k = 1; for(i = 1, k++ ; i < n ; i++, k++) *k = *(k - 1) + 1; } void afficheTab(int* k, int n) { int i; for(i = 0 ; i < n ; i++, k++) printf("%d ", *k); printf("\n"); } int main() { int t[N]; initTab(t, N); afficheTab(t, N); return 0; }
La lecture du chapitre précédent, si vous y avez survécu, vous a
probablement mené à une question que les élèves posent souvent :
”Monsieur pourquoi on fait ça ?”. C’est vrai ! Pourquoi on
manipulerait les tableaux avec des pointeurs alors que dans les
exemples que je vous ai donné, on peut le faire sans les pointeurs ?
Dans la mesure où un tableau est un pointeur, on peut, même à
l’intérieur d’un sous-programme auquel un tableau a été passé en
paramètre, manipuler ce tableau avec des crochets. Alors dans quel cas
utiliserons-nous des pointeurs pour parcourir les tableaux ?
De la même façon qu’il existe des cas dans lesquels on connait
l’adresse d’une variable scalaire mais pas son nom, il existe des
tableaux dont on connait l’adresse mais pas le nom.
Lorsque l’on déclare un tableau, il est obligatoire de préciser sa
taille. Cela signifie que la taille d’un tableau doit être
connue à la compilation. Alors que faire si on ne connait pas cette
taille ? La seule solution qui se présente pour le moment est le
surdimensionnement, on donne au tableau une taille très (trop) élevée
de sorte qu’aucun débordement ne se produise.
Nous aimerions procéder autrement, c’est à dire préciser la
dimension du tableau au moment de l’exécution. Nous allons pour
cela rappeler quelques principes. Lors de la déclaration d’un tableau
t, un espace mémoire alloué au stockage de la variable t, c’est à
dire la variable qui contient l’adresse mémoire du premier élément du
tableau. Et un autre espace mémoire est alloué au stockage des
éléments du tableau. Il y a donc deux zones mémoires utilisées.
Lorsque vous déclarez un pointeur p, vous allouez un espace mémoire
pour y stocker l’adresse mémoire d’un entier. Et p, jusqu’à ce qu’on
l’initialise, contient n’importe quoi. Vous pouvez ensuite faire
pointer p sur l’adresse mémoire que vous voulez (choisissez de
préférence une zone contenant un int...). Soit cette adresse est
celle d’une variable qui existe déjà. Soit cette adresse est celle
d’un espace mémoire créée spécialement pour l’occasion.
Vous pouvez demander au système d’exploitation de l’espace mémoire
pour y stocker des valeurs. La fonction qui permet de réserver n
octets est malloc(n). Si vous écrivez malloc(10), l’OS réserve 10
octets, cela s’appelle une allocation dynamique, c’est à dire
une allocation de la mémoire au cours de l’exécution. Si vous voulez
réserver de l’espace mémoire pour stocker un int par exemple, il
suffit d’appeler malloc(2), car un int occupe 2 octets en mémoire.
En même temps, c’est bien joli de réserver de l’espace mémoire, mais
ça ne sert pas à grand chose si on ne sait pas où il se trouve ! C’est
pour ça que malloc est une fonction. malloc retourne l’adresse
mémoire du premier octet de la zone réservée. Par conséquent, si vous
voulez créer un int, il convient d’exécuter l’instruction :
p = malloc(2) où p est de type int*. Le malloc réserve deux
octets, et retourne l’adresse mémoire de la zone allouée. Cette
affectation place donc dans p l’adresse mémoire du int
nouvellement créé.
Cependant, l’instruction p = malloc(2) ne peut pas passer la
compilation. Le compilateur vous dira que les types void* et int*
sont incompatibles (incompatible types in assignement).
Pour votre culture générale, void* est le type ”adresse
mémoire” en C. Alors que int* est le type ”adresse mémoire d’un
int”. Il faut donc dire au compilateur que vous savez ce que vous
faites, et que vous êtes sûr que c’est bien un int que vous allez
mettre dans la variable pointée. Pour ce faire, il convient d’effctuer
ce que l’on appelle un cast, en ajoutant, juste après
l’opérateur d’affectation, le type de la variable se situant à gauche
de l’affectation entre parenthèses. Dans l’exemple ci-avant, cela
donne : int* p = (int*)malloc(2).
Voici un exemple illustrant l’utilisation de malloc.
#include<stdio.h> #include<stdlib.h> int main() { int* p; p = (int*)malloc(sizeof(int)); *p = 28; printf("%d\n", *p); return 0; }
Vous remarquez que nous sommes bien dans un cas où l’on connait l’adresse d’une variable mais pas son nom. Le seul moyen de manier la variable allouée dynamiquement est d’utiliser un pointeur.
Lorsque que l’on effectue une allocation dynamique, l’espace réservé ne peut pas être alloué pour une autre variable. Une fois que vous n’en avez plus besoin, vous devez le libérer explicitement si vous souhaitez qu’une autre variable puisse y être stockée. La fonction de libération de la mémoire est free. free(v) où v est une variable contenant l’adresse mémoire de la zone à libérer. A chaque fois que vous allouez une zone mémoire, vous devez la libérer ! Un exemple classique d’utilisation est :
#include<stdio.h> #include<stdlib.h> int main() { int* p; p = (int*)malloc(sizeof(int)); *p = 28; printf("%d\n", *p); free(p); return 0; }
Notez bien que la variable p, qui a été allouée au début du main, a été libéré par le free(p).
La pointeur p qui ne pointe aucune adresse a la valeur NULL. Attention, il n’est pas nécessairement initialisé à NULL, NULL est la valeur que, conventionnellement, on décide de donner à p s’il ne pointe sur aucune zone mémoire valide. Par exemple, la fonction malloc retourne NULL si aucune zone mémoire adéquate n’est trouvée. Il convient, à chaque malloc, de vérifier si la valeur retournée par malloc est différente de NULL. Par exemple,
#include<stdio.h> #include<stdlib.h> int main() { int* p; p = (int*)malloc(sizeof(int)); if(p == NULL) exit(0); *p = 28; printf("%d\n", *p); free(p); return 0; }
Vous remarquez que le test de non nullité de la valeur retournée par malloc est effectué immédiatement après l’allocation dynamique. Vous ne devez jamais utiliser un pointeur sans avoir vérifié sa validité, autrement dit, sa non-nullité. Un pointeur contenant une adresse non valide est appelé un pointeur fou. Vous devrez, dans votre vie de programmeur, les traquer avec hargne !
Lors de l’allocation dynamique d’un tableau, il est nécessaire de
déterminer la taille mémoire de la zone de la zone à occuper. Par
exemple, si vous souhaitez allouer dynamiquament un tableau de 10
variables de type char. Il suffit d’exécuter l’instruction
malloc(10), car un tableau de 10 char occupe 10 octets en
mémoire. Si par contre, vous souhaitez allouer dynamiquement un
tableau de 10 int, il conviendra d’exécuter malloc(20), car
chaque int occupe 2 octets en mémoire.
Pour se simplifier la vie, le compilateur met à notre disposition la
fonction sizeof, qui nous permet de calculer la place prise en
mémoire par la variable d’un type donné. Par exemple, soit T un
type, la valeur sizeof(T) est la taille prise en mémoire par une
variable de type T. Si par exemple on souhaite allouer dynamiquement
un int, il convient d’exécuter l’instruction
malloc(sizeof(int)). Attention, sizeof prend en paramètre un
type !
Si on souhaite allouer dynamiquement un tableau de n variables de
type T, on exécute l’instruction malloc(n * sizeof(T)). Par
exemple, pour allouer un tableau de 10 int, on exécute malloc(10
* sizeof(int)). Voici une variante du programme d’un programme
précédent :
#include<stdio.h> #include<stdlib.h> #define N 26 char* initTab(int n) { char* k; int i; char value = 'a'; k = (char*)malloc(n*sizeof(char)); if (k == NULL) return NULL; for(i = 0 ; i < n ; i++, value++) *(k + i) = value; return k; } void afficheTab(char* k, int n) { int i; for(i = 0 ; i < n ; i++, k++) printf("%c ", *k); printf("\n"); } int main() { char* p = initTab(N); if (p == NULL) return -1; afficheTab(p, N); free(p); return 0; }
J’ai dit tout à l’heure : ”Pour le compilateur, *p est la variable pointée par p, cela signifie que l’on peut, pour le moment du moins, utiliser indifférement *p ou x”. En précisant ”pour le moment du moins”, j’avais déjà l’intention de vous montrer des cas dans lesquels ce n’était pas possible. C’est à dire des cas dans lesquels on connait l’adresse d’une variable mais pas son nom.
A titre de rappel, observez attentivement le programme suivant :
#include<stdio.h> void echange(int* x, int* y) { int t = *x; *x = *y; *y = t; } int main() { int a = 1; int b = 2; printf("a = %d, b = %d\n", a, b); echange(&a, &b); printf("a = %d, b = %d\n", a, b); return 0; }
A votre avis, affiche-t-il
a = 1, b = 2 a = 2, b = 1
ou bien
a = 1, b = 2 a = 1, b = 2
Méditons quelque peu : la question que l’on se pose est ”Est-ce que le sous-programme echange échange bien les valeurs des deux variables a et b” ? Il va de soi qu’il échange bien les valeurs des deux variables x et y, mais comme ces deux variables ne sont que des copies de a et b, cette permutation n’a aucun effet sur a et b. Cela signifie que la fonction echange ne fait rien, on aurait pu écrire à la place un sous-programme ne contenant aucune instruction, l’effet aurait été le même. Ce programme affiche donc
a = 1, b = 2 a = 1, b = 2
Ceux dont la mémoire n’a pas été réinitialisé pendant les vacances se
souviennent certainement du fait qu’il était impossible de passer en
paramètre des variables scalaires par référence. J’ai menti, il existe
un moyen de passer des paramètres par référence, et vous aviez des
indices vous permettant de vous en douter ! Par exemple, l’instruction
scanf("%d", & x) permet de placer une valeur saisie par
l’utilisateur dans x, et scanf est un sous-programme... Vous
conviendrez donc que la variable x a été passée en paramètre
par référence. Autrement dit, que la valeur de x est modifiée
dans le sous-programme scanf, donc que la variable permettant de
désigner x dans le corps de ce sous-programme n’est pas une copie de
x, mais la variable x elle-même, ou plutôt un alias de la
variable x.
Vous pouvez d’ores et déjà retenir que
nomsousprogramme(…, &x, …) |
sert à passer en paramètre la variable x par référence. Et finalement, c’est plutôt logique, l’instruction
nomsousprogramme(…, x, …) |
passe en paramètre la valeur de x, alors que
nomsousprogramme(…, &x, …) |
passe en paramètre l’adresse de x, c’est-à-dire un moyen de
retrouver la variable x depuis le sous-programme et de modifier sa
valeur.
Cependant, si vous écrivez echange(&a, &b), le programme ne
compilera pas... En effet, le sous-programme echange prend en
paramètre des int et si vous lui envoyez des adresses mémoire à la
place, le compilateur ne peut pas ”comprendre” ce que vous voulez
faire... Vous allez donc devoir modifier le sous-programme echange
si vous voulez lui passer des adresses mémoire en paramètre.
On arrive à la question suivante : dans quel type de variable puis-je mettre l’adresse mémoire d’une variable de type entier ? La réponse est int*, un pointeur sur int. Observons le sous-programme suivant,
void echange(int* x, int* y) { int t = *x; *x = *y; *y = t; }
x et y ne sont pas des int, mais des pointeurs sur int. De ce
fait le passage en paramètre des deux adresses &a et &b fait
pointer x sur a et y sur b. Donc *x est un alias de a et
*y est un alias de b. Nous sommes, comme décrit dans
l’introduction de ce chapitre dans un cas dans lequel on connait
l’adresse d’une variable, mais pas son nom : dans le sous-programme
echange, la variable a est inconnue (si vous l’écrivez, ça ne
compilera pas...), seul le pointeur *x permet d’accéder à la
variable a.
Il suffit donc, pour écrire un sous-programme prenant en paramètre des
variables passées par référence, de les déclarer comme des pointeurs,
et d’ajouter une * devant à chaque utilisation.