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 :

  1. 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.
  2. À 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.
  3. 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 :

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;
};

Cet article provient de Wikibooks et est sous licence GNU Free Documentation License. Il a été écrit par plusieurs personnes et est constamment mis à jour. Cet article est la version du 6 mai 2005 à 04:14. L'article d'origine se trouve à http://fr.wikibooks.org/wiki/Programmat ... ypes_avancés.