Programmation C/Tableaux
Il existe deux types de tableaux : les tableaux statiques, dont la taille est connue à la compilation, et les tableaux dynamiques, dont la taille est connue à l'exécution. Nous ne nous intéresserons pour l'instant qu'aux tableaux statiques, les tableaux dynamiques seront présentés avec les pointeurs.
Syntaxe
modifierDéclaration
modifierT tableau[N];
Déclare un tableau de N éléments, les éléments étant de type T. N doit être un nombre connu à la compilation, N ne peut pas être une variable. Pour avoir des tableaux dynamiques, il faut passer par l'allocation mémoire.
Cette dernière restriction a été levée par la norme C99 qui a apporté le type tableau à longueur variable appelé Variable Length Array (VLA)[1]. il est maintenant possible de déclarer un tableau de type VLA par :
T tableau[expr];
où expr est une expression entière, calculée à l'exécution du programme. Ce type de tableau ne peut pas appartenir à la classe de stockage static ou extern. Sa portée est limitée au bloc dans lequel il est défini. Par rapport à une allocation dynamique, les risques de fuite mémoire sont supprimés.
Accès aux éléments
modifierL'exemple précédent a permis de déclarer un tableau à N éléments. En C, ces éléments sont indexés de 0 à N-1, et le ie élément peut être accédé de la manière suivante (où i
est supposé déclaré comme une variable entière ayant une valeur entre 0 et N-1):
tableau[i]
Note: La syntaxe suivante est équivalente à la précédente:
i[tableau]
Bien qu'autorisée par le C, elle va à l'encontre de ce à quoi nombre de programmeurs sont habitués dans d'autres langages, c'est pourquoi elle est très peu utilisée. Comme, de plus, elle n'apporte aucun réel avantage par rapport à la première syntaxe, elle est simplement à éviter, au profit de la précédente.
Exemples
modifierint i;
int tableau[10]; /* déclare un tableau de 10 entiers */
for (i = 0; i < 10; i++) /* boucle << classique >> pour le parcours d'un tableau */
{
tableau[i] = i; /* chaque case du tableau reçoit son indice comme valeur */
}
int tableau[1]; /* déclare un tableau d'un entier */
tableau[10] = 5; /* accède à l'élément d'indice 10 (qui ne devrait pas exister) */
printf("%d\n", tableau[10]);
Ce deuxième exemple, non seulement peut compiler (le compilateur peut ne pas détecter le dépassement de capacité), mais peut aussi s'exécuter et afficher le « bon » résultat. Le langage C n'impose pas à une implémentation de vérifier les accès, en écriture comme en lecture, hors des limites d'un tableau ; il précise explicitement qu'un tel code a un comportement indéfini, donc que n'importe quoi peut se passer. En l’occurrence, ce code peut très bien marcher comme on pourrait l'attendre, i.e. afficher 5
, ou causer un arrêt du programme avec erreur (si la zone qui correspondrait au 11ème élément du tableau est hors de la mémoire allouée au processus, le système d'exploitation peut détecter une tentative d'accès invalide à une zone mémoire, ce qui peut se traduire par une « erreur de segmentation » qui termine le programme), ou encore corrompre une autre partie de la mémoire du processus (dans le cas où le pseudo-11ème élément correspondrait à une partie de la mémoire du processus), ce qui peut modifier son comportement ultérieur de manière très difficile à prévoir.
Ce code est donc un exemple d'un type de bogue très courant en langage C. S'il est facile à détecter dans ce code très court, il peut être très difficile à identifier dans du code plus complexe, où on peut par exemple essayer d'accéder à un indice i
d'un tableau, la valeur de i
pouvant varier suivant un flot d'exécution complexe, ou alors essayer de copier le contenu d'un tableau dans un autre, sans vérifier la taille du tableau de destination (ce dernier cas est connu sous le nom de débordement de tampon, dépassement de capacité, ou encore buffer overflow en anglais). Il est donc très important de s'assurer que tous les accès au contenu de tableaux, en lecture comme en écriture, se font dans les limites de ses bornes.
Tableaux à plusieurs dimensions
modifierLes tableaux vus pour l'instant étaient des tableaux à une dimension (ie : des tableaux à un seul indice), il est possible de déclarer des tableaux possédant un nombre aussi grand que l'on veut de dimensions. Par exemple pour déclarer un tableau d'entiers à deux dimensions :
int matrice[10][5];
Pour accéder aux éléments d'un tel tableau, on utilise une notation similaire à celle vue pour les tableaux à une dimension :
matrice[0][0] = 5;
affecte la valeur 5 à la case d'indice (0,0) du tableau.
Avec le type VLA (Variable Length Array), introduit par C99, il est possible de définir un tableau à plusieurs dimensions dont les bornes ne sont connues qu'à l'exécution et non à la compilation :
static void vlaDemo(int taille1, int taille2)
{
// Declaration du tableau VLA
int table[taille1][taille2];
// ...
Initialisation des tableaux
modifierIl est possible d'initialiser directement les tableaux lors de leur déclaration :
int tableau[5] = { 1 , 5 , 45 , 3 , 9 };
initialise le tableau d'entiers avec les valeurs fournies entre accolades (tableau[0] = 1;
, tableau[1] = 5;
, etc.)
À noter que si on ne spécifie aucune taille entre les crochets du tableau, le compilateur la calculera automatiquement pour contenir tous les éléments. La déclaration ci-dessus aurait pu s'écrire plus simplement :
int tableau[] = { 1 , 5 , 45 , 3 , 9 };
Si on déclare un tableau de taille fixe, mais qu'on l'initialise avec moins d'éléments qu'il peut contenir, les éléments restant seront mis à zéro. On utilise cette astuce pour initialiser rapidement un tableau à zéro :
int tableau[512] = {0};
Cette technique est néanmoins à éviter, car dans ce cas le tableau sera stocké en entier dans le code du programme, faisant grossir artificiellement la taille du programme exécutable. Alors qu'en ne déclarant qu'une taille et l'initialisant à la main au début du programme, la plupart des formats d'exécutable sont capables d'optimiser en ne stockant que la taille du tableau dans le programme final.
Néanmoins, cette syntaxe est aussi utilisable pour les tableaux à plusieurs dimensions :
int matrice[2][3] = { { 1 , 2 , 3 } , { 4 , 5 , 6 } };
Ce qui peut aussi s'écrire :
int matrice[2][3] = { 1 , 2 , 3 , 4 , 5 , 6 };
À noter que si on veut aussi utiliser l'adaptation dynamique, il faut quand même spécifier une dimension :
int identite[][3] = { { 1 , 0 , 0 } , { 0 , 1 , 0 } , { 0 , 0 , 1 } };
/* Ou plus "simplement" */
int identite[][3] = { { 1 } , { 0 , 1 } , { 0 , 0 , 1 } };
Conversion des noms de tableaux en pointeurs
modifierÀ quelques exceptions près (c.f. ci-dessous), un nom de tableau apparaissant dans une expression C sera automatiquement converti en un pointeur vers son premier élément lors de l'évaluation de cette expression : si tab
est le nom d'un tableau d'entiers, le nom de tableau tab
sera converti dans le type pointeur vers int
dans l'expression tab, et la valeur de cette expression sera l'adresse du début de la zone mémoire allouée pour le stockage des éléments du tableau, i.e. l'adresse de son premier élément. Le code suivant est par exemple valide :
int tab[10];
int *p, *q;
p = tab;
q = &(tab[0]);
La déclaration int tab[10]
réserve une zone mémoire suffisante pour stocker 10 variables de type int
, désignées par tab[0]
, tab[1]
..., tab[9]
. Dans l'instruction p = tab
, l'expression tab
est de type pointeur vers int
, et la valeur affectée à p
est l'adresse de la première case du tableau. Cette adresse est aussi l'adresse de la variable tab[0]
: dans la seconde instruction, l'adresse de tab[0]
est affectée à q
via l'opérateur de prise d'adresse &
. Après la seconde instruction, p
et q
ont la même valeur, et les instructions *p = 1
, *q = 1
ou tab[0] = 1
deviennent opératoirement équivalentes.
Exceptions à la règle de conversion
modifierNoter que dans la déclaration int tab[10]
, le nom tab
ne désigne pas en lui-même un pointeur mais bien un tableau, de type tableau d'entiers à 10 éléments, même si ce nom est converti en pointeur dans la plupart des contextes. Les exceptions à la règle de conversion interviennent :
- lorsque le nom du tableau est opérande de l'opérateur unaire
&
, - lorsqu'il est argument de l'opérateur
sizeof
, - lorsqu'il est argument d'un pré ou d'une post-incrémentation ou décrémentation (
--
,++
) - lorsqu'il constitue la partie gauche d'une affectation,
- lorsqu'il est suivi de l'opérateur d'accès à un champ.
Dans chaque cas, le nom du tableau gardera le sens d'une variable de type tableau, c'est-à-dire conservera son type initial :
- dans l'expression
&tab
, la sous-expressiontab
conserve son type "tableau d'entiers à 10 éléments", et l'expression elle-même est de type "pointeur vers tableau d'entiers à 10 éléments". Le code suivant est par exemple valide :
int tab[10]; /* tableau d'entiers à 10 éléments */
int (*p)[10]; /* pointeur vers les tableaux d'entiers à 10 éléments */
p = &tab;
Le code suivant est en revanche incorrect :
int tab[10];
int *p;
p = &tab; /* incorrect. '&tab' n'est pas du bon type */
- dans
sizeof tab
la taille calculée sera celle detab
considéré encore comme une variable de type "tableau d'entiers à 10 éléments", soit 10 fois la taille d'unint
. - dans
tab++
,tab--
,--tab
ou++tab
, la sous-expressiontab
n'est ni une expression numérique ni un pointeur, mais bien une variable de type tableau : elle n'est donc ni incrémentable, ni décrémentable, et l'expression globale ne sera jamais compilable, - dans l'instruction
tab = expr
oùexpr
est une expression quelconque,tab
est toujours considéré comme une variable de type "tableau d'entiers à 10 éléments"... la règle de conversion des noms de tableaux et les restrictions sur les conversions explicites font qu'il est impossible queexpr
soit de même type : cette affectation générera toujours une erreur de typage, et ne sera jamais compilable, - dans l'expression
tab.champ
oùchamp
est un nom de champ quelconque,tab
est de type tableau, i.e. ne possède aucun champ, donc cette expression ne sera jamais compilable.
Le cas où tab
est membre gauche d'une affectation montre que les valeurs de type tableau ne sont jamais manipulables de manière directe : il est par exemple impossible d'affecter un tableau à un autre tableau - l'instruction tab_1 = tab_2
est toujours incompilable, à cause de la conversion en pointeur de tab_2
- ou de comparer structurellement deux tableaux par une comparaison simple - dans tab_1 == tab_2
, les deux noms seront convertis en pointeur, et la comparaison effectuée sera celle des adresses.
Cas des paramètres de fonctions
modifierTout paramètre de fonction déclaré comme étant de type "tableau d’éléments de type T" est automatiquement converti en un pointeur vers T à la compilation. Les deux écritures suivantes sont équivalentes :
version avec tableaux | version avec pointeurs |
---|---|
void f(int t[])
{
/* ... */
}
void g(int m[][10])
{
/* ... */
}
|
void f(int *t)
{
/* ... */
}
void g(int (*m)[10])
{
/* ... */
}
|
Dans la première version de f
et g
, les paramètres t
et m
sont bien des pointeurs, conformément à l'équivalence avec la seconde version, et sont donc réaffectables dans le corps de ces fonctions. La taille en la première dimension de t
et de m
, même si elle est spécifiée dans le code (e.g. void g(int m[10][10])
), sera de toute manière effacée à la compilation : il ne s'agit alors que d'un simple commentaire pour le programmeur.
Cette équivalence est cohérente avec la règle de conversion des noms de tableaux : lors d'un appel de la forme f(tab)
où tab
est un tableau d'entiers, le nom tab
sera converti en pointeur vers int
, qui est bien le type du paramètre de f
dans la seconde version.
Cas des tableaux externes
modifierDans le cas des déclarations externes, et contrairement au cas des paramètres de fonctions, il n'y a pas équivalence entre la notation par tableaux et la notation par pointeurs. Le programme suivant est en général compilable, mais incorrect :
fichier1.c | fichier2.c |
---|---|
#include <stdio.h>
int tab[] = {42};
void f(void)
{
printf("fichier1 : tab = %p\n", tab);
}
|
#include <stdio.h>
extern int *tab;
void f(void)
int main(int nb, char *argv[] )
{
f();
printf("fichier2 : tab = %p\n", tab);
return 0;
}
|
Les valeurs affichées pour tab
dans main
et dans f
seront en général distinctes.
L'erreur se situe dans la déclaration externe de tab
située dans le fichier fichier2.c
. Il aurait fallu écrire la déclaration suivante, spécifiant correctement le type de tab
dans fichier2.c
comme étant celui d'un tableau et non d'un pointeur (mais ne spécifiant pas sa taille, le compilateur n'effectuant de toute manière aucun test de dépassement) :
extern int tab[];
L'absence de message d'erreur dans la compilation de la première version de ce programme n'est due qu'à l'insuffisance de la vérification de la cohérence globale du typage lors de la phase de liaison : il est plus généralement possible de déclarer une variable externe de n'importe quel autre type que son type réel, sans que l'incohérence globale du typage soit nécessairement signalée par le compilateur. Aucune norme ne spécifiant le comportement du programme résultant, ce comportement est, de fait, indéfini.
Tableaux VLA passés à une fonction
modifierEn C99, il est possible de passer à une fonction un tableau de type VLA (Variable Length Array)[1]. Il faut passer les tailles des dimensions en premier.
Exemple de fonction recevant en argument un tableau de type VLA à deux dimensions :
static void vlaPrint(int taille1, int taille2, int table[taille1][taille2])
{
// Ecriture du tableau sur stdout
for (int i=0; i< taille1; i++)
{
for (int j=0; j< taille2; j++)
{
(void)printf("table[%d][%d] = %d\n", i, j, table[i][j]);
}
}
}
Le prototype de cette fonction peut s'écrire de la sorte
static void vlaPrint(int, int, int[*][*])
Notez l'emploi des * pour les dimensions variables.
Chaînes de caractères
modifierComme il fut dit tout au long de cet ouvrage, les chaînes de caractères sont des tableaux particuliers. En déclarant une chaîne de caractères on peut soit la manipuler en tant que pointeur soit en tant que tableau. Considérez les déclarations suivantes :
char * chaine1 = "Ceci est une chaine";
char chaine2[] = "Ceci est une autre chaine";
Bien que se manipulant exactement de la même façon, les opérations permises sur les deux variables ne sont pas tout à fait les mêmes. Dans le premier cas on déclare un pointeur sur une chaîne de caractères statique, dans le second cas, un tableau (alloué soit sur la pile si la variable est déclarée dans une fonction ou soit dans le segment global si la variable est globale) de taille suffisante pour contenir tous les caractères de la chaîne affectée (incluant le caractère nul).
Le second cas est donc une notation abrégée pour char chaine2[] = { 'C', 'e', 'c', 'i', ' ', ..., 'e', '\0' };
. Dans tous les autres cas (ceux où une chaîne de caractères ne sert pas à initialiser un tableau), la chaîne déclarée est statique (les données sont persistantes entre les différents appels de fonctions), et sur certaines architectures, pour ne pas dire toutes, elle est même en lecture seule. En effet l'instruction chaine1[0] = 'c';
peut provoquer un accès illégal à la mémoire. En fait le compilateur peut optimiser la gestion des chaînes en regroupant celles qui sont identiques. C'est pourquoi il est préférable de classifier les pointeurs sur chaîne de caractères avec le mot clé const
.
On comprend aisément que si chaine2
est alloué sur la pile (déclaré dans une fonction), la valeur ne pourra pas être utilisée comme valeur de retour. Tandis que la valeur de chaine1
pourra être retournée même si c'est une variable locale.