De la différence entre tableaux et pointeurs en C

Quand j’ai commencé le C, je confondais les tableaux et les pointeurs, à part l’utilisation nécessaire de malloc() avec les seconds. Après, je savais qu’ils étaient différents mais je n’avais pas vraiment tirer les choses au clair les différences réelles entre les deux et toutes les implications que cela avait. Avec souvent des soucis dans des programmes pour tout faire marcher sans warning. Après une incompréhension récente et profonde (j’en parle dans la partie 1 de cet article) avec des pointeurs sur tableaux, j’ai décidé de faire le point sur la différence entre tableaux et pointeurs. Je vous livre les faits marquants.

1 – Mise en évidence

Pour commencer, donnons un exemple prouvant qu’un tableau et un pointeur ne sont pas identiques. Dans les deux fonctions suivantes, on crée deux objets qu’on pense équivalents : un tableau d’entiers et un pointeur pointant vers une zone réservée grâce à une allocation dynamique. On a a priori la même chose : une zone de 6 entiers continus en mémoire. On appelle souvent le premier « tableau statique » et le deuxième « tableau dynamique » (bonjour la confusion).

void exemple_1(void)
{
int tableau[] = {0,1,2,3,4,5};

printf("tableau       = %x\n", tableau);
printf("&tableau      = %x\n\n", &tableau);
}

void exemple_2(void)
{
int *pointeur = calloc(6 , sizeof(int));

printf("pointeur      = %x\n", pointeur);
printf("&pointeur     = %x\n\n", &pointeur);
}

On réalise les mêmes opérations sur ces deux objets. S’ils sont équivalents, on devrait obtenir les mêmes résultats. Ô surprise ! les résultats ne sont pas identiques :

&tableau[0]   = 22fec8
&tableau      = 22fec8

pointeur      = 3e2c98
&pointeur     = 22feec

Le constat est simple : un tableau et un pointeur sont deux objets différents avec des comportements différents.

Note : cet exemple peut ne pas paraitre très intéressant. Je le donne car c’est avec un code comme ça que je me suis dit qu’il y avait vraiment quelque chose qui m’échappait entre tableaux et pointeurs. Pour schématiser, j’avais fait une fonction attendant ne paramètre un pointeur sur tableau et en lui passant un tableau ou l’adresse de ce tableau, j’obtenais le même résultat. C’est parce que les deux valeurs étaient les mêmes, bien que les objets fussent différents, que cela marchait. J’y reviens dans la partie suivante.

2 – L’unique règle

Il y a une unique règle à comprendre et à retenir, qu’on retrouve dans la norme C99 (document n1256) à la partie 6.3.2.1, paragraphe 3 :

Except when it is the operand of the sizeof operator or the unary & operator, or is a string literal used to initialize an array, an expression that has type ‘‘array of type’’ is converted to an expression with type ‘‘pointer to type’’ that points to the initial element of the array object and is not an lvalue. If the array object has register storage class, the behavior is undefined.

Cette règle permet d’expliquer les comportements différents des 2 fonctions précédentes. La règle précédente conduit à dire que l’argument tableau est automatiquement converti en l’adresse de son premier élément quand il est passé en paramètre à la fonction printf(). La fonction exemple_1bis() suivante donnera donc le même résultat que la fonction exemple_1() :

void exemple_1bis(void)
{
int tableau[] = {0,1,2,3,4,5};

printf("&tableau[0]   = %x\n", &tableau[0]);
printf("&tableau      = %x\n\n", &tableau);
}

En revanche, l’argument &tableau correspond bien à l’adresse du tableau lui-même puisqu’on est l’un des 2 uniques cas où l’objet de type tableau n’est pas implicitement converti en un pointeur vers son premier élément. Le tableau et son premier élément étant en toute logique au même endroit en mémoire, les valeurs passées à la fonction printf() sont donc les mêmes pour les deux paramètres.

La fonction exemple_2() n’utilise pas un tableau mais un pointeur et le résultat est simple à analyser. Dans le premier cas, c’est la valeur du pointeur qui est passé, c’est-à-dire l’adresse du début du bloc mémoire alloué grâce malloc(); dans le second cas, c’est la valeur de l’adresse du pointeur, c’est-à-dire l’emplacement du pointeur lui-même dans la mémoire.

Une dernière remarque qu’implique cette règle : même avec ce pointeur obtenu implicitement, il n’est pas possible d’incrémenter un objet de type tableau ou de lui assigner une autre valeur. Un objet de type tableau est constant par définition (son contenu ne l’est pas forcément) et il n’est pas nécessaire d’essayer de lui appliquer le mot-clé const.

3 – Paramètre d’une fonction

Une fonction ne peut pas prendre réellement un tableau en paramètre d’une fonction. Cela a déjà été dit de manière implicite dans la partie précédente en affirmant que l’argument tableau de printf() était automatiquement converti en l’adresse de son premier élément. Les arguments sont passés par copie aux fonctions, en langage C. Ainsi, passer le tableau lui-même en paramètre reviendrait à le copier en entier sur la pile d’appel. Au mieux, votre pauvre pile prend une baffe dans sa face ; au pire vous lui planter carrément un couteau dans le dos. On contourne le problème en passant en argument un pointeur vers le premier élément du tableau et c’est la que la conversion implicite ressort du chapeau.

C’est d’ailleurs le seul cas où int* et int[] sont équivalents : lors de la déclaration d’un paramètre d’une fonction. Les deux notations sont alors permises et équivalentes. Par exemple, le code suivant ne génèrera pas de d’erreur de conflicting types :

void equi_1(int tab[]);

void equi_1(int * pt)
{
printf("%d\n", *pt);
}

void equi_2(int* tab[]);    // au lieu de faire un tableau de int,
// on fait un tableau de int*

void equi_2(int* *pt)      // par analogie, on fait un pointeur sur un int*
{
printf("%d\n", **pt);
}

4 – Tableaux multidimensionnels

Il n’existe pas de tableaux multidimensionnels en C. Pour créer des tableaux bidimensionnels par exemple, on crée en fait des tableaux de tableaux. Si on veut passer un tableau bidimensionnel (un tableau de tableaux, donc) en paramètre à une fonction, on passe en réalité un pointeur vers le premier élément du tableau, qui lui même est un tableau. On retrouve une nouvelle équivalence pointeur / tableau pour les paramètres des fonctions :

void equi_3(int (*ptab)[]);  // pointeur sur tableau de type incomplet, qui peut etre complete
void equi_3(int (*ptab)[4]); // si on specifie la taille, elle doit etre la meme partout
void equi_3(int (*ptab)[5]); // ainsi, cette ligne genere une erreur

void equi_3(int tab[][4])
{
printf("%d\n", tab[0][0]);
}

5 – Pointeurs sur un type incomplet

Quand on crée un pointeur sur un tableau, on peut spécifier ou pas la taille du tableau pointé. C’est ce qu’on voit dans les exemples de la partie précédente. Il est en effet possible de créer un pointeur sur un type incomplet, c’est-à-dire un pointeur sur un tableau de taille inconnue. Pour un tableau de int, c’est le type int(*)[]. Une déclaration est de la forme :

int tab[] = {42, 43, 44};
int (*p_sur_tab)[] = &tab;

Cette forme a l’avantage de ne pas spécifier une taille fixe des tableaux. C’est plus souple, notamment dans pour les paramètres des fonctions. En revanche, cela interdit d’écrire quelque chose comme p_sur_tab++ puisqu’on ne connait pas la taille de l’objet pointé et on ne peut pas effectuer le décalage mémoire nécessaire. On perd donc les possibilités d’arithmétique sur pointeur. On considère les deux fonctions suivantes :

void avec_taille(int (*ptab)[3])
{
printf("Avec %d\n", ptab[0][0]);
}

void sans_taille(int (*ptab)[])
{
printf("Sans %d\n", (*ptab)[0]);
//    printf("Sans %d\n", ptab[0][0]); // erreur car taille inconnue implique pas d'arithmetique de pointeur
}

On essaye alors de compiler les lignes suivantes et on observe des erreurs et des warnings :

    int (*p_sur_tab)[] = &tab;      // pointeur incomplet sur  "tableau de int"
int (*p_sur_tab3)[3] = &tab;    // ok car tab est bien de longueur 3 (type int[3])
int (*p_sur_tab5)[5] = &tab;    // warning car l'objet pointe n'est pas de type int[5]

avec_taille(p_sur_tab);
avec_taille(p_sur_tab3);
avec_taille(p_sur_tab5);    // warning car le parametre n'est pas du type attendu

sans_taille(p_sur_tab);
sans_taille(p_sur_tab3);
sans_taille(p_sur_tab5);

Attention ! Ce type n’est pas équivalent à un int** :

void equi_4(int (*ptab)[]); // erreur car ici, il y a un conflit des types

void equi_4(int ** ppt)
{
printf("%d\n", **ppt);
}

En effet, *ppt est de type int* alors que *ptab est de type tableau de int. Cela peut conduire à des plantages méchants de votre programme comme expliqué dans ce tutoriel du Site du zéro (oui, pour une fois je recommande ce site ^^). Si une fonction a un paramètre formel de type int**, l’appel doit lui donner l’adresse d’un pointeur sur int. Il sera utilisé pour un tableau de pointeurs sur int.

Conclusion

Le fait que le nom d’un tableau est transformé la plupart du temps en pointeur (vers son premier élément) donne l’impression qu’un tableau se comporte comme un pointeur. Certains éléments du langage ajoutent à la confusion, comme utiliser des [] avec un pointeur comme avec un tableau. C’est une facilité d’écriture, mais cela ne transforme pas le pointeur en tableau.

Cela fait souvent dire aux gens que les pointeurs et les tableaux sont équivalents. Ce n’est pas vrai et, promis, je ne le dirai plus !

Liens

J’ai regroupé tous les exemples que j’ai donnés dans cet article dans un fichier, que vous pourrez compiler pour voir les différents messages de votre compilateur :

https://github.com/Bktero/DummyCodes/blob/master/C/tab_pointeur.c

Des pages intéressantes à lire :

http://clips.imag.fr/commun/bernard.cassagne/Introduction_ANSI_C/node67.html

http://www.developpez.net/forums/d1250463/c-cpp/c/tableaux-multidimension-pointeurs/#post6833815

http://www.developpez.net/forums/d1211788/c-cpp/c/cast-int/

http://www.siteduzero.com/tutoriel-3-344044-la-verite-sur-les-tableaux-et-pointeurs-en-c.html

http://www.developpez.net/forums/d1251651/c-cpp/c/debuter/probleme-argument-fonction/#post6841041

Edition du 05 mai 2014 : je vous conseille également de lire cette page, très bien écrite : tableaux et pointeurs par Jean-Marc Bourguet. J’aime bien la manière d’expliquer, notamment le pourquoi de la syntaxe commune entre tableaux et pointeurs, p[n].

2 Réponses

  1. Manu

    Bah les deux représentent des types distincts pour le compilateur, mais les deux ont un comportement assez comparable.

    Un tableau d’entiers:
    int tab[1] = {1};
    Est « en quelque sorte » un pointeur constant sur entier:
    int *const p;

    Aux différences que:
    * A la compilation le compilateur va te dire « incompatible types in assignment » plutôt que « assignment of read-only variable » si tu essayes de mal utiliser ton tableau
    * L’initialisation du tableau ne peut pas faire référence à une variable. Tu peux faire:
    int *const p = tab;
    Mais pas:
    int tab[] = p;
    * Les tableaux à taille définie ont en effet une arithmétique particulière, les tableaux à taille non définie gardant leur arithmétique sur le type initial, comme les pointeurs

    Mais ça ne t’empêche pas de faire:
    int (*tab)[1];
    int (*tab2)[2] = (void*) tab;
    Ou même:
    int *const *p;
    int (*tab2)[1] = (void*) p;

    Et là tu retombes sur tes pieds:
    int test(tab[2]);

    test(*tab2);
    Donc tout est vraiment dans la gestion du type par le compilateur, mais au final tu peux transcrire toutes les opérations de tableau en opérations équivalentes en pointeur.

    Bref, autant d’arguments pour oublier les tableaux et n’utiliser que les pointeurs ! 😉

    J’aime

    9 août 2012 à 11:52

  2. Pingback: Ne pas faire de référence externe à un tableau avec un pointeur en C | Pierre Gradot

Laisser un commentaire

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur la façon dont les données de vos commentaires sont traitées.