Les tableaux

La manipulation d’une collection d’objets se fait en Python avec une list. La structure de données la plus proche en langage C est le tableau.

De ce que l’on a vu pour le moment, C ne manipule que des types primitifs (et des pointeurs) et on aura donc des tableaux d’entiers, de nombres rééls ou de caractères (et de pointeurs). Le tableau de caractères est la façon de représenter une chaîne de caractères en C.

Les tableaux C ne contiennent que des données homogènes. Un tableau d’entiers ne peut contenir que des entiers.

Les données stockées dans un tableau sont stockées dans des cases mémoires contigües. On exploitera cette caractéristique lorsqu’on manipulera les tableaux avec des pointeurs.

Avertissement

On verra dans le chapitre Structures et alias que l’on peut aggréger dans une structure plusieurs types primitifs pour les manipuler comme une entité unique. Sans surprise, on pourra manipuler une collection de ces structure dans un tableau de structure.

Déclaration

En C, comme toute variable, un tableau doit être déclaré avec la syntaxe type nom [ taille ];. Ainsi :

  • int x[10]; déclare un tableau x permettant de stocker 10 entiers ;

  • double y[10]; déclare un tableau y permettant de stocker 10 rééls ;

  • char z[10]; déclare un tableau z permettant de stocker une chaîne de 10 caractères ;

  • int* p[10]; déclare un tableau p permettant de stocker 10 pointeurs vers des entiers.

La déclaration définit un espace réservé qui doit être plus vaste que l’espace rééllement utilisé.

Il y a ici une différence majeure entre C et Python :

  • Python fait croitre/décroitre dynamiquement (au cours de l’exécution du programme) et automatiquement (sans intervention du programmeur) la taille des listes ;

  • en C la déclaration est statique (définie à la compilation) et la mémoire allouée de cette façon ne peut pas être modifiée au cours de l’exécution du programme. On verra dans le chapitre Allocation dynamique comment gérer la mémoire dynamiquement (c’est à dire au cours de la vie du programme). Mais contrairement à Python, ça nécessitera l’intervention du programmeur.

Initialisation

Dans certains langages, la déclaration d’un tableau initialise également les valeurs à 0. Ce n’est pas le cas en C. Ainsi, le programme suivant, qui déclare un tableau (ligne 5) mais ne l’initialise pas, conduit à un résultat imprévisible (et non reproductible) :

 1//tab.c
 2
 3#include <stdio.h>
 4
 5int main()
 6{
 7    int x[10] ;
 8
 9    // ABSENCE d'INITIALISATION
10
11    for ( int i = 0; i < 10; i++ ){
12        printf("%d\n", x[i]);
13    }
14
15    return 0;
16}

Lorsqu’on l’exécute dans un terminal, on obtient des valeurs étranges:

-1409518872 32573 -2096975408 22037 0 0 -2096975744 22037 898055648 32766

A la déclaration, le compilateur réserve 40 octets (4 octets pour chacun des 10 éléments du tableau) quelque part dans la mémoire. L’affichage reflète l’état de cette portion de mémoire.

Encore plus ennuyeux, l’affichage d’un élément qui n’appartient pas au tableau est un code C valide (en Python ça déclenche une erreur) :

printf("%d ", x[10]);

Dans le meilleur des cas, une autre valeur étrange est retournée. Dans le pire des cas, cet accès illégal peut provoquer le crash du programme

Segmentation fault

Il est donc indispensable de :

  1. déclarer le tableau pour réserver de la place en mémoire ;

  2. initialiser ses valeurs, sinon ce sont des nombres aléatoires ;

  3. et contrôler l’accès aux éléments d’un tableau, sinon on risque un accès à une zone illégale de la mémoire et un crash du programme.

Pour les tableaux de taille réduite, on peut déclarer/initialiser en une seule instruction. La taille du tableau n’est pas requise, elle est déduite de la partie RHS (Right Hand Side) de l’affectation :

int x[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9} ;

On peut également (d’abord) déclarer, puis (ensuite) initialiser le tableau dans une autre partie du programme :

int x[10] ;

for ( int i = 0; i < 10; i++ ){
        x[i] = i ;
        printf("%d ", x[i]);
    }

Avertissement

La déclaration/initialisation globale dans une même instruction est correcte :

int x[10] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9} ;

La déclaration, suivie de l’initialisation élément par élément est correcte également :

int x[10];

for (int i=0 ; i<10 ; i++) {
    x[i] = i;
}

Mais la déclaration, suivie d’une initialisation globale échoue à la compilation :

int x[10];
x = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

Le compilateur renvoie une erreur de compilation car hors déclaration, l’accolade est utilisée pour délimiter un bloc de code:

error: expected expression before ‘{’ token
x = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};

Tableau multidimensionnel

Le langage C permet la manipulation de tableaux multi dimensionnels avec la syntaxe

type nom [ x ][ y ];

qui déclare un tableau de x lignes et y colonnes.

Voici deux façons de déclarer / initialiser un tableau à 2 dimensions. La première fait apparaître explicitement la structuration en lignes et en colonnes :

int x[2][3] = {
{0, 1, 2} ,
{3, 4, 5}
};

La seconde non. Le tableau est rempli de gauche à droite et de haut en bas (ligne par ligne) :

int x[2][3] = {0, 1, 2, 3, 4, 5};

Chacune des deux déclaration / initialisation conduit au même résultat :

../_images/tableau.png

L’accès à un des éléments est direct : x[1][2] = 5.

Exercice 1 : recherche dans un tableau

L’objectif de cet exercice est de se familiariser avec les tableaux en écrivant quelques fonctions pour les manipuler.

Le code source sera stocké dans le fichier tab.c.

Initialisation

Dans la fonction principale main() :

  • déclarer un tableau t d’entiers capable de stocker 100 valeurs ;

  • déclarer un entier size qui définit la taille effective du tableau. Initialiser à 10 dans la même instruction ;

  • écrire une boucle d’initialisation du tableau t en lui affectant size valeurs aléatoires entre 0 et 49 (lire ci dessous). A chaque itération, afficher la valeur dans le terminal ;

Astuce

La fonction rand() ne prend pas de paramètres et retourne un entier choisi aléatoirement entre 0 et une valeur dépendante de la machine. L’instruction rand() % n retourne une valeur entre 0 et n-1. Le prototype de rand() est dans <stdlib.h>.

Minimum

Ecrire la fonction secondaire mini() retournant la valeur minimale contenue dans le tableau. Le prototype de la fonction (c’est à dire le code de la fonction dont le corps a été remplacé par un ;) est défini ci dessous. Contrairement à Python, le tableau C ne connait pas sa taille. Lors de l’écriture de fonctions manipulant les tableaux, on passera donc systématiquement en paramètres, le tableau lui même et la taille du tableau.

int mini(int t[], int size);

Tester la conformité de la fonction en déclenchant son appel depuis main() et en affichant la valeur retournée. Un exemple d’exécution est donné plus bas.

Indice du minimum

Ecrire la fonction secondaire imini() retournant l’indice de la valeur minimale contenue dans le tableau. Le prototype de la fonction est défini ci dessous.

int imini(int t[], int size);

Tester la conformité de la fonction en déclenchant son appel depuis main() et en affichant la valeur retournée. Un exemple d’exécution est donné plus bas.

Maximum

Ecrire la fonction secondaire maxi() retournant la valeur maximale contenue dans le tableau. Le prototype de la fonction est défini ci dessous.

int maxi(int t[], int size);

Tester la conformité de la fonction en déclenchant son appel depuis main() et en affichant la valeur retournée. Un exemple d’exécution est donné plus bas.

Indice du maximum

Ecrire la fonction secondaire imaxi() retournant l’indice de la valeur maximale contenue dans le tableau. Le prototype de la fonction est défini ci dessous.

int imaxi(int t[], int size);

Tester la conformité de la fonction en déclenchant son appel depuis main() et en affichant la valeur retournée. Un exemple d’exécution est donné plus bas.

Somme

Ecrire la fonction secondaire somme() retournant la somme des éléments du tableau. Le prototype de la fonction est défini ci dessous.

int somme(int t[], int size);

Tester la conformité de la fonction en déclenchant son appel depuis main() et en affichant la valeur retournée. Un exemple d’exécution est donné plus bas.

Moyenne

Ecrire la fonction secondaire moyenne() retournant la moyenne des éléments du tableau. Utiliser une des fonctions précédentes pour effectuer le calcul sans redondance de code. On portera une attention particulière au type des entités manipulées. On peut modifier le type d’une expression avec un cast comme on l’a déjà vu dans l’exercice sur les nombres premiers du chapitre Les structures de contrôle. Le prototype de la fonction est défini ci dessous.

double moyenne(int t[], int size);

Tester la conformité de la fonction en déclenchant son appel depuis main() et en affichant la valeur retournée. Un exemple d’exécution est donné plus bas.

Comptage

Ecrire la fonction secondaire compte() retournant le nombre d’éléments du tableau compris entre deux valeurs. Le prototype de la fonction est défini ci dessous.

int compte(int t[], int size, int lowest, int highest);

Choisir les paramètres lowest et highest en fonction des valeurs générées dans le tableau. Tester la conformité de la fonction en déclenchant son appel depuis main() et en affichant la valeur retournée. Un exemple d’exécution est donné ci dessous.

Trié ou pas ?

Ecrire la fonction secondaire est_trie() retournant 1 si le tableau est croissant, -1 s’il est décroissant, et 0 sinon. Le prototype de la fonction est défini ci dessous.

int est_trie(int t[], int size);

Tester la conformité de la fonction en déclenchant son appel depuis main() sur des tableaux de nature différentes (trié croissant, trié décroissant, pas trié) et en affichant la valeur retournée. Un exemple d’exécution est donné ci dessous.

Exemple d’exécution

Une fois entièrement terminé, votre programme doit s’exécuter comme ci dessous (les valeurs peuvent différer)

$ gcc -std=c99 -Wall -Wextra tab.c -o tab
$ ./tab
33
36
27
15
43
35
36
42
49
21
minimum : 15
indice du minimum : 3
maximum : 49
indice du maximum : 8
somme : 337
moyenne : 33.700000
4 valeurs de t comprises entre 36 et 43
est trié ? 0

Compilation modulaire

Comme on l’a vu dans le chapitre Processus de compilation, il peut être intéressant de fabriquer un fichier objet séparé contenant uniquement des fonctions d’une même famille.

Pour mettre en oeuvre ce mécanisme, structurer le programme précédent en trois fichiers :

  • un fichier utils.c ne contenant que les fonctions mini(), imini(), maxi(), imaxi(), somme(), moyenne() et compte(). Ce fichier n’est pas destiné à produire d’exécutable ;

  • un fichier utils.h ne contenant que le prototype des fonctions précédentes ;

  • un fichier tab2.c contenant la fonction main() faisant appel aux fonctions de utils.c. Ne pas oublier la directive #include correspondante.

Vous compilerez indépendemment les deux fichiers source:

$ gcc -std=c99 -Wall -Wextra -c utils.c
$ gcc -std=c99 -Wall -Wextra -c tab2.c

Si la compilation s’est déroulée sans erreur, il y a maintenant dans le répertoire de travail :

  • les fichiers sources tab2.c et utils.c ;

  • le fichier d’en têtes utils.h ;

  • et les fichiers objet correspondants tab2.o et utils.o.

Le résultat de la commande ls:

$ ls
tab2.c     tab2.o    utils.c    utils.h    utils.o

Le fichier exécutable tab2 est produit à partir des deux fichiers objet tab2.o et utils.o avec la commande

$ gcc -std=c99 -Wall -Wextra utils.o tab2.o -o tab2

Rigoureusement, cette phase ne réalise que l’édition de liens.

L’exécution fournit le résultat attendu:

$ ./tab2
33
36
27
15
43
35
36
42
49
21
minimum : 15
indice du minimum : 3
maximum : 49
indice du maximum : 8
somme : 337
moyenne : 33.700000
4 valeurs de t comprises entre 36 et 43

Manipulation avec des pointeurs

Un tableau est une zone réservée de la mémoire, possédant les caractéristiques suivantes :

  • une adresse de début ;

  • un type d’élément ;

  • une taille (en nombre d’éléments). La taille est définie lors de la déclaration du tableau et ne peut pas être modifiée par la suite.

Avertissement

Contrairement à Python, hors de la fonction déclarante, un tableau C ne connait pas le nombre de ses éléments. Il y a plusieurs conséquences :

  • on doit impérativement passer la taille du tableau en paramètre des fonctions manipulant les tableaux ;

  • on ne peut pas déterminer la taille d’un tableau en utilisant la syntaxe sizeof(t)/sizeof(t[0]) hors de la fonction déclarante.

Puisqu’un tableau est caractérisé par l’adresse de son premier élément, il est donc naturel d’imaginer manipuler un tableau avec des pointeurs. On va même voir que ça donne une grande flexibilité.

Considérons le programme suivant qui initialise un tableau avec les premières valeurs de la suite de Fibonacci.

 1// tab3.c
 2
 3#include <stdio.h>
 4
 5#define SIZE 20
 6
 7int main()
 8{
 9
10    // Déclaration
11
12    int x[100];
13    // int* p = x;
14
15    // Initialisation
16
17    x[0] = 1;
18    x[1] = 1;
19    for (int i = 2; i < SIZE; i++)
20        x[i] = x[i - 1] + x[i - 2];
21}

On peut afficher les valeurs de façon traditionnelle, avec la syntaxe x[i] :

1for (int i = 0; i < 10; i++)
2    printf("x[%d] = %d\n",i, x[i]);

Le résultat est sans surprise:

x[0] = 1
x[1] = 1
x[2] = 2
x[3] = 3
x[4] = 5
x[5] = 8
x[6] = 13
x[7] = 21
x[8] = 34
x[9] = 55

Observons le code suivant qui affiche respectivement le tableau sous forme de pointeur, l’adresse de la variable déclarée comme tableau, et l’adresse du premier élément du tableau:

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

Le résultat peut sembler étonnant au premier abord, puisque les 3 valeurs sont identiques:

  x   = 0x7fffe5322070
 &x   = 0x7fffe5322070
&x[0] = 0x7fffe5322070

Cela signifie que :

  • le nom de la variable qui désigne un tableau est en fait… un pointeur ;

  • qui représente l’adresse du tableau global ;

  • et qui n’est rien d’autre que l’adresse de son premier élément.

L’équivalence tableau - pointeur est donc naturelle parce que pour le langage C ces deux notions se confondent !

On peut tirer profit de cette équivalence pour manipuler différemment les éléments d’un tableau. Puisque x est un pointeur vers le début du tableau :

  • x+i est un pointeur vers la ième case du tableau ;

  • et *(x+i) est le contenu de la ième case du tableau.

Note

Si x est un pointeur, dans l’expression x+1, 1 ne représente pas un entier mais la taille d’un élément du tableau ! C’est ce qu’on appelle l’arithmétique des pointeurs. Ainsi x+1 décalera l’adresse x de :

  • 4 octets si c’est un tableau d’entiers ;

  • 8 octets si c’est un tableau de doubles ;

  • 1 octet si c’est un tableau de char ;

Ainsi, puisque la syntaxe *(x+i) est strictement équivalente à x[i], le code ci dessous :

for (int i = 0; i < 10; i++)
    printf("*(x+%d) = %d\n",i ,*(x+i) );

produit le même résultat que précédemment.

Puisque l’on manipule des pointeurs, on peut observer l’occupation mémoire avec l’opérateur & :

for (int i = 0; i < 10; i++)
    printf("&x[%d] = %p\n",i , x+i );

Le résultat de l’exécution:

&x[0] = 0x7fffe5322070
&x[1] = 0x7fffe5322074
&x[2] = 0x7fffe5322078
&x[3] = 0x7fffe532207c
&x[4] = 0x7fffe5322080
&x[5] = 0x7fffe5322084
&x[6] = 0x7fffe5322088
&x[7] = 0x7fffe532208c
&x[8] = 0x7fffe5322090
&x[9] = 0x7fffe5322094

Tout ceci est résumé dans le graphique suivant.

../_images/tab_pointeurs.png
  • Quel est le type des éléments du tableau ?

  • Le plan d’adressage est cohérent avec le type.

  • Les cases mémoires réservées pour chaque élément du tableau sont contigües.

Un programmeur étourdi a réalisé le même programme en utilisant un tableau de doubles pour stocker les éléments de la suite de Fibonacci. Inférer le résultat de l’exécution précédente en faisant l’hypothèse que l’adresse de départ est la même.

  • Quel est l’écart entre deux cases mémoire consécutives ?

  • Quel est le gaspillage mémoire (en octets) sur un tableau de 100 cases ?

Exercice 2 : ordonnancement de 2 tableaux

L’objectif de cet exercice est de parcourir les valeurs de deux tableaux t1 et t2 déja triés par ordre croissant et de produire un affichage ordonné de l’ensemble de leurs valeurs.

Le code source sera stocké dans le fichier order.c.

Un exemple d’exécution:

$ gcc -std=c99 -Wall -Wextra order.c -o order
$ ./order

Tableau t1 : 2, 7, 10, 15, 21, 23,
Tableau t2 : 1, 1, 7, 9, 15,

Valeurs ordonnées :
1, 1, 2, 7, 7, 9, 10, 15, 15, 21, 23,

La fonction principale main()

Déclarer 2 tableaux t1 et t2 pouvant contenir 20 nombres entiers.

Déclarer 2 entiers size1 et size2 (représentant le nombre de valeurs effectives stockées dans t1 et t2) avec des valeurs aléatoires comprises entre 10 et 19.

La première valeur de chacun des tableaux sera un nombre aléatoire entre 0 et 4.

Les valeurs suivantes seront obtenues par des incréments aléatoires compris entre 0 et 9.

Etes vous convaincu que les tableaux sont croissants par construction ?

Lorsque les deux tableaux ont été initialisés, afficher leur contenu, comme dans l’exemple d’exécution ci dessus.

L’ordonnancement

Créer une fonction secondaire order_and_display() dont le prototype est:

int order_and_display(int* t1, int size1, int* t2, int size2);

L’objectif de cette fonction est de produire un affichage ordonné de l’ensemble des valeurs des deux tableaux passés en argument, comme dans l’exemple d’exécution ci dessus. La taille des tableaux peut différer.

La contrainte est de travailler avec des tableaux passés sous forme de pointeurs. On se rappellera donc que la syntaxe x[i] est strictement équivalente à la syntaxe *(x+i).

Par défaut la source du générateur de nombres aléatoires est constante. L’intérêt est que la même suite est générée à chaque exécution. C’est intéressant d’avoir quelque chose de reproductible pour la mise au point du programme.

Si on souhaite générer une séquence différente à chaque exécution, on initialise la source avec la fonction srand() avant de faire appel à rand():

#include<time.h>
...
srand(time(0));