Professional Documents
Culture Documents
Mais si C++ est d’un abord aisé, sa maîtrise n’est pas si évidente, car il
s’agit d’un langage extrêmement puissant. Passer de C à C++ équivaut
sensiblement à changer une vieille deux-chevaux contre une voiture équipée
d’un moteur V6 ; si l’accélération est foudroyante, un apprentissage soigné
s’impose.
1
Note aux programmeurs connaissant le
C
Les programmeurs qui connaissent le C n’ont pas été oubliés. Pour
apprendre plus rapidement C++, ils pourront sauter les paragraphes
marqués d'un bandeau de couleur, indiquant des spécifications héritées du
C sans modifications, comme ceci:
Plan du site
On trouvera dans ce site une description complète des spécifications du
langage C++. Cette description est répartie sur dix chapitres. Vous pouvez à
tout moment vous référer au sommaire complet en cliquant sur le lien en
bas de chaque page.
2
Le chapitre 5 est important car il décrit l’usage des fonctions, objets de
base de C et C++ ; les programmeurs connaissant le C se pencheront avec
attention sur les passages d’arguments par référence et sur les fonctions en
ligne, une nouveauté toute simple mais très puissante de C++, qui remplace
en grande partie les macros.
Des exercices
Nous avons parsemé le livre de petits exercices destinés en grande partie
à permettre au lecteur de vérifier sa compréhension des notions introduites.
Nous ne pouvons qu’insister sur la quasi-nécessité de chercher à les
résoudre, au moins mentalement, et mieux encore en programmant, car la
programmation est un art qui s’apprend en pratiquant. Le langage C++
3
cache sa puissance sous des dehors simples et bonhommes ; de même, ces
exercices paraissent souvent très simples : la solution (qui est donnée en fin
d’ouvrage) ne sera pourtant peut-être pas celle que vous imaginiez.
1/ ÉLEMENTS DE BASE
Le C++ est un langage structuré, typé et modulaire, autorisant la
programmation orientée objet. Nous verrons dans le cours de ce livre ce que
signifie exactement chacun de ces termes. Les trois premières
caractéristiques, héritées du C, en font un langage de programmation
« classique » (par opposition aux langages « exotiques » comme Lisp,
Prolog, ou SmallTalk) ; la programmation orientée objet (POO) permet des
développements intéressants, difficiles à réaliser en C. En outre, C++
contient des facilités, non liées à la POO, que le C ne possède pas.
Messages d’erreur
Le texte que le programmeur tape est appelé texte source du
programme ; le code compilé obtenu est l’exécutable. Pour que la
4
compilation ne soit pas trop lente, il faut aussi qu’elle ne soit pas trop
complexe. Par conséquent, le programmeur est tenu de respecter un certain
nombre de conventions qui ont pour but de faciliter cette compilation, et
aussi d’éviter des erreurs.
Dans certains cas rares, il arrive qu’une erreur fatale (Fatal error) se
produise en cours de compilation ; celle-ci s’arrête alors immédiatement.
C’est le cas notamment des débordements de mémoire (Fatal error : out of
memory).
5
Il est important de savoir que les messages d’erreur ne sont pas toujours
ceux attendus. En effet, le compilateur signale le premier terme qui
l’empêche d’accepter le texte, mais il se peut que l’erreur soit avant. Par
exemple, le code suivant :
Symboles et identificateurs
Le texte source est composé d’un certain nombre de termes séparés par
des espaces blancs (ou par rien dans certain cas) ; les tabulations et les sauts
de lignes sont considérés comme des blancs en général.
6
Les mots suivants ne sont pas des identificateurs :
Les symboles sont les groupes de caractères qui ne sont pas des
identificateurs. On y trouve notamment un grand nombre de caractères
spéciaux d’opérations, qui se lisent individuellement, ou par paire, voire par
triple, comme par exemple :
+ * ++ && <<= /* */
Commentaires
Il est très utile de placer à l’intérieur d’un programme des commentaires,
indiquant en quelques mots ce que l’on fait à cet endroit. Cela permet de
relire plus facilement le programme ultérieurement, surtout pour ceux qui
ne l’ont pas écrit.
void fonction(int i)
/* Cette fonction fait ceci, cela ...
(suit une description de la fonction). */
{
...
7
void fonction(int i)
// Cette fonction fait ceci, cela ...
// (suit une description de la fonction).
{
... // ici commence la
fonction
/*
void fonction(int i)
/*Cette fonction fait ceci, cela ... */
{
...
}
*/
/*
void fonction(int i)
*//* Cette fonction fait ceci, cela ... *//*
{
...
}
*/
/*
void fonction(int i)
// Cette fonction fait ceci, cela ...
{
...
}
*/
8
Ce groupe est ici entièrement ignoré ; si l’on retire /* et */, la fonction
sera de nouveau compilée.
9
Le type décimal double a un ensemble de valeurs différent, et certaines
opérations comme la division modulo n’ont pas de sens sur ce type : ses
propriétés sont donc différentes de celles de int.
Déclarations de variables
Une donnée est une brique élémentaire dans un programme que l’on
caractérise par son type, d’une part, et par sa valeur actuelle d’autre part.
Nous venons de voir ce qu’est un type ; la valeur actuelle de la donnée (par
exemple 12 pour un entier) est sujette à modification en général, sous la
contrainte qu’elle reste dans l’ensemble de valeurs du type. Par contre, le
type de la donnée reste toujours le même ; en conséquence, les propriétés
d’une donnée, qui sont celles de son type, sont constantes.
Voici donc comment on indique que l’on va utiliser une donnée entière
nommée nombre :
int nombre;
Par défaut, on crée ainsi une variable, c’est-à-dire une donnée que l’on
peut modifier librement.
Une telle écriture s’appelle une déclaration ; elle indique à C++ que l’on
souhaite utiliser une telle donnée. La déclaration est obligatoire : si on
l’oublie, et que dans la suite on utilise le nom nombre, le compilateur refusera
ce terme non déclaré, puisqu’il ne peut deviner de quel type de variable il
s’agit. Ceci permet d’éviter de nombreuses erreurs ; en particulier, si l’on fait
une faute de frappe, en tapant par exemple nmbre au lieu de nombre, le
compilateur signalera l’erreur (par le message Error : Undefined symbol
10
'nmbre'), alors que d’autres langages créeraient automatiquement une
variable de ce nom, ce qui serait tout à fait erroné.
nom_de_type nom_de_variable ;
Une telle déclaration a deux utilités. Primo, elle indique à C++ que l’on a
besoin d’une variable ; celui-ci va donc prendre un petit morceau de la
mémoire et le réserver spécialement à cet usage. Secundo, elle indique au
compilateur les propriétés de la variable, qui sont celles de son type ; de la
sorte, si l’on écrit par erreur une opération interdite sur cette variable, le
compilateur le signalera par un message.
Définitions de variables
En pratique, il est fréquent que l’on souhaite initialiser cette variable,
c’est-à-dire lui donner une valeur. En effet, il est important de savoir que
lorsqu’on déclare une variable, sa valeur est la plupart du temps indéfinie ;
en particulier, ce n’est pas zéro en général ! (La plupart du temps, c'est
simplement le contenu de la mémoire réservée à l'usage de la donnée.) Ce
point est essentiel, il est source de nombreuses erreurs. On initialisera les
variables le plus souvent possible, afin d’éviter tout problème, en écrivant
par exemple :
int nombre = 1;
11
sont entières sauf indication du contraire ; elles sont supposées déclarées
précédemment) :
nombre = 75;
nombre = 12 + 7;
nombre = i + j;
nombre = 1 + 2 * nombre;
i + j = 2;
12
d’affectation =, c’est-à-dire essentiellement les noms de variables (plus
d’autres écritures étudiées plus tard).
13
suivie de la définition de la fonction, constituée par une séquence
d’instructions élémentaires enclose entre accolades {}. Ici, la seule
instruction est la déclaration du résultat de la fonction par le mot clé
return.
La fonction principale
Tout programme C++ doit comprendre au moins une fonction, nommée
main (adjectif anglais signifiant « principale »), qui est le point d’entrée du
programme en ce sens que le programme commence au début de main et
s’arrête à la fin de celle-ci.
#include <iostream.h>
main()
{
cout << nombre * nombre * nombre;
return 0;
}
Pour bien marquer la différence entre cette fonction et les autres, nous
n’écrivons rien entre les parenthèses, et nous omettons la déclaration de son
type résultat, qui est int. Cette écriture est autorisée car int est le type
résultat par défaut des fonctions ; il est toutefois préférable de toujours
l’indiquer pour les fonctions autres que main, pour d’évidentes raisons de
clarté. Nous verrons ultérieurement que main peut en fait avoir des
arguments.
14
D’une façon générale, les instructions comprises entre les accolades
correspondant à main sont exécutées par le programme de la première à la
dernière dans l’ordre où elles sont écrites, et le programme s’arrête lorsqu’il
rencontre l’instruction return ou la fin de la fonction main (c’est-à-dire
l’accolade fermante). Nous verrons cependant qu’il est possible de modifier
cet ordre d’exécution par des instructions adéquates.
#include <iostream.h>
int cube_nombre(void)
{
return nombre * nombre * nombre;
}
main()
{
cout << cube_nombre();
return 0;
}
15
fonction, remplaçant cet appel par la quantité renvoyée, et continue
l’exécution normalement. On peut représenter ce fonctionnement par un
petit schéma ; les flèches indiquent dans quel sens l’exécution du
programme se déroule.
Une fonction peut aussi être appelée seule dans une instruction. Dans ce
cas, sa valeur de retour est ignorée.
int cube(int x)
{
return x * x * x;
}
16
faisait pas, on ne pourrait calculer le cube de cet argument, puisqu’on ne
pourrait y faire référence.
cube(cube(nombre))
Une fonction peut avoir autant d’arguments que souhaité, avec des types
différents éventuellement. Par exemple, la fonction mathématique suivante,
tirée de la librairie <math.h>, admet deux arguments, le premier de type
double, le second de type int, et renvoie une valeur de type double :
17
D’autre part, il arrive que certaines fonctions ne
renvoient aucun résultat (). Dans ce cas, elles
sont déclarées avec pour résultat void ; ce mot
réservé sert donc de « remplissage » soit pour une
liste d’arguments vide, soit pour un résultat
inexistant. Voici deux exemples tirés de la
librairie <conio.h> :
void gotoxy(int x, int y) ;
void clrscr(void);
Boucles et branchements
Nous avons dit plus haut que les instructions d’un programme étaient
exécutées, par défaut, de la première à la dernière l’une après l’autre. Il est
rare en pratique qu’un tel comportement soit souhaitable. Dans la plupart
des cas, on souhaite au contraire pouvoir choisir entre plusieurs actions
possibles, en fonction d’un critère quelconque. On souhaite souvent aussi
recommencer une action plusieurs fois, le propre des ordinateurs étant
d’effectuer des tâches répétitives sans ennui ni fatigue. Pour toutes ces
actions, C++ fournit un certain nombre d’instructions spéciales introduites
par des mots clés particuliers.
18
Lorsqu’on a le choix d’une alternative, ce qui est extrêmement fréquent,
on utilise l’instruction de branchement simple if, dont la syntaxe est la
suivante :
Voir solution
19
int abs(int x)
{
if (x < 0) return -x else return x;
}
Il arrive parfois que l’on n’ait pas d’alternative, simplement une action à
faire si la condition est réalisée, rien sinon. Dans ce cas, on peut omettre la
clause else. Voici une instruction qui remplace x par sa valeur absolue :
if (x < 0) x = -x;
Il arrive souvent que l’on souhaite faire plusieurs actions ensemble dans
le cas où une condition est vérifiée. Dans ce cas, on doit les grouper avec des
accolades, comme ceci :
if (x + y < 0) { x = 0; y =0; }
else { x = x + y; y = x -y; }
if (x + y < 0) {
x = 0;
20
y = 0;
}
else {
x = x + y;
y = x -y;
}
21
while (1) cout << "Hello !";
#include <iostream.h>
main()
{
int nombre = 0;
while (nombre <= 1000)
{
cout << nombre << '\t';
nombre += 7;
}
return 0;
}
int nombre = 0;
do {
22
cout << nombre << '\t';
nombre += 7;
}
while (nombre <= 1000);
#include <iostream.h>
main()
{
int somme = 0, i = 1;
while (i < 100) {
somme += i; // augmente la
somme
i += 2; // nombre impair
suivant
}
cout << "La somme est : " << somme;
return 0;
}
23
Elle demande la réalisation des actions suivantes : exécuter l’instruction
d’initialisation, puis tester la condition (qui doit ici encore être une
expression numérique). Si elle est non nulle, exécuter l’instruction action,
puis l’instruction itération, et tester à nouveau la condition, et ainsi de
suite jusqu’à ce que la condition devienne nulle.
On notera que l’initialisation n’est réalisée qu’une fois, avant toute autre
action. Le schéma est donc identique à celui produit par la boucle while
suivante :
initialisation;
while (condition) {
action;
itération;
}
#include <iostream.h>
main()
{
for (int nombre = 0; nombre <= 1000; nombre
+= 7)
cout << nombre << '\t';
return 0;
}
24
Déclarations internes
En C++, les déclarations de variables sont autorisées à peu près
n’importe où dans un programme. On les utilise surtout dans des boucles
for :
if (ok) {
int i = 0;
// ...
}
else {
long i = 1;
// ...
}
Dans ce dernier cas, la variable n’existe que le temps d’exécuter son bloc
(c’est-à-dire de l’accolade ouvrante à l’accolade fermante) ; cela permet de
déclarer deux variables différentes de même nom dans deux blocs différents
et séparés, comme dans notre exemple ci-dessus. On reviendra sur ces
notions de déclarations locales et de « visibilité » au chapitre 5.
25
2/ TYPES PREDEFINIS ET
OPERATEURS
Il existe en C++ un certain nombre de types dits prédéfinis, c’est-à-dire
automatiquement connus du compilateur; nous connaissons déjà les types
int et double. Ces types sont dotés d’un certain nombre d’opérations de
base exprimées par des opérateurs, comme l’addition, etc. Ces opérateurs se
retrouvent sur d’autres types, et nous verrons aussi plus tard qu’il est
possible de les redéfinir. On retiendra pour le moment que les opérateurs,
très nombreux en C++, y jouent un rôle central.
Types entiers
Nous connaissons déjà le type int, qui désigne un entier avec signe codé
(sur PC) sur deux octets (seize bits, dont un de signe); son ensemble de
valeurs varie donc de -215 à 215-1, c’est-à-dire de -32 768 à 32 767. Il arrive
parfois que ces quantités soient insuffisantes. On dispose alors du type
« entier long » long int, codé sur quatre octets. Son ensemble de valeurs
varie donc de -231 = -2 147 483 648 à 231-1 = 2 147 483 647.
Chacun de ces trois types peut être utilisé en version sans signe. On
obtient alors les types unsigned int, dont les valeurs sont de 0 à 216-1 =
26
65535, ainsi que unsigned short int, et unsigned long int qui
varie de 0 à 232-1 = 4 294 967 295. On peut aussi écrire signed int, etc.,
mais c’est sans intérêt en général puisque c’est la valeur par défaut.
unsigned u, v;
long l1, l2;
int i, j, k = 0;
unsigned long ll = -1; // attention ! valeur
0xFFFFFFFF en fait
Dans cet exemple, on a déclaré deux unsigned int, deux long int,
trois int, dont un initialisé à 0, et un unsigned long int initialisé... à
une valeur qui ne fait pas partie de son ensemble de valeurs ! Cette écriture
est cependant permise.
Par ailleurs, lorsque une opération est effectué sur deux types entiers,
celle-ci est réalisée avec les arrondis correspondants au plus grand de ces
types, indépendamment du type résultat. Par exemple les instructions
suivantes:
27
fournissent la valeur 0 (supposant toujours que int contient deux octets),
parce que le produit est effectué dans le type int. Pour contourner ce
problème , il faut d'abord convertir l'un des entiers en long, en écrivant par
exemple l = i* (long)j. Voir aussi plus loin sur ce point délicat.
unsigned long x = u;
x *= v;
Constantes entières
On utilise souvent dans les programmes des constantes entières, comme
0, 12, -2000, etc. Lorsqu’elles sont écrites en décimal, ce qui est le cas
général, elles ne doivent pas commencer par un zéro : écrire 12 et non 012
(voir paragraphe suivant). Il ne faut pas non plus placer d’espace à
l’intérieur : écrire 2000 et non 2 000 qui serait interprété comme 2, suivi de
000, et provoquerait une erreur.
28
int i = 10000;
long l = 10000L * i;
unsigned long ul = 30000UL * i;
placera bien la valeur 100 000 000 dans l, alors que si l’on avait écrit 10000
* i,un débordement se serait produit, plaçant la valeur erronée -7936 dans
l. De même, la valeur correcte de 300 000 000 sera placée dans ul, au lieu
de la valeur erronée 4 294 943 488.
On notera que le zéro initial est le signe d’un nombre octal : c’est
pourquoi il est interdit pour les valeurs décimales, et une écriture comme
078 provoquera une erreur de compilation (Error : Illegal octal digit,
chiffre octal incorrect); cela ne pose un problème que pour la valeur zéro,
qui est de toute façon identique en décimal ou en octal.
29
Les chiffres hexadécimaux A à F peuvent être écrits en majuscules ou en
minuscules
int i = 3, j = -12;
unsigned u = 35000;
long l = -100000;
unsigned long ul = 3000000000;
Pour comprendre ces exemples, il faut savoir que lorsqu’on fait agir
dans une même opération deux entiers de même type, le résultat
est de ce même type, avec troncature éventuelle, c'est-à-dire
perte des bits excédentaires; par exemple, sur des entiers codés sur 32
bits, tous les calculs sont effectués modulo 232.
Si l’on fait agir deux entiers de types différents, les entiers signés sont
convertis en non-signés si nécessaire, et les courts en plus long afin que les
deux types soient les mêmes. Par exemple, si j et u interviennent ensemble,
j est transformé en entier non-signé (ce qui donne 65524). Si j et l
30
multiplication est prioritaire et réalisée en premier. Le tableau de
précédence complet des opérateurs est donné en annexe.
31
positif (si y est négatif, le résultat est indéfini). Cette (troncature)
opération donne donc x * 2y, avec troncature.
Opérateur binaire de décalage des bits à droite. i >> 2 donne 0
>> Comme précédemment, mais les bits sont décalés vers l >> i donne -12500
la droite. L’opération x >> y est donc égale à x / 2y. u >> 4 donne 2187
Opérateur binaire symétrique de « et » logique. Les i&6 donne 2
& bits des deux opérandes sont conjugués en « et » i&j donne 0
logique (multiplication logique). l&u donne 2080
Opérateur binaire symétrique de « ou exclusif » i^6 donne 5
^ logique. Les bits des opérandes sont conjugués en « i^j donne -9
ou exclusif » logique (différence logique). l^u donne -69160
Opérateur binaire symétrique de « ou » logique. Les i|6 donne 7
| bits des opérandes sont conjugués en « ou » logique i|j donne -9
(addition logique). l|u donne -67080
Caractères et chaînes
Un type entier particulier, nommé char, est utilisé pour les caractères. Il
comprend toutes les valeurs ASCII de 0 à 255. Il y a trois moyens de donner
une valeur constante à un caractère. La première consiste à écrire le
caractère entre apostrophes :
char c = 'E';
32
'\f' saut de page 12
'\n' nouvelle ligne 10
'\r' retour chariot 13
'\t' tabulation 9
'\v' tabulation verticale 11
Les deux derniers peuvent être écrits sans barre oblique inverse : '"' et
'?'.
Notons qu’il existe des types unsigned char et signed char. Par défaut, le
type char signifie signed char, c’est-à-dire que l’octet de poids fort, quand il
existe, est égal à -1 lorsque le caractère est supérieur ou égal à 128, mais on
dispose d’une option de compilation demandant de le considérer comme
unsigned ; dans ce cas, l’octet de poids fort est toujours nul.
33
entre guillemets. Il est possible d’y placer des caractères spéciaux en les
écrivant comme indiqué dans la section précédente. Par exemple la chaîne
suivante :
34
" (\") et enfin d’un saut de ligne (\n). En comptant le zéro final, cela fait
seize caractères, et l’on obtient à l'affichage, avec un signal sonore :
"Farceur\(tabulation) !"
autorisent.
Une variable de type float occupe quatre octets, peut varier de ±3.4 10-38
à ±3.4 1038, et a une précision d’environ sept chiffres décimaux. Une
variable de type double occupe huit octets, peut varier de ±1.7 10-308 à
±1.7 10308, et a une précision d’environ quinze chiffres décimaux. Enfin une
variable de type long double occupe dix octets, peut varier de ±3.4 10-4932 à
3.4 104932 et a une précision d’environ dix-neuf chiffres décimaux.
35
float f = 2.6;
int i = f;
sont tout à fait autorisées. Elles ont pour conséquence une troncature de la
valeur à virgule flottante, par suppression des décimales. En conséquence, i
vaudra 2 après l’initialisation ci-dessus. De plus, lorsqu’il y a débordement
de capacité des entiers, seuls les bits les moins significatifs sont conservés,
comme pour les autres opérations. Donc, si f était initialisé à 65 789.9 par
exemple, i serait initialisé à 253 = 65 789 modulo 65 536. Toutefois, si le
nombre décimal est plus grand que la plus grande valeur entière possible
(232 -1), le programme s’interrompt en indiquant une erreur (overflow,
c’est-à-dire débordement).
Ces opérateurs ont leur sens usuel, identique à celui des entiers, sauf
pour la division / qui est normale ici, alors qu’elle n’est faite que de manière
euclidienne sur les entiers.
Opérateurs et raccourcis
Nous avons déjà examiné les opérateurs agissant sur les nombres entiers
et décimaux. Il s’agit d’opérateurs assez usuels, qui se retrouvent dans
beaucoup de langages de programmation. Ceux que nous allons étudier à
présent sont très spécifiques de C++, et servent essentiellement de
raccourcis d’écriture.
36
Incrémentation et décrémentation
Deux opérateurs méritent un paragraphe spécial. Il s’agit de l’opérateur
d’incrémentation ++ et de celui de décrémentation --. Ces opérateurs
unaires peuvent agir sur une variable entière (y compris caractère) ou
décimale, ainsi que sur les pointeurs comme on le verra, et ont deux effets
groupés. La variable est en effet augmentée de 1 (incrémentation) ou
diminuée (décrémentation) de 1, tandis que la valeur renvoyée est soit
l’ancienne valeur de la variable (post-incrémentation ou post-
décrémentation), soit la nouvelle (pré-incrémentation et pré-
décrémentation).
int i = 4;
int u = i++;
int v = i--;
int w = --u;
int t = ++v;
37
donc très rapides du processeur. Cependant, il est évident qu’ils diminuent
la lisibilité des programmes.
u = (i + j)++;
est refusée par le compilateur (Error : Lvalue required), tandis que celle-ci :
u = i + j++;
int i = 10, j = 3;
Voir solution float f = i/j;
j = 10-f++*--i;
Opérateurs logiques
Il n’existe pas en C++ de type logique, comme par exemple le type
Boolean du Pascal. Les nombres, entiers ou décimaux, sont utilisés à la place
38
à un ou deux nombres entiers ou décimaux (une conversion est faite pour
rendre les types des deux opérandes égaux). Ce sont les suivants :
Opérateur unaire de négation logique. !x vaut 0 si x est non nul, 1 sinon. Cet
! opérateur ne peut être appliqué à un décimal, car il donne un résultat erroné ; écrire
dans ce cas x == 0.
==
Opérateur binaire symétrique d’égalité. x == y vaut 1 si les opérandes sont égaux, 0
sinon.
!= Opérateur binaire symétrique d’inégalité. Contraire de ==.
< Opérateur binaire d’inégalité. x < y vaut 1 si x est strictement inférieur à y, 0 sinon.
> Opérateur binaire d’inégalité. x > y vaut 1 si x est strictement supérieur à y, 0 sinon.
<= Opérateur binaire d’inégalité. Contraire de >.
>= Opérateur binaire d’inégalité. Contraire de <.
&&
Opérateur binaire symétrique logique « et » . Renvoie 1 si ses deux opérandes sont
non nuls, 0 sinon.
||
Opérateur binaire symétrique logique « ou » . Renvoie 1 si l’un au moins de ses deux
opérandes est non nul, 0 s’ils sont tous deux nuls.
39
Par exemple, on écrira :
si l’on souhaite que action soit exécutée seulement quand les variables x
et y sont strictement positives.
40
opérateurs est non nul, 0 sinon. Comment peut-on le
Voir solution
simuler ?
41
A méditer, pour éviter des écritures trop sophistiquées qui se
révéleraient catastrophiques !
i + j;
Cependant, une telle écriture est sans intérêt, puisque la somme est
perdue ; elle provoque d’ailleurs éventuellement un message du compilateur
(Warning : Code has no effect, c’est-à-dire attention, le code n’a pas d’effet).
Par contre, l’écriture suivante :
i++;
i = a + b;
est en fait un opérateur = avec un effet de bord. On peut donc l’utiliser dans
des expressions, par exemple comme ceci :
j = 3 * (i = a + b);
42
Cette propriété de =, qui n’existe pas dans la plupart des autres langages
de programmation, permet des écritures pratiques. Par exemple, une
affectation à tiroirs :
i = j = k = 0;
qui place 0 dans les trois variables car, dans une affectation, la partie droite
est calculée en premier, ce qui signifie que cette écriture équivaut à i = (j =
(k = 0)).
int i = j = 0;
équivaut à :
j = 0; int i = j;
et non à :
int i = 0, j = 0;
Dans les deux premiers cas, j est supposé déjà déclaré, alors que dans le
dernier, on déclare et initialise simultanément i et j.
i *= 7; j %= 360; k <<= 8;
43
ont pour effet de multiplier i par 7, remplacer j par sa valeur modulo 360,
et de décaler k de huit bits vers la gauche (c’est-à-dire d’ajouter un octet nul
à la fin de k, et de détruire l’octet de poids fort).
i *= (j /= 5);
Changements de type
Il arrive qu’une variable d’un certain type doive être recopiée dans une
autre, d’un type différent. Le compilateur doit alors effectuer un
changement de type (type casting). Ce changement est automatique dans
certains cas, notamment pour les passages des entiers entre eux ou des
décimaux aux entiers et réciproquement.
(xxx) expression
ou encore :
xxx (expression)
int i = 100, j = 3;
double d = i/j;
44
d vaudra alors 33.0, parce que la division est effectuée sur des opérateurs
entiers et que le reste est donc perdu. Par contre, si l’on écrit l’une
quelconque des deux lignes suivantes :
double d = i/ (double) j;
ou bien :
double d = i/ double(j);
d = double(i)/j;
Voir solution d = (double) i/j;
d = double(i/j);
d = double(i)/ double(j);
45
Autres raccourcis et opérateurs
Deux autres opérateurs permettent certains raccourcis. Le premier est
l’opérateur ? :, dont la syntaxe est la suivante :
z = ( x < y ? x : y);
if (x < y) z = x; else z = y;
ce qui est nettement plus clair. Cela permet toutefois d’éviter certaines
répétitions dans des expressions un peu complexes. Par exemple, on
comparera :
à l’autre possibilité :
déjà nettement plus lourde (et l’on aurait pu faire pire, avec une fonction
plus complexe que sqrt).
46
j = (i = j, 100);
i = j; j = 100;
somme = 0;
for (i = 0; i <= 100; i = +1)
somme = somme + i;
cout << somme << endl;
ce qui aurait été plus clair mais moins rapide et moins concis.
Nous terminons avec un opérateur bien utile dans des opérations de bas
niveau, nommé sizeof. Il se présente en réalité comme une fonction, mais
on peut l’appliquer soit à une variable, soit à un type, sous les formes :
Dans les deux cas, l’entier unsigned retourné indique le nombre d’octets
occupés par la variable, ou par une variable du type indiqué, en mémoire.
47
Par exemple, si x est du type char on obtiendra 1, mais 4 si x est du type
long ;de même, dans la deuxième ligne on obtiendra 4, qui est le nombre
d’octets occupés par une variable de type float.
z = y+++x;
Voir solution
48
Solution de l’exercice 2.9
Elle signifie surtout que la personne qui l’a écrite
écrit mal, bien qu’elle soit correcte. Avec des
parenthèses, elle équivaut à z = (x++) + y. La somme
x+y passe donc dans z, et x est incrémenté. Ce n’est pas
sera-t-elle exécutée ?
3/ TYPES COMPOSES
Dans ce chapitre, nous étudions les types composés à partir d'éléments
simples, y compris les références (qui sont une importante nouveauté en
49
C++, par rapport à C), à l'exception de ceux qui relèvent de la
programmation orientée objet (chapitre 6).
Références
Les références de type sont une fonctionnalité nouvelle de C++ par
rapport au langage C. Nous en verrons de très intéressants usages en
programmation orientée objet, mais il n’ont pas de rapport direct avec elle.
Nous avons dit qu’une donnée est une case de la mémoire qui possède un
certain type. Pour pouvoir parler de cette case, on peut soit utiliser son
adresse en mémoire (qui est la méthode des pointeurs), soit faire référence
directement à la donnée, ce qui est le cas quand on utilise un nom de
variable.
int i = 18;
En général, un seul nom suffit pour une case mémoire. Mais dans
certains cas, on a besoin d’un autre nom, d’une seconde référence. Pour la
créer et l’initialiser, on utilise l’opérateur de référence & (à ne pas confondre
avec l’opérateur binaire de « et » logique; il s’agit d’un opérateur unaire
ici) :
int& j = i;
Ce qui signifie : j est une référence sur un entier (type int&) qui est
équivalente à i. Dès lors, tous les usages de j dans la suite du programme
seront équivalents à ceux de i. Par exemple, si l’on écrit :
int i = 18;
int& j = i;
50
i++;
j++;
int& j = i;
ou :
int &j = i;
int&j = i;
ce qui est toutefois peu clair. La seconde écriture précédente est la plus
utilisée en pratique, car elle permet de déclarer simultanément entier et
référence sur entier, comme ceci :
51
écrit j++ par exemple, l’opérateur ++ s’applique à la donnée de type int dont
j est une référence, et non au type int&.
Il est parfaitement possible par contre d’initialiser une référence sur une
constante, ou sur une variable d’un type différent. Par exemple, on peut
écrire :
Dans ce cas, une variable temporaire de type int est créée et initialisée à
10, avant que k ne devienne une référence de cette variable. Le code
précédent équivaut donc à :
int k = 10;
ce qui signifie que les références sur des constantes n’ont aucun intérêt
direct (mais elles sont très utiles indirectement dans les appels de fonctions,
comme on le verra).
unsigned u = 1;
int &k = u;
52
créera une variable intermédiaire comme précédemment; il en résulte que
les modifications de k et u seront tout à fait indépendantes, c’est pourquoi le
compilateur vous prévient alors par un message d’attention Warning :
Temporary used to initialize 'k', une variable temporaire est utilisée pour
initialiser ‘k’.
Tableaux
On utilise souvent un groupe d’éléments du même type, pour un usage
global. Par exemple, une chaîne de caractères est un groupe de caractères
individuels. Un vecteur en mathématiques est un groupe de nombres réels,
etc.
int tab[20];
53
crée un tableau nommé tab composé de 20 entiers. Les éléments du tableau
sont notés tab[0], tab[1], ..., tab[19]. On notera que la numérotation
commence toujours à 0, et se termine donc à n-1 si n est le nombre
d’éléments du tableau. Voici par exemple une boucle qui remplit les
éléments du tableau avec les entiers de 1 à 20 :
54
affiche_tableau(10, tab);
affiche_tableau(10, &tab[10]);
Dans ce cas, les éléments 10 à 19 sont écrits. On note que &tab[i] signifie
« adresse de l’élément i du tableau » (l’opérateur d’adressage & est détaillé
avec les pointeurs, il n’a pas exactement le même sens que pour les
références), et est donc formellement identique à tab, qui signifie « adresse
du premier élément du tableau » . C’est la raison pour laquelle on peut le
faire passer pour une variable de type int[] dans cet appel. On peut aussi
écrire tab+10 dans cet exemple, grâce à l'arithmétique des pointeurs.
Voir solution
55
être remplacé par (i -debut), à condition de faire cette soustraction à
chaque étape.
56
Il s’agit d’un type d’erreur fréquent en C. Nous verrons plus tard
comment créer en C++ des tableaux qui vérifient leurs indices avant de les
utiliser, grâce à la programmation orientée objet.
Recopie de tableaux
Il n’est pas possible d’utiliser les opérateurs =, ==, etc., avec les tableaux.
En fait, aucun des opérateurs que nous avons vus sur les nombres n’est
utilisable avec un tableau. Pour recopier un tableau dans un autre, on peut
utiliser une boucle comme celle-ci :
ou encore la routine memmove, qui recopie une partie de la mémoire dans une
autre (voir plus loin) :
L’utilisation de memmove n’est guère pratique, mais bien plus rapide que la
boucle précédente
int table[4] = { 1, 2, 3, 4 };
57
Dans ce cas, les éléments restants sont initialisés à zéro. Par contre, il ne
faut pas mettre trop d’éléments (Error : Too many initializers, trop de
valeurs d’initialisation). Attention, l’écriture suivante est interdite :
Tableaux multidimensionnels
Nous terminons en notant qu’un tableau peut avoir n’importe quoi pour
éléments, y compris des tableaux. On obtient ainsi des tableaux
multidimensionnels :
float matrice[3][3];
int grand_tableau[2][5][10];
On peut omettre des valeurs avec les mêmes règles. Ainsi l’écriture :
58
Pointeurs
Pour de nombreuses raisons, liées en partie à l’historique du C, les
pointeurs jouent un rôle central en C et en C++. Nous en détaillons ici les
caractéristiques et de nombreux exemples seront vus dans toute la suite.
int i = 18;
int* p = &i;
La donnée pointée par p est, par définition, celle qui se trouve à l’adresse
qui est la valeur de p. On la dénote *p (aucun rapport avec la multiplication,
* est ici un opérateur unaire, tout comme & est unaire quand il désigne un
adressage), et l’on appelle déréférencement le fait d’appliquer l’opérateur *
à un pointeur.
*p = 20;
ce qui modifie le contenu de la case pointée par p, donc i. Notons bien que,
dans cette opération, p n’est absolument pas modifié et reste un pointeur
sur i.
59
On notera que, pour les mêmes raisons que pour les références, on
déclare souvent les pointeurs ainsi :
int *p;
int *p[10];
int (*q)[5];
Voir solution
int *(r[7]);
Pointeurs ou références ?
Il est très important de distinguer les pointeurs, qui sont des adresses
mémoire, des références qui sont des variables de types très divers. On
notera en particulier que les pointeurs sont tous formellement identiques,
bien que l’écriture suivante soit interdite :
60
char *p;
int *q = p; // interdit, types de pointeurs différents
char *p;
int *q = (int*) p;
d’une variable de type double (huit octets en mémoire à l’adresse 1000 par
exemple), d’une référence dd (aucun octet puisqu’il s’agit de la même
variable) et d’un pointeur (deux octets pour un pointeur court, à l’adresse
2000 par exemple). Voici donc l’état de la mémoire (chaque carré indique
un octet) :
61
Il est tout à fait possible de modifier un pointeur, c’est-à-dire de changer
l’adresse à laquelle il réfère. On dispose même pour cela d’un certain
nombre d’opérateurs.
char *pc;
int *pi = (int*) pc;
long *pl = (long*) pc;
pc++;
pi++;
pl++;
et qu’au début pc vaut 1000 par exemple, à la fin pc vaudra 1001, pi vaudra
1002, pl vaudra 1004.
62
Solution de l’exercice 3.3
On écrit effr. En effet, on affiche d’abord le caractère 'e' (*++p : le
pointeur est incrémenté puis déréférencé), puis 'f' (dans ++*p, p n’est pas
modifié, mais seulement le caractère pointé par p, à savoir 'e', qui est
incrémenté), puis 'f' encore (dans *(p++), le pointeur est déréférencé et post-
incrémenté : p pointe donc à présent sur 'r'), puis 'r' (car *p++ équivaut à
*(p++),
puisque les opérateurs de déréférencement et d’incrémentation ont
la même priorité et s’évaluent de droite à gauche). En fin de compte, p
pointe sur la chaîne restante "te", tandis que la chaîne d’origine s s’est
transformée en "vfrte".
Notons que vu la façon dont l’opérateur << agit sur les fichiers de sortie
(voir chapitre 9), l’écriture :
pl += 6; pi -= 2;
63
Évidemment, il est aussi possible d’ajouter un entier à un pointeur, puis
de placer le résultat dans un autre pointeur de même type :
pi2 = pi + 5;
64
Exercice 3.4 Quelles modifications faudrait-il faire à la fonction
précédente, si l’on voulait trouver le nombre
d’éléments non nuls consécutifs dans une table
Voir solution d’entiers désignée par un pointeur int* ?
65
int strcmp(char *s1, char *s2)
{
while ( (*s1) && (*s2) && (*s1 == *s2) )
{ s1++; s2++; }
return *s1 -*s2;
}
int k;
char *s;
void *p = &k;
p = s;
66
Cette propriété est surtout utilisée pour des arguments de fonction,
lorsqu’on souhaite recevoir un pointeur quelconque. Par exemple, la
fonction memmove est approximativement déclarée ainsi :
67
En contrepartie, il est interdit de faire des opérations arithmétiques sur
les pointeurs de type void*, puisqu’on ne connaît pas la taille de ce qu’ils
pointent. Ainsi l’écriture :
void *p;
p++; // incorrect
68
Solution de l’exercice 3.7
Lorsque dest est plus grand que source, et que
les zones mémoire se recouvrent, il faut recopier du
dernier octet au premier. Imaginons que la mémoire
pointée par source contienne les octets suivants :
01 02 03 04 05 06 07 08 09
que dest soit égal à source +3 (et donc pointe sur 04),
01 02 03 01 02 03 01 08 09
01 02 03 01 02 03 04 08 09
C’est déjà le cas avec les chaînes de caractères utilisées sous le type char*.
Mais la remarque s’applique à tous les tableaux, et plus particulièrement à
ceux qui sont placés en mémoire dynamique (voir dans le chapitre 5, sur ce
sujet), ou dont la taille n’est pas connue à l’avance, comme par exemple
dans les appels de fonctions. Ainsi, la procédure affiche_tableau, vue au
paragraphe sur les tableaux, pourrait être réécrite ainsi :
Comme les tableaux sont en fait des pointeurs sur leur premier élément,
on peut encore écrire :
int tableau[20];
affiche_tableau(20, tableau);
70
En effet, lorsqu’on écrit :
deviennent :
71
On notera que certaines opérations comme ici le choix entre tabulation
et fin de ligne sont assez difficiles. En outre l’accès à un élément particulier
du tableau est peu pratique, puisqu’il faut écrire tab +i*5 +j là où
tableau[i][j] suffisait précédemment. En particulier, il est nécessaire de
connaître la largeur du tableau (5).
72
La solution de facilité consiste à allouer une place fixe déterminée
arbitrairement. Mais cela pénalise l’utilisateur, et peut poser des problèmes
si la taille allouée est trop petite (pas assez de place) ou trop grande
(encombrement de la mémoire). En outre, il faut savoir qu’il existe trois
blocs importants de mémoire disponibles : le segment de données, le tas
(anglais heap), qui n’est généralement limité que par la mémoire disponible
pour le programme et enfin la pile (anglais stack), réservée aux arguments
de fonctions.
73
(en fait, il existe une fonction nommée strdup dans <string.h> qui fait ce
travail). Notons d’autre part que le bloc alloué n’est pas remis à zéro. Il
existe une fonction nommée memset pour cela.
Il existe une fonction cousine de malloc nommée calloc ; elle admet deux
paramètres entiers, les multiplie et appelle malloc. On utilise souvent cette
fonction pour des tableaux :
Il arrive parfois que l’on souhaite modifier la taille d’un bloc alloué. On
utilise alors realloc :
74
Lorsqu’on a fini d’utiliser un bloc de mémoire, il est préférable de le
libérer, afin qu’il puisse être réutilisé ultérieurement. On utilise pour cela la
fonction free :
Notons que free ne doit être appliquée qu’à des blocs alloués par malloc,
calloc ou realloc, sous peine d’erreurs très graves. Normalement, l’appel de
Exercice 3.8 Écrire une fonction qui ajoute deux chaînes l’une à
l’autre. Il faudra allouer la mémoire nécessaire au
résultat. On pourra utiliser les fonctions strlen et
Voir solution strcpy (longueur et copie de chaînes) de <string.h>.
75
Un pointeur nul est renvoyé en cas d’échec.
T *p = (*T) malloc(sizeof(T));
T *p = new T;
Notons bien que, dans ce cas, dim peut être n’importe quelle expression,
alors que dans une déclaration de tableau normale, il faut une constante.
Cependant, une taille explicite doit être déclarée, il ne faut pas laisser les
crochets vides.
76
int matrice [][] = new int [3][3]; // incorrect
int table[] = new int[10]; // incorrect
Lorsque les objets ainsi obtenus ne sont plus utiles, on peut appeler
l’opérateur delete qui les détruit, comme son nom l’indique :
delete dp;
delete table;
delete matrice;
Comme dans le cas de free, le pointeur n’est pas remis à zéro, et il ne faut
appeler delete que sur un bloc alloué par new, ou à la rigueur un pointeur
nul.
4/ AUTRES CAPACITES DU
LANGAGE
Avant de revenir sur certains points importants à propos des fonctions,
et surtout avant de commencer l’étude des constructions avancées de C++,
nous détaillons ici certaines capacités du langage qui n’ont pas encore été
vues ; aucune d’entre elles n’est réellement essentielle, mais elles s’avèrent
parfois utiles, notamment pour fournir des raccourcis d’écriture.
Constantes
77
Il est possible en C++ de définir des données constantes. Ces données ne
peuvent être modifiées.
Pi += 1; // refusé !!
donnera le message Error : Lvalue required (car une constante n’est pas une
lvalue) ; dans d’autres cas, on obtiendrait plus clairement Error : Cannot
modify a const object, on ne peut pas modifier un objet constant.
ou de manière équivalente :
dp++;
78
double z = *dp;
dans ce cas, z prend la valeur de Pi, comme si dp n’avait pas été modifié ;
surprenant, non ?
et des pointeurs constants (ne pas confondre avec les pointeurs sur des
constantes) :
Dans ce cas, l’opération ++dc par exemple est interdite, puisqu’il s’agit
d’un pointeur constant. Par contre, on peut écrire :
79
const double *const dcc = Π
Enumérations
Les énumérations sont un moyen commode de regrouper des constantes,
lorsqu’elles ont une signification voisine, ou reliée. Voici un exemple
d’énumération (il s’agit des codes de valeurs des touches du pavé numérique
d’un PC, qui sont des caractères étendus) :
enum carac_pave_num {
HOME = 71, UP = 72, PGUP = 73, LEFT =
75,
RIGHT = 77, END = 79, DOWN = 80, PGDN
= 81,
INS = 82, ANNL = 83 };
jour_semaine js = Mercredi;
De telles écritures sont par exemple nettement plus claires dans des
boucles :
80
Notons que cela n’empêche nullement js de sortir de l’intervalle [0..6] ;
dans ce cas cependant, elle ne correspond plus aux constantes de
l’énumération.
Dans ce cas, UP vaut 1+HOME, soit 71, etc. Les valeurs sont donc bien les
mêmes. Comme l’énumération n’a pas de nom, il n’est pas possible de créer
une variable ayant le type de ces constantes ; il faut utiliser une variable
entière normale.
Interruptions d’exécution
Dans certains cas, on doit interrompre une boucle, une fonction, voire
tout le programme en plein travail. C’est le cas par exemple si une erreur se
produit subitement (valeur incorrecte, fichier subitement interrompu, etc.).
On dispose pour cela d’instructions spéciales qui évitent d’écrire des if
imbriqués multiples.
Les éléments qui suivent sont hérités du C, et nous n'avons pas parlé de
goto, qui doit être évité absolument. Le C++ ajoute encore un système
d’exceptions, qui sont souvent plus utiles que les éléments ci-après.
81
double table[100];
int i;
... // initialiser ici
// remplacer tous les éléments de la table
// par leur racine carrée :
for (i = 0; i < 100; i++)
if (table[i] < 0) break; // catastrophe !
else table[i] = sqrt(table[i]);
Le mot break est réservé ; il ne doit pas être utilisé comme identificateur.
Signalons en outre que cette instruction est aussi utilisée pour les
instructions de branchements multiples switch (voir ci-après).
82
Poursuivant toujours de notre haine les nombres négatifs, nous décidons
cette fois de les ignorer purement et simplement ; l’écriture ci-dessus ne
calculera et n’affichera que les racines carrées des nombres positifs.
Cet exemple est tout à fait factice, et illustre surtout les mauvaises façons
d’utiliser continue. Dans un grand nombre de cas en effet, un simple if bien
placé fait l’affaire :
et est généralement plus clair. L’instruction continue doit donc être utilisée
avec modération.
int lire_fichier(void)
{
if (!ouvrir_fichier())
return -1; // erreur, fichier introuvable
... // lecture
return 0; // renvoie 0 : tout va bien
Arrêt du programme
Il existe plusieurs moyens d’arrêter le programme en cours de route. Le
premier est d’utiliser return dans la fonction main, comme on l’a vu. En
particulier return 0 indique une terminaison normale du programme.
83
if (erreur) exit(1);
absolue. Elles peuvent causer des pertes de données, dans les fichiers
ouverts en écriture notamment.
switch (expression) {
case constante1 : instructions1;
case constante2 : instructions2;
............................
case constanteN : instructionsN;
default : instructions0;
}
L’expression expression est évaluée (elle doit être d’un type numérique),
puis comparée à constante1, constante2, etc., jusqu’à ce qu’une identité soit
trouvée ; dans ce cas, les instructions qui suivent (il peut y en avoir
plusieurs) sont exécutées, jusqu’à l’accolade fermante finale, et non jusqu’à
84
l’instruction case suivante. Si aucune des constantes n’est égale à
l’expression, ce sont les instructions qui suivent default qui sont exécutées.
Cette dernière clause est semblable au else du branchement simple, et peut
également être omise.
switch (i) {
case 1 : i = 0;
case 2 : i = 10; // probablement erroné
default i++;
}
switch (i) {
case 1 : i = 0; break;
case 2 : i = 10; break;
default i++;
}
85
for (char *s = chaine; *s; s++)
switch (*s) {
case 'à':; case 'â':; // continuer...
case 'ä': *s = 'a'; break; // ... ici
case 'é':; case 'è':; case 'ê':; // idem
case 'ë': *s = 'e'; break;
case 'ì':; case 'î':; // idem
case 'ï': *s = 'i'; break;
case 'ô':; case 'ò':; // idem
case 'ö': *s = 'o'; break;
case 'ù':; case 'ü':; // idem
case 'û': *s = 'u'; break;
case 'ÿ': *s = 'y'; break;
}
return chaine;
}
Seules les minuscules ont été traitées ; le lecteur étendra sans peine la
méthode aux majuscules. On notera le point-virgule obligatoire entre le
deux-points et le case suivant, même s’il n’y a pas d’instruction. Il n’y a pas
ici de clause default, puisqu’il n’y a pas d’action si le caractère n’est pas
accentué.
char toupper(char c)
{
switch (c) {
case 'a' : return 'A';
case 'b' : return 'B';
// etc...
case 'z' : return 'Z';
default : return c;
}
86
}
Voir solution
char toupper(char c)
{
if ( (c > 'z') || (c < 'a') ) return
c;
else return c - 'a' + 'A';
}
87
Variables registres
Le microprocesseur qui exécute le programme ne peut pas faire
d’opérations directes sur la mémoire. Ainsi, s’il doit exécuter :
k = i + j;
register int i;
le mot int étant facultatif, puisqu’il s’agit du type par défaut. Dans ce cas, le
compilateur essaie d’utiliser un registre pour i.
Cependant, il se peut que les registres soient déjà utilisés. Dans ce cas,
aucune erreur n’est produite, mais la variable est placée en mémoire
normale, comme si l’on n’avait pas écrit le mot register.
88
De ce fait, déclarer une variable register n’améliorera que rarement les
performances ; cela peut même les dégrader, en incitant le compilateur à
réserver un registre pour cette variable, alors qu’une autre l’utiliserait avec
plus de profit. Pour toutes ces raisons, il est préférable de ne pas utiliser
l’indication register, bien que cela ne puisse aucunement provoquer de
problèmes.
Définitions de types
Il arrive que l’on utilise des types un peu complexes en C++. Par exemple
l’écriture :
est assez peu claire, et de surcroît assez longue à écrire. Voici comment on
peut éclaircir cet embrouillamini :
89
typedef unsigned size_t;
ce qui signifie que size_t est équivalent à unsigned int, mais permet de
mettre en valeur ces arguments en montrant qu’ils désignent une taille
mémoire, et non quelque chose d’autre.
Les définitions de types sont aussi spécialement utiles avec les pointeurs
de fonction (voir chapitre 5).
Variables volatiles
Dans de très rares cas, une variable peut être modifiée sans que le
programme le sache ; cela arrive par exemple avec une variable globale
modifiée par une tâche de fond ou par une interruption système.
volatile int i;
Précisons aussi que les fonctions membres d’une classe peuvent être
déclarées volatiles (voir chapitre 6 pour les classes) ; dans ce cas, une
instance volatile de la classe ne pourra utiliser que les fonctions membres
volatiles.
Arguments de main
La fonction principale main (vue au premier chapitre) peut en fait
recevoir des arguments. Il s’agit du nombre d’arguments passés à la ligne de
commande du système d'exploitation (pour ceux qui utilisent le mode
terminal), de la valeur de ces arguments, et de l’environnement courant du
système.
90
Ainsi, si vous avez écrit la fonction principale d’un programme nommé
prog de la façon suivante :
Vous pouvez déclarer main sous l’une des quatre formes suivantes :
Le nom des paramètres est à votre choix, mais leur type et leur ordre est
imposé.
5/ FONCTIONS
91
Nous avons déjà vu des exemples de fonctions, et nous avons pu
entrevoir leur importance en C++. Nous allons à présent examiner en détail
les capacités offertes par ces objets essentiels.
Prototypes de fonctions
Nous avons vu qu’il était possible de déclarer une fonction, ou de la
définir entièrement. L’une et l’autre opération ne se font que dans la
« partie globale » du programme, c’est-à-dire à l’extérieur de toute autre
fonction. Le C++ ne permet pas en effet de créer des fonctions imbriquées
comme le Pascal ou d’autres langages de programmation.
Nous savons déjà ce que sont les arguments, le nom et le type de résultat
de la fonction. Reste à expliquer quel peut être l’intérêt de simplement
déclarer une fonction, au lieu de la définir complètement.
92
en de tels arguments. Dans le cas contraire, il affiche un message d’erreur
signalant la non-concordance des types. Cela renforce considérablement la
sécurité d’écriture des programmes.
void a(void);
void b(void); // fonctions appelées par main
main()
{
a();
b();
...
}
int c(void)
{
// fonction définie entièrement, utilisée par a()
}
void a(void)
{
c();
...
}
void b(void)
{
...
}
93
int f(int a, float x); // prototype
// autres...
car les noms donnés sont de toute façon ignorés ; ils sont toutefois utiles
dans certains cas pour indiquer ce qu’ils signifient :
indique clairement dans quel ordre placer les deux chaînes, ce qui est
évidemment essentiel dans ce cas.
Les prototypes sont beaucoup utilisés dans les fichiers d’en-têtes (voir
chapitre 10), et dans la récursivité croisée (voir paragraphe sur la
récursivité).
94
Il est important de bien comprendre la différence entre les arguments
formels d’une fonction et ses paramètres réels (ou effectifs). Les arguments
formels sont ceux déclarés avec la fonction :
Les paramètres réels sont ceux qui sont envoyés au moment de l’appel de
la fonction :
int i, k, l;
f(i+j, i, k+l);
La seule chose qui compte, c’est la coïncidence des types. On voit que
dans notre exemple cette coïncidence n’est pas parfaite ; en effet, si i+j et i
sont bien de type int, k+l est aussi entier, et non un double. Dans ce cas, le
compilateur tente de faire coïncider les deux types : ici, c’est possible
puisque les types entiers et décimaux sont compatibles (imaginer une
assignation d = k+l, parfaitement possible). De même, une fonction ayant
pour paramètre un pointeur void* pourra accepter n’importe quel pointeur :
void g(void* p)
....
char *s;
g(s) // ok char* -> void* possible
Ces paramètres sont dit passés par valeur, c’est-à-dire que seule leur
valeur est connue de la fonction, non leur adresse. En conséquence, il n’est
pas possible à une fonction de modifier ses paramètres, même en changeant
95
la valeur des arguments. Par exemple, la fonction d’échange suivant ne
marche pas :
Cela peut poser des problèmes avec certaines fonctions qui renvoient un
pointeur, par exemple. Imaginons une fonction qui lit une chaîne de
caractères particulière sur un périphérique. L’implantation suivante est
incorrecte :
char *lecture(void)
{
char tampon[256];
// ...lecture de la chaîne dans le tampon
return tampon; // NON ! variable détruite !
}
96
En effet, au retour de la fonction, le tableau tampon est détruit, et le
pointeur n’a donc plus aucun sens. Il faut déclarer le tampon en variable
statique, indiquant par là au compilateur que cette variable ne doit pas être
détruite :
char *lecture(void)
{
static char tampon[256];
// ...lecture de la chaîne dans le tampon
return tampon; // Ok variable conservée
}
Il faut bien comprendre qu’une variable statique est unique, elle n’est pas
recréée à chaque appel de la fonction. En conséquence, si l’on écrit :
char *lecture(void)
{
char tampon[256];
// ...lecture de la chaîne dans le tampon
return strdup(tampon); // dupliquer
}
97
variables dynamiques placées dans le tas au moment de l’exécution, par un
appel à malloc (voir chapitre 3). D’autre part, le programmeur qui appelle la
fonction lecture devra penser à libérer la mémoire occupée par s1 et s2
lorsqu’il n’en aura plus besoin.
Arguments tableaux
Les tableaux ne sont jamais passés par valeur, c’est-à-dire jamais
recopiés dans la pile (supposée trop petite pour une telle opération). Un
argument de type int[] par exemple est en fait un pointeur sur son premier
argument, et est donc un int* en réalité. Pour avoir une copie, il faut la
réaliser soi-même en créant la place nécessaire dans le tas, puis en
recopiant le tableau, et enfin en libérant la place utilisée en fin de fonction.
Il est également possible de faire recopier le tableau en bloc à l’aide d’une
structure (voir chapitre suivant).
98
Il existe deux solutions à ce problème. La première consiste à utiliser des
structures adéquates, nous la verrons dans les chapitres suivants.
int i = 4, j = 6;
echange (&i, &j); // à présent i == 6, j == 4
99
Pour résoudre ce problème, on peut en C++ utiliser les références ; il
suffit d’indiquer pour arguments des références int& :
int i = 4, j = 6;
echange (i, j); // à présent i == 6, j == 4
ce qui est nettement plus naturel. Que se passe-t-il alors ? Au lieu de créer
une case mémoire spéciale pour recopier les valeurs des paramètres, ce sont
les adresses de ceux-ci qui sont en réalité passées à la fonction, sous la
forme d’une référence (qui est donc ici une sorte de « pointeur implicite » ).
Celle-ci peut donc modifier les valeurs sur place.
int i = 4, j = 6;
echange (i+j, j) // incorrect;
Dans ce cas, i+j n’est pas une variable, on ne peut donc donner son
adresse. D’ailleurs, avec la méthode des pointeurs, comme &(i+j) n’a pas de
sens, une erreur serait provoquée.
Ici, il n’y a pas réellement d’erreur. En effet, nous avons vu qu’il est
possible d’initialiser une référence avec une constante ; dans ce cas, une
variable temporaire est créée, initialisée à la valeur de i+j, soit 10, et c’est
une référence à cette variable que désigne a. On voit que dans ce cas, le
comportement est pratiquement identique à celui d’un argument normal
par valeur.
100
A fortiori, si l’on écrit :
echange(1, 2);
int i = 1, j = 2, k = 3;
permute (0, i, j, k);
Dans certains cas, il est préférable de passer une référence sur un objet,
plutôt que l’objet tout entier, non parce que la fonction souhaite le modifier,
mais simplement parce qu’il s’agit d’un objet de grande taille (en mémoire).
Pour l’instant, nous n’avons pas vu d’objet plus grand que long double (10
octets), mis à part les tableaux qui de toute façon ne sont jamais passés par
valeur. Cependant, on peut en créer avec les structures et des classes
(chapitre suivant). Dans ce cas, il se peut que l’appel d’une fonction du
genre :
void f(grandtype g)
101
constante (voir sous-paragraphe suivant pour une explication sur les
arguments constants), comme ceci :
Cette méthode peut être envisagée même pour le type double (bien qu’elle
soit alors un peu plus lente) si l’on souhaite ne pas engorger la pile
(fonctions récursives par exemple).
Arguments constants
Il est parfaitement possible de déclarer constant l’argument d’une
fonction, avec le mot clé const, comme on le ferait pour une autre donnée.
Cependant, c’est sans intérêt pour les arguments usuels, car s’ils sont passés
par valeur, la fonction ne peut les modifier extérieurement, et s’ils sont
passés par référence, c’est bien en général pour les modifier (sauf dans le
cas de grands types, comme on l’a vu précédemment).
102
on aurait eu le message Error : Cannot modify a const object, on ne peut
modifier un objet constant.
int i = 10;
f(i++, i++) // effet ?
appellera soit f(10, 11), soit f(11, 10) suivant le compilateur. En Turbo
C++, c’est la deuxième solution qui est choisie, aussi étrange que cela puisse
paraître. En tout état de cause, un tel code n’est absolument pas portable, il
doit être évité.
int i = 6;
int j = --i << --i;
placera en Turbo C++ la valeur 80 (5 << 4) dans j et non la valeur 128 (4 <<
5). Par contre, l’écriture :
fera écrire les chiffres 4 puis 5 au contraire, car il s’agit en fait de deux
appels de fonction dissimulés (voir chapitre 7) :
Même s’il n’y a qu’un seul effet de bord, mais que la variable est
réutilisée, il y a ambiguïté. Par exemple, l’écriture :
int i = 6;
103
f(i, --i);
équivaut à l’appel de f(5, 5) en Turbo C++, mais peut valoir f(6, 5) avec
d’autres compilateurs. Par contre, l’écriture :
j = i << --i;
place la valeur 160 (5 << 5) dans j, et non 192 (6 << 5), car les effets de bord
des expressions sont calculés en premier par Turbo C++. De même,
l’écriture :
envoie les caractères 5 et 5 à l’écran. Toutes ces écritures sont non portables
et aventureuses.
Imaginons par exemple une procédure qui ajoute une chaîne à la fin
d’une autre. Un tel ajout peut provoquer un débordement, aussi n’est-il pas
inutile de donner un paramètre indiquant la taille maximale à ne pas
dépasser pour la chaîne résultat. Voici une implantation possible d’une telle
fonction :
104
if (la > 0) memmove(dest+ld, ajout, la);
if (max) *(dest+max) = 0; // zéro final
return dest;
}
On ignore ici le paramètre max s’il est négatif ou nul, la seconde chaîne est
alors recopiée entièrement derrière la première. Si ce paramètre est
inférieur à la longueur de dest la chaîne est simplement tronquée, ajout ne
sert à rien. Voici un exemple d’utilisation de cette fonction :
105
char tampon[256] = "";
ajoute(tampon, s);
ajoute(tampon, s2);
Ces arguments par défaut sont une capacité très intéressante de C++, qui
permet des écritures beaucoup plus agréables. On peut ainsi regrouper
plusieurs fonctions. Par exemple, il existe deux fonctions de copie dans la
librairie <string.h> (qui est écrite en langage C, non en C++) :
qui recopie entièrement source dans dest, que nous connaissons déjà, et :
qui copie au plus max caractères de source vers dest. On voit qu’avec les
arguments par défaut, une seule fonction aurait suffi, comme ceci par
exemple :
Une fonction peut avoir plusieurs arguments par défaut, qui peuvent être
des expressions constantes de toutes sortes :
Cependant seuls les derniers arguments peuvent avoir une valeur par
défaut, il n’est pas possible de faire un mélange :
106
Pour réaliser cette opération, il faut soit placer une valeur par défaut
spéciale constante pour b, et recopier a dans b au début de la fonction quand
b est égal à cette valeur, soit écrire deux versions de la même fonction, ce qui
est permis en C++ (voir plus loin).
Nous utiliserons beaucoup dans la suite ces arguments par défaut, qui
permettent de simplifier notablement les programmes, en évitant une
surcharge de fonctions.
Ellipse
L’ellipse est une capacité du langage C qui permet d’écrire des fonctions
avec un nombre variable d’arguments. Un exemple classique est la fonction
printf, fonction d’écriture standard du C (en C++, on utilise plutôt cout). La
printf("Bonjour.");
printf("La racine de %d est %lg.", 2, sqrt(2));
printf("Il est %d heures, %d minutes, %d
secondes.",
heure, min, sec);
%d Variable int
%ld Variable longint
%g Variable float
%lg Variable double
%s Variable char* (chaîne de caractères)
%c Variable char
107
mais il en existe beaucoup d’autres ; pour écrire le caractère %, il suffit de le
redoubler (%%). On arrive ainsi à des écritures assez absconses :
(et encore, on peut faire bien pire). Le plus grave, cependant, est l’absence
de contrôle de types. Si dans l’écriture ci-dessus, la variable pourcent est de
type double, alors qu’une float est attendue d’après la chaîne de format, cela
conduira au mieux à une écriture erronée, au pire à une catastrophe plus ou
moins amusante. Essayez par exemple ceci :
int i = 1;
printf("Badaboum %s", i);
108
Voici à titre d’exemple une fonction qui calcule la moyenne d’une suite
d’entiers, terminés par un zéro :
#include <stdarg.h>
au lieu de :
109
D’une façon générale, il vaut mieux éviter les fonctions avec ellipse
lorsqu’elles ne s’imposent pas. Ici, l’ellipse de la fonction sera
avantageusement remplacée par un pointeur sur une liste d’entiers, plus un
paramètre entier indiquant le nombre d’éléments à prendre en compte, ce
qui permet de mettre des zéros dans la liste :
#include <stdarg.h>
110
Exercice 5.2 Écrire une fonction équivalente à printf. On ne
prendra en compte que les formats %d (int), %g (pour
double, et non float, à cause de ce qui vient d’être dit),
#include <iostream.h>
#include <stdarg.h>
111
ecritchar(*s);
break;
default : ecritchar(*form); // ignorer le
%
}
va_end(vl);
}
main()
{
printf2("Nombre = %d%%.\n", 12);
printf2("%s + %s\n", "chaine1", "chaine2");
printf2("Racine de %d = %g\n", 2, 1.4142);
printf2("Tabulation = %c et CR = %c", '\t',
'\n');
return 0;
}
Les trois fonctions ecritxxx ont été déclarées inline à cause de leur
petitesse. On notera que le doublon %% est bien traité (premier exemple),
grâce à la clause default : les caractères qui suivent un %, s’ils ne sont pas
dans la liste des codes, sont affichés normalement et le % est ignoré. Noter
aussi la déclaration en const char* de format (car la chaîne n’est pas
modifiée), et de form (sinon le compilateur refuse d’y copier format).
void danger(...)
112
par les arguments par défaut dans la mAccès aux
variables globales
Dans certains cas, une variable globale peut être masquée par un
argument de la fonction ou par une variable locale. C’est le cas lorsque les
deux ont le même nom, comme dans cet exemple :
long x = 4;
long x = 4;
113
puisque nous avons vu qu’en réalité un type comme int[] recouvrait un
pointeur int* sur le premier élément. On peut renvoyer un pointeur sur une
fonction (voir plus loin).
int& f(void)
int& f(void)
{
int i;
...
return i; // non erreur
}
car la variable i est détruite en fin de fonction. Par contre, on peut ici aussi
utiliser une variable statique :
int &f(void)
{
static int i;
...
return i; // ok
}
114
}
En langage C, on utilise pour cela une macro (voir chapitre 10). En C++,
on gagne beaucoup à écrire une fonction « en ligne » (inline). Il suffit pour
cela d’écrire :
115
Notons enfin que si une fonction en ligne peut être déclarée, elle ne peut
être appelée avant d’avoir été définie entièrement, puisque le compilateur
doit connaître le code de la fonction pour le remplacer à l’endroit de l’appel.
Bloc d’instructions
Nous devons d’abord expliquer ce qu’est un bloc d’instructions. Il s’agit
d’une unité d’étendue regroupant une ou plusieurs instructions. Les blocs
sont toutes les séquences d’instructions commençant par une accolade
ouvrante { et s’achevant par l’accolade fermante correspondante }. En
particulier, chaque fonction possède un bloc principal qui est celui qui
contient toute la définition de l’implantation de la fonction. Cependant, il
peut contenir des blocs secondaires, notamment à l’intérieur d’instructions
composées comme if, switch, for, while, do...while. Ces instructions
composées contiennent souvent des blocs entre accolades, inclus dans des
blocs plus grands. Ce processus d’imbrication peut être très élevé. Voici
quelques blocs :
void f(void)
{ // bloc principal de f, 1
while (..) { // bloc 2 inclus dans 1
...
if (..) { // bloc 3 inclus dans 2
. ..
for (int i..) { // bloc 4 inclus dans 3
...
} // fin bloc 4
} // fin bloc 3
...
116
} // fin bloc 2
} // fin bloc 1
Conflit d’identificateurs
Il est parfaitement possible de donner le même nom a deux variables
différentes, à condition qu’elles ne soient pas toutes deux globales ou toutes
deux dans le même bloc. En particulier, deux arguments d’une même
fonction doivent avoir des noms différents. (Pour accéder à une variable
globale dont le nom est recouvert par une variable locale, voir
précédemment.)
int i = 1;
if (i > 0) {
char i = 'A' // ok, bloc différent
..
.}
while (i-- > 0) {
double i = Pi; // idem
...
}
117
Variables statiques et dynamiques
Revenons aux variables. En premier lieu, on distingue les variables
statiques et les variables dynamiques. Les premières existent aussi
longtemps que le programme, et sont initialisées (éventuellement) en même
temps que lui. Les secondes sont créées (et initialisées éventuellement) à un
certain moment dans le programme, puis détruites ultérieurement ; ce
processus peut se produire plusieurs fois pour une même variable.
#include <iostream.h>
void g(void)
{
118
int loc = 0; // locale
for (int i = 1; i <= glob2; i++) // i est locale
cout << i << '\t' << glob1++ * loc++ <<
'\n';
}
main()
{
int mloc1, mloc2 = 7; // statique de main
f(mloc2, mloc1);
if (mloc1 > 0) {
for (int k = 1; k <= 3; k++) f(0,0);
mloc1 = 0;
}
g();
return 0;
}
Une telle variable n’est visible que dans le bloc où elle est déclarée. Il en
résulte que par exemple g et main ne peuvent avoir accès à stat. Il s’agit d’un
processus beaucoup plus sûr que l’utilisation de globales.
119
Toutes les variables statiques sont créées dans le segment de données à
la compilation.
ou de new, et qu’il détruit quand bon lui semble. Ces variables ne sont pas
prises en compte par le compilateur, ce n’est pas lui qui s’en occupe.
Les variables locales sont celles qui sont déclarées à l’intérieur d’un bloc,
et qui sont détruites à la fin de ce bloc. C’est le cas des variables loc et i dans
g (dont le bloc est celui de la fonction, elles sont donc détruites avec elle),
mloc1 et mloc2 dans main, dont nous avons déjà parlé, et k dans main (dont le
Enfin, les variables cachées sont celles qui ne sont jamais nommées. Il
s’agit en particulier des variables temporaires créées par le programme
lorsqu’on initialise une référence sur une constante. Dans ce cas, une
variable provisoire est créée et initialisée, dont la durée de vie est celle de la
référence afférente. Ainsi, lors de l’appel de f(0,0), une variable temporaire
de valeur zéro est créée dont la durée de vie est celle de arg2, c’est-à-dire la
durée de f.
120
variables automatiques ne sont jamais initialisées par le compilateur, il faut
le faire explicitement.
est en réalité plus générale que cela, car on peut aussi souhaiter avoir le
maximum de variables de type long, par exemple, ou double. Dans de
nombreux langages de programmation, et en C, il faudrait écrire des
versions spéciales de cette fonction, avec des noms différents, tels que
maxlong, maxdouble, etc. En C++, il est possible de donner le même nom à
toutes ces fonctions :
int i = 1, j = 3;
i = max(i, j); // appel de max(int, int)
double d1, d2;
d1 = max(d1, d2); // max(double, double)
121
Ici, le compilateur applique les règles standard de promotion des types ;
le type float peut être changé en double sans perte de précision, c’est donc
max(double, double) qui est appelée, et le résultat est donc correct.
max(int, int), car il n’est pas possible de faire passer une variable float par
l’un des types long ou int sans perte de précision.
Cette règle de promotion des types nous évite d’écrire des fonctions pour
chaque type, ce qui serait fastidieux. On aurait même pu éviter d’écrire une
fonction max(int, int). Dans ce cas, tous les types entiers (char, unsigned
char, unsigned, int, unsigned long, long) utiliseraient la version max(long,
long), tandis que float et double utiliseraient max(double, double). Seul le type
Signalons que le cas des fonctions ayant des références pour paramètres
est différent. Supposons écrites par exemple les fonctions :
122
float f1, f2;
echange(f1, f2); // pas de concordance
Récursivité
Une fonction est dite récursive lorsqu’elle s’appelle elle-même, ce qui est
parfaitement autorisé :
void baoum(void)
{
baoum();
}
123
s’appelle elle-même indéfiniment, jusqu’à ce que la pile déborde,
provoquant des effets variés (dans le genre horrible).
double fact(int n)
{
if (n > 1) return n*fact(n-1);
else return 1;
}
Nous avons utilisé le type double car les factorielles sont des nombres
entiers très grands.
double fact(int n)
{
double f = 1;
while (n > 1) f *= n--;
return f;
}
if (condition) appel-récursif;
else pas_d’appel_récursif;
seront bien mieux écrites sans récursivité par une simple boucle de la forme
while (condition) (par exemple).
124
Il existe même des cas de récursivité catastrophique. Un exemple
frappant est donné avec les nombres de Fibonacci. Il s’agit d’une suite de
nombres, les premiers égaux à 0 et 1, et chacun des autres égal à la somme
des deux précédents :
0 1 1 2 3 5 8 13 21 34 ...
long fib(unsigned n)
{
if (n > 1) return fib(n-1) + fib(n-2);
else return n;
}
Voir solution
125
seront faits. C’est le cas par exemple lorsqu’une fonction de lecture de
fichier échoue. Elle peut alors afficher un message et, si l’utilisateur
souhaite essayer à nouveau, se rappeler récursivement pour reprendre ; il
est alors entendu qu’au bout de trois ou quatre essais, on abandonne.
void fonction2(void);
void fonction1(void)
{
...
fonction2();
...
}
void fonction2(void)
{
...
fonction1();
...
}
On notera que l’emploi d’un prototype est ici obligatoire, puisque aucune
des deux fonctions ne peut être définie avant l’autre, sous peine de
provoquer une erreur de compilation. De plus, comme dans le cas de la
récursivité simple, il doit y avoir des instructions de branchement dans les
fonctions.
int (*pf1)(double);
long (*pf2)(void);
void (*pf3)(int, float);
126
Le pointeur pf1 est un pointeur sur une fonction ayant un argument
double, et de résultat entier. Le pointeur pf2 désigne une fonction sans
argument de résultat entier long. Le pointeur pf3 désigne une fonction ayant
deux arguments, le premier entier, le second float, et sans résultat.
127
Voir solution comme paramètre ?
int (*p2[])(double);
int i = (*pf1)(10.1);
128
On doit dans ce cas faire un changement de type :
int i = pf1(10.1);
pf1++;
129
void (*pff)(int, pfonc);
// pointeur de fonction sans résultat ayant un int et
// un pointeur de fonction pfonc comme arguments
130
(int (*)(const void*, const void*))
compare);
ce qui est un changement de type tout à fait acceptable quoique fort pénible
à écrire. Dans la pratique, pour des types aussi simples que int, on écrira
une fonction de comparaison ayant le bon type, quitte à faire un
changement de type un peu délicat à l’intérieur. En écrivant un programme
complet, on obtient ainsi :
// essai de qsort
#include <stdlib.h>
#include <iostream.h>
main()
{
int table[Ntab];
init_table(Ntab, table);
cout << "\n\nTable non triée :\n";
aff_table(Ntab, table);
qsort(table, Ntab, sizeof(int), comp_int);
131
return 0;
}
Le tri de 100 nombres prend environ 0.036 seconde sur notre machine,
et celui de 1000 environ 0.52 seconde ; dans les deux cas, la majorité du
temps se déroule en comparaisons.
Retour au texte.
6/ CLASSES ET STRUCTURES
Nous avons vu jusqu’ici un nombre restreint de types composés. En
particulier, pour grouper plusieurs variables, nous ne connaissons encore
que les tableaux. Or ceux-ci ne permettent de grouper que des variables de
même type. Pour en grouper de différentes, il faut utiliser les structures,
héritées du C, ou mieux encore les classes, qui sont la pierre de base de la
programmation orientée objet.
Structures
Les données d’un programme sont rarement dispersées. Elles peuvent en
général être pensées sous la forme de groupes plus ou moins importants,
ayant une cohérence significative. Par exemple, dans une gestion de
132
personnel, on utilisera des fiches contenant le nom, le prénom, l’âge,
l’adresse, etc., de chaque employé. Il serait peu logique de placer chacun des
éléments de ces fiches dans des tableaux différents, car cela compliquerait
la recherche de l’ensemble des caractéristiques d’un employé donné.
struct fiche {
char *nom, *prenom;
int age;
// etc ...
};
On a ainsi en fait défini un type, non une variable. Des variables de type
fiche peuvent être déclarées de la même façon que pour tout autre type :
Un tel type peut être manipulé comme n’importe quel autre, et l’on peut
très bien définir des pointeurs sur ce type, ou des tableaux, ce qui dans
notre exemple serait sans doute plus judicieux :
fiche employes[];
fiche employe;
employe.nom = "Dupont";
employe.prenom = "Jean";
employe.age = 34;
133
Cet opérateur a une précédence plus forte que le déréférencement, aussi
pour éviter dans le cas de pointeurs des parenthésages pénibles, on dispose
d’un second opérateur qui déréférence le pointeur avant d’appliquer le
point, noté -> :
fiche *pempl;
pempl->nom = "Durand"; // plus simple que (*pempl).nom
pempl->age = 25; // etc.
Définition
La syntaxe générale d’une définition de structure est la suivante (les
crochets indiquent les éléments facultatifs) :
struct [nom_struct] {
type1 champ1;
type2 champ2;
......
typeN champN;
} [var1, var2, ..., varM];
Les termes var1, etc., sont des variables déclarées simultanément. Il est
en effet possible de déclarer des variables structure en même temps que le
type, comme ceci :
struct fiche {
char *nom, *prenom;
int age;
} employe1, employes[];
struct {
char *nom, *prenom;
int age;
} employes[100];
134
Dans ce cas, il n’est plus possible par la suite de déclarer d’autres
variables du même type.
Toutefois, tant que le mot fiche n’est pas utilisé par un autre type ou une
variable, il n’y a aucune ambiguïté et par conséquent le mot struct est
facultatif. On peut toutefois le préciser si l’on craint une confusion.
typedef struct {
char *nom, *prenom;
int age;
} fiche;
Dans ce cas, le nom du type figure à la fin, comme pour toute définition
de type. Cette déclaration est toutefois dépourvue d’intérêt : il n’est en effet
plus permis de déclarer des variables struct fiche, il faut écrire fiche tout
seul ; il n’est pas possible de déclarer des variables en même temps. De plus,
cette écriture est plus lourde que la précédente.
struct recursive {
recursive interne; // NON, interdit
};
135
Cependant, on peut employer des références ou (plus fréquemment) des
pointeurs sur le type structure courant :
struct fiche {
fiche *suivante;
char *nom, *prenom;
// ...
};
On peut ainsi créer une liste chaînée (voir les exemples plus loin dans ce
chapitre).
Arguments de fonctions
Les structures peuvent être passés comme arguments de fonctions de la
même façon que tout autre type :
ou de références :
136
Déclaration sans définition
Jusqu’à présent, toutes les structures que nous avons utilisées étaient
entièrement définies dès le début. Il est toutefois permis de déclarer une
structure sans en donner la définition, comme ceci :
struct exemple;
struct exemple1;
struct exemple2 {
exemple1* pex1;
// ...
};
struct exemple1 {
exemple2* pex2;
// ...
};
Fonctions membres
Lorsqu’on ne crée une structure qu’avec des champs de données, elle est
en quelque sorte « nue » , car on ne dispose pas de moyens pour l’utiliser.
Bien sûr, on peut modifier ses champs un par un, mais c’est souvent
fastidieux. Il est préférable de créer des fonctions spéciales, comme ceci :
137
void ecrit_np_fiche(fiche &f, char *nouveau_nom,
char *nouveau_prenom)
{
f.nom = nouveau_nom;
f.prenom = nouveau_prenom;
}
Déclaration
Il est bien plus simple de définir en même temps la structure et des
fonctions qui agissent sur elle. Pour cela, il suffit de déclarer des fonctions
membres, appelées aussi méthodes :
struct fiche {
char *nom, *prenom;
int age;
void ecrit_np(char *nouv_nom, char *nouv_pre);
};
Implantation
L’implantation de la fonction membre sera donnée plus loin dans le
programme, à tout endroit jugé adéquat :
138
Noter que le nom de la méthode est précédé du nom de la structure suivi
par l’opérateur de résolution de portée :: (sur lequel nous reviendrons). On
indique ainsi au compilateur qu’il s’agit de la fonction membre définie dans
la structure fiche. En effet, d’autres structures pourraient avoir une fonction
membre du même nom, et il peut y avoir aussi une fonction normale ayant
ce nom ; en outre le compilateur sait ainsi immédiatement qu’il doit passer
un paramètre implicite fiche *this dans la fonction. C’est pourquoi le nom
de la structure est obligatoire : il ne doit jamais être omis, même s’il n’y a
qu’une fonction portant ce nom dans tout le programme.
fiche employe;
employe.ecrit_np("Dupont", "Jean");
fiche *pempl;
pempl->ecrit_np("Durand", "Paul");
139
Dans le premier cas, le paramètre this passé à la méthode ecrit_np est
&employe, dans le second cas c’est pempl.
Méthodes en ligne
Les fonctions membres peuvent, comme les autres, être déclarées en
ligne par le mot clé inline :
struct fiche {
char *nom, *prenom;
int age;
void ecrit_np(char *nouv_nom,
char *nouv_pre);
};
struct fiche {
char *nom, *prenom;
int age;
void ecrit_np(char *nouv_nom, char *nouv_pre)
{ nom = nouv_nom; prenom = nouv_pre; }
};
140
Rappelons toutefois que certains compilateurs ne placent pas en ligne les
fonctions contenant une boucle ou une instruction de branchement multiple
switch. Dans ce cas, ils le signalent par un message Warning, mais l’écriture
n’est cependant pas fautive : il suffit d’ignorer le message.
Ordre de déclaration
L’ordre de déclaration des champs et des méthodes dans une structure
est indifférent, car le compilateur lit la structure en bloc, avant même
d’interpréter les méthodes en ligne. En conséquence, l’écriture suivante est
parfaitement possible :
struct bizarre {
int i;
int methode1(void)
{ if (j) return methode2(); else return 0
}
int methode2(void)
{ if (i) return i; else return j; }
int j;
};
Les champs ont des valeurs différentes d’une instance à l’autre. Par
exemple, les deux instances suivantes :
141
fiche employe1, employe2;
employe1.nom = "Dupont";
employe2.nom = "Durand";
Les méthodes sont identiques d’une instance à l’autre. Ainsi, si l’on écrit :
employe1.ecrit_np("Dupont", "Jean");
employe2.ecrit_np("Durand", "Paul");
ce sera la même fonction fiche::ecrit_np qui sera appelée dans les deux cas ;
seules différeront les valeurs des arguments, non seulement les arguments
effectifs, mais aussi l’argument caché this, qui vaudra &employe1 dans le
premier cas, et &employe2 dans le second.
employe *pempl;
int *empl_age = &(pempl->age);
struct exemple {
// ...
142
void methode(int);
};
est interdite parce que le type de la fonction membre methode n’est pas void
(*)(int). Le langage fournit donc des opérateurs spéciaux pour indiquer des
pointeurs sur des membres d’une classe ; en l’occurrence, il faut ici utiliser
::* pour avoir le type correct de fonction :
Noter les parenthèses obligatoires car les opérateurs .* et ->* ont une
précédence plus faible que les parenthèses d’appel de fonction (voir tableau
en annexe). De plus, on ne peut pas ici omettre le déréférencement de pf
comme on le ferait pour des pointeurs sur des fonctions normales.
On peut prendre des pointeurs sur des fonctions en ligne, mais celles-ci
ne le sont plus forcément dans ce cas.
Membres statiques
Nous avons vu que les méthodes d’une structure sont identiques pour
toutes les instances, mais pas les champs. Cependant, il peut arriver que l’on
souhaite qu’une donnée soit partagée par toutes les instances de la classe.
Nous en verrons des exemples dans la suite.
Pour cela, il suffit de déclarer le champ comme statique, avec le mot clé
static que nous connaissons déjà. Par exemple, chaque employé a un
supérieur direct, mais il n’y a qu’un seul patron :
143
struct fiche {
fiche *superieur; // supérieur direct
static fiche *patron; // le PDG
char *nom, *prenom;
int age;
int estpatron(void)
{ return (this == patron); }
int estdirecteur(void)
{ return (superieur == patron); }
};
fiche employe;
fiche lepatron = *employe.patron;
144
en utilisant le nom de la structure suivi de ::. De ce fait, de telles méthodes
n’ont pas d’argument caché this.
Un exemple
Un exemple simple fera mieux comprendre le système et son intérêt. La
structure de liste chaînée est sans doute déjà connue du lecteur. Il s’agit
d’une suite de noeuds, contenant chacun un pointeur vers un autre noeud et
145
un élément d’information d’un type quelconque (nommé element dans la
suite). L’intérêt de cet agencement réside dans sa fluidité ; il est très facile
par exemple de supprimer ou d’ajouter un élément.
struct noeud {
noeud *suivt; // le suivant dans la liste
element elm; // information contenue
};
Une liste chaînée est cependant fragile. Si par erreur on modifie l’un des
pointeurs, la liste sera coupée, les informations perdues et le programme
complètement égaré.
Nous allons donc blinder notre structure afin de limiter les risques :
struct noeud {
private :
noeud *suivt; // le suivant dans la liste
element elm; // information contenue
public :
noeud *suivant(void) { return suivt; }
element &contenu(void) { return elm; }
};
Nos deux champs sont à présent privés, de sorte que l’écriture suivante :
noeud no;
no.suivt = 0;
On ne peut plus dans notre exemple que lire le contenu (le champ elm) du
noeud, ou connaître le suivant de la liste, à l’aide des méthodes. C’est
évidemment un peu excessif, car il n’est dès lors plus possible de modifier la
146
liste. Nous allons ajouter une fonction qui insère un élément dans la liste, et
une autre qui le supprime :
struct noeud {
private :
noeud *suivt; // le suivant dans la liste
element elm; // information contenue
public :
noeud *suivant(void) { return suivt; }
element &contenu(void) { return elm; }
noeud* supprime_svt(void);
noeud* insere(element e);
};
noeud* noeud::supprime_svt(void);
// supprime le noeud suivant et
// renvoie un pointeur sur le nouveau suivant
{
if (!suivt) return 0; // pas de suivant
noeud *s = suivt;
suivt = s->suivt;
delete s;
return suivt;
}
noeud* noeud::insere(element e)
// insère un nouvel élément de valeur e derrière this.
// renvoie 0 si plus de mémoire, sinon suivt.
{
noeud *nouveau = new noeud;
if (!nouveau) return 0; // plus de mémoire
nouveau->suivt = suivt;
nouveau->elm = e;
return suivt = nouveau;
}
147
le problème (un meilleur moyen est donné plus loin
Voir solution
dans le chapitre) ?
noeud* noeud::cree(element e)
{
noeud *nouveau = new noeud;
if (!nouveau) return 0;
nouveau->suivt = 0;
nouveau->elm = e;
return nouveau;
}
Cette méthode peut être appelée par un noeud non initialisé puisqu’elle
n’utilise pas les champs de this. On aurait pu aussi déclarer cette méthode
statique. Les constructeurs sont toutefois une solution plus simple (voir
ultérieurement dans le chapitre).
Classes
Nous avons vu sur un exemple comment déclarer les parties privées et
publiques d’une structure. Les premiers membres qui apparaissent sont
publics, jusqu’à la rencontre du mot private. Les suivants sont alors privés,
148
jusqu’à rencontrer public, etc. On peut placer ces « bascules modales »
autant de fois que nécessaire.
comme dans notre exemple, puisque les champs sont la plupart du temps
déclarés d’abord.
class noeud {
noeud *suivt; // le suivant dans la liste
element elm; // information contenue
public :
noeud *suivant(void) { return suivt; }
element &contenu(void) { return elm; }
noeud* supprime_svt(void);
noeud* insere(element e);
};
Les structures ne sont pas très utiles en C++ ; elles sont surtout fournies
pour des raisons de compatibilité avec le langage C. Dans la suite, nous
utiliserons presque toujours des classes.
On notera qu’une classe doit toujours contenir au moins une fois le mot
public (ou protected, expliqué au chapitre 8) sauf si elle est vide ; dans le cas
Constructeurs et destructeurs
149
Lorsqu’on déclare une variable, le compilateur lui alloue
automatiquement une place en mémoire. Éventuellement, il lui donne une
valeur initiale si celle-ci est précisée :
int i = 1;
Dans le cas d’une structure sans partie privée, il est possible d’initialiser
aussi une instance en donnant la liste des valeurs des champs, dans l’ordre
de leur déclaration :
struct exemple {
int i;
char c;
} ex = { 1, 'A' }, ex2 = { 2 };
150
Constructeurs
Chaque classe (ou structure) peut avoir un ou plusieurs constructeurs.
Ce sont des méthodes qui se distinguent de deux façons : leur nom est celui
de la classe, et elles n’ont aucun résultat (pas même void). Voici une classe
dotée de deux constructeurs :
class exemple {
int i;
char c;
public :
exemple() { i = 0; c = 0 } // constructeur 1
exemple(int ii, char cc)
{ i = ii; c = cc } // constructeur 2
;}
Les constructeurs ne sont pas des fonctions comme les autres. Par
exemple, il n’est pas possible de les appeler explicitement, ni de prendre
leur adresse.
151
Lorsqu’un constructeur par défaut existe, on peut déclarer une instance
de classe sans préciser de paramètres :
Il n’en est pas de même si aucun constructeur par défaut n’existe. Par
exemple cette autre classe :
class autre {
double d;
public:
autre(double dd) { d = dd; }
};
autre a;
provoque une erreur de compilation Error : Could not find a match for
'autre::autre()', impossible de trouver une occurrence de 'autre::autre()'.
Ce mécanisme est très utile, comme on le verra sur notre exemple de la liste
chaînée ci-après.
class autre {
double d;
public:
autre(double dd = 0) { d = dd; }
152
};
autre au(1.2);
mais elle est plus claire. En outre, il est possible d’initialiser des tableaux de
cette façon :
153
il est parfaitement légitime de l’appeler sous cette forme :
f( exemple(1, 2) );
f( exemple() );
Constructeurs de copie
Les constructeurs d’une classe donnée classe peuvent avoir n’importe
quoi comme arguments, sauf des données de type classe. Ils peuvent avoir
des pointeurs *classe comme arguments, ainsi que des références &classe.
Cependant, dans ce dernier cas, le constructeur ne doit avoir qu’un seul
argument &classe, et les autres arguments, s’il y en a, doivent avoir une
valeur par défaut. Ce constructeur est alors appelé constructeur de copie. Il
sert lors d’affectations du genre :
class classexmpl {
// champs ...
154
public :
classexmpl(); // constructeur par défaut
classexmpl(int i); // un autre constructeur
classexmpl(classexmpl& c); // constructeur de copie
// méthodes...
};
c2 = c1;
l’opérateur d’affectation n’est appelé qu’une fois (la seconde), tandis que
c’est le constructeur de copie qui est appelé la première fois. Si ces deux
appels n’ont pas été différenciés jusqu’alors, c’est que par défaut ils
provoquent le même effet ; il n’en est pas nécessairement ainsi dans des
classes définies par un programmeur.
155
est réalisée, malgré les apparences, sur l’argument ex, mais le constructeur
de copie de nouveau ; ici aussi les effets peuvent être différents.
Destructeurs
Les destructeurs ont le rôle inverse des constructeurs. Ils sont appelés
lorsqu’une instance de classe sort de la visibilité courante. Par exemple, lors
de l’appel de la fonction g(autre au), sous la forme g(1), nous avons dit que
le constructeur autre::autre(double) était appelé pour la variable
automatique au, argument de g. À la fin de la fonction g, le destructeur
afférent est appelé.
class troisieme {
char *tampon;
unsigned taille;
public :
troisieme() { taille = 0; tampon = 0; }
// constructeur par défaut
troisieme(int t)
{ tampon = new char[taille = t]; }
// ce constructeur prend une place dans le tas
~troisieme() { delete tampon; } // destructeur
// ...
};
156
mémoire prise, il peut aussi avoir à fermer des fichiers ouverts, à détruire
des éléments provisoires, etc. Le destructeur standard ne fait rien.
Il est possible de créer un tableau avec new, mais dans ce cas c’est le
constructeur par défaut qui est obligatoirement appelé ; il n’y a pas de
moyen d’en préciser un autre (contrairement aux tableaux statiques qui
peuvent être initialisés par un constructeur à argument unique).
157
double *pd = new double(3.1416);
long l = 1000, *pl = new long(l);
qui initialise la case mémoire après sa création (si elle a été réellement
créée ; si la place manque, rien n’est fait).
Pour ce qui est de l’instruction delete, il n’y a pas le choix : chaque classe
ayant un seul destructeur (éventuellement implicite), c’est celui-là qui est
appelé avant de supprimer la place mémoire. Précisons toutefois un
problème particulier aux tableaux. Si l’on écrit ceci :
Dans ce cas, le destructeur est appelé dix fois pour les dix éléments du
tableau.
On notera que pour les types simples, dont le destructeur ne fait rien, il
n’est pas nécessaire de procéder ainsi. Nos écritures des chapitres
précédents étaient donc correctes.
158
static char tampon[sizeof(exemple)];
exemple *pex = (exemple*) tampon; // pas de place occupée
dans le tas
Ici la variable pointée par pex est donc logée dans le tampon, et non dans
le tas ; on suppose de plus qu’il n’est pas nécessaire d’appeler un
constructeur (ce qui n’est guère prudent, mais peut marcher si la classe
utilise le constructeur par défaut standard).
Comme pex n’est pas dans le tas, il ne faut surtout pas appeler delete avec
pex, car on risquerait des ennuis. Pour appeler le destructeur, il faut donc un
appel explicite, avec le nom complet :
pex->exemple::~exemple();
Il est assez facile de voir qu’une telle classe ne doit pas avoir de
constructeur par défaut. En effet, le noeud ne « vit » que pour conserver
l’information element ; il n’a aucun sens par lui-même. Par exemple créer un
tableau de noeuds est un non-sens, puisqu’on définit cette classe pour servir
dans une liste chaînée.
class noeud {
noeud *suivt; // le suivant dans la liste
159
element elm; // information contenue
public :
noeud(element, noeud* = 0); // constructeur
noeud(noeud& n) { elm = n.elm; } // constructeur
de copie
noeud *suivant(void) { return suivt; }
element &contenu(void) { return elm; }
noeud* supprime_svt(void);
noeud* insere(element);
};
noeud* noeud::supprime_svt(void)
// supprime le noeud suivant et renvoie un pointeur
// sur le nouveau suivant
{
if (!suivt) return 0; // pas de suivant
noeud *s = suivt;
suivt = suivt->suivt;
delete s;
return suivt;
}
160
On notera à quel point notre constructeur à deux paramètres simplifie la
fonction d’insertion. Ces deux méthodes ont d’ailleurs été déclarées en
ligne.
Exercice 6.2 Écrire une fonction qui crée une liste chaînée
complète à partir d’un tableau d’éléments element. La
fonction renverra un pointeur sur le premier noeud de
la liste (racine). L’ordre des éléments de la liste devra
Voir solution
être le même que dans le tableau. Écrire une seconde
fonction identique, sauf que l’ordre de la liste devra
être l’inverse de celui du tableau.
161
while ( (nombre-- > 0) & & (new
noeud(*tab++, racine)) );
return racine;
}
Ici aussi la liste est incomplète s’il n’y a pas assez de place en mémoire,
mais ce sont les derniers éléments qui sont insérés.
On notera que ces deux fonctions n’utilisent pas les parties privées de la
classe noeud. Il n’est donc pas nécessaire d’en faire des méthodes. Cela ne
serait guère pratique, puisque pour appeler une méthode il faut une
instance de la classe. Ici, il suffit d’écrire :
class init {
// ...
public :
init(long l);
~init();
} globale(100);
init::init(long l)
// fonction exécutée avant le commencement de main
{
// ...
}
162
init::~init()
// fonction exécutée après la fin du programme
{
// ...
}
Quant aux membres statiques des classes, ils ne sont pas initialisés par
un constructeur : leur valeur est mise à zéro, c’est tout, ce qui constitue
généralement une erreur, et quelque chose à éviter si ce membre est une
instance de classe. Dans ce cas, il existe un mécanisme spécifique, l’héritage
(voir chapitre 8) qui permet à une classe de contenir des parties instances
d’autres classes. Une autre méthode consiste à utiliser des pointeurs
Initialisations multiples
Il est parfaitement permis à une classe d’avoir un ou plusieurs membres
instances d’autres classes. En utilisant toujours les classes d’exemples du
début, voici une classe contenant des membres instances :
class multiple {
exemple ex;
autre au;
public :
multiple() {} // constructeur par défaut
163
// ...
};
multiple::multiple()
{
ex.exemple::exemple(); // incorrect
au.autre::autre(0); // idem
}
Les constructeurs sont appelés dans l’ordre de leur écriture (donc ici
d’abord exemple::exemple(), puis autre::autre(0)).
class multiple {
exemple ex;
autre au;
public :
multiple() : ex(), au(0) {}
multiple(double d) : ex(), au(d) {}
multiple(double d, int i, char c) : ex(i, c),
au(d) {}
multiple(autre& a, exemple& e) : ex(e), au(a)
{}
multiple(multiple& m) : ex(m.ex), au(m.au) {}
// ...
};
164
Le dernier constructeur est un constructeur de copie ; comme celui qui le
précède, il appelle les constructeurs de copie des classes exemple et autre (qui
rappelons-le sont toujours définis).
Tous les types ont un constructeur de copie, pas seulement les classes.
De ce fait, même une classe très ordinaire peut initialiser ses membres de
cette façon :
class ordinaire {
double dd;
int ii;
public :
ordinaire(int i, double d) : ii(i), dd(d) {}
// ...
};
class fautive {
int& i;
public:
fautive() {}; // NON, pas d’init. de la référence
};
165
Il faut dans ce cas écrire obligatoirement une initialisation utilisant le
constructeur de copie, et aucun autre. Par exemple l’écriture suivante pour
le constructeur de fautive :
fautive() : i(22) {}
fautive::fautive() : i(j) { }
fautive::fautive(int k) : i(k) { };
Une classe peut aussi contenir des pointeurs sur des instances d’autres
classes. Dans ce cas, il n’y a aucune difficulté particulière :
class multiple {
exemple *pex;
autre *pau;
public :
multiple()
{ pex = new exemple; pau = new autre(0); }
multiple(autre& a, exemple& e)
{ pex = new exemple(e); pau = new
autre(a);}
multiple(multiple& m) {
pex = new exemple(*m.pex);
166
pau = new autre (*m.pau); }
~multiple() { delete pau; delete pex; }
// ...
};
167
Exercice 6.3 Écrire une classe chaine, contenant une chaîne de
caractères de longueur arbitraire, et telle que la
fonction suivante soit correcte (syntaxiquement
parlant, car son effet est sans intérêt), ainsi que le
programme qui suit :
main()
Voir solution {
f("suite\n");
f(" milieu ", "bord");
return 0;
}
168
constructeur par défaut et un de copie. Un destructeur pour libérer la place
prise par la chaîne est également nécessaire. Enfin, on doit avoir une
méthode longueur donnant la longueur de la chaîne, une autre ecrire qui
l’écrit à l’écran, et une troisième ajoute qui ajoute une seconde chaîne à la
fin et renvoie une référence à la chaîne courante, afin que les appels à cette
méthode puissent être chaînés comme on le voit dans f.
// utilisation de chaînes
#include <iostr.h>
#include <string.h>
#include <stdlib.h>
class chaine {
char* p; // chaîne
unsigned lg; // longueur de la chaîne
public :
chaine() { p = 0; lg = 0; }
chaine(char *s)
{
p = strdup(s);
if (p) lg = strlen(s); else lg = 0;
}
chaine(chaine& c) // constucteur de copie
{
if (c.p) p = strdup(c.p); else p = 0;
if (p) lg = c.lg; else lg = 0;
}
~chaine() { delete p; }
chaine& chaine::ajoute(chaine& c)
// ajoute la chaîne c à la fin
{
if (!c.lg) return *this; // rien à ajouter
if (lg) {
169
char *cp = c.p;
char *pp = (char*) realloc(p, lg +c.lg
+1);
if (pp) { // place mémoire obtenue
p = strcat(pp, cp);
lg += c.lg;
}
}
else { // chaîne vide au début
p = strdup(c.p);
if (p) lg = c.lg;
}
return *this;
}
Polymorphisme
Nous avons déjà vu un premier avantage de la programmation orientée
objet : la protection des données, fournie par la déclaration de membres
privés dans une classe.
170
accéder à toute la liste ; en quelque sorte, chaque noeud est racine de la liste
entière. Voici un nouveau type noeud répondant à ces attentes :
class noeud {
noeud *suivt, *prec;
element elm;
public :
~noeud() // destructeur
{ prec->suivt = suivt; suivt->prec = prec;
}
element& contenu(void) { return elm; }
noeud* suivant(void) { return suivt; }
noeud* precedent(void) { return prec; }
171
L’utilisation d’une telle liste est cependant un peu fastidieuse,
notamment lorsqu’il faut la créer par petits morceaux, ou la détruire de
même. Nous avons vu qu’on pouvait définir des fonctions pour cela. Mais en
fait, ce que nous voulons, c’est un type de liste, qui permette des opérations
d’écriture, et dans lequel on puisse avancer ou reculer (c’est-à-dire modifier
l’élément pris comme base de référence), ainsi qu’insérer ou supprimer des
éléments. Voici un exemple de programme que l’on souhaite voir
fonctionner avec une telle liste :
main()
{
element table[9] = { 1, 2, 3, 4, 5, 6, 7, 8, 9
};
liste ls(9, table); // 9 éléments dans la liste
ls.affiche(); // affiche tout
ls.avance(5); // avance de 5 éléments
ls.affiche();
ls.supprime(6); // supprime 6 éléments
ls.affiche();
ls.insere(18); // ajouter l’élément 18
ls.insere(17);
ls.recule(); // reculer d’un élément
ls.affiche();
return 0;
}
1 2 3 4 5 6 7 8 9
6 7 8 9 1 2 3 4 5
3 4 5
5 17 18 3 4
Il n’est pas très difficile de créer une classe liste répondant à ces
attentes. Voici une possibilité :
class liste {
noeud* courant;
int nombre;
172
public :
element& valeur(void)
{ if (courant) return courant->contenu();
}
unsigned nombre_elt(void) { return nombre; }
173
Écrivez les méthodes qui ne sont pas en ligne ci-
dessus : le constructeur à deux arguments, le
destructeur, avance, affiche, insere et supprime. La
méthode insere renvoie 0 s’il n’y a plus de place en
mémoire.
Voir solution
liste::~liste()
// destructeur de la liste : supprime tous les noeuds
{
if (!nombre) return;
while (--nombre) delete courant->suivant();
delete courant;
}
174
{
if (combien > nombre) combien = nombre;
while (combien--) {
cout << '\t' << courant->contenu();
courant = courant->suivant();
}
cout << '\n';
}
void liste::supprime(int n)
// supprimer le nombre indiqué d'éléments de la liste
{
if (n >= nombre) n = nombre;
while (n-- > 0) {
courant = courant->suivant();
delete courant->precedent();
nombre--;
}
if (nombre == 0) courant = 0;
}
175
Nous avons donc rempli notre contrat : le lecteur pourra essayer cette
classe avec le programme donné, et constater que l’on a bien le résultat
souhaité.
Voir solution
liste::liste(liste& ls)
{ // duplique toute la liste
courant = 0; nombre = 0;
if (!ls.nombre) return;
noeud *lscourant0 = ls.courant, *np = 0;
while ((np = new noeud(ls.valeur(), courant
=np))
&& (++nombre < ls.nombre) )
ls.avance();
ls.courant = lscourant0; // rétablir initial
if (np) courant = np->suivant();
}
liste::liste(liste& ls)
// duplique toute la liste
courant = 0; nombre = ls.nombre;
for (int n = nombre; n; n--) {
ls.avance();
176
courant = new noeud(ls.valeur(), courant);
}
}
Par contre, avec pour éléments des entiers long, cela semble peu
rentable. Imaginons donc qu’ayant écrit tout un programme utilisant le type
liste, vous vous apercevez que celui-ci est trop lent et prend trop de place
en mémoire.
public :
liste() { nombre = 0; courant = tab = 0; }
177
liste(int n, const element*); // constructeur avec
table
~liste();
element& valeur(void)
{ if (courant) return *courant; }
unsigned nombre_elt(void) { return nombre; }
int insere(element);
void supprime(int n = 1);
};
Ici tab est un tableau d’éléments placé dans le tas ; courant est un
pointeur sur l’un de ces éléments (on aurait pu utiliser aussi un entier
servant d’index). Les méthodes ont le même effet, mais évidemment une
implantation différente. Le type noeud n’est pas utilisé puisqu’il ne s’agit pas
d’une vraie liste chaînée.
Voir solution
inline liste::~liste()
// destructeur de la liste : supprime la table
{
delete tab;
}
178
courant += combien % nombre;
combien = courant-tab; // index courant
if (combien < 0) courant += nombre; else
if (combien >= nombre) courant -= nombre;
}
}
179
void liste::supprime(int n)
// supprimer le nombre indiqué d'éléments de la liste
{
if (n <= 0) return;
if (n >= nombre) {
nombre = 0; courant = tab = 0; return; }
nombre -= n; // nouvelle taille
int i = courant -tab,
queue = nombre -i; // ce qui reste en fin
unsigned size = sizeof(element);
if (queue > 0) // ramener les derniers
memmove(courant, courant+n, queue*size);
else
if (queue < 0) // supprimer les premiers en trop
memmove(tab, tab-queue, nombre*size);
tab = (element*) realloc(tab, nombre*size);
// plus petit donc toujours ok en principe
if (queue > 0) courant = tab +i;
else courant = tab;
}
Retour au texte.
180
Voir solution faire ?
liste::liste(liste& ls)
{
nombre = 0; courant = tab = 0;
if (!ls.nombre) return;
if (!(tab = new element[ls.nombre])) return;
memmove(tab, ls.tab,
(nombre =ls.nombre)*sizeof(element));
courant = tab + (ls.courant -ls.tab);
}
Que peut-on conclure de cet exemple ? Primo, nous avons constaté une
encapsulation des données insuffisantes avec le type noeud, obligeant à faire
des manipulations compliquées au programme ; pour éviter cela, nous
avons créé une couche supplémentaire de logiciel, avec une classe liste.
181
Nous verrons au chapitre 10 comment utiliser plusieurs fichiers
différents pour compléter à la fois l’encapsulation et le polymorphisme (et
notamment éviter des recompilations). Nous verrons aussi ultérieurement
un autre aspect du polymorphisme : les gabarits.
Champs de bits
L’unité usuelle de compte en informatique est l’octet, car les ordinateurs
manipulent les données par paquets de huit bits en général, et souvent de
seize ou plus. Cependant, dans certains cas, on doit accéder à certains bits
individuellement dans une donnée.
Une telle structure est semblable à toute autre, mais derrière le nom des
champs du type int ou unsigned, on précise un nombre de 1 à 16 indiquant la
taille en bits du champ. Voici un exemple simple :
struct champbits {
unsigned basbas : 4;
unsigned bashaut : 4;
unsigned hautbas : 4;
182
int hauthaut : 4;
};
Cette structure occupe seize bits (4 fois 4) en mémoire, soit la taille d’un
entier usuel. Notons qu’on aurait pu la déclarer plus brièvement ainsi :
struct champbits {
unsigned basbas : 4, bashaut : 4, hautbas : 4;
int hauthaut : 4;
};
Les champs de bits sont utilisés comme des entiers ordinaires ; lors
d’une affectation, les bits excédentaires sont supprimés, les bits manquants
sont nuls. Par exemple, si l’on écrit :
champbits cb;
int i = 30 // 30 == 0x1E
cb.bashaut = i; // met 0xE == 14 dans cb.bashaut
i = cb.bashaut; // maintenant i == 14
Si vous écrivez :
int i = -7890;
champbits cb;
cb = *(champbits*)&i; // recopie i dans cb
183
quatre octets occupés par un float (des bits de poids faibles aux forts) : 23
bits de mantisse, 8 bits d’exposant (biaisé par 127), et un bit de signe :
L’exposant est biaisé, ce qui explique qu’il faille retirer 127. Quant à la
mantisse, elle est ici en deux parties car on ne peut avoir de champs de bits
de plus de seize bits. En outre, le bit le plus élevé (le vingt-quatrième), qui
est toujours à 1, n’est pas stocké dans le nombre, il faut le rajouter
explicitement (d’où le 0x800000). La valeur du nombre est alors :
Nous avons vu précédemment que tous les bits des champs de bits
étaient placés les uns derrière les autres, du moins significatif (déclaré en
premier) au plus significatif. Il arrive que l’on ne souhaite pas utiliser
certains bits. Dans ce cas, il suffit de ne pas nommer le champ
correspondant. Par exemple, les microprocesseurs de la famille 8086 ont un
mot d’état de seize bits, dont seuls quelques-uns ont un sens. Ainsi, le bit ZF
est à 1 si la dernière opération a produit un résultat nul, à 0 sinon. Voici une
structure reproduisant cette configuration :
struct flags {
unsigned CF : 1; // retenue
184
unsigned : 1;
unsigned PF : 1; // parité
unsigned : 1;
unsigned AF : 1; // retenue auxiliaire
unsigned : 1;
unsigned ZF : 1; // zéro
unsigned SF : 1; // signe
unsigned TF : 1; // trap
unsigned IF : 1; // autorisation d’interruption
unsigned DF : 1; // direction
unsigned OF : 1; // débordement
unsigned : 4;
}
Les bits non utilisés, au nombre de sept, figurent sans nom dans cette
structure.
On notera que les champs de bits n’ont pas d’adresse mémoire (il est
illégal d’utiliser l’opérateur d’adressage & avec eux), puisqu’ils ne se trouvent
pas nécessairement sur une limite d’octet. En outre le langage ne permet
pas de les organiser en tableaux.
Les champs de bits peuvent procurer des facilités dans certains cas ; ils
sont surtout utiles dans des applications très techniques faisant intervenir le
matériel ou les périphériques.
Une structure peut avoir à la fois des champs de bits et des champs
normaux, ainsi que des méthodes. Une classe et une union (ci-après)
peuvent aussi en avoir.
Unions
Les structures ont pour taille approximativement la somme des tailles de
leurs composants. Leur taille peut donc devenir très grande. Or il arrive que
185
certains champs ne soient pas utilisés lorsque d’autres le sont, parce qu’ils
sont mutuellement incompatibles.
union longgroupe {
long l;
unsigned mots[2];
unsigned char octets[4];
}
n’occupe que quatre octets en mémoire (on suppose ici que c’est la taille des
entiers long, mais cela peut être différent sur votre ordinateur). De la sorte,
si l’on écrit :
longgroupe lg;
lg.l = 100000;
comme 100000 = 0x186A0, on trouvera dans lg.mots les valeurs { 0x86A0, 0x1 }
(soit 34464 et 1) (sur PC, le mots de poids faibles sont placés en premier) et
dans lg.octets { 0xA0, 0x86, 0x1, 0x0 } (soit 160, 134, 1 et 0). On a ainsi un
moyen simple de décomposer un entier long, ou n’importe quoi d’autre, en
octets.
longgroupe lg = { 100000 };
186
récupérer de la place, si l’on ne dispose pas d’un moyen simple pour savoir
quelle est le champ qui correspond à une information valable.
class noms {
char oriental; // 1 = oriental, 0 = occidental
union {
char nomorient[3][10];
char nomoccident[2][15];
};
public :
noms(char *nom, char *prenom1, char *prenom2 =
0)
{ oriental = (prenom2 != 0);
if (oriental) {
strncpy(nomorient[0], nom, 10);
strncpy(nomorient[1], prenom1, 10);
strncpy(nomorient[2], prenom2, 10);
}
else {
strncpy(nomoccident[0], nom, 15);
strncpy(nomoccident[1], prenom1, 15);
}
}
char *nom(void)
{ if (oriental) return nomorient[0];
else return nomoccident[0];
}
187
char *prenom1(void)
{ if (oriental) return nomorient[1];
else return nomoccident[1]; }
char *prenom2(void)
{ if (oriental) return nomorient[2];
else return ""; }
char *prenomcomplet(void)
{ static char tampon[22];
if (oriental) {
strcpy(tampon, nomorient[1]);
strcat(tampon, "-");
strcat(tampon, nomorient[2]);
return tampon;
}
else return nomoccident[1];
}
};
Voir solution
188
Il suffit d’utiliser une chaîne de caractères normale, plus un pointeur
indiquant l’emplacement du début du prénom ; selon les besoins, on placera
un zéro ou un blanc entre les deux. De même, un autre pointeur indiquera
la séparation entre les deux prénoms orientaux. Ce second séparateur est à
zéro pour un occidental :
class noms {
char chaine[32];
char *separateur; // adresse du zéro ou blanc
char *separateur2; // adresse du zéro ou tiret
public :
noms(char *nom, char *prenom1, char *prenom2 =
0)
{
int i = (prenom2 ? 10 : 15);
strncpy(chaine, nom, i);
separateur = strchr(chaine, 0);
separateur2 = separateur+1;
strncpy(separateur2, prenom1, i);
if (prenom2) {
separateur2 = strchr(separateur2, 0);
strncpy(separateur2+1, prenom2, i);
}
else separateur2 = 0;
}
char *nom(void)
{ *separateur = 0; return chaine; }
char *prenom1(void)
{ if (separateur2) *separateur2 = 0;
return separateur+1; }
char *prenom2(void)
{ if (separateur2) return separateur2+1;
else return ""; }
char *prenomcomplet(void)
{ if (separateur2) *separateur2 = '-';
return 1+separateur; }
char *nomcomplet(void)
{ *separateur = ' ';
prenomcomplet();
return chaine; }
};
Cette classe n’occupe que cinq octets de plus que l’autre, et la facilité des
opérations est un gain de temps important. On notera d’ailleurs que l’on
189
n’est plus obligé de tronquer les chaînes : si un nom fait 20 caractères et le
prénom 8, on peut les placer ensemble ; une meilleure implantation du
constructeur est donc possible (nouvel exercice...).
Cet exemple illustre le fait que les unions sont nettement moins
dangereuses lorsqu’elles sont membres de classes contenant un indicateur
qui les contrôle.
Les unions peuvent avoir des méthodes et des constructeurs, mais tous
leurs membres sont obligatoirement publics ; en outre, Turbo C++ n’accepte
pas que les unions anonymes aient des méthodes.
Sauf pour des décompositions comme longgroupe l’intérêt des unions est
assez faible en C++, d’autant que, contrairement à ce qui se passe par
exemple en Pascal, les parties qui se recouvrent dans une union sont
nécessairement réduites à un seul élément, et que l’union elle-même est
tout entière en mode de recouvrement, ce qui oblige à utiliser des structures
imbriquées compliquées pour faire recouvrir des données différentes.
class noms {
char type; // 0 = occ, 1 = orient, 2 = américain.
union {
char nomoccident[2][15];
190
char nomorient[3][10];
struct { char nom[15];
char prenom[14];
char initiale;
} nomamericain;
};
public :
// ...
};
Observer que l’étiquette nomamericain est ici obligatoire, comme on l’a dit
dans le texte. Il va sans dire que la gestion d’un tel ensemble nécessite
quelques acrobaties étonnantes. Donnons quand même un exemple de
méthode, une qui écrit le nom complet à l’écran :
void noms::ecrire(void)
{
switch (type) {
case 0: cout << nomoccident[1] << ' '
<< nomoccident[0]; break;
case 1: cout << nomorient[0] << ' '
<< nomorient[1] << '-' <<
nomorient[2];
break;
case 2: cout << nomamericain.prenom << ' '
<< nomamericain.initiale <<
". "
<< nomamericain.nom;
}
}
7/ AMIES ET OPERATEURS
Nous avons vu dans le chapitre précédent ce qu’était une classe et les
bases de sa manipulation. Un des principaux atouts du langage C++ résulte
de son grand nombre d’opérateurs, mais aussi de sa capacité à redéfinir ces
opérateurs, ce qui permet des écritures particulièrement simples et
191
agréables. Pour cela, le mécanisme de fonctions et classes « amies » est
pratiquement indispensable.
Amies
Nous avons vu qu’une classe avait généralement des membres privés, et
que ceux-ci n’étaient pas accessibles par des fonctions non membres. Cette
restriction peut sembler lourde, mais elle est à la base même de la
protection des données qui fait une grande partie de la puissance de la
programmation par objets en général, et de C++ en particulier.
class exemple {
// parties privées...
public :
static exemple* f(void);
// ...
};
exemple *p = exemple::f();
192
Fonctions amies
Une fonction est l’amie (friend) d’une classe lorsqu’elle est autorisée à
adresser directement les membres privés de cette classe. Pour la déclarer
ainsi, il faut donner, à l’intérieur de la classe, la déclaration complète de la
fonction précédée du mot clé friend. Voici un exemple simple :
class exemple {
int i, j;
public:
exemple() { i = 0; j = 0; }
friend exemple inverse(exemple);
};
Une fonction peut être amie d’autant de classes que nécessaire, mais
évidemment cela n’est utile que lorsque la fonction utilise une instance de la
classe, et plus précisément modifie un membre privé de la classe (car en
général il existe des fonctions membres en ligne permettant de lire ces
membres privés ou une interprétation de ceux-ci).
193
déclaration d’une classe sans sa définition), il n’est pas possible de lui
ajouter des fonctions amies. Cela n’est pas une restriction du langage, mais
au contraire un moyen sûr et efficace de protéger des données. De ce fait,
avant de « verrouiller » une classe, on prendra soin de fournir tous les
moyens d’accès raisonnables (en lecture notamment) aux champs utiles,
afin de permettre la création de fonctions non amies utilisant cette classe.
Lorsque cette précaution a été prise, il n’est plus besoin d’une fonction
amie, en dépit des apparences. Par exemple, la librairie <complex.h> fournit
une classe complex (qui est fondamentalement formée de deux nombres à
virgule flottante nommés partie réelle et partie imaginaire) et un ensemble
de fonctions la manipulant ; cependant, les concepteurs de la librairie n’ont
pas implanté une opération importante sur les nombres complexes,
nommée conjugaison, qui consiste simplement à changer le signe de la
partie imaginaire. Est-ce à dire qu’il faut modifier <complex.h> pour déclarer
« amie » la fonction ayant cet effet ? Nullement, car on dispose de deux
fonctions amies real et imag donnant les parties réelle et imaginaire d’un
complexe, ainsi que du constructeur complex(double, double) qui crée un
complexe à partir de ses deux parties. De ce fait, il suffit d’écrire une
fonction normale :
Cette fonction n’est pas amie de la classe complex, mais elle n’accède qu’à
des parties publiques de celle-ci (le constructeur et les deux fonctions amies
real et imag), il n’y a donc pas de problème. On pourrait bien sûr s’inquiéter :
les trois appels de fonction (real, imag et complex) ne vont-ils pas grever le
temps d’exécution de cette opération pourtant élémentaire ? Nullement, car
ces trois fonctions très simples aussi sont écrites en ligne. De ce fait,
l’écriture c1 = conjug(c2) ; ne provoquera aucun appel de fonction, puisque
conjug est aussi en ligne.
Méthodes amies
194
On souhaite parfois qu’une méthode d’une classe puisse accéder aux
parties privées d’une autre classe. Pour cela, il suffit de déclarer la méthode
friend également, en utilisant son nom complet (nom de classe suivi de :: et
du nom de la méthode). Par exemple :
class autre {
// ...
void combine(exemple);
};
class exemple {
// ...parties privées
public :
friend void autre::combine(exemple);
};
class exemple {
// ...parties privées
public :
friend void combine(autre&, exemple);
};
class autre {
// ...
friend void combine(autre&, exemple);
};
{
// accède aux membres des deux arguments
}
195
mais la syntaxe d’appel est alors différente : combine(au,ex) contre
au.combine(ex).
Classes amies
Lorsqu’on souhaite que tous les membres d’une classe puissent accéder
aux parties privées d’une autre classe, on peut déclarer « amie » une classe
entière :
class exemple {
// parties privées...
public :
friend autre;
// ...
};
class autre {
// ...
};
Les membres de la classe autre peuvent tous modifier les parties privées
des instances de exemple. Noter la déclaration de autre avant celle de exemple,
obligatoire (sinon on obtient Error : Undefined symbol 'autre', symbole
'autre' non défini). Pour l’éviter, on peut éventuellement changer l’ordre de
définition, mais il suffit en fait de préciser le sélecteur class derrière friend :
class exemple {
// parties privées...
public :
friend class autre;
// ...
};
class autre {
// ...
};
196
autresoit amie de exemple et une de exemple amie de autre, il faut déclarer les
deux classes entièrement amies l’une de l’autre.
Redéfinition d’opérateurs
Lorsqu’on crée une nouvelle classe, il se peut que certaines actions
correspondent intuitivement à un concept d’opération.
Imaginons par exemple une classe fraction qui gère des nombres
fractionnaires non sous leur forme à virgule flottante, mais sous leur forme
plus mathématique de quotient de deux nombres entiers :
class fraction {
long num, den; // numérateur, dénominateur
public :
fraction(long numer, long denom = 1)
{ num = numer; den = denom; }
};
197
fraction f1(2, 5), f2 = 4; // f1 = 2/5, f2 = 4/1
f3 = somme(f1, f2); // f3 = 4 +2/5 = 22/5
f3 = somme(f3, -6); // f3 = 22/5 -6 = -8/5
class fraction {
long num, den; // numérateur, dénominateur
public :
fraction(long numer, long denom = 1)
{ num = numer; den = denom; }
198
f2.den*f1.den);
}
exécutée dans ce cas), mais il ne faut pas écrire 2/5 qui donnerait une
division entière normale (soit 0 ici).
ce qui donne le même résultat mais est évidemment peu rentable. Cela
indique toutefois clairement dans quel ordre les opérations sont exécutées.
199
Solution de l’exercice 7.1
Aucune fonction n’est appelée, puisque les opérateurs et le constructeur
sont écrits en ligne. Le compilateur développe donc l’expression sous la
forme :
fraction f;
f.num = (1*(1*5)+(2*1)*1)*(3*1)-(1*8)*((1*5)*1);
f.den = (3*1)*((1*5)*1);
Les opérateurs redéfinis peuvent aussi être écrits comme des fonctions
membres :
class fraction {
// ...
fraction operator+(fraction f)
{ return fraction(num*f.den + den*f.num,
den*f.den); }
200
On obtient 8/16 et ajoutant 1/4 à lui-même, contre 2/4 en multipliant
par 2. Dans les deux cas, le résultat est égal à 1/2, mais cela peut poser des
problèmes par la suite, car les nombres ont des numérateurs et
dénominateurs qui augmentent très vite.
Pour régler le problème, il faut simplifier les fractions. Il faut pour cela
écrire une fonction calculant le PGCD (Plus Grand Commun Diviseur) de
deux nombres, en utilisant l’algorithme d’Euclide. Voici une solution :
class fraction {
int num; int den;
fraction& reduire()
{ int d = pgcd(num, den);
num /= d; den/= d;
return *this; }
public :
// ....
}
Opérateurs unaires
Les opérateurs unaires peuvent également être redéfinis. On peut par
exemple légitimement redéfinir le moins unaire :
201
inline fraction operator-(fraction f)
{
return fraction( -f.num, f.den);
}
Il en résulte que, lorsque le nom d’un opérateur que l’on souhaite définir
n’est pas clairement imposé par le contexte, il convient de réfléchir
soigneusement à celui que l’on choisira, notamment en fonction de la
précédence souhaitée. Ainsi, on pourrait imaginer, sur une classe
numérique comme fraction, d’utiliser l’opérateur ^ pour symboliser
l’exponentiation ( « x à la puissance y » ), comme c’est le cas dans certains
langages de programmation. Ce choix est bien entendu possible, mais pas
très heureux, car la précédence de cet opérateur est assez faible. De ce fait,
une expression comme a + b^c sera interprétée comme (a+b)^c, ce qui n’est
pas très naturel. On préférera dans ce cas définir une méthode, nommée par
exemple pow (abréviation de l’anglais power, puissance), et écrire a + b.pow(c)
qui ne prête pas à erreur.
202
sont symétriques par exemple. Si dans un contexte naturel, a + b est égal à b
+ a, il n’en est pas forcément ainsi pour un opérateur redéfini, et le
compilateur ne le supposera donc pas : la première expression correspond à
operator+(a, b), la seconde à operator+(b, a). Cela peut sembler
anecdotique, mais est très important en pratique, pour des objets comme
les matrices, dont la multiplication n’est pas commutative.
class intptr {
int *p;
public :
....
friend int operator*(exempleptr);
}
203
Toutefois ce genre d’écriture est difficile et risqué, notamment à cause
des problèmes de post- et pré-incrémentation (voir plus loin). Il est
beaucoup plus fréquent de créer des classes équivalant à des tableaux (voir
paragraphe sur l’opérateur []).
Tous les opérateurs sur les pointeurs sont redéfinissables (pour des
classes), même -> et ->*. Notons toutefois que -> doit obligatoirement
renvoyer un pointeur ou une classe.
operator type() dans la classe (ce doit être une méthode obligatoirement).
class fraction {
// ... comme ci-avant
operator double() { return num/ double(den); }
};
204
Les opérateurs de conversion ne peuvent avoir pour arguments que des
classes nouvellement définies, comme on l’a dit au paragraphe précédent.
En conséquence, on ne peut pas créer un opérateur operator fraction(long)
par exemple.
Incrémentation et décrémentation
Les opérateurs d’incrémentation ++ et de décrémentation -- peuvent être
redéfinis comme les autres. Ils posent toutefois un problème particulier car
on ne peut pas distinguer leur application en préfixe et en suffixe. Par
exemple, si l’on a écrit :
fraction operator++(fraction& f)
{
f.num += f.den; return f;
}
// ......
fraction f = 5, g = f++/7;
la valeur de g sera 6/7 et non 5/7 comme attendu. En effet, la façon dont on
a écrit l’opérateur, dont l’argument est d’abord augmenté puis retourné,
signifie qu’il agit comme un pré-incrément. Le langage permet son
utilisation sous les deux formes ++f ou f++, mais pas la définition de deux
opérateurs d’incrémentation, un de pré-incrément, l’autre de post-
incrément.
Voir solution
205
fraction operator++(fraction& f)
{
fraction g = f;
f.num += f.den;
return g;
}
Pour cette raison, il est préférable de ne pas redéfinir ces opérateurs, sauf
en leur donnant un sens tout à fait différent de l’incrémentation, afin
d’éviter toute erreur.
Opérateurs [] et ()
Les crochets sont un opérateur binaire : l’un des arguments est la
variable qui précède les crochets, l’autre celle qui se trouve entre eux. Cet
opérateur est redéfinissable, ce qui permet des écritures « d’imitation de
tableaux » . Par exemple, avec le type liste vu au chapitre précédent, on
peut écrire :
class liste {
noeud* courant;
int nombre;
public :
// ... autres méthodes ...
friend element operator[](liste& l, int i);
};
206
Noter la valeur par défaut pour le second argument. On peut donc
écrire :
liste l;
// .....
element e1 = l[5], e2 = l[];
et e2 est alors la valeur courante dans la liste. Cette notation plus agréable et
naturelle permet de se débarrasser de la fonction membre valeur.
L’appel de fonction, qui comme on le sait se note par des parenthèses (),
est un opérateur assez semblable à [], qui peut être redéfini (pas pour les
fonctions, mais pour les classes). Il a un avantage déterminant sur les
autres, et notamment sur [], c’est qu’on peut placer un nombre quelconque
d’arguments entre les parenthèses : il s’agit donc en fait d’un opérateur « N-
aire » pour toute valeur de N. Cela permet de l’utiliser pour des tableaux
multidimensionnels par exemple.
class matrice {
double *tab; // liste des éléments à la suite
unsigned lgn, col; // nb de lignes et colonnes
publi c:
// ...
double operator()(int i, int j)
{ if ( (i > lgn) || (i < 1) ||
(j > col) || (j < 1) ) return 0;
else return *(tab + (--i)*col + --j);
}
};
207
Cet opérateur étant défini, il suffira donc d’écrire :
matrice M;
// ...
double d = M(1,5);
Noter que les deux opérateurs que nous avons définis dans ce
paragraphe, et qui agissent sur des classes ayant une allure de tableau,
donnent à celles-ci une grande qualité que les tableaux usuels n’ont pas : ils
vérifient leurs arguments afin d’éviter des débordements des limites des
tableaux. Dans nos exemples, les fonctions renvoient zéro lorsque les bornes
sont dépassées, mais on pourrait aussi afficher un message d’erreur, lancer
une exception, etc.
Opérateurs d’affectation
L’affectation =, et ses dérivées +=, *=, etc., sont des opérateurs binaires.
L’affectation est prédéfinie pour toutes les classes, et représente alors une
copie terme à terme des membres de la classe.
class matrice {
double *tab; // liste des éléments à la suite
unsigned lgn, col; // nb de lignes et colonnes
public:
// ...
matrice& operator=(matrice& m)
{
lgn = m.lgn; col = m.col;
tab = new double[lgn*col];
return *this;
}
};
208
En général, on retourne type& comme résultat, afin de permettre des
écritures du type :
matrice m1 = m2;
m3 = m2;
inline matrice::matrice(matrice& m)
// constructeur de copie
{ *this = m; }
209
Dans certains cas, on souhaite rendre impossible une affectation dans
une classe, ou encore une copie par construction. Cependant il n’existe
aucun moyen direct de « dédéfinir » l’affectation ou la construction par
copie, qui sont toujours fournies par défaut lorsqu’on ne les redéfinit pas.
Un moyen indirect consiste à déclarer les méthodes correspondantes, mais
sans en donner de définition (aucune implantation). Dans ce cas, tout appel
de l’une ou l’autre provoquera une erreur d’édition de liens ; c’est la
méthode utilisée pour certaines classes de flots (voir chapitre 9). Une autre
possiblité est de déclarer ces méthodes dans la partie privée d’une classe ;
dans ce cas, le compilateur refusera les appels de copie, mais pas dans les
fonctions amies ni les méthodes.
matrice& operator+=(matrice& m)
{ return *this = *this + m; }
Noter que, comme dans ce dernier cas m1 a été passé par valeur et non
par référence comme m2, un constructeur de copie est appelé au moment de
l’addition, et c’est sur cette copie que l’on ajoute le deuxième argument (voir
la fin du chapitre).
210
Nous avons dit au chapitre 3 que new et delete étaient des opérateurs
unaires. Cependant, étant donné leur usage un peu particulier, ils possèdent
leurs règles propres pour la redéfinition.
L’idée est alors de ranger tous ces petits entiers dans une même table,
afin qu’ils soient compactés au maximum. Une table de bits auxiliaire
indiquera simplement l’emplacement des éléments libres de la table. Pour
cela, nous allons redéfinir les opérateurs new et delete sur un type element
formellement identique à un entier. Pour ne pas avoir à réécrire tous les
opérateurs sur les entiers, nous définissons aussi un opérateur de
changement de type de element vers int et un constructeur de int vers
element. Voici la classe obtenue :
#include <alloc.h>
#include <mem.h>
class element {
int i;
static element* table; // table d'alloc. mémoire
static char* libres; // indicat. de blocs libres
public :
static unsigned tailletable; // taille de table
element() { i = 0; }
element(int j) { i = j; }
operator int() { return i; }
void* operator new(unsigned);
211
void operator delete(void *, unsigned);
};
212
main()
{
element *tab[10];
for (int i = 0; i < 10; i++)
tab[i] = new element(i+10); // 10 allocations
*tab[1] = *tab[5] + (*tab[6])/(*tab[0]); //
par exemple
for (i = 0; i < 10; i++)
delete tab[i]; // autant de libérations
return 0;
}
indique la taille de l’élément à allouer. On pourrait penser ici que cette taille
est forcément égale à 2 (taille de element), mais il n’en est rien car, comme
on le verra au chapitre 8, des classes peuvent avoir hérité de element (avec
l’opérateur new associé), et être plus grandes. Dans ce cas, on ne peut les
mettre dans notre table, c’est pourquoi lorsque taille est plus grand que
sizeof(element), l’opérateur renvoie un bloc normal de la mémoire
dynamique dans notre exemple.
213
D’autre part, la syntaxe d’appel est assez curieuse, comme on le sait déjà,
puisqu’elle reste identique à celle de l’opérateur prédéfini. Noter en
particulier que bien que new renvoie un pointeur void*, c’est un element* qui
est reçu en fait par tab[i] dans notre exemple, ainsi qu’on peut
légitimement l’espérer.
éléments, et un pointeur d’octets libres, qui désigne une liste de bits mis à 1
si l’emplacement correspondant est libre, à 0 sinon. Ces deux membres sont
privés et ne sont utilisés que par new et delete. Le troisième membre statique
est un entier qui indique la taille de la table à allouer, divisée par huit. La
valeur par défaut est 100 (ce qui permet de placer 800 entiers dans la table),
mais on peut la changer avant le premier appel à new (qui initialise la table)
afin d’avoir plus ou moins de place. En effet, quand la table est pleine, new
renvoie zéro ; il ne peut pas augmenter la taille de la table, car un appel de
realloc changerait l’adresse de celle-ci, et par conséquent ferait perdre
toutes les données de la table pointées par des pointeurs extérieurs (comme
tab[i] dans main).
Pour calculer son résultat, new cherche le premier bit à 1 dans la table de
bits ; pour cela il cherche le premier octet non nul, et le décale autant de fois
que nécessaire pour obtenir un bit à 1 ; on utilise pour cela un masque du
type 0..010..0 (binaire). On met le bit à 0 en soustrayant le masque, et l’on
214
renvoie table + numero, où numero est l’index de la position nouvellement
prise dans la table.
Noter que de tels opérateurs ne s’appliquent pas sur les tableaux. Ainsi,
si l’on écrit :
c’est l’opérateur standard qui est utilisé, puisqu’on ne peut redéfinir les
opérateurs sur les tableaux. Par contre, rien n’empêche d’écrire une classe
simulant un tableau avec un opérateur new pour avoir le même effet.
class exemple {
// ...
void* operator new(unsigned, void* adresse =
0)
{ if (adresse) return adresse;
else return malloc(taille); }
void operator delete(void *p)
{ free(p); }
}
// .....
exemple *exp = new exemple;
char tampon[sizeof(exemple)];
exemple *exp2 = new(&tampon) exemple;
215
Ici nous avons ajouté un second paramètre adresse, qui indique une
adresse où stocker l’objet. Ainsi, si le pointeur exp pointe sur un bloc normal
de la mémoire, puisque dans ce cas on n’a pas précisé adresse (qui a donc la
valeur par défaut 0, d’où appel de malloc), par contre exp2 pointe sur le
tableau tampon. Quel est l’intérêt d’une telle manoeuvre ? Elle permet
d’appeler un constructeur pour un objet placé dans le tableau tampon, ce qui
n’est pas possible autrement. Quel en est le danger ? C’est que le
programmeur appelle delete avec exp2, alors qu’aucun bloc n’a été alloué. Il
faut ici appeler explicitement le destructeur, comme on l’a dit au chapitre 6 :
exp2->exemple::~exemple();
et non delete. Une méthode plus sûre consisterait à placer un champ dans la
classe indiquant si l’objet a été alloué en mémoire dynamique ou dans un
tampon ; ce champ peut être retrouvé par delete qui connaît l’adresse de
l’objet à détruire.
216
unsigned occupes = bloc[MAXINT];
if (occupes + taille >= MAXINT) return 0;
bloc[occupes++] = taille;
bloc[MAXINT] += (3+taille)/2;
return bloc + occupes;
}
217
return f1;
}
fraction f(2/5);
4 = f;
class fraction {
// ......
fraction operator+(fraction f)
{
f.num = num*f.den + den*f.num;
f.den *= den;
return f;
}
}
Entre ces deux comportements, il faut donc choisir. Dans certains cas,
les deux semblent équivalents. À ce moment il est préférable en général
d’utiliser des membres, qui sont plus faciles à écrire, puisqu’on a accès
directement aux champs.
Pour les fonctions qui ne sont pas des opérateurs, on choisit selon la
syntaxe souhaitée. Par exemple, l’inversion d’une matrice est plus agréable
écrite inv(M) que M.inv() : on en fera plutôt une amie. Par contre, l’élévation
à une puissance entière est peut-être plus claire sous la forme M.pow(i) que
pow(M, i) : on en fera un membre.
218
Pour ce qui est du type des arguments et du résultat, il faut choisir entre
une référence et un élément normal. Pour les arguments, il suffit de faire
comme pour toute fonction : si l’argument est petit et a des constructeurs
simples, on peut le passer par valeur. Si par contre la fonction ne modifie
pas l’argument et que celui-ci est gros, ou a des constructeurs compliqués
(exigeant par exemple une allocation de bloc mémoire), utiliser une
référence. Quant au résultat, il est préférable en général de le passer par
valeur. Un résultat référence est en effet dangereux. Cependant, on peut
passer un tel résultat référence lorsque la référence est en fait un des
arguments référence ou pointeur (y compris this s’il y a lieu) : c’est le cas
des affectations, et aussi de << et >> pour les fichiers de sortie et d’entrée
(voir chapitre 9).
On peut aussi renvoyer une référence sur un argument passé par valeur,
parce que le destructeur afférent n’est appelé qu’après la fin complète du
calcul de l’expression courante. Par exemple, si l’on écrit :
class exemple {
// .....
exemple(exemple&); // constructeur de copie
~exemple(); // destructeur
exemple& operator+=(exemple ex)
{ // affectation-addition
// ... additionner...
return *this;
}
};
main()
{
exemple exmpl1, exmpl2;
exemple exmpl3 = exmpl1 + exmpl2;
// ....
}
219
{
exmpl1.exemple::exemple(); // constr. par défaut
exmpl2.exemple::exemple(); // idem
// début de l’addition : création de ex1
ex1.exemple::exemple(exmpl1); // constr. de copie
// passage dans la fonction en ligne operator+
ex1.exemple::operator+=(exmpl2); // addition
// retour du résultat ex1 dans exmpl3
exmpl3.exemple::exemple(ex1); // constr. de copie
// addition terminée
ex1.exemple::~exemple(); // appel du
destructeur
// ......
}
exmpl3.exemple::exemple(operator+(exmpl1,
exempl2));
8/ HERITAGE
220
Nous avons vu comment les classes permettaient la protection des
données en C++. Cette protection peut cependant paraître gênante : si l’on
souhaite faire une modification mineure d’une classe, sans avoir accès au
code de celle-ci, il semble qu’il faille tout réécrire. Il n’en est heureusement
pas ainsi, grâce au mécanisme de l’héritage, dont les applications sont
extrêmement étendues, comme nous allons le voir à présent.
Réutilisation du code
Imaginons qu’on vous a fourni une bibliothèque de formes graphiques
contenant par exemple une classe rectangle ayant l’allure suivante :
class rectangle {
// membres privés
public :
rectangle();
rectangle(int gche, int haut, int drte, int
bas);
~rectangle();
void trace();
void efface();
void valeur(int& gche, int& haut,
int& drte, int& bas);
void change(int gche, int haut,
int drte, int bas);
};
A présent, vous souhaitez créer une classe qui ne se trouve pas dans la
bibliothèque, et représente un rectangle plein (avec une couleur de
remplissage). Il est clair que la plupart des méthodes de rectangle
s’appliquent à notre nouvelle classe, et qu’il faut simplement ajouter un
221
champ indiquant la couleur de remplissage, plus deux méthodes couleur qui
permettent de connaître cette couleur et de la modifier ; il faut aussi
changer trace et efface.
Pour cela, nous allons écrire que notre nouvelle classe rectplein est en
fait un rectangle, plus quelque chose. Cela s’écrit ainsi :
void rectplein::trace(void)
222
{
if (coul) {
int gche, drte, haut bas;
valeur(gche, haut, drte, bas);
remplirrect(gche, haut, drte, bas, coul);
}
rectangle::trace();
}
Méthodes héritées
Tous les membres d’une classe, et notamment les méthodes, sont hérités
par la classe dérivée. Cependant, il y a quelques exceptions. D’abord les
constructeurs et destructeurs ne sont pas hérités, ils ont leur propres règles
(voir ci-après).
223
Le changement de type sur this permet la recopie sous la forme d’une
instance rectangle, soit par l’opérateur d’affectation de celle-ci, s’il existe,
soit par la copie membre à membre par défaut (voir le paragraphe sur le
polymorphisme). On aurait pu aussi écrire un appel explicite à
rectangle::operator=, si l’on savait que celui-ci était défini (le compilateur
refuse en effet cet appel lorsque seule l’affectation par défaut est définie).
Notons que notre exemple est assez inutile, puisqu’il fait exactement ce
que ferait l’opérateur d’affectation par défaut (il n’y a aucune opération
particulière de réalisée).
Constructeurs et destructeurs
Lorsque la classe de base possède un constructeur par défaut, celui-ci est
appelé automatiquement avant l’appel du constructeur de la classe dérivée,
pour initialiser les données membres de base. Il est cependant permis à un
constructeur de la classe dérivée de faire un appel explicite à un
constructeur de la classe de base, afin d’initialiser les membres hérités ; cet
appel se fait de la même façon que pour les membres qui sont des classes
(chapitre 5), c’est-à-dire en plaçant derrière la liste des arguments le
symbole : puis le nom de la classe de base (qui est aussi celui de son
constructeur) avec ses arguments. Voici donc comment définir de manière
naturelle les deux constructeurs de la classe rectplein :
rectplein::rectplein()
{
// appel implicite de rectangle::rectangle();
couleur = 0;
}
224
Le premier constructeur appelle en fait le constructeur par défaut de
rectangle (qui crée un rectangle vide), ce qu’il n’est pas nécessaire de
préciser. Par contre, dans le second, on souhaite utiliser l’autre constructeur
(qui crée un rectangle à partir de ses coordonnées), et il faut alors le
mentionner explicitement.
Il résulte de ces règles que lorsqu’une classe n’a pas de constructeur par
défaut, les classes dérivées doivent obligatoirement appeler un constructeur
de la classe de base.
rectplein::~rectplein()
{
efface();
// appel implicite de rectangle::~rectangle();
}
225
Il existe une troisième catégorie de membres, les membres protégés
(protected). Du point de vue de la classe qui les déclare, ils sont identiques à
des membres privés : on ne peut pas y accéder de l’extérieur. Par contre,
une classe dérivée peut accéder aux membres protégés de sa classe de base
(alors qu’elle ne le peut pas pour les membres privés).
class A {
int a1;
protected :
int a2;
public :
int a3;
};
226
int c1;
protected :
int c2;
public :
int c3;
};
Pour les structures c’est le contraire, puisque l’héritage est par défaut
public. Pour le rendre privé, il suffit de placer le mot private devant le nom
de la classe de base. Dans les deux cas, il n’existe pas de dérivation
« protégée » .
Il arrive que l’on souhaite modifier ces états par défaut pour un membre
ou deux seulement. Dans ce cas, il suffit de renommer les membres hérités
en les plaçant au bon endroit. Voici un exemple :
227
La classe D, pour laquelle on a précisé un héritage privé (inutilement,
c’est la valeur par défaut), diffère de B en ce que le membre hérité a2 est
protégé dans D, alors qu’il était privé dans B. La classe E diffère de C en ce
que le membre hérité a2 est privé pour elle (protégé pour C) et a3 est protégé
pour elle (public pour C).
Bien que l’héritage des classes soit privé par défaut, il est en général
préférable de le déclarer public. Par exemple, notre classe rectplein est mal
déclarée à la section précédente, il faut un héritage public :
Pour ce qui est des membres, le choix entre les différents accès n’est pas
toujours évident. En effet, s’il est clair que l’on doit déclarer publics les
membres (en général seulement des méthodes) que l’on souhaite accessibles
de l’extérieur, il n’est pas forcément facile de choisir entre privés et protégés
pour les autres, puisque cela exige de réfléchir à ce que pourraient être
d’éventuelles classes dérivées de la classe courante. Dans la suite de ce
chapitre, nous nous efforcerons de donner quelques indications sur des
exemples, car il n’y a pas réellement de règle générale en la matière : cela
dépend si l’on souhaite que les classes dérivées connaissent bien le contenu
de leur base ou non.
Méthodes virtuelles
228
Revenons à notre exemple rectangle et rectplein pour mettre en lumière
un problème inhérent à l’héritage. Nous ne connaissons pas le contenu de la
classe rectangle, mais imaginons qu’il s’agisse simplement des quatre
coordonnées h, b, g, d du rectangle. Dans ce cas, la méthode change a
probablement l’allure suivante :
void rectangle::change(//...)
{
rectangle::efface();
// ...
rectangle::trace();
}
229
Une solution simple et expéditive consisterait alors à réécrire la méthode
change ; seulement voilà, on ne peut pas : nous ne savons pas comment les
coordonnées du rectangle sont stockées dans la classe rectangle, et le
saurions-nous, nous ne pourrions pas les modifier puisque les membres
correspondants sont inaccessibles dans la classe rectplein.
class rectangle {
// membres privés
public :
rectangle();
rectangle(int gche, int haut, int drte, int
bas);
virtual ~rectangle();
virtual void trace();
virtual void efface();
230
rectangle::trace ;
si l’on appelle rp.change, où rp est de type rectplein, la
méthode appellera cette fois rectplein::efface et rectplein::trace.
On note que les méthodes change et valeur n’ont pas besoin d’être définies
comme virtuelles, parce que leur code n’a aucune raison d’être modifié par
les classes dérivées de rectangle (en fait, elles ne peuvent pas le modifier,
puisqu’elles n’ont pas accès aux coordonnées du rectangle).
Lorsqu’une méthode est déclarée virtuelle dans une classe, elle l’est
automatiquement pour toutes les classes dérivées ; il n’est donc pas
nécessaire de réécrire virtual devant leur déclaration.
Destructeurs virtuels
Les constructeurs ne peuvent jamais être déclarés virtuels, pour des
raisons évidentes : ils sont spécifiques à une classe et doivent être redéfinis
dans les classes descendantes pour une initialisation correcte. De la même
231
façon, les opérateurs new et delete, lorsqu’ils sont redéfinis pour une classe,
ne sont pas virtuels.
Les fonctions membres statiques ne peuvent pas être virtuelles non plus,
puisqu’elles peuvent être appelées « hors contexte ». C’est là une différence
importante avec les fonctions membres normales.
Compatibilité
Lorsqu’on utilise une variable d’une classe, il est possible de lui affecter
une variable de la classe dérivée :
rectangle r;
rectplein rp; // rectplein dérive de rectangle
r = rp // parfaitement correct
232
Dans ce cas, seuls les champs de la classe rectangle, hérités par rp, sont
recopiés dans r. Plus généralement, une variable d’une classe dérivée peut
être utilisée partout où cela est possible pour la classe de base.
rectangle *pr;
rectplein rp, *prp = &rp;
pr = prp; // autorisé
233
Il n’est pas nécessaire de préciser un changement de type. Par contre, en
sens inverse il faut le faire, et l’opération est alors périlleuse, car
l’ordinateur risque fort de se planter si l’on fait un appel à une méthode de
rectplein avec un pointeur à qui l’on a affecté une valeur rectangle*. On sait
de toute façon que les changements de types sur les pointeurs doivent être
employés avec prudence.
234
Il se peut que dans un même programme on souhaite disposer des deux
types de listes chaînées. Une première solution consiste à donner des noms
différents aux deux classes (et non identiques comme on l’a fait au chapitre
6), et à utiliser l’une ou l’autre selon les besoins.
Cela pose toutefois des problèmes spécifiques. En effet, si l’on veut par
exemple utiliser un tableau de listes (ou plutôt de pointeurs de listes), il
faudra choisir un des deux types, et faire constamment des changements de
types, avec les risques que cela suppose.
Une méthode bien meilleure consiste à faire dériver l’une des classes de
l’autre, comme ceci :
class liste {
noeud* courant;
protected :
int nombre;
public :
liste() { nombre = 0; courant = 0; }
liste(int n, const element*); // consructeur avec
table
virtual ~liste();
public :
235
listetab() { courant = tab = 0; }
listetab(int n, const element*); // consructeur
avec table
~listetab();
236
Solution de l’exercice 8.1
Voici une solution simple :
void liste::affiche(unsigned
combien)
// affiche combien éléments de la liste
// (et nombre au maximum)
{
if (combien > nombre) combien
= nombre;
int reste = nombre -combien;
while (combien--) {
cout << '\t' << valeur();
avance();
}
avance(reste);
cout << '\n';
}
237
Solution de l’exercice 8.2
Outre le destructeur, les méthodes avance, valeur,
insere et supprime sont virtuelles. Il s’agit d’un choix
element table[5] = { 1, 3, 5, 7, 11 };
liste *listes[3] = { new liste(5, table),
new listetab(2, table +3),
new listetab(3, table) };
238
Exercice 8.3 Quel est l’affichage produit par cet exemple ?
Quelle est la place mémoire totale occupée dans le tas,
en fonction de la taille S = sizeof(element) des
Voir solution éléments de liste ?
1 3 5 7 11
7 11
1 3 5
239
Un autre inconvénient important résulte de ce qu’il devient impossible
de définir le type listetab avant l’autre, et difficile de recommencer avec de
nouveaux types de listes. Pour régler ce problème plus élégamment, le
langage fournit les classes abstraites.
Classes abstraites
Une classe est abstraite lorsque l’une au moins de ses méthodes
virtuelles est pure. Pour déclarer une méthode virtuelle pure, il suffit de ne
pas donner d’implantation et d’écrire = 0 ; derrière sa déclaration. Seules les
fonctions virtuelles peuvent être déclarées pures, sous peine d’erreur
(Error : Non-virtual function 'xxx' declared pure).
public :
virtual ~liste() { nombre = 0; };
240
virtual int insere(const element&) = 0; //
pure
virtual void supprime(int n = 1) = 0; //
pure
};
public :
listech() { nombre = 0; courant = 0; }
listech(int n, const element*); // c. avec table
~listech();
public :
listetab() { courant = tab = 0; }
listetab(int n, const element*); // c. avec table
~listetab();
La classe liste est abstraite, puisque quatre de ses méthodes ont été
déclarées pures. On notera que certaines ne le sont pas : il s’agit
essentiellement (et pas par hasard) de celles qui n’étaient pas virtuelles
dans notre exemple précédent.
241
Le petit programme de démonstration reste identique, sauf que le
premier élément de listes doit être initialisé en écrivant new listech..., au
lieu de new liste.
destructeur virtuel, même s’il ne fait rien comme dans notre exemple : on
est ainsi certain de la bonne destruction des objets des classes dérivées.
Voir solution
242
Solution de l’exercice 8.4
On peut bien sûr créer un opérateur d’affectation
pour chaque classe concrète, mais cela ne permet pas
de faire des affectations de l’une de ces classes à
l’autre. Voici une solution à ce problème, basée sur la
remarque simple que les méthodes de liste
permettent de connaître entièrement le contenu d’une
liste, et donc d’en construire une copie :
virtual liste&
operator=(liste&) = 0;
};
liste& listech::operator=(liste&
ls)
// copie une liste dans this
{
supprime(nombre);
int reste = ls.nombre_elt();
if (!reste) return *this;
noeud *np = 0;
while ( (np = new
noeud(ls.valeur(),
243courant = np)) &&
(reste--) ) {
ls.avance();
Ce type de classe peut paraître curieux au premier abord. En fait, il est
assez pratique, notamment quand, comme dans notre exemple, on souhaite
implanter de plusieurs façons différentes une forme d’objet ; l’utilisateur n’a
plus alors qu’à choisir celle qu’il préfère. Le seul inconvénient, assez léger,
vient de ce qu’il faut utiliser des pointeurs dans ce cas ; cela évite cependant
de se tromper en utilisant un tableau de liste alors qu’il faut un tableau de
pointeurs.
Polymorphisme automatique
Un comportement idéal serait qu’une classe abstraite ait plusieurs
implantations, et que celles-ci puissent changer toutes seules pour passer de
l’une à l’autre. Par exemple, lorsqu’une liste chaînée classique listech
commencerait à déborder, elle se transformerait toute seule en liste-tableau
listetab qui est plus compacte.
Héritage multiple
Jusqu’à présent nous avons utilisé des classes qui dérivaient d’une
unique classe de base. Il est parfaitement possible qu’une classe hérite de
plusieurs classes. Voici un exemple :
class A {
// ...
};
class B {
// ...
};
244
class C : public A, B {
// ...
};
Noter que dans cette écriture, tout comme dans la déclaration d’héritage,
c’est une virgule qui sépare les différentes classes de base, et non le symbole
deux-points.
Conflits de noms
Lorsqu’une classe hérite de plusieurs autres, il se peut que deux des
classes de base aient des champs ou des méthodes ayant le même nom. S’il
s’agit d’un champ d’une part, et d’une méthode d’autre part, ou de deux
méthodes mais avec des listes d’arguments différents, il n’y a pas
d’ambiguïté et le compilateur se débrouillera en fonction du contexte
d’utilisation.
C c;
c.A::x = 0;
245
Lorsqu’il s’agit de méthodes, il est préférable de recouvrir les méthodes
de base en déclarant une méthode dans la nouvelle classe ayant le même
nom et les mêmes arguments.
Héritage virtuel
Nous avons dit précédemment que si deux classes A et B dérivent d’une
même troisième Z, une dérivation de A et B placera dans la classe dérivée C
deux copies de Z.
class Z { ... };
On notera que dans ce cas, il n’y a pas d’ambiguïté lorsqu’on utilise avec
une instance de C les membres hérités de Z (alors qu’il y en a une si la
dérivation n’est pas virtuelle, même pour les méthodes puisqu’elles ne
savent sur quel instance s’appliquer si l’on n’utilise pas un spécificateur A::
ou B::), sauf si les deux classes intermédiaires ont redéfini une méthode
virtuelle de Z (auquel cas le compilateur ne peut choisir) ; si une seule des
deux classes intermédiaires a redéfini une méthode virtuelle de Z, c’est la
méthode redéfinie, et non la méthode de Z, qui sera utilisée.
Fonctionnement interne
246
Après toutes ces observations sur l’héritage des classes, le lecteur se
demande peut-être « comment ça marche » . Il n’est pas nécessaire de le
savoir en pratique (il suffit de savoir que ça marche en effet), mais cela peut
être utile à l’occasion. Nous expliquons ci-après comment Turbo C++
implante les classes (il peut y avoir des variations selon les compilateurs) ;
pourquoi les instances de classes contenant des méthodes virtuelles
prennent deux octets de mémoire de plus que les autres ; pourquoi certaines
opérations sont impossibles.
class A {
int a1;
public :
// ... méthodes
};
class B : A {
int b1;
public :
// ... méthodes
};
Si une méthode de A est appelée, elle reçoit comme les autres l’adresse de
l’objet par l’intermédiaire du pointeur this. Or, vu l’ordre dans lequel les
champs sont placés, ce pointeur indique en mémoire la partie « instance de
A » de l’objet ; de ce fait, les méthodes de A fonctionnent exactement de la
247
Compliquons un peu les choses en supposant que B a des méthodes
virtuelles :
class B : A {
int b1;
public :
virtual void b2();
virtual void b3();
void b4();
};
Chaque instance contient à présent un pointeur caché sur une table fixe
(il en existe une seule pour toute la classe B) qui contient les adresses des
méthodes virtuelles. Lorsque le compilateur rencontre un appel à b2 par
exemple, il regarde ce pointeur caché dans this (ou dans l’objet qui appelle
b2), puis l’augmente d’autant que nécessaire (ici 0) pour être sur la bonne
méthode ; ayant ainsi l’adresse de la méthode, il ne reste plus qu’à y sauter.
class C : B {
int c1;
public :
void b2();
void b3();
virtual void c2();
248
};
Le pointeur caché n’a maintenant plus la même valeur que dans les
instances de B : il pointe sur une nouvelle table particulière à C, et dans
laquelle les adresses des méthodes recouvertes figurent à la place de celles
de B. Lorsque le compilateur rencontrera un appel à b2 avec une instance de
C,il ira chercher dans cette table-ci (sans le savoir, car il exécute exactement
le même travail qu’avant), et passera donc dans la bonne méthode
recouverte C::b2. Ceci explique le fonctionnement des méthodes
virtuelles : le pointeur caché est identique pour deux instances d’une même
classe, mais différent pour deux classes distinctes ; de ce fait, il caractérise
la classe à laquelle appartient l’instance, et permet donc de choisir la bonne
méthode.
class D : B {
int d1;
public :
virtual void d2();
void b3();
};
class E : D, C {
int e1;
public :
void b3();
void c2();
};
249
L’allure d’une instance de D est la suivante :
Jusqu’à présent nous n’avons augmenté la taille des objets que de deux
octets au maximum. Il n’en est plus de même avec l’héritage multiple. Voici
l’allure en mémoire d’une instance de E :
250
Lorsqu’on appelle une méthode de D avec une instance de E, ce n’est pas
le pointeur this qui est passé, mais celui que nous avons nommé « this
bis » , qui correspond au début de D en mémoire.
L’allure de D est assez semblable, sauf que b2, qui n’est pas recouverte
dans D, n’apparaît pas dans la première table :
251
On a à présent ceci dans E :
252
On retiendra surtout qu’il s’agit d’un processus complexe, dans lequel il
est préférable de ne pas intervenir, et que la taille des objets est difficile à
prévoir (utiliser l’opérateur sizeof pour la connaître).
9/ FLOTS D’ENTREES-SORTIES
Les entrées-sorties, c’est-à-dire les opérations de lecture à partir du
clavier, d’un fichier disque ou d’un périphérique, et les écritures à l’écran,
sur disque, imprimante ou périphérique, sont parmi les opérations les plus
fréquentes sur ordinateur. Leur maîtrise est donc essentielle en
programmation. En C++, on dispose de « flots » d’entrée ou de sortie qui
permettent facilement ces opérations. Nous décrivons ici ces flots et leur
utilisation. Nous donnons aussi quelques indications sur leur structure
interne, lorsqu’elle met en relief certaines capacités intéressantes de C++,
ou des astuces de programmation connues.
Classes de flots
Les classes de flots sont au nombre de dix-huit, réparties dans trois
fichiers distincts : <iostream.h>, <fstream.h> et <strstrea.h>. Un quatrième
fichier <iomanip.h> peut être utilisé dans certains cas (voir fin du chapitre).
253
le groupe fondamental, qui comprend la classe de base ios, la
classe de sortie ostream, celle d’entrée istream, et la mixte
iostream ;
le groupe des périphériques standard, avec la même structure
mais sans classe de base : ostream_withassign,
istream_withassign, iostream_withassign ;
le groupe des fichiers sur disques, qui a la même structure que
le fondamental ; la base est fstreambase, la sortie ofstream,
l’entrée ifstream et la mixte fstream ;
le groupe des chaînes de caractères, qui a aussi la même
structure que le fondamental ; la base est strstreambase, la
sortie ostrstream, l’entrée istrstream et la mixte
strstream.
La répartition peut sembler complexe, mais elle est en fait assez simple à
comprendre. Un flot d’entrées-sorties est une liste de caractères qu’on ne
charge pas entièrement en mémoire. Donc en premier lieu un flot doit avoir
un tampon, c’est-à-dire un petit bloc de mémoire où ranger les caractères
en attente. Ce tampon est géré par un élément de la classe streambuf ou de
ses dérivées, qui fournit des opérations comme « placer n caractères dans le
tampon » , « retirer n caractères » , etc. Ces opérations sont de bas niveau,
elles ne nous regardent pas.
254
Chaque flot va donc contenir un pointeur sur un tampon (ou plusieurs
éventuellement), plus un certain nombre de renseignements auxiliaires
indiquant notamment l’état dans lequel il se trouve. C’est en fait le type de
tampon qui détermine en grande partie le type de flot ; par contre, le type de
flot indique les opérations autorisées. En particulier, il n’est évidemment
pas permis d’écrire sur un flot d’entrée ou de lire dans un flot de sortie.
ios::goodbit Lorsque ce bit vaut 0, ainsi que tous les autres, tout va bien. La fonction
membre int good(void) renvoie 1 si tous les bits d’état sont à zéro (tout
va bien), 0 sinon.
ios::eofbit Lorsque ce bit vaut 1, la fin du fichier est atteinte. La fonction membre int
eof() renvoie 1 dans ce cas, 0 sinon.
ios::failbit Ce bit est à 1 lorsqu’une opération a échoué. Le flot peut être réutilisé.
ios::badbit Ce bit est à 1 lorsqu’une opération invalide a été tentée ; en principe le flot
peut continuer à être utilisé mais ce n’est pas certain.
255
ios::hardfail Ce bit est à 1 lorsqu’une erreur grave s’est produite ; il ne faut plus utiliser le
flot.
class ios {
public:
// ...
operator void* ();
int operator! ();
};
L’opérateur ! (redéfini pour cette classe) renvoie 1 si l’un des bits d’état
est à 1 (flot incorrect), 0 sinon. Au contraire l’opérateur void*, lui aussi
redéfini, renvoie 0 si l’un des bits d’état est à 1, un pointeur non nul (et
dépourvu de signification) sinon. Cela permet des écritures du type :
iostream fl;
// ...
if (fl) cout << "Tout va bien !\n";
// ...
if (!fl) cout << "Une erreur s'est produite.\n";
Mode d’écriture
Une autre énumération regroupe les masques unitaires utilisés pour le
champ de mode. Celui-ci indique de quelle façon les données sont lues ou
écrites, et ce qui se passe au moment de l’ouverture du flot. On l’utilise
surtout pour les fichiers. Voici la liste de ces bits :
256
ios::in Fichier ouvert en lecture.
ios::out Fichier ouvert en écriture.
ios::app Ajoute les données, en écrivant toujours à la fin (et non à la position
courante).
ios::ate Aller à la fin du fichier à l’ouverture (au lieu de rester au début).
ios::trunc Supprime le contenu du fichier, s’il existe déjà ; cette suppression est
automatique pour les fichiers ouverts en écriture, sauf si ios::ate ou
ios::app a été précisé dans le mode.
ios::nocreate Pour une ouverture en écriture, ne crée pas le fichier s’il n’existe pas déjà ;
une erreur (bit ios::failbit positionné) est produite dans le cas où le
fichier n’existe pas encore.
ios::noreplace Pour une ouverture en écriture, si ni ios::ate ni ios::app ne sont
positionnés, le fichier n’est pas ouvert s’il existe déjà, et une erreur est
produite.
ios::binary Fichier binaire, ne faire aucun formatage.
Indicateurs de format
Les flots permettent un grand nombre de formatages des données. Il
existe un champ de format dans ios, et une liste de masques unitaires qui
lui correspondent, dont voici la signification lorsqu’ils sont à 1 dans ce
champ :
ios::skipws Supprime les espaces (blancs, tabulations, etc.) en lecture. Ce bit est à 1
par défaut, contrairement aux autres.
ios::left Ajustement à gauche en écriture.
ios::right Ajustement à droite en écriture.
ios::internal Remplissage après le signe + ou -, ou l’indicateur de base (et non avant).
ios::dec Écriture décimale (base 10).
ios::oct Écriture en octal (base 8).
ios::hex Écriture en hexadécimal (base 16).
257
ios::showbase En écriture, écrire un indicateur de base.
ios::showpoint Écrit obligatoirement le point décimal pour les nombres à virgule
flottante, même si toutes les décimales sont nulles.
ios::uppercase Écrit les lettres A à F en majuscules dans les chiffres hexadécimaux
(minuscules sinon).
ios::showpos Écrit le signe pour tous les entiers, même positifs.
ios::scientific Pour les nombres à virgule flottante, écriture en notation scientifique
(1.75e+01 par exemple).
ios::fixed Pour les nombres à virgule flottante, écriture en notation à virgule
flottante (17.5 avec le même exemple).
ios::unitbuf Vide les tampons après écriture.
ios::stdio Vide les tampons de sortie standard out et de sortie d’erreur err après
insertion.
Par exemple
f.setf(ios::uppercase|ios::hex,
ios::uppercase|ios::basefield);
change les bits de base d’écriture et de uppercase afin d’écrire les entiers en
hexadécimal avec des lettres majuscules pour les chiffres A à F.
258
écrite ; si la donnée est plus petite, la partie restante est remplie avec le
caractère de remplissage ; si elle est trop grande elle n’est pas tronquée. Ce
champ est remis à zéro après chaque opération formatée. Il peut être lu par
la méthode int width(void) et modifié par int width(int) (qui renvoie la
valeur précédente).
int i = 245;
double d = 75.8901;
cout.precision(2);
cout.setf(ios::scientific, ios::floatfield);
cout << d; // écrit 7.59e+01
cout.width(7);
cout.fill('$');
cout << i; // écrit $$$$245
cout.width(9);
cout.fill('#');
cout.setf(ios::left|ios::hex,
ios::adjustfield|ios::basefield);
cout << i; // écrit f5#######
cout.fill(' ');
cout.width(6);
cout.setf(ios::internal|ios::showpos|ios::dec,
ios::adjustfield|ios::showpos|ios::basefield);
cout << i; // écrit + 245
Cette notation n’est pas très pratique, une meilleure sera indiquée plus
loin (paragraphe sur les manipulateurs).
259
Autres éléments
Dans sa partie publique, la classe ios comprend aussi une énumération
seek_dir de trois éléments ios::beg, ios::cur, ios::end, qui sont utilisés dans
les changements de position (voir plus loin pour les flots de sortie et les flots
d’entrée).
260
Flots de sortie : classe ostream
La classe fondamentale des flots de sortie est ostream. Elle dérive de ios
de manière publique et virtuelle :
Les flots de sortie sont retardés, c’est-à-dire que les données prennent
place dans le tampon jusqu’à ce qu’il soit plein ou jusqu’à la fermeture du
flot. Pour forcer celui-ci à écrire ces données tout de suite, il suffit d’appeler
la méthode ostream& flush(void) (la valeur renvoyée est le flot lui-même).
Changement de position
Un flot de sortie pointant sur un fichier ou une organisation du même
genre possède un indicateur de position. Cet indicateur marque
l’emplacement de la prochaine lecture ; il avance à chaque écriture du
nombre de caractères écrits.
261
o ios::beg : référence = début du fichier
o ios::cur : référence = position courante
o ios::end : référence = fin du fichier.
ofstream fl;
// ...
fl.seekp(-10, ios::cur);
Écriture formatée
L’opérateur << est redéfini pour les flots de sortie sous la forme d’une
méthode ostream& operator<<(type) pour tous les types prédéfinis (y compris
unsigned char* et signed char* pour les chaînes de caractères), ainsi que void*
(pour écrire la valeur d’un pointeur) et même streambuf* (pour prendre les
caractères d’un autre tampon et les écrire).
Nous avons déjà utilisé bien des fois cet opérateur avec cout. Comme il
renvoie une référence sur le flot courant, on peut chaîner les écritures
comme ceci :
262
ce qui équivaut à :
cout.operator<<(i).operator<<("ce jour").
operator<<(d).operator<<('\n');
Nouvelles sorties
Lorsqu’on écrit une nouvelle classe d’objets, il peut être très intéressant
de pouvoir les écrire de la même façon que d’autres. Rien n’est plus facile, il
suffit de définir un opérateur << adapté. Par exemple, avec la classe fraction
que nous avons définie au chapitre 7, il suffit d’écrire :
class fraction {
int num, den;
public :
// ...
friend ostream& operator<<(ostream& os,
fraction f)
{ return os << f.num << '/' << f.den; }
};
et le tour est joué. Noter que la déclaration n’est pas identique à celle des
précédents opérateurs <<, qui étaient des membres de la classe ostream.
Cependant, cela n’a pas d’importance, l’effet reste le même.
Changement de position
263
Un flot d’entrée a aussi un indicateur de position qui peut être lu par la
méthode streampos tellg(void) ; cette méthode ne porte pas le même nom
que dans les flots de sortie, car il peut exister indépendamment un
indicateur d’entrée et un de sortie (flots mixtes, voir ci-après).
Toutes ces méthodes existent en fait en deux versions, pour signed char
et unsigned char. La fonction get à trois arguments lit une série de caractères
et les place dans un tableau ; elle s’arrête soit quand le nombre maximal
indiqué est dépassé, soit quand le caractère final (de valeur par défaut '\n')
est rencontré (ou encore si elle arrive en fin de fichier). Un caractère nul
final est ajouté. La fonction getline a le même effet sans troisième
argument ; avec un troisième argument différent de '\n', elle s’arrête
lorsqu’elle rencontre le caractère final précisé ou la fin de la ligne '\n'. Enfin
la fonction read lit un bloc de caractères de longueur indiquée sans aucun
formatage.
Réinsertion
Il est possible de lire le prochain caractère sans le sortir du tampon
d’entrée en utilisant la fonction membre int peek(void). On peut aussi
264
savoir combien de caractères il reste dans le tampon (sans formats) par int
gcount(void).
Lecture formatée
L’opérateur >>est redéfini pour les flots d’entrée sous la forme istream&
operator>>(type&), pour tous les types prédéfinis, et sous la forme istream&
o Pour les entiers short, int et long, signés ou non, les espaces (blancs,
tabulations, etc.) sont sautés et le signe éventuel puis les chiffres sont
lus jusqu’à rencontrer un caractère autre qu’un chiffre ; les préfixes
sont acceptés comme dans les constantes de C++ : les nombres
commençant par 0 sont lus en octal, et les nombres commençant par
0x ou 0X sont lus en hexadécimal. Ce comportement peut toutefois être
265
débordement de la chaîne, il est recommandé d’utiliser le champ de
largeur en le positionnant avec width ; en effet, la valeur par défaut de
zéro implique une lecture sans limite absolue.
Dans tous les cas, si la fin de fichier est rencontrée en cours de lecture,
rien n’est placé dans la variable et le bit failbit est positionné.
Comme le résultat est une référence sur le flot d’entrée courant, on peut
chaîner les lectures. Voici quelques exemples :
int i, j;
float f;
char chaine[40];
cin.setf(ios::hex, ios::basefield);
cin >> i;
// si vous écrivez : 07a89
// alors i devient 0x7A89
cin.setf(ios::dec, ios::basefield);
cin >> i;
// si vous écrivez : 077
// alors i devient 77 (et non la valeur octale 077 égale à 63)
cin >> d;
// si vous écrivez : 125.89e-14
// alors d devient 1.2589E-12
266
On peut évidemment aussi redéfinir l’opérateur d’entrée pour les
nouvelles classes.
Voir solution
267
virtual ~iostream();
protected:
iostream();
};
Les deux opérateurs >> et << restent bien entendu disponibles, ainsi que
tous les autres membres.
Flots prédéfinis
Nous avons dit qu’il n’est pas possible d’écrire une assignation d’une
instance de ostream vers une autre, et qu’il en est de même pour istream et
iostream.
268
Si ces classes existent, c’est que c’est généralement une erreur de
recopier un flot dans un autre : en particulier, si deux flots partagent le
même fichier sur disque, des dégâts risquent de survenir. Le fait de déclarer
un flot _withassign indique clairement que l’on souhaite faire une telle copie.
Cela ne pose pas de problèmes avec les flots prédéfinis, qui sont au nombre
de quatre :
ostream_withassign cout Comme on le sait déjà, ce flot envoie ses sorties à l’écran.
istream_withassign cin Ce flot prend ses entrées au clavier.
ostream_withassign cerr Flot d’erreur. Par défaut identique à cout.
ostream_withassign clog Flot d’erreur mais avec un tampon.
ofstream ferr("ERROR.MSG");
if (ferr) // si on a pu ouvrir le fichier...
cerr = ferr;
Le tampon utilisé par ces classes est de type filebuf, qui est une
dérivation de streambuf adaptée aux fichiers disques. Cette classe se charge
notamment des opérations de bas niveau.
269
La classe fstreambuf sert surtout de classe de base pour les trois autres.
Elle implémente notamment les fonctions open et close décrites ci-après
pour les classes dérivées.
La fonction membre close, qui est en fait définie dans fstreambase, ferme
le fichier en vidant le tampon. Il ne faut pas oublier de l’appeler, sans quoi
des données seraient perdues. Noter toutefois que le destructeur appelle
cette fonction.
Les écritures se font comme avec cout ; on notera que par défaut les
fichiers sont ouverts en mode texte ; dans ce mode, les caractères '\n' sont
transformés en une paire de caractères saut de ligne + retour chariot (sur
270
DOS ou Windows) conformément aux standards texte, et inversement en
lecture. Pour éviter de telles transformations catastrophiques sur des
fichiers binaires, il faut positionner le bit ios::binary dans le champ de
mode (deuxième paramètre de open).
La classe istream est semblable à ostream, sauf que la valeur par défaut du
second paramètre de open est ios::in et qu’elle hérite de istream.
Quant à la classe fstream, elle est aussi semblable, sauf que le second
paramètre de open n’a pas de valeur par défaut et doit donc être précisé
impérativement.
271
Flots en mémoire
Les flots d’entrées-sorties ne concernent pas que les périphériques.
Parfois, il peut être utile de les utiliser en mémoire. Ainsi, si l’on souhaite
avoir une chaîne de caractères représentant la valeur d’un entier, il suffit de
l’écrire dans un flot en mémoire, puis de lire ce flot comme une chaîne.
272
};
Les constructeurs par défaut initialisent les instances sur des chaînes
vides. Les autres constructeurs permettent de donner un tampon de
mémoire et une taille maximale aux instances ; le troisième paramètre de
celui de ostrstream et strstream est le mode d’ouverture.
Lorsque les tampons sont créés par les instances (appel des
constructeurs par défaut), elles les gèrent entièrement et les augmentent à
chaque écriture ; ils sont alors détruits par le destructeur.
273
Manipulateurs
Nous avons vu que l’on pouvait formater de différentes façons les
entrées-sorties à l’aide du champ de format, du champ de largeur et du
champ de remplissage.
Pour les employer, il suffit de les écrire sur le flot de la même façon qu’un
objet normal, au moment où l’on souhaite changer le mode. Par exemple,
l’écriture suivante :
cout.setf(ios::dec, ios::basefield);
cout << i;
cout.setf(ios::hex, ios::basefield);
cout << j << '\n';
274
sont 8 (octal), 10 (décimal), 16 (hexadécimal), et 0 qui
indique un comportement standard : sorties en décimal sauf
pour les pointeurs, entrées suivant le préfixe.
setfill(char) Fixe le caractère de remplissage.
setprecision(int) Fixe la précision (nombre de décimales en virgule
flottante).
setw(int) Fixe le champ de largeur width.
resetiosflags(long) Met à zéro dans le champ de forme les bits qui sont à 1
dans le paramètre.
setiosflags(long) Met à 1 dans le champ de forme les bits qui sont à 1 dans le
paramètre.
int i = 32;
cout << setfill('*') << setw(9) << hex << i;
// écrit : *******20
double d = 1/3.141592;
cout << setprecision(3) << d;
// écrit : 0.318
275
Une méthode plus astucieuse est utilisée en réalité. Elle consiste à noter
qu’il est parfaitement possible de passer un argument de type « pointeur sur
fonction » à un opérateur. Voici donc par exemple comment sont implantés
les manipulateurs endl, ends et flush sur les flots de sorties :
Les manipulateurs dec, hex et oct agissent sur la classe ios simplement en
changeant le bit adéquat dans le champ de forme. Le manipulateur ws agit
sur istream également en positionnant le bit adéquat. Il est tout à fait
possible de définir ses propres manipulateurs sur ce modèle.
class smanip {
ios& (*fn)(ios&, int);
276
int ag;
public:
smanip(ios& (*f)(ios&, int), int a)
{ fn = f; ag = a; }
friend istream& operator>>(istream& s, smanip&
f)
{ (*f.fn)(s, f.ag); return s; }
friend ostream& operator<<(ostream& s, smanip&
f)
{ (*f.fn)(s, f.ag); return s; }
};
smanip setw(int w)
{
return smanip(setw_fonc, w);
}
classes génériques.
10/ PREPROCESSEUR,
EDITEUR DE LIENS ET
FICHIERS MULTIPLES
Jusqu’à présent, nous avons utilisé des exemples très courts qui tenaient
parfaitement dans un seul fichier. En réalité, à partir de quelques centaines
de lignes de code, il devient rentable d’utiliser plusieurs fichiers. Le langage
277
fournit divers moyens pour cela, que nous allons étudier à présent. Nous
examinons aussi le préprocesseur, qui travaille avant le compilateur.
Le préprocesseur
La compilation d’un programme se déroule en trois phases. La première
est exécutée par le préprocesseur, elle vise à remplacer les directives de
compilation par du texte C++ normal. La seconde est la compilation
proprement dite. La troisième est l’édition de liens.
#directive [paramètres]
sera considérée comme une seule ligne ; rappelons que ceci est vrai aussi du
compilateur qui ignore les paires \ + saut de ligne (chapitre 1).
278
La directive nulle est constituée d’un symbole # seul sur une ligne ; elle
est ignorée.
#include <fichier>
L’écriture :
#include "fichier"
Dans les deux cas, on peut spécifier un chemin d’accès pour le fichier ;
les écritures sont alors équivalentes.
Enfin l’écriture :
#include identificateur
279
provoque le remplacement de l’identificateur par la macro de ce nom ; celle-
ci doit avoir été définie (voir le paragraphe sur les macros) et correspondre
à un nom de fichier correct enclos entre < > ou entre guillemets " ".
#include <fichier>
L’écriture :
#include "fichier"
Dans les deux cas, on peut spécifier un chemin d’accès pour le fichier ;
les écritures sont alors équivalentes.
Enfin l’écriture :
#include identificateur
280
provoque le remplacement de l’identificateur par la macro de ce nom ; celle-
ci doit avoir été définie (voir le paragraphe sur les macros) et correspondre
à un nom de fichier correct enclos entre < > ou entre guillemets " ".
#define identificateur
#undef identificateur
Même s’il n’avait pas été défini auparavant, aucune erreur n’est produite.
#if condition
.....
#endif
281
au paragraphe précédent), et 0 sinon. Par exemple, on peut écrire (les
parenthèses sont facultatives) :
L’écriture :
#if defined(identificateur)
#ifdef identificateur
De même, l’écriture :
#if !defined(identificateur)
#ifndef identificateur
La clause #if peut avoir une clause #else, plus éventuellement des clauses
intermédiaires #elif (pour else if). Voici un exemple :
#ifdef __cplusplus
#elif defined(_VIDEO)
282
Les clauses de compilation conditionnelles peuvent être imbriquées
comme les clauses if en C++.
Constantes prédéfinies
Quelques constantes sont éventuellement prédéfinies au début de la
compilation par le compilateur C++ ; elles peuvent être utilisées dans des
clauses de compilation conditionnelles. Leur nom et valeur dépendent du
compilateur, du système et de la machine utilisés. Voici par exemple les
principales utilisées par Turbo C++ sous MS-DOS :
#ifndef __cplusplus
#error Ce programme ne fonctionne qu'avec C++
#endif
283
Les directives #pragma sont spécifiques à un compilateur particulier.
Lorsque la directive est inconnue au compilateur courant, il l’ignore. Nous
ne donnons ici que les principales directives de ce type de Turbo C++.
#pragma argsused
La directive :
défaut est 100. Les fonctions de démarrage sont lancées dans l’ordre du plus
petit numéro de priorité au plus grand ; ces numéros doivent se trouver
entre 64 et 255, les valeurs 0 à 63 étant réservées aux librairies standard.
Macros
La clause #define sert aussi à définir des macros. Il s’agit d’abréviations
ou de noms symboliques pour d’autres objets, et elles ont
284
traditionnellement un nom en majuscules. Voici quelques exemples
classiques de macros en C :
#define PI 3.141592
#define ERRMSG "Une erreur s'est produite.\n"
#define CARRE(x) (x)*(x)
285
, (x) )
qui équivaut à :
#define CARRE(x) x * x
// ...
j = CARRE(i+1);
j = i+1 * i+1;
en sortie vaut 5, et non 4, parce que la macro a été étendue sous la forme
i
(i++)*(i++) et provoque deux incrémentations. Ce genre d’erreur est
particulièrement ardu à repérer.
286
D’une façon générale les macros sont dangereuses, car il n’y a aucun
contrôle des types ; ainsi, si l’on utilise la macro AFFICHE définie ci-avant avec
un paramètre non entier, on risque de sérieux problèmes.
const Pi = 3.141592;
const Errmsg = "Une erreur s'est produite.\n";
qui ne posent pas de problèmes, même avec des effets de bord, et qui
vérifient les types de leurs paramètres.
Certaines fonctions en ligne sont d’ailleurs bien plus simples que les
macros correspondantes. Essayez d’écrire la macro correspondant à :
287
Les classes génériques fournissent un autre exemple d’utilisation
pratique des macros en C++.
Classes génériques
Il n’y a pas, dans les premières versions de C++, de moyen de définir une
classe générique, c’est-à-dire dépendant d’un paramètre comme le type d’un
élément contenu dans la classe. On peut toutefois le simuler en utilisant une
macro. Dans les versions plus récentes, cette fonctionnalité a été ajoutée
sous le nom anglais de template (en français, gabarits).
liste.h
class noeud {
noeud *suivt;
element elm;
public :
noeud(element e, noeud *suivant = 0)
{ elm = e; suivt = suivant; }
noeud *suivant(void) { return suivt; }
element &contenu(void) { return elm; }
};
Dans ce cas, avant d’inclure liste.h dans votre fichier, il faudra écrire la
définition de element :
288
par exemple. C’est la méthode que nous avons employée jusqu’à présent.
Elle a toutefois l’inconvénient de ne permettre l’utilisation que d’un seul
type de liste chaînée.
Une seconde méthode consiste à écrire les deux macros suivantes (dans
un fichier séparé en général, que nous nommons encore liste.h) :
liste.h
#include <generic.h>
#define listedeclare(typ) \
class noeud(typ) { \
noeud(typ) *suivt; \
typ elm; \
public : \
noeud(typ)(typ e, noeud(typ) *suivant = 0)
\
{ elm = e; suivt = suivant; } \
noeud(typ) *suivant(void) { return suivt; }
\
typ &contenu(void) { return elm; } \
}
#include "liste.h"
289
declare(liste, int);
declare(liste, double);
main()
{
noeud(int) *ni = new noeud(int)(0);
noeud(double) *nd = new
noeud(double)(3.14);
// ...
}
class noeud_int {
noeud_int *suivt;
int elm;
public :
noeud_int(int e, noeud_int *suivant = 0)
{ elm = e; suivt = suivant; }
noeud_int *suivant(void) { return suivt; }
int &contenu(void) { return elm; }
};
class noeud_double {
noeud_double *suivt;
double elm;
public :
noeud_double(double e, noeud_double
*suivant = 0)
{ elm = e; suivt = suivant; }
noeud_double *suivant(void) { return suivt;
}
double &contenu(void) { return elm; }
};
main()
{
noeud_int *ni = new noeud_int(0);
noeud_double *nd = new noeud_double(3.14);
290
// ...
}
Avec nos clauses declare, on a donc en fait déclaré deux classes noeud_int
et noeud_double. Les noms de ces classes peuvent être utilisés tels quels, ou
sous la forme de macros noeud(int) et noeud(double) qui donne l’illusion
d’une classe générique. Notons qu’il faut toutefois effectivement une
déclaration pour chaque type utilisé, ce qui ne serait pas le cas avec une
vraie classe générique comme il en existe dans certains langages comme
ADA, ou les versions récentes de C++.
291
Notons que l’éditeur de liens exécute une tâche complexe, car il vérifie
aussi la cohérence des déclarations multiples, et ne conserve que les
fonctions réellement utilisées : les autres, quelle que soit leur provenance,
sont éliminées, ce qui garantit un programme de taille (presque) minimale.
Librairies standard
Une librairie est un regroupement de fichiers objets déjà compilés. Ces
fichiers objets (*.obj ou *.o) sont ceux fournis par le compilateur.
Fichiers multiples
Lorsqu’un programme devient volumineux, il est peu rentable de le
placer dans un seul fichier : la compilation devient très longue, puisqu’il
faut tout recompiler chaque fois, et il est difficile de s’y retrouver.
292
Comme les fichiers doivent communiquer entre eux, il faut au moins un
fichier en-tête qui leur soit commun. En pratique, on utilise même souvent
un fichier en-tête pour chaque source, sauf celui qui contient main ; en
général on donne à ce fichier le même nom avec le suffixe *.h (mais ce n’est
nullement obligatoire). Lorsqu’on utilise cette méthode très fréquente, le
fichier en-tête peut être considéré comme l’interface d’un module dont le
fichier source est l’implantation. Dans ce fichier d’en-tête on placera toutes
les déclarations (de classes, de fonctions, de constantes, de variables, etc.)
susceptibles d’être utilisées par les autres. On n’oubliera pas les fonctions en
ligne, car le compilateur doit les connaître entièrement pour les placer
directement dans le code produit.
293
o mainfra.cpp :
Fichier contenant main et la gestion de base du
programme (commandes, etc.).
fraction.h
#ifndef _FRACTION_H
#define _FRACTION_H
class fraction {
// ....définition de la classe
// avec fonctions en ligne éventuelles
};
#endif
fraction.cpp
#include "fraction.h"
listefra.h
#ifndef _LISTEFRA_H
#define _LISTEFRA_H
#include "fraction.h"
class liste_fra {
// listes de fractions
};
#endif
listefra.cpp
294
#include "fraction.h"
class noeud_fra {
// ...classe utilisée seulement par liste_fra
};
matrfra.h
#ifndef _MATRFRA_H
#define _MATRFRA_H
#include "fraction.h"
class matrice_fra {
// ...
};
#endif
matrfra.cpp
#include "listefra.h"
#include "matrfra.h"
iosfract.h
#ifndef _IOSFRACT_H
#define _IOSFRACT_H
#include "matrfra.h"
// déclarations de routines d’affichage et de lecture
// de fractions et de matrices
#endif
iosfract.cpp
295
#include <iostream.h>
#include "iosfract.h"
mainfra.cpp
#include "iosfract.h"
main()
{
// ...
}
296
ainsi qu’à la librairie standard, pour obtenir le programme souhaité. Cela
est fait par l’intermédiaire d’un projet, que nous allons examiner à présent.
Projets
Continuant sur notre exemple, il va falloir indiquer à l’éditeur de liens de
chercher les fonctions dans les cinq fichiers objets créés. Il n’est pas
suffisant de lancer la compilation pour obtenir le programme, car l’éditeur
de liens ne trouvera pas les fonctions ; en effet, il faut absolument
comprendre que bien que les fichiers en-tête et source correspondants aient
généralement le même nom (avec un suffixe différent), il n’en est pas
forcément ainsi. De plus, l’inclusion des fichiers est faite par le
préprocesseur, l’éditeur de liens ne la connaît pas. Enfin, les directives
d’inclusion ne suffisent pas à indiquer quels sont les fichiers objets
effectivement utilisés ; par exemple, en regardant mainfra.cpp, on pourrait
croire qu’un seul fichier objet est nécessaire ; même en remontant les
inclusions successives, on ne trouverait pas iosfract qui est « caché » dans
matrfra.cpp. Il est pourtant certain que les cinq fichiers objets doivent être
C++ fournit un système plus simple nommé projet. Il suffit d’ouvrir une
fenêtre de projet nommée par exemple calcfra.prj et d’y inclure les cinq
fichiers source *.cpp.
297
o si une modification a été faite, le compilateur est appelé et recrée le
fichier objet correspondant ;
o une fois tous les fichiers objets à jour, l’éditeur de liens est appelé avec
la liste de tous ces fichiers objets ; si aucune erreur ne se produit, un
fichier exécutable est alors écrit sur disque. Ce fichier a le même nom
que le projet par défaut, avec le suffixe exe (calcfra.exe dans notre
exemple).
Si l’on souhaite qu’un tel objet soit utilisable dans tous les fichiers du
projet, il faut le déclarer externe, en utilisant le mot-clé extern. Si l’on préfère
que l’objet ne soit utilisable que pour les fonctions du fichier courant, il faut
298
le déclarer statique avec le mot-clé static. Les fonctions, définitions de
types et variables sont externes par défaut, les constantes statiques.
int glob = 1;
sans initialisation, et dans l’un des deux fichiers objets (n’importe lequel)
une déclaration statique avec initialisation (explicite ou implicite puisque
les objets statiques sont toujours initialisés) de l’objet :
mainfra.cpp
#include "iosfract.h"
int glob = 1;
// ...
Ainsi, chaque fois que l’on fait référence à glob dans iosfract.cpp, le
compilateur sait que la variable est en fait ailleurs, et place un lien que
l’éditeur de liens se chargera de résoudre.
Lorsqu’un objet est externe, il ne doit être initialisé que dans le fichier
objet qui le contient effectivement, sinon une erreur est produite. Précisons
299
aussi qu’une variable peut être déclarée externe à l’intérieur d’une fonction,
bien que ce soit d’un intérêt assez faible.
Dans ce cas, il est possible à plusieurs fichiers objets d’avoir une fonction
nommée fonc sans entraîner de conflit, même si les fonctions sont
différentes. En fait, l’éditeur de liens ne connaîtra pas le nom de ces
fonctions statiques, qui n’est utilisé que par le compilateur et effacé en fin
de fichier. La déclaration statique est donc utile pour des fonctions, des
variables et des types qui n’ont pas à être utilisés ailleurs, car elle facilite le
travail du compilateur et de l’éditeur de liens. Noter aussi qu’une fonction
en ligne est inconnue de l’éditeur de liens (elle est toujours statique).
Les constantes sont statiques par défaut ; de la sorte, deux fichiers objets
peuvent utiliser chacun leur version d’une constante sans problèmes. Cela
permet aussi d’écrire une constante dans un fichier en-tête et d’inclure ce
fichier dans plusieurs sources. Une constante peut être déclarée externe, si
l’on souhaite l’initialiser ailleurs. Précisons toutefois que les tableaux
constants (y compris les chaînes de caractères) ainsi que les classes et
structures constantes sont, eux, externes par défaut (afin d’éviter une
duplication de leur contenu dans les différents fichiers objets).
300
ANNEXES
Liste des mots réservés de C++
Les mots suivants sont réservés en C++ :
301
Les opérateurs qui ne peuvent être redéfinis sont sur fond orange.
302
(type) 14 <- 1 Changement de type Change le type de la variable située
derrière la parenthèse fermante en
celui indiqué entre parenthèses.
sizeof 14 <- 1 Taille Taille du type donné en argument,
ou du type de la variable donnée en
argument.
new 14 <- 1 Allocation Crée un bloc mémoire de taille
adéquat pour stocker un objet, et
renvoie un pointeur sur ce bloc.
delete 14 <- 1 Désallocation Détruit un bloc créé par new.
.* 13 -> 2 Adressage d’un Appel d’une méthode de classe par
pointeur sur membre l’intermédiaire d’un pointeur de
fonction.
->* 13 -> 2 Déréférencement + Déréférence le pointeur, puis appelle
adressage d’un une méthode de classe par
pointeur sur membre l’intermédiaire d’un pointeur de
fonction.
* 12 -> 2 Multiplication Multiplie les deux nombres.
/ 12 -> 2 Division Divise le nombre de gauche par celui
de droite.
% 12 -> 2 Reste modulo Donne le reste de la division
euclidienne de l’entier de gauche par
celui de droite.
+ 11 -> 2 Addition Additionne les deux nombres.
- 11 -> 2 Soustraction Soustrait les deux nombres.
<< 10 -> 2 Décalage à droite Décale à droite les bits de l’entier
précédent du nombre de bits indiqué
par l’entier suivant l’opérateur,
divisant ainsi par 2 puissance de cet
entier.
>> 10 -> 2 Décalage à gauche Décale à gauche les bits de l’entier
précédent du nombre de bits indiqué
par l’entier suivant l’opérateur,
multipliant ainsi par 2 puissance de
cet entier.
< 9 -> 2 Inférieur strict Renvoie un booléen indiquant si le
nombre de gauche est strictement
inférieur à celui de droite.
<= 9 -> 2 Inférieur large Renvoie un booléen indiquant si le
nombre de gauche est inférieur ou
égal à celui de droite.
> 9 -> 2 Supérieur strict Renvoie un booléen indiquant si le
nombre de gauche est strictement
supérieur à celui de droite.
>= 9 -> 2 Supérieur large Renvoie un booléen indiquant si le
303
nombre de gauche est supérieur ou
égal à celui de droite.
== 8 -> 2 Égal Renvoie un booléen indiquant si les
deux termes sont égaux.
!= 8 -> 2 Différent Renvoie un booléen indiquant si les
deux termes sont différents.
& 7 -> 2 ET par bits Applique l’opération logique ET sur
les bits des opérandes entiers. Ne pas
utiliser avec les booléens (voir &&).
^ 6 -> 2 OU EXCLUSIF par Applique l’opération logique OU
bits EXCLUSIF sur les bits des
opérandes entiers.
| 5 -> 2 OU par bits Applique l’opération logique OU sur
les bits des opérandes entiers. Ne pas
utiliser avec les booléens (voir ||).
&& 4 -> 2 ET logique ET logique entre deux booléens. Si
le premier opérande est faux, le
second n’est pas évalué.
|| 3 -> 2 OU logique OU logique entre deux booléens. Si
le premier opérande est vrai, le
second n’est pas évalué.
? : 2 <- 3 Selon que Évalue le booléen situé devant ?. Si
le résultat est vrai renvoie le terme
précédant :, sinon renvoie celui qui
suit.
= 1 <- 2 Affectation Calcule le terme de droite et place la
valeur dans la variable désigné à
gauche. Renvoie la valeur obtenue.
*= /= 1 <- 2 Affection + opération Effectue l’opération dont le symbole
%= += -=
précède le signe =, entre la variable
&= ^= |=
<<= >>= identifiée à gauche et le terme situé à
droite, puis place le résultat dans
cette variable. Renvoie la valeur
obtenue.
, 0 -> 2 Succession Évalue le terme de gauche, puis celui
de droite et renvoie ce dernier.
304