Tableaux et pointeurs

Le rapport entre tableaux et pointeurs est confus pour la plupart des débutants en C. Le langage lui-même fait tout pour induire cette confusion et il est aidé en cela par les documents à destination des débutants qui présentent les choses avec des variations sur « les tableaux sont des pointeurs ».

Les tableaux ne sont pas des pointeurs, mais il est quasiment impossible de manipuler des tableaux sans se servir des pointeurs en C, et si la syntaxe rend la chose aisée c'est au risque de créer de la confusion.

Pointeurs

Les pointeurs, c'est une famille de types dont les valeurs possibles sont les adresses des objets d'un type donné. Par exemple les pointeurs vers int ont pour valeur les adresses des objets de type int.

On référence l'objet (et on déréference le pointeur) en utilisant l'opérateur unaire préfixe *. La syntaxe des déclarations en C mimiquant la syntaxe de l'utilisation, c'est aussi avec cette opérateur que l'on déclare les variables de type pointeur. Par exemple int *ptr; déclare une variable ptr de type pointeur vers int et dans la portée de cette déclaration *ptr désigne l'object de type int pointé par ptr.

On peut former un pointeur vers un objet en utilisant l'opérateur unaire préfixe &. Par exemple, dans le contexte de int i;, &i est un pointeur vers int dont la valeur est l'adresse de i.

On combine fréquemment le déréférencement d'un pointeur et l'accès à un membre d'une structure. À cause de cette fréquence, il y a une syntaxe spéciale: (*p).m peut se noter p->m.

On peut additionner les pointeurs avec des entiers. On considère alors que le pointeur pointe vers une séquence d'objets contigus en mémoire et de même type. Le résultat de l'addition d'un pointeur avec n est un pointeur vers le nième élément de la séquence.

On utilise fréquemment le déréférencement du résultat de l'addition d'un pointeur et d'un entier. À cause de cette fréquence, il y a une syntaxe spéciale : *(p+n) peut se noter p[n][1].

Tableaux

Les tableaux, c'est une famille de types dont les valeurs possibles sont les séquences non vides d'objets contigus en mémoire d'un type donné. Par exemple, les tableaux de 4 int ont pour valeurs les séquences de 4 int.

Les tableaux sont des types défavorisés en C. On peut déclarer des variables. On peut construire un pointeur vers eux avec l'opérateur &. On peut avoir leur taille avec sizeof. C'est tout. On ne peut pas les copier, que ce soit pour les assigner ou les passer en paramètre.

Pour faire à peu près n'importe quoi d'intéressant, il faut passer par les pointeurs et l'arithmétique sur les pointeurs. Mais pour ce faire, il faut avoir un pointeur vers un élément. On peut faire tellement peu de choses avec les tableaux et obtenir un pointeur vers le premier élément d'un tableau est une opération tellement courante qu'elle est implicite : les tableaux sont convertis en un pointeur vers leur premier élément dans la plupart des contextes.

Tableau unidimensionnel

Pour définir un tableau tab de n int, on utilise

int tab[n];

n doit être une expression constante entière[2]. Pour accéder à la taille en bytes du tableau, on utilise sizeof tab. Si on veut un pointeur vers le tableau, il faut utiliser &tab qui est de type pointeur vers tableau de n int. Dans tous les autres contextes, tab sera converti en un pointeur vers le premier int du tableau.

Si on veut la taille en nombre d'éléments, il faut diviser la taille en bytes du tableau par la taille en bytes d'un élément, donc sizeof tab/sizeof *tab. Dans l'expression sizeof *tab, tab est d'abord converti en un pointeur vers le premier élément du tableau, ce pointeur est déréférencé et on prend la taille de l'objet résultant, c'est donc bien la taille du premier élément du tableau.

Pour accéder au iième élément du tableau, on utilise tab[i]. À nouveau tab est converti en un pointeur vers le premier élément du tableau, on ajoute i à ce pointeur ce qui donne un pointeur vers le iième élément du tableau et on déréférence ce pointeur.


On ne peut pas copier les tableaux, donc on ne peut pas les passer en paramètre à une fonction. à la place, on peut passer un pointeur vers le tableau. Pour passer un pointeur vers un tableau de n int à une fonction, la fonction doit avoir pour prototype :

void f(int (*pTab)[n]);

Elle s'appelle avec f(&tab). Dans la fonction, on peut récupérer la taille en bytes du tableau avec sizeof *pTab, la taille en nombre d'éléments avec sizeof *pTab/sizeof **pTab (dans sizeof **pTab, *pTab est un tableau qui n'est pas utilisé comme argument de sizeof ou de &, donc il est converti en un pointeur vers son premier élément, cette conversion n'est pas réservée au tableau qui sont nommés, et l'exemple de *pTab montre que si on ne peut pas faire grand chose avec les tableaux, il est cependant possible d'avoir des expressions de type tableau plus compliquées qu'une variable). On peut accéder au iième élément avec (*pTab)[i].


Tout cela n'est pas très agréable à utiliser, en particulier une fonction ne peut pas manipuler des tableaux de taille inconnue. Mais comme tout passe de toute manière par les pointeurs, en pratique quand on veut une fonction qui prend un tableau, on lui passe d'une part un pointeur vers son premier élément, et d'autre part la taille. Le prototype ressemble donc a :

void f(int *pTab, size_t n);

Elle s'appelle avec f(tab, sizeof tab/sizeof *tab). Dans la fonction, on peut récupérer la taille en bytes du tableau avec n*sizeof *pTab, la taille en nombre d'éléments avec n. On peut accéder au iième élément avec pTab[i]. C'est quand même beaucoup plus simple à utiliser même s'il faut passer la taille explicitement et ne pas utiliser dans la fonction sizeof pTab ou sizeof *pTab pour obtenir la taille du tableau.


De même, quand on veut allouer un tableau dynamiquement, on n'utilise généralement pas

int (*pTab)[n] = malloc(sizeof *pTab);

en conjonction avec (*pTab)[i], mais on fait

int *pTab = malloc(n * sizeof *pTab);

en conjonction avec pTab[i]. Avec à nouveau l'avantage que la taille peut ne pas être connue statiquement.

Tableau bidimensionnel

Il n'y a pas de tableaux multidimensionnels en C comme il peut y en avoir dans d'autres langages. On utilise à la place des tableaux de tableaux, ce qui revient quasiment au même. Un tableau de l tableaux de c int se déclare

int tab[l][c];

Pour accéder au jième élément de la iième ligne, on utilise tab[i][j]. L'interprétation est que tab (de type tableau de l tableaux de c int) est converti en un pointeur vers son premier élément (donc en un pointeur vers un tableau de c int). L'ajout de i suivit du dérérérencement donne le iième tableau de c int. Celui-ci est converti à son tour en un pointeur vers son premier élément (donc un pointeur vers int ; à nouveau, la conversion a bien lieu pour tous les tableaux, pas uniquement les variables) auquel on ajoute c et qu'on déréférence.


Pour passer un tableau bidimensionnel à une fonction, on peut déclarer la fonction

void f(int (*pTab)[l][c]);

l'appeler avec f(&tab) et utiliser (*pTab)[i][j]. l et c devant être fixés à la compilation. Tout comme dans le cas unidimensionnel, il est plus commun de déclarer une fonction

void f(int (*pTab)[c], size_t l);

de l'appeler avec f(tab) et d'utiliser pTab[i][j]. c doit toujours être fixé a la compilation mais l est maintenant un paramètre et peut donc être variable.


De même, quand on veut allouer un tableau dynamiquement, on n'utilise généralement pas

int (*pTab)[l][c] = malloc(sizeof *pTab);

en conjonction avec (*pTab)[i][j], mais on fait

int (*pTab)[c] = malloc(l * sizeof *pTab);

en conjonction avec pTab[i][j]. Avec l'avantage que la taille dans la première dimension peut ne pas être connue statiquement.


Si on veut un tableau bidimensionel dont la taille dans la deuxieme dimention est determinee dynamiquement, deux choix sont possibles. Le premier est d'allouer un tableau de la taille totale désirée :

int *pTab = malloc(l * c * sizeof *pTab);

et d'utiliser pTab[i * c + j].


Le deuxième est de faire un tableau dynamique de tableaux dynamiques[3] (avec l'avantage qu'on peut varier la taille de chacun des sous-tableaux individuellement si c'est utile, par exemple dans le cas de matrices triangulaires).

int **pTab = malloc(l * sizeof *pTab);
for (i = 0; i < l; i++)
   pTab[i] = malloc(c * sizeof *pTab[i]);

et d'utiliser pTab[i][j].

On peut aussi faire l'allocation en une fois.

int **pTab = malloc(l * sizeof *pTab + l * c * sizeof **pTab);
int *base = (int*)(pTab + l);
for (i = 0; i < l; i++)
   pTab[i] = base + i*c;

Compléments

Copier des tableaux, impossible ?

Pour copier des tableaux, la syntaxe

t1 = t2;

n'est pas utilisable. Il faut utiliser memcpy

memcpy(t1, t2, sizeof t2);

Si on désire copier beaucoup de tableaux, et en particulier de pouvoir les passer par valeur à une fonction, une possibilité est d'encapsuler le tableau dans une struct. Si on a

struct Tableau {
   int m[10];
} t1, t2;

alors t1 = t2; devient possible de même que le passage par valeur à une fonction. Le prix à payer est la complication de la syntaxe d'indexation : t1.m[i].

Pour ajouter à la confusion

Les déclarations de fonctions

void f(int *pTab, size_t l);
void g(int (*pTab)[c], size_t l);

peuvent aussi s'écrire

void f(int pTab[], size_t l);
void g(int pTab[][c], size_t l);

et même

void f(int pTab[42], size_t l);
void g(int pTab[42][c], size_t l);

Dans le deuxième case, la taille (ici 42) est ignorée. Il est important de noter que cette utilisation d'une syntaxe associée aux tableaux pour declarer un pointeur n'est possible que pour la première dimension des paramètres de fonction.


En B, langage intermédiaire entre BCPL et C, la syntaxe

auto tab[10];

avait comme effet quelque chose qui s'écrirait en C

word _tab[11];
word* tab = _tab;

(ni l'absence de const ni le 11 ne sont des erreurs).

Il y a eu des étapes intermédiaire entre le B et le C ou les pointeurs se déclaraient avec [], la possibilité de le faire dans les déclarations de fonction en est un reliquat.


[1] L'addition est commutative, et l'opérateur [] aussi. On a donc la curiosité que n[p] est aussi valable et synonyme de p[n]. Ce n'est en pratique pas utilisé.

[2] C99 a introduit des tableaux de taille dynamique, ils n'en sera plus question par la suite dans ce document.

[3] Technique appelée vecteurs de Iliffe.