Types avancés
Écrit le 11/11/2004 par Wikibooks
Dernière mise à jour : 02/02/2006
Structures
struct ma_structure {
type1 champ1;
type2 champ2;
...
typeN champN;
} var1, var2, ..., varM;
Déclare une structure (ou enregistrement) ma_structure composé de N champs, champ1 de type type1, champ2 de type type2, etc. On déclare, par la même occasion, M variables de type struct ma_structure.
Accès aux champs
L'accès aux champs d'une structure se fait avec un point :
struct complexe { int reel; int imaginaire; } c; c.reel = 1; c.imaginaire = 2;
Initialisation
Il y a plusieurs façons d'initialiser une variable de type structure :
- En initialisant les champs un à un :
struct { char ch; int nb; float pi; } variable;
variable.ch = 'a';
variable.nb = 12345;
variable.pi = 3.141592;
Cette façon est néanmoins pénible lorsqu'il y a beaucoup de champs. - À la déclaration de la variable :
struct { char ch; int nb; float pi; } variable = { 'a', 12345, 0.141592 };
Les valeurs des champs sont assignés dans l'ordre où ils sont déclarés. S'il manque des initialisations, les champs seront initialisés à 0. L'inconvénient c'est qu'on doit connaitre l'ordre où sont déclarés les champs, ce qui peut être tout aussi pénible à retrouver. - Une extension de la norme ISO C99 permet d'initialiser certains champs à la déclaration de la variable, en les nommants :
struct { char ch; int nb; float pi; } variable = { .pi = 3.141592, .ch = 'a', .nb = 12345 };
Les champs non initialisés seront mis à zéro.
Manipulation
Les structures se manipulent par valeur, comme les types atomiques du langage C et contrairement aux tableaux.
La seule opération prise en charge par le langage est la copie binaire, lors des affectations ou des passages de paramètres à des sous-fontions. Toutes les autres opérations sont à la charge du programmeur, notamment la comparaison d'égalité (C.f section suivante).
Alignement et bourrage (padding)
Il s'agit d'un concept relativement avancé, mais qu'il est bien de connaitre pour agir en connaissance de cause. Lorsqu'on déclare une structure, on pourrait naïvement croire que les champs se suivent les uns à la suite des autres en mémoire. Considérez la structure suivante :
struct ma_structure { char champ1; /* 8bits */ int champ2; /* 32bits */ char champ3; /* 8bits */ };
On pourrait penser que cette structure fasse 6 octets et pourtant sur la majeure partie des compilateurs, pour ne pas dire tous, on obtiendrait une taille de 12 octets.
En fait les compilateurs insèrent des octets entre les champs pour pouvoir les aligner sur un adressage pair, ou multiple de 4 ou de 8. C'est en fait une limitation de la plupart des processeurs, qui ne peuvent lire des mots de plus d'un octet que s'ils sont alignés sur un certain adressage. En fait toutes les variables déclarées suivent cette règle : aussi bien les variables locales aux fonctions, les champs de structures, les paramètres de fonctions, etc.
Cette quantité de bourrage est en fait non seulement dépendante de l'architecture, mais aussi du compilateur. Ce dernier possède en général des options qui permettent de paramétrer avec quelle finesse se fera l'alignement. Ces options sont bien évidemment spécifiques et pas du tout portables.
Concernant les structures, le seul cas où on a la garantie de n'avoir aucun bourrage, c'est lorsque les champs sont tous du même type (ou au moins de la même taille). Ce qui revient à déclarer un tableau, en fait. Dans l'exemple précédant, pour avoir une structure « compacte », on aurait pu écrire :
struct ma_structure { char champ1; char champ2[sizeof(int)]; char champ3; };
L'initialisation du champ2 est néanmoins plus délicate. À noter que l'expression suivante n'est pas portable et pourra provoquer des comportements imprévisibles, bien que syntaxiquement valide et ne générant un avertissement que sur très peu de compilateur :
/* Ce code contient une erreur grossière et volontaire */ struct ma_structure essai; * (int *) essai.champ2 = 12345;
En effet le champ2 de la structure n'étant pas aligné, sur certaine architecture l'initialisation peut effectuer un accès illégal à la mémoire et stoppera dans ce cas l'exécution du programme. C'est pour cette raison que la norme ISO C déconseille (interdit) l'usage de conversion dans le membre gauche d'une affectation. Une bonne façon de procéder serait :
struct ma_structure essai; int entier = 12345; memcpy( essai.champ2, &entier, sizeof entier );
Plus pénible certes, mais nécessaire pour garantir une bonne portabilité.
Pointeurs vers structures
Il est (bien entendu) possible de déclarer des variables de type pointeur vers structure :
struct ma_struct * ma_variable;
Comme pour tout pointeur, on doit allouer de la mémoire pour la variable avant de l'utiliser :
ma_variable = (struct ma_struct *) malloc( sizeof(struct ma_struct) );
L'accès aux champs peut se faire comme pour une variable de type structure « normale » :
(* ma_variable).champ
Ce cas de figure est en fait tellement fréquent, qu'il existe un raccourci pour l'accès aux champs d'un pointeur vers structure :
ma_variable->champ
Unions
Une union et un enregistrement se déclarent de manière identique :
union {
type1 champ1;
type2 champ2;
...
typeN champN;
} var1, var2, ..., varM;
Toutefois, à la différence d'un enregistrement, les N champs d'une instance de cette union occupent le même emplacement en mémoire. Modifier l'un des champ modifie donc tous les champs de l'union. Typiquement, une union s'utilise lorsqu'un enregistrement peut occuper plusieurs fonctions bien distinctes et que chaque fonction ne requière pas l'utilisation de tous les champs.
L'exemple suivant déclare une structure droite, qui a pour but de coder une droite du plan passant par deux points connus, ou passant par un point et perpendiculaire à une autre droite connue.
struct { int type; union { struct { struct point *p1, *p2; }; struct { struct point *p; struct droite *d; }; }; } droite;
Selon la valeur de type, on ira chercher l'information soit dans p1 et p2, soit dans p et d.
Définitions de nouveaux types (typedef)
Le langage C offre un mécanisme assez pratique pour définir de nouveaux types à partir des types atomiques. Il s'agit de l'instruction typedef.
typedef ancien_type nouveau_type;
Contrairement au langage à typage fort (comme le C++), le C se base sur les types atomiques pour décider de la compatibilité entre deux types. Dit plus simplement, la définition de nouveaux types est plus un mécanisme d'alias qu'une réelle définition de type. Les deux types sont effectivement parfaitement interchangeable. À la limite on pourrait presque avoir les mêmes fonctionnalités en utlisant le préprocesseur C, bien qu'avec ce dernier vous aurez certainement beaucoup de mal à sortir de tous les pièges qui vous seront tendus.
Quelques exemples
typedef unsigned char octet; typedef double matrice4_4[4][4]; typedef struct ma_structure * ma_struct; typedef void (*gestionnaire_t)( int ); /* Utilisation */ octet nombre = 255; matrice4_4 identite = { {1,0,0,0}, {0,1,0,0}, {0,0,1,0}, {0,0,0,1} }; ma_struct pointeur = NULL; gestionnaire_t fontion = NULL;
Cette instruction est souvent utilisé conjointement avec la déclaration des structures, pour s'affranchir de devoir écrire à chaque fois le mot clé struct. Elle permet aussi de grandement simplifier les prototypes de fonctions qui prennent des pointeurs sur des fonctions en argument. Il est conseillé de définir un nouveau type, plutôt que de l'écrire in extenso dans la déclaration du prototype. Considérez les deux déclarations :
/* Déclaration confuse */ void (*fonction(int, void (*)(int)))(int); /* Déclaration claire avec typedef */ typedef void (*handler_t)( int ); handler_t fonction( int, handler_t );
Les vétérans des systèmes Unix auront reconnu le prototype imbitable de l'appel système signal(), qui permet de rediriger les signaux POSIX.
Énumérations
enum nom_enum { val1, val2, ..., valN };
Les symboles val1, val2, ..., valN pourront être utilisés littéralement dans la suite du programme. Ces symboles sont en fait remplacés par des entiers lors de la compilation. La numérotation commençant par défaut à 0, s'incrémentant à chaque déclaration. Dans l'exemple ci-dessus, val1 vaudrait 0, val2 1 et valN N-1.
On peut changer à tout moment la valeur d'un symbole, en affectant au symbole, la valeur constante voulue (la numérotation recommençant à ce nouvel indice). Par exemple :
enum Booleen { Vrai = 1, Faux = 0 }; /* Pour l'utiliser */ enum Booleen variable = Faux;
Ce qui est assez pénible en fait, puisqu'il faut à chaque fois se souvenir que le type Booleen est dérivé d'une énumération. Il est préférable de simplifier les déclarations, grâce à l'instruction typedef :
typedef enum { Faux, Vrai } Booleen; /* Pour l'utiliser */ Booleen variable = Faux;
À la lecture de ceci, on peut légitimement se demander ce qu'aporte en plus les énumérations par rapport aux directives du préprocesseur. En fait, on peut essentiellement souligner que :
- Lors des cas multiples (
switch), le compilateur peut vérifier que l'ensemble des cas couvre l'intervalle de valeur du type, et émettre un avertissement si ce n'est pas le cas. Ce qui est évidemment impossible à faire avec des#define. - Là où l'utilité est plus flagrante, c'est lors du débogage. Un bon débogueur peut afficher le nom de l'élément énuméré, au lieu de simplement une valeur numérique, ce qui rends un peu moins pénible ce processus déjà très rébarbatif à la base, surtout lorsque le type en question est une structure avec des dizaines, pour ne pas dire une centaine, de champs.
- Certains compilateurs (
gccpour ne citer que le plus connu) n'inclu pas par défaut les symboles du préprocesseur avec l'option standard de débogage (-g), principalement pour éviter de faire exploser la taille de l'exécutable. Si bien que dans un débogueur il est souvent impossible d'afficher la valeur associée à une constante du préprocesseur autrement qu'en fouillant dans les sources. À moins d'avoir un environnement plutôt évolué, cette limitation peut s'avérer très pénible.
Type incomplet
Pour garantir un certain degré d'encapsulation, il peut être intéressant de masquer le contenu d'un type complexe, pour éviter les usages trop « optimisés » de ce type. Pour cela, le langage C permet de déclarer un type sans indiquer explicitement son contenu.
struct ma_structure; /* Plus loin dans le code */ struct ma_structure * nouvelle = alloue_objet();
Les différents champs de la structure n'étant pas connus, le compilateur ne saura donc pas combien de mémoire allouer. On ne peut donc utiliser les types incomplets qu'en tant que pointeur. C'est pourquoi, il est pratique d'utiliser l'instruction typedef pour alléger les écritures :
typedef struct ma_structure * ma_struct; /* Plus loin dans le code */ ma_struct nouvelle = alloue_objet();
Cette construction est relativement simple à comprendre, et proche d'une conception objet. À noter que le nouveau type défini par l'instruction typedef peut très bien avoir le même nom que la structure. C'est juste pour éviter les ambiguités, qu'un nom différent a été choisi dans l'exemple.
Un autre cas de figure relativement classique, où les types incomplets sont très pratiques, ce sont les structures s'auto-référençant, comme les listes chainées, les arbres, etc.
struct liste { struct liste * suivant; struct liste * precedant; void * element; };
Ou de manière encore plus tordue, plusieurs types ayant des références croisées :
struct type_a; struct type_b; struct type_a { struct type_a * champ1; struct type_b * champ2; int champ3; }; struct type_b { struct type_a * ref; void * element; };



