Structures, unions et énumérations de données |
Le besoin de structurer les données
Lorsqu'on écrit un programme, on tente souvent de représenter des objets ayant des propriétés. Ainsi par exemple, on peut vouloir faire un programme qui manipule des représentations informatiques d'un point de l'écran, donc repéré par 2 coordonnées x et y, et possédant une couleur color. Or les coordonnées sont propres à chaque point ainsi créé. On veut que chaque point ait une coordonnée x, une y et une couleur, sans avoir besoin de trouver pour chaque point un nom différent de variable et sans avoir recours à des tableaux non nécessaires.
Une solution a été trouvée sous la forme de structures, ou struct. Une struct est un type défini par le programmeur, et qui contient des variables qui seront propres à chaque instance de ce type (instance = exemplaire de variable). Voici tout de suite un petit exemple pour ne pas vous noyer dans le flou :
struct Point
{ int x; }; Point centre, pixel; centre.x = 160; pixel.x = 10; |
Les coordonnées d'un point sont entières (vu que l'écran est divisé en pixels) et la couleur est stockée sur 8 bits ce qui nous permet d'afficher 256 couleurs (ça me permet également de vous montrer qu'on peut utiliser des types différents pour les membres d'une structure :-).
L'instruction struct Point { /* ... */ }; définit un nouveau type appelé Point, et les variables membres qu'il contient. La ligne qui suit déclare alors 2 variables du type Point. Ces deux variables seront alors dotées des membres x, y et color, chacune ayant son exemplaire distinct de ceux-ci.
L'accès aux données membres
Les six lignes restantes vous montrent comment accéder à ces variables membres. Bien sûr, toutes les opérations autres que l'affectation sont possibles, comme sur les variables classiques, et la seule différence, c'est qu'elles sont différenciées les unes des autres par le moyen d'y accéder : nomDeVariable.nomDeMembre. Le point est très important.
De la même façon que l'on parle toujours de la couleur de quelque chose, vous ne pouvez pas utiliser x, y ou color sans préciser de quelle variable vous parlez, cela n'a aucun sens, à moins bien sûr d'en avoir déclaré au préalable :
struct Point
{ int x; }; Point pixel; pixel.x = 15; pixel.y = 30; |
L'instruction x = -1; ne pose aucun problème, car x a été préalablement déclaré. Bien sûr, il n'y a aucune interférence entre x et pixel.x, et il faut considérer ces variables comme totalement différentes.
Par contre, l'instruction y = -2; provoquera une erreur lors de la compilation, car y n'a pas été déclaré, même si la struct Point contient une donnée membre qui s'appelle y : elles n'ont rien à voir l'une avec l'autre.
Tout comme les variables, les types personnalisés (comme les struct) ont leur portée. La plupart du temps, on crée des struct à la portée globale, car ainsi on peut déclarer des variables de ce type n'importe où dans le programme, sans avoir à se soucier de savoir si le type est accessible.
Retenez que :
![]() |
|
Les syntaxes
Vous avez plusieurs façons de définir ce nouveau type que sera votre struct. Les voici présentées une par une :
|
|
|
|||
|
|
|
|||
Ce type de déclaration est le plus courant, et je vous recommande de l'utiliser plus que les autres. En effet, la définition du type Point peut se faire n'importe où, et vous pourrez contrôler la portée des variables de ce type que vous déclarerez. En effet, on voudrait qu'un type soit universel (utilisable partout dans le pogramme), mais pas nécessairement les variables. | Ce type de déclaration est également courant. Vous constatez que le bloc de déclaration de données membres est immédiatement suivi de deux déclarations de variables. Cet exemple à l'inconvenient de donner aux variable la même portée que le type lui-même, autrement dit, dans la plupart des cas, une portée globale (ce que nous n'aimons pas!!!). Bien sûr, rien n'empêche de déclarer d'autres variables de ce type de manière plus conventionnelle lorsque vous en aurez besoin. | Vous pouvez également créer un type anonyme et déclarer deux variables de ce type, sans avoir spécifié le nom du type. Le problème de portée est le même que dans l'exemple ci-avant, mais en plus, vous ajoutez le fait que vous ne pourrez plus déclarer de variables de ce type. Si vous êtes sûr de ne pas avoir besoin de redéclarer de variables de ce type particulier (y compris pour passer des arguments à une fonction), vous pouvez utiliser cette notation, ce qui vous évite de devoir trouver un nom pour chaque type, mais cela est peu recommandé. |
Opérations
Avec les types fondamentaux, nous étions habitués à représenter des valeurs. Mais avec ce nouveau type, nous devons étendre la notion de valeur à la notion d'objet. En effet, une fois que nous avons créé notre struct, il ne s'agit plus d'une valeur mais d'un objet donc chaque membre représente une propriété. Ainsi, si on peut faire des additions entre entiers ou réels, on ne peut faire d'additions entre objets : additionnez un nombre avec un autre, vous obtenez un nombre. Additionnez une poire avec une autre, vous obtenez... deux poires! Ca ne marche plus.
Lorsque vous écrivez quelque chose comme :
Point pixel1, pixel2;
/* on trifouille avec pixel1 */; pixel2 = pixel1; |
le compilateur interprète cela comme copie tous les membres de pixel1 dans pixel2. Alors, comme nous avons affaire à des types conventionnels, tout se passe bien, et on obtient en fait :
Point pixel1, pixel2;
/* on trifouille avec pixel1 */; pixel2.x = pixel1.x; pixel2.y = pixel1.y; pixel2.color = pixel1.color; |
C'est d'ailleur ce qui se passe si vous passez une structure comme argument d'une fonction (mais oui, c'est parfaiement possible, comme avec tous les types de données en C/C++). Souvenez-vous, un argument d'une fonction à l'intérieur de celle-ci est une copie de la variable passée lors de l'appel de cette fonction.
Par contre, si vous tentez d'additionner pixel1 avec pixel2, le compilateur vous crachera à la figure un fort sympatique Illegal structure operation, autrement dit opération illégale avec les structures.
Avec les fonctions
Passer une struct comme argument d'une fonction n'a rien de sorcier, c'est même tout naturel :
struct Point { /* nos données membres */ };
void AfficheValeurs(Point point) cout << "x = " << point.x } int main(void) Point pixel; AfficheValeurs(pixel); return 0; } |
C'est également enfantin de renvoyer une struct avec return, sur le même principe que pour les autres types normaux que nous connaissons.
Enfin, pour les plus téméraires, sachez que comme une struct est un type de donnée, vous pouvez très bien construire une struct avec comme membre une autre struct. Vous pouvez également très bien construire des tableaux d'instances de struct, et les utiliser avec la syntaxe suivante :
struct Point { /* nos données membres */ };
Point tableau[100]; tableau[i].x = i; |
Les Unions
Dans une structure, les données membres sont placées de manière contigüe en mémoire. Supposons que dans notre ordinateur, un char prenne un octet, un int en prenne 4 et un long double en prenne 10 (comme indiqué dans l'annexe Les types de variables). Définissons alors notre struct comme ceci :
struct Structure
{ char c; }; |
Voici alors le schéma de sa répartition en mémoire :
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Elle occupe donc en mémoire la somme des tailles de ses membres. D'ailleurs, vous pouvez avoir la taille de votre struct avec l'opérateur sizeof.
Il existe un autre type de structure de données, appelé union. Une union se comporte extérieurement de manière très similaire à la struc, mis à part qu'elle ne peut être passée comme argument à une fonction. En matière de syntaxe et d'accès aux données membres, rien ne change. Ce qui change, c'est la répartition en mémoire et les conséquences qui en découlent quant à l'utilisation des membres.
Voici notre premier exemple d'union :
union Union
{ char c; }; |
Vous constatez qu'il n'y a pas grand chose qui change. Par contre, voici sa répartition en mémoire :
|
|||||||||
|
|
|
|
||||||
|
|
|
|
|
|
|
|
|
|
Les trois membres commencent tous à la même case!!! Donc la taille de l'union est égale à la taille du membre le plus grand. Le rôle principal de l'union est d'économiser de l'espace en mémoire. En effet, au lieu de mettre les membres bout à bout en mémoire, vous les superposez, si bien que vous pourriez utiliser 50 ints dans une fonction sans prendre plus de 4 octets en mémoire. Cependant, si c'était aussi simple que ça, on ne travaillerait qu'avec des unions. En fait, il y a une contrainte : on ne peut travailler qu'avec un seul membre à la fois. Je m'explique.
Du fait de cette superposition, vous ne pouvez charger qu'un seul type à la fois. Si vous effectuez une affectation à l'un, les valeurs des autres membres ne sont plus retenues... ce n'est ni facile à expliquer, ni facile à comprendre. Un bon moyen de retenir ce qui se passe, c'est de se dire qu'une union est comme un ensemble de variables que vous ne pouvez jamais utiliser en même temps : l'utilisation de l'une de ces variables provoque la "disparition" de toutes les autres, jusqu'à ce que vous réeffectuiez une affectation avec une autre.
Je vous ai présenté ici les unions car elles existent. Cependant, je crois bien que je n'en ai jamais utilisé une seule de ma vie, et tenter de les utiliser en pensant économiser de la mémoire, c'est se compliquer la vie pour rien. Je ne reparlerais donc plus de ces unions, et tout ce qu'il vous faut savoir, c'est qu'elles existent, histoire que vous ne soyez pas complètement déboussolés si vous en croisez une dans un programme.
Enumérations de données
En C/C++, il existe un mot clé, enum, qui permet de créer un type, dérivé d'un type fondamental existant, permettant de ne prendre que certaines valeurs, représentées par des contantes du type de l'enum. C'est pas clair? Prenez par exemple les mois de l'année : il n'y en a que 12 et ils portent tous un nom. Il semble donc cohérent de les regrouper et d'en faire un pseudo-type : ainsi, vous manipulez des mois, et pas un entier quelconque. Voici un petit exemple :
enum mois
{ Janvier, Fevrier, Mars, }; mois actuel = Fevrier; actuel = 13; |
Ce petit exemple vous montre l'affichage suivant :
Nous sommes en février (1)
Tiens, c'est bizarre! |
Permière constation : actuel à une valeur (il vaut 1). Pourtant, nous n'avons écrit aucun nombre dans le programme? En effet. Dans la déclaration de l'enum, nous listons toute une série de constantes qui portent les noms des 12 mois de l'année. Or ces constantes devraient différer en valeur (pour les comparaisons, c'est tout de même plus pratique). Donc, le compilateur leur assigne a chacune une valeur par défaut : 0 pour la première constante de la liste, et il incrémente ensuite la valeur de 1 pour chaque constante qui suit. Ainsi, Janvier vaut 0, Fevrier vaut 1 (comme le montre le programme), etc jusqu'à Decembre qui vaut 11.
Bien sûr, rien ne vous empêche de préciser une valeur pour n'importe laquelle de ces constantes : il suffit de rajouter = valeur après le nom de la constante dont vous souhaitez changer la valeur. Si vous faites cela, le compilateur va continuer son incrémentation automatique à partir de la valeur que vous avez précisé. Vous pouvez également très bien donner à l'une de ces constantes la même valeur qu'à une autre. Puisque je te dis que tout est possible, mon frère! :-)
Par contre, si vous avez compilé cet exemple (en faisant les rajouts d'usage, bien entendu), vous avez surement eu droit à une mise en garde du genre : Assigning int to mois. Ceci signifie que vous avez précisé une valeur qui n'était pas du type mois à une variable qui elle, est du type mois. Ceci vous permet de contrôler les valeurs que vous assignez à vos variables enum, afin de garder la cohérence citée susditement (vous remarquerez au passage que les constantes de l'éunumération sont de type int). Vous l'aurez compris, l'enum est surtout là pour permettre d'isoler certaines valeurs d'un type fondamental afin de les différencier d'autres valeurs, de même type mais n'ayant pas la même fonction.
Définition d'un type à partir d'un autre (alias de type)
Imaginons que nous souhaitions faire un programme centré sur des calculs mathématiques tels que nous les connaissons. Dans ce cas là, nous voudrions bien pouvoir parler en termes que nous utilisons en maths, c'est-à-dire avec des mots comme reel (de l'ensemble R), entier naturel (de l'ensemble N), entier relatif (de l'ensemble Z)... Or le C/C++ ne nous permet d'utiliser que des valeurs de type int, float, double, etc, vous connaissez la ribambelle comme votre propre famille maintenant. Nous voudrions donc pouvoir créer des types qui ressemblent un peu plus à ceux que nous utilisons depuis notre plus tendre enfance (vous savez, à l'époque où les professeurs avaient encore le droit de coller une bonne grosse torgnole aux mauvais élèves... ah, que de souvenirs émouvants et tendres). Vous voulez la formule magique? La voici :
typedef unsigned int entierN;
typedef signed int entierZ; typedef signed double reel; reel x = 3.1; |
Je vois que vous êtes sublimés par la magnificience de la construction syntaxique. Ou alors, c'est encore l'un de mes délires. Ca vient peut-être de cette soupe aux champignons de tout-à-l'heure... :-)
Vous l'aurez compris, typedef permet de créer un alias pour un type donné : ici, entierN signifie tout simplement unsigned int, mais il est plus adapté au contexte dans lequel nous nous sommes placés (à savoir un programme chargé en maths). Ceci vous permet en plus de profiter du typage assez sévère du C++, très utile pour éviter des affectations douteusese et pour surveiller votre programme en général.
Prenez garde, cependant : créer trop de types à votre sauce rend vite le programme illisible. Un exemple, et ceci n'engage que moi, c'est les types définis lorsqu'on programme pour Windows : il doit y avoir une bonne dizaine d'alias différents pour des valeurs qui ne sont en fait que des int (il y a les HANDLE, les COLORREF, les HWND... et puis avec les majuscules, c'est très laid!!!). Or c'est plutôt confusionnant, à la fin. Sous BeOS, on peut trouver des alias déjà plus judicieux : le int32 est un int qui tient sur 32 bits quelque soit l'architecture. C'est déjà plus raisonnable et plus utile. Choisissez donc judicieusement vos alias, et surtout (comme pour tout), éviter d'en faire trop (ou comme l'a dit Confucius en son temps : "l'informatique est un vaste monde dans lequel l'âme doit trouver le chemin célèste, alors s'il vous plaît, écrivez des programmes lisibles").
Les meilleures choses ont une fin
Eh! oui, même ce cours-ci va se terminer... c'est triste, mais il faut accepter la fatalité incommensurable de la réalité. :-)
Aujourd'hui, nous allons faire un travail un peu différent : vous allez bâtir le programme du jour par vous-même, de A à Z (en passant par K, bien évidemment). Mon travail se limitera à vous donner les instructions pour le réaliser. Vous allez utiliser, bien entendu, une struct comme élément principal du programme. Pour les autres choses que nous avons vu aujourd'hui, et que je considère comme des sucreries (et plus particulièrement l'union), vous pourrez par vous-même chercher des applications et des exemples. Voici tout d'abord un petit rappel pour tout remettre au point :
![]() |
|
Maintenant que nous avons revu ceci, voici votre liste de courses :
![]() |
C(v1*w2 - v2*w1, u1*w2 - u2*w1, u1*v2 - u2*v1). |
Voilà, ce sera tout. Je ne vous l'ai pas encore dit, mais nous nous servirons de ce genre de Vecteur pour faire le RayTracer, donc ne les négligez pas, sinon vous aurez ça en plus à devoir comprendre avant de vous lancer dans ce passionnant projet. Bien sûr, rien ne vous empêche d'étoffer ce programme et ajoutant encore des fonctions. On pourrait égalemement imaginer une struct Point représentant un point dans l'espace, et construire des vecteurs à partir de points, et bien d'autres gourmandises encore. N'hésitez pas à vous lancer dans cette direction, car vous y gagnerez pour le RayTracer tant promis!!! Alors, à vos Vecteurs!
De plus, vu qu'il n'y a pas de programme aujourd'hui pour vous servir d'exemple, surtout n'hésitez pas à me contacter si quelque chose vous semble difficile.
Voir aussi: Les types de variables