9. Structures et autres types utilisateurs
Structures
Jusqu'à présent, nous utilisions des types de données simples : entiers, réels ou encore les tableaux et les pointeurs. Dans ce chapitre, nous abordons les types personnalisés, construits dans le cadre d'une application spécifique. L'un de ces types utilisateurs est un type composé, appelé structure, permettant de regrouper des données pouvant être de types différents dans une même entité.
Ce premier exemple
struct Point {int abscisse;int ordonnee;};
déclare un nouveau type, struct Point, composé de deux champs de type int appelés abscisse et ordonnee. Le mot-clé struct est obligatoire, il introduit la description de la structure. Il peut être suivi d'un tag (ou étiquette) identifiant la structure (ici: Point). La liste des champs est placée entre les caractères accolades '{' et '}'.
Une variable origine de ce type struct Point sera définie en précisant son type et recevra éventuellement une valeur initiale.
struct Point origine; // sans initialisation oustruct Point origine = {0, 0}; // avec initialisation
Notez que, par convention d'écriture, les noms de types commencent toujours par une majuscule. S'ils sont composés de plusieurs mots, chaque mot commence par une majuscule et il n'y a pas de séparateurs. Exemples: MonType, PileDEntiers, GrapheNonCyclique.
Notez également que les définitions de types se font généralement en début de programme, c'est-à-dire après les directives du préprocesseur (#include et #define) et avant le programme principal et les fonctions. De cette manière, les types utilisateurs pourront être utilisés à tout endroit du code qui suit leur définition. Dans le chapitre Modularisation, nous avons vu que les définitions de types, tout comme les prototypes de fonctions, sont de préférence placées dans des fichiers d'entête (extension .h) qui seront inclus dans des fichiers sources (extension .c).
Utilisation
Deux nouveaux opérateurs sont introduits pour permettre l'accès à un des champs d'une structure.
| Niveau de priorité | Opérateur | Description | Associativité |
|---|---|---|---|
| 15 | [ ] | indice de tableau | de gauche à droite |
| ( ) | appel de fonction | ||
| . | sélection de membre | ||
| -> | sélection de membre par déréférencement | ||
| ++ -- (suffixe) | post incrémentation et décrémentation | ||
| 14 | ++ -- (préfixe) | pré incrémentation et décrémentation | unaire, de droite à gauche |
| ~ | complément à 1 (inversion des bits) | ||
| ! | non logique | ||
| + | identité (opérateur unaire) | ||
| - | changement de signe (complément à 2) | ||
| sizeof | calcule la taille d'une variable (d'un type) | ||
| (cast) | changement forcé de type | ||
| & | adresse | ||
| * | indirection, déréférenciation |
L'accès à un champs d'une structure se fait en spécifiant le nom de la variable et le nom du champs introduit par l'opérateur point '.', comme dans l'exemple suivant:
origine.abscisse = 2;
où origine est le nom de la variable et abscisse le nom du composant.
Une structure peut également être accédée par pointeur, struct Point *ptr. Dans ce cas on utilisera plus volontiers l'opérateur flèche '->' pour accéder à un composant, comme dans l'exemple suivant:
struct Point *ptr = &origine;(*ptr).abscisse = 2;ptr->abscisse = 2; // syntaxe plus claire et concise que celle de l'instruction précédente
L'écriture ptr->abscisse = 2; équivaut à l'expression (*ptr).abscisse = 2; (attention, les parenthèses sont obligatoires vu les règles de priorité des opérateurs) mais nous préférerons la première à la seconde pour raison de lisibilité.
Opérations sur les structures
Les seules opérations permises sur les structures sont:
-
l'accès à un champ
origine.abscisseptr->abscisse -
prendre l'adresse d'une structure
ptr = &origine -
prendre l'adresse d'un champ
int * p = &origine.abscisse -
affectation de structure à structure (remarquez qu'une structure est copiée bit à bit dans une autre structure de même type)
monPoint = origine; -
passage d'une structure comme paramètre de fonction (cf. point suivant)
void afficherPoint (struct Point p) { ... } -
structure comme valeur de retour d'une fonction
struct Point fct (int a, int o) { struct Point p; ....; return p; }
Structure comme paramètre de fonction
Le langage C définissant uniquement le passage par valeur, une structure passée en paramètre à une fonction sera copiée (bit à bit) sur la pile d'exécution. Par conséquent, la structure ne pourra pas être modifiée par la fonction.
void fct (struct MaStructure st) {...}
Pour pouvoir être modifiée par une fonction, l’adresse de la structure doit être fournie en paramètre!
void fct (struct MaStructure * st) {...}
L'adresse d'une structure peut également être fournie à une fonction pour une autre raison. En effet, dans le cas d'une 'grosse' structure (en terme d'espace mémoire), on préfèrera fournir un pointeur pour éviter une copie couteuse de la structure sur la pile. Dans ce cas, la structure sera déclarée constante si l'on souhaite éviter qu'elle soit modifiée par la fonction.
void fct (const struct GrosseStructure * st) {...}
Taille d'une structure
La taille d'une structure sera toujours supérieure ou égale à la somme des tailles de tous ses composants: sizeof(struct) ≥ ∑ sizeof(composants)
Ceci est lié à la nécessité d'aligner les champs sur des adresses mémoires compatibles avec le type des composants (cf. phénomène du structure padding que l'on pourrait traduire par "espacement" ou "remplissage" de structure).
Structures récursives
Une structure récursive est une structure dont au moins un des champs est lui-même une structure de ce même type. En C, il n'est pas possible de créer une structure réellement récursive car le compilateur doit connaître la taille de la structure et donc la taille de chaque champ avant de l'utiliser. L'écriture suivante est donc interdite :
struct Noeud {int valeur;struct Noeud suivant; // erreur: récursivité interdite};
Pour contourner cette limitation et vu l'importance des structures récursives dans les langages de programmation, le C va utiliser les pointeurs sur structures (la taille d'un pointeur est une valeur connue) pour référencer une structure à l'intérieur d'elle-même. Comme dans l'exemple suivant:
struct Noeud {int valeur;struct Noeud * suivant; // OK: récursivité par pointeur};
Champs de bits (bit fields)
Il existe une utilisation particulière des structures, les champs de bits qui vont limiter les composants à un certains nombre de bits. En effet, il est parfois nécessaire pour un programmeur de décrire en termes de bits la structure d'une ressource matérielle de la machine. Un exemple typique est la programmation système qui nécessite de manipuler des registres particuliers de la machine. Dans une déclaration de structure, il est possible de faire suivre la définition d'un membre par une indication du nombre de bits que doit avoir ce membre. Le langage C appelle cela un champ de bits:
struct Champ {unsigned a:4;unsigned :2; // composante inutilisés, sert de paddingunsigned b:5;unsigned c:5;};
L'accès à un champ se fait comme pour les structures classiques x.c mais il n'est pas permis de prendre l'adresse d'un tel champ (ex: &x.c).
Structures anonymes
Pour les structures utilisées ponctuellement, la spécification d'un tag est inutile. On peut écrire:
struct {int x, y;double d;} a, b, *p;
Notez cependant que ces déclarations sont uniques: la même déclaration ailleurs dans le code créera un autre type et donc des variables incompatibles!
Enumérations
Un type enuméré est un type construit à partir d'un ensemble de valeurs spécifiées dans la définition de ce type. Le principal intérêt des énumérations est de permettre de définir des ensembles de constantes entières en les regroupant par thème. Cela permet d'éviter d'utiliser des littéraux et de rendre le code plus facile à lire.
enum Couleur {ROUGE, VERT, BLEU, BLANC, NOIR};
définit un type enum Couleur et les différentes valeurs que peuvent prendre les variables de ce type.
enum Couleur maCouleur, maFavorite;
définit 2 variables de type enum Couleur.
Les identifiants des valeurs énumérées respectent les règles du C pour les identificateurs. Par convention d'écriture et étant donné qu'il s'agit de valeurs constantes, les identifiants d'énumérations s'écrivent de préférence en majuscules. Ils s'utilisent seuls, sans spécifier le nom du type:
maCouleur = ROUGE;
Utilisation
Une énumération est en fait un type entier, et chaque valeur déclarée dans le type correspond à une valeur entière. La valeur de chaque constante énumérée est celle de la précédente augmentée de 1 et celle de la première est 0. Dans l'exemple ci-dessus, ROUGE vaut 0, VERT 1, etc.
Il est également possible de préciser la valeur de certaines constantes (ou de toutes les constantes) à l’aide d’une affectation:
enum EntierNaturel {DIX = 10, ONZE, DOUZE , TREIZE};
Ce qui est parfaitement équivalent à:
enum EntierNaturel {DIX = 10, ONZE = 11, DOUZE = 12, TREIZE = 13};
Une énumération étant un type entier, l'arithmétique sur les entiers peut s'y appliquer.
maCouleur++;
va recevoir la couleur VERT ou
maFavorite = maCouleur + BLEU;
code tout à fait illisible et à déconseiller.
Cette propriété des énumérations fait qu'elles sont fréquemment utilisées dans des for ou dans des switch (i.e. branchement multiple en fonction d'une valeur entière):
switch (maCouleur) {case ROUGE :// instructions1break;case JAUNE :// instructions2break;case VERT :// instructions3break;default :// instructions4}
Notez pour finir que le type énuméré est souvent utilisé pour définir les constantes booléennes en C:
enum Boolean {FALSE, TRUE};
Etant donné qu'une énumération est de type entier, cette déclaration définit bien FALSE à 0 et TRUE à 1.
Énumération vs magic numbers
Comme le montrent les exemples précédents, les types énumérés améliorent la lisibilité du code. Mais ils présentent un autre intérêt: rendre le code plus fiable en remplaçant les « magic numbers » (i.e. constantes numériques non nommées dans le code source). Une énumération permet d'éviter d'utiliser des magic numbers en nommant les constantes d'un programme. Cela rend le code plus lisible, plus compréhensible et plus facilement maintenable.
Pour illustrer cela, imaginons un programme ayant besoin de nombreux compteurs, par exemple pour de l'analyse statistique de texte. La définition d'un tableau de compteurs permettra d'éviter la multiplication des variables et d'utiliser des boucles for :
int nStat[4];// nStat[0] --> nombre de paragraphes// nStat[1] --> nombre de lignes// nStat[2] --> nombre de mots// nStat[3] --> nombre de caractères
Chaque indice du tableau désigne un compteur particulier. Ainsi on accède au nombre de mots en écrivant nStat[2]. Cet indice est un magic number ! Toute modification de cette liste de compteurs (p.ex. changer la séquence en insérant un compteur de chapitres au début et un compteur de caractères sans espaces à la fin) est sujette à erreurs. Une solution plus élégante et surtout plus fiable est d'utiliser un type énuméré:
enum TextStat {CPT_PAR,CPT_LIG,CPT_MOT,CPT_CAR,STAT_NUM // Nombre total de statistiques};int nStat[STAT_NUM];
Pour accéder au nombre de mots, on écrit dès lors: nStat[CPT_MOT]. Si l'on souhaite ajouter de nouveaux compteurs, il suffit de mettre à jour le type énuméré sans avoir à modifier de magic number ou le reste du code. Il faut juste s'assurer que STAT_NUM est placé à la fin de l'énumération.
Unions
Une union est une structure où les champs se superposent en mémoire. Ces unions sont utilisées pour stocker dans une même zone mémoire des valeurs de types différents, mais à des moments différents. Par exemple :
union Exemple {int a;double b;char *s;};
Une variable de ce type pourra contenir soit un int, soit un double, soit une chaîne (i.e. adresse sur un caractère).
Par conséquent, la taille d'une union est au moins égale à la taille du plus grand champ:
sizeof(union) ≥ max ( sizeof(composants) )Le bon usage des unions
L'union sera fréquemment utilisée en gérant un code identifiant le champ utilisé dans la variable. Et donc on pourra avoir une structure reprenant l'union et le code indiquant le champ à considérer dans cette union. Par exemple:
struct MonUnion {int type; // 0 --> double, 1 --> int, 2 --> charunion {double x;int y;char c;} u;} s;
L'utilisation de la variable s impliquera de spécifier le type :
#define DOUBLE 0#define INT 1#define CHAR 2if (s.type == DOUBLE) {traiter(s.u.x);}
typedef
Le mot-clé typedef permet de définir un synonyme d'un type existant. Par exemple
typedef unsigned int size_t;
définit un synonyme size_t à un entier non signé unsigned int.
L'utilisation de typedef ne crée pas un nouveau type (avec tous les contrôles et tests effectués par le compilateur), mais il aide à la lecture et à la compréhension du code.
L'usage de typedef est recommandé pour simplifier l'écriture de types complexes, comme les structures. Nous trouverons donc fréquemment le renommage de structures via typedef.
struct Point {int abscisse;int ordonnees;};typedef struct Point Point;
Il n'y a pas de confusion entre les deux noms Point car ils sont utilisés dans deux classes de nommage différentes (Point et struct Point).
Voici une version plus condensée:
typedef struct Point {int abscisse;int ordonnee;} Point;
ou de façon encore plus condensée (notez qu'une telle structure anonyme ne conviendra pas dans le cas de structures récursives):
typedef struct {int abscisse;int ordonnee;} Point;
Pour terminer, remarquez que grâce à typedef, le type booléen (qui n'existe pas en ANSI C mais a été introduit par la norme C99 via la librairie standard stdbool.h) peut être simplement défini grâce à l'instruction:
typedef enum {FALSE, TRUE} Boolean;...Boolean valid = TRUE; // définition d'une variable booléenne