14 Pointeurs¶
Attention les vélos, on s'attaque à un sujet délicat, difficile, scabreux, mais nécessaire. Un sujet essentiel, indispensable et fantastique: les pointeurs.
Les pointeurs sont des variables qui, au lieu de stocker une valeur, stockent une adresse mémoire. Dans quel but me direz-vous ? Pour créer des indirections, simplifier l'exécution du code.
Prenons un exemple concret. Le Vicomte de Valmont décide d'écrire à la marquise de Merteuil et il rédige une lettre. Il cachette sa lettre et la dépose dans sa boîte aux lettres pour enlèvement par le facteur moyennant quelques sous. En des termes programmatiques, on a :
char lettre[] = "Chère Marquise, ...";
Cette variable lettre
est dès lors enregistrée en mémoire à l'adresse 0x12345abc
qui pourrait correspondre à l'emplacement de la boîte aux lettres du Vicomte.
Le facteur qui ne craint pas la besogne prend connaissance du courrier à livrer, mais constate avec effroi que le cachet de cire a fondu sous l'effet du réchauffement climatique. La lettre est collée au fond de la boîte et il ne parvient pas à la détacher. Pire encore, et ajoutant à la situation déjà cocasse un dramatisme sans équivoque, à forcer de ses maigres doigts le papier de l'enveloppe se déchire et le contenu de lettre indécollable lui est révélée.
Je l'admets volontiers, il me faut bien faire quelques pirouettes pour justifier qu'une valeur en mémoire ne peut être transportée d'un lieu à un autre à simple dos de facteur. Aussi, notre facteur qui est si bon, mais qui n'a plus la mémoire de sa jeunesse, ni papier d'ailleurs, décide de mémoriser la lettre et de la retranscrire chez madame la Marquise qu'il connaît bien. Or comme il est atteint de la maladie de 64-bits il n'arrive à mémoriser que 8 caractères Chère Ma
. Sur son bolide, il arrive à destination et retranscrit dans le fond de la boîte de madame de Merteuil les huit caractères fidèlement retranscrits. Comme il est bonnet, mais assidu, il consacre le restant de sa journée en des allers-retours usant la gomme de son tout nickelé jusqu'à ce que toute la lettre ait été retranscrite.
On se retrouve avec une copie de la lettre chez madame de Merteuil :
char valmont_mailbox[] = "Chère Maquise, ...";
char merteil_mailbox[] = "Chère Maquise, ...";
La canicule n'étant pas finie, et cette physique discutable ne pouvant être déjouée, la marquise décide de résoudre le problème et se rends à Tarente (un très mauvais choix par jour de canicule) et formule sa réponse sur le mur sud du Castello Aragonese ayant préalablement pris soin de noter la position GPS du mur avec exactitude (0x30313233
):
char castello_wall[] = "Cher Vicomte ...";
char (*gps_position)[] = &castello_wall;
De retour chez elle, elle prie le facteur de transmettre au vicomte de Valmont ce simple mot: 0x30313233
. Le facteur parvient sans mal à mémoriser les 4 octets du message.
La variable gps_position
ne contient donc pas le message, mais seulement l'adresse mémoire de ce message. Il s'agit ici d'un pointeur sur un tableau de caractères.
Entre temps, le vicomte qui est paresseux s'est équipé d'un téléscripteur capable d'exécuter du code C et il parvient à lire le message de sa complice la marquise.
printf("%s", *gps_position);
S'il avait oublié l'astérisque (*
, U+002A) dans cette dernière ligne il n'aurait pas vu le message espéré, mais simplement 0123
qui correspond au contenu à l'adresse mémoire ou se trouve l'adresse du message (et non le message).
L'astérisque agit donc comme un déréférencement, autrement dit, la demande expresse faite au dévoué facteur d'aller à l'adresse donnée récupérer le contenu du message.
Oui, mais, on utilise un astérisque pour déréférencer, mais dans l'exemple précédant on a utilisé l'esperluette (&
, U+0026): &castello_wall
, pourquoi ? L'esperluette quand elle préfixe une variable peut être traduite par l'adresse de. Cela revient à l'étape pendant laquelle la marquise a mesuré la position GPS du mur sur à Tarente.
Il manque encore une chose, il y a aussi une astérisque sur (*gps_position)[]
. Cela vaudrait-il dire qu’on déréférence la position GPS pour affecter l'adresse du mur ? Non, pas du tout... Et c'est d'ailleurs à cette étape que les novices perdent le fil. Où en étais-je ?
Notons qu'il y a plusieurs interprétations de l'astérisque en C :
Opérateur de multiplication:
a * b
Déréférencement d'un pointeur:
*ptr
Déclaration d'un pointeur:
int * ptr
Donc ici, on déclare un pointeur. En appliquant la règle gauche-droite que l'on verra plus bas :
char (*gps_position)[]
^^^^^^^^^^^^ 1. gps_position est
^ 2. ...
^ 3. un pointeur sur
^^ 4. un tableau de
^^^^ 5. caractères
6. PROFIT...
Résumons :
Un pointeur est une variable
Il contient une adresse mémoire
Il peut être déréférencé pour en obtenir la valeur de l'élément qu'il pointe
L'adresse d'une variable peut être obtenue avec une esperluette
14.1 Pointeur simple¶
Le format le plus simple d'un pointeur sur un entier s'écrit avec l'astérisque *
:
int* ptr = NULL;
La valeur NULL
corresponds à l'adresse nulle 0x00000000
. On utilise cette convention pour bien indiquer qu'il s'agit d'une adresse et non d'une valeur scalaire.
À tout moment, la valeur du pointeur peut être assignée à l'adresse d'un entier puisque nous avons déclaré un pointeur sur un entier :
int boiling = 100;
int freezing = 0;
for (char i = 0; i < 10; i++) {
ptr = i % 2 ? &boiling : &freezing;
printf("%d", *ptr);
}
Lorsque nous avions vu les tableaux, nous écrivions :
int array[10] = {0,1,2,3,4,5,6,7,8,9};
Vous ne le saviez pas, mais 𝄽 plot twist 𝄽 la variable array
est un pointeur, et la preuve est que array
peut être déréférencé:
printf("%d", *array); // Affiche 0
La différence entre un tableau et un pointeur est la suivante :
Il n'est pas possible d'assigner une adresse à un tableau
Il n'est pas possible d'assigner des valeurs à un pointeur
D'ailleurs, l'opérateur crochet []
n'est rien d'autre qu'un sucre syntaxique:
a[b] == *(a + b);
Bien que ce soit une très mauvaise idée, il est tout à fait possible d'écrire le code suivant puisque l'addition est commutative :
assert(4[a] == a[4]);
14.2 Arithmétique de pointeurs¶
Fondamentalement un pointeur est une variable qui contient un ordinal, c'est-à-dire qu'il peut être imaginé l'ajout à un pointeur une grandeur finie :
char str[] = "Le vif zéphyr jubile sur les kumquats du clown gracieux";
for (char* ptr = str; *ptr; ptr++) {
putchar(*ptr);
}
Imaginons que l'on souhaite représenter le carré magique suivant :
┌───┬───┬───┐
│ 4 │ 9 │ 2 │
├───┼───┼───┤
│ 3 │ 5 │ 7 │
├───┼───┼───┤
│ 8 │ 1 │ 6 │
└───┴───┴───┘
On peut le représenter en mémoire linéairement et utiliser de l'arithmétique de pointeur pour le dessiner :
char magic[] = "492" "357" "816";
char* ptr = magic;
for (size_t row = 0; row < 3; row++) {
for (size_t col = 0; col < 3; col++)
putchar(*(ptr + row * 3 + col));
putchar('\n');
}
Mais ? N'est-ce pas là ce que fait le compilateur lorsque l'adresse les éléments d'un tableau multi dimensionnel ?
char magic[][3] = {"792", "357", "816"};
for (size_t row = 0; row < 3; row++) {
for (size_t col = 0; col < 3; col++)
putchar(magic[row][col]);
putchar('\n');
}
Oui très exactement, les deux codes sont similaires, mais l'un est plus élégant que l'autre, lequel d'après vous ?
L'arithmétique de pointeur est donc chose courante avec les tableaux. À vrai dire, les deux concepts sont interchangeables :
Élément |
Premier |
Deuxième |
Troisième |
n ième |
---|---|---|---|---|
Accès tableau |
|
|
|
|
Accès pointeur |
|
|
|
|
De même, l'exercice peut être répété avec des tableaux à deux dimensions :
Élément |
Premier |
Deuxième |
n ligne m colonne |
---|---|---|---|
Accès tableau |
|
|
|
Accès pointeur |
|
|
|
14.3 Pointeur et chaînes de caractères¶
static const char* conjonctions[] = {
"mais", "ou", "est", "donc", "or", "ni", "car"
};
Fig. 14.1 Pointeur sur une chaîne de caractère¶
Cette structure est très exactement la même que pour les arguments transmis à la fonction main
: la définition char *argv[]
.
14.4 Structures et pointeurs¶
14.4.1 Initialisation d'un pointeur sur une structure¶
De la même manière qu'avec les types standards, on peut définir un pointeur sur une structure de données.
typedef struct Date {
unsigned char day;
unsigned char month;
unsigned int year;
} Date;
L'exemple précédent définit un type de donnée Date. On pourrait donc initialiser un pointeur sur cette structure de la façon suivante :
Date date;
Date *p; // Pointeur sur un type Date
p = &date; // Initialisation du pointeur sur un type structuré
Le pointeur reste un pointeur, soit un espace mémoire qui contient une adresse vers la structure Date
. En conséquence, la taille de ce pointeur est de 8 bytes sur une machine 64 bits :
Date *p;
assert(sizeof(p) == 8);
14.4.2 Utilisation d'un pointeur sur une structure¶
On a vu que les champs d'une structure sont accessibles au travers du
.
faisant la liaison entre la variable et le champ. Cela est
valable si la variable est du type structuré. Si la variable est du type
pointeur sur une structure, on remplacera le .
par ->
.
Date date;
Date *p;
p = &date;
p->day = 29;
p->month = 12;
p->year = 1964;
La syntaxe ->
est un sucre syntaxique. Les deux écritures suivantes sont par conséquent équivalentes :
p->year
(*p).year
14.4.3 Utilisation d'un pointeur récursif sur une structure¶
Lorsqu'on utilise des listes chaînées, on a besoin de créer une structure contenant des données ainsi qu'un pointeur sur un élément précédent et un autre sur l'élément suivant. Ces pointeurs sont du même type que la structure dans laquelle ils sont déclarés et cela impose un style d'écriture spécifique :
typedef struct Element {
struct Element *prev; // Pointeur sur l'élément précédent
struct Element *next; // Pointeur sur l'élément suivant
unsigned long data; // Donnée d'une liste chaînée
} Element;
Exemple d'utilisation :
Element e[3];
// Premier élément de la liste
e[0].prev = NULL;
e[0].next = &e[1];
// Second élément de la liste
e[1].prev = &e[0];
e[1].next = &e[2];
// troisième élément de la liste
e[2].prev = &e[1];
e[2].next = NULL;
14.5 Pointeurs et paramètres de fonctions¶
Les fonctions comportent une liste de paramètres permettant de retourner une information au programme appelant. Il est souvent indispensable de pouvoir fournir à une fonction des paramètres qu'elle peut modifier lors de son exécution. Pour se faire, on passera par l'utilisation de pointeurs.
14.5.1 Paramètres sous la forme de pointeurs¶
Le prototype d'une fonction recevant un (ou plusieurs) pointeur s'écrit de la manière suivante :
type fonction(Type *param);
Cette fonction reçoit un paramètre (param) qui est un pointeur sur le type Type
.
Exemple de prototype :
int compute(double x, double *pres);
La fonction calcul prend 2 paramètres. Le premier (x) est du type double. Le second (pres) est un pointeur sur un double. Il sera donc possible, lors de l'appel de la fonction, de lui donner l'adresse d'une variable dans laquelle la fonction placera le résultat du calcul.
int calcul(double x, double * pres) {
*pres = x * 2.; // calcul du double de x
// place le resultat à l'adresse pres
return 0; // code retour = 0 (int)
}
int main() {
double value = 7.;
double r = 0.;
int res = 0;
res = compute(value, &r);
// res vaut maintenant 14.
}
Lors de l'appel d'une fonction recevant un pointeur comme paramètre, on
placera le symbole &
pour lui donner l'adresse de la variable.
14.6 Transtypage de pointeurs (cast)¶
Le cast
de pointeur s'avère nécessaire lorsqu'un pointeur du type void
est déclaré, comme c'est le cas pour la fonction de copie mémoire memcpy
. En effet, cette fonction accepte en entrée un pointeur vers une région mémoire source, et un pointeur vers une région mémoire de destination. D'un cas d'utilisation à un autre, le format de ces régions mémoires peut être de nature très différente :
char message[] = "Mind the gap, please!";
int array[128];
struct { int a; char b; float c[3] } elements[128];
Il faudrait donc autant de fonction memcpy
que de type possible, ce qui n'est ni raisonnable, ni même imaginable. Face à ce dilemme, on utilise un pointeur neutre, celui qui n'envie personne et que personne n'envie void
et qui permet sans autre :
void *ptr;
ptr = message;
ptr = array;
ptr = elements;
Que pensez-vous que sizeof(void)
devrait retourner ? Formellement ceci devrait mener à une erreur de compilation, car void
n'a pas de substance, et donc aucune taille associée. Néanmoins gcc
est très permissif de base et (à ma grande surprise), il ne génère par défaut ni warning, ni erreurs sans l'option -Wpointer-arith
sur laquelle nous aurons tout le loisir de revenir.
L'intérêt d'un pointeur, c'est justement de pointer une région mémoire et le plus souvent, de la balayer grâce à l'arithmétique de pointeurs. Notre fonction de copie mémoire doit en somme pouvoir parcourir toute la région mémoire de source et de destination et de ce fait incrémenter le pointeur. Mais, n'ayant aucune taille l'arithmétique de pointeur n'est pas autorisée avec le pointeur void
et nous voilà bien avancés, notre pointeur ne nous est guère d'usage que son utilité éponyme: rien.
Or, le titre de cette section étant le transtypage, il doit donc y avoir moyen de s'en sortir par une pirouette programmatique dans laquelle je déclare un nouveau pointeur du type char auquel j'associe la valeur de ptr par un cast explicite.
char *iptr = (char*)ptr;
Dès lors, l'arithmétique est redevient possible iptr++
. Pourquoi ne pas avoir utilisé ce subterfuge plus tôt me direz-vous ? En effet, il m'aurait été possible d'écrire char *ptr = (char*)elements;
directement et sans détour, mais ceci aurait alors mené à ce prototype-ci :
void *memcpy(char* dest, const char* src, size_t n);
La clé est dans le standard ISO/IEC 9899:2011 section 6.3.2.3 page 55 :
A pointer to void may be converted to or from a pointer to any object type. A pointer to any object type may be converted to a pointer to void and back again; the result shall compare equal to the original pointer.
Autrement dit, il n'est pas nécessaire, ni recommandé de faire un transtypage explicite pour convertir vers et en provenance d'un pointeur sur void
. Et donc, l'astuce de memcpy est que la fonction accepte n'importe quel type de pointeur et c'est le message auto documenté du code.
Et quant à l'implémentation de cette fonction me direz-vous ? Une possibilité serait :
void memcpy(void *dest, void *src, size_t n)
{
char* csrc = src;
char* cdest = dest;
for (size_t i = 0; i < n; i++)
cdest[i] = csrc[i];
}
Où plus concis :
void memcpy(void *dest, void *src, size_t n)
{
for (size_t i = 0; i < n; i++)
((char*)dst)[i] = ((char*)src)[i];
}
Or, rien de tout ceci n'est juste. memcpy
est une fonction fondamentale en C, ce pourquoi nous nous y attardons temps. Elle est constamment utilisée et doit être extrêmement performante. Aussi, si le compilateur cible une architecture 64-bits pourquoi diable copier les éléments par paquet de 8-bits. C'est un peu comme si notre facteur, au début de ce chapitre, aurait fait ses allers-retours avec en tête qu'un octet par trajet. L'implémentation dépend donc de l'architecture cible et doit tenir compte des éventuels effets de bords. Par exemple s'il faut copier un tableau de 9 x 32 bits. Une architecture 64-bits aura une grande facilité à copier les 8 premiers octets, mais quant au dernier, il s'agit d'un cas particulier et selon la taille de la copie et l'architecture du processeur, l'implémentation devra être ajustée. C'est pourquoi ce type très bas niveau de fonction est l'affaire d'une cuisine interne du compilateur et dont le développeur ne doit pas se soucier. Vous êtes comme Thomas l'apôtre, et ne me croyez pas ? Alors, digressons et essayons :
#include <string.h>
#include <stdio.h>
int main(void)
{
char a[] = "La Broye c'est fantastique!";
char b[sizeof(a)];
memcpy(a, b, sizeof(a));
printf("%s %s", a, b);
}
On observe qu'il n'y a aucun appel de fonction à memcpy
comme c'est le cas pour printf
(bl printf
). La copie tient place en 6 instructions.
main :
// Entry
str lr, [sp, #-4]!
sub sp, sp, #60
// Inline memcpy
mov ip, sp // Destination address
add lr, sp, #28 // Source address (char b located 28 octets after a)
ldmia lr!, {r0, r1, r2, r3} // Load 4 x 32-bits
stmia ip!, {r0, r1, r2, r3} // Store 4 x 32-bits
ldm lr, {r0, r1, r2} // Load 3 x 32-bits
stm ip, {r0, r1, r2} // Store 3 x 32-bits
// Display (printf)
add r2, sp, #28
mov r1, sp
ldr r0, .L4
bl printf
// Exit
mov r0, #0
add sp, sp, #60
ldr pc, [sp], #4
.L4 :
.word .LC0
.LC0 :
.ascii "La Broye c'est fantastique!\000"
Vous pouvez jouer avec cet exemple ici.
14.7 Pointeurs de fonctions¶
Un pointeur peut pointer n'importe où en mémoire, et donc il peut également pointer non pas sur une variable, mais sur une fonction. Les pointeurs de fonctions sont très utiles pour des fonctions de rappel (callback).
Par exemple on veut appliquer une transformation sur tous les éléments d'un tableau, mais la transformation n'est pas connue à l'avance. On pourrait écrire :
int is_odd(int n)
{
return !(n % 2);
}
void map(int array[], int (*callback)(int), size_t length)
{
for (size_t i = 0; i < length; i++) {
array[i] = callback(array[i]);
}
}
void main(void)
{
int array[] = {1,2,3,4,5};
map(array, is_odd);
}
Avec la règle gauche droite on parvient à décortiquer la déclaration :
int (*callback)(int)
^^^^^^^^ callback is
^
^ a pointer on
^^^^^ a function taking an int
^^^ and returning an int
14.8 La règle gauche-droite¶
Cette règle est une recette magique permettant de correctement décortiquer une déclaration C contenant des pointeurs. Il faut tout d'abord lire :
Symbole |
Traduction |
Direction |
---|---|---|
|
|
Toujours à gauche |
|
|
Toujours à droite |
|
|
Toujours à droite |
- Première étape
Trouver l'identifiant et se dire
L'identifiant est
.- Deuxième étape
Chercher le symbole à droite de l'identifiant. Si vous trouvez un
()
, vous savez que cet identifiant est une fonction et vous avezL'identifiant est une fonction retournant
. Si vous trouvez un[]
vous dites alorsL'identifiant est un tableau de
. Continuez à droite jusqu'à ce que vous êtes à court de symboles, OU que vous trouvez une parenthèse fermante)
.- Troisième étape
Regardez le symbole à gauche de l'identifiant. S’il n'est aucun des symboles précédents, dites quelque chose comme
int
. Sinon, convertissez le symbole en utilisant la table de correspondance. Continuez d'aller à gauche jusqu'à ce que vous êtes à court de symboles OU que vous rencontrez une parenthèse ouvrante(
.- Ensuite...
Continuez les étapes 2 et 3 jusqu'à ce que vous avez une déclaration complète.
Voici quelques exemples :
int *p[];
Trouver l'identifiant:
p
:p est
int *p[]; ^
Se déplacer à droite:
p est un tableau de
int *p[]; ^^
Se déplacer à gauche:
p est un tableau de pointeurs sur
int *p[]; ^
Continuer à gauche:
p est un tableau de pointeurs sur un int
int *p[]; ^^^
14.8.1 cdecl¶
Il existe un programme nommé cdecl qui permet de décoder de complexes déclaration c :
$ cdecl 'char (*(*x[3])())[5]'
declare x as array 3 of pointer to function returning pointer to array 5 of char
Une version en ligne est également disponible.
14.9 Initialisation par transtypage¶
L'utilisation de structure peut être utile pour initialiser un type de donnée en utilisant un autre type de donnée. Nous citons ici deux exemples.
int i = *(int*)(struct { char a; char b; char c; char d; }){'a', 'b', 'c', 'd'};
union {
int matrix[10][10];
int vector[100];
} data;
14.10 Enchevêtrement ou Aliasing¶
Travailler avec les pointeurs demande une attention particulière à tous les problèmes d'alisasing dans lesquels différents pointeurs pointent sur une même région mémoire.
Mettons que l'on souhaite simplement déplacer une région mémoire vers une nouvelle région mémoire. On pourrait implémenter le code suivant :
void memory_move(char *dst, char*src, size_t size) {
for (int i = 0; i < size; i++)
*dst++ = *src++;
}
Ce code est très simple mais il peut poser problème selon les cas. Imaginons que l'on dispose d'un tableau simple de dix éléments et de deux pointeurs *src
et *dst
. Pour déplacer la région du tableau de 4 éléments vers la droite. On se dirait que le code suivant pourrait fonctionner :
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│0│1│2│3│4│5│6│7│8│9│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
^*src ^*dst
┌─┬─┬─┬─┬─┬─┬─┐
│0│1│2│3│4│5│6│
└─┴─┴─┴─┴─┴─┴─┘
↓ ↓ ↓ ↓ ↓ ↓ ↓
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│0│1│2│0│1│2│3│4│5│6│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
Naïvement l'exécution suivante devrait fonctionner, mais les deux pointeurs source et destination s'enchevêtrent et le résultat n'est pas celui escompté.
char array[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
char *src = &array[0];
char *dst = &array[3];
memory_move(b, a, 7);
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│0│1│2│3│4│5│6│7│8│9│ Tableau d'origine
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│0│1│2│0│1│2│0│1│2│0│ Opération avec `memory_move`
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│0│1│2│0│1│2│3│4│5│6│ Opération avec `memmove` (fonction standard)
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘
Notre simple fonction de déplacement mémoire ne fonctionne pas avec des régions mémoires qui s'enchevêtrent. En revanche, la fonction standard memmove
de <stdlib.h>
fonctionne, car elle autorise, au détriment d'une plus grande complexité, de gérer ce type de situation.
Notons que sa fonction voisine memcpy
ne dois jamais être utilisée en cas d'aliasing. Cette fonction se veut performante, c'est-à-dire qu'elle peut être implémentée en suivant le même principe que notre exemple memory_move
. Le standard C99 ne définit pas le comportement de memcpy
pour des pointeurs qui se chevauchent.
Exercice 14.2¶
Donnez les valeurs affichées par ce programme pour les variables a
à e
.
#include <stdio.h>
#include <stdlib.h>
int test(int a, int * b, int * c, int * d) {
a = *b;
*b = *b + 5;
*c = a + 2;
d = c;
return *d;
}
int main(void) {
int a = 0, b = 100, c = 200, d = 300, e = 400;
e = test(a, &b, &c, &d);
printf("a:%d, b:%d, c:%d, d:%d, e:%d\n", a, b, c, d, e);
}