Aller au contenu principal

Les pointeurs

Ce que ce chapitre apporte5 points
  • Déclarer un pointeur, l'initialiser avec une adresse, et lire ce qu'il désigne.
  • Employer & et * à bon escient, en sachant lequel annule l'autre.
  • Écrire une fonction qui modifie une variable de l'appelant.
  • Vérifier un pointeur avant de le déréférencer, et reconnaître un pointeur pendant.
  • Parcourir un tableau par un pointeur, et dire de combien d'octets p + 1 avance.

Un pointeur est une variable qui contient une adresse. Toute la difficulté réputée du sujet tient dans cette phrase et dans ses conséquences, pas dans une syntaxe compliquée : il n'y a que deux opérateurs à connaître, et ils se répondent.

Ce qui rend les pointeurs indispensables est une limite rencontrée deux chapitres plus tôt : une fonction reçoit des copies, donc elle ne peut pas modifier les variables de l'appelant. Lui donner une adresse lève cette limite d'un coup, et ouvre en même temps la porte à la mémoire dynamique, aux chaînes de caractères, et à toutes les structures qui grandissent.

Deux opérateurs qui se répondent

C

&mesure se lit « l'adresse de mesure ». *p se lit « la case que p désigne ». Les deux s'annulent : *(&mesure) est exactement mesure.

L'étoile a deux emplois qu'il ne faut pas confondre. Dans int *p, elle fait partie de la déclaration et signifie « p est un pointeur vers un int ». Dans *p = 7, elle est l'opérateur qui va chercher la case désignée.

Un pointeur a un type, et ce type n'est pas décoratif
int *p et char *c contiennent tous deux une adresse, de la même taille. Ce qui les distingue est ce que la machine fait de cette adresse : lire quatre octets et les interpréter comme un entier, ou lire un seul octet.
Le type d'un pointeur dit donc deux choses : combien d'octets lire, et de combien avancer quand on ajoute un.

Modifier la variable de l'appelant

C'est la raison d'être des pointeurs, et l'exemple qui la montre tient en quatre lignes.

C

La fonction reçoit deux adresses, donc deux copies d'adresses, et c'est bien un passage par valeur comme tous les autres. Mais les cases désignées, elles, sont celles de main, et personne ne les a recopiées.

À manipuler
Retirer les étoiles de la fonction et les esperluettes de l'appel, pour obtenir void echange(int a, int b) appelée par echange(x, y). Le programme s'exécute, n'affiche aucune erreur, et n'échange rien : la fonction a permuté ses deux copies.
C'est la version la plus courante de cette fonction chez qui apprend le langage, et son silence est ce qui la rend instructive.

Un pointeur qui ne désigne rien

L'adresse zéro, notée NULL, sert de valeur « ne désigne rien ». Elle n'est jamais valide, et c'est précisément pour cela qu'elle est utile : elle se teste.

C

Le bloc suivant montre ce que ce test évite.

C

Hors de ces pages, déréférencer un pointeur nul provoque l'arrêt brutal du programme par le système, sous le nom de faute de segmentation. C'est le seul cas de cette famille où la machine prévient, et elle ne prévient que parce que l'adresse zéro est interdite par le système.

Un pointeur qui désigne une case disparue

Bien plus dangereux, parce qu'il marche presque toujours.

C

Le cadre de fabrique est rendu au retour, et son contenu est le premier à être réécrit par l'appel suivant. L'interpréteur de ces pages refuse la lecture et dit pourquoi ; un vrai programme lit l'ancienne valeur, qui s'y trouve encore le plus souvent, jusqu'au jour où elle n'y est plus.

Le bogue qui apparaît quand on ajoute du code ailleurs
Un pointeur pendant produit un programme qui fonctionne pendant des mois, puis qui se met à rendre des valeurs absurdes après l'ajout d'une fonction sans rapport, parce que le nouvel appel réutilise la pile au même endroit.
La règle qui l'évite tient en une phrase : ne jamais faire sortir d'une fonction l'adresse d'une de ses variables locales. Ce qui doit survivre à la fonction se demande au tas, au chapitre sur la mémoire dynamique.

Parcourir par un pointeur

Le chapitre précédent l'a établi : le nom d'un tableau vaut l'adresse de sa première case. Un pointeur peut donc s'y promener.

C

p + 1 n'avance pas d'un octet mais d'un élément, donc de quatre octets pour un int. C'est le compilateur qui multiplie par la taille du type, et c'est pourquoi le type d'un pointeur compte.

L'écriture t[i] est d'ailleurs définie par le langage comme *(t + i). Les crochets ne sont pas une construction à part : ils sont une abréviation, et c'est ce qui explique qu'un pointeur accepte les crochets, et qu'un tableau accepte l'étoile.

À manipuler
Remplacer int *p = t; par char *p = t; puis relancer. L'écart entre p et p + 1 tombe à un octet, *p vaut toujours 10 parce que le premier octet de 10 vaut 10, et *(p + 2) donne 0 au lieu de 30 : le troisième octet du premier entier, qui ne porte rien.
Le tableau n'a pas changé, seule la lunette a changé. C'est la démonstration la plus directe de ce qu'un type de pointeur décide : combien d'octets lire, et de combien avancer.

À calculer soi-même

L'arithmétique des pointeurs se fait en éléments, et se vérifie en octets.

Avancer d'un élément

  • 1.

    Un int * vaut 2000. Que vaut ce pointeur après p + 1, en supposant un int de 4 octets ?

  • 2.

    Un char * vaut 2000. Que vaut p + 1 ?

  • 3.

    Un double * vaut 2000. Que vaut p + 3 ?

  • 4.

    Deux int * valent 2000 et 2040. Combien d'éléments les séparent ?

  • 5.

    Un tableau int t[6] commence à l'adresse 3000. Que vaut &t[4] ?

  • 6.

    Que vaut (t + 5) - (t + 5) ?

  • 7.

    Combien d'octets occupe un pointeur dans l'interpréteur de ces pages ?

Vérification

Vérification rapideon peut se reprendre

1.Que contient un pointeur ?

2.Que fait *p = 7 ?

3.De combien d'octets avance un int * quand on lui ajoute 1 ?

4.Pourquoi ne faut-il pas rendre l'adresse d'une variable locale ?

5.Qu'est-ce que NULL ?

Exercices type

Quelle est la différence entre int *p et *p, dans deux lignes différentes ?

Dans une déclaration, int *p annonce que p est un pointeur vers un int. L'étoile décrit le type.

Dans une expression, *p va chercher la case désignée par p. L'étoile est un opérateur.

La confusion vient de ce que les deux emplois se ressemblent, et elle se dissipe en lisant la déclaration ainsi : « *p est un int », ce qui est vrai des deux points de vue.

Comment écrire une fonction qui rend à la fois le minimum et le maximum d'un tableau ?

En lui passant les adresses de deux variables à remplir :

void extremes(int t[], int n, int *mini, int *maxi) {
    *mini = t[0];
    *maxi = t[0];
    for (int i = 1; i < n; i++) {
        if (t[i] < *mini) *mini = t[i];
        if (t[i] > *maxi) *maxi = t[i];
    }
}

L'appelant déclare deux variables et passe leurs adresses : extremes(mesures, 10, &plus_petit, &plus_grand);.

C'est la convention de toute la bibliothèque standard : ce qui sort par un paramètre est une adresse, et le return reste disponible pour dire si l'opération a réussi.

Un pointeur est vérifié contre NULL au début d'une fonction, puis déréférencé cinquante lignes plus bas. Le test suffit-il ?

Oui, à une condition : que rien entre les deux ne l'ait remplacé, et qu'il ne soit pas devenu pendant entre-temps, par un free ou par le retour de la fonction qui possédait la case.

Le test contre NULL ne protège que d'un seul cas, celui du pointeur jamais rempli. Il ne dit rien d'un pointeur qui désigne une case libérée : cette adresse-là n'est pas nulle, elle est simplement périmée, et aucun test ne peut la reconnaître.

C'est pourquoi la discipline habituelle consiste à remettre un pointeur à NULL juste après le free qui a rendu sa case : le test redevient alors capable de le détecter.

La méthode

  1. Lire une déclaration à l'envers : int *p se lit « *p est un int », donc « p pointe un int ».
  2. Initialiser tout pointeur à sa déclaration, par une adresse ou par NULL, jamais rien d'autre.
  3. Tester avant de déréférencer, dès que le pointeur vient d'un appel qui peut échouer.
  4. Ne jamais rendre l'adresse d'une variable locale, quoi qu'il en coûte à l'écriture.
  5. Compter en éléments quand un pointeur avance, et vérifier en octets si le doute persiste.

Synthèse

  • Un pointeur est une variable qui contient une adresse. & donne l'adresse, * donne la case désignée, et les deux s'annulent.
  • Passer une adresse à une fonction lui permet de modifier la variable de l'appelant, sans contredire le passage par valeur.
  • NULL est l'adresse zéro, réservée à « ne désigne rien », et c'est le seul défaut de pointeur qui se teste.
  • Un pointeur vers une variable locale d'une fonction revenue est pendant : il ne se teste pas, et il fonctionne souvent, ce qui le rend redoutable.
  • p + 1 avance d'un élément, donc de la taille du type pointé. t[i] est défini comme *(t + i).

Et ensuite

Un pointeur de char mérite un chapitre à lui seul, parce que le langage n'a pas de type pour le texte. Les chaînes de caractères montrent la convention qui en tient lieu, et ce qu'elle coûte.

Mettre en pratique

Adresse et déréférencement, l'arithmétique en éléments, et le pointeur pendant qui fonctionne quand même.

Tous les exercices sur les pointeurs