pdf - e-book - archive - github.com

1.10  Pointeurs

La notion de pointeur est très importante en C, elle va vous permettre de comprendre le fonctionnement d’un programme, de programmer de façon davantage propre et performante, et surtout de concevoir des programmes que vous ne pourriez pas mettre au point sans cela.

Une des premières choses à comprendre quand on programme, c’est qu’une variable est un emplacement de la mémoire dans lequel vous allez placer une valeur. En programmant, vous utilisez son nom pour y lire ou écrire une valeur. Mais ce qui se passe au coeur de la machine est quelque peu plus complexe, les noms que l’on donne aux variables servent à masquer cette complexité.

Pour le compilateur, une variable est un emplacement dans la mémoire, cet emplacement est identifié par une adresse mémoire. Une adresse est aussi une valeur, mais cette valeur sert seulement à spécifier un emplacement dans la mémoire. Lorsque vous utilisez le nom d’une variable, le compilateur le remplace par une adresse, et manipule la variable en utilisant son adresse.

Ce système est ouvert, dans le sens où vous pouvez décider d’utiliser l’adresse d’une variable au lieu d’utiliser son nom. Pour ce faire, on utilise des pointeurs.

1.10.1  Introduction

Un pointeur est une variable qui contient l’adresse mémoire d’une autre variable.

Déclaration

T* est le type d’une variable contenant l’adresse mémoire d’une variable de type T. Si une variable p de type T* contient l’adresse mémoire d’une variable x de type T, on dit alors que p pointe vers x (ou bien sur x). &x est l’adresse mémoire de la variable x. Exposons cela dans un exemple,

#include<stdio.h>

int main()
{
  int x = 3;
  int* p;
  p = &x;
  return 0;
}

Télécharger le fichier

x est de type int. p est de type int*, c’est à dire de type pointeur de int, p est donc faite pour contenir l’adresse mémoire d’un int. &x est l’adresse mémoire de la variable x, et l’affectation p = &x place l’adresse mémoire de x dans le pointeur p. A partir de cette affectation, p pointe sur x.

Affichage

La chaîne de format d’une adresse mémoire est ”%X”. On affiche donc une adresse mémoire (très utile pour débugger :-) comme dans l’exemple ci-dessous,

#include<stdio.h>

int main()
{
  int x = 3;
  int* p;
  p = &x;
  printf("p contient la valeur %X, qui n'est autre que l'adresse %X de x\n", 
  p, &x);
  return 0;
}

Télécharger le fichier

Accès à la variable pointée

Le lecteur impatient se demande probablement à quoi peuvent servir toutes ces étoiles ? Quel peut bien être l’intérêt des pointeurs ?

Si p pointe sur x, alors il est possible d’accéder à la valeur de x en passant par p. Pour le compilateur, *p est la variable pointée par p, cela signifie que l’on peut, pour le moment du moins, utiliser indifférement *p ou x. Ce sont deux façons de se référer à la même variable, on appelle cela de l’aliasing. Explicitons cela sur un exemple,

#include<stdio.h>

int main()
{
  int x = 3;
  int* p;
  p = &x;
  printf("x = %d\n", x);
  *p = 4;
  printf("x = %d\n", x);
  return 0;
}

Télécharger le fichier

L’affectation p=&x fait pointer p sur x. A partir de ce moment, *p peut être utilisé pour désigner la variable x. De ce fait, l’affectation x=4 peut aussi être écrite *p=4. Toutes les modifications opérées sur *p seront répercutées sur la variable pointée par p. Donc ce programme affiche

x = 3
x = 4

Récapitulons

Qu’affiche, à votre avis, le programe suivant ?

#include<stdio.h>

int main()
{
  int x = 3;
  int y = 5;
  int* p;
  p = &x;
  printf("x = %d\n", x);
  *p = 4;
  printf("x = %d\n", x);
  p = &y;
  printf("*p = %d\n", *p);
  *p = *p + 1;
  printf("y = %d\n", y);  
  return 0;
}

Télécharger le fichier

x est initialisé à 3 et y est initialisé à 5. L’affectation p = &x fait pointer p sur x, donc *p et x sont deux écritures différentes de la même variable. Le premier printf affiche la valeur de x : plus précisément x = 3. Ensuite, l’affectation *p = 4 place dans la valeur pointée par p, à savoir x, la valeur 4. Donc le deuxième printf affiche x = 4. L’affectation p = &y fait maintenant pointer p sur y, donc la valeur de *p est la valeur de la variable pointée y. le troisième printf affiche donc *p = 5. N’oubliez pas que comme p pointe sur y, alors *p et y sont deux alias pour la même variable, de ce fait, l’instruction *p = *p + 1 peut tout à fait s’écrire y = y + 1. Cette instruction place donc dans y la valeur 6, le quatrième printf affiche donc y = 6. Ce programme affiche donc :

x = 3
x = 4
*p = 5
y = 6

1.10.2  Tableaux

Il est possible d’aller plus loin dans l’utilisation des pointeurs en les employant pour manipuler des tableaux. Tout d’abord, éclaircissons quelques points.

Démystification (et démythification) du tableau en C

Les éléments d’un tableau sont juxtaposés dans la mémoire. Autrement dit, ils sont placés les uns à coté des autres. Sur le plan de l’adressage, cela a une conséquence fort intuitive. Sachant qu’un int occupe 2 octets en mémoire et que &T[0] est l’adresse mémoire du premier élément du tableau T, quelle est l’adresse mémoire de T[1] ? La réponse est la plus simple : &T[0] + 2 (ne l’écrivez jamais ainsi dans un programme, vous verrez pourquoi plus loin dans le cours...). Cela signifie que si l’on connait l’adresse d’un élément d’un tableau (le premier en l’occurrence), il devient possible de retrouver les adresses de tous les autres éléments de ce tableau.

J’ai par ailleurs, une assez mauvaise surprise pour vous. Vous utilisez sans le savoir des pointeurs depuis que vous utilisez des tableaux. Etant donné la déclaration int T[50], vous conviendrez que les désignations {T[0], …, T[49]} permettent de se référer aux 50 éléments du tableau T. Mais vous est-il déjà arrivé, en passant un tableau en paramètre, d’écrire T sans écrire d’indice entre crochets ? Par exemple,

#include<stdio.h>

void initTab(int K[], int n)
{
  int i;
  for(i = 0 ; i < n ; i++)
    K[i] = i + 1;
}

void afficheTab(int K[], int n)
{
  int i;
  for(i = 0 ; i < n ; i++)
    printf("%d\n", K[i]);
}

int main()
{
  int T[50];
  initTab(T, 50);
  afficheTab(T, 50);
  return 0;
}

Télécharger le fichier

Vous remarquez que lorsque l’on passe un tableau en paramètre à un sous-programme, on mentionne seulement son nom. En fait, le nom d’un tableau, T dans l’exemple ci-avant, est l’adresse mémoire du premier élément de ce tableau. Donc, T est une variable contenant une adresse mémoire, T est par conséquent un pointeur. Lorsque dans un sous-programme auquel on a passé un tableau en paramètre, on mentionne un indice, par exemple K[i], le compilateur calcule l’adresse du i-ème élément de K pour lire ou écrire à cette adresse.

Utilisation des pointeurs

Commençons par observer l’exemple suivant :

#include<stdio.h>

int main()
{
  char t[10];
  char* p;
  t[0] = 'a';
  p = t;
  printf("le premier element du tableau est %c.\n", *p);
  return 0;
}

Télécharger le fichier

La variable t contient l’adresse mémoire du premier élément du tableau t. p est un pointeur de char, donc l’affectation p = t place dans p l’adresse mémoire du premier élément du tableau t. Comme p pointe vers t[0], on peut indifférement utiliser *p ou t[0]. Donc ce programme affiche

le premier element du tableau est a.

Calcul des adresses mémoire

Tout d’abord, je tiens à rappeler qu’une variable de type char occupe un octet en mémoire. Considérons maintenant les déclarations

char t[10] ;

et

charp = t ;

Nous savons que si p pointe vers t[0], il est donc aisé d’accéder au premier élément de t en utilisant le pointeur p. Mais comment accéder aux autres éléments de t ? Par exemple T[1] ? Souvenez-vous que les éléments d’un tableau sont juxtaposés, dans l’ordre, dans la mémoire. Par conséquent, si p est l’adresse mémoire du premier élément du tableau t, alors (p + 1) est l’adresse mémoire du deuxième élément de ce tableau. Vous êtes conviendrez que *p est la variable dont l’adresse mémoire est contenue dans p. Il est possible, plus généralement, d’écrire *(p + 1) pour désigner la variable dont l’adresse mémoire est (p + 1), c’est à dire (la valeur contenue dans p) + 1. Illustrons cela dans un exemple, le programme

#include<stdio.h>

int main()
{
  char t[10];
  char* p;
  t[1] = 'b';
  p = t;
  printf("le deuxieme element du tableau est %c.\n", *(p+1));
  return  0;
}

Télécharger le fichier

affiche

le deuxième element du tableau est b.

En effet, p+1 est l’adresse mémoire du deuxième élément de t, il est donc possible d’utiliser indifférement *(p+1) et t[1]. Plus généralement, on peut utiliser *(p + i) à la place de t[i]. En effet, (p + i) est l’adresse du i-ème élément de t, et *(p + i) est la variable dont l’adresse mémoire est (p + i). Par exemple,

#include<stdio.h>
#define N 26

int main()
{
  char t[N];
  char v = 'A';
  char* p;
  int i;
  p = t;

  /* initialisation du tableau*/
  for (i = 0 ; i < N ; i++)
    *(p + i) = v++;

  /* affichage du tableau*/
  for(i = 0 ; i < N ; i++)
    printf("%c ", *(p + i));

  printf("\n");
  return 0;
}

Télécharger le fichier

Ou encore, en utilisant des sous-programmes,

#include<stdio.h>
#define N 26

void initTab(char* k, int n)
{
  int i;
  int v = 'A';
  for(i = 0 ; i < n ; i++, k++, v++)
     *k = v;
}

void afficheTab(char* k, int n)
{
  int i;
  for(i = 0 ; i < n ; i++, k++)
    printf("%c ", *k);
  printf("\n");
}

int main()
{
  char t[N];
  initTab(t, N);
  afficheTab(t, N);
  return 0;
}

Télécharger le fichier

Ces deux sous-programmes affichent

A B C D E F G H I J K L M N O P Q R S T U V W X Y Z

Arithmétique des pointeurs

Supposons que le tableau t contienne des int, sachant qu’un int occupe 2 octets en mémoire. Est-ce que (t + 1) est l’adresse du deuxième élément de t ?

Mathématiquement, la réponse est non, l’adresse du deuxième élément est t + (la taille dun int) = (t + 2). Etant donné un tableau p d’éléments occupant chacun n octets en mémoire, l’adresse du i-ème élément est alors p + i × n.

Cependant, la pondération systématique de l’indice par la taille occupée en mémoire par chaque élément est d’une part une lourdeur dont on se passerait volontier, et d’autre part une source d’erreurs et de bugs. Pour y remédier, le compilateur prend cette partie du travail en charge,on ne pondérera donc pas les indices ! Cela signifie, plus explicitement, que quel que soit le type des éléments du tableau p, l’adresse mémoire du i-ème élément de p est p + i. On le vérifie expérimentalement en exécutant le programme suivant :

#include<stdio.h>
#define N 30

void initTab(int* k, int n)
{
  int i;
  *k = 1;
  for(i = 1, k++ ; i < n ; i++, k++)
     *k = *(k - 1) + 1;
}

void afficheTab(int* k, int n)
{
  int i;
  for(i = 0 ; i < n ; i++, k++)
    printf("%d ", *k);
  printf("\n");
}

int main()
{
  int t[N];
  initTab(t, N);
  afficheTab(t, N);
  return 0;
}

Télécharger le fichier

1.10.3  Allocation dynamique de la mémoire

La lecture du chapitre précédent, si vous y avez survécu, vous a probablement mené à une question que les élèves posent souvent : ”Monsieur pourquoi on fait ça ?”. C’est vrai ! Pourquoi on manipulerait les tableaux avec des pointeurs alors que dans les exemples que je vous ai donné, on peut le faire sans les pointeurs ? Dans la mesure où un tableau est un pointeur, on peut, même à l’intérieur d’un sous-programme auquel un tableau a été passé en paramètre, manipuler ce tableau avec des crochets. Alors dans quel cas utiliserons-nous des pointeurs pour parcourir les tableaux ?

De la même façon qu’il existe des cas dans lesquels on connait l’adresse d’une variable scalaire mais pas son nom, il existe des tableaux dont on connait l’adresse mais pas le nom.

Un problème de taille

Lorsque l’on déclare un tableau, il est obligatoire de préciser sa taille. Cela signifie que la taille d’un tableau doit être connue à la compilation. Alors que faire si on ne connait pas cette taille ? La seule solution qui se présente pour le moment est le surdimensionnement, on donne au tableau une taille très (trop) élevée de sorte qu’aucun débordement ne se produise.

Nous aimerions procéder autrement, c’est à dire préciser la dimension du tableau au moment de l’exécution. Nous allons pour cela rappeler quelques principes. Lors de la déclaration d’un tableau t, un espace mémoire alloué au stockage de la variable t, c’est à dire la variable qui contient l’adresse mémoire du premier élément du tableau. Et un autre espace mémoire est alloué au stockage des éléments du tableau. Il y a donc deux zones mémoires utilisées.

La fonction malloc

Lorsque vous déclarez un pointeur p, vous allouez un espace mémoire pour y stocker l’adresse mémoire d’un entier. Et p, jusqu’à ce qu’on l’initialise, contient n’importe quoi. Vous pouvez ensuite faire pointer p sur l’adresse mémoire que vous voulez (choisissez de préférence une zone contenant un int...). Soit cette adresse est celle d’une variable qui existe déjà. Soit cette adresse est celle d’un espace mémoire créée spécialement pour l’occasion.

Vous pouvez demander au système d’exploitation de l’espace mémoire pour y stocker des valeurs. La fonction qui permet de réserver n octets est malloc(n). Si vous écrivez malloc(10), l’OS réserve 10 octets, cela s’appelle une allocation dynamique, c’est à dire une allocation de la mémoire au cours de l’exécution. Si vous voulez réserver de l’espace mémoire pour stocker un int par exemple, il suffit d’appeler malloc(2), car un int occupe 2 octets en mémoire.

En même temps, c’est bien joli de réserver de l’espace mémoire, mais ça ne sert pas à grand chose si on ne sait pas où il se trouve ! C’est pour ça que malloc est une fonction. malloc retourne l’adresse mémoire du premier octet de la zone réservée. Par conséquent, si vous voulez créer un int, il convient d’exécuter l’instruction : p = malloc(2) où p est de type int*. Le malloc réserve deux octets, et retourne l’adresse mémoire de la zone allouée. Cette affectation place donc dans p l’adresse mémoire du int nouvellement créé.

Cependant, l’instruction p = malloc(2) ne peut pas passer la compilation. Le compilateur vous dira que les types void* et int* sont incompatibles (incompatible types in assignement). Pour votre culture générale, void* est le type ”adresse mémoire” en C. Alors que int* est le type ”adresse mémoire d’un int”. Il faut donc dire au compilateur que vous savez ce que vous faites, et que vous êtes sûr que c’est bien un int que vous allez mettre dans la variable pointée. Pour ce faire, il convient d’effctuer ce que l’on appelle un cast, en ajoutant, juste après l’opérateur d’affectation, le type de la variable se situant à gauche de l’affectation entre parenthèses. Dans l’exemple ci-avant, cela donne : int* p = (int*)malloc(2).

Voici un exemple illustrant l’utilisation de malloc.

#include<stdio.h>
#include<stdlib.h>

int main()
{
  int* p;
  p = (int*)malloc(sizeof(int));
  *p = 28;
  printf("%d\n", *p);
  return 0;
}

Télécharger le fichier

Vous remarquez que nous sommes bien dans un cas où l’on connait l’adresse d’une variable mais pas son nom. Le seul moyen de manier la variable allouée dynamiquement est d’utiliser un pointeur.

La fonction free

Lorsque que l’on effectue une allocation dynamique, l’espace réservé ne peut pas être alloué pour une autre variable. Une fois que vous n’en avez plus besoin, vous devez le libérer explicitement si vous souhaitez qu’une autre variable puisse y être stockée. La fonction de libération de la mémoire est free. free(v) où v est une variable contenant l’adresse mémoire de la zone à libérer. A chaque fois que vous allouez une zone mémoire, vous devez la libérer ! Un exemple classique d’utilisation est :

#include<stdio.h>
#include<stdlib.h>

int main()
{
  int* p;
  p = (int*)malloc(sizeof(int));
  *p = 28;
  printf("%d\n", *p);
  free(p);
  return 0;
}

Télécharger le fichier

Notez bien que la variable p, qui a été allouée au début du main, a été libéré par le free(p).

La valeur NULL

La pointeur p qui ne pointe aucune adresse a la valeur NULL. Attention, il n’est pas nécessairement initialisé à NULL, NULL est la valeur que, conventionnellement, on décide de donner à p s’il ne pointe sur aucune zone mémoire valide. Par exemple, la fonction malloc retourne NULL si aucune zone mémoire adéquate n’est trouvée. Il convient, à chaque malloc, de vérifier si la valeur retournée par malloc est différente de NULL. Par exemple,

#include<stdio.h>
#include<stdlib.h>

int main()
{
  int* p;
  p = (int*)malloc(sizeof(int));
  if(p == NULL)
    exit(0);
  *p = 28;
  printf("%d\n", *p);
  free(p);
  return 0;
}

Télécharger le fichier

Vous remarquez que le test de non nullité de la valeur retournée par malloc est effectué immédiatement après l’allocation dynamique. Vous ne devez jamais utiliser un pointeur sans avoir vérifié sa validité, autrement dit, sa non-nullité. Un pointeur contenant une adresse non valide est appelé un pointeur fou. Vous devrez, dans votre vie de programmeur, les traquer avec hargne !

L’allocation dynamique d’un tableau

Lors de l’allocation dynamique d’un tableau, il est nécessaire de déterminer la taille mémoire de la zone de la zone à occuper. Par exemple, si vous souhaitez allouer dynamiquament un tableau de 10 variables de type char. Il suffit d’exécuter l’instruction malloc(10), car un tableau de 10 char occupe 10 octets en mémoire. Si par contre, vous souhaitez allouer dynamiquement un tableau de 10 int, il conviendra d’exécuter malloc(20), car chaque int occupe 2 octets en mémoire.

Pour se simplifier la vie, le compilateur met à notre disposition la fonction sizeof, qui nous permet de calculer la place prise en mémoire par la variable d’un type donné. Par exemple, soit T un type, la valeur sizeof(T) est la taille prise en mémoire par une variable de type T. Si par exemple on souhaite allouer dynamiquement un int, il convient d’exécuter l’instruction malloc(sizeof(int)). Attention, sizeof prend en paramètre un type !

Si on souhaite allouer dynamiquement un tableau de n variables de type T, on exécute l’instruction malloc(n * sizeof(T)). Par exemple, pour allouer un tableau de 10 int, on exécute malloc(10 * sizeof(int)). Voici une variante du programme d’un programme précédent :

#include<stdio.h>
#include<stdlib.h>
#define N 26

char* initTab(int n)
{
  char* k;
  int i;
  char value = 'a';
  k = (char*)malloc(n*sizeof(char));
  if (k == NULL)
    return NULL;
  for(i = 0 ; i < n ; i++, value++)
     *(k + i) = value;
  return k;
}

void afficheTab(char* k, int n)
{
  int i;
  for(i = 0 ; i < n ; i++, k++)
    printf("%c ", *k);
  printf("\n");
}

int main()
{
  char* p = initTab(N);
  if (p == NULL)
    return -1;
  afficheTab(p, N);
  free(p);
  return 0;
}

Télécharger le fichier

1.10.4  Passage de paramètres par référence

J’ai dit tout à l’heure : ”Pour le compilateur, *p est la variable pointée par p, cela signifie que l’on peut, pour le moment du moins, utiliser indifférement *p ou x”. En précisant ”pour le moment du moins”, j’avais déjà l’intention de vous montrer des cas dans lesquels ce n’était pas possible. C’est à dire des cas dans lesquels on connait l’adresse d’une variable mais pas son nom.

Permutation de deux variables

A titre de rappel, observez attentivement le programme suivant :

#include<stdio.h>

void echange(int* x, int* y)
{
  int t = *x;
  *x = *y; 
  *y = t;
}

int main()
{
  int a = 1;
  int b = 2;
  printf("a = %d, b = %d\n", a, b);
  echange(&a, &b);
  printf("a = %d, b = %d\n", a, b);
  return 0;
}

Télécharger le fichier

A votre avis, affiche-t-il

a = 1, b = 2
a = 2, b = 1

ou bien

a = 1, b = 2
a = 1, b = 2

Méditons quelque peu : la question que l’on se pose est ”Est-ce que le sous-programme echange échange bien les valeurs des deux variables a et b” ? Il va de soi qu’il échange bien les valeurs des deux variables x et y, mais comme ces deux variables ne sont que des copies de a et b, cette permutation n’a aucun effet sur a et b. Cela signifie que la fonction echange ne fait rien, on aurait pu écrire à la place un sous-programme ne contenant aucune instruction, l’effet aurait été le même. Ce programme affiche donc

a = 1, b = 2
a = 1, b = 2

Remarques

Ceux dont la mémoire n’a pas été réinitialisé pendant les vacances se souviennent certainement du fait qu’il était impossible de passer en paramètre des variables scalaires par référence. J’ai menti, il existe un moyen de passer des paramètres par référence, et vous aviez des indices vous permettant de vous en douter ! Par exemple, l’instruction scanf("%d", & x) permet de placer une valeur saisie par l’utilisateur dans x, et scanf est un sous-programme... Vous conviendrez donc que la variable x a été passée en paramètre par référence. Autrement dit, que la valeur de x est modifiée dans le sous-programme scanf, donc que la variable permettant de désigner x dans le corps de ce sous-programme n’est pas une copie de x, mais la variable x elle-même, ou plutôt un alias de la variable x.

Vous pouvez d’ores et déjà retenir que

nomsousprogramme(…, &x, …)

sert à passer en paramètre la variable x par référence. Et finalement, c’est plutôt logique, l’instruction

nomsousprogramme(…, x, …)

passe en paramètre la valeur de x, alors que

nomsousprogramme(…, &x, …)

passe en paramètre l’adresse de x, c’est-à-dire un moyen de retrouver la variable x depuis le sous-programme et de modifier sa valeur.

Cependant, si vous écrivez echange(&a, &b), le programme ne compilera pas... En effet, le sous-programme echange prend en paramètre des int et si vous lui envoyez des adresses mémoire à la place, le compilateur ne peut pas ”comprendre” ce que vous voulez faire... Vous allez donc devoir modifier le sous-programme echange si vous voulez lui passer des adresses mémoire en paramètre.

Utilisation de pointeurs

On arrive à la question suivante : dans quel type de variable puis-je mettre l’adresse mémoire d’une variable de type entier ? La réponse est int*, un pointeur sur int. Observons le sous-programme suivant,

void echange(int* x, int* y)
{
  int t = *x;
  *x = *y;
  *y = t;
}

x et y ne sont pas des int, mais des pointeurs sur int. De ce fait le passage en paramètre des deux adresses &a et &b fait pointer x sur a et y sur b. Donc *x est un alias de a et *y est un alias de b. Nous sommes, comme décrit dans l’introduction de ce chapitre dans un cas dans lequel on connait l’adresse d’une variable, mais pas son nom : dans le sous-programme echange, la variable a est inconnue (si vous l’écrivez, ça ne compilera pas...), seul le pointeur *x permet d’accéder à la variable a.

Il suffit donc, pour écrire un sous-programme prenant en paramètre des variables passées par référence, de les déclarer comme des pointeurs, et d’ajouter une * devant à chaque utilisation.

1.10.5  Pointeurs sur fonction