3. Types avancés et classes de stockage▲
Le langage C/C++ permet la définition de types personnalisés construits à partir des types de base du langage. Outre les tableaux, que l'on a déjà présentés, il est possible de définir différents types de données évolués, principalement à l'aide de la notion de structure. Par ailleurs, les variables déclarées dans un programme se distinguent, outre par leur type, par ce que l'on appelle leur classe de stockage. La première section de ce chapitre traitera donc de la manière dont on peut créer et manipuler de nouveaux types de données en C/C++, et la deuxième section présentera les différentes classes de stockage existantes et leur signification précise.
3.1. Structures de données et types complexes▲
En dehors des types de variables simples, le C/C++ permet de créer des types plus complexes. Ces types comprennent essentiellement les structures, les unions et les énumérations, mais il est également possible de définir de nouveaux types à partir de ces types complexes.
3.1.1. Les structures▲
Les types complexes peuvent se construire à l'aide de structures. Pour cela, on utilise le mot clé struct. Sa syntaxe est la suivante :
struct
[nom_structure]
{
type champ;
[type champ;
[...]]
}
;
Il n'est pas nécessaire de donner un nom à la structure. La structure contient plusieurs autres variables, appelées champs. Leur type est donné dans la déclaration de la structure. Ce type peut être n'importe quel autre type, même une structure.
La structure ainsi définie peut alors être utilisée pour définir une variable dont le type est cette structure.
Pour cela, deux possibilités :
- faire suivre la définition de la structure par l'identificateur de la variable ;
struct
Client
{
unsigned
char
Age;
unsigned
char
Taille;
}
Jean;
ou, plus simplement :
struct
{
unsigned
char
Age;
unsigned
char
Taille;
}
Jean;
- Dans le deuxième exemple, le nom de la structure n'est pas mis.
déclarer la structure en lui donnant un nom, puis déclarer les variables avec la syntaxe suivante :
[struct
] nom_structure identificateur;
struct
Client
{
unsigned
char
Age;
unsigned
char
Taille;
}
;
struct
Client Jean, Philippe;
Client Christophe; // Valide en C++ mais invalide en C
Dans cet exemple, le nom de la structure doit être mis, car on utilise cette structure à la ligne suivante. Pour la déclaration des variables Jean et Philippe de type struct Client, le mot clé struct a été mis. Cela n'est pas nécessaire en C++, mais l'est en C. Le C++ permet donc de déclarer des variables de type structure exactement comme si le type structure était un type prédéfini du langage. La déclaration de la variable Christophe ci-dessus est invalide en C.
Les éléments d'une structure sont accédés par un point, suivi du nom du champ de la structure à accéder. Par exemple, l'âge de Jean est désigné par Jean.Age.
Note : Le typage du C++ est plus fort que celui du C, parce qu'il considère que deux types ne sont identiques que s'ils ont le même nom. Alors que le C considère que deux types qui ont la même structure sont des types identiques, le C++ les distingue. Cela peut être un inconvénient, car des programmes qui pouvaient être compilés en C ne le seront pas forcément par un compilateur C++. Considérons l'exemple suivant :
int
main(void
)
{
struct
st1
{
int
a;
}
variable1 =
{
2
}
;
struct
{
int
a;
}
variable2; /* variable2 a exactement la même structure
que variable1, */
variable2 =
variable1; /* mais cela est ILLÉGAL en C++ ! */
return
0
;
}
Bien que les deux variables aient exactement la même structure, elles sont de type différents ! En effet, variable1 est de type « st1 », et variable2 de type « » (la structure qui a permis de la construire n'a pas de nom). On ne peut donc pas faire l'affectation. Pourtant, ce programme était compilable en C pur...
Note : Il est possible de ne pas donner de nom à une structure lors de sa définition sans pour autant déclarer une variable. De telles structures anonymes ne sont utilisables que dans le cadre d'une structure incluse dans une autre structure :
struct
struct_principale
{
struct
{
int
champ1;
}
;
int
champ2;
}
;
Dans ce cas, les champs des structures imbriquées seront accédés comme s'il s'agissait de champs de la structure principale. La seule limitation est que, bien entendu, il n'y ait pas de conflit entre les noms des champs des structures imbriquées et ceux des champs de la structure principale. S'il y a conflit, il faut donner un nom à la structure imbriquée qui pose problème, en en faisant un vrai champ de la structure principale.
3.1.2. Les unions▲
Les unions constituent un autre type de structure. Elles sont déclarées avec le mot clé union, qui a la même syntaxe que struct. La différence entre les structures et les unions est que les différents champs d'une union occupent le même espace mémoire. On ne peut donc, à tout instant, n'utiliser qu'un des champs de l'union.
union
entier_ou_reel
{
int
entier;
float
reel;
}
;
union
entier_ou_reel x;
x peut prendre l'aspect soit d'un entier, soit d'un réel. Par exemple :
x.entier=
2
;
affecte la valeur 2 à x.entier, ce qui détruit x.reel.
Si, à présent, on fait :
x.reel=
6.546
;
la valeur de x.entier est perdue, car le réel 6.546 a été stocké au même emplacement mémoire que l'entier x.entier.
Les unions, contrairement aux structures, sont assez peu utilisées, sauf en programmation système où l'on doit pouvoir interpréter des données de différentes manières selon le contexte. Dans ce cas, on aura avantage à utiliser des unions de structures anonymes et à accéder aux champs des structures, chaque structure permettant de manipuler les données selon une de leurs interprétations possibles.
struct
SystemEvent
{
int
iEventType; /* Discriminant de l'événement.
Permet de choisir comment l'interpréter. */
union
{
struct
{
/* Structure permettant d'interpréter */
int
iMouseX; /* les événements souris. */
int
iMouseY;
}
;
struct
{
/* Structure permettant d'interpréter */
char
cCharacter; /* les événements clavier. */
int
iShiftState;
}
;
/* etc. */
}
;
}
;
/* Exemple d'utilisation des événements : */
int
ProcessEvent(struct
SystemEvent e)
{
int
result;
switch
(e.iEventType)
{
case
MOUSE_EVENT:
/* Traitement de l'événement souris... */
result =
ProcessMouseEvent(e.iMouseX, e.iMouseY);
break
;
case
KEYBOARD_EVENT:
/* Traitement de l'événement clavier... */
result =
ProcessKbdEvent(e.cCharacter, e.iShiftState);
break
;
}
return
result;
}
3.1.3. Les énumérations▲
Les énumérations sont des types intégraux (c'est-à-dire qu'ils sont basés sur les entiers), pour lesquels chaque valeur dispose d'un nom unique. Leur utilisation permet de définir les constantes entières dans un programme et de les nommer. La syntaxe des énumérations est la suivante :
enum
enumeration
{
nom1 [=
valeur1]
[, nom2 [=
valeur2]
[...]]
}
;
Dans cette syntaxe, enumeration représente le nom de l'énumération et nom1, nom2, etc. représentent les noms des énumérés. Par défaut, les énumérés reçoivent les valeurs entières 0, 1, etc. sauf si une valeur explicite leur est donnée dans la déclaration de l'énumération. Dès qu'une valeur est donnée, le compteur de valeurs se synchronise avec cette valeur, si bien que l'énuméré suivant prendra pour valeur celle de l'énuméré précédent augmentée de 1.
enum
Nombre
{
un=
1
, deux, trois, cinq=
5
, six, sept
}
;
Dans cet exemple, les énumérés prennent respectivement leurs valeurs. Comme quatre n'est pas défini, une resynchronisation a lieu lors de la définition de cinq.
Les énumérations suivent les mêmes règles que les structures et les unions en ce qui concerne la déclaration des variables : on doit répéter le mot clé enum en C, ce n'est pas nécessaire en C++.
3.1.4. Les champs de bits▲
Il est possible de définir des champs de bits et de donner des noms aux bits de ces champs. Pour cela, on utilisera le mot clé struct et on donnera le type des groupes de bits, leurs noms, et enfin leurs tailles :
struct
champ_de_bits
{
int
var1; /* Définit une variable classique. */
int
bits1a4 : 4
; /* Premier champ : 4 bits. */
int
bits5a10 : 6
; /* Deuxième champ : 6 bits. */
unsigned
int
bits11a16 : 6
; /* Dernier champ : 6 bits. */
}
;
La taille d'un champ de bits ne doit pas excéder celle d'un entier. Pour aller au-delà, on créera un deuxième champ de bits. La manière dont les différents groupes de bits sont placés en mémoire dépend du compilateur et n'est pas normalisée.
Les différents bits ou groupes de bits seront tous accessibles comme des variables classiques d'une structure ou d'une union :
struct
champ_de_bits essai;
int
main(void
)
{
essai.bits1a4 =
3
;
/* suite du programme */
return
0
;
}
3.1.5. Initialisation des structures et des tableaux▲
Les tableaux et les structures peuvent être initialisées, tout comme les types classiques peuvent l'être. La valeur servant à l'initialisation est décrite en mettant les valeurs des membres de la structure ou du tableau entre accolades et en les séparant par des virgules :
/* Définit le type Client : */
struct
Client
{
unsigned
char
Age;
unsigned
char
Taille;
unsigned
int
Comptes[10
];
}
;
/* Déclare et initialise la variable John : */
struct
Client John={
35
, 190
, {
13594
, 45796
, 0
, 0
, 0
, 0
, 0
, 0
, 0
, 0
}}
;
La variable John est ici déclarée comme étant de type Client et initialisée comme suit : son âge est de 35, sa taille de 190 et ses deux premiers comptes de 13594 et 45796. Les autres comptes sont nuls.
Il n'est pas nécessaire de respecter l'imbrication du type complexe au niveau des accolades, ni de fournir des valeurs d'initialisations pour les derniers membres d'un type complexe. Les valeurs par défaut qui sont utilisées dans ce cas sont les valeurs nulles du type du champ non initialisé. Ainsi, la déclaration de John aurait pu se faire ainsi :
struct
Client John={
35
, 190
, 13594
, 45796
}
;
Note : La norme C99 fournit également une autre syntaxe plus pratique pour initialiser les structures. Cette syntaxe permet d'initialiser les différents champs de la structure en les nommant explicitement et en leur affectant directement leur valeur. Ainsi, avec cette nouvelle syntaxe, l'initialisation précédente peut être réalisée de la manière suivante :
/* Déclare et initialise la variable John : */
struct
Client John={
.Taille =
190
,
.Age =
35
,
.Comptes[0
] =
13594
,
.Comptes[1
] =
45796
}
;
On constatera que les champs qui ne sont pas explicitement initialisés sont, encore une fois, initialisés à leur valeur nulle. De plus, comme le montre cet exemple, il n'est pas nécessaire de respecter l'ordre d'apparition des différents champs dans la déclaration de la structure pour leur initialisation.
Il est possible de mélanger les deux syntaxes. Dans ce cas, les valeurs pour lesquelles aucun nom de champ n'est donné seront affectées au champs suivants le dernier champ nommé. De plus, si plusieurs valeurs différentes sont affectées au même champ, seule la dernière valeur indiquée sera utilisée.
Cette syntaxe est également disponible pour l'initialisation des tableaux. Dans ce cas, on utilisera les crochets directement, sans donner le nom du tableau (exactement comme l'initialisation des membres de la structure utilise directement le point, sans donner le nom de la structure en cours d'initialisation). On notera toutefois que cette syntaxe n'est pas disponible en C++. Avec ce langage, il est préférable d'utiliser la notion de classe et de définir un constructeur. Les notions de classe et de constructeur seront présentées plus en détails dans le Chapitre 8. C'est l'un des rares points syntaxiques où il y a incompatibilité entre le C et le C++.
3.1.6. Les alias de types▲
Le C/C++ dispose d'un mécanisme de création d'alias, ou de synonymes, des types complexes. Le mot clé à utiliser est typedef. Sa syntaxe est la suivante :
typedef
définition alias;
où alias est le nom que doit avoir le synonyme du type et définition est sa définition. Pour les tableaux, la syntaxe est particulière :
typedef
type_tableau type[(taille)]([taille](...));
type_tableau est alors le type des éléments du tableau.
typedef
unsigned
int
mot;
mot est strictement équivalent à unsigned int.
typedef
int
tab[10
];
tab est le synonyme de « tableau de 10 entiers ».
typedef
struct
client
{
unsigned
int
Age;
unsigned
int
Taille;
}
Client;
Client représente la structure client. Attention à ne pas confondre le nom de la structure (« struct client ») avec le nom de l'alias (« Client »).
Note : Pour comprendre la syntaxe de typedef, il suffit de raisonner de la manière suivante. Si l'on dispose d'une expression qui permet de déclarer une variable d'un type donné, alors il suffit de placer le mot clé typedef devant cette expression pour faire en sorte que l'identificateur de la variable devienne un identificateur de type. Par exemple, si on supprime le mot clé typedef dans la déclaration du type Client ci-dessus, alors Client devient une variable dont le type est struct client.
Une fois ces définitions d'alias effectuées, on peut les utiliser comme n'importe quel type, puisqu'ils représentent des types :
unsigned
int
i =
2
, j; /* Déclare deux unsigned int */
tab Tableau; /* Déclare un tableau de 10 entiers */
Client John; /* Déclare une structure client */
John.Age =
35
; /* Initialise la variable John */
John.Taille =
175
;
for
(j=
0
; j<
10
; j =
j+
1
) Tableau[j]=
j; /* Initialise Tableau */
3.1.7. Transtypages▲
Il est parfois utile de changer le type d'une valeur. Considérons l'exemple suivant : la division de 5 par 2 renvoie 2. En effet, 5/2 fait appel à la division euclidienne. Comment faire pour obtenir le résultat avec un nombre réel ? Il faut faire 5./2, car alors 5. est un nombre flottant. Mais que faire quand on se trouve avec des variables entières (i et j par exemple) ? Le compilateur signale une erreur après i dans l'expression i./j ! Il faut changer le type de l'une des deux variables. Cette opération s'appelle le transtypage. On la réalise simplement en faisant précéder l'expression à transtyper du type désiré entouré de parenthèses :
(type) expression
int
i=
5
, j=
2
;
((float
) i)/
j
Dans cet exemple, i est transtypé en flottant avant la division. On obtient donc 2.5.
Le transtypage C est tout puissant et peut être relativement dangereux. Le langage C++ fournit donc des opérateurs de transtypages plus spécifiques, qui permettent par exemple de conserver la constance des variables lors de leur transtypage. Ces opérateurs seront décrits dans la Section 10.2 du chapitre traitant de l'identification dynamique des types.
3.2. Les classes de stockage▲
Les variables C/C++ peuvent être créées de différentes manières. Il est courant, selon la manière dont elles sont créées et la manière dont elles pourront être utilisées, de les classer en différentes catégories de variables. Les différents aspects que peuvent prendre les variables constituent ce que l'on appelle leur classe de stockage.
La classification la plus simple que l'on puisse faire des variables est la classification locale - globale. Les variables globales sont déclarées en dehors de tout bloc d'instructions, dans la zone de déclaration globale du programme. Les variables locales en revanche sont créées à l'intérieur d'un bloc d'instructions. Les variables locales et globales ont des durées de vie, des portées et des emplacements en mémoire différents.
La portée d'une variable est la zone du programme dans laquelle elle est accessible. La portée des variables globales est tout le programme, alors que la portée des variables locales est le bloc d'instructions dans lequel elles ont été créées.
La durée de vie d'une variable est le temps pendant lequel elle existe. Les variables globales sont créées au début du programme et détruites à la fin, leur durée de vie est donc celle du programme. En général, les variables locales ont une durée de vie qui va du moment où elles sont déclarées jusqu'à la sortie du bloc d'instructions dans lequel elles ont été déclarées. Cependant, il est possible de faire en sorte que les variables locales survivent à la sortie de ce bloc d'instructions. D'autre part, la portée d'une variable peut commencer avant sa durée de vie si cette variable est déclarée après le début du bloc d'instructions dans lequel elle est déclarée. La durée de vie n'est donc pas égale à la portée d'une variable.
La classe de stockage d'une variable permet de spécifier sa durée de vie et sa place en mémoire (sa portée est toujours le bloc dans lequel la variable est déclarée). Le C/C++ dispose d'un éventail de classes de stockage assez large et permet de spécifier le type de variable que l'on désire utiliser :
- auto : la classe de stockage par défaut. Les variables ont pour portée le bloc d'instructions dans lequel elles ont été crées. Elles ne sont accessibles que dans ce bloc. Leur durée de vie est restreinte à ce bloc. Ce mot clé est facultatif, la classe de stockage auto étant la classe par défaut ;
- static : cette classe de stockage permet de créer des variables dont la portée est le bloc d'instructions en cours, mais qui, contrairement aux variables auto, ne sont pas détruites lors de la sortie de ce bloc. À chaque fois que l'on rentre dans ce bloc d'instructions, les variables statiques existeront et auront pour valeurs celles qu'elles avaient avant que l'on quitte ce bloc. Leur durée de vie est donc celle du programme, et elles conservent leurs valeurs. Un fichier peut être considéré comme un bloc. Ainsi, une variable statique d'un fichier ne peut pas être accédée à partir d'un autre fichier. Cela est utile en compilation séparée (voir plus loin) ;
- register : cette classe de stockage permet de créer une variable dont l'emplacement se trouve dans un registre du microprocesseur. Il faut bien connaître le langage machine pour correctement utiliser cette classe de variable. En pratique, cette classe est très peu utilisée ;
- volatile : cette classe de variable sert lors de la programmation système. Elle indique qu'une variable peut être modifiée en arrière-plan par un autre programme (par exemple par une interruption, par un thread, par un autre processus, par le système d'exploitation ou par un autre processeur dans une machine parallèle). Cela nécessite donc de recharger cette variable à chaque fois qu'on y fait référence dans un registre du processeur, et ce même si elle se trouve déjà dans un de ces registres (ce qui peut arriver si on a demandé au compilateur d'optimiser le programme) ;
- extern : cette classe est utilisée pour signaler que la variable peut être définie dans un autre fichier. Elle est utilisée dans le cadre de la compilation séparée (voir le Chapitre 6 pour plus de détails).
Il existe également des modificateurs pouvant s'appliquer à une variable pour préciser sa constance :
- const : ce mot clé est utilisé pour rendre le contenu d'une variable non modifiable. En quelque sorte, la variable devient ainsi une variable en lecture seule. Attention, une telle variable n'est pas forcément une constante : elle peut être modifiée soit par l'intermédiaire d'un autre identificateur, soit par une entité extérieure au programme (comme pour les variables volatile). Quand ce mot clé est appliqué à une structure, aucun des champs de la structure n'est accessible en écriture. Bien qu'il puisse paraître étrange de vouloir rendre « constante » une « variable », ce mot clé a une utilité. En particulier, il permet de faire du code plus sûr ;
- mutable : disponible uniquement en C++, ce mot clé ne sert que pour les membres des structures. Il permet de passer outre la constance éventuelle d'une structure pour ce membre. Ainsi, un champ de structure déclaré mutable peut être modifié même si la structure est déclarée const.
Pour déclarer une classe de stockage particulière, il suffit de faire précéder ou suivre le type de la variable par l'un des mots clés auto, static, register, etc. On n'a le droit de n'utiliser que les classes de stockage non contradictoires. Par exemple, register et extern sont incompatibles, de même que register et volatile, et const et mutable. Par contre, static et const, de même que const et volatile, peuvent être utilisées simultanément.
int
appels(void
)
{
static
int
n =
0
;
return
n =
n+
1
;
}
Cette fonction mémorise le nombre d'appels qui lui ont été faits dans la variable n et renvoie ce nombre. En revanche, la fonction suivante :
int
appels(void
)
{
int
n =
0
;
return
n =
n +
1
;
}
renverra toujours 1. En effet, la variable n est créée, initialisée, incrémentée et détruite à chaque appel. Elle ne survit pas à la fin de l'instruction return.
const
int
i=
3
;
i prend la valeur 3 et ne peut plus être modifiée.
Les variables globales qui sont définies sans le mot clé const sont traitées par le compilateur comme des variables de classe de stockage extern par défaut. Ces variables sont donc accessibles à partir de tous les fichiers du programme. En revanche, cette règle n'est pas valide pour les variables définies avec le mot clé const. Ces variables sont automatiquement déclarées static par le compilateur, ce qui signifie qu'elles ne sont accessibles que dans le fichier dans lequel elles ont été déclarées. Pour les rendre accessibles aux autres fichiers, il faut impérativement les déclarer avec le mot clé extern avant de les définir.
int
i =
12
; /* i est accessible de tous les fichiers. */
const
int
j =
11
; /* Synonyme de "static const int j = 11;". */
extern
const
int
k; /* Déclare d'abord la variable k... */
const
int
k =
12
; /* puis donne la définition. */
Notez que toutes les variables définies avec le mot clé const doivent être initialisées lors de leur définition. En effet, on ne peut pas modifier la valeur des variables const, elles doivent donc avoir une valeur initiale. Enfin, les variables statiques non initialisées prennent la valeur nulle.
Les mots clés const et volatile demandent au compilateur de réaliser des vérifications additionnelles lors de l'emploi des variables qui ont ces classes de stockage. En effet, le C/C++ assure qu'il est interdit de modifier (du moins sans magouiller) une variable de classe de stockage const, et il assure également que toutes les références à une variable de classe de stockage volatile se feront sans optimisations dangereuses. Ces vérifications sont basées sur le type des variables manipulées. Dans le cas des types de base, ces vérifications sont simples et de compréhension immédiate. Ainsi, les lignes de code suivantes :
const
int
i=
3
;
int
j=
2
;
i=
j; /* Illégal : i est de type const int. */
génèrent une erreur parce qu'on ne peut pas affecter une valeur de type int à une variable de type const int.
En revanche, pour les types complexes (pointeurs et références en particulier), les mécanismes de vérifications sont plus fins. Nous verrons quels sont les problèmes soulevés par l'emploi des mots clés const et volatile avec les pointeurs et les références dans le chapitre traitant des pointeurs.
Enfin, en C++ uniquement, le mot clé mutable permet de rendre un champ de structure const accessible en écriture :
struct
A
{
int
i; // Non modifiable si A est const.
mutable
int
j; // Toujours modifiable.
}
;
const
A a={
1
, 1
}
; // i et j valent 1.
int
main(void
)
{
a.i=
2
; // ERREUR ! a est de type const A !
a.j=
2
; // Correct : j est mutable.
return
0
;
}