Cette fiche introduit la notion d'arithmétique de pointeurs (opérations sur des adresses) et donne quelques compléments sur les types pointeur.
1. Opération sur les adresses : arithmétique de pointeurs
On peut écrire deux types d'opératiosn avec des pointeurs :
- des opérations arithmétiques
- et des expressions booléennes
Commençons par l'arithmétique. L'arithmétique de pointeur, en C, c'est la possibilité d'effectuer des calculs sur des adresses mémoire - donc sur des pointeurs.
La première opération possible est :
pointeur + unentier
(ou pointeur - unentier
)
Le résultat de pointeur + unentier
est une nouvelle adresse, décallée de unentier
cases.
Attention : La taille d'une "case" (en nombre d'octets) est déterminée par le type du pointeur. L'arithmétique de pointeurs dépend du type du pointeur et plus précisément de la taille mémoire occupée par la chose pointée. Les calculs sont entendus en nombre de cases, et non pas en nombre d'octets.
Ainsi :
- Si
pointeur
est de typeint *
, alorspointeur + N
décalle la case mémoire de N cases, et ajoutera donc (N*4) octets à la valeur depointeur
, puisqu'unint occupe 4 octets en mémoire; - Si
pointeur
est de typechar *
, alorspointeur + N
décalle la case mémoire de N cases, et ajoutera donc (N*1) octets à la valeur depointeur
, puisqu'un codechar occupe 1 octet; - Si
pointeur
est de typedouble *
, alorspointeur + N
décalle la case mémoire de N cases, et ajoutera donc (N*8) octets à la valeur depointeur
, puisqu'undouble occupe 8 octets; - etc.
La seconde opération possible est :
pointeur2 - pointeur1
.
Le résultat de pointeur2 - pointeur1
est un entier :
le nombre de cases (pas d'octets !) séparant les deux adresses.
Attention : les deux pointeurs doivent être de même type (exemple : des pointeurs sur deux entiers)
On reprend notre histoire de pochettes, mais cette fois ci on suppose que les pochettes sont groupées par paquet de 10 : chaque paquet de 10 pochette représente un chapitre. Le chapitre 1 débute à la pochette numéro 0, le chapitre débute à la pochette numéro 10, etc.
Désigner le chapitre (5+1), c'est désigner le chapitre 6, c'est à dire la pochette numéro (d'adresse) 60.
Si une pochette (un pointeur) contient l'adresse d'un chapitre, par exemple le numéro 50 (adresse du chapitre 5), alors ajouter 1 à la valeur de cette pochette de type "adresse d'un chapitre" c'est désigner le chapitre suivant , c'est à dire la pochette numéro 60.
Si une pochette (un pointeur) contient l'adresse d'une pochette, par exemple à nouveau 50, alors ajouter 1 à la valeur de cette pochette de type "adresse d'une pochette" c'est désigner la pochette suivante , c'est à dire la pochette numéro 51.
Et en ce qui concerne les opérations booléennes sur les pointeurs :
ptr1 == ptr2
: est vrai si les deux pointeurs ont la même valeur (si ils pointent la même adresse)ptr1 != ptr2
: est vrai si les deux pointeurs ont des valeurs différentes (si ils pointent deux adresses différentes)ptr1 > ptr2
: est vrai si la valeur deptr1
est plus grande que celle deptr2
(siptr1
pointe plus loin en mémoire queptr2
)- Et ainsi de suite avec les opérateurs
<, <=, >=
Attention : les deux pointeurs comparés doivent être de même type (exemple : adresses de deux entiers) !
Voyons tout cela sur un exemple :
int main() {
int i = 8;
int * ptr = &i ; // ptr pointe i
printf("Valeur de ptr (adresse de i) : %p\n", ptr);
printf("Valeur de ptr+1 (adresse suivant la variable i) : %p\n\n", ptr+1 );
// RAPPEL: pour un tableau de N éléments:
// La 1ère case du tableau est à l'indice entier 0
// La dernière case du tableau est à l'indice entier (N-1)
// RAPPEL : un double occupe 8 octets en mémoire
double tab[10];
if(tab == & tab[0]) {
printf("La valeur de la variable tableau tab est bien la même que l'adresse de la première case\n");
}
printf("Adresse de la première case du tableau : %p\n", tab);
double * pa = & tab[0];
printf("Valeur du pointeur qui pointe la 1ère case du tableau : %p\n\n", pa);
double * pb = (pa + 1); // ARITHMETIQUE DE POINTEUR : adresse + entier => adresse
// Comme pa est de type (double *), et qu'un double occupe 8 octets,
// alors ajouter pa+1 (cases) ajoute en fait (1*8) octets à l'adresse pa.
// Autrement dit : pb pointe la case 2ème case (d'indice 1) du tableau !
printf("Valeur du pointeur pb : %p\n\n", pb);
printf("Vérifions que c'est bien l'adresse de la 2ème case du tableau : %p\n\n", & tab[1]);
if(pb > pa) {
printf("La case mémoire pointée par pb est bien plus loin que celle pointée par pa\n\n");
}
// vérifions encore...
printf("Valeur de pa+7, adresse de la 8ème case du tableau : %p\n", pa+7);
printf("(On vérifie que (pa+7) ajoute bien 7*8 == 56 octets à l'adresse pa.)\n\n");
double *pc = & tab[5];
printf("Valeur de pc, c'est à dire adresse de la 6ème case du tableau : %p\n", pc);
int nbCases = pc-pa; // // ARITHMETIQUE DE POINTEUR : adresse - adresse => entier
// Les deux adresse p5 et p0 sont toutes deux de type (double *) => ca marche
// La soustraction des deux adresses de même type est comptée en nombre de cases
// (ici : nombre de paquet de 8 octets, puisque les pointeurs sont de type double *)
printf("Nombre de cases séparant les adresses pointées par pc et par pa : %d\n", nbCases);
}
Lorsqu'on exécute ce programme, en supposant que la variable i est placée à l'adresse
0x7fff58330a8c
et le tableau tab à l'adresse
0x7fff4ff8eaa0
,
alors on obtient dans le Terminal :
Valeur de ptr (adresse de i) : 0x7fff58330a8c
Valeur de ptr+1 (adresse suivant la variable i) : 0x7fff58330a90
La valeur de la variable tableau tab est bien la même que l'adresse de la première case
Adresse de la première case du tableau : 0x7fff4ff8eaa0
Valeur du pointeur qui pointe la 1ère case du tableau : 0x7fff4ff8eaa0
Valeur du pointeur pb : 0x7fff4ff8eaa8
Vérifions que c'est bien l'adresse de la 2ème case du tableau : 0x7fff4ff8eaa8
La case mémoire pointée par pb est bien plus loin que celle pointée par pa
Valeur de pa+7, adresse de la 8ème case du tableau : 0x7fff4ff8ead8
(On vérifie que (pa+7) ajoute bien 7*8 == 56 octets (ou 0x38 en hexa) à l'adresse pa).
Valeur de pc, c'est à dire adresse de la 6ème case du tableau : 0x7fff58330ac8
Nombre de cases séparant les adresses pointées par pc et par pa : 5
On observe bien, dans cet exemple, que l'arithmétique de pointeur travaille en "nombre de cases" :
- 4 octets si les adresses manipulées sont de type
int *
- 8 octets si les adresses manipulées sont de type
double *
- 1 octet si les adresses manipulées sont de type
char *
- etc.
On retrouvera cette propriété, bien utile, dans la fiche suivante "pointeurs et tableaux".
2. Quelques compléments sur les types des pointeurs
Puisque les pointeurs sont typés (par le type de la chose pointée), il convient de prendre garde à ne pas mélanger les types des pointeurs.
On évitera, par exemple, d'affecter à un pointeur - sur - float
l'adresse d'une variable entière, ou encore d'affecter une valeur entière à un pointeur (un entier n'est pas une adresse !).
Si on écrit le code :
int main() {
int i = 8;
float * ptr = &i; // oups !
int * p = 78; // oups !
double * p_y;
p_y = ptr ; // oups !
if(p_y == p) {
printf("p_y et p pointent la même adresse\n");
}
}
alors, aux lignes 3, 4 et 6, le compilateur va vous exprimer son grand mécontentement, avec
des messages de "warning" tels que :
ttt.c:3:13: warning: incompatible pointer types initializing 'float *' with an expression of type 'int *' [-Wincompatible-pointer-types]
float * ptr = &i; // oups !
^ ~~
ttt.c:4:11: warning: incompatible integer to pointer conversion initializing 'int *' with an expression of type 'int' [-Wint-conversion]
int * p = 78; // oups !
^
ttt.c:6:9: warning: incompatible pointer types assigning to 'double *' from 'float *' [-Wincompatible-pointer-types]
p_y = ptr ; // oups !
^
ttt.c:7:12: warning: comparison of distinct pointer types ('double *' and 'int *') [-Wcompare-distinct-pointer-types]
if(p_y == p) {
^
Ces warnings du compilateur "incompatible pointer type"
sont très importants. Il ne faut pas les ignorer.
Pensez aux équations aux dimensions en Physique.
En physique, dans une équation, la dimension de ce qui est
à droite et à gauche doivent être les mêmes, sans quoi c'est que vous faites n'importe quoi !
Eh bien, c'est la même chose lorsqu'on programme en C avec des pointeurs.
Mais pourquoi donc le compilateur génère-t-il des Warnings, et non pas des erreurs ?
Eh bien parce que, en C, un développeur averti a le droit de "tricher" sur les types des choses pointées !
Cela permet par exemple de manipuler l'espace mémoire d'une variable
int
non pas comme un ensemble cohérent de 4 octets,
mais comme 4 octets séparés, comme dans l'exemple suivant :
int main() {
int i = 8; // i est un int, donc occupe 4 octets en mémoire
// Trichons un peu, et faisons comme si, à l'adresse de i, nous avions
// non pas une variable de type int, mais un simple octet...
char * ptr = &i;// ptr pointe i...
// Mais en considérant cette adresse comme étant celle d'un octet
// (un char) et non pas d'un entier !
*ptr = 0x00; // On affecte 0 au 1er octet de la variable i
ptr ++; // ou ptr = ptr+1 (arithmétique de pointeur)
// Désormais, ptr pointe le 2nd octet de la variable i,
// considéré comme un char
*ptr = 0x00; // On affecte 0 au 2eme octet de la variable i
ptr ++; // ptr pointe le 3eme octet de la variable i, considéré comme char
*ptr = 0xFF; // On affecte 255 au 3eme octet de la variable i
ptr ++; // ptr pointe le 3eme octet de la variable i, considéré comme char
*ptr = 0x01; // On affecte 1 au 4eme octet de la variable i
// autrement dit, on a manipulé i comme une suite de 4 octets
// pour "allumer" un à un les bits de chacun de ces octets !
printf("i ? %d\n", i); // affiche la nouvelle valeur de i
}
Ce code est tout à fait licite en C !
Il est un exemple de la puissance des pointeurs pour manipuler la mémoire.
Certains d'entre vous auront la joie de tâter de cela en 2ème année. Mais pour cette année, on restera sage et on évitera de mélanger les types de pointeurs...
Vous savez qu'en C, le mot clé void
("vide", "néant")
est un type qui permet, par exemple, de préciser qu'une fonction ne retourne
aucune valeur (c'est ce qu'on appelle une procédure).
void
est aussi utile pour déclarer une
variable pointeur, qui stockera donc une adresse,
lorsqu'on ne veut pas préciser le type de la chose pointée.
Un pointeur "pointant n'importe quoi" en mémoire, en quelque sorte.
C'est ce qu'on appelle un "pointeur générique".
On peut écrire par exemple :
int main() {
int i = 8;
void * p = &i;
// p est un pointeur de type "void".
// Sa valeur est l'adresse de la variable i.
// Ce code ne provoque pas de warning de compilation :
// on peut affecter un pointeur - sur - void l'adresse de n'importe quel type de variable.
// ... mais, par contre, le pointeur p ne "sait pas" qu'il pointe un entier
}
Attention toutefois : puisque un pointeur void
ne sait pas ce qu'il pointe, il n'est pas possible de le déréférencer,
ni de l'utiliser pour faire de l'algorithmique de pointeurs.
Ainsi, le code suivant ne compile pas :
int main() {
int i = 8;
void * p = &i;
// p est un pointeur de type "void".
// Sa valeur est l'adresse de la variable i
// ... mais p ne sait pas qu'il pointe un entier
*p = 10298 ; // ERREUR de compilation.
// Car comment savoir sur combien d'octets affecter
// la valeur 10298 ?
void * p2 = p + 9 ; // ERREUR de compilation (... en général ...
// mais certains compilateurs sont peut regardant ...)
// Car combien d'octets faudrait-il ajouter à l'adresse p ?
}
Mais alors, à quoi sert un pointeur de type void *
?
Eh bien, à de nombreuses choses !
Pour ne donner qu'un exemple, c'est parfois très utile, comme paramètre de fonctions, lorsque la fonction doit travailler sur des adresses mémoire, sans qu'on souhaite préciser le type pointé.
Ainsi, voici le prototype et le début de la documentation de la fonction
memcpy
de la librairie standard C
(voir man memcpy
pour plus de détail) :
void * memcpy(void *restrict dst, const void *restrict src, size_t n);
DESCRIPTION
The memcpy() function copies n bytes from memory area src to memory area dst.
(etc.)
S'entrainer
#include <stdio.h>
int main() {
char c = 'a';
char * p1 = &c;
int i = 12;
int * p2 = &i;
double tab[4] ;
double * p3 = tab;
printf("valeur de l'adresse stockée dans p1 %p\n", p1);
printf("valeur de l'adresse stockée dans p2 %p\n", p2);
printf("valeur de l'adresse stockée dans p3 %p\n", p3);
p1 = p1 + 1 ;
p2 = p2 + 2 ;
p3 = p3 + 3 ;
printf("Nouvelle valeur de l'adresse stockée dans p1 %p\n", p1);
printf("Nouvelle valeur de l'adresse stockée dans p2 %p\n", p2);
printf("Nouvelle valeur de l'adresse stockée dans p3 %p\n", p2);
*p1 = 'b';
*p2 = 89;
*p3 = 2.71828;
printf("tab[3] = %lf\n", tab[3]);
return 0;
}
On suppose que les lignes 13 à 15 de ce code affichent dans le Terminal :
valeur de l'adresse stockée dans p1 0x7fff5465fa9b
valeur de l'adresse stockée dans p2 0x7fff5465fa8c
valeur de l'adresse stockée dans p3 0x7fff5465faa0
Quelles sont les réponses correctes ?
Nouvelle valeur de l'adresse stockée dans p1 0x7fff5465fa9c
Nouvelle valeur de l'adresse stockée dans p2 0x7fff5465fa94
Nouvelle valeur de l'adresse stockée dans p3 0x7fff5465fa94
Nouvelle valeur de l'adresse stockée dans p1 0x7fff5465fa9c
Nouvelle valeur de l'adresse stockée dans p2 0x7fff5465fa8e
Nouvelle valeur de l'adresse stockée dans p3 0x7fff5465faa3
p1
et p2
pour changer les valeurs des cases mémoires pointées !
p3
pour changer la valeur de la case mémoire pointée !
tab[3] = 2.718280
L'arithmétique de pointeur dépend du type de pointeur, et de la taille occupée en mémoire par la chose pointée.
Or : un char
occupe 1 octet,
un int
occupe 4 octets
(sur les machines de l'école...
ça peut être plus ou moins sur d'autres processeurs),
un double
occupe 8 octets
Pour les 2 premières questions, il suffit alors de savoir compter en hexadécimal !
Pour les 3 dernières questions, il faut vous demander quelle est la variable pointée par les pointeurs après les opérations arithméthiques. Est-ce bien une case valide contenant une variable du bon type ?