Pointeurs

Écrit le 11/11/2004 par Wikibooks
Dernière mise à jour : 02/02/2006

Dans cette section, nous allons présenter un mécanisme permettant de manipuler les adresses, les pointeurs. Un pointeur a pour valeur l'adresse d'un objet C d'un type donné (un pointeur est typé). Ainsi, un pointeur contenant l'adresse d'un entier sera de type pointeur vers entier.

Usage et déclaration

L'opérateur & permet de connaitre l'adresse d'une variable, on dira aussi la référence. Toute déclaration de variable occupe un certain espace dans la mémoire de l'ordinateur. La référence permet de savoir où cet emplacement se trouve. En simplifiant à l'extrême, on peut considérer la mémoire d'un ordinateur comme une gigantesque table d'octet. Quand on déclare une variable de type int, elle sera allouée à un certain emplacement (ou dit autrement : un indice, une adresse ou une référence) dans cette table. Un pointeur peut donc être vu comme un nombre allant de 0 à la quantité maximale de mémoire dont dispose votre ordinateur (moins un, pour être exact).

Un pointeur sera donc toujours de la même taille (occupera la même place en mémoire), quelque soit l'objet se trouvant à cet emplacement. Habituellement, il s'agit de la plus grande taille directement gérable par le processeur : sur une architecture 32bits, elle sera de 4 octets, sur une architecture 64bits, 8 octets, etc. Le type du pointeur ne sert qu'à renseigner comment sont organisées les données suivant l'adresse référencée par le pointeur. Ce code, par exemple, affiche la référence d'une variable au format hexadécimal :

int i;

printf("%p\n", &i);

Pouvoir récupérer l'adresse n'a d'intérêt que si on peut manipuler l'objet pointé. Pour cela, il est nécessaire de pouvoir déclarer des pointeurs, ou dit autrement un objet pouvant contenir des références. Pour cela on utilise l'étoile (*) entre le type et le nom de la variable pour indiquer qu'il s'agit d'un pointeur :

T * pointeur, * pointeur2, /* ..., */ * pointeurN;

Déclare les variables pointeur, pointeur2, ..., pointeurN de type pointeur vers le type T. À noter la bizarrerie du langage à vouloir associer l'étoile à la variable et non au type, qui oblige à répéter l'étoile pour chaque variable.

/* Ce code contient une déclaration volontairement confuse */
int * pointeur, variable;

Cet exemple de code déclare un pointeur sur un entier de type int et une variable de type int. Dans un vrai programme, il est rarement possible d'utiliser des noms aussi triviaux, aussi il est recommandé de séparer la déclaration des variables de celles des pointeurs (ou d'utiliser l'instruction typedef, qui, elle, permet d'associer l'étoile au type), la lisibilité du programme sera légèrement améliorée.

Il est essentiel de bien comprendre ce qui a été déclaré dans ces exemples. Chaque pointeur peut contenir une référence sur un emplacement de la mémoire (un indice dans notre fameuse table). On peut obtenir une référence (ou un indice) avec l'opérateur & (ou allouer une référence soit-même avec des fonctions dédiées, c.f section suivante). Cet opérateur transforme donc une variable de type T en un pointeur de type T *. Insistons sur le terme variable, car évidemment des expressions telles que '&23435' ou '&(2 * a / 3.)' n'ont aucun sens, dans la mesure où les constantes et expressions du langage n'occupent aucun emplacement en mémoire susceptible d'intéresser votre programme.

Il ne faut pas oublier que, comme toutes les variables en C, un pointeur est à l'origine non initialisé. Une bonne attitude de programmation est de s'assurer que lorsqu'il ne pointe pas vers un objet valide, sa valeur est mise à zéro (ou NULL, qui est déclaré entre autre dans stdio.h).

L'arithmétique des pointeurs

L'arithmétique associée aux pointeurs est sans doute ce qui a valu au C sa réputation « d'assembleur plus compliqué et plus lent que l'assembleur ». On peut très vite construire des expressions incompréhensibles avec les opérateurs disponibles. Dans la mesure du possible, il est conseillé de se limiter à des expressions simples, quitte à les décomposer, car la plupart des compilateurs savent très bien optimiser un code C.

Les pointeurs étant des nombres entiers, on peut s'attendre à ce que l'arithmétique soit relativement semblable à celle liée à l'arithmétique entière. À quelques restrictions près, on peut dire que c'est le cas.

Déréférencement

Il s'agit de l'opération la plus simple sur les pointeurs. Comme son nom l'indique, il s'agit de l'opération réciproque au référencement (&). L'opérateur associé est l'étoile (*, qui est aussi utilisé pour déclarer un type pointeur). Cet opérateur permet donc de transformer un pointeur de type T *, en un objet de type T, les opérations affectant l'objet pointé :

int   variable = 10;
int * pointeur = &variable;

* pointeur = 20; /* Positionne 'variable' à 20 */

Arithmétique de base

En déclarant un pointeur de type T, on indique en fait que toute la mémoire de l'ordinateur doit être gérée comme une table d'objet de type T et non plus d'octet. C'est pour cette raison qu'en C, la notion de pointeur et de tableau est intimement liée. Partant de ce principe, on imagine quels sont les effets des opérateurs arithmétiques avec les pointeurs.

int   indice   = 0;
int * pointeur = 0;

indice   = indice   + 1;
pointeur = pointeur + 1;

On comprend aisément qu'indice vaudra 1 à la fin de ce programme. Par contre, en additionnant 1 à 'pointeur', on indique qu'on veut accéder à la case mémoire suivante, mémoire dont on a demandé à ce qu'elle soit structurée en tant que table d'entier de type 'int'. Donc en additionnant (ou soustrayant) une quelconque valeur à un pointeur, la taille de l'objet pointé doit être prise en compte, afin de conserver un adressage cohérent (et éviter les problèmes d'alignement).

Les opérations arithmétiques permises avec les pointeurs sont donc :
* Addition / soustraction d'une valeur entière (on avance / recule d'un nombre de cases mémoires égal à la taille du type T c'est à dire à sizeof(T)) : le résultat est donc un pointeur.
* Soustraction de deux pointeurs de même type (combien d'objet de type T y a t-il entre les deux pointeurs) : le résultat est donc un entier.

Arithmétique avec effet de bord

C'est sans doute ce qui a donné des sueurs froides à des générations de programmeurs découvrant le C : un usage « optimisé » de la priorité des opérateurs, le tout imbriqué dans des expressions à rallonge. Par exemple 'while( *d++ = *s++ );', pour copier une chaine de caractères.

En fait, en décomposant l'instruction, c'est nettement plus simple qu'il ne parait. Par exemple :

int   i;
int * entier;

/* ... */

i = *entier++; /* i = *(entier++); */

Dans ce cas de figure, l'opérateur d'incrémentation ayant priorité sur celui de déréférencement, c'est celui-ci qui sera appliqué en premier. Comme il est postfixé, l'opérateur ne prendra effet qu'à la fin de l'expression (donc de l'affectation). La variable i sera donc tout simplement affectée de la valeur pointée par entier et après cela le pointeur sera incrémenté. Voici les différents effets suivant les combinaisons de ces deux opérateurs :

i = *++entier;     /* Incrémente d'abord le pointeur, puis déréférence la nouvelle adresse pointée */
i = ++*entier;     /* Incrémente la valeur pointée par "entier", puis affecte le résultat à "i" */
i = (*entier)++;   /* Affecte la valeur pointée par "entier" et incrémente cette valeur */

On peut évidemment complexifier les expressions à outrance, mais privilégier la compacité au détriment de la clarté et de la simplicité dans un hypothétique espoir d'optimisation est une erreur de débutant à éviter.

Gestion dynamique de la mémoire

Les déclarations de variables en C et dans pas mal d'autres langages ont une limitation très importante : leur taille doit être connue à la compilation. Inutile de s'étaler sur le fait que dans bien des cas, cette taille ne sera connue qu'au moment de l'exécution. Pour cela, il faudra nécessairement passer par de l'allocation dynamique de mémoire.

L'allocation mémoire se fait à l'aide des fonctions standards malloc, calloc et realloc et la désallocation avec free. La gestion de la mémoire en C est un point particulièrement sensible. En fait, le principal problème est que le programmeur est livré à lui même. Dans un monde idéal, chaque allocation devrait être soigneusement controllée et avoir un comportement clairement défini en cas de ressource insuffisante. Les lectures/écritures dans le bloc mémoire devraient toujours se faire dans les limites allouées et enfin chaque bloc devrait être libéré une et une seule fois par la fonction dédiée.

Dans la pratique, c'est bien plus facile à dire qu'à faire. Pour faire court, on ne saurait trop conseiller que d'être extrêmement prudent dans l'utilisation des fonctions suivantes.

Allocation

La fonction la plus simple d'allocation mémoire est malloc :

#include <stdlib.h>

void * malloc( size_t taille );

Elle prend en argument la taille que l'on veut allouer et renvoie un pointeur vers une zone mémoire allouée ou NULL si la demande échoue. Trois remarques peuvent être faites :
#malloc renvoie une valeur de type void *, il n'est pas nécessaire de faire une conversion explicite (cela est nécessaire en C++) ;
#l'argument taille est de type size_t, l'appel à la fonction malloc devrait toujours se faire conjointement à un appel à l'opérateur sizeof.
#La zone mémoire retournée, si elle est différente de NULL, n'est pas initialisée. C'est une erreur de conception grave que d'accéder au bloc mémoire en s'attendant à trouver une certaine valeur (0 par exemple).

Un petit exemple d'allocation dynamique :

int * i;

i = malloc( sizeof( int ) );

Dans cet exemple, on déclare un pointeur vers entier i auquel on alloue la place pour un entier (sizeof(int)). La valeur pointée (*i) est indéfinie.

Pour intialiser le bloc mémoire à zéro et l'allouer en même temps, on peut utiliser la fonction calloc() :

void * calloc( size_t nb_element, size_t taille );

Cette fonction allouera un bloc mémoire de nb_element * taille octets, en initialisant le bloc à 0 avant de le retourner. La valeur de retour est identique à malloc.

Réallocation

Il arrive fréquemment qu'un bloc alloué n'ait pas la taille suffisante pour accueillir de nouvelles données. Pour changer la taille d'un tel bloc, on peut employer la fonction realloc :

#include <stdlib.h>

void * realloc( void * ancien_bloc, size_t nouvelle_taille );

realloc tentera de réajuster la taille du bloc pointé par ancien_bloc à la nouvelle taille spécifiée. À noter que si nouvelle_taille vaut zéro, l'appel est équivalent à free( ancien_bloc );. De même que si ancien_bloc vaut NULL, l'appel est équivalent à malloc( nouvelle_taille );.

La fonction retourne l'adresse du nouveau bloc, qui peut avoir la même adresse qu' ancien_bloc, même si rien ne peut le garantir. NULL est retourné si le bloc n'a pas pu être retaillé, ancien_bloc ne sera alors pas modifié. Évidemment, si la taille demandée est plus grande que la taille d'origine, la mémoire supplémentaire ne sera pas initialisée.

Signalons d'emblée un piège classique, qui consiste à écrire :

/* Ce code contient une erreur grossière et volontaire */
mon_pointeur = realloc( mon_pointeur, taille + 1024 );

Ce code contient en fait une fuite mémoire. Si realloc échouait, la dernière référence valide de mon_pointeur serait perdue, ainsi que tout espoir de pouvoir libérer la mémoire. C'est d'autant plus navrant que ce genre de construction est souvent employée pour lire des lignes arbitrairement longues d'un fichier texte, par exemple. Une bonne façon de faire, serait :

void * nouveau = realloc( mon_pointeur, taille + 1024 );

if( nouveau != NULL )
{
    mon_pointeur = nouveau;
    /* Continuez le traitement ... */
}
else
{
    free( mon_pointeur );
    return UNE_ERREUR;
}

C'est certes beaucoup plus long, beaucoup plus pénible à écrire, mais la stabilité d'un programme est à ce prix.

Libération

Le C ne possède pas de mécanisme de ramasse-miettes, la mémoire allouée dynamiquement par un programme doit donc être explicitement libérée.

void free( void * pointeur );

La fonction prend en argument un pointeur vers une zone mémoire précédemment allouée par un appel à malloc, calloc ou realloc et libère la zone mémoire pointée.

Est-il nécessaire d'indiquer qu'accéder au contenu d'un bloc mémoire libéré est un grave défaut de conception, pouvant au mieux passer inaperçu, ou au pire provoquer un arrêt brutal du programme ? Un bon réflexe de programmation est d'assigner la valeur 'NULL' à tout pointeur dont le bloc vient d'être libéré.

De même que libérer deux fois le même bloc mémoire ou libérer un bloc qui n'a pas été alloué par une des fonctions d'allocation provoque un comportement imprévisible. En général, on peut s'attendre à une corruption de l'espace mémoire, extrêmement pénible à diagnostiquer, car les problèmes surviennent rarement au moment de l'appel fautif. C'est ce qui fait que le programme peut provoquer des accès illégaux dans des portions de code que l'on croyait exemptes de défaut (ou suffisament triviales pour ne pas en contenir).

Encore une fois, une attention particulière doit être portée à la gestion de la mémoire en C, afin d'éviter autant que possible, d'une part les fuites mémoires et d'autre part les accès illégaux en lecture/écriture. Le C n'étant pas d'une très grande aide dans ce domaine, il faut avoir une certaine acuité, ingéniosité, et même paranoïa dans la gestion des ressources en C. Sans quoi la loi de Murphy vous fera cruellement rappeler que si le pire peut arriver, alors c'est ce qui arrivera.

Tableaux dynamiques

Un des interêts des pointeurs et de l'allocation dynamique est de permettre de décider de la taille d'une variable au moment de l'exécution, comme par exemple pour les tableaux. Ainsi pour allouer un tableau de N entiers (N étant connu à l'exécution), on déclare une variable de type pointeur sur entier à laquelle on alloue une zone mémoire correspondant à N entiers :

int N;
int * tableau;

N = 10;
tableau = malloc( sizeof(int) * N );

/* opérations sur le tableau */

free( tableau );

La première remarque que l'on peut faire sur cet exemple est que c'est un mauvais exemple, la taille du tableau est connue (N = 10), on devrait donc utiliser un tableau statique.

Tableaux dynamiques à plusieurs dimensions

Tout comme on pouvait déclarer des tableaux statiques à plusieurs dimensions, on peut déclarer des tableaux dynamiques à plusieurs dimensions. Pour déclarer un tel tableau, on déclare des pointeurs sur des pointeurs (etc.) sur des types. Pour déclarer un tableau dynamique d'entiers à deux dimensions :

int ** matrice;

L'allocation d'un tel objet va se derouler en plusieurs étapes (une par étoile), on alloue d'abord l'espace pour un tableau de pointeurs vers entier. Ensuite, on alloue pour chacun de ces tableaux l'espace pour un tableau d'entiers. Si on veut une matrice 4x5 :

#define LIGNES   4
#define COLONNES 5
int i;

matrice = malloc( sizeof(int *) * LIGNES );

for(i = 0;i < 4;i++)
{
    matrice[i] = malloc( sizeof(int) * COLONNES );
}

Pour libérer l'espace alloué pour une telle structure, on procède de manière inverse, on commence par libérer chacune des lignes du tableau, puis le tableau lui même :

for(i = 0;i < 4;i++)
{
    free(matrice[i]);
}
free(matrice);

Utilisation des pointeurs pour passer des paramètres par adresse

Une autre utilisation des pointeurs est pour passer des paramètres par adresse aux fonctions.

void inverse(int * a, int *b)
{
    int tmp;
    tmp = *a;
    *a = *b;
    *b = tmp;
}

Pointeurs vers fonctions

type_retour (*pointeur_fonction)(liste_paramètres);

Déclare pointeur_fonction, un pointeur vers une fonction prenant liste_paramètres comme paramètres et renvoyant type_retour. Le parenthésage est ici obligatoire, sans quoi l'étoile se rattacherait au type de retour. Pour faire pointer un pointeur vers une fonction, on utilise une affectation « normale » :

pointeur_fonction = &fonction;
/* Qui est en fait équivalent à : */
pointeur_fonction = fonction;

fonction est compatible avec le pointeur (mêmes paramètres et valeur de retour). Une fois que le pointeur pointe vers une fonction, on peut appeler cette fonction :

(*pointeur_fonction)(paramètres);
/* Ou plus simplement, mais moins logique syntaxiquement */
pointeur_fonction(paramètres);

Cet article provient de Wikibooks et est sous licence GNU Free Documentation License. Il a été écrit par plusieurs personnes et est constamment mis à jour. Cet article est la version du 6 mai 2005 à 8:28. L'article d'origine se trouve à http://fr.wikibooks.org/wiki/Programmation_C_Pointeurs.