Les pointeurs

Dans ce chapitre, nous allons aborder une notion centrale du langage C : les pointeurs.

Les pointeurs constituent ce qui est appelé une fonctionnalité bas niveau, c’est-à-dire un mécanisme qui nécessite de connaître quelques détails sur le fonctionnement d’un ordinateur pour être compris et utilisé correctement. Dans le cas des pointeurs, il s’agira surtout de disposer de quelques informations sur la mémoire vive.

Présentation

Avant de vous présenter le concept de pointeur, un petit rappel concernant la mémoire s’impose (n’hésitez pas à relire le chapitre sur les variables si celui-ci s’avère insuffisant).

Souvenez-vous : toute donnée manipulée par l’ordinateur est stockée dans sa mémoire, plus précisément dans une de ses différentes mémoires (registre(s), mémoire vive, disque(s) dur(s), etc.). Cependant, pour utiliser une donnée, nous avons besoin de savoir où elle se situe, nous avons besoin d’une référence vers cette donnée. Dans la plupart des cas, cette référence est en fait une adresse mémoire qui indique la position de la donnée dans la mémoire vive.

Les pointeurs

Si l’utilisation des références peut être implicite (c’est par exemple le cas lorsque vous manipulez des variables), il est des cas où elle doit être explicite. C’est à cela que servent les pointeurs : ce sont des variables dont le contenu est une adresse mémoire (une référence, donc).

+---------+---------+
| Adresse | Contenu |
+---------+---------+
|    1    |   56.7  |
+---------+---------+
|    2    |   78    |
+---------+---------+
|    3    |   0     |
+---------+---------+
|    4    |   101   |
+---------+----+----+
               |
     +---------+
     |
+----v----+---------+
|   101   |   42    |
+---------+---------+
|   102   |   3.14  |
+---------+---------+
|   103   |   999   |
+---------+---------+

Comme vous pouvez le voir sur le schéma ci-dessus, le contenu à l’adresse 4 est lui-même une adresse et référence le contenu situé à l’adresse 101.

Utilité des pointeurs

Techniquement, il y a trois utilisations majeures des pointeurs en C :

  • le passage de références à des fonctions ;
  • la manipulation de données complexes ;
  • l’allocation dynamique de mémoire.
Passage de références à des fonctions

Rappelez-vous du chapitre sur les fonctions : lorsque vous fournissez un argument lors d’un appel, la valeur de celui-ci est affectée au paramètre correspondant, paramètre qui est une variable propre à la fonction appelée. Toutefois, il est parfois souhaitable de modifier une variable de la fonction appelante. Dès lors, plutôt que de passer la valeur de la variable en argument, c’est une référence vers celle-ci qui sera envoyée à la fonction.

Manipulation de données complexes

Jusqu’à présent, nous avons manipulé des données simples : int, double, char, etc. Cependant, le C nous permet également d’utiliser des données plus complexes qui sont en fait des agrégats (un regroupement si vous préférez) de données simples. Or, il n’est possible de manipuler ces agrégats qu’en les parcourant donnée simple par donnée simple, ce qui requiert de disposer d’une référence vers les données qui le composent.

Nous verrons les agrégats plus en détails lorsque nous aborderons les structures et les tableaux.

L’allocation dynamique de mémoire

Il n’est pas toujours possible de savoir quelle quantité de mémoire sera utilisée par un programme. En effet, si vous prenez le cas d’un logiciel de dessin, ce dernier ne peut pas prévoir quelle sera la taille des images qu’il va devoir manipuler. Pour pallier ce problème, les programmes recourent au mécanisme de l’allocation dynamique de mémoire : ils demandent de la mémoire au système d’exploitation lors de leur exécution. Pour que cela fonctionne, le seul moyen est que le système d’exploitation fournisse au programme une référence vers la zone allouée.

Déclaration et initialisation

La syntaxe pour déclarer un pointeur est la suivante.

type *nom_du_pointeur;

Par exemple, si nous souhaitons créer un pointeur sur int (c’est-à-dire un pointeur pouvant stocker l’adresse d’un objet de type int) et que nous voulons le nommer « ptr », nous devons écrire ceci.

int *ptr;

L’astérisque peut être entourée d’espaces et placée n’importe où entre le type et l’identificateur. Ainsi, les trois définitions suivantes sont identiques.

int *ptr;
int * ptr;
int* ptr;

Notez bien qu’un pointeur est toujours typé. Autrement dit, vous aurez toujours un pointeur sur (ou vers) un objet d’un certain type (int, double, char, etc.).

Initialisation

Un pointeur, comme une variable, ne possède pas de valeur par défaut, il est donc important de l’initialiser pour éviter d’éventuels problèmes. Pour ce faire, il est nécessaire de recourir à l’opérateur d’adressage (ou de référencement) : & qui permet d’obtenir l’adresse d’un objet. Ce dernier se place devant l’objet dont l’adresse souhaite être obtenue. Par exemple comme ceci.

int a = 10;
int *p;

p = &a;

Ou, plus directement, comme cela.

int a = 10;
int *p = &a;

Faites bien attention à ne pas mélanger différents types de pointeurs ! Un pointeur sur int n’est pas le même qu’un pointeur sur long ou qu’un pointeur sur double. De même, n’affectez l’adresse d’un objet qu’à un pointeur du même type.

int a;
double b;
int *p = &b; /* Faux */
int *q = &a; /* Correct */
double *r = p; /* Faux */
Pointeur nul

Vous souvenez-vous du chapitre sur la gestion d’erreurs ? Dans ce dernier, nous vous avons dit que, le plus souvent, les fonctions retournaient une valeur particulière en cas d’erreur. Quid de celles qui retournent un pointeur ? Existe-t-il une valeur spéciale qui puisse représenter une erreur ou bien sommes-nous condamnés à utiliser une variable globale comme errno ?

Heureusement pour nous, il existe un cas particulier : les pointeurs nuls. Un pointeur nul est tout simplement un pointeur contenant une adresse invalide. Cette adresse invalide dépend de votre machine, mais elle est la même pour tous les pointeurs nuls. Ainsi, deux pointeurs nuls ont une valeur égale.

Pour obtenir cette adresse invalide, il vous suffit de convertir explicitement la constante entière zéro vers le type de pointeur voulu. Ainsi, le pointeur suivant est un pointeur nul.

int *p = (int *)0;

Rappelez-vous qu’il y a conversion implicite vers le type de destination dans le cas d’une affectation. La conversion est donc superflue dans ce cas-ci.

La constante NULL

Afin de clarifier un peu les codes sources, il existe une constante définie dans l’en-tête <stddef.h> : NULL. Celle-ci peut être utilisée partout où un pointeur nul est attendu.

int *p = NULL; /* Un pointeur nul */

Utilisation

Indirection (ou déréférencement)

Maintenant que nous savons récupérer l’adresse d’un objet et l’affecter à un pointeur, voyons le plus intéressant : accéder à cet objet ou le modifier via le pointeur. Pour y parvenir, nous avons besoin de l’opérateur d’indirection (ou de déréférencement) : *.

Le symbole * n’est pas celui de la multiplication ? :euh:

Si, c’est aussi le symbole de la multiplication. Toutefois, à l’inverse de l’opérateur de multiplication, l’opérateur d’indirection ne prend qu’un seul opérande (il n’y a donc pas de risque de confusion).

L’opérateur d’indirection attend un pointeur comme opérande et se place juste derrière celui-ci. Une fois appliqué, ce dernier nous donne accès à la valeur de l’objet référencé par le pointeur, aussi bien pour la lire que pour la modifier.

Dans l’exemple ci-dessous, nous accédons à la valeur de la variable a via le pointeur p.

int a = 10;
int *p = &a;

printf("a = %d\n", *p);
Résultat
a = 10

À présent, modifions la variable a à l’aide du pointeur p.

int a = 10;
int *p = &a;

*p = 20;
printf("a = %d\n", a);
Résultat
a = 20
Passage comme argument

Voici un exemple de passage de pointeurs en arguments d’une fonction.

#include <stdio.h>

void test(int *pa, int *pb)
{
    *pa = 10;
    *pb = 20;
}


int main(void)
{
    int a;
    int b;
    int *pa = &a;
    int *pb = &b;

    test(&a, &b);
    test(pa, pb);
    printf("a = %d, b = %d\n", a, b);
    printf("a = %d, b = %d\n", *pa, *pb);
    return 0;
}
Résultat
a = 10, b = 20
a = 10, b = 20

Remarquez que les appels test(&a, &b) et test(pa, pb) réalisent la même opération.

Retour de fonction

Pour terminer, sachez qu’une fonction peut également retourner un pointeur. Cependant, faites attention : l’objet référencé par le pointeur doit toujours exister au moment de son utilisation ! L’exemple ci-dessous est donc incorrect étant donnée que la variable n est de classe de stockage automatique et qu’elle n’existe donc plus après l’appel à la fonction ptr().

#include <stdio.h>


int *ptr(void)
{
    int n;

    return &n;
}


int main(void)
{
    int *p = ptr();

    *p = 10;
    printf("%d\n", *p);
    return 0;
}

L’exemple devient correct si n est de classe de stockage statique.

Pointeur de pointeur

Au même titre que n’importe quel autre objet, un pointeur a lui aussi une adresse. Dès lors, il est possible de créer un objet pointant sur ce pointeur : un pointeur de pointeur.

int a = 10;
int *pa = &a;
int **pp = &pa;

Celui-ci s’utilise de la même manière qu’un pointeur si ce n’est qu’il est possible d’opérer deux indirections : une pour atteindre le pointeur référencé et une seconde pour atteindre la variable sur laquelle pointe le premier pointeur.

#include <stdio.h>


int main(void)
{
    int a = 10;
    int *pa = &a;
    int **pp = &pa;

    printf("a = %d\n", **pp);
    return 0;
}

Ceci peut continuer à l’infini pour concevoir des pointeurs de pointeur de pointeur de pointeur de… Bref, vous avez compris le principe. :p

Pointeurs génériques et affichage

Le type void

Vous avez déjà rencontré le mot-clé void lorsque nous avons parlé des fonctions, ce dernier permet d’indiquer qu’une fonction n’utilise aucun paramètre et/ou ne retourne aucune valeur. Toutefois, nous n’avons pas tout dit à son sujet : void est en fait un type, au même titre que int ou double. o_O

Et il représente quoi ce type, alors ?

Hum… rien (d’où son nom). :-°
En fait, il s’agit d’un type dit « incomplet », c’est à dire que la taille de ce dernier n’est pas calculable et qu’il n’est pas utilisable dans des expressions. Quel est l’intérêt de la chose me direz-vous ? Permettre de créer des pointeurs « génériques » (ou « universels »).

En effet, nous venons de vous dire qu’un pointeur devait toujours être typé. Cependant, cela peut devenir gênant si vous souhaitez créer une fonction qui doit pouvoir travailler avec n’importe quel type de pointeur (nous verrons un exemple très bientôt). C’est ici que le type void intervient : un pointeur sur void est considéré comme un pointeur générique, ce qui signifie qu’il peut référencer n’importe quel type d’objet.

En conséquence, il est possible d’affecter n’importe quelle adresse d’objet à un pointeur sur void et d’affecter un pointeur sur void à n’importe quel autre pointeur (et inversement).

int a;
double b;
void *p;
double *r;

p = &a; /* Correct */
p = &b; /* Correct */
r = p; /* Correct */
Afficher une adresse

Il est possible d’afficher une adresse à l’aide de l’indicateur de conversion p de la fonction printf(). Ce dernier attend en argument un pointeur sur void. Vous voyez ici l’intérêt d’un pointeur générique : un seul indicateur suffit pour afficher tous les types de pointeurs.

Notez que l’affichage s’effectue le plus souvent en hexadécimal.

int a;
int *p = &a;

printf("%p == %p\n", (void *)&a, (void *)p);

Tant que nous y sommes, profitons en pour voir quelle est l’adresse invalide de notre système.

printf("%p\n", (void *)0); /* Comme ceci */
printf("%p\n", (void *)NULL); /* Ou comme cela */

Oui, le plus souvent, il s’agit de zéro. :p

Exercice

Pour le moment, tout ceci doit sans doute vous paraître quelques peu abstrait et sans doute inutile. Toutefois, rassurez-vous, cela vous semblera plus clair après les chapitres suivants.

En attendant, nous vous proposons un petit exercice mettant en pratique les pointeurs : programmez une fonction nommée « swap », dont le rôle est d’échanger la valeur de deux variables de type int. Autrement dit, la valeur de la variable « a » doit devenir celle de « b » et la valeur de « b », celle de « a ».

Correction
#include <stdio.h>

void swap(int *, int *);


void swap(int *pa, int *pb)
{
    int tmp = *pa;
    *pa = *pb;
    *pb = tmp;
}


int main(void)
{
    int a = 10;
    int b = 20;

    swap(&a, &b);
    printf("a = %d, b = %d\n", a, b);
    return 0;
}

Nous en avons fini avec les pointeurs, du moins, pour le moment. :diable:
En effet, les pointeurs sont omniprésents en langage C et nous n’avons pas fini d’en entendre parler. Mais pour l’heure, nous allons découvrir une des fameuses données complexes dont nous avons parlé en début de chapitre : les structures.

En résumé
  1. Un pointeur est une variable dont le contenu est une adresse ;
  2. L’opérateur d’adressage & permet de récupérer l’adresse d’une variable ;
  3. Un pointeur d’un type peut uniquement contenir l’adresse d’un objet du même type ;
  4. Un pointeur nul contient une adresse invalide qui dépend de votre système d’exploitation ;
  5. Un pointeur nul est obtenu en convertissant zéro vers un type pointeur ou en recourant à la macroconstante NULL.
  6. L’opérateur d’indirection (*) permet d’accéder à l’objet référencé par un pointeur ;
  7. En cas de retour d’un pointeur par une fonction, il est impératif de s’assurer que l’objet référencé existe toujours ;
  8. Le type void permet de construire des pointeurs génériques ;
  9. L’indicateur de conversion %p peut être utilisé pour afficher une adresse. Une conversion vers le type void * est pour cela nécessaire.