You are on page 1of 304

INTRODUCTION

Qu'est-ce que C++ ?


Le langage C, inventé à la fin des années 1980, s’est imposé comme le
langage de programmation phare sur les micro- et mini-ordinateurs, grâce à
sa facilité d’utilisation, son adéquation au langage machine qui permet une
compilation rapide et un code performant, et surtout grâce aux systèmes
Unix, dont le noyau est écrit en C.

Cependant, ce langage dû à Kernighan et Ritchie, malgré plusieurs


améliorations successives (dont une normalisation par l’ANSI), souffrait
encore d’un certain nombre de défauts qui semblaient difficilement
évitables, et notamment d’une sécurité de programmation assez faible.

Il revient à Bjarne Stroustrup, des laboratoires d’ATT, d’avoir amélioré


considérablement le C en augmentant notablement ses capacités, sa
sécurité, et en lui donnant la possibilité de programmer par objets, mode
de programmation qui s’est beaucoup répandu ces dernières années ; ceci
fut fait en respectant l’esprit du C, si bien que la nouvelle mouture, C++,
semble — trompeusement — simple.

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.

Ce site décrit le langage conformément aux normes d’ATT. Les


programmes peuvent donc être utilisés avec n’importe quel compilateur
conforme aux spécifications 2.0 et suivantes. Lorsque le comportement
dépend explicitement du compilateur, nous l’avons indiqué.

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:

Section décrivant un comportement hérité


du C
Par contre, il leur est vivement recommandé de lire soigneusement les
paragraphes non marqués, qui donnent les nouveautés ou les modifications
intervenues entre C et C++ ; bien que C++ soit très fortement compatible
avec C, certains points de détail sont des pièges inattendus pour les
programmeurs habitués au C. Ces points sont mis en relief dans le site.

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.

Le chapitre 1 donne une introduction aux éléments de base de C++, il est


d’un abord très facile. Le chapitre 2 le prolonge avec une description des
types prédéfinis, et des opérateurs, très importants en C++ ; ce chapitre est
un peu moins simple, certains points comme la précédence des opérateurs
étant assez délicats. Le chapitre 3 décrit les types composés comme les
pointeurs et les tableaux, et introduit une nouveauté importante de C++, les
références. Le chapitre 4 complète une description qui est encore
essentiellement celle du C avec des capacités complémentaires.

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.

La programmation orientée objet (POO) est étudiée ensuite aux


chapitres 6 à 9. La définition des classes et les éléments de base sont donnés
au chapitre 6 ; le chapitre 7 introduit le concept essentiel de redéfinition des
opérateurs, qui donne à C++ une puissance inégalée même parmi les
langages orientés objet. Le chapitre 8 introduit la notion fondamentale
d’héritage qui fait une grande partie de l’efficacité de la POO. Quant au
chapitre 9, il décrit les flots d’entrées-sorties, une application très pratique
de la POO aux entrées-sorties, fournissant un système bien plus souple et
bien plus agréable que le vieux printf. Ces chapitres sont assez difficiles,
car les notions qu’ils introduisent sont, malgré leur apparente simplicité,
d’une profondeur rare en programmation. Les programmeurs C ne pourront
guère sauter que le premier paragraphe du chapitre 6, qui traite des
structures.

Enfin le chapitre 10 explique les différentes phases de la compilation


d’un programme C++ et indique comment programmer avec plusieurs
fichiers. Les programmeurs C liront le paragraphe sur les macros, afin
d’apprendre comment on peut les éviter en C++, et au contraire quand elles
se révèlent encore utiles.

Des annexes donnent quelques points importants de références, et


notamment le tableau de précédence des opérateurs.

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.

Compilation et structure d’un texte


source
Le langage C, et a fortiori C++, est un langage compilé. Cela signifie que
les instructions que l’on écrit ne sont pas directement lues par la machine :
celle-ci attend qu’on lui donne l’ordre de compiler le programme. Ce dernier
est alors lu en bloc, du début à la fin, et transformé en code machine. C’est
ce code qui est ensuite exécuté en tant que programme. Les avantages de la
compilation, par rapport à l'interprétation, sont importants : le code est
beaucoup plus rapide, et les erreurs d’écriture plus faciles à repérer. En
outre, elle oblige à une certaine organisation du programme (sur laquelle
nous reviendrons) qui limite les risques d’erreurs. Cependant, elle exige
d’écrire directement des programmes complets.

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.

Lorsque ces conventions ne sont pas respectées, le compilateur suppose


que le programmeur s’est trompé. Il affiche alors un message d’erreur, qui
peut être de trois types.

Un message d’attention Warning signale simplement une bizarrerie


dans le programme qui ne l’empêche pas cependant d’être compilé et
exécuté ; c’est le cas par exemple lorsqu’on déclare une variable sans
l’utiliser : le compilateur affiche Warning : xxx declared but never used (xxx
déclarée mais jamais utilisée), où xxx désigne le nom de la variable en
question (en fait le message est un peu plus long, car le compilateur précise
le numéro de ligne et le nom de la fonction courante). De tels messages sont
peut-être des erreurs du programmeur, ou peut-être pas. Ils n’empêchent
nullement le programme de fonctionner, et peuvent donc être ignorés, bien
qu’il soit généralement préférable d’y prêter attention.

Un message Error signale une erreur qui empêche le programme de


fonctionner. C++ continue cependant la compilation (si les options par
défaut sont choisies) jusqu’à la fin du fichier, afin de signaler par la même
occasion les autres erreurs. Cependant, la première erreur peut en
provoquer toute une série d’autres. Ainsi un point-virgule mal placé (voir
exemple ci-après) peut engendrer des dizaines de messages d’erreur
successifs, car il ôte au compilateur ses points de repère, lui faisant ainsi
« perdre les pédales ». De telles erreurs de syntaxe sont fréquentes, surtout
pour un débutant, ainsi que les erreurs en frappant un nom de variable.
Elles doivent être corrigées impérativement pour que le programme puisse
fonctionner.

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 :

int fonction(void); // erreur ici


{
// texte de la fonction...

provoque l’arrêt du compilateur sur la ligne contenant l’accolade ouvrante {,


avec le message Error : declaration was expected, c’est-à-dire une
déclaration était attendue. En effet, à cette étape c’est ce qui devrait suivre
du point de vue du compilateur. Mais en réalité, il ne fallait pas mettre de
point-virgule à la fin de la ligne précédente (voir la différence entre
déclaration et définition de fonction plus loin).

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.

Les termes sont constitués d’identificateurs et de symboles. Les


identificateurs servent à donner des noms aux variables, types, fonctions,
etc. Tout nom ne peut cependant pas être utilisé. Les identificateurs ne
peuvent contenir que des lettres (minuscules ou majuscules mais pas de
lettre accentuée), des chiffres (0 à 9), ou le caractère spécial de
soulignement (_) ; ils ne doivent pas commencer par un chiffre. En
particulier, les espaces ne sont pas admis, ils coupent l’identificateur en
plusieurs.

Voici quelques exemples d’identificateurs :

Salut carre_entier _9 uneChaine


var3

6
Les mots suivants ne sont pas des identificateurs :

Hello! Premier? 7val 9_

De plus, certains identificateurs sont réservés pour des opérations


spéciales ; on les appelle des mots réservés, ou mots clés. Leur liste figure
en annexe.

En C++, on distingue les mots en majuscules de ceux en minuscules. En


conséquence, A et a sont deux identificateurs différents. Les mots réservés
sont toujours en minuscules.

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.

Il existe deux sortes de commentaires en C++. L’ancien type (hérité du


C) commence par le doublon /* et se finit par */ comme dans cet exemple :

void fonction(int i)
/* Cette fonction fait ceci, cela ...
(suit une description de la fonction). */
{
...

Le deuxième type, nouveau en C++, commence par le doublon // et se


termine à la fin de la ligne courante :

7
void fonction(int i)
// Cette fonction fait ceci, cela ...
// (suit une description de la fonction).
{
... // ici commence la
fonction

Tous ces commentaires sont considérés comme des blancs par le


compilateur.

On ne peut pas en général imbriquer des commentaires du premier type


(bien qu’une option le permette parfois). Le code suivant :

/*
void fonction(int i)
/*Cette fonction fait ceci, cela ... */
{
...
}
*/

est erroné car le commentaire s’arrête à la fin de la troisième ligne, et non


en dernière ligne. Il faudrait écrire :

/*
void fonction(int i)
*//* Cette fonction fait ceci, cela ... *//*
{
...
}
*/

Il est donc préférable d’utiliser toujours le second type de commentaires,


sauf pour « débrancher » momentanément une partie du code, car on peut
alors croiser les deux types de commentaires :

/*
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.

Types de données et variables


Un langage informatique manipule de l’information, comme le terme
l’indique. Celle-ci est stockée dans les cases mémoire de l’ordinateur sous
forme de bits. Cependant, il est rare que l’on ait besoin de manipuler ces
bits en tant que tels. En général, on souhaite plutôt utiliser des entités plus
sophistiquées, comme des entiers, des réels, des caractères, etc. Chacune de
ces entités va elle-même être codée sur un certain nombre de bits dans la
mémoire, mais ce détail n’est pas très intéressant pour le programmeur,
bien qu’il ait des conséquences importantes sur lesquelles nous reviendrons.

Un langage évolué comme C++ permet d’utiliser des données de haut


niveau, comme des nombres entiers par exemple, en se chargeant lui-même
de la « basse besogne » consistant à convertir les bits de mémoire en ce type
de donnée, ou inversement.

Caractérisation des types


Le grand mot est lancé : les données ont donc un type qui indique deux
choses importantes :

• l’ensemble de valeurs dont elles font partie,


• l’ensemble des propriétés qui les caractérisent.

Donnons tout de suite un exemple simple : le type entier int


(abréviation de l’anglais integer), qui est le plus utilisé en C++, a
habituellement pour ensemble de valeurs tous les nombres de -32767 à
32768 compris ; parmi les propriétés (très nombreuses) qui le caractérisent,
on trouve un grand nombre d’opérations possibles, comme l’addition (+), la
soustraction (-), la multiplication (notée * et non avec un point ou une
croix comme en mathématiques), la division entière (/) et modulo (%), etc.

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.

Lorsque le programmeur veut utiliser une donnée, il doit :

• préciser son type en écrivant le nom de celui-ci,


• donner un nom particulier à la donnée (afin de pouvoir y faire
référence),
• éventuellement préciser des éléments supplémentaires lorsqu’il
souhaite que la donnée ait des propriétés spécifiques en plus.

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é.

La syntaxe d’une déclaration de variable est donc élémentaire :

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;

Un tel couple déclaration + initialisation sera nommé définition, car il


définit entièrement la variable. Sa syntaxe est aussi très simple :

nom_de_type nom_de_variable = valeur_initiale ;

Modification d’une variable par affectation


Lorsqu’on souhaite modifier une variable, c’est-à-dire changer sa valeur
actuelle, le moyen le plus simple est d’effectuer une affectation. Cette
opération s’exprime avec le symbole =, comme ceci (les variables nommées

11
sont entières sauf indication du contraire ; elles sont supposées déclarées
précédemment) :

nombre = 75;

A droite du signe =, on doit trouver un nom de variable ; à gauche, on


doit avoir une expression du même type que cette variable. Une telle
expression peut être composée de nombreuses façons, comme on le verra, et
notamment en utilisant une ou plusieurs variables de type adéquat séparées
par des opérateurs autorisés (nous y reviendrons). Voici quelques
exemples :

nombre = 12 + 7;
nombre = i + j;
nombre = 1 + 2 * nombre;

On notera que l’on peut aussi utiliser la valeur actuelle de la variable


pour créer la nouvelle, comme dans le cas de la troisième ligne ; une telle
écriture n’est pas une équation demandant à l’ordinateur une résolution (et
dont la solution serait ici -1), mais bien une affectation : la machine prend
l’ancienne valeur de la variable nombre (par exemple 12), la multiplie par 2
(rappelons que * signifie multiplier), ajoute 1, et place le résultat obtenu
(25) dans la case mémoire associée à nombre, modifiant ainsi sa valeur
actuelle.

Exercice 1.1 Pourquoi ne peut-on écrire :

i + j = 2;

Voir solution Quelle est l’erreur produite, en supposant les deux


variables i et j déclarées de type int ?

Solution de l’exercice 1.1


C’est que i+j n’est pas un nom de variable, mais une expression. L’erreur
Error : Lvalue required est générée; le terme lvalue, abréviation de left
value désigne tout ce qui peut se trouver à gauche de l’opérateur

12
d’affectation =, c’est-à-dire essentiellement les noms de variables (plus
d’autres écritures étudiées plus tard).

Nous verrons plus tard d’autres moyens de modifier la valeur d’une


variable ; l’affectation est cependant de loin la plus fréquemment utilisée.

Des fonctions et des programmes


Nous avons dit au début de ce chapitre que C++ était un langage
modulaire ; cela signifie que les actions élémentaires ayant une certaine
cohérence peuvent être regroupées en unités plus importantes, créant ainsi
de nouveaux types d’action, utilisables simplement par leur nom.

Une fonction élémentaire


Ainsi, supposons que l’on écrive un programme de calcul sur des
nombres entiers, dans lequel on calcule fréquemment le cube de nombre ;
un tel cube peut être écrit nombre * nombre * nombre mais c’est assez lourd.

Pour abréger nos notations, nous allons introduire dans le programme


une fonction qui aura pour tâche de calculer le cube de nombre :

int cube_nombre(void) // exemple un peu simplet !


{
return nombre * nombre * nombre;
}

On a indiqué en premier le type du résultat de la fonction (int, c’est-à-


dire entier), puis le nom de la fonction, choisi arbitrairement (ici
cube_nombre) ; viennent ensuite une parenthèse ouvrante qui indique au
compilateur que l’on déclare une fonction (et non une variable), puis la liste
des arguments (ici aucun, ce qu’on exprime par le terme void) et une
parenthèse fermante. Cette première ligne déclare la fonction. Elle est ici

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.

Voici donc un exemple de programme élémentaire :

#include <iostream.h>

int nombre = 15;

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.

Ce programme écrit simplement le cube de nombre. La directive #include


demande au préprocesseur d’inclure les en-têtes des fonctions d’entrées-
sorties. Le flot de sortie cout est celui de l’écran, et l’écriture cout << x
affiche le nombre x à l’écran. Nous reviendrons sur cela plus tard.

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.

La fonction main doit renvoyer un résultat, qui est le numéro retourné au


système d'exploitation ; la valeur 0 indique un fonctionnement normal, les
autres une erreur.

Appel d’une fonction


Nous aurions pu utiliser notre fonction cube_nombre ainsi :

#include <iostream.h>

int nombre = 15;

int cube_nombre(void)
{
return nombre * nombre * nombre;
}

main()
{
cout << cube_nombre();
return 0;
}

Observons bien la notation cube_nombre() qui a remplacé le produit


précédent. Elle indique au programme d’appeler la fonction cube_nombre,
sans arguments, et de remplacer cube_nombre() par le résultat renvoyé par
cette fonction.

Lorsque le programme arrive sur un appel de fonction comme celui-ci,


qui se distingue d’une variable par les parenthèses qui le suivent, il se
« déroute », et continue son exécution à l’intérieur de la fonction, jusqu’à ce
qu’il y rencontre une instruction return, qui lui fournit la quantité
recherchée ; le programme revient alors à l'instruction suivant l'appel de

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.

Fonctions avec arguments


Notre fonction cube_nombre n’est guère pratique, car elle ne permet de
calculer que le cube de la variable nombre ; on pourrait bien sûr écrire une
fonction comme celle-là pour chaque variable, mais ce serait fastidieux. Il
est préférable d’écrire une fonction générale calculant le cube d’un nombre
quelconque.

Voici une telle fonction :

int cube(int x)
{
return x * x * x;
}

Ici nous avons précisé un argument entre parenthèses, alors que


cube_nombre n’en avait pas, ce qui s’exprimait par le terme void.
L’argument est un entier, ce qui est exprimé par l’indication de type int.
On lui donne le nom x pour la durée de la fonction, afin de pouvoir le
désigner à l’intérieur de celle-ci ; cela n’est pas obligatoire mais, si on ne le

16
faisait pas, on ne pourrait calculer le cube de cet argument, puisqu’on ne
pourrait y faire référence.

A présent, dans le programme principal, on devra indiquer la valeur de


l’argument (nommée le paramètre effectif) de cube avant d’appeler celle-ci,
comme ceci :

cout << cube(nombre);

L’oubli du paramètre entraînerait une erreur de compilation (Error : Too


few parameters in call to 'cube(int)', c’est-à-dire trop peu de paramètres

dans l’appel de ‘cube(int)’).

Notons qu’une expression entière peut être passée en paramètre à cube,


si son résultat est du type int :

cout << cube(nombre+1);

On obtient ainsi d’agréables raccourcis d’écriture.

Exercice 1.2 Comment écrirait-on

cube(cube(nombre))

Voir solution sans utiliser de fonctions ?

Solution de l’exercice 1.2


nombre * nombre * nombre * nombre * nombre * nombre * nombre * nombre *
nombre; (eh oui!)

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 :

double ldexp(double x, int exp);

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

Lorsqu’on désire arrêter le déroulement d’une telle fonction, on peut


écrire une instruction return sans rien derrière ; en l’absence d’une telle
instruction, la fonction se termine sur l’accolade fermante. Toutes les autres
fonctions doivent avoir une instruction return, sinon elles renvoient un
résultat aléatoire (un message d’erreur prévient de l’oubli dans certains cas :
Error : Function should return a value, c’est-à-dire la fonction doit

renvoyer une valeur).

Le concept de fonction est central en C++, et nous en verrons de


nombreux exemples ultérieurement.

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.

Instruction de branchement simple if

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 :

if (condition) action1; else action2;

Ici, condition désigne n’importe quelle expression donnant un résultat


numérique (entier ou à virgule flottante). Cette quantité est calculée ; si elle
est non nulle, l’instruction action1 est exécutée ; dans le cas contraire,
l’instruction action2 est exécutée. Dans les deux cas, le déroulement du
programme se poursuit normalement sur l’instruction suivante.

Par exemple, l’instruction suivante donne un message différent selon que


la variable nombre est nulle ou non :

if (nombre) cout << "Nombre non nul";


else cout << "Nombre nul";

Notons bien que les parenthèses entourant l’expression de condition,


même lorsqu’elle se réduit à la valeur d’une seule variable comme ici, sont
absolument obligatoires.

Dans la pratique, on utilisera plutôt des opérateurs spéciaux permettant


de comparer deux quantités. Ces opérateurs sont < (inférieur à), >
(supérieur à), <= (inférieur ou égal), >= (supérieur ou égal), == (égal à), !=
(différent). Ils renvoient 1 si l’inégalité ou l’égalité est vérifiée, 0 sinon.

Ainsi, l’instruction suivante place dans y la valeur absolue de x :

if (x < 0) y = -x; else y = x;


Exercice 1.3 Écrire une fonction abs qui renvoie la valeur
absolue d’un nombre.

Voir solution

Solution de l’exercice 1.3


Pas besoin de y :

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;

On peut aussi ne rien écrire à la place de action1, comme ceci :

if (erreur) ; else cout << "Aucune erreur";

Notons le point-virgule obligatoire entre la parenthèse fermante et le


mot else. On préférera toutefois une écriture plus explicite :

if (erreur == 0) cout << "Aucune


erreur";

On peut aussi utiliser l’opérateur ! qui change les valeurs nulles en 1 et


les autres en 0, inversant ainsi les conditions :

if (!erreur) cout << "Aucune erreur";

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

ou de manière plus aérée :

if (x + y < 0) {
x = 0;

20
y = 0;
}
else {
x = x + y;
y = x -y;
}

Observons l’absence de point-virgule devant else et à la fin de


l’ensemble : les accolades fermantes les rendent inutiles (devant else, un
point-virgule est ici fautif, et produit une erreur Error : Misplaced else, else
mal placé ; à la fin de l’ensemble, il ajoute simplement une instruction
vide). Par contre, les points-virgules devant ces accolades fermantes sont
obligatoires (message d’erreur Error : Statement missing ;, ; manquant).

Boucles simples while et do


Il est fréquent que l’on doive répéter une action un grand nombre de fois
dans un programme ; en fait, la plupart des algorithmes sont basés sur de
telles répétitions. On dispose pour cela de trois instructions spéciales que
l’on appelle instructions de boucle.

Les deux premières se ressemblent beaucoup. Il s’agit de la boucle


while, dont la syntaxe est la suivante :

while (condition) action;

et de la boucle do...while de syntaxe :

do action while (condition);

La première demande à la machine de calculer d’abord la condition (qui


doit être une expression entière comme dans le cas de l’instruction de
branchement) ; si elle est nulle, on passe directement à l’instruction
suivante. Si elle est non nulle au contraire, l’instruction action est
exécutée, et le processus recommence (c’est-à-dire qu’on ne passe pas à
l’instruction suivante, mais on réévalue la condition, etc.). Une telle boucle
ne cesse donc que lorsque l’expression condition devient nulle. En
conséquence, la séquence suivante :

21
while (1) cout << "Hello !";

a l’intéressant effet d’écrire indéfiniment le mot « Hello ! » à l’écran,


puisque 1 n’est jamais nul.

La seconde boucle est à peine différente. Elle demande à la machine


d’exécuter l’action, puis d’évaluer la condition, et de recommencer ainsi
jusqu’à ce que la condition devienne nulle. La différence réside dans le fait
que l’instruction action est exécutée au moins une fois, quelle que soit la
condition. Une telle boucle est plus adaptée que la première lorsque les
variables dont dépend la condition sont calculées par l’instruction action,
et n’ont pas de sens à l’entrée de la boucle. Nous en verrons des exemples
plus tard.

Il arrive souvent que l’on souhaite exécuter plusieurs instructions dans


une telle boucle. Dans ce cas, comme pour l’instruction de branchement, on
les enclôt dans des accolades, et l’on ne place pas de point-virgule après
l’accolade fermante. Voici par exemple un programme qui affiche tous les
nombres multiples de 7 compris entre 0 et 1000.

#include <iostream.h>

main()
{
int nombre = 0;
while (nombre <= 1000)
{
cout << nombre << '\t';
nombre += 7;
}
return 0;
}

On a écrit une tabulation derrière chaque nombre. L’écriture nombre += 7


est un raccourci d’écriture pour ajouter 7 à nombre (voir plus loin).

Dans ce cas particulier, on aurait pu aussi écrire :

int nombre = 0;
do {

22
cout << nombre << '\t';
nombre += 7;
}
while (nombre <= 1000);

à la place de l’autre boucle, puisqu’on souhaite écrire au moins un nombre


de toute façon. On notera que les accolades sont absolument obligatoires,
bien qu’ici les instructions soient encloses par les termes do et while.

Exercice 1.4 Écrire un programme qui additionne tous les


nombres impairs inférieurs à 100, et qui affiche le
résultat obtenu.
Voir solution

Solution de l’exercice 1.4


Le résultat est 2 500 :

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

Boucle complexe for


La troisième instruction de boucle est bien plus sophistiquée que les
autres, bien qu’elle ne soit nullement nécessaire. Elle a la syntaxe suivante :

for (initialisation; condition; itération)


action;

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

Le programme de la section précédente aurait donc pu être écrit ainsi


(compte tenu de la possibilité d’une déclaration interne, décrite à la section
suivante) :

#include <iostream.h>

main()
{
for (int nombre = 0; nombre <= 1000; nombre
+= 7)
cout << nombre << '\t';
return 0;
}

Cette boucle permet donc essentiellement des écritures plus compactes,


au prix parfois d’une certaine perte de lisibilité, surtout si elle est utilisée
sans discernement.

On notera que l’instruction action peut être remplacée par un groupe


entre accolades, comme pour les autres boucles, mais que
initialisation et itération doivent rester des instructions uniques
(on peut toutefois en mettre plusieurs par des astuces d’écriture décrites au
chapitre 2).

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 :

for (int i = 0; i < 100; i++)


// ...

et dans des blocs internes :

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.

Nous avons vu les principales capacités élémentaires de C++. Nous


allons à présent réaliser une approche plus systématique, en étudiant plus à
fond les éléments entrevus ci-dessus, et en en développant de nouveaux.

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.

Il existe en outre un type « entier court » short int, mais son


ensemble de valeurs est le même que int sur PC. Il n’est donné que pour
des raisons de compatibilité avec les systèmes UNIX, sur lesquels les tailles
de codage des entiers ne sont pas, en général, les mêmes. On prendra garde
en général d’éviter de faire des hypothèses sur les valeurs maximales et
minimales des types entiers, qui peuvent varier d’une machine à l’autre,
parfois d’un compilateur à l’autre. Pour ppalier ces inconvénients, il est
parfoi utile d'utiliser une instruction typedef pour créer un nom de type
que l'on ajustera selon les circonstances. Il est possible aussi de calculer le
nombre d'octets occupés par un type, en utilisant l'opérateur sizeof.

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.

En pratique, il est assez lourd d’écrire unsigned int par exemple. Il


est donc permis d’omettre le mot int dans ces écritures (sauf quand il est
seul). On écrira donc unsigned pour unsigned int, long pour long
int, et unsigned long pour unsigned long int :

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.

En effet, lorsque dans un programme un débordement


quelconque se produit dans un calcul sur des entiers, aucune
erreur n’est signalée. Les calculs se poursuivent simplement en
tronquant les bits excédentaires. Il en résulte que les calculs sur les entiers à
deux octets (int et short, signés ou non) se font modulo 216 = 65536, et
sur les entiers à quatre octets (long et unsigned long) modulo 232 =
4 294 967 296. En conséquence, la variable ll ci-dessus est en fait
initialisée à 4 294 967 296 -1 = 4 294 967 295, (ou 0xFFFFFFFF en
hexadécimal), soit la plus grande valeur possible pour un unsigned long.

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:

short i = 256; int j = 512;


long l = i*j; // attention !

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.

Exercice 2.1 Après les initialisations suivantes, combien vaut x ?

int u = 10000, v = 10000;


Voir solution unsigned long x = u *v;

Solution de l’exercice 2.1


Cela dépend du compilateur, mais il est peu probable que x vale
100 000 000 comme on devrait l’attendre. En Turbo C++, par exemple, sur
PC, x vaut 4 294 959 360, soit encore 232 - 7 936. En effet, le produit u*v est
trop grand pour tenir dans un entier à deux octets ; il est donc tronqué, ce
qui donne -7 936, d’où le résultat final. Une telle écriture est donc erronée et
doit être proscrite. On peut l’améliorer par un changement de type (voir
plus loin dans ce chapitre), mais il est bien plus sûr d’écrire :

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.

La constante peut être suivie des caractères U et/ou L, écrits en


majuscules ou en minuscules, qui indiquent au compilateur de prendre la
valeur sans signe (U pour unsigned) ou longue correspondante. Par
exemple, l’écriture :

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.

Exercice 2.2 Dans cet exemple, peut-on écrire 30000L au lieu


de 30000UL ? Et si la valeur était 300000, que
faudrait-il écrire ?
Voir solution

Solution de l’exercice 2.2


Oui, car les valeurs signées et non-signées prennent la même place :
aucun débordement ne se produit, seule la signification des bits change; il
est donc inutile de placer le suffixe U. Dans le cas de 300000, il s’agit
obligatoirement d’un entier long, il est donc inutile en plus de placer le
suffixe L.

Constantes hexadécimales et octales


Les valeurs constantes entières sont généralement écrites en décimal,
mais il peut parfois être utile de les écrire en octal ou en hexadécimal. Pour
cela, on place un préfixe qui est le chiffre 0 pour les valeurs octales, et 0x
pour les hexadécimales. Par exemple, on a les égalités suivantes :

119 = 0167 = 0x77;


118783 = 0347777 = 0x1CFFF;

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

Opérateurs sur les entiers


On dispose d’un grand nombre d’opérateurs sur les entiers. Nous en
donnons ci-après la liste (on n’a pas indiqué les opérateurs de comparaison,
voir plus loin) avec des exemples dans lesquels on a supposé les
initialisations suivantes :

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

interviennent ensemble, j est transformé en long (et reste donc -12). Si j et


ul interviennent ensemble, j est transformé en unsigned long (ce qui donne

4 294 967 284). Enfin si u et l interviennent ensemble, u est converti en long.


Toutes ces conversions sont indépendantes du type du résultat.

Les opérateurs sur les entiers agissent de gauche à droite, c'est-à-dire


que l’opérande de gauche est évalué en premier. Ils ont en outre une priorité
correspondant à l’habitude : les multiplications ou divisions avant les
additions et soustractions, elles-mêmes avant les décalages qui sont encore
avant les opérations sur les bits. Par exemple, comme * précède +, une
opération comme i+j*l doit être lue i+(j*l), c’est-à-dire que la

30
multiplication est prioritaire et réalisée en premier. Le tableau de
précédence complet des opérateurs est donné en annexe.

Opérateur Description Exemples


Opérateur unaire sans effet. +i donne 3
+
+j donne -12
Opérateur unaire de changement de signe. Si x est une donne -3
-i
-
variable unsigned, on ajoute 216 ou 232 à -x pour -l donne 100000
conserver un signe positif. -u donne 30536
-ul donne 1294967296¤
Opérateur unaire inversant tous les bits (1 changé en donne -4
~i
0, 0 en 1). L’effet obtenu est que ~x = -x-1 si x est donne 11
~j
~
une variable signée, ~x = M-x si x est non signé, où M ~l donne 99999
désigne la plus grande valeur possible de x, soit ~u donne 30535
65 535 pour une variable à deux octets, et ~ul donne 1294967695
4 294 967 295 pour une à quatre octets.
Opérateur binaire symétrique de multiplication. i*j donne -36
l*i donne -300000
*
u*i donne 39464
(troncature)
Opérateur binaire de division sans reste. On obtient le l/i donne -33333
quotient de la division euclidienne des deux u/i donne 11666
opérandes, s’ils sont positifs ; sinon le résultat dépend l/j donne 8333
/ de la machine et il est préférable de se méfier. Les (-l)/j donne -8333
résultats suivants sont obtenus avec Turbo C++ sur u/j donne 0 (ici j est
PC. transformé en unsigned
de valeur 65 524)
Opérateur binaire de division modulo. Renvoie le l%i donne -1
reste de la division euclidienne des opérandes s’ils u%i donne 2
% sont positifs. On a toujours la relation x == (x/y)*y l%j donne 4
+ (x%y). (-l)%j donne -4
u%j donne 35000 (idem)
Opérateur binaire symétrique d’addition. i+jdonne -9
+
u+i donne 35003
ul+l donne 2999900000
l+u donne -65000
Opérateur binaire de soustraction. donne 15
i-j
u-i donne 34997
- ul-l donne 3000100000
l-ul donne 1294867296
(débordement)
Opérateur binaire de décalage des bits à gauche : x << i << 2 donne 12
<< y vaut x décalé de y bits à gauche ; les bits de poids l << i donne -80000
fort sortants sont perdus, les entrants valent 0, si y est u << 4 donne 35712

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

Ceci constitue une liste respectable d’opérateurs, mais en pratique, ce


sont surtout les opérateurs arithmétiques +, -, *, /, % qui sont utilisés, les
autres opérant au niveau des bits sont plutôt destinés à des opérations de
masquage assez pointues, et rares.

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

On ne peut écrire une apostrophe seule de cette manière. Il faut utiliser


la deuxième ou la troisième méthode. La deuxième est un ensemble de
caractères spéciaux signalés par une barre oblique inverse \, dont voici la
liste :

Caractère Description Code ASCII


'\a' signal sonore 7
'\b' espace arrière 8

32
'\f' saut de page 12
'\n' nouvelle ligne 10
'\r' retour chariot 13
'\t' tabulation 9
'\v' tabulation verticale 11

'\\' barre oblique inverse \ 92

'\'' apostrophe ' 39

'\"' guillemet " 34

'\?' point d’interrogation ? 63

Les deux derniers peuvent être écrits sans barre oblique inverse : '"' et
'?'.

La troisième méthode consiste à donner directement la valeur ASCII du


caractère soit en octal soit la forme '\nnn' où nnn désigne un nombre octal
(sans 0 initial obligatoire), soit en hexadécimal sous la forme '\xhh' où hh
désigne un nombre en hexadécimal. On a donc par exemple les identités :

'?' == '\?' == '\77' == '\x3F'

car il s'agit du 63ème caractère de la table ASCII, et que 63 == 077 = 0x3F.


Il n’y a pas d’opération spécifique sur les caractères, qui sont considérés
comme des entiers par le compilateur. Dans ces opérations, un octet de
poids fort est ajouté au caractère pour obtenir un entier sur deux octets.
Donc (char)63 donne encore '?'.

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.

Chaînes de caractères constantes


Une chaîne de caractères est constituée d’un certain nombre de
caractères suivis du caractère spécial nul '\0', placé par le compilateur, qui
indique la fin de la chaîne. Pour écrire une chaîne constante, on la place

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 :

"\tLes sanglots longs\n\tDes violons\n"

sera écrite ainsi :

Les sanglots longs


Des violons

avec passage à la ligne à la fin de chaque vers (caractère \n) et tabulation au


début (caractère \t). Dans tous les cas, le zéro final est ajouté
automatiquement; ce caractère n’est jamais affiché.

Exercice 2.3 Laquelle des deux chaînes suivantes est incorrecte ?


Pourquoi ? Combien y a-t-il de caractères dans l’autre
chaîne, et que vaut-elle ?

Voir solution "\xffFolie !\"\n"


"\xFF\"Farceur\a\\\t !\"\n"

Il n’existe aucun opérateur sur les chaînes de caractères. On dispose


cependant dans la librairie <string.h> d’un grand nombre de fonctions
adaptées, dont nous verrons quelques exemples ultérieurement.

Solution de l’exercice 2.3


La première chaîne est incorrecte. En effet, comme elle commence par
\x, on cherche un nombre hexadécimal derrière. On trouve alors ffF (o n'est
pas un chiffre hexadécimal), soit un nombre trop grand pour convenir à un
caractère (0 à 255 = 0xFF seulement). Le compilateur affiche alors Error :
Numeric constant too large (constante numérique trop grande).

La seconde chaîne est correcte. Elle correspond au caractère 255 = 0xFF


(\xFF), suivi d’un guillemet " (\"), puis des lettres F, a, r, c, e, u, r, du
caractère 7 = '\a' (signal sonore), d’une barre oblique inverse \ (\\), d’une
tabulation (\t), d’un espace, d’un point d’exclamation, d’un autre guillemet

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) !"

le caractère 255 étant affiché comme un espace au début.

Nombres à virgule flottante


On a parfois besoin de faire des calculs sur des nombres décimaux (ou
nombres à virgule flottante), et non seulement entiers. Pour cela, on
dispose de trois types à virgule flottante, nommés float, double et long
double. Ils se distinguent par la taille qu’ils occupent et la précision qu’ils

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.

À ces intervalles de valeurs il faut ajouter quatre valeurs spéciales, 0,


+infini, -infini et NaN (not a number, pas un nombre). Les trois dernières
sont générées en cas de débordement de capacité, ou d’opération interdite
(0/0 par exemple). En principe, de telles valeurs ne sont jamais vues car
elles provoquent l’arrêt du programme avec un message. Toutefois, ce
comportement par défaut peut être modifié par les routines signal et
matherr. Par défaut, les dépassements de capacité par le bas ne sont pas
interceptés : ils renvoient simplement la valeur 0.

Les entiers sont automatiquement convertis en décimaux lorsqu’une


opération fait intervenir ces deux types de variables. Inversement, des
affectations comme celle-ci :

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).

Opérateurs sur les décimaux


Les opérateurs sur les décimaux sont les suivants, dans l’ordre de
priorité :

+ Opérateur unaire sans effet.


Priorité forte
- Opérateur unaire de changement de signe.
* Opérateur binaire symétrique de multiplication.
Priorité moyenne Opérateur binaire de division ;
/
il s’agit de la division de deux décimaux au sens usuel.
+ Opérateur binaire symétrique d’addition.
Priorité faible
- Opérateur binaire symétrique de soustraction.

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).

Étudions cela sur un exemple :

int i = 4;
int u = i++;
int v = i--;
int w = --u;
int t = ++v;

À l’issue de ces initialisations, que valent les différentes variables ? Il


suffit de regarder pas à pas. Au départ, i vaut 4. L’écriture u = i++ est une
post-incrémentation (++ est derrière la variable i à laquelle il s’applique) qui
a deux effets. D’abord, la valeur de i, soit 4, est recopiée dans u ; puis la
variable i est incrémentée. À l’issue de cette seconde ligne, u vaut donc 4 et i
5. La troisième ligne est semblable, mais cette fois i est décrémenté après
avoir été copié dans v; donc i vaut alors 4 et v 5.

Dans la quatrième ligne, on a une pré-décrémentation. Par conséquent, u


est d’abord décrémenté, prenant ainsi la valeur 3, puis cette valeur est
recopiée dans w. Dans la dernière ligne, on a une pré-incrémentation ; v est
donc incrémenté, prenant la valeur 6, et recopié dans t.

À l’issue de l’ensemble, i vaut 4, u 3, v 6, w 3, t 6. Si cela ne vous


paraissait pas évident au premier abord, c’est normal !

Ces deux opérateurs sont parfois très pratiques comme raccourcis


d’écriture. En outre, ils correspondent à des opérations très naturelles et

37
donc très rapides du processeur. Cependant, il est évident qu’ils diminuent
la lisibilité des programmes.

On prendra garde de ne pas tenter de les appliquer à des expressions qui


ne représentent pas des variables. Ainsi, l’expression :

u = (i + j)++;

est refusée par le compilateur (Error : Lvalue required), tandis que celle-ci :

u = i + j++;

est correcte, car l’opérateur d’incrémentation est prioritaire. Ici, u prend la


valeur de la somme i+j, puis j est incrémenté.

Exercice 2.4 Après la séquence d’instructions suivantes,


combien valent les variables ?

int i = 10, j = 3;
Voir solution float f = i/j;
j = 10-f++*--i;

Solution de l’exercice 2.4


>code>f vaut 4, i vaut 9 et j vaut -17. En effet, i/j vaut 3 (et non 3.333...), car il y a
troncature tant que l’on reste avec des opérandes entiers. Ensuite, la dernière ligne équivaut,
vu la priorité et l’ordre des opérateurs (cf. annexe), aux suivantes :
i = i -1; // pré-décrémentation
j = 10 - (f*i); // * prioritaire par rapport à -
f = f + 1; // post-incrémentation

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

avec la convention suivante : une valeur nulle correspond à la valeur logique


faux, une autre valeur à vrai.

Il existe des opérateurs qui fournissent automatiquement des valeurs


logiques, c’est-à-dire en l’occurrence un entier égal à 0 ou 1 ; ils s’appliquent

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 <.

Ces opérateurs sont redondants, puisque par exemple x<y équivaut à


!(x>=y), que !x équivaut à x == 0, etc.

Exercice 2.5 Il n’existe pas d’opérateur logique unaire qui


renvoie 1 si son opérande est non-nul, 0 sinon.
Comment le simuler ? Est-ce très utile ?
Voir solution

Solution de l’exercice 2.5


Il suffit d’écrire !!x (si x n’est pas décimal) ou encore x != 0. C’est
toutefois d’une utilité assez faible, puisque la valeur logique obtenue est
équivalente à celle de x seul. On pourra ainsi aussi bien écrire if (x)... que
if (!!x)...

Lorsqu’on a deux valeurs logiques, on peut effectuer des opérations


dessus à l’aide de deux opérateurs particuliers :

&&
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 :

if ( (x >0) && (y >0) ) action;

si l’on souhaite que action soit exécutée seulement quand les variables x
et y sont strictement positives.

On a les relations dites de Morgan entre ces deux opérateurs :

x && y = !(!x || !y)

x || y = !(!x && !y)

Notons finalement que si test1 et test2 sont deux instructions


booléennes, alors test1 && test2 n’est pas exactement identique à test1 &
test2. (Dans ce dernier cas on utilise un opérateur sur les entiers, voir
précédemment.) En effet, lorsque que les tests provoquent des exécutions,
comme par exemple des appels de fonctions, le premier est exécuté d’abord,
et s’il donne la valeur « faux » le second n’est pas exécuté lorsqu’on écrit
test1 && test2 (car le && donnera de toutes façons « faux »), tandis qu’il l’est

tout de même (inutilement) dans l’autre écriture. Cela permet non


seulement de raccourcir l'exécution, mais aussi d’éviter des erreurs subtiles,
comme dans cet exemple, où p est un pointeur sur un entier :

if ( (p != null) && (*p > 0)) ...

Le premier test vérifie que le pointeur pointe effectivement sur quelque


chose, et le second n’est exécuté que dans l’affirmative. Remplacer && par &
ici provoquerait des erreurs d’exécution en cas de pointeur nul !

Un remarque similaire vaut pour la différence entre || et |. On retiendra


qu’il faut quasiment toujours utiliser && et || avec les booléens. (Voyez aussi
la solution de l’exercice 2.7 ci-après.)

Exercice 2.6 Il n’existe pas d’opérateur de « ou exclusif logique »


en C++, qui renverrait 1 si l’un seulement de ses

40
opérateurs est non nul, 0 sinon. Comment peut-on le
Voir solution
simuler ?

Solution de l’exercice 2.6


Cela dépend des opérandes. Si ce sont deux
expressions logiques, il suffit d’utiliser l’opérateur !=.
Par exemple, l’expression (x < 0) != (y < 0) renvoie 1
si les variables x et y ont un signe opposé. Si les
opérandes sont des entiers, on ne peut pas utiliser x !=
y ni x^y car une telle expression peut être non nulle

avec deux arguments non nuls (par exemple 1^3 donne


2 et 1!=3 donne 1). Par contre, on peut utiliser !x^!y
qui donne bien 0 si les deux arguments sont non nuls,
ou nuls ensemble, et 1 sinon. Enfin avec deux
décimaux, on pourra écrire (x ==0)^(y ==0) ou (x
==0)!=(y ==0).

Les deux relations suivantes sont-elles vraies, si i et


Exercice 2.7
j sont deux entiers ?

if (i&&j)... équivaut à if (i&j)...


Voir solution if (i||j)... équivaut à if (i|j)...

Solution de l’exercice 2.7


La première relation est fausse. En effet, si i vaut 1 et j 2 par exemple, on
a i&&j égal à 1 puisque les deux arguments sont non nuls, alors que i&j vaut
0 (pas de bit à 1 correspondant). Par contre la seconde relation est vraie. En
effet, si les deux variables sont nulles, on a zéro dans les deux cas. Si i est
nul mais pas j, on obtient d’une part if (1)... et d’autre part if (j)... et
l’instruction est exécutée dans les deux cas. Si les deux variables sont non
nulles, i||j vaut 1 et i|j est non nul ce qui équivaut à la même valeur
logique.

41
A méditer, pour éviter des écritures trop sophistiquées qui se
révéleraient catastrophiques !

Instruction opératoire et affectation


Une instruction peut être composée simplement d’une opération, comme
ceci :

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

permet d’incrémenter i. Dans ce cas, on aurait pu aussi écrire ++i en


obtenant le même effet, puisqu’on n’utilise pas la valeur de i pour la
recopier ailleurs. On dit que les opérateurs ++ et -- ont un effet de bord,
alors que les autres n’en ont pas, dans le sens qu’ils ne modifient pas les
termes auxquels ils s’appliquent.

Inversement l’affectation, que nous avons déjà rencontrée sous la forme :

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

A l’issue, i vaut a+b, et j vaut le triple de i. Il s’agit là encore d’un


raccourci d’écriture. Les parenthèses sont ici obligatoires, car l’opérateur = a
une priorité plus faible que les autres (voir annexe). En leur absence, le
compilateur tenterait de recopier a+b dans 3*i ce qui est impossible puisque
3*i n’est pas une « lvalue » , c’est-à-dire une variable.

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)).

Dans des appels de fonctions aussi, on souhaite parfois conserver un


argument :

gotoxy( x = wherex(), y = wherey()+1 );

Attention toutefois dans les initialisations qui ne peuvent être faites à


tiroirs. En effet, l’écriture :

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.

L’opérateur d’affectation peut être combiné avec des opérateurs binaires


*, /, +, -, %, <<, >>, &, |, ^ pour former un opérateur d’assignation composé.

Par exemple x += y équivaut à x = x + y. Il s’agit là encore d’un raccourci


d’écriture, qui donne une légère accélération au programme, car le
processeur n’adresse qu’une seule fois la variable.

Ainsi les instructions :

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).

Comme il s’agit d’opérateurs d’assignation, on peut encore écrire, par


exemple :

i *= (j /= 5);

ce qui a pour effet de diviser j par 5 et de multiplier i par le quotient


obtenu; ainsi, si i valait 12 et j 17, en sortie ils valent respectivement 36 et 3.

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.

Dans d’autres cas, la transformation n’est pas automatique, il faut


l’expliciter avec un opérateur de changement de type, sans quoi le
compilateur proteste. Un tel opérateur a une syntaxe très simple : pour
transformer une variable ou une expression d’un type quelconque en un
type différent xxx, on écrit :

(xxx) expression

ou encore :

xxx (expression)

comme si xxx était une fonction.

Voici un exemple simple. Si l’on écrit :

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 sera initialisé à 33.333..., comme attendu, car j aura été transformé en


décimal avant la division; dans ce cas, i est aussi transformé implicitement
en décimal pour faire la division, suivant les règles de conversion résultant
des opérations. (On aurait pu aussi faire le changement de type sur i.)

Le changement de type est réellement considéré comme un opérateur


unaire par le compilateur (des opérateurs unaires en fait, car il en existe
autant que de types), et il peut être redéfini comme les autres (voir le
chapitre 7). Il a une priorité plus élevée que les opérateurs binaires.

Exercice 2.8 Parmi les lignes suivantes, lesquelles donnent à d la


valeur 33.333... ?

d = double(i)/j;
Voir solution d = (double) i/j;
d = double(i/j);
d = double(i)/ double(j);

Solution de l’exercice 2.8


Toutes, sauf la troisième puisque i/j vaut 33. La seconde ligne est
identique à la première, et non à la troisième, puisque le changement de
type a une précédence plus forte que la division (voir annexes).

Le changement de type est couramment utilisé pour modifier les types de


pointeurs (voir paragraphe à ce sujet).

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 :

condition ? valeur1 : valeur2

L’expression condition est évaluée; si elle est non nulle, l’opération


renvoie valeur1 sinon valeur2. Par exemple, l’écriture suivante :

z = ( x < y ? x : y);

place le plus petit de x ou de y dans z (les parenthèses sont facultatives


mais recommandées). Ici cela ne sert à rien, car on aurait pu aussi bien
écrire :

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 :

cout << "La plus petite racine est "


<< sqrt(x < y ? x : y) << ".\n";

à l’autre possibilité :

cout << "La plus petite racine est ";


if (x < y) cout << sqrt(x); else cout << sqrt(y);
cout << ".\n";

déjà nettement plus lourde (et l’on aurait pu faire pire, avec une fonction
plus complexe que sqrt).

Autre opérateur de raccourci, la virgule. Elle permet d’enchaîner


plusieurs expressions là où il n’en faudrait qu’une en principe ; les
expressions sont calculées de gauche à droite, et le résultat est celui de la
dernière, les résultats intermédiaires étant perdus.

Par exemple, l’écriture suivante :

46
j = (i = j, 100);

place la valeur 100 dans j et l’ancienne valeur de j dans i. Ici les


parenthèses sont obligatoires, car la virgule a une priorité plus faible que
l’assignation.

En pratique, l’écriture précédente aurait été plus claire sous la forme :

i = j; j = 100;

mais on peut parfois éviter des longueurs avec la virgule, en remplaçant


deux instructions (ou plus) par une seule. Cela est particulièrement utile
dans une boucle for, dont la partie initialisation et la partie incrémentation
ne doivent avoir qu’une seule instruction. Par exemple :

for (i = 0, somme = 0; i <= 100; somme += i++);


cout << somme << endl;

calcule puis affiche la somme de tous les nombres de 1 à 100 (exercice : le


vérifier)(ici endl provoque un saut de ligne, voir chapitre 9). Dans cet
exemple, on voit à l’œuvre les principaux raccourcis du langage C++ que
sont la boucle for, la virgule (dans l’initialisation), l’affectation-addition +=,
et la post-incrémentation (de i). Sans ces outils, il aurait fallu écrire :

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 :

taille = sizeof x; // ou sizeof(x)


taille = sizeof(float);

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.

Précédence des opérateurs


Les opérateurs, lorsqu’ils sont combinés dans une même expression,
sont évalués dans un certain ordre. Nous avons déjà dit par exemple que
a+b*c équivalait à a+(b*c) et non à (a+b)*c car la multiplication est prioritaire
par rapport à l’addition.

D’autre part, les opérateurs non symétriques exigent un ordre précis


d’évaluation, soit de droite à gauche, soit de gauche à droite. Par exemple,
a/b/c doit se lire (a/b)/c car la division est évaluée de gauche à droite.

Nous donnons en annexe l’ordre de priorité des opérateurs. Ils sont


répartis en seize groupes. À l’intérieur de chaque groupe, l’ordre de
circulation gauche-droite ou droite-gauche précisé s’applique. Par exemple,
* et / sont dans le même groupe (précédence 12) avec la circulation gauche-

droite ; donc l’expression a*b/c*d doit se lire ((a*b)/c)*d. Les groupes de


numéros plus petits ont la plus forte priorité. Dans le doute, il est largement
préférable de mettre des parenthèses.

Exercice 2.9 Que signifie l’écriture suivante :

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

x + (++y), parce que les expressions sont lues de

gauche à droite, et que ++ est prioritaire par rapport à


l’addition.

Exercice 2.10 Pour quelles valeurs de x l’instruction instruction

sera-t-elle exécutée ?

Voir solution if (0 < x < 100) instruction;

Solution de l’exercice 2.10


Pour toute valeur. En effet, comme < est évalué de gauche à droite (voir
annexe), elle équivaut à if ( (0 < x) < 100)... Or (0 < x) vaut 0 ou 1
suivant le signe de x, et est donc toujours inférieur à 100. Une écriture bien
trompeuse, à la place de laquelle il fallait mettre :

if ( (0 < x) && (x < 100) ) instruction;

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.

Ainsi, après la déclaration suivante :

int i = 18;

le compilateur a réservé une case mémoire de deux octets pour la variable i,


et chaque fois que dans le programme on parle de i, le compilateur sait qu’il
doit utiliser cette case-là. Nous dirons que i est une référence pour la
donnée correspondante de valeur 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++;

À la fin de ces instructions, i et j seront tous deux égaux à 20. Plus


exactement, l’unique case mémoire dénommée à la fois i et j contiendra la
valeur 20.

Les références servent rarement directement. En général, on les utilise


dans des paramètres de fonctions, ou des résultats, ainsi qu’en
programmation orientée objet. Nous en verrons de nombreux exemples
plus loin.

Signalons d’autre part que l’on peut indifféremment écrire :

int& j = i;

ou :

int &j = i;

La première écriture signifie « j est du type référence sur un entier int&,


et équivaut à i » , tandis que la seconde signifie « &j, c’est-à-dire la case
mémoire référencée par j, est de type entier, et est la même que i » ; ces
deux significations sont équivalentes, si bien qu’on peut même écrire :

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 :

int i = 18, &j = i;

On tient compte dans ce cas que les initialisations se font de gauche à


droite.

Il est important de comprendre qu’il n’existe aucune opération sur les


références, car il serait impossible au compilateur de différencier une telle
opération de celle agissant sur le type de base. C’est pourquoi, lorsqu’on

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&.

En particulier, il n’y a pas d’opérateur d’affectation donnant une


référence. Par conséquent, une référence ne peut être modifiée, et ne prend
de valeur qu’au moment de son initialisation. Pour cette dernière raison,
l’initialisation d’une référence est obligatoire. L’écriture :

int &j; // incorrect

est refusée par le compilateur (Error : Object must be initialized, l’objet


doit être initialisé).

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 :

int &k = 10;

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 provisoire = 10;


int &k = provisoire;

Fonctionnellement, un tel code ne diffère pas de :

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).

Le comportement est identique lorsqu’on initialise une référence avec


une expression, ou une variable d’un type différent de celui indiqué dans
l’expression, même proche. Ainsi, l’écriture suivante :

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’.

Lorsqu’on spécifie un changement de type, le comportement n’est pas


clair et dépend manifestement du bon vouloir du compilateur :

int &k = (int) v;

sera toujours accepté par Turbo C++, sans erreur ni avertissement, si la


variable v peut être convertie en un entier. Cependant, ce n’est que si v est
du type unsigned, long ou unsigned long, ou encore de l’un des types short, et
évidemment int, qu’aucune variable temporaire n’est créée. Par contre, si v
est du type char, ou de l’un des types décimaux, une variable temporaire est
créée mais non signalée, à la suite d’un bogue dans Turbo C++... Autant
éviter ces écritures par conséquent, et utiliser franchement des pointeurs.
Notons que depuis le spécification 2.1 de C++, de telles écritures sont
interdites.

Naturellement, on peut créer autant de types référence qu’il y a de types


possibles : long&, float&, double&, etc.

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.

Le C++ fournit un moyen de créer de tels groupes, appelés tableaux en


informatique. Il suffit de nommer le tableau et d’indiquer le type de ses
éléments et leur nombre entre crochets. Par exemple la déclaration
suivante :

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 :

for (int i = 0; i < 20; i++) tab[i] = i+1;

Il est parfaitement possible d’utiliser la variable tab toute seule. Elle


désigne alors le tableau tout entier, c’est-à-dire en fait un pointeur sur le
premier élément tab[0] (à propos des pointeurs, voir plus loin). Un tel
pointeur est toutefois constant et ne peut être modifié.

Tableaux sans dimension


La longueur du tableau doit toujours être une constante, jamais une
quantité susceptible de varier dans le programme. Cependant, il arrive que
l’on ne sache pas au moment de l’écriture du programme quelle sera
exactement la longueur du tableau. Par exemple, on peut avoir besoin d’un
tableau de taille n, où n est une variable entière susceptible de prendre
n’importe quelle valeur a priori. Dans ce cas, si l’on doit réserver la place en
mémoire, il faut utiliser des pointeurs (voir paragraphe ci-après).

Cependant, dans certains cas, on ne connaît pas la taille du tableau, mais


on sait que le tableau existe déjà. C’est ce qui arrive dans un appel de
fonction. Voici un exemple de fonction qui affiche les n premiers éléments
d’un tableau d’entiers à l’écran, séparés par des tabulations :

void affiche_tableau(unsigned n, int tableau[])


{
for (int i = 0; i < n; i++)
cout << tableau[i] << '\t';
}

On notera que la fonction n’a pas besoin de savoir la taille du tableau,


seulement le nombre d’éléments à afficher : ce nombre peut être plus petit
que la taille du tableau. On pourra par exemple n’afficher que la moitié du
tableau tab défini à la section précédente :

54
affiche_tableau(10, tab);

Il est même possible de n’afficher que la fin du tableau, comme ceci :

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.

Écrire une fonction qui affiche les éléments


d’indices debut à fin (compris) d’un tableau d’entiers, à
Exercice 3.1 raison de quatre par ligne d’écran ; debut et fin sont
deux entiers paramètres de la fonction.

Voir solution

Solution de l’exercice 3.1


Il suffit de passer à la ligne tous les quatre éléments :

void affiche_tableau2(unsigned debut, unsigned


fin,
int tableau[]);
{
int elem = 1;
for (int i = debut; i <= fin; i++)
cout << tableau[i] << (elem++ %4 ? '\t':
'\n');
}

On observera que l’utilisation de l’opérateur ?: permet d’éviter une


écriture plus complexe avec un if, sans toutefois être absolument
nécessaire. D’autre part, le nombre elem qui compte les éléments pourrait

55
être remplacé par (i -debut), à condition de faire cette soustraction à
chaque étape.

Dépassement des index d’un tableau


Ceci nous amène à une angoissante question. Que se passe-t-il si par
erreur on adresse un élément du tableau qui n’existe pas ? Par exemple si
l’on utilise tab[20], puisque les indices valables vont de 0 à 19 dans notre
exemple).

Dans ce cas, il ne se passe rien de particulier, et aucune erreur n’est


affichée. Simplement, on obtient les entiers qui suivent ou précèdent dans la
mémoire. Qu’est-ce à dire ? Du point de vue du tableau tab, la mémoire est
organisée en blocs contenant des entiers, tous de la même taille (deux octets
dans nos exemples) ; la valeur de tab elle-même est un pointeur sur le
premier élément, c’est-à-dire que l’on sait où exactement se trouve l’élément
0. La mémoire a donc l’allure représentée ci-après :

Dans ce schéma, la zone en blanc représente la mémoire allouée par le


programme au tableau, c’est-à-dire celle qui lui est réservée. Les zones
grises sont des zones de mémoire occupées par d’autres variables, ou par du
code. On voit cependant que la numérotation des éléments du tableau
indiquée ne s’arrête pas à 19, mais se poursuit au-delà. Elle se poursuit aussi
avec des indices négatifs avant le premier élément. Donc, si l’on utilise
tab[20], on obtiendra ce qui se trouve en mémoire juste après le tableau,
sous la forme d’un entier, c’est-à-dire probablement n’importe quoi. Le
comportement du programme ne sera sans doute pas celui espéré. Ce sera
pire encore si l’on écrit à cet endroit, car on modifiera ainsi des variables de
façon inattendue.

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 :

for (int i = 0; i < 20; i++)


tab2[i] = tab[i];

ou encore la routine memmove, qui recopie une partie de la mémoire dans une
autre (voir plus loin) :

memmove( tab2, tab, 20*sizeof(int) );

On notera l’utilisation de sizeof pour obtenir la taille des entiers, afin


d’avoir un code portable. On aurait pu aussi écrire sizeof(tab) au lieu de
20*sizeof(int), puisque ici on recopie tout le tableau.

L’utilisation de memmove n’est guère pratique, mais bien plus rapide que la
boucle précédente

Initialisation d’un tableau


Il est parfaitement possible d’initialiser un tableau. Il suffit pour cela de
donner les valeurs de ses éléments, séparées par des virgules, entre
accolades :

int table[4] = { 1, 2, 3, 4 };

On n’est pas obligé de donner tous les éléments :

int table[100] = { 1, -1, 2, -2 };

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 :

int table [7] = {1, , , , 5, , 7}; // non, incorrect

(Error : expression expected, expression attendue); il faut placer


explicitement les zéros manquants.

Comme les variables, il est préférable de toujours initialiser les tableaux.

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

créent un tableau de 9 décimaux et un autre de 100 entiers. Les éléments de


ces tableaux sont accessibles par indirections successives : matrice[2][1] par
exemple donne le sixième élément (6 == 3*2+1 -1) du tableau.

Un tel tableau peut être initialisé, il s’agit en effet d’un tableau de


tableaux :

int mat[3][2] = { {1, 2}, {3, 4}, {5, 6} };

On peut omettre des valeurs avec les mêmes règles. Ainsi l’écriture :

int mat[5][2] = { {1, 2}, {0}, {4, 5}, {8} };

donnera à mat la valeur initiale .

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.

Un pointeur est un nombre qui désigne l’adresse d’une partie de la


mémoire. Voici un exemple :

int i = 18;
int* p = &i;

On a ici déclaré un pointeur p sur le type int (type int*), et on l’a


initialisé sur l’adresse de la variable i. En effet, dans ce contexte, l’opérateur
& désigne l’adresse en mémoire d’une variable (écriture cohérente avec son
usage pour les références). Si l’on imagine par exemple que le compilateur a
placé la variable i à l’adresse 1000 (c’est-à-dire dans les octets numérotés
1000 et 1001), alors la valeur de p sera précisément cette adresse 1000. Il
s’agit donc effectivement d’un nombre, de deux ou quatre octets, voire plus,
selon les types de machines, mais que nous dénommerons plus
fréquemment adresse par la suite.

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.

On peut alors avoir accès à la valeur de i par l’intermédiaire de p, en


écrivant par exemple :

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

Ceci est cohérent, puisque *p désigne effectivement une variable de type


int, et permet de placer plusieurs déclarations ensemble, comme ceci :

int i = 18, j = i, *p = &i, &k = i;

Signalons un type pointeur très souvent utilisé, le type char*. Il permet de


désigner une chaîne de caractères, en pointant sur le premier élément de
cette chaîne. La librairie <string.h> qui opère sur ces chaînes prend toujours
le type char* comme argument de ses fonctions. Nous donnerons des
exemples de telles fonctions dans la suite.

Exercice 3.2 Que désignent les déclarations suivantes :

int *p[10];
int (*q)[5];
Voir solution
int *(r[7]);

Solution de l’exercice 3.2


Les crochets sont prioritaires par rapport au déréférencement (voir
annexes). Donc p est un tableau de dix pointeurs int*, et r de sept. Mais q est
un pointeur sur un tableau de cinq entiers.

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

pour des raisons de précautions ; il faut écrire :

char *p;
int *q = (int*) p;

c’est-à-dire utiliser un changement de type, afin que le compilateur accepte


l’affectation ; dans ce cas, p et q ont exactement la même valeur : les
modifications apportées à *p, c’est-à-dire à l’emplacement de mémoire
pointé par p, se répercuteront donc forcément sur *q, puisqu’il s’agit de la
même place mémoire. On a donc ainsi une partie de la mémoire accessible
de deux façons différentes (comme avec les références), mais sous deux
types différents aussi (contrairement aux références). En général, de tels
comportements ne sont pas souhaitables.

Le schéma suivant illustre la différence entre pointeur et référence. On


suppose les déclarations suivantes :

double d = 1.2, &dd = d, *p = &d;

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) :

Arithmétique des pointeurs

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.

L’opérateur d’incrémentation ++ par exemple agit de la même façon que


sur des entiers. Cependant, p est augmenté d’une position, non d’un octet.
En effet, p pointe sur un type T qui a une certaine taille en octets t (dans le
cas de int par exemple, t vaut 2 ou 4 en général) ; lorsqu’on écrit p++, dans
ce cas, l’adresse qui est la valeur de p est augmentée de t octets, afin de
pointer sur l’élément de type T supposé suivre dans la mémoire.

On retrouve ainsi un comportement identique à celui des tableaux. Un


pointeur de type int*, par exemple, considère que la mémoire est découpée
en tranches de deux octets (si le type int occupe deux octets) ; un pointeur
de type long* la considère comme découpée en tranches de quatre octets, et
un de type char* la voit en tranches de un octet seulement.

Ainsi, si l’on écrit :

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.

Exercice 3.3 D’après les règles de précédence des opérateurs,


qu’est-ce qui est affiché ici :

char s[] = "verte", *p = s;


cout << *++p;
Voir solution cout << ++*p;
cout << *(p++);
cout << *p++;

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 :

cout << *++p << ++*p << *(p++) << *p++;

aurait provoqué l’affichage de la chaîne tsev, la chaîne d’origine étant


transformée en "veste". En effet, dans ce cas, les arguments sont évalués de
droite à gauche, et affichés dans l’ordre inverse, à cause des appels
imbriqués de la procédure d’affichage.

Tout cela explique pourquoi il est préférable d’éviter ce genre d’écriture


peu claires !

On peut ainsi ajouter ou retrancher un entier à un pointeur ; le pointeur


se déplace alors du nombre de positions indiquées. Ainsi, en exécutant à la
suite des précédentes :

pl += 6; pi -= 2;

le pointeur pl pointera six positions d’entiers longs plus loin, soit à


l’adresse 1004 + 6 * 4 = 1028, tandis que pi pointera sur 1002 - 2 * 2 = 998.

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;

Dans ce cas, comme pi vaut 998, pi2 vaudrait 1008.

De même, il est possible de retrancher deux pointeurs de même type ;


dans ce cas, on obtient un entier, qui n’est pas la différence des adresses,
mais la différence des positions. Par exemple la différence :

int i = pi2 - pi;

placera la valeur (1008 - 998)/2, soit 5, dans i.

Il en résulte en particulier qu’on ne peut calculer la différence de deux


pointeurs de types différents, sauf en utilisant une conversion de type afin
de les faire coïncider :

i = pl - pi; // interdit types différents


i = (int*) pl -pi; // ok, changement de type pour pl

L’arithmétique des pointeurs est très souvent utilisée dans la pratique.


Voici par exemple une implantation possible de la fonction strlen qui
renvoie la longueur d’une chaîne de caractères (caractère nul final non
compris) :

unsigned strlen(char *chaine)


{
for (char *s = chaine; *s; s++) //
les chaînes se terminent par 0;
return s -chaine;
}

Que se passe-t-il en réalité ? Un autre pointeur char* nommé s est créé et


initialisé à la même valeur que chaine. Puis il est incrémenté (s++), jusqu’à
rencontrer le caractère nul (c’est-à-dire jusqu’à ce qu’on n’ait plus *s != 0,
d’où la forme particulièrement simple du test : *s). La différence de valeur
entre les deux pointeurs est alors égale à la longueur de la chaîne.

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* ?

Solution de l’exercice 3.4


Il faut simplement remplacer les deux occurrences
du mot char par le mot int, plus éventuellement
modifier les noms des variables et de la fonction :

unsigned tablen(int *tableau)


{
for (int *p =
tableau; *p; p++);
return p -tableau;
}

On notera que, bien que les entiers int n’occupent


pas la même place mémoire que les caractères, les
règles de l’arithmétique des pointeurs font que le
nombre cherché est bien p - tableau, et non la moitié.

Exercice 3.5 En vous inspirant du schéma de fonction


précédent, réécrivez la fonction strcmp, qui compare
deux chaînes de caractères s1 et s2, et renvoie 0 si
elles sont égales, une valeur < 0 si s1 < s2 (au sens
lexicographique, c’est-à-dire si s1 précède s2 dans le
Voir solution dictionnaire), et une valeur > 0 si s1 > s2.

On supposera les caractères unsigned, comme dans


la librairie <string.h>.

Solution de l’exercice 3.5


Voici un exemple de solution :

65
int strcmp(char *s1, char *s2)
{
while ( (*s1) && (*s2) && (*s1 == *s2) )
{ s1++; s2++; }
return *s1 -*s2;
}

Brève explication : la boucle while s’arrête lorsque l’une des conditions


suivantes est rencontrée : soit l’une des deux chaînes a été lue entièrement ;
dans ce cas, si c’est aussi le cas de l’autre, les deux chaînes sont égales, et
comme *s1 == *s2 == 0, on a bien le résultat souhaité. Sinon, si par exemple
*s2 == 0, mais *s1 != 0, la chaîne s1 est plus longue que s2, donc supérieure,

et l’on renvoie *s1 qui est > 0, soit le résultat souhaité.

Deuxième cas d’arrêt de la boucle : on rencontre deux caractères


différents. Dans ce cas, il est normal de renvoyer leur différence, qui indique
précisément l’ordre des deux chaînes.

Pointeur sur rien, pointeur sur tout


Il existe un type spécial nommé void, que l’on utilise lorsqu’une
fonction n’a pas d’arguments, ou pas de résultat ; il signifie en quelque sorte
« rien » .

Il est parfaitement possible de déclarer un pointeur de type void*. Dans


ce cas, on obtient un « pointeur sur rien » , ou plutôt un « pointeur sur
tout » . En effet, dans ce cas, on peut placer n’importe quel autre pointeur
dans p sans faire de changement de type :

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 :

void *memmove(void* dest, void* source, unsigned


taille);

Cette fonction recopie taille octets de l’adresse source vers l’adresse


dest, et renvoie dest. On voit ici que seule l’adresse placée dans les
pointeurs est importante ; la nature de ce qui est pointé est sans intérêt. On
peut ainsi recopier des blocs entiers de mémoire de tous types, par exemple
des tableaux, comme on l’a vu au paragraphe précédent.

Exercice 3.6 Réécrivez la fonction char *strcpy(char *dest, char


*source) qui recopie la chaîne source dans dest (en

supposant qu’il y a assez de place), et renvoie dest,


Voir solution uniquement avec des pointeurs. Écrire une seconde
version utilisant memmove et strlen.

Solution de l’exercice 3.6


Sans memmove :

char *strcpy(char *dest, char *source)


{
for (char *d = dest; *source;
*(d++) = *(source++) );
return dest;
}

Avec memmove et strlen :

char *strcpy(char *dest, char *source)


{
memmove(dest, source, strlen(source) );
return dest;
}

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

provoque le message Error : Size of this expression is unknown or zero, la


taille de cette expression est nulle ou inconnue. Il faut écrire un changement
de type :

( (int*) p )++; // augmente l’adresse de sizeof(int)2

ou utiliser une variable pointeur de type adéquat.

Exercice 3.7 Écrire une implémentation de memmove. On tiendra


compte du fait que les deux zones mémoire peuvent se
recouvrir, et que l’opération doit se faire correctement
Voir solution dans tous les cas.

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),

et que l’on souhaite recopier 4 octets. Dans ce cas, en


recopiant du premier au dernier, 01 est copié sur 04, 02
sur 05, etc., donnant le résultat :

01 02 03 01 02 03 01 08 09

qui n’est pas celui recherché. En recopiant dans l’ordre


inverse, 04 est copié sur 07, puis 03 sur 06, etc., et l’on
obtient par contre le bon résultat :

01 02 03 01 02 03 04 08 09

Retenir donc que dans ce genre de copie, si la zone


de destination suit la zone source, il faut recopier de la
fin au début, et inversement sinon.

On obtient donc la fonction suivante :

void *memmove(void *dest, void


*source, int taille)
{
char *d = dest, *s = source;
if (d > s) {
d += taille;
s += taille;
while (taille-- >
0) *(--d) = *(--s);
} else
if (d < s)
while (taille-- >
0) *(d++) = *(s++);
}

On notera que, bien69


sûr, rien n’est fait si source ==
dest. Noter aussi que dans le sens descendant,
Pointeurs ou tableaux ?
L’arithmétique des pointeurs rend leur utilisation si pratique qu’on s’en
sert souvent dans des situations où, dans d’autres langages, on aurait utilisé
des tableaux.

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 :

void affiche_tableau(unsigned n, int *tab)


{
for (int *fin = tab +n; tab < fin;
tab++)
cout << *tab << '\t';
}

On notera que l’incrémentation de tab n’est pas fautive et ne provoque


pas d’effet de bord (voir chapitre 5). On remarquera aussi que le pointeur
initialisé dans la boucle for n’est pas celui qui est incrémenté : mais cela est
parfaitement permis.

Comme les tableaux sont en fait des pointeurs sur leur premier élément,
on peut encore écrire :

int tableau[20];
affiche_tableau(20, tableau);

tout comme avec l’ancienne version.

La question qui vient naturellement à l’esprit est la suivante : y a-t-il gain


ou perte par rapport à l’ancienne version (qui utilisait un entier d’index i et
affichait les tableau[i] successifs) ? Bien qu’a priori on puisse penser que
ces deux implémentations sont équivalentes, il n’en est rien en réalité, il y a
un gain.

70
En effet, lorsqu’on écrit :

cout << tableau[i];

la machine fait deux opérations : connaissant l’adresse de tableau et i ,


elle multiplie le second par la taille des entiers et ajoute le résultat à
tableau (pointeur sur l’élément 0), obtenant ainsi une adresse qui est celle
de l’élément d’indice i. L’écriture précédente est donc équivalente à :

cout << *(int*)((char*)tableau + i * sizeof(int));

ce qui est évidemment plus complexe (donc plus lent) que :

cout << *tab;

De telles considérations justifient souvent l’emploi de pointeurs à la


place des tableaux, en particulier pour les opérations que l’on souhaite
rapides, comme celles sur les chaînes de caractères par exemple.

Reste à savoir comment l’on définit des tableaux multidimensionnels de


cette façon. C’est déjà moins pratique ; il existe deux possibilités. Soit on
considère qu’un tableau multidimensionnel est un tableau unidimensionnel
dont on numérote spécialement les éléments, auquel cas on utilise un
pointeur simple. Par exemple les instructions suivantes, qui écrivent
entièrement une matrice de 3*5 entiers :

int tableau[3][5] = ... // initialiser...


int i = 0, j = 0;
while (i < 3) {
cout << tableau[i][j] << (j < 4 ? '\t' :
'\n');
if (++j == 5) { i++; j = 0; }
}

deviennent :

int *tab = ... // initialiser...


int *fin = tab + 3*5;
for (int *t = tab; t < fin; t++)
cout << *t << ( (t -tab+1)%5 ? '\t' : '\n');

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).

La deuxième approche consiste à dire que le tableau multidimensionnel


est un tableau de tableaux. On peut donc le remplacer par un pointeur sur
un pointeur :

int **tbp = ... // initialiser...


int **fin = tbp +3;
for (int **tp = tbp; tp < tbp; tp++) {
int *stop = *tp +4;
for (int *p = *tp; p <= stop; p++)
cout << *p << (p < stop ? '\t' :
'\n');
}

Cette solution a des avantages et des inconvénients. En général,


l’initialisation (omise ici) est plus lourde, car il faut attribuer la place au
pointeur de pointeurs, puis à chaque pointeur séparément. En contrepartie,
l’accès à des éléments fixes peut être plus facile. Cette structure permet en
outre une utilisation partielle seulement, c’est-à-dire que certains pointeurs
peuvent rester à zéro ; cela fait gagner de la mémoire. Un usage pratique,
lorsqu’il est secondé par la POO.

Allocation dynamique de la mémoire


Il s’agit de la motivation principale d’utilisation des pointeurs, dans tous
les langages de programmation. Dans de très nombreux programmes en
effet, on ne sait pas d’avance quelle place une donnée (un tableau par
exemple) va devoir utiliser. Imaginons par exemple un traitement de texte :
il est impossible de savoir quelle taille allouer au tableau de caractères qui
contient le texte, puisque cette taille dépend de ce qui sera tapé par
l’utilisateur.

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.

La mémoire située dans le tas ne peut être allouée que dynamiquement,


c’est-à-dire au moment de l’exécution du programme. La mémoire du
segment de données est allouée à la compilation, en conséquence
d’instructions comme celles-ci :

int i = 0; // quatre octets alloués


char *s = "Bonjour à tous."; // 16 octets alloués
double matrice[3][3]; // 9*8 == 72 octets
alloués
char tampon[256]; // 256 octets alloués

L’allocation dynamique peut se faire de deux façons différentes. La


première consiste à employer des fonctions spéciales définies dans
<alloc.h>. La plus connue de ces fonctions est malloc, qui réserve un bloc de
mémoire de taille spécifiée, suivant le format :

void *malloc(size_t taille);

taille est le nombre d’octets souhaités (size_t est équivalent à unsigned).


La fonction renvoie un pointeur qui vaut NULL (c’est-à-dire 0) s’il n’y a plus
de place ou si taille == 0, et sinon sur le bloc alloué. On écrira ainsi, pour
dupliquer une chaîne de caractères par exemple (ne pas oublier la place
pour le zéro final) :

char *s1 = "Bonjour à tous!\n";


char *s2 = (char*) malloc( 1+strlen(s1) );
if (s2) strcpy(s2, s1);

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 :

int dim = 10;


double *table = (double*) calloc(dim,
sizeof(double));

Il arrive parfois que l’on souhaite modifier la taille d’un bloc alloué. On
utilise alors realloc :

void *realloc(void *bloc, size_t taille);

taille est la nouvelle taille demandée, bloc est le bloc mémoire à


réallouer. La fonction renvoie NULL si taille == 0 ou s’il n’y a pas assez de
place en mémoire, et sinon un pointeur sur la nouvelle position de bloc. Par
exemple, on peut augmenter la taille de notre bloc table, si dim vient à
augmenter en cours de programme :

dim += 10; // dim augmente


table = (double*) realloc(table, dim*sizeof(double)
);
if (!table) erreur("Plus de mémoire");

On doit ici faire la multiplication, car il n’y a pas d’équivalent à calloc


pour la réallocation. On notera que, si le résultat doit être converti en
double*, il n’est pas nécessaire de convertir table en void*, comme on l’a

expliqué à propos de ce type spécial de pointeur.

Précisons que la fonction erreur doit arrêter le programme, car table


est perdu si la réallocation a échoué, puisqu’on n’a plus le pointeur table
d’origine.

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 :

void free(void *bloc);

Il suffit donc d’écrire :

free(table); table = NULL;

On a remis le pointeur à zéro, afin d’éviter des erreurs dans la suite. Il ne


faut plus en effet utiliser un bloc libéré, de même qu’il ne faut pas utiliser un
bloc incomplet : les erreurs d’écriture en mémoire sont particulièrement
graves.

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

free sur une valeur nulle est sans effet.

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>.

Solution de l’exercice 3.8


Si l1 et l2 sont les longueurs des deux chaînes, il faut l1 +l2 +1 caractères
(ne pas oublier le zéro final !). On obtient alors :

char *concat(char *s1, char*s2)


{
int l1 = strlen(s1);
char *s = (char*) malloc( l1 +strlen(s2) +1);
if (s) { // allocation réussie
strcpy(s, s1);
strcpy(s +l1, s2);
}
return s;
}

75
Un pointeur nul est renvoyé en cas d’échec.

Opérateurs new et delete


Il n’est pas toujours agréable de devoir écrire, afin d’allouer un bloc pour
une variable de type T :

T *p = (*T) malloc(sizeof(T));

Cette écriture peut être simplifiée en :

T *p = new T;

En C++, new est un opérateur (donc un mot réservé), redéfinissable


comme on le verra au chapitre 7, qui calcule automatiquement la taille du
type sur lequel il agit, appelle malloc, et renvoie un pointeur de type
adéquat, épargnant ainsi le changement de type. Voici un exemple :

double *dp = new double;

Pour un tableau, il suffit de préciser sa dimension :

double *table = new double[dim];

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.

Ce mécanisme fonctionne aussi pour les tableaux multidimensionnels :

int (*matrice)[3] = new int [3][3];


float (*grosse)[2][3] = new float [5][2][3];

Notons que les écritures suivantes sont refusées :

int **matrice = new int [3][3]; // incorrect

76
int matrice [][] = new int [3][3]; // incorrect
int table[] = new int[10]; // incorrect

La dernière donne par exemple le message Error : Cannot initialize


'int[0]' with 'int near*', impossible d’initialiser le type 'int[0]' avec 'int*' ;
les autres sont du même goût.

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.

Ces opérateurs new et delete sont, comme on le voit, bien plus


pratiques que les fonctions de <alloc.h>. Cependant, on a parfois recours à
celles-ci pour des opérations spéciales, et en particulier pour réallouer un
bloc, car il n’y a pas d’opérateur équivalent à realloc.

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.

Il s’agit généralement de valeurs universelles :

const double Pi = 3.141592;

Il est conventionnel en C++ (quoique non obligatoire) d’écrire les


constantes en majuscules (PI) ou en commençant par une majuscule (Pi).
Rappelons que le langage différencie les majuscules des minuscules dans les
identificateurs, de sorte que PI, Pi, et pi sont trois identificateurs distincts.

Toute tentative d’écriture se soldera par un refus très net du


compilateur :

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.

Si l’on tente d’utiliser des pointeurs :

double *dp = &Pi;

on obtient Error : Cannot initialize 'double near*' with 'const double

near*'. Il faut en effet écrire :

const double *dp = &Pi;

ou de manière équivalente :

double const *dp = &Pi;

Cette déclaration signifie que *dp est constant. En conséquence, toute


occurrence de *dp dans le programme est remplacée par la constante, même
si l’on modifie dp. En effet, le pointeur lui-même n’est pas constant, on peut
l’incrémenter :

dp++;

78
double z = *dp;

dans ce cas, z prend la valeur de Pi, comme si dp n’avait pas été modifié ;
surprenant, non ?

Quant aux références, on peut parfaitement les utiliser :

double &d = Pi; // valable


d++; // ok, d == 4.14 maintenant

En fait, on a initialisé la référence sur une constante, ce qui provoque la


création d’une variable provisoire, comme on l’a expliqué au chapitre
précédent.

La variable Pi est donc tout à fait blindée, il est réellement impossible de


la changer ; et pour cause : elle n’existe pas ! Aucune place en mémoire n’est
allouée à Pi, de sorte qu’il n’est pas possible de prendre son adresse (ni
même d’en connaître la valeur en cours de débogage). La valeur réelle est
simplement substituée à l’écriture Pi, ainsi qu’à dp, dans tout le
programme, ce qui constitue un raccourci pratique.

On peut parfaitement définir des tableaux constants :

const int table[3] = { 1, 2, 3 };

et des pointeurs constants (ne pas confondre avec les pointeurs sur des
constantes) :

double *const dc = &d;

Dans ce cas, l’opération ++dc par exemple est interdite, puisqu’il s’agit
d’un pointeur constant. Par contre, on peut écrire :

(*dc)++; // équivaut à d++;

On ne peut donc pas initialiser dc avec &Pi.

Il existe aussi des pointeurs constants pointant sur des constantes :

79
const double *const dcc = &Pi;

Dans ce cas, on ne peut modifier ni dcc ni *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 };

Cet ensemble permet de définir d’un coup une succession de constantes,


qui sont considérées comme des entiers en réalité.

On peut également définir ainsi des constantes symboliques :

enum jour_semaine { Lundi, Mardi, Mercredi,


Jeudi, Vendredi, Samedi, Dimanche };

Dans ce cas, comme on n’a pas précisé de valeurs, le compilateur attribue


automatiquement les valeurs 0, 1, 2, ..., 6 aux constantes définies. On peut
alors définir une variable de type jour_semaine :

jour_semaine js = Mercredi;

De telles écritures sont par exemple nettement plus claires dans des
boucles :

for (jour_semaine js = Lundi; js <= Dimanche;


js++)
if (js < Samedi) cout << "Au boulot !\n";
else cout << "Dodo !\n";

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.

D’une façon générale, dans une énumération, on peut omettre le nom de


l’énumération (créant ainsi une énumération anonyme, qui n’est donc
qu’une liste de constantes) et l’initialisation de certaines constantes ; dans
ce cas, chaque constante pour laquelle on n’a pas précisé de valeur vaut 1 de
plus que la précédente, ou 0 si elle est la première. On aurait donc pu écrire
plus simplement :

enum { HOME = 71, UP, PGUP, LEFT = 75, RIGHT = 77,


END = 79, DOWN, PGDN, INS, ANNL };

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.

Arrêt d’une boucle par break


Lorsqu’on est à l’intérieur d’une boucle while, do...while, ou for, on peut
à tout instant interrompre cette boucle par l’instruction spéciale break. Voici
un bref exemple :

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]);

Dans ce cas, la boucle est brutalement stoppée lorsqu’on rencontre un


élément négatif, parce que cela correspond à une erreur. On peut ensuite
vérifier que le programme s’est déroulé sans erreur en testant si i a bien
atteint la valeur 100 ; dans la négative, le tableau était mal initialisé à
l’origine.

L’instruction break interrompt la boucle la plus intérieure, lorsqu’il en


existe plusieurs imbriquées ; il n’y a pas de moyen direct de lui demander
d’interrompre deux boucles imbriquées ou plus simultanément.

L’exécution du programme se continue normalement après l’interruption


de la boucle, par la première instruction qui suit celle-ci.

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).

Saut dans une boucle par continue


L’instruction continue s’utilise dans les mêmes conditions que break, mais
elle ne provoque pas l’arrêt de la boucle. Simplement, elle fait sauter
directement au début du pas de boucle suivant, sans exécuter les ordres
suivants. Voici un exemple :

for (int i = 0; i < 100; i++) {


if (table[i] < 0) continue;
cout << ( table[i] = sqrt(table[i]) ) << '\t';
}

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 :

for (int i = 0; i < 100; i++)


if (table[i] > 0)
cout << ( table[i] = sqrt(table[i]) ) <<
'\t';

et est généralement plus clair. L’instruction continue doit donc être utilisée
avec modération.

Tout comme break, continue ne s’applique qu’à la boucle la plus


intérieure. Il s’agit aussi d’un mot réservé.

Arrêt d’une fonction


Nous avons vu que l’instruction return permet de déclarer le résultat
d’une fonction. Rappelons aussi que cette instruction provoque l’arrêt de la
fonction. En conséquence, on pourra l’utiliser pour arrêter et renvoyer un
code d’erreur en cas de problème :

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.

Le second consiste à utiliser la fonction exit :

83
if (erreur) exit(1);

Cette fonction commence par exécuter un certain nombre de tâches


comme fermer des fichiers, vider les tampons, etc., puis arrête le
programme en renvoyant son argument. L’entier renvoyé est le résultat
d’exécution donné au système d'exploitation ; une valeur de 0 indique une
terminaison normale, une autre indique une erreur. C’est cette valeur qui
est le résultat de main.

La fonction _exit est presque identique à la précédente, mais elle


interrompt le programme directement, sans exécuter aucune tâche
intermédiaire. Enfin la fonction abort, qui n’a pas de paramètre, affiche un
message d’erreur sur le terminal (Abnormal program termination) puis appelle
_exit(3). Ces deux fonctions ne doivent être utilisées qu’en cas d’urgence

absolue. Elles peuvent causer des pertes de données, dans les fichiers
ouverts en écriture notamment.

Ces trois fonctions se trouvent dans <stdlib.h>.

Instruction de redirection multiple switch


Nous avons vu que, lorsqu’on devait choisir entre deux possibilités, on
utilisait une instruction de branchement if...else. Il existe une autre
instruction de branchement, nommée switch, dont la syntaxe est la
suivante :

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.

Il est important de noter que toutes les instructions suivant la constante


qui coïncide avec l’expression sont exécutées. Ainsi, si l’on écrit :

switch (i) {
case 1 : i = 0;
case 2 : i = 10; // probablement erroné
default i++;
}

la variable i sera mise à 11 si elle valait 1 ou 2 au départ, sinon elle sera


incrémentée. En effet, si i vaut 1 au départ, les trois instructions i = 0; i =

10; i++; sont exécutées à la suite.

Dans ce cas, il s’agit certainement d’une erreur. Pour la corriger, on


utilise l’instruction d’interruption break, qui interrompt la circulation dans
l’instruction de branchement, passant directement à celle qui suit l’accolade
fermante :

switch (i) {
case 1 : i = 0; break;
case 2 : i = 10; break;
default i++;
}

Ici, on a bien le comportement imaginé. Cette utilisation de break dans


une instruction switch est extrêmement fréquente ; il est important de ne
pas l’oublier.

Dans certains cas cependant on ne souhaite pas placer de break derrière


certaines constantes, en particulier lorsqu’il y en a plusieurs. Voici par
exemple une fonction qui remplace dans une chaîne les caractères accentués
par leur équivalent sans accent :

char *sansaccent(char *chaine)


{

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é.

L’écriture d’un branchement multiple n’est pas toujours la meilleure


méthode. Il est parfois préférable d’utiliser des systèmes plus rapides. Ainsi,
dans notre exemple, un tableau de correspondance des caractères initialisé
une fois pour toutes aurait permis un traitement plus expéditif.

Dans certains cas, il est franchement impossible d’utiliser une


instruction switch, parce qu’elle serait trop pénible à écrire. Ainsi, la
fonction toupper de la librairie <ctype.h>, qui transforme un caractère en la
majuscule correspondante, devrait être écrite avec un switch :

char toupper(char c)
{
switch (c) {
case 'a' : return 'A';
case 'b' : return 'B';
// etc...
case 'z' : return 'Z';
default : return c;
}

86
}

Il s’agit évidemment d’une écriture bien trop longue. On notera toutefois


deux points importants à son sujet. Tout d’abord, le fait d’écrire une
instruction return nous dispense d’écrire un break, puisque cela termine
directement la fonction. D’autre part, il s’agit d’une situation typique où le
compilateur ne peut pas savoir si tous les cas ont été traités ou non ; en
conséquence, si l’on oublie la clause default ici essentielle, la fonction
retournera une valeur aléatoire si son argument n’était pas une minuscule
(c’est d’ailleurs le comportement de la variante _toupper de cette fonction).

Exercice 4.1 Comment programmer plus facilement cette


fonction, sans utiliser switch ?

Voir solution

Solution de l’exercice 4.1


Il suffit de savoir que les caractères minuscules 'a'...'z' se suivent dans
le code ASCII, et qu’il en est de même des majuscules :

char toupper(char c)
{
if ( (c > 'z') || (c < 'a') ) return
c;
else return c - 'a' + 'A';
}

Enfin, il ne faut pas tenter d’utiliser switch pour des intervalles de


nombres, puisqu’on ne peut pas placer une condition derrière case ; pour la
même raison, il vaut mieux ne pas l’utiliser avec des décimaux.

Les mots switch, case et default sont réservés en C++.

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;

il lit d’abord les deux entiers i et j à partir de la mémoire, en les recopiant


dans des petites cases mémoire personnelles que l’on appelle registres. Il
peut alors additionner deux de ces registres, et placer le résultat obtenu
dans un troisième. Puis ce troisième est replacé en mémoire à l’adresse du
résultat k.

Ces opérations de lecture et d’écriture en mémoire sont très rapides,


mais provoquent toutefois un léger ralentissement des programmes.

Lorsqu’une vitesse élevée est souhaitée, on peut demander au


compilateur de placer une variable dans un registre, et non en mémoire. Il
suffit d’écrire :

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.

Ce qualificatif register est un reste des temps héroïques où la


programmation était beaucoup plus proche du langage machine, et où la
moindre différence de temps comptait ; époque aussi où les compilateurs
étaient peu performants. De nos jours, tous les compilateurs essaient de
placer les variables automatiques dans des registres, même si elles ne sont
pas déclarées register. Il se peut donc qu’une variable soit en fait dans un
registre sans que vous le sachiez ; sont particulièrement visés, les compteurs
de boucle et les variables très fugitives.

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 :

unsigned long *p[3][5];

est assez peu claire, et de surcroît assez longue à écrire. Voici comment on
peut éclaircir cet embrouillamini :

typedef unsigned long ulong;


typedef ulong* ulongptr;
ulongptr p[3][5];

On a défini deux nouveaux types, ulong identique à unsigned long et


ulongptr identique à ulong*. La déclaration de p met alors en relief le fait
qu’il s’agit d’un tableau de pointeurs, et non d’un pointeur sur un tableau
comme la première écriture pouvait donner à croire. Dans tout le reste du
programme, on pourra écrire partout ulong à la place de unsigned long, ce
qui est bien agréable.

Les clauses typedef sont donc essentiellement des raccourcis d’écriture,


mais qui aident beaucoup à lire les programmes. Elles permettent d’éviter
de nombreuses erreurs.

En outre, la définition de types permet de mettre en valeur certaines


variables, du point de vue fonctionnel. Par exemple, les arguments entiers
des fonctions malloc, realloc, etc., sont du type size_t défini ainsi :

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).

Il ne faut pas exagérer non plus : un nombre trop élevé de déclarations


de types, surtout avec des noms mal choisis, finirait par nuire à la lisibilité,
ce qui est le contraire du but recherché.

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.

Dans ce cas, la variable doit être déclarée volatile, afin que le


compilateur sache qu’il ne peut la conserver nulle part (surtout pas dans un
registre), et qu’il ne peut faire certaines optimisations sur elle :

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 :

main(int argc, char *argv[], char *env[])


{
// ...
}

et qu’au moment de son utilisation on tape ceci (à la ligne de commande du


DOS) :

/bin/prog argument1 argument2

alors dans le programme, argc vaudra 3 (un de plus que le nombre


d’arguments), et argv sera un tableau de pointeurs char* contenant les
pointeurs sur les chaînes "/bin/prog", "argument1" et "argument2"
respectivement. On peut ainsi tester le nombre d’arguments passés, et leur
valeur.

Quant au tableau env, il contient la liste des variables d’environnement


du système, sous la forme « VAR=valeur » ; par exemple en DOS, on trouvera
peut-être « PATH=C:\DOS;C:\;C:\TC\BIN » ; la dernière chaîne est nulle,
indiquant la fin du tableau. Les variables environnement peuvent être
obtenues plus simplement en utilisant la fonction getenv de <stdlib.h>.

Vous pouvez déclarer main sous l’une des quatre formes suivantes :

main() // paramètres inaccessibles


main(int argc // nombre de paramètres
main(int argc, char *argv[]) // paramètre seulss
main(int argc, char *argv[], char *env[]) // tout

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.

La syntaxe de la déclaration seule (qui indique le mode d'usage de la


fonction) est la suivante :

type_resultat nom_fonction (arguments);

tandis que la définition (qui indique la réalisation effective de la fonction) a


la syntaxe suivante :

type_resultat nom_fonction (arguments)


{
implantation
}

Noter l’absence de point-virgule dans ce cas.

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.

Lorsque le compilateur rencontre un appel de fonction dans une


instruction, il doit déjà avoir rencontré une déclaration de cette fonction
(comme pour tout objet de C++ d’ailleurs) ; cette déclaration, si elle est
seule, est souvent appelée prototype de la fonction, car elle permet au
compilateur de savoir exactement le type du résultat renvoyé, et celui des
arguments ; il peut alors vérifier que les paramètres effectivement passés
correspondent aux arguments, ou du moins qu’ils peuvent être transformés

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.

En contrepartie, il faut, avant d’utiliser une fonction, soit la définir


entièrement, soit en donner un prototype (la déclarer). Cela permet
notamment de placer les fonctions dans un ordre différent. Par exemple,
beaucoup de programmeurs préfèrent placer la fonction main en début de
programme. Il faut alors donner le prototype des autres fonctions avant,
comme ceci :

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)
{
...
}

Une fonction déclarée doit obligatoirement être définie ultérieurement,


sinon l’éditeur de liens proteste (voir chapitre 10). Le prototype de la
fonction doit correspondre exactement à la définition, il n’est pas permis de
changer d’avis en cours de route :

93
int f(int a, float x); // prototype

// autres...

int f(long a, float x) // erreur ! différent du prototype


{
// implantation
}

Cependant, il n’est pas nécessaire de donner des noms aux arguments


dans le prototype ; la déclaration précédente peut se faire sous la forme :

int f(int, float); // prototype


....
int f(int a, float x) // définition
{
// implantation
}

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 :

char *strcpy(char *dest, char *source);

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é).

Arguments d’une fonction


Une fonction a presque toujours des arguments ; de leur bon usage, et de
leur déclaration intelligente dépend grandement l’ergonomie d’un
programme. Nous allons voir qu’il existe plusieurs méthodes, non
équivalentes, pour passer un argument à une fonction.

Noter que certaines de ces écritures sont nouvelles en C++.

Passage par valeur

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 :

void f(int i, int j, double x)

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

On notera que ces paramètres peuvent parfaitement avoir des noms


différents, ou identiques à ceux des arguments ; cela n’a aucune espèce
d’importance, car les noms des arguments n’ont de signification qu’à
l’intérieur de la définition de la fonction, et sont oubliés sitôt celle-ci
compilée. Le compilateur ne peut donc même pas savoir que l’on a passé un
paramètre nommé i à la place d’un argument nommé j ; il peut donc
encore moins nous le reprocher.

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

Par contre, si l’on écrit g(d), où d est de type double, le compilateur


protestera, car il n’existe pas de changement de type de double vers void*.

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 :

void echange(int a, int b)


{
int c = a;
a = b; b = c; // tout à fait permis
} // ne marche pas : aucun effet

Il est d’ailleurs facile de comprendre pourquoi, puisqu’on peut appeler


echange(1,2) ; il serait gênant que 1 et 2 soient échangés !

Que se passe-t-il en réalité ? Le programme dispose d’un espace mémoire


spécial appelé pile. Il s’agit d’une structure LIFO (Last In, First Out c’est-à-
dire dernier entré, premier sorti). Lorsqu’on appelle une fonction, les
arguments sont calculés et placés dans des cases mémoire provisoires
situées dans la pile ; ces cases mémoire peuvent être utilisées comme
n’importe quelle variable, et en particulier leur contenu peut être modifié.
Cependant, à la fin de la fonction, toutes ces cases sont détruites, en ce sens
que l’espace qui leur est réservé est remis à la disposition du programme
(pour le prochain appel de fonction).

Les variables créées à l’intérieur d’une fonction (comme c dans echange)


sont également créées dans la pile au moment de leur déclaration, et
détruites après. On dit qu’il s’agit de variables automatiques, en ce sens
qu’elles sont gérées entièrement par le compilateur (voir le paragraphe sur
les variables et leur visibilité).

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
}

Les variables statiques ne se trouvent pas sur la pile, mais dans le


segment de données, dans une partie spéciale. Leur « durée de vie » est
celle du programme, et la place mémoire qu’elles occupent ne peut jamais
être libéré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 *s1 = lecture(), *s2 = lecture();

les deux pointeurs seront en fait égaux, et le tampon ne contient que la


dernière chaîne lue, non la première qui est perdue. Pour éviter ce
problème, on peut modifier ainsi la fonction lecture :

char *lecture(void)
{
char tampon[256];
// ...lecture de la chaîne dans le tampon
return strdup(tampon); // dupliquer
}

La fonction strdup se charge de créer la mémoire et de dupliquer la


chaîne qui est son argument. Avec une telle écriture, les pointeurs s1 et s2
seront cette fois différents, et ne pointeront pas sur l’adresse de tampon. On
note que dans ce cas, le compilateur ne peut pas savoir combien de fois dans
le programme la fonction de lecture sera appelée, donc combien de
duplications de chaîne seront faites, donc combien de mémoire il faudra ; à
cause de cela, les chaînes créées par strdup sont nécessairement des

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).

Un problème se pose avec les tableaux multidimensionnels lorsqu’on


n’en connaît pas les dimensions à la compilation. En effet, la procédure
suivante, destinée à sommer tous les éléments d’un tableau à deux
dimensions dont on indique le nombre de lignes et de colonnes, est
incorrecte :

double somme_matr(int lgn, int col, double


matr[][])
{
double somme = 0;
for (int l = 0; l < lgn; l++) {
for (int c = 0; c < col; c++)
somme += matr[l][c]; // erreur ici
}
return somme;
}

En effet, le compilateur ne connaît pas le nombre de colonnes du tableau,


même si la fonction le connaît, elle. En conséquence, il affiche Error : Size
of this expression is unknown or zero, la taille de cette expression est
inconnue ou nulle, exprimant ainsi son embarras.

98
Il existe deux solutions à ce problème. La première consiste à utiliser des
structures adéquates, nous la verrons dans les chapitres suivants.

La seconde consiste à utiliser un type pointeur sur tableau, et à faire soi-


même les opérations nécessaires :

double somme_matr(int lgn, int col, double


(*matr)[])
{
double somme = 0;
for (int l = 0; l < lgn; l++) {
for (int c = 0; c < col; c++)
somme += (*matr)[l*col +c];
}
return somme;
}

Cette solution n’est guère élégante, mais elle fonctionne. L’utilisation se


fait très simplement ainsi :

double mat[2][3] = { {1, 2, 3}, {7, 8, 9} };


double d = somme(2, 3, mat);

Passage par référence


Nous avons vu précédemment que la fonction echange n’échangeait rien
du tout. La solution « ancienne » (langage C) à ce problème consiste à
utiliser les pointeurs ; la fonction connaît alors l’adresse des arguments, et
peut donc les modifier en déréférençant les pointeurs :

void echange(int *a, int *b)


{
int c = *a;
*a = *b; *b = c;
}

L’inconvénient de cette méthode est qu’il faut préciser explicitement


l’opérateur d’adressage dans un appel à la fonction :

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& :

void echange(int &a, int &b)


{
int c = a;
a = b; b = c;
}

On peut alors écrire :

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.

Que se passe-t-il si l’on écrit :

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.

Le résultat obtenu est alors de recopier i+j dans j. L’autre recopie


s’effectue sur une variable temporaire, donc est sans effet. En particulier, i
n’est pas modifié.

100
A fortiori, si l’on écrit :

echange(1, 2);

l’appel est dépourvu d’effet.

Ce comportement peut paraître curieux et inutile, mais il est parfois bien


pratique. Imaginons ainsi que nous ayons écrit une fonction, version
étendue de echange, qui permute circulairement quatre arguments (c’est-à-
dire copie le premier dans le second, le second dans le troisième, etc., et le
quatrième dans le premier). On utilisera en général cette fonction avec
quatre variables, mais parfois on peut écrire dans le programme :

int i = 1, j = 2, k = 3;
permute (0, i, j, k);

qui revient à copier 0 dans i, i dans j, et j dans k. On réutilise ainsi


partiellement les capacités de la fonction, grâce à cette possibilité de créer
des références constantes (ce qui est par contre tout à fait interdit avec les
pointeurs).

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)

fasse déborder la pile si la taille du type grandtype est importante ; et de


toute façon, cela ralentit le programme, puisqu’il recopie sur la pile le
paramètre g. Si la fonction ne modifie pas son argument, cette recopie est
tout à fait inutile. Dans ce cas, on a intérêt à passer l’argument par
référence ; pour bien montrer que l’on ne fait cela que dans un but
d’optimisation, et non pour modifier l’argument, on donnera une référence

101
constante (voir sous-paragraphe suivant pour une explication sur les
arguments constants), comme ceci :

void f(const grandtype& g);

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).

Par contre, c’est particulièrement intéressant pour les tableaux et les


pointeurs. Imaginons par exemple que nous programmions une fonction
qui calcule la somme des n premiers termes d’un tableau. Dans ce cas, le
tableau n’a aucune raison d’être modifié, bien au contraire. Cependant, une
fausse manipulation provoquera cette modification, ce qui risque d’être
catastrophique, la fonction ayant alors un effet de bord inattendu.

Voici comment créer une telle fonction de manière précautionneuse :

long sommetab(unsigned n, const int *tab)


{
long somme = 0;
while (n-- > 0) somme += *tab++;
return somme;
}

Observons que la fonction ne se prive pas de modifier n et le pointeur tab,


qui eux ne sont pas constants : comme ce sont des arguments passés par
valeur, il n’y a aucun risque. Cependant, les éléments du tableau sont
constants ; dès lors, si l’on avait écrit par erreur (*tab)++ au lieu de *tab++,

102
on aurait eu le message Error : Cannot modify a const object, on ne peut
modifier un objet constant.

Ordre d’évaluation des paramètres


L’ordre dans lequel les paramètres d’une fonction sont évalués n’est pas
précisé par le langage. Il en résulte que les passages avec deux effets de bord
sur la même variable ont des résultats indéterminés. Par exemple, l’écriture
suivante :

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é.

Signalons que le problème est particulièrement grave avec les


opérateurs. Par exemple, l’écriture :

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 :

cout << --i << --i;

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) :

operator<<( operator<<(cout, --i), --i);

et l’ordre d’évaluation est alors différent comme on vient de le dire.

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 :

cout << i << --i;

envoie les caractères 5 et 5 à l’écran. Toutes ces écritures sont non portables
et aventureuses.

Retenir cette règle : éviter absolument tout appel de fonction et toute


expression contenant un effet de bord sur une variable et une autre
utilisation quelconque de cette même variable.

Arguments par défaut


Il est très fréquent que certains paramètres d’une fonction prennent une
valeur particulière plus souvent que d’autres, ou encore ne servent à rien
dans certains cas.

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 :

char* ajoute(char *dest, const char *ajout, int


max)
// ajoute la seconde chaîne à la première, sans
// dépasser la taille max pour le résultat.
{
int ld = strlen(dest), la = strlen(ajout);
if ( (max > ld +la) || (max <= 0) ) max =0;
else la = max -ld -1;

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 :

char s1[10] = "Hello! ", s2 = "Comment allez-vous


?";
ajoute(s1, s2, 10);

La chaîne s1 vaut alors "Hello! Com", soit 10 caractères, en comptant le


zéro final, et remplit donc entièrement la mémoire qui lui est attribuée.

Cependant, dans de nombreux cas, on n’a pas besoin de préciser un


paramètre max, parce qu’on est sûr qu’il n’y aura pas débordement. On
précise alors une valeur nulle pour max, qui signifie conventionnellement
« toute la place disponible » . Par exemple, dans ce cas :

char tampon[256] = "";


ajoute(tampon, s, 0);
ajoute(tampon, s2, 0);

De tels cas sont très fréquents en pratique. Mais on en voit clairement


l’inconvénient : il faut préciser un troisième argument inutile, dont la valeur
conventionnelle doit être retenue par coeur (cela peut être plus complexe
que 0).

En C++, on peut donner une valeur par défaut à un argument de


fonction, comme ceci :

char *ajoute(char *dest, const char *ajout,


int max = 0 )
// reste identique...

Dans ce cas, on peut appeler la fonction sans préciser ce paramètre, qui


prend alors la valeur par défaut indiquée :

105
char tampon[256] = "";
ajoute(tampon, s);
ajoute(tampon, s2);

est ici équivalent à l’écriture précédente, mais plus sobre.

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++) :

char *strcpy(char *dest, const char *source);

qui recopie entièrement source dans dest, que nous connaissons déjà, et :

char *strncpy(char *dest, const char *source,


size_t max)

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 :

char *strcpy(char *dest, const char *source,

size_t max = 65535);

Une fonction peut avoir plusieurs arguments par défaut, qui peuvent être
des expressions constantes de toutes sortes :

void f(int a, double d = 2*Pi, int n = 0);


int g(long n = -1, void *p = NULL);

Cependant seuls les derniers arguments peuvent avoir une valeur par
défaut, il n’est pas possible de faire un mélange :

int h(long n =0, int i, float f =1); // non, mélange!

D’autre part, il n’est pas possible de placer des expressions dépendant


d’autres arguments :

int k(int a, int b = a); // interdit

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

déclaration de printf est la suivante :

int printf(const char *format, ...)

Noter l’ellipse, qui se traduit par trois points de suspension derrière le


dernier argument (la dernière virgule est facultative). On peut alors appeler
printf en précisant autant de variables que l’on veut (y compris zéro)

derrière la chaîne de caractères :

printf("Bonjour.");
printf("La racine de %d est %lg.", 2, sqrt(2));
printf("Il est %d heures, %d minutes, %d
secondes.",
heure, min, sec);

Dans la chaîne format, on doit placer des codes spéciaux suivant le


caractère %, pour indiquer les variables à afficher. Les principaux formats
sont les suivants :

%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 :

printf("Bénéfices\t= %d\nPourcentage\t= %g%%\n\n",


benef, pourcent);

(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);

Nous ne pouvons pas vous dire le résultat, cela dépend de l’état de la


mémoire ; mais on peut obtenir un bien curieux listing de celle-ci.

Pour ces deux raisons, clarté et sécurité, printf est avantageusement


remplacée par le flot de sortie cout, qui lui ne peut absolument pas produire
d’erreur de ce type (voir chapitre 9).

La programmation des fonctions avec ellipse est un peu délicate. Il faut


utiliser trois fonctions spéciales définies dans <stdarg.h>, nommées va_start,
va_arg, et va_end. Elles utilisent un type spécial nommé va_list :

void va_start(va_list vl, derniere);


type va_arg(va_list vl, type);
void va_end(va_list vl);

Voici comment faire dans la pratique :

o déclarer une variable vl de type va_list ;


o appeler va_start(vl, der), où der est le nom de la dernière variable qui
précède l’ellipse ;
o pour chaque argument, appeler va_arg(vl, type) où type est le type de
l’argument souhaité : la fonction renvoie alors un argument de ce
type ;
o quand tous les arguments ont été lus, appeler va_end(vl).

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>

float moyenne(int premier...)


{
va_list vl;
va_start(vl, premier);
long somme = premier;
int suivant, nombre = 1;
while (suivant = va_arg(vl, int)) {
somme += suivant;
nombre++;
}
va_end(vl);
return somme/float(nombre);
}

On notera que vous devez connaître les types et le nombre d’arguments,


par une méthode personnelle (dans l’exemple, tous les arguments sont int
et le dernier est nul) ; il n’y a aucun moyen de les déterminer autrement. En
outre il ne faut pas oublier d’argument en cours de route, ni les appels à
va_start et va_end. De tels oublis peuvent provoquer un plantage de
l’ordinateur.

L’utilisation de fonctions avec ellipse est aussi dangereuse que leur


programmation. Ainsi, si l’on écrit malencontreusement :

float f = moyenne(1, 2, 3); // catastrophe !!

au lieu de :

float f = moyenne(1, 2, 3, 0);

le résultat risque d’être désagréable. De même si l’on écrit :

float f = moyenne(1, i, 2, 3, 0);

et que i est nul.

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 :

float moyenne(unsigned nombre, int *liste)


{
long somme = 0;
for (int i = nombre; i > 0; i--)
somme += *liste++;
return somme/float(nombre);
}
Exercice 5.1 Écrire une version de moyenne avec ellipse ayant
pour premier paramètre le nombre nombre d’entiers qui
le suivent.
Voir solution

Solution de l’exercice 5.1


C’est en fait plus simple :

#include <stdarg.h>

float moyenne(unsigned nombre...)


{
va_list vl;
va_start(vl, nombre);
long somme = 0;
for (unsigned n = nombre; n; n--)
somme += va_arg(vl, int);
va_end(vl);
return somme/float(nombre);
}

On notera que la conversion automatique des arguments interdit de


récupérer des variables de type float avec va_arg (ils sont en fait au format
double).

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),

%c (char) et %s (char*). On tiendra compte du

redoublement %% indiquant un simple caractère %. On


Voir solution
suppose que l’on dispose de trois fonctions, ecritchar
qui écrit un caractère unique, ecritint qui écrit un int,
et ecritdouble qui écrit un double (les écrire avec le flot
de sortie cout par exemple).

Solution de l’exercice 5.2


C’est un peu plus difficile, mais voici une solution, avec un petit
programme d’essai :

#include <iostream.h>
#include <stdarg.h>

inline void ecritchar(char c) { cout << c; }

inline void ecritint(int i) { cout << i; }

inline void ecritdouble(double d) { cout << d; }

void printf2(const char *format ...)


{
va_list vl;
va_start(vl, format);
for (const char *form = format; *form; form++)
if (*form != '%') ecritchar(*form);
else
switch (*++form) {
case 'd' : ecritint(va_arg(vl, int));
break;
case 'g' : ecritdouble(va_arg(vl,
double));
break;
case 'c' : ecritchar(va_arg(vl, char));
break;
case 's' : for (char *s = va_arg(vl,
char*) ; *s; s++)

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).

Terminons en notant que la fonction va_start nécessite un premier


argument pour aller chercher les suivants, et qu’en conséquence les
déclarations du genre :

void danger(...)

quoique permises, sont très dangereuses puisqu’il n’y a pas de moyen


simple de retrouver les arguments.

D’une façon générale, l’ellipse est un outil dangereux et


difficile à manier, qui sera avantageusement remplacé

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;

void fonc(int x, int y)


{
// ...
}

Toute référence à x dans la fonction fonc désigne l’argument int x et non


la variable globale long x qui est donc masquée par l’argument.

On peut toutefois accéder à la variable globale en utilisant l’opérateur de


résolution de portée :: (dont nous expliquerons l’usage au chapitre 6). Il
suffit pour cela d’écrire ::x au lieu de x, et l’on désigne ainsi la variable
globale :

long x = 4;

void fonc(int x, int y)


{
::x += x*y;
}

La fonction fonc ajoute donc le produit de ses deux arguments à la


variable globale long x.

ajorité des cas.

Résultat d’une fonction


Le résultat d’une fonction peut être de n’importe quel type, à l’exception
d’une fonction et d’un tableau ; ce dernier sera remplacé par un pointeur,

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).

Une fonction peut en particulier renvoyer une référence, comme ceci :

int& f(void)

Nous verrons de nombreuses utilisations de cela en programmation


orientée objet, notamment pour les nouveaux opérateurs (voir chapitre 7).
Indiquons cependant ici que les résultats de type références sont aussi
trompeurs que ceux de type pointeurs. Il ne faut pas écrire :

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
}

Fonctions en ligne inline


Il est très fréquent, dans tout langage de programmation modulaire,
d’écrire de petites fonctions de quelques instructions seulement, qui sont en
fait essentiellement des commodités d’écriture. Par exemple, la fonction max
qui donne le plus grand de deux entiers :

int max(int a, int b)


{
if (a < b) return b; else return a;

114
}

Le problème de ces fonctions est que la séquence appel de fonction +


retour de fonction, dont la durée et la longueur de code sont d’habitude
négligeables, devient ici une grande partie de l’opération effectuée. Or, vu la
petite taille du code généré, il serait préférable que celui-ci soit placé
directement à l’endroit où la fonction est appelée, c’est-à-dire que la
séquence :

x = max (i+j, j);

soit directement transformée en :

if (i+j < j) x = j; else x = i+j;

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 :

inline int max(int a, int b)


{
if (a < b) return b; else return a;
}

Dans ce cas, le compilateur sait que chaque occurrence de l’appel de max


devra être remplacée par le code de celle-ci. L’exemple ci-dessus est donc
bien transformé de la façon souhaitée, mais avec un petit plus (non
négligeable cependant) : la somme i+j n’est calculée qu’une seule fois, et
non deux comme l’écriture le laissait croire (et ce qui arriverait avec des
macros).

Les fonctions en ligne permettent parfois d’augmenter sensiblement la


vitesse du code. Il n’y a aucune raison de s’en priver, sauf dans quelques cas
rares ; en particulier, on ne peut prendre de pointeurs sur elles (voir
paragraphe sur les pointeurs), puisqu’elles n’existent pas réellement. En
outre, certains compilateurs ne permettent pas la création de fonctions en
ligne contenant une boucle (for, while, do...while), ni de branchements
multiples switch. Dans ce cas, le compilateur affiche un Warning et ignore le
mot inline.

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.

Le mot inline est réservé en C++ (voir annexes).

Différentes variables et leur visibilité


Nous avons vu différents types de déclarations de variables. Il est peut-
être temps de les récapituler, et d’étudier quelles fonctions peuvent utiliser
ces variables.

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

On perçoit l’importance du rôle de l’indentation pour éclaircir ces


structures essentielles.

Notons encore que le bloc des variables déclarées dans la partie


initialisation d’une boucle for est celui qui contient cette instruction, et non
la boucle elle-même (ainsi la variable i appartient au bloc 3, et non au 4 ;
elle peut être référencée dans tout ce bloc).

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.)

Voici par exemple quelques constructions permises (quoique peu


claires) :

int i = 1;
if (i > 0) {
char i = 'A' // ok, bloc différent
..
.}
while (i-- > 0) {
double i = Pi; // idem
...
}

Par contre ceci est interdit :

void f(int i, int j)


{
char i = 'A'; // non, même bloc que l’argument
for (int j = 0;...) // non, même raison
}

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.

Il existe trois sortes de variables statiques. Les variables globales sont


déclarées en dehors de toute fonction, souvent au début du fichier. Dans le
schéma de programme ci-après, c’est le cas des variables glob1 et glob2. Ces
variables sont automatiquement initialisées, même si aucune initialisation
n’est explicitée par le programmeur : dans ce cas, elles sont mises à zéro
(c’est ce qui arrive à glob1 dans notre exemple, et glob2 prend évidemment la
valeur indiquée 10).

Ces variables globales sont visibles (c’est-à-dire utilisables) de toutes les


fonctions qui les suivent dans le fichier, comme des fonctions f et g dans
notre exemple. Il en résulte parfois des effets pervers si l’on utilise
n’importe quoi comme variable globale, car une fonction peut modifier une
telle variable, provoquant alors des effets inattendus dans les autres
fonctions. Ainsi g modifie glob1.

// schéma de programme montrant les différents


// types de variables

#include <iostream.h>

int glob1, glob2 = 10; // statiques globales

void f(int arg1, int &arg2)


{
static int stat = 1; // statique explicite
arg2 = ++arg1 * stat++;
}

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

Deuxième type de variables statiques, celles qui sont explicitement


déclarées ainsi avec le mot réservé static dans une fonction (pour l’intérêt
de cette déclaration, voir précédemment). C’est le cas, dans notre exemple,
de la variable stat dans la fonction f. Une telle variable est également
initialisée à zéro si aucune initialisation explicite n’est indiquée. Dans notre
exemple, la variable stat est augmentée dans f, il en résulte que lors des
appels successifs de cette fonction, on aura des résultats différents en
général.

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.

Troisième type de variables statiques, celles qui sont déclarées dans le


bloc principal de la fonction main, comme mloc1 et mloc2. Ces variables sont
en fait des variables locales de main (voir ci-après), mais comme la durée de
parcours de main est celle du programme entier, elles existent tout au long
du programme. De telles variables ne sont cependant visibles que dans la
fonction main. De plus elles ne sont jamais initialisées automatiquement (la
valeur de mloc1 au début du programme est donc aléatoire).

119
Toutes les variables statiques sont créées dans le segment de données à
la compilation.

Il existe deux sortes de variables dynamiques. Les variables dynamiques du


programmeur sont celles qu’il crée explicitement dans le tas à l’aide de malloc

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 autres variables dynamiques sont dites automatiques, parce qu’elles


sont automatiquement créées et détruites par le programme de façon
transparente pour le programmeur. Elles sont placées dans la pile. Il en
existe différentes espèces.

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

bloc est celui du if). Aucune de ces variables n’est initialisée


automatiquement, il est donc préférable de toujours préciser une valeur
initiale.

Les arguments d’une fonction sont des variables automatiques dont la


durée de vie est celle de la fonction ; ces variables sont toujours initialisées
lors de l’appel de la fonction, avec les paramètres réels passés à celle-ci, ou
les valeurs par défaut s’il y a lieu. C’est le cas de arg1 et de la référence arg2
dans f.

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.

On retiendra deux points importants. Primo, les variables globales sont


dangereuses, car n’importe qui peut les modifier sans prévenir. Secundo, les

120
variables automatiques ne sont jamais initialisées par le compilateur, il faut
le faire explicitement.

Recouvrement des fonctions


En pratique, il existe de nombreuses fonctions dont l’effet est plus
général que leur implantation courante. Par exemple, la fonction de
maximum :

inline int max (int a, int b)


{ if (a > b) return a; else return b; }

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 :

inline int max (int a, int b)


{ if (a > b) return a; else return b; }

inline long max (long a, long b)


{ if (a > b) return a; else return b; }

inline double max (double a, double b)


{ if (a > b) return a; else return b; }

On dit que l’on a réalisé un recouvrement de fonctions. Lorsque le


compilateur rencontre des appels à max, il sélectionne la bonne fonction
selon le type des arguments :

int i = 1, j = 3;
i = max(i, j); // appel de max(int, int)
double d1, d2;
d1 = max(d1, d2); // max(double, double)

Dans certains cas, cependant, on peut se demander ce qui va se passer :

float f1, f2;


f1 = max(f1, f2); // quelle fonction ?

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.

Cependant, si nous n’avions pas écrit cette fonction, le compilateur aurait


protesté dans l’appel précédent, en indiquant Error : Ambiguity between
'max(long, long)' and 'max(int , int)', ambiguïté entre max(long, long) et

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

long double ne pourrait être utilisé, sauf en précisant explicitement une


transformation de type :

long double ld1, ld2;


ld1 = max( (double) ld1, ld2);

Dans ce cas, le premier argument permet au compilateur de résoudre


l’ambiguïté (il n’est pas nécessaire de préciser un second changement de
type) ; cependant, il y a une perte de précision, il est donc préférable d’écrire
une nouvelle version de max.

Signalons que le cas des fonctions ayant des références pour paramètres
est différent. Supposons écrites par exemple les fonctions :

inline void echange(int& a, int& b)


{ int c = a; a = b; b = c; }

inline void echange(long& a, long& b)


{ long c = a; a = b; b = c; }

inline void echange(double& a, double& b)


{ double c = a; a = b; b = c; }

Dans ce cas, si l’on écrit :

122
float f1, f2;
echange(f1, f2); // pas de concordance

le compilateur signale à nouveau une ambiguïté car la promotion des types


ne s’applique pas aux références. On peut essayer d’écrire :

echange( (double) f1, f2);

mais alors le compilateur prévient qu’il utilisera une variable temporaire


pour f2 (Warning : Temporary used for parameter 'b' in call to
'echange(double, double)', variable temporaire utilisée pour le paramètre
'b' dans l’appel de 'echange(double, double)'). Si vous essayez quand même,
une variable temporaire est en fait utilisée pour les deux paramètres, et
l’appel est sans effet. En fait, une variable float ne peut être considérée
comme identique à une double, même dans une opération aussi simple. La
meilleure solution consiste donc à écrire une version de echange pour chaque
type.

D’une façon générale, deux fonctions sont considérées comme


différentes si elles ont une liste d’arguments différente, et dans ce cas elles
peuvent avoir le même nom. Par contre, si seul le type de résultat renvoyé
est différent, le compilateur ne peut pas distinguer les deux fonctions et
affichera un message d’erreur si on les utilise.

Récursivité
Une fonction est dite récursive lorsqu’elle s’appelle elle-même, ce qui est
parfaitement autorisé :

void baoum(void)
{
baoum();
}

Si nous avons emprunté à Balzac l’interjection « baoum » (voyez le début


du « Colonel Chabert »), c’est pour bien montrer que l’appel d’une telle
fonction provoquera un plantage du programme ; en effet, la fonction

123
s’appelle elle-même indéfiniment, jusqu’à ce que la pile déborde,
provoquant des effets variés (dans le genre horrible).

En pratique, une fonction récursive aura toujours au moins une


instruction conditionnelle if ou switch, afin que dans certains cas au moins,
il n’y ait pas d’appel récursif.

Un exemple traditionnel de fonction récursive est la fonction factorielle.


Cette fonction calcule la factorielle d’un entier n, c’est-à-dire le produit de
tous les nombres de 1 à n. Comme visiblement la factorielle de n est n fois la
factorielle de n-1, il est tentant d’écrire :

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.

Il s’agit d’un très mauvais exemple d’utilisation de la récursivité, car la


même fonction se calcule visiblement bien plus facilement avec une simple
boucle :

double fact(int n)
{
double f = 1;
while (n > 1) f *= n--;
return f;
}

D’une façon générale, les fonctions récursives de la forme :

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 ...

Il est alors tentant d’écrire :

long fib(unsigned n)
{
if (n > 1) return fib(n-1) + fib(n-2);
else return n;
}

Grave erreur, car le temps d’exécution de cette fonction devient vite


prohibitif, surtout comparé à une version non récursive.

Exercice 5.3 Écrire une version non récursive de la fonction fib.

Voir solution

En effet, chaque appel de fib se répercute en deux appels, si bien que


pour calculer fib(26), par exemple, il faut environ 225 = 33 554 432 appels,
et plus de 6 secondes sur la machine de test, tandis qu’il ne faut que 0.016
seconde, soit 400 fois moins, à la version non récursive (solution de
l’exercice). De plus, la version non récursive est parfaitement capable de
calculer fib(60) = 1 820 529 360 en moins d’une seconde, tandis qu’il
faudrait sensiblement... 3 000 ans à la version récursive pour faire le calcul,
et une pile de onze milliards de giga-octets !

De cette leçon, on retiendra que la récursivité ne doit pas être employée


de façon irréfléchie. De très nombreux algorithmes sont présentés sous
forme récursive ; mais il est souvent bien préférable, et parfois bien plus
simple, de les programmer sans récursivité.

Dans certains cas cependant la récursivité est pratique. On ne doit pas


hésiter à l’utiliser si l’on sait que seul un petit nombre d’appels récursifs

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.

Un cas intéressant est celui de la récursivité croisée. Il s’agit d’un groupe


de fonctions qui s’appellent les unes les autres cycliquement. Par exemple,
deux fonctions :

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.

Pointeurs sur des fonctions


Il est parfaitement possible d’utiliser des pointeurs sur des fonctions en
C++. Il s’agit de pointeurs particuliers qui désignent le point d’entrée d’une
fonction dans le programme. On les déclare ainsi :

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.

On notera que du fait de la précédence de l’opérateur () sur le


déréférencement *, il est obligatoire de placer des parenthèses.

Exercice 5.4 Que désignent les écritures suivantes :

int *p1(float, int[]);


void (*p2)(int (*)(float,
float));
Voir solution
long (*) (double[], int) (*p3)
(long);
Solution de l’exercice 5.4
[4] p1 est une fonction admettant un paramètre
float et un autre int[], et renvoyant un pointeur int*.
p2 est un pointeur sur une fonction sans résultat et

admettant pour paramètre un pointeur sur une


fonction ayant deux paramètres float et un résultat
int. p3 est un pointeur sur une fonction ayant un

paramètre long et pour résultat un pointeur sur une


fonction ayant un résultat long et deux paramètres
double[] et int... Voir plus loin l’utilisation de typedef
pour éviter cela...

Comment déclarer un tableau de pointeurs sur des


Exercice 5.5
fonctions renvoyant un int et acceptant un double

127
Voir solution comme paramètre ?

Solution de l’exercice 5.5


Comme ceci :

int (*p2[])(double);

Ici encore, il est préférable d’utiliser typedef.

Pour initialiser ces pointeurs, on indique simplement l’adresse de la


fonction sur laquelle ils pointeront, avec l’opérateur d’adressage &, comme si
c’était de simples variables :

int unefonction(double d);


.....
int (*pf1)(double) = &unefonction;

Ensuite, on peut appeler la fonction pointée à l’aide d’un


déréférencement (même remarque que précédemment à propos de la
précédence de () sur *) :

int i = (*pf1)(10.1);

Noter que pour affecter l’adresse d’une fonction à un pointeur,


l’adéquation des arguments et du résultat doit être parfaite, et ce jusqu’aux
variantes unsigned ou const. Par exemple, les trois initialisations suivantes
seront refusées par le compilateur :

int fonct1(unsigned u);


void fonct2(const char *s);
float fonct3(void);
// ....
int (*p1)(int) = &fonct1; // non, incorrect
void (*p2)(char *s) = &fonct2; // idem
double (*p3)(void) = &fonct3; // idem

128
On doit dans ce cas faire un changement de type :

int (*p1)(int) = (int (*)(int)) &fonct1; // ok


void (*p2)(char *s) = (void (*)(char*)) &fonct2;
// ok

Le changement de type serait aventureux pour p3, car la promotion de


float en double ne sera pas automatique, entraînant des erreurs graves.

En réalité, la plupart des compilateurs se montrent très tolérants vis-à-


vis des écritures avec des pointeurs de fonctions, qui sont aussi considérés
comme des références. On peut alors écrire :

int i = pf1(10.1);

De même, on peut écrire :

int (*pf1)(double) = unefonction;

Cependant, il ne faut pas supprimer l’opérateur * dans cette dernière


expression, sinon le compilateur croirait à une définition de fonction.

Les pointeurs de fonctions sont des pointeurs spéciaux, et aucune


opération arithmétique n’est permise sur eux. Si par exemple on écrit :

pf1++;

le compilateur répond Error : Size of this expression is unknown or zero, la


taille de cette expression est inconnue ou nulle, montrant ainsi qu’il ne peut
lui donner un sens, puisqu’une fonction, au contraire de tout autre type, n’a
pas de taille fixée.

Les déclarations de pointeurs de fonctions seront grandement facilitées


en utilisant typedef. Ainsi, voici quelques déclarations :

typedef int pfonc(double);


// type pointeur sur fonction(double), de résultat int
pfonc pf1 = unefonction;
// pointeur sur une telle fonction
pfonc tpf[10] = { unefonction };
// tableau de 10 pointeurs de fonction

129
void (*pff)(int, pfonc);
// pointeur de fonction sans résultat ayant un int et
// un pointeur de fonction pfonc comme arguments

Les fonctions peuvent tout à fait avoir un pointeur de fonction comme


paramètre ou comme résultat, comme on l’a déjà vu. C’est même une
utilisation fréquente de ces types. Ainsi, on trouve dans la librairie
<stdlib.h> une fonction nommée qsort dont l’en-tête est le suivant :

void qsort(void *base, size_t nelem, size_t


largeur,
int (*fcomp)(const void*, const void*)
);

Cette fonction effectue un tri en utilisant le célèbre algorithme


« QuickSort ». Le premier argument base est un pointeur sur le début de la
table à trier ; le second est le nombre d’éléments à trier ; le troisième est la
taille en octets de chaque élément ; le dernier est un pointeur sur une
fonction qui compare deux éléments, en renvoyant un nombre strictement
négatif si le premier est strictement inférieur au second, zéro s’ils sont
égaux, et un nombre strictement positif si le premier est strictement
supérieur au second.

Exercice 5.6 Écrire un programme qui trie un tableau de 100


entiers, en utilisant qsort. On initialisera le tableau
avec des valeurs aléatoires comprises entre 0 et 999,
Voir solution en appelant random(1000).

Solution de l’exercice 5.6


Il faut écrire une fonction de comparaison. Imaginons cette fonction déjà
écrite, pour d’autres parties du programme, sous la forme :

int compare(int *a, int *b)

Dans ce cas, il faudrait appeler qsort sous la forme :

qsort(table, 100, sizeof(int),

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>

const Ntab = 100 // taille du tableau

void init_table(int n, int *tab)


// remplit la table de n nombres aléatoires
{
while (n-- > 0) *tab++ = random(1000);
}
,void aff_table(int n, const int *tab)
// affiche la table
{
while (n-- > 0) cout << *tab++ << '\t';
cout << '\n';
}

int comp_int(const void *a, const void *b)


// comparaison pour qsort
{
return *(int*) a - *(int*) b;
}

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

cout << "\n\nTable triée :\n";


aff_table(Ntab, table);

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.

La même librairie contient deux fonctions de recherche, l’une bsearch est


une recherche dichotomique dans une table triée, l’autre lsearch est une
recherche linéaire dans une table non triée. Il s’agit là d’utilisations typiques
et très utiles des pointeurs sur des fonctions.

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.

Notons qu'il y a peu de différences en C++ entre structures et classes


(voir plus loin), et qe l'on utilise le plus souvent des classes. Tout ce qui est
expliqué sur les structures dans ce qui suit est valable aussi pour les classes.

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é.

Utilisation des structures


En C++, un regroupement de telles variables différentes peut se faire à
l’aide d’une structure, définie par exemple comme ceci :

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 :

fiche employe1, employe2;

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[];

Adressage des champs


Les différents éléments d’une structure sont appelés des champs, ou des
données membres. Lorsqu’on veut accéder à l’un de ces champs, dans une
fiche déterminée, il suffit d’indiquer le nom de la variable de type fiche, puis
celui du champ (tel qu’il a été défini dans la structure fiche), reliés par
l’opérateur . (point) :

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[];

Il s’agit ici encore d’un raccourci d’écriture.

On a pu voir ci-dessus que le nom de structure était facultatif. En effet, il


se peut que l’on n’ait que quelques variables de ce type structure à utiliser
dans le programme, auquel cas il n’est pas nécessaire de nommer la
structure :

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.

Précisons aussi qu’en principe, les variables de type structure doivent


être déclarées avec le mot struct, comme ceci :

struct fiche employes[100];

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.

Il est même possible d’utiliser une déclaration typedef (voir chapitre 4)


pour définir une structure :

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.

Types des champs


Les champs d’une structure peuvent être aussi nombreux que souhaité,
et de n’importe quel type, y compris d’autres structures. Précisons toutefois
que les structures récursives sont interdites :

struct recursive {
recursive interne; // NON, interdit
};

Dans ce cas en effet, la structure aurait virtuellement une taille infinie.


Le compilateur le signale par le message Error : Size of 'interne' is
unknown or zero, la taille de 'interne' est inconnue ou nulle.

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 :

void fonction(struct fiche employe, int n);

et le mot struct est facultatif. Cependant, le passage d’une structure peut


poser problème, puisqu’il s’agit souvent d’éléments volumineux en
mémoire. C’est pourquoi on préfère en général passer les structures sous la
forme de pointeurs :

void fonction(fiche *pempl, int n);

ou de références :

void fonction(fiche &employe, int n);

Evidemment, de tels passages sont obligatoires si la fonction doit avoir


un effet de bord modifiant la structure. Dans le cas contraire, on aura
intérêt à préciser le pointeur ou la référence comme désignant une structure
constante, à l’aide du mot const, comme on l’a dit au chapitre précédent.

Lorsqu’une structure est passée entièrement, c’est-à-dire par valeur,


certains compilateurs C++ émettent un avertissement : Warning : Structure
passed by value. C’est le moment ou jamais de réfléchir si ce passage par
valeur est réellement nécessaire. Dans l’affirmative, il suffit d’ignorer le
message.

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;

Dans ce cas, la définition doit se trouver plus loin dans le programme. Il


s’agit donc de quelque chose de très semblable aux prototypes de fonctions.
L’intérêt est le même : on peut ainsi masquer le contenu de la structure en
ne plaçant que la déclaration dans un fichier en-tête (voir chapitre 10).

On peut aussi définir des structures croisées :

struct exemple1;

struct exemple2 {
exemple1* pex1;
// ...
};

struct exemple1 {
exemple2* pex2;
// ...
};

ce qui serait impossible autrement. Notons toutefois que les opérations


nécessitant la taille de exemple1 sont interdites dans exemple2 ; en particulier,
on ne peut pas placer un champ de type exemple1 dans exemple2. Les
fonctions acceptant un paramètre de ce type peuvent par contre être
déclarées (mais difficilement définies puisqu’on ne peut utiliser aucun
membre de la structure).

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

Toutefois, de telles fonctions sont très désordonnées si elles sont


dispersées à travers le programme. Le C+ ajoute au C un moyen bien plus
simple de traiter ce problème : les fonctions membres.

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

Le compilateur distingue ce membre d’une donnée usuelle à cause des


parenthèses. Observons un point important : la structure elle-même n’a pas
été passée en paramètre. En effet, une fonction membre reçoit toujours
l’objet par lequel elle est appelée, sous la forme d’un paramètre implicite de
type pointeur, nommé this.

Implantation
L’implantation de la fonction membre sera donnée plus loin dans le
programme, à tout endroit jugé adéquat :

void fiche::ecrit_np(char *nouv_nom, char


*nouv_pre)
{
this->nom = nouv_nom;
this->prenom = nouv_pre;
}

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.

Nous voyons ici l’usage du paramètre caché this. Cependant, cette


écriture est assez lourde. Il est permis de l’abréger ainsi :

void fiche::ecrit_np(char *nouv_nom, char


*nouv_pre)
{
nom = nouv_nom;
prenom = nouv_pre;
}

En effet, toutes les fonctions membres « connaissent » automatiquement


le nom de tous les membres (fonctions et données) de la structure. De ce
fait, on utilise assez peu le paramètre this explicitement, sauf lorsqu’on
souhaite connaître l’adresse de la structure (c’est pourquoi this est un
pointeur, et non une référence).

Appel d’une fonction membre


On appelle une fonction membre avec l’opérateur . (point), de la même
façon qu’on désigne une donnée membre :

fiche employe;
employe.ecrit_np("Dupont", "Jean");

ou l’opérateur -> s’il s’agit d’un pointeur :

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.

Ces deux appels sont équivalents à :

ecrit_np_fiche(employe, "Dupont", "Jean");


ecrit_np_fiche(*pempl, "Durand", "Paul");

Notons que certains compilateurs (comme g++) placent this à la fin de


la liste des arguments, non au début.

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

inline void fiche::ecrit_np(char *nouv_nom,


char *nouv_pre)
{
nom = nouv_nom;
prenom = nouv_pre;
}

Toutefois, un procédé plus simple consiste à écrire l’implantation de la


fonction directement dans la déclaration de structure :

struct fiche {
char *nom, *prenom;
int age;
void ecrit_np(char *nouv_nom, char *nouv_pre)
{ nom = nouv_nom; prenom = nouv_pre; }
};

Dans ce cas, la fonction membre est automatiquement en ligne, bien


qu’on n’ait pas écrit le mot inline.

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

Cependant, on écrit en général d’abord les champs, puis les méthodes


pour d’évidentes raisons de clarté.

Les méthodes peuvent être récursives, en accord avec le principe énoncé


précédemment.

Différences entre données et fonctions membres


Il est important de comprendre la différence subtile qui sépare les
données membres (champs) et les fonctions membres (méthodes) d’une
structure. Elle est assez proche de la différence entre la structure elle-même
(en tant que type, c’est-à-dire d’objet théorique) et les variables de ce type
structure, que l’on appelle instances de la structure, qui sont au contraire
des objets pratiques ayant une adresse et un volume mémoire déterminé.

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

ont des champs nom différents.

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.

Il n’est pas possible de faire en sorte que certaines instances d’une


structure donnée utilisent certaines méthodes, et d’autres de différentes.
Cependant, il existe un moyen simple, l’héritage, de créer une nouvelle
structure identique à la première, sauf pour certaines méthodes qui
différeront éventuellement (voir chapitre 8).

Pointeurs sur membres


Il est parfaitement possible de prendre l’adresse d’un membre de
structure (ou de classe), comme ceci :

employe *pempl;
int *empl_age = &(pempl->age);

Noter cependant les parenthèses imposées par la précédence des


opérateurs (voir tableau en annexe ; pour l’intérêt et l’usage des pointeurs
de fonctions, voir chapitre 5.)

Un problème se pose toutefois avec les fonctions membres non statiques.


En effet, celles-ci admettent un paramètre implicite this. De ce fait,
l’écriture suivante :

struct exemple {
// ...

142
void methode(int);
};

void (*pf)(int) = exemple::methode; // incorrect

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 :

void (exemple::*pf)(int) = exemple::methode; // ok

Il est obligatoire de préciser exemple:: devant le nom de la méthode, car


d’autres classes (dérivées, voir chapitre 8) pourraient être utilisées dans
certains cas. Pour appeler ces méthodes, il faut toujours une instance de
exemple. On utilise les opérateurs .* et ->* :

exemple ex, *pex;


(ex.*pf)(6); // équivaut à ex.methode(6);
(pex->*pf)(9); // équivaut à pex->methode(9);

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

Nous avons ajouté deux méthodes qui indiquent si l’employé en question


est le patron, ou si c’est un directeur (employé dont le supérieur direct est le
patron).

Les membres statiques permettent d’économiser des variables globales.


Ils sont initialisés à zéro comme toute variable statique ; il n’est pas possible
de leur donner une autre valeur initiale (mais les constructeurs suppléent à
ce défaut, comme on le verra plus loin).

Il existe deux moyens d’accéder aux membres statiques. Le premier


consiste à utiliser une variable du type structure adéquat, et l’opérateur
point, comme pour tout autre membre :

fiche employe;
fiche lepatron = *employe.patron;

Le second consiste à utiliser le nom de la structure et l’opérateur de


résolution de portée ::, comme ceci :

fiche lepatron = *fiche::patron;

On observera au passage que ces deux écritures sont correctes car


l’opérateur de déréférencement * a une précédence plus faible que le point .
et que :: (voir annexe).

Il est également permis de créer des méthodes statiques. Le principal


intérêt de telles méthodes, par rapport aux autres qui sont de toute façon
également partagées par toutes les instances d’une même structure, est
d’être accessibles même si l’on ne dispose d’aucune instance de la structure,

144
en utilisant le nom de la structure suivi de ::. De ce fait, de telles méthodes
n’ont pas d’argument caché this.

Protection des données


Une des sources les plus fréquentes d’erreur dans les programmes est la
modification incorrecte d’une donnée. Telle variable que l’on croyait égale à
telle valeur se trouve en fait différente parce qu’une fonction erronée l’a
changée sans prévenir. La recherche de telles erreurs est assez difficile, car
même si l’on sait quelle variable est incorrecte, il n’est pas forcément
évident de trouver la fonction fautive parmi les dizaines qui l’utilisent.

Le langage C++ fournit, nous l’avons vu, un certain nombre de


mécanismes afin d’éviter ce type d’erreur ; par exemple, on peut déclarer
constants certains arguments de fonctions afin qu’une erreur d’écriture les
modifiant soit signalée par le compilateur. Une certaine discipline du
programmeur reste utile, par exemple en évitant d’utiliser de nombreuses
variables globales.

Mais l’un des meilleurs mécanismes, quoiqu’il paraisse lourd au début,


est lié aux structures, et surtout aux classes comme le verra plus loin. Il est
possible en effet de déclarer certains champs ou certaines données comme
privés, en utilisant le mot clé private. Ces champs et ces méthodes privés ne
peuvent plus être utilisés par les fonctions extérieures à la structure ; seules
les autres méthodes peuvent les lire ou les modifier.

Au contraire, les méthodes et (plus rarement) les champs librement


modifiables seront déclarés publics à l’aide du mot clé public. Par défaut, les
membres d’une structure sont publics, c’est pourquoi nous avons pu écrire
les exemples précédents de manière correcte.

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.

Voici un exemple d’implantation d’une structure noeud, brique de base


d’une liste chaînée, sans méthode pour le moment :

typedef long element; // (par exemple)

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;

provoque une erreur Error : 'noeud::suivt' is not accessible,


'noeud::suivt' n’est pas accessible.

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

Contrairement aux précédentes, ces fonctions n’ont pas été écrites en


ligne, car elles sont un peu plus complexes (mais on aurait tout de même pu
le faire, elles ne contiennent pas de boucle).

Exercice 6.1 Quel fondement essentiel manque dans notre liste


chaînée, qui la rend inutilisable ? Comment résoudre

147
le problème (un meilleur moyen est donné plus loin
Voir solution
dans le chapitre) ?

Solution de l’exercice 6.1


Pour insérer un nouvel élément dans une liste, il faut déjà disposer d’un
noeud. Mais on ne peut pas initialiser un noeud correctement, puisqu’il n’y
a pas d’accès aux champs. Une solution consisterait à définir une méthode
élémentaire qui crée un nouveau noeud, point de départ d’une liste
chaînée :

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).

À part la réserve mentionnée dans l’exercice, notre type de base pour


liste chaînée est assez au point, et évitera bien des erreurs. Nous ferons
mieux par la suite, mais voyons encore quelques notations.

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.

En pratique, les champs sont généralement privés, et les méthodes


publiques (puisqu’elles ne risquent pas d’être modifiées). De ce fait, le mot
private viendra logiquement dès le début de la déclaration de la structure,

comme dans notre exemple, puisque les champs sont la plupart du temps
déclarés d’abord.

Pour raccourcir les écritures, on peut utiliser le mot class à la place du


mot struct. Une classe est en tout point identique à une structure, sauf que
ses membres sont privés par défaut, et non publics. De ce fait, la déclaration
de noeud se fera un peu plus simplement ainsi :

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 deux champs sont ici privés.

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

contraire on ne pourrait rien faire de ses membres. Ce n’est cependant pas


vrai si elle contient le mot friend, terme expliqué au chapitre 7, puisqu’alors
une fonction au moins, ou une classe entière pourra utiliser cette classe ; on
définit ainsi une classe « réservée ».

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

exemple ex3 = { 5, 'a' };

Les champs non précisés sont mis à zéro dans ce cas.

Cette possibilité d’initialiser des données au moment de leur déclaration


est précieuse, et doit être employée au maximum, comme on l’a déjà dit
précédemment. Cependant, elle possède trois défauts importants. D’abord,
dans le cas de classes ou de structures ayant des parties privées, on ne peut
évidemment pas l’employer, puisque ces parties sont inaccessibles ; même
si elles l’étaient, rien ne prouve que le programmeur qui utilise une classe
sait l’initialiser correctement ; par exemple, on ne sait pas initialiser
correctement la classe iostream (voir chapitre 9) qui est fournie dans la
librairie d’entrées-sorties. D’autre part, les initialisations complexes,
nécessitant par exemple des appels de fonctions successifs ne sont pas
possibles directement. Enfin, les variables automatiques n’étant pas
initialisées par le compilateur, le programmeur risque d’oublier de le faire,
et il en résultera des erreurs graves.

De ce fait, le langage fournit à l’intention des classes et structures un


système un peu plus complexe, mais bien plus sophistiqué, les
constructeurs et les destructeurs. Ce système supplée non seulement au
premier défaut mais également aux deux autres, comme nous allons le voir.

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

Lorsqu’une variable est déclarée, elle est initialisée par un constructeur.


Le choix se fait en fonction des arguments, comme pour les fonctions
recouvertes :

exemple ex1(1,'A'); // constructeur 2 appelé


exemple ex2() // constructeur 1 appelé

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.

Constructeurs par défaut


Une classe peut avoir ou non un constructeur par défaut. Il s’agit d’un
constructeur sans aucun paramètre. Dans notre exemple de la section
précédente, c’est celui que nous avons numéroté 1.

S’il n’y a aucun constructeur défini dans la classe, C++ en crée un


automatiquement qui est un constructeur par défaut, et qui ne fait rien. De
ce fait, une classe a toujours un constructeur, mais n’a pas forcément un
constructeur par défaut ; en effet, si l’on définit un constructeur avec
paramètre seul, C++ ne fournit pas le constructeur par défaut automatique,
et de ce fait il n’y en a pas.

151
Lorsqu’un constructeur par défaut existe, on peut déclarer une instance
de classe sans préciser de paramètres :

exemple ex2; // plus pratique que ex2()

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

n’a pas de constructeur par défaut, aussi l’écriture :

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.

Lorsqu’on veut créer un tableau d’instances de la classe sans donner de


valeurs initiales, un constructeur par défaut doit exister ; il est alors appelé
pour tous les éléments du vecteur :

exemple table[10]; // ok, 10 appels du constructeur 1

autre tab[3]; // NON, pas de constructeur par défaut

Précisons un point très important au sujet de la présence d’un


constructeur par défaut : bien que les constructeurs puissent avoir des
arguments par défaut comme toute autre fonction, un constructeur dont
tous les arguments possèdent une valeur par défaut n’est pas un
constructeur par défaut. Ainsi, si l’on modifie la classe autre comme ceci :

class autre {
double d;
public:
autre(double dd = 0) { d = dd; }

152
};

alors l’initialisation suivante est acceptée :

autre a; // ok, appel de autre::autre(0)

mais le tableau est toujours refusé :

autre tab[3]; // NON, pas de constructeur par défaut

Appel des constructeurs


Un constructeur ne peut pas être appelé autrement que lors d’une
initialisation. Cependant, il peut l’être de différentes façons. Par exemple,
s’il existe un constructeur qui n’admet qu’un seul paramètre, ou plusieurs
mais tel que tous les arguments sauf le premier ont une valeur par défaut,
on peut l’appeler en écrivant le signe égal suivi du paramètre.

Par exemple, en reprenant la classe autre du paragraphe précédent, on


peut écrire :

autre au = 1.2; // appel de autre::autre(1.2)

Cette écriture est équivalente à la forme classique :

autre au(1.2);

mais elle est plus claire. En outre, il est possible d’initialiser des tableaux de
cette façon :

autre atab[4] = { 1.2, 2, 0.7, -9.9 };

Il est obligatoire de préciser toutes les valeurs initiales lorsqu’aucun


constructeur par défaut n’existe.

Le compilateur gère lui-même les initialisations de variables


automatiques, parmi lesquelles figurent les arguments de fonction. Par
conséquent, si l’on a écrit une fonction comme celle-ci :

void f(exemple ex);

153
il est parfaitement légitime de l’appeler sous cette forme :

f( exemple(1, 2) );
f( exemple() );

Dans ce cas les constructeurs adéquats sont appelés à l’entrée de la


fonction (et des destructeurs à la sortie). De même, si l’on a une fonction :

void g(autre au);

on peut tout à fait écrire :

g(1); // appel de g( autre::autre(1) )

En d’autres termes, le constructeur autre::autre(double) définit un


changement de type automatique du type double vers le type autre (et donc
de tous les types numériques vers ce type).

Il est parfaitement possible de préciser une valeur par défaut à un


argument de type classe, pourvu qu’on utilise un constructeur :

void f(exemple ex = exemple() );


void f2(exemple ex = exemple(1, 1) );
void g(autre au = 0);

Dans le dernier cas, on a encore utilisé le changement de type


automatique.

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

classexmpl c1; // constructeur par défaut


classexmpl c2 = c1; // appel du constructeur de copie
// équivaut à classexmpl
c2(c1);

Toute classe a nécessairement un constructeur de copie. Lorsqu’aucun


n’est défini explicitement, le compilateur en crée un automatiquement, qui
se contente de recopier champ par champ l’argument dans this. Ce
constructeur suffit dans les cas simples. Nous donnons un peu plus loin des
exemples où il ne suffit pas.

Noter ce point très important : le constructeur de copie n’est appelé


(comme tout constructeur) que lors d’une initialisation. De ce fait, si l’on
écrit :

c2 = c1;

ce n’est pas ce constructeur qui est appelé, mais l’opérateur d’affectation =,


qui par défaut recopie les champs un à un ; il faut également le redéfinir si
nécessaire (voir chapitre 7). Cette remarque met en relief un fait essentiel
qui est que lors des deux écritures :

exemple c2 = c1; // appel du constructeur de copie


c2 = c1 // appel de l'opérateur d'affectation;

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.

Pour la même raison, il faut comprendre que, lors de l’appel d’une


fonction f(exemple ex) sous la forme f(e1), ce n’est pas une affectation qui

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é.

La tâche d’un destructeur est souvent très simple. Il s’agit généralement


de libérer la mémoire prise par l’instance sur le tas, s’il y a lieu. Dans bien
des cas, le destructeur standard qui est créé implicitement par le
compilateur lorsqu’on n’en a pas défini explicitement est largement
suffisant. Il n’en est toutefois pas toujours ainsi. Imaginons une classe qui
utilise un grand tableau créé dans le tas (par new par exemple, comme vu au
chapitre 3) ; dans ce cas, un destructeur sera défini qui libérera cette place :

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

Le destructeur a pour nom le nom de la classe précédé du symbole tilde


~. Un destructeur n’a aucun résultat, comme les constructeurs, et n’admet
aucun argument ; de ce fait, il ne peut y avoir qu’un destructeur par classe.

D’une façon générale, le destructeur doit tout « remettre en ordre » dans


ce que l’instance de classe peut avoir modifié. Outre la libération de la

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.

New et delete avec constructeurs et destructeurs


L’opérateur new dont nous avons déjà parlé fait en réalité deux choses
successivement : primo, il réserve la place mémoire nécessaire à l’objet dans
le tas ; secundo, il appelle un constructeur. Inversement, delete appelle
d’abord le destructeur, puis libère la place mémoire.

Comme une classe peut avoir plusieurs constructeurs, on peut préciser


quel constructeur est appelé au moment de l’appel de new. Il suffit pour cela
d’écrire la liste des arguments derrière le nom de classe qui suit new. Voici
quelques exemples, basés sur les classes définies aux sections précédentes :

exemple *pe1 = new exemple(1, 2); // appel du


constructeur 2
exemple *pe2 = new exemple; // appel du
constructeur 1
autre *pa = new autre(1); // appel de
autre::autre(1)
classexmpl *c1 = new classexmpl; // constructeur par
défaut
classexmpl *c2 = new classexmpl(*c1); // constructeur de
copie

Lorsqu’aucun paramètre n’est précisé (c’est-à-dire de la façon dont nous


avons utilisé new jusqu’à présent), le constructeur par défaut est appelé ; s’il
n’existe pas, une erreur de compilation se produit.

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).

Précisons que cette méthode d’initialisation conjuguée avec new est


valable aussi pour des types qui ne sont pas des classes, car ils possèdent
toujours un constructeur par défaut (d’où l’écriture que nous avons utilisée
jusqu’à présent) et un constructeur de copie. On peut donc écrire par
exemple :

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 :

exemple *pex = new exemple[10];


// ...
delete pex // incorrect;

le compilateur, qui n’a aucun moyen de connaître la taille du tableau pointé


par pex, n’appellera le destructeur que pour le premier élément, ce qui peut
poser problème. Pour lui demander de tout détruire, il faut préciser
explicitement avec delete, le nombre d’éléments à supprimer :

exemple *pex = new exemple[10];


// ...
delete[10] pex;

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.

Appel explicite d’un destructeur


Il n’est pas possible d’appeler explicitement un constructeur (il faut
passer par une initialisation), mais on peut appeler explicitement un
destructeur.

Il s’agit d’une technique un peu spéciale pour programmeurs


chevronnés. Elle est basée sur le fait que l’on peut créer un objet qui ne
réserve pas de place mémoire. Voici un exemple :

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();

Nous verrons au chapitre 7 un autre exemple basé sur une redéfinition


de l’opérateur new.

Retour sur l’exemple


Revenons à la liste chaînée qui nous a servi d’exemple précédemment.
Quels constructeurs et destructeurs lui donner ?

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.

Un constructeur simple ayant pour argument un element permettra de


définir une racine de liste (noeud seul dans une liste). Un autre
constructeur, acceptant aussi un element, plus un pointeur sur un noeud,
permettra de créer un nouveau noeud en l’insérant derrière l’autre.

Il est facile de faire un seul constructeur de ces deux, en utilisant les


arguments par défaut. On obtient alors la définition de classe suivante :

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

inline noeud::noeud(element e, noeud *precedent)


// crée le noeud et l’insère derrière precedent
// si non nul; sinon crée une racine
{
elm = e;
if (precedent) {
suivt = precedent->suivt;
precedent->suivt = this;
}
else suivt = 0;
}

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

inline 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(e, this);
return nouveau;
}

Nous avons défini aussi un constructeur de copie qui ne recopie que la


partie element de la classe ; en effet, recopier le pointeur serait ici une faute,
puisqu’on sèmerait le désordre dans la liste chaînée.

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.

Solution de l’exercice 6.2


Avec les éléments dans le même ordre :

noeud* cree_liste(int nombre, element *tab)


{
if ( (nombre <= 0) || (!tab) ) return 0;
noeud *racine = new noeud(*tab++), *pn =
racine;
while ( (--nombre > 0) && (pn) )
pn = new noeud(*tab++, pn);
return racine;
}

On notera que cette fonction renvoie 0 si la racine n’a pu être créée, et


une liste incomplète si tous les éléments n’ont pu être insérés faute de place
en mémoire (condition &&(pn) dans la boucle).

Pour insérer dans l’ordre inverse, il suffit de toujours insérer derrière la


racine, qui contient le dernier élément de la table :

noeud* cree_liste_inv(int nombre, element *tab)


{
if ( (nombre <= 0) || (!tab) ) return 0;
noeud *racine = new noeud(*(tab + --nombre));
if (racine)

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 :

const N = ...// valeur fixe


element table[N] = { ... // liste des éléments };
noeud *racine = cree_liste(N, table);

et la liste chaînée est construite. On aurait pu toutefois en faire des


méthodes statiques.

Variables globales et statiques


Les variables globales sont initialisées par l’appel d’un constructeur
avant le début du programme principal (fonction main, voir chapitre 1), et
détruites par leur destructeur à la fin de celui-ci (sauf en cas d’interruption
forcée du programme par _exit ou abort). Cela fournit un moyen très simple
d’exécuter une fonction avant d’entrer dans main :

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
{
// ...
}

Il existe parfois, selon le compilateur, d’autres moyens d’obtenir ce


résultat en utilisant des #pragma (voir chapitre 10), mais celui-ci est à notre
avis le plus simple, et aussi le plus portable puisque les constructeurs et
destructeurs font partie du langage C++.

Les variables statiques, à l’intérieur des fonctions, sont aussi initialisées


par un constructeur et détruites en fin de programme. Cependant, le
langage ne précise pas si ces constructeurs doivent être appelés même si la
fonction qui contient la variable statique ne l’est jamais, et à quel moment.
Par exemple Turbo C++ initialise de telles variables au moment du premier
appel de la fonction, mais d’autres compilateurs peuvent éventuellement
faire ces initialisations au début du programme. Il est donc préférable de ne
pas faire d’hypothèses sur ce point.

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

Tout le problème consiste à initialiser ces membres dans le constructeur


de la classe. La solution immédiate :

multiple::multiple()
{
ex.exemple::exemple(); // incorrect
au.autre::autre(0); // idem
}

est incorrecte, car on ne peut pas appeler explicitement un constructeur.

Le langage fournit un mécanisme spécial pour résoudre ce problème.


Derrière la liste des arguments du constructeur, on place le symbole deux-
points :, suivi des initialisations de chaque membre, séparées par une
virgule :

multiple::multiple() : ex(), au(0); // constructeur par


défaut

Les constructeurs sont appelés dans l’ordre de leur écriture (donc ici
d’abord exemple::exemple(), puis autre::autre(0)).

Cette séquence d’initialisation doit être écrite dans chaque constructeur,


s’il y a lieu :

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) {}
// ...
};

Ce constructeur est équivalent à :

ordinaire(int i, double d) { ii = i; dd = d;}

L’intérêt de la première notation est assez faible dans ce cas, la seconde


est bien plus claire.

Lorsque le destructeur d’une classe est appelé, automatiquement ou non,


les destructeurs des membres sont exécutés à la fin de celui-ci de manière
automatique.

Lorsqu’une classe contient des références à d’autres types (classes ou


non), le mécanisme d’initialisation est strictement identique. Cependant,
dans ce cas, l’initialisation est obligatoire. Ainsi l’écriture :

class fautive {
int& i;
public:
fautive() {}; // NON, pas d’init. de la référence
};

provoque une erreur Error : Reference member 'i' is not initialized in

function fautive::fautive(), le membre référence 'i' n’est pas initialisé.

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) {}

est refusée par le compilateur (Error : Reference member 'i' need a


temporary for initialization, le membre référence 'i' nécessite une variable
temporaire pour l’initialisation).

C’est tout à fait logique : le compilateur ne peut pas savoir si un membre


référence a été initialisé sur une variable temporaire au moment de la
destruction. En conséquence, il interdit d’initialiser une référence sur une
variable temporaire, et en contrepartie il n’y a jamais d’appel de destructeur
sur les membres références au moment de la destruction de l’instance. Si ce
mécanisme ne vous convient pas, il faut utiliser un membre normal, non
une référence.

Les seuls constructeurs acceptables pour que la classe fautive ne le soit


plus seraient :

fautive::fautive() : i(j) { }
fautive::fautive(int k) : i(k) { };

où j est une variable globale, ou un autre membre de la classe fautive.

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

Le destructeur doit alors explicitement libérer la mémoire occupée par


les instances pointées.

Intérêt des constructeurs


Nous avons dit au début de ce long paragraphe sur les constructeurs et
destructeurs qu’ils étaient fournis en premier lieu pour résoudre le
problème posé par l’initialisation des membres privés d’une classe, sans
perdre les avantages de l’encapsulation des données. Cependant, nous
avons vu qu’ils résolvent aussi les deux autres problèmes que posait la
méthode élémentaire d’initialisation. D’une part, ils permettent en effet une
initialisation arbitrairement complexe, avec de nombreux appels de
fonctions, des boucles, etc. D’autre part, le constructeur par défaut est
appelé automatiquement lors de la création de variables automatiques (s’il
existe, sinon une erreur de compilation est produite), et le destructeur est
appelé lorsque la variable automatique devient inutile.

De plus, la possibilité de supprimer le constructeur par défaut en en


définissant d’autres avec paramètres empêche un programmeur étourdi
d’initialiser une variable automatique lorsque c’est absolument nécessaire ;
par exemple, comme on le verra au chapitre 9, la classe iostream ne possède
pas de constructeur par défaut, mais un constructeur avec tampon ; de la
sorte, on ne peut pas créer une instance de cette classe sans lui fournir le
tampon nécessaire. Il s’agit là d’un mécanisme caché extrêmement puissant
qui assure que les instances d’une classe contiennent toujours des valeurs
significatives et cohérentes (à condition que les méthodes de la classe aient
été définies correctement évidemment).

Les constructeurs sont donc beaucoup plus intéressants que les


initialisateurs usuels. On retiendra qu’il s’agit d’un mécanisme puissant, qui
ne se maîtrise pas immédiatement : une certaine accoutumance est
nécessaire.

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 :

void f(chaine& c1, chaine& c2 =


chaine() )
{
chaine c = c2;
if (c2.longueur() == 0)
c.ajoute("Début = ");
c.ajoute(c1).ajoute(c2);
c.ecrire();
}

main()
Voir solution {
f("suite\n");
f(" milieu ", "bord");

return 0;
}

Il ne faut pas gaspiller de mémoire, mais deux


chaînes même identiques seront conservées dans deux
zones différentes. Le programme doit provoquer
l’écriture de la chaîne Début = suite, puis sur la ligne
suivante de bord milieu bord.

On pourra utiliser les fonctions de <string.h> et des


autres librairies standard nécessaires.

Solution de l’exercice 6.3


Nous allons placer dans la classe chaine à la fois un pointeur sur une
chaîne de caractères usuelle (à charge pour la classe de gérer la mémoire
occupée par cette chaîne dans le tas), et un entier précisant la longueur de la
chaîne, ce qui évite de la recalculer chaque fois. De plus, vu l’écriture de f, il
faut un constructeur admettant une chaîne comme paramètre, un

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.

Voici une implantation possible de cette classe :

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

unsigned longueur(void) { return lg; }


void ecrire(void) { if (p) cout << p; }
chaine& ajoute(chaine&);
};

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

Noter l’usage de realloc dans la méthode ajoute, qui est impératif ; en


effet, strcat (qui concatène deux chaînes) ne crée pas la place mémoire
nécessaire, contrairement à strdup. Le lecteur vérifie que la fonction ajoute
marche même en cas d’ajout à soi-même c.ajoute(c) ; c’est à cela que sert le
pointeur cp.

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.

Il en existe d’autres, et l’un des plus importants est le polymorphisme,


que nous allons illustrer à présent par un exemple (ce qui nous permettra en
même temps de nous perfectionner dans l’usage des classes).

Revenons sur notre liste chaînée. Elle possède un certain nombre de


défauts. Des défauts fonctionnels d’abord : on ne peut pas « revenir en
arrière » dans la liste, puisque les noeuds ne pointent que sur les suivants.
Pour améliorer cela, nous utiliserons une liste circulaire doublement
chaînée. Chaque noeud pointe à la fois sur le précédent et le suivant, et le
dernier et le premier l’un sur l’autre. L’intérêt de cette méthode est qu’il
suffit de posséder un pointeur sur l’un quelconque des noeuds pour pouvoir

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 :

typedef long element; // (par exemple)

class noeud {
noeud *suivt, *prec;
element elm;
public :

noeud(const element& e, noeud* apres = 0)


{ // construit et insère après apres
elm = e;
if (apres) {
suivt = apres->suivt;
prec = apres;
apres->suivt = this;
suivt->prec = this;
}
else prec = suivt = this;
}

~noeud() // destructeur
{ prec->suivt = suivt; suivt->prec = prec;
}
element& contenu(void) { return elm; }
noeud* suivant(void) { return suivt; }
noeud* precedent(void) { return prec; }

noeud* insere(const element& e)


{ // insère après this
noeud *nouveau = new noeud(e, this);
return nouveau;
}
};

Noter la présence d’un destructeur qui permet de supprimer un élément


de la liste sans altérer la structure de celle-ci ; il n’était pas possible d’en
écrire un semblable avec une liste chaînée simple.

On a supposé les éléments assez volumineux (quoique dans notre


exemple il s’agisse de long), d’où l’emploi de références constantes comme
arguments des méthodes insere et noeud.

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

On doit voir alors ceci à l’écran :

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

étant entendu que l’insertion se fait à la position courante.

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 :

liste() { nombre = 0; courant = 0; }


liste(int n, const element*); // constructeur avec
table
~liste();

void avance(int combien = 1);


void recule(int combien = 1)
{ avance(-combien); }

element& valeur(void)
{ if (courant) return courant->contenu();
}
unsigned nombre_elt(void) { return nombre; }

void affiche(unsigned combien = 65535);

int insere(const element&);


void supprime(int n = 1);
};

La liste contient le nombre de ses éléments (afin d’éviter d’avoir à les


recompter quand on en a besoin), et un pointeur sur un élément quelconque
de la liste, qui est la racine courante ; il est parfaitement possible
(contrairement à une liste simple) d’avancer ce pointeur de noeud en noeud
ou de le faire reculer ; on ne change ainsi que l’ordre de vue des éléments,
non leur nombre ni leur position relative.

La méthode nombre_elt indique le nombre d’éléments dans la liste, et


valeur donne la valeur de l’élément courant. Un constructeur permet de
créer la liste en bloc à partir d’un tableau, comme dans l’exemple de
programme ci-dessus, et le constructeur par défaut crée une liste vide.

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.

Pour toutes les méthodes, on tiendra compte de la


limitation de la mémoire, qui peut obliger à n’insérer
qu’une partie des éléments lors de l’appel du
Exercice 6.4
constructeur par exemple.

Voir solution

Solution de l’exercice 6.4


Voici une solution possible :

liste::~liste()
// destructeur de la liste : supprime tous les noeuds
{
if (!nombre) return;
while (--nombre) delete courant->suivant();
delete courant;
}

void liste::avance(int combien)


// avance du nombre indiqué dans la liste
{
if (courant)
if (combien > 0)
while (combien--)
courant = courant->suivant();
else
if (combien < 0)
while (combien++)
courant = courant->precedent();
}

void liste::affiche(unsigned combien)


// affiche combien éléments de la liste
// (et nombre_elt au max)

174
{
if (combien > nombre) combien = nombre;
while (combien--) {
cout << '\t' << courant->contenu();
courant = courant->suivant();
}
cout << '\n';
}

liste::liste(int n, const element* etab)


// construit une liste de n éléments pointés par etab
{
nombre = 0; courant = 0;
if ( (n <= 0) || (!etab) ) return;
noeud* np = 0;
while ( (np = new noeud(*etab++, courant =
np))
&& (++nombre < n) );
if (np) courant = np->suivant();
}

inline int liste::insere(const element& e)


// insère e à la position courante;
// renvoie 0 si plus de place
{
noeud *np = new noeud(e, courant-
>precedent());
if (np) { courant = np; nombre++; return 1; }
else return 0;
}

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

Noter comme le constructeur et le destructeur de noeud simplifient la


tâche, en particulier dans insere et supprime.

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é.

Exercice 6.5 Quel(s) manque(s) voyez-vous dans la classe liste ?


Et quelles méthodes lui ajouteriez-vous ?

Voir solution

Solution de l’exercice 6.5


Il paraît essentiel de créer un constructeur de copie, car la copie standard
ne donnera pas le résultat correct, puisque les deux instances de liste
désigneraient la même liste ; en particulier, lors d’une insertion ou
suppression sur l’une, le champ nombre de l’autre deviendrait incorrect. Voici
un tel constructeur de copie :

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

Noter que la fonction est considérablement compliquée par l’incertitude


sur l’allocation mémoire. Si l’on décidait d’ignorer ce problème, il suffirait
en effet d’écrire :

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

La remarque vaut aussi pour l’autre constructeur (exercice précédent).

Naturellement, d’autres méthodes peuvent encore être ajoutées : nous


laissons ici l’imagination du lecteur travailler.

Un tel mécanisme de liste doublement chaînée est intéressant lorsque les


éléments sont assez volumineux (sinon l’ajout de deux pointeurs
augmenterait trop la place mémoire occupée) et que l’on fait de nombreuses
opérations d’insertion et de suppression.

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.

Pour l’accélérer et gagner de la place mémoire, on peut changer le type


liste en un tableau ordinaire.

En programmation ordinaire, il faudrait refaire tout le programme


utilisant le type liste. Cela n’est pas le cas en programmation objet. Voici
une seconde implantation possible de liste, qui... n’est pas une liste mais un
tableau dont l’apparence extérieure est identique, de sorte que le
programme donné précédemment fonctionne de la même façon avec celle-
ci :

class liste { // 2° version : tableau


element *tab, *courant;
int nombre;

public :
liste() { nombre = 0; courant = tab = 0; }

177
liste(int n, const element*); // constructeur avec
table
~liste();

void avance(int combien = 1);


void recule(int combien = 1)
{ avance(-combien); }

element& valeur(void)
{ if (courant) return *courant; }
unsigned nombre_elt(void) { return nombre; }

void affiche(unsigned combien = 65535);

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.

Exercice 6.6 Écrire les six méthodes non implantées ci-dessus.

Voir solution

Solution de l’exercice 6.6


Voici une solution :

inline liste::~liste()
// destructeur de la liste : supprime la table
{
delete tab;
}

void liste::avance(int combien)


// avance du nombre indiqué dans la liste
{
if (courant) {

178
courant += combien % nombre;
combien = courant-tab; // index courant
if (combien < 0) courant += nombre; else
if (combien >= nombre) courant -= nombre;
}
}

void liste::affiche(unsigned combien)


// affiche combien éléments de la liste
// (et nombre_elt au max)
{
if (combien > nombre) combien = nombre;
element *fin = tab +nombre;
while (combien--) {
cout << '\t' << *courant;
if (++courant == fin) courant = tab;
}
cout << '\n';
}

liste::liste(int n, const element* etab)


// construit une liste de n éléments pointés par etab
{
nombre = 0; courant = tab = 0;
if ( (n <= 0) || (!etab) ) return;
if (!(tab = new element[n])) return;
memmove(tab, etab, n*sizeof(element) );
nombre = n; courant = tab;
}

int liste::insere(const element& e)


// insère e à la position courante;
// renvoie 0 si plus de place
{
unsigned size = sizeof(element);
element *ep =
(element*) realloc(tab, (1+nombre)*size );
if (!ep) return 0; // plus de place mémoire
int i = courant -tab; // index actuel
tab = ep; courant = tab +i;
memmove(courant+1, courant, (nombre++ -
i)*size);
*courant = e;
return 1;
}

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

On notera que les opérations de suppression et d’insertion sont plus


complexes, mais que les autres sont plus simples. D’autre part, le
constructeur a un effet légèrement différent de celui de la première version,
en ce sens que si la place mémoire manque, il crée une liste vide, alors que
l’autre remplissait partiellement la liste jusqu’à débordement.

Retour au texte.

Un examen détaillé et des essais montrent que l’opération d’insertion est


à présent trois fois plus lente, mais que les autres sont plus rapides, même
la suppression (car la suppression de plusieurs noeuds est assez lente en
fait). On y gagne donc s’il y a peu d’insertions séparées.

Exercice 6.7 Le reproche fait à la première implantation de liste


(exercice 6.5) s’applique-t-il toujours ? Si oui, que

180
Voir solution faire ?

Solution de l’exercice 6.7


Le même reproche s’applique. Voici un constructeur de copie possible :

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

On a la même différence avec la première version que pour l’autre


constructeur (exercice précédent).

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.

Ensuite, changeant d’avis sur l’implantation de liste, nous avons recréé


cette classe sur un modèle complètement différent, mais les en-têtes des
méthodes sont restés les mêmes, de sorte que le programme d’exemple n’a
nécessité aucune modification.

Cette capacité à réaliser un ensemble logiciel de plusieurs façons


différentes mais de manière totalement transparente s’appelle le
polymorphisme. C’est un atout capital de la POO, surtout pour des
programmes de grande taille qu’il est exclu de réécrire entièrement
lorsqu’on en modifie une petite partie.

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.

Unions et champs de bits


Pour finir ce chapitre, nous donnons deux capacités que C++ hérite du
langage C. Si elles ne sont pas d’un usage très courant, elles peuvent
toutefois être d’un certain secours dans des cas très spécifiques dont nous
donnons quelques exemples.

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.

Pour cela, on dispose d’opérateurs sur les types entiers, comme le


décalage à gauche ou à droite (<< , >>), le « et » , le « ou » et le « ou exclusif »
logiques (&, |, ^).

Cependant, lorsqu’on doit faire de nombreux accès aux bits séparés


d’une donnée, cela devient trop long, et désagréable, de spécifier une
opération logique chaque fois. Les structures à champs de bits permettent
de résoudre ce problème.

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

Lorsqu’un champ de bits est unsigned, sa valeur varie de 0 à 2b -1, où b est


le nombre de bits. Par exemple, le champ basbas a une valeur de 0 à 15.
Lorsque le champ est signed, sa valeur varie de -2b-1à 2b-1-1, le bit de poids
fort servant de bit de signe ; ainsi le champ hauthaut varie de -8 à 7.

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

le résultat est de tronquer i en ne conservant que ses quatre bits de poids


faibles.

Si vous écrivez :

int i = -7890;
champbits cb;
cb = *(champbits*)&i; // recopie i dans cb

vous obtiendrez dans cb la décomposition de -7890 en quatre parties, soit {


14, 2, 1, -2 }, indiquant que ce nombre vaut 0xE12E en mémoire (dans sa
forme sans signe).

Voici un autre exemple, un peu plus intéressant à notre avis. La fonction


suivante calcule la valeur de la mantisse, de l’exposant et du signe d’un
nombre à virgule flottante float. Ces quantités sont réparties ainsi dans les

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 :

void disseque(float f, int& signe, int& exposant


long& mantisse)
{
struct dissq {
unsigned mantisse1 : 16;
unsigned mantisse2 : 7;
unsigned exposant : 8;
int signe : 1;
} fb;

fb = *(dissq*)&f; // recopie f dans fb


exposant = fb.exposant -127;
signe = fb.signe;
mantisse = 0x800000 | fb.mantisse1 |
(long(fb.mantisse2) << 16);
}

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.

Signalons aussi que, lorsque l’on demande au compilateur Turbo C++


d’aligner les données sur les mots de mémoire, il ne doit pas y avoir de
champ de bits chevauchant une limite de mot, sinon il est décalé sur le mot
suivant.

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.

Pour résoudre partiellement ce problème, le langage fournit les unions. Il


s’agit de groupes de données, comme les structures, mais au lieu de se
trouver placées les unes derrière les autres en mémoire, elles se trouvent
toutes à la même adresse. Par exemple, l’union suivante :

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.

On peut initialiser une union en donnant entre accolades la valeur de son


premier champ, comme ceci :

longgroupe lg = { 100000 };

D’une façon générale, une union peut contenir autant de champs de


n’importe quel genre que souhaité, mais ils se trouvent tous à la même
adresse, de sorte que la taille de la structure est celle du plus long de ces
champs. En outre, l’union ne sait pas quel champ « est le bon » , en ce sens
que n’importe lequel peut être modifié à tout moment, avec des
répercussions sur tous les autres. C’est pourquoi il est peu recommandé de
placer des informations différentes dans une union simplement pour

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.

Un tel moyen consiste à utiliser les unions comme champs de structures


ou mieux encore de classes. Prenons un exemple (un peu bête car il est
difficile de ne pas en prendre un artificiel, vu qu’il existe souvent de
meilleurs systèmes) : nous disposons d’un fichier avec les noms complets de
personnes. En Occident, le nom complet est généralement formé de deux
mots, ici arbitrairement limités à 15 caractères. Dans certains pays, en
Orient notamment, il est composé de trois mots plus petits (10 caractères),
dont seul le premier représente le nom de famille, les deux autres formant le
prénom. Voici un exemple de classe qui peut indifféremment stocker un
nom oriental ou occidental. Un champ spécial indique si l’on est en
présence de l’une ou l’autre alternative :

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

Noter qu’il n’est pas nécessaire de préciser un nom de champ pour


l’union, les noms des champs internes suffisent. Par contre, quand une
union contient un champ de type structure, classe ou union, il faut lui
donner un nom.

Le constructeur suppose que lorsque aucun second prénom n’est précisé,


il s’agit d’un nom occidental. On peut donc écrire :

noms occ("Dupont", "Jean");


noms ori("Fang", "Li", "Zi");

Nous laissons au lecteur le soin d’écrire une fonction renvoyant le nom


complet, avec la convention que le prénom vient en premier en occident,
tandis qu’il vient en dernier en orient.

Exercice 6.8 Trouvez un moyen plus simple d’implanter une


telle classe, sans utiliser d’union.

Voir solution

Solution de l’exercice 6.8

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.

Exercice 6.9 Sans écrire les méthodes, donner une implantation


d’une classe pouvant contenir soit un nom et un
prénom de 15 caractères chacun, soit un nom de 15
caractères, un prénom de 14 et une initiale
Voir solution intermédiaire de 1 (nom américain), soit un nom en
trois parties à l’orientale.

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.

Solution de l’exercice 6.9


Il faut recourir à une structure dans l’union :

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.

Dans certains cas, cependant, on souhaite pouvoir utiliser une fonction


qui puisse accéder aux membres d’une classe, sans toutefois nécessairement
disposer d’une instance de cette classe par laquelle l’appeler.

Une première possibilité consiste à utiliser un membre statique. Si l’on


écrit par exemple :

class exemple {
// parties privées...
public :
static exemple* f(void);
// ...
};

il est possible d’appeler la fonction f sans passer par un membre, comme


ceci :

exemple *p = exemple::f();

Cependant cette notation, si elle a l’avantage de la clarté, est assez


lourde. C’est pourquoi le langage fournit les fonctions amies pour résoudre
ce problème.

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

exemple inverse(exemple ex)


// renvoie ex avec tous les bits inversés
{
exemple ex2 = ex;
ex2.i = ~ex2.i; // accès aux champs
ex2.j = ~ex2.j;
return ex;
}

À part le fait qu’elle est amie de la classe exemple, la fonction est


parfaitement ordinaire, et peut être déclarée et définie de la même façon
que toute autre.

Le terme même d’amie indique clairement que la fonction doit avoir un


comportement décent : il faut veiller à ce qu’elle ne modifie pas
incorrectement les membres de la classe.

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).

Notons que la « déclaration d’amitié » doit se faire à l’intérieur de la


classe. De ce fait, si l’on dispose d’une classe mais sans avoir la possibilité de
la modifier (par exemple, dans un fichier en-tête on peut ne trouver que la

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 :

inline complexe conjug(complexe c)


{
return complexe(real(c), -imag(c));
}

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

void autre::combine(exemple ex)


{
// utilise les membres privés de ex
}

La fonction combine, qui fait une modification quelconque de l’instance de


autre qui l’appelle, à l’aide des données contenues dans une instance de

exemple, a libre accès aux parties privées des deux classes.

On aurait pu aussi écrire une fonction amie des deux classes :

class exemple {
// ...parties privées
public :
friend void combine(autre&, exemple);
};

class autre {
// ...
friend void combine(autre&, exemple);
};

void combine(autre& au, exemple ex)

{
// 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 autre; // déclaration

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 {
// ...
};

Cette écriture, comme la précédente avec une déclaration, est inutilisable


pour des méthodes isolées. De ce fait, si l’on souhaite qu’une méthode de

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

Un élément de la classe « représente » donc la valeur ,


mathématiquement parlant. De ce fait, il est naturel de définir par exemple
une addition sur de tels nombres :

inline fraction somme(fraction f1, fraction f2)


{
return fraction(f1.num*f2.den + f1.den*f2.num,
f2.den*f1.den);
}

On a bien sûr utilisé la formule :

De plus, la fonction somme est supposée avoir été déclarée amie de la


classe, puisqu’elle en utilise les membres.

Lorsqu’on utilise ces fractions, il faut alors écrire :

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

ce qui n’est pas extrêmement pratique. Il paraît relativement naturel


d’écrire plutôt :

fraction f1(2, 5), f2 = 4; // f1 = 2/5, f2 =


4/1
f3 = f1 + f2 - 6; // f3 = 4 +2/5 -6 =
-8/5

C’est ce que permet la redéfinition d’opérateurs.

Opérateurs sur de nouvelles classes


Nous allons définir les quatre opérations de base pour la classe fraction.
Pour cela, il suffit de nommer operator+, operator-, etc., les fonctions
opératoires :

class fraction {
long num, den; // numérateur, dénominateur
public :
fraction(long numer, long denom = 1)
{ num = numer; den = denom; }

friend fraction operator+(fraction,


fraction);
friend fraction operator-(fraction, fraction);
friend fraction operator*(fraction, fraction);
friend fraction operator/(fraction, fraction);
};

inline fraction operator+(fraction f1, fraction


f2)
{
return fraction(f1.num*f2.den + f1.den*f2.num,
f2.den*f1.den);
}

inline fraction operator-(fraction f1, fraction


f2)
{
return fraction(f1.num*f2.den - f1.den*f2.num,

198
f2.den*f1.den);
}

inline fraction operator*(fraction f1, fraction


f2)
{
return fraction(f1.num*f2.num, f2.den*f1.den);
}

inline fraction operator/(fraction f1, fraction


f2)
{
return fraction(f1.num*f2.den, f2.num*f1.den);
}

On peut alors écrire :

fraction f = 1 + 2/fraction(5) - fraction(1,3)*8;

La précédence des opérateurs reste la même (voir tableau en annexe), si


bien que f vaut 1 +2/5 -((1/3)*8), soit -4/15. Noter qu’on peut écrire
2/fraction(5), ou fraction(2)/5, ou fraction(2)/fraction(5), ou encore
fraction(2, 5) (qui cependant a un sens différent car il n’y a pas d’opération

exécutée dans ce cas), mais il ne faut pas écrire 2/5 qui donnerait une
division entière normale (soit 0 ici).

Il reste possible d’employer le nom complet des opérateurs, comme ceci :

fraction f = operator-(operator+(1, operator/(2,


fraction(5)),
operator*(fraction(1,3), 8));

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.

Exercice 7.1 Combien de fonctions sont-elles appelées dans


l’expression précédente ? Et quelle est la place
mémoire occupée au total ?
Voir solution

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

à vous de le vérifier... Quant à la place mémoire occupée, c’est celle de f, soit


huit octets. Si les fonctions n’étaient pas écrites en ligne, il y aurait treize
appels de fonctions, dont neuf appels du constructeur, et la place mémoire
occupée serait (transitoirement) égale à 8*9 octets, sans compter ceux
occupés par f ; cependant cette place serait restituée à la fin du calcul par
neuf appels du destructeur standard, correspondant aux neuf appels
automatiques du constructeur.

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

Nous verrons en fin de chapitre comment choisir l’une ou l’autre


déclaration.

Exercice 7.2 Que se passe-t-il si l’on additionne 1/4 à lui-même ?


Obtient-on le même résultat qu’en multipliant par 2 ?
Comment régler le problème ?

Solution de l’exercice 7.2

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 :

int pgcd(int a, int b)


{
if ( (a == 0) || (b == 0) ) return a+b;
int c;
do {
c = a%b;
a = b; b = c;
} while (c);
return a;
}

class fraction {
int num; int den;
fraction& reduire()
{ int d = pgcd(num, den);
num /= d; den/= d;
return *this; }
public :
// ....
}

Il suffit alors d’appeler la fonction membre privée reduire à la fin de


chaque opération pour simplifier les fractions. Noter que lorsque l’un des
arguments de la fonction pgcd est nul, la fonction renvoie l’autre, de telle
sorte que la fraction sera réduite en 0/1 (soit 0) ou 1/0 (infini, qui est en fait
une valeur erronée).

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

On note que, malgré l’identité des noms, le compilateur accepte cette


fonction en même temps que le moins binaire : c’est un autre exemple de
recouvrement de fonction.

Opérateurs redéfinissables et hypothèses


Tous les opérateurs sont redéfinissables, sauf ?: (qui est le seul opérateur
ternaire de C++), sizeof, et ceux directement liés aux classes, à savoir le
point (.), ainsi que les pointeurs sur membres (.*) et les opérateurs de
résolution de portée (:: et ::*).

On ne peut pas créer de nouveaux opérateurs ayant un nom ne figurant


pas dans la liste donnée en annexe, comme par exemple ** ou :=. De plus, il
n’est pas possible de changer l’ « arité » d’un opérateur, c’est-à-dire son
caractère binaire ou unaire. Enfin on ne peut pas modifier leur précédence,
qui reste toujours celle indiquée dans le tableau en annexe.

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.

En dehors des deux règles énoncées ci-dessus, il n’y a aucune restriction


pratique sur la redéfinition d’opérateurs. En particulier, le compilateur ne
fait aucune hypothèse fonctionnelle à leur sujet ; il ne suppose jamais qu’ils

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.

Rappelons qu’en vertu des règles de recouvrement de fonctions, il peut


exister plusieurs versions différentes d’un même opérateur si elles
s’appliquent à des opérandes différents. Par exemple, on pourrait définir un
opérateur operator+(fraction, long) si l’on connaissait un moyen nettement
plus rapide d’additionner une fraction et un entier que deux fractions (ce
qui n’est guère le cas). Dans ce cas, il faudrait aussi définir operator+(long,
fraction) afin que le gain soit obtenu quel que soit l’ordre d’écriture des
termes.

Types dont on peut redéfinir les opérateurs


On ne peut redéfinir les opérateurs que pour les types structures, classes
ou unions. Les autres en effet sont prédéclarés, avec des opérateurs fixés
une fois pour toutes.

En particulier, on ne peut pas redéfinir les opérateurs pour les pointeurs


ou les tableaux. Si l’on souhaite un type fonctionnellement équivalent en
redéfinissant certains opérateurs, il faut créer une classe. Voici un exemple
imaginable :

class intptr {
int *p;
public :
....
friend int operator*(exempleptr);
}

int operator*(exempleptr ep)


// déréférencement spécial
{
// fait quelque chose de particulier ici
}

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.

Opérateurs de changement de type


Le changement de type est un opérateur (en fait une noria d’opérateurs,
puisqu’il y en a autant que le nombre de types). Pour un type donné, il peut
s’écrire de deux façons différentes lorsqu’on l’utilise soit sous la forme
opératoire (type) x, soit sous la forme fonctionnelle type(x). Dans tous les
cas, c’est un opérateur unaire, de nom operator type() (mais pas
operator(type)() qui provoquerait une erreur). La syntaxe est un peu

spéciale, en ce sens qu’aucun type résultat n’est à déclarer (c’est en fait


type), c’est-à-dire qu’on n’écrit pas type operator type() mais directement

operator type() dans la classe (ce doit être une méthode obligatoirement).

Voici par exemple une définition de changement de type de fraction vers


double tout à fait naturelle :

class fraction {
// ... comme ci-avant
operator double() { return num/ double(den); }
};

On peut alors écrire :

fraction f(3,17); // donne 3/17


double d = double(f); // ou encore d =
(double)f;

Notons que la définition d’un opérateur inverse, de fraction vers double,


est plus problématique, car les fractions ne sont pas généralement
représentables exactement dans un nombre à virgule flottante. La
conversion inverse exige donc une définition d’une notion de précision.

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.

Nous connaissons cependant déjà la solution à ce problème : il suffit


d’écrire un constructeur fraction::fraction(long) dont l’effet sera
strictement identique. C’est d’ailleurs ce que nous avons fait précédemment.

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.

Exercice 7.3 Comment écrire operator++(fraction) si l’on


souhaite obtenir l’effet d’un post-incrément ?

Voir solution

Solution de l’exercice 7.3


Il faut utiliser un objet intermédiaire :

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

element operator[](liste& l, int i = 0);


// donne le i-ième élément de la liste après courant
{
if (!courant) return 0;
noeud *anccourant = l.courant;
l.avance(i);
element e = courant->contenu;
courant = anccourant;
return e;
}

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.

Exercice 7.4 Écrire le même opérateur avec l’autre genre de


liste (celle qui est en fait un tableau). Aurait-on pu

écrire cet opérateur si l’on ne pouvait pas modifier la


Voir solution
définition de la classe liste pour y insérer la
déclaration friend ?

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.

Ainsi, si l’on définit une classe matrice, on peut écrire ceci :

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

Nous avons ici écrit l’opérateur comme un membre, mais on aurait pu


écrire une fonction amie.

207
Cet opérateur étant défini, il suffira donc d’écrire :

matrice M;
// ...
double d = M(1,5);

pour avoir le cinquième élément de la première ligne. Cette notation est


plus agréable que M[1][5] qui de plus aurait nécessité une double
redéfinition d’opérateur.

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.

Il est parfaitement possible de la redéfinir. C’est spécialement utile pour


les classes utilisant des membres pointeurs. Par exemple, pour la classe
matrice :

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;


m1 = m2 = m1 + m2;

Toutefois, on peut aussi déclarer un résultat void si l’on souhaite


interdire de telles écritures.

Exercice 7.5 Dans les anciennes versions de C++, l’affectation


prédéfinie était une copie en bloc d’un objet dans
l’autre. Pouvez-vous donner un exemple où le
comportement de la nouvelle version diffère de
Voir solution l’ancienne ? Quel comportement est préférable selon
vous ?

Il est très important de différencier les deux écritures suivantes :

matrice m1 = m2;
m3 = m2;

Pour m3, le compilateur appelle l’opérateur d’affectation, mais pour m1,


c’est le constructeur de copie (qui existe toujours, voir chapitre 6) qui est
appelé. Cette séquence résulte donc (nonobstant le fait qu’on ne peut
appeler explicitement un constructeur) en :

matrice m1.matrice::matrice(m2); // constructeur


m3.operator=(m2); // affectation

De ce fait, lorsqu’un opérateur d’affectation est défini, on écrira


généralement le constructeur de copie ainsi :

inline matrice::matrice(matrice& m)
// constructeur de copie
{ *this = m; }

sauf si l’on souhaite des effets particuliers et différents (à vos risques et


périls).

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.

Les affectations-opérations comme += ne sont jamais prédéfinies pour


une classe, et jamais interprétées comme des raccourcis d’écriture pour x =
x + y. En conséquence, on définira de même :

matrice& operator+=(matrice& m)
{ return *this = *this + m; }

Dans certains cas, il sera peut-être rentable de faire le contraire :

matrice& operator +=(matrice& m)


{
// ... faire une addition sur place
}

matrice& operator+(matrice m1, matrice& m2)


{
return m1 += m2;
}

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).

Opérateurs new et delete

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.

Commençons par expliquer à quoi peut servir la redéfinition de tels


opérateurs. Imaginons un programme dans lequel on souhaite gérer des
structures dynamiques, du type liste chaînée, arbre, etc., contenant de
nombreux pointeurs sur des éléments nombreux mais de petite taille (par
exemple des entiers int). La gestion de la mémoire va alors poser problème,
car l’allocateur standard de mémoire malloc est peu adapté aux petits blocs :
en effet, il conserve plusieurs informations sur chaque bloc, comme sa taille,
etc., qui ne sont pas forcément utiles et surtout prennent beaucoup de place
par comparaison à la taille d’un entier. Il est facile de le vérifier, en utilisant
la fonction coreleft() qui indique la place mémoire disponible. En faisant
1 000 appels à malloc(2), celle-ci diminue de 8 000 (et non 2 000).

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

void* element::operator new(unsigned taille)


{
if (taille != sizeof(element))
return malloc(taille);
if (!table) { // table à allouer
unsigned max = 65535/(8*taille+1);
// taille max.
if (!tailletable) tailletable = 100;
if (tailletable > max) tailletable =
max;
table = (element*) calloc(tailletable,
8*taille+1);
if (!table) return 0;
libres = (char*)(table +
8*tailletable);
memset(libres, ~0, tailletable);
}
unsigned numero // chercher le premier bloc libre
char *tablefin = libres + tailletable;
for (char *p = libres;
(*p == 0) && (p < tablefin); p++);
if (p >= tablefin) return ;0 // table pleine
numero = 8*(p -libres);
unsigned char octet = *p, masque = 1;
while ( (octet & masque) == 0)
{ masque <<= 1; numero++; }
*p -= masque; // mettre bit à 0 : occupé
return table + numero;
}

void element::operator delete(void* pe, unsigned


taille)
{
if (!pe) return; // pe est nul
if (taille != sizeof(element)) // pas alloué dans
table
delete pe; return;
}
unsigned numero = (element*)pe -table;
unsigned char masque = 1 << (numero%8);
char *p = libres + numero/8;
*p |= masque; // mettre bit à 1 pour libérer
}

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

Noter la syntaxe de déclaration des deux fonctions opérateurs. Dans le


cas de new, il s’agit d’une fonction renvoyant un pointeur void* (adresse de
l’élément alloué), et ayant au moins un paramètre de type unsigned (ou
size_t ce qui est équivalent, ce dernier type étant défini dans <alloc.h>) qui

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.

L’opérateur delete est une fonction sans résultat, avec un paramètre


pointeur void* (l’élément à supprimer) et un second paramètre optionnel
indiquant lui aussi la taille.

La syntaxe de ces fonctions est particulière à plus d’un titre. D’abord,


bien qu’il s’agisse ici de fonctions membres, il n’est pas permis de faire
référence à des champs non statiques, ni à this. En effet, ces fonctions sont
appelées pour des objets en cours de création, avant le constructeur, ou en
cours de destruction, après le destructeur. Précisons cependant que les deux
fonctions connaissent l’adresse correspondant à this : dans le cas de delete,
c’est le premier argument, dans le cas de new, c’est la valeur à calculer. Par
conséquent, l’opérateur new par exemple peut placer des valeurs dans ce qui
deviendra l’objet, et notamment des zéros.

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.

Donnons quelques explications sur notre programme exemple, qui est


très typique de ce genre de manipulation. On a placé en membre statique de
element un pointeur table qui donne le début de la table où sont placés les

é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).

Chaque élément va occuper 17 bits dans la table : seize à un emplacement


pointé par le résultat de new, plus un dix-septième au même emplacement
relatif par rapport au début de la table de bits pointés par liste. La taille
totale occupée par l’ensemble des tables est donc (8*2+1)*tailletable,
puisqu’il y a 8*tailletable éléments. Lorsque new trouve une valeur de table
nulle au départ (table non encore allouée), il alloue un bloc de cette
dimension en mémoire par un appel à calloc (variante de malloc). La table
proprement dite occupe les 8*2*tailletable premiers octets, tandis que la
table de bits pointée par libres occupe les tailletable octets restants.

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.

L’opérateur delete se contente quant à lui de mettre à 1 le même bit à


l’aide d’un « ou » logique (il ne faut pas employer une addition, car le bit
peut être déjà à 1 : dans notre exemple, il n’y a aucune erreur si l’on
désalloue deux fois le même bloc).

Ce système est très utile et fait gagner beaucoup de place, puisque


chaque entier occupe à présent 17 bits de mémoire, contre 64 avec
l’allocateur standard. L’allocation de 1 000 éléments occupe au total 2 125
octets.

Noter que de tels opérateurs ne s’appliquent pas sur les tableaux. Ainsi,
si l’on écrit :

element *tab = new element[5];

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.

L’opérateur new peut avoir des arguments supplémentaires. Dans ce cas,


ceux-ci doivent être écrits entre parenthèses derrière le mot new lors de son
appel. Voici un exemple :

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.

Il n’est pas possible de passer un paramètre supplémentaire à delete


(Error : 'operator delete' must be declared with one argument, l’opérateur
delete doit être déclaré avec un argument, ce qui est d’ailleurs faux
puisqu’on peut en placer un second pour la taille).

Opérateurs new et delete globaux


Nous avons vu comment redéfinir les opérateurs d’allocation et de
désallocation pour une classe particulière. Il est possible de redéfinir
globalement ces opérateurs, de sorte qu’ils agissent sur toutes les classes,
même les prédéfinies. Voici un exemple (assez stupide) :

const MAXINT = 32767;


unsigned* bloc;

void* operator new(unsigned taille)


{
if (!bloc) {
bloc = new unsigned[MAXINT];
if (!bloc) return 0;
bloc[MAXINT] = 0;
}

216
unsigned occupes = bloc[MAXINT];
if (occupes + taille >= MAXINT) return 0;
bloc[occupes++] = taille;
bloc[MAXINT] += (3+taille)/2;
return bloc + occupes;
}

void operator delete(void *p)


{
if (!p) return;
unsigned position = (unsigned*)p -bloc -1;
unsigned taille = (3 + bloc[position])/2;
if (bloc[MAXINT]-position == taille)
bloc[MAXINT] = position;
}

Dans notre exemple, ces opérateurs utilisent un bloc fixe en mémoire, et


placent les objets dans ce bloc, les uns derrière les autres, précédés par leur
taille. Les objets ne sont détruits que lorsqu’ils sont les derniers insérés.

Opérateurs membres ou amis


Lorsqu’on définit un opérateur pour une classe, on ne sait pas forcément
très bien comment le déclarer. En particulier, faut-il en faire une fonction
membre, ou une fonction amie ? Et quels arguments doivent être passés en
référence ?

Il n’y a pas de réponse générale à ce problème, mais un certain nombre


de règles simples que l’on peut suivre, quoiqu’elles n’aient rien d’obligatoire.

Si l’opérateur demande parmi ses arguments une valeur modifiable


(lvalue), il est préférable d’en faire une méthode, afin d’éviter des écritures
étranges. C’est ce que nous avons fait pour l’opérateur d’affectation, dont le
premier argument est une valeur modifiable. En effet, si l’on écrivait :

fraction& operator=(fraction& f1, fraction f2)


// bizarre...
{
f1.num = f2.num;
f1.den = f2.den;

217
return f1;
}

alors l’écriture suivante :

fraction f(2/5);
4 = f;

serait parfaitement licite : elle équivaudrait à créer un objet temporaire de


valeur 4/1, y recopier 2/5, puis à le détruire : il n’y aurait donc aucun effet.
Le moins que l’on en puisse dire c’est que ce n’est guère naturel. Si l’on a par
contre défini un tel opérateur comme un membre (comme nous l’avons fait
pour la classe matrice précédemment), cette écriture devient interdite parce
que le compilateur ne fait pas de conversion de type pour les instances qui
appellent un membre.

Inversement, si l’on avait écrit l’opérateur d’addition ainsi :

class fraction {
// ......
fraction operator+(fraction f)
{
f.num = num*f.den + den*f.num;
f.den *= den;
return f;
}
}

on pourrait ajouter 1 à 2/5 mais pas 2/5 à 1.

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

exemple& operator+(exemple ex1, exemple& ex2)


{ return ex1+= ex2; } // addition

main()
{
exemple exmpl1, exmpl2;
exemple exmpl3 = exmpl1 + exmpl2;
// ....
}

alors le programme sera développé comme ceci :

main() // écriture développée

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
// ......
}

On voit que le destructeur pour l’argument provisoire ex1 est appelé


après que celui-ci ait été copié dans le résultat de l’addition exmpl3. De ce
fait l’opération se déroule correctement, ce qui n’aurait pas été le cas
autrement. L’ordre des appels peut être vérifié en regardant les imbrications
explicites dans les opérateurs de fonction. Ainsi l’addition équivaut à :

exmpl3.exemple::exemple(operator+(exmpl1,
exempl2));

ce qui explique pourquoi le destructeur est appelé en dernier.

De telles considérations sont complexes, et pour un gain parfois faible.


Dans le doute, n’utilisez pas de références.

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

Vous ne connaissez pas les membres privés (même si vous les


connaissiez vous ne pourriez pas les changer, ils sont définitivement hors de
portée), ni le code des méthodes dont vous connaissez simplement le nom et
l’usage : valeur donne les coordonnées des bords du rectangle, change les
modifie, trace dessine le rectangle à l’écran tandis que efface le supprime ;
le constructeur par défaut crée un rectangle vide, l’autre crée un rectangle
dont on fournit les coordonnées des bords.

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 :

class rectplein : rectangle {


int coul;
public :
rectplein();
rectplein(int gche, int haut,
int drte, int bas, int couleur = 0);
~rectplein();
void trace(void);
void efface();
int couleur() { return coul; } // donne couleur
int couleur(int nouvelle)
{ // donne la couleur et la change
int ancienne = coul;
coul = nouvelle;
trace();
return ancienne;
}
};

On dit que l’on a dérivé la classe rectplein de rectangle. Dans ce cas, la


classe dérivée hérite des caractéristiques de la classe de base, et en
particulier de ses membres. Dans certains cas, les membres de la classe de
base doivent être redéfinis (cas de trace et efface notamment), dans d’autres
les méthodes de la classe de base conviennent aussi (cas de change et valeur
dans notre exemple).

La classe dérivée peut utiliser les membres publics de la classe de base,


même si elle les redéfinit. Par exemple, la fonction trace de rectplein se
réduit à deux opérations : remplir le rectangle avec la couleur de
remplissage, puis dessiner le bord de ce rectangle. Si l’on suppose qu’on
dispose d’une fonction remplirrect réalisant le premier travail, il suffit
d’écrire :

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

On a appelé la méthode valeur héritée de rectangle (puisqu’on ne connaît


pas les coordonnées du rectangle qui sont des membres privés de la classe
de base) ainsi que la méthode trace de rectangle ; dans ce dernier cas, il faut
absolument écrire rectangle::trace() et non trace() qui ferait un appel
récursif infini.

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).

Les opérateurs sont hérités normalement, comme d’autres fonctions.


Cependant, aucune opération n’est réalisée sur les nouveaux membres, c’est
pourquoi il est généralement préférable de redéfinir ces opérateurs.

Enfin l’opérateur d’affectation est un cas particulier, car il n’est pas à


proprement parler hérité non plus. Lorsqu’il n’est pas redéfini explicitement
dans une classe dérivée, il recopie membre à membre les nouveaux
membres de cette classe dérivée, et appelle l’opérateur d’affectation de la
classe de base pour la copie de la partie héritée. Lorsqu’on le redéfinit pour
la classe dérivée, l’opérateur pour la classe de base n’est pas appelé, il faut
donc le faire explicitement, comme ceci :

rectplein& rectplein::operator=(rectplein rp)


{
*(rectangle*)this = rp;
coul = rp.coul;
}

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

rectplein::rectplein(int gche, int haut,


int drte, int bas, int couleur)
: rectangle(gche, haut, drte, bas)
// explicite
{
coul = couleur;
trace();
}

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.

Le destructeur d’une classe dérivée appelle le destructeur de la classe de


base après l’exécution de ses tâches explicites. Ainsi on peut écrire :

rectplein::~rectplein()
{
efface();
// appel implicite de rectangle::~rectangle();
}

Comme il n’y a qu’un destructeur par classe, il n’y a pas à choisir.

On retiendra que les constructeurs sont appelés dans l’ordre ascendant


des classes (de base vers dérivées), tandis que les destructeurs le sont dans
l’ordre inverse. Il s’agit bien là d’un ordre conforme à la logique. En effet, le
constructeur d’une classe dérivée peut avoir besoin des membres de la
classe de base (c’est le cas dans notre exemple, puisque la fonction trace
utilise les coordonnées du rectangle) : il en résulte que la partie de base de
l’objet doit être initialisée avant qu’on ne commence la construction
explicite. Inversement, le destructeur aussi peut avoir besoin des membres
hérités : il ne faut donc pas les détruire en premier, mais seulement après.

Membres privés, publics, protégés


Nous avons vu au chapitre 6 que certains membres d’une classe
pouvaient être publics (les méthodes en général), mais que par défaut ils
étaient privés. Pour les structures c’est le contraire.

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).

Une classe peut dériver de manière publique d’une classe de base, ou de


manière privée. Par défaut, une classe dérive de manière privée, et une
structure de manière publique. Voici comment les deux types d’héritages
influent sur la nature des membres hérités :

o membres publics : ils restent publics dans une classe dérivée de


manière publique, mais deviennent privés dans une classe dérivée de
manière privée.
o membres protégés : ils restent protégés dans une classe dérivée de
manière publique, mais deviennent privés dans une classe dérivée de
manière privée.
o membres privés : ils ne sont jamais accessibles dans une classe dérivée.

Pour dériver une classe de manière publique, comme ce n’est pas la


valeur par défaut, il faut placer le mot public devant le nom de la classe de
base. Voici quelques exemples :

class A {
int a1;
protected :
int a2;
public :
int a3;
};

class B : A { // héritage privé


int b1;
protected :
int b2;
public :
int b3;
};

class C : public A { // héritage public

226
int c1;
protected :
int c2;
public :
int c3;
};

La classe A possède trois membres, un privé a1, un protégé a2, un public


a3 ; de l’extérieur seul a3 est accessible. La classe B possède six membres :

un indisponible directement a1, trois privés a2, a3 et b1, un protégé b2 et un


public b3 ; de l’extérieur, seul b3 est accessible. Enfin la classe C possède
aussi six membres : un indisponible a1, un privé c1, deux protégés a2 et c2 et
deux publics a3 et c3 ; de l’extérieur, seuls a3 et c3 sont accessibles.

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 :

class D : private A { // héritage privé


int d1;
protected :
int d2;
A::a2;
public :
int d3;
};

class E : public A { // héritage public


int e1;
A::a2;
protected :
int e2;
A::a3;
public :
int e3;
};

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).

On ne peut pas diminuer la protection d’un membre par héritage. Si l’on


essaie dans une classe dérivée de déclarer public un membre protégé de la
classe de base, ou si l’on essaie de redéclarer un membre privé de la classe
de base, on obtient une erreur (Error : Access declarations cannot grant or
reduce access, les déclarations d’accès ne peuvent pas octroyer ou réduire
le niveau d’accès).

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 :

class rectplein : public rectangle {


// ...
};

Dans le cas contraire, il ne serait pas possible d’appeler les méthodes


héritées valeur et change à partir d’une variable de type rectplein.

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(int gche, int haut,


int drte, int bas)
{
efface();
if ( (gche >= drte) || (haut >= bas) )
{ g = d; return; } // rectangle vide
g = gche; d = drte;
h = haut; b = bas;
trace();
}

A priori, il n’y a aucune raison de changer cette méthode pour notre


classe rectplein. Pourtant, si l’on fait un essai, un appel de change avec une
instance de rectplein ne donnera pas le bon résultat. Pourquoi ?

Rappelons-nous que la classe rectangle a été compilée avant la classe


rectplein. Dès lors, lorsque le compilateur, agissant sur le code source de la
méthode change ci-dessus, rencontre un appel à efface et un autre à trace, il
cherche les méthodes de ce nom ; il n’en connaît alors que deux, celles de la
classe rectangle. En mettant les points sur les i, le compilateur « voit » donc
ceci :

void rectangle::change(//...)
{
rectangle::efface();
// ...
rectangle::trace();
}

On voit dès lors pourquoi cette méthode ne fonctionnera pas


correctement avec rectplein : le rectangle ne sera ni correctement effacé, ni
correctement retracé, parce que l’on a modifié les méthodes
correspondantes dans notre nouvelle classe.

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.

Ce problème est classique en programmation orientée objet, et résulte du


principe même de compilation. Les langages de POO interprétés comme
SmallTalk n’ont pas de difficultés de cet ordre.

La solution réside dans la déclaration de méthodes virtuelles. Pour les


déclarer telles, il suffit de placer le mot réservé virtual devant le nom de la
méthode. Si le programmeur qui a conçu la classe rectangle était prévoyant,
il a compris que certaines des fonctions membres de la classe auraient à être
modifiées dans des classes descendantes :

class rectangle {
// membres privés
public :
rectangle();
rectangle(int gche, int haut, int drte, int
bas);

virtual ~rectangle();
virtual void trace();
virtual void efface();

void valeur(int& gche, int& haut,


int& drte, int& bas);
void change(int gche, int haut,
int drte, int bas);
};

Lorsque le compilateur sait qu’une méthode est virtuelle, il ne place pas


un appel direct à cette méthode, mais recherche la dernière méthode
redéfinie dans la classe de this ayant le même nom et les mêmes types
d’arguments, et appelle celle-ci. Il en résulte que la méthode change aura
cette fois-ci le bon comportement : si l’on appelle r.change où r est de type
rectangle, la méthode appellera les fonctions rectangle::efface et

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.

On prendra garde que les méthodes ne sont pas seulement caractérisées


par leur nom, mais aussi par la liste de leurs arguments. En conséquence, si
l’on définit par exemple une méthode rectplein::trace(int couleur), celle-ci
ne sera pas virtuelle (sauf déclaration explicite) car il s’agit d’une méthode
différente de rectplein::trace() (en vertu des règles de recouvrement de
fonctions vues au chapitre 5) ; de toute façon, ce ne sera pas elle qui sera
appelée par rectangle::change, ne serait-ce que parce que les arguments ne
correspondent pas. Précisons qu’en parlant de la liste des arguments, nous
parlons aussi des arguments par défaut : en définissant une unique méthode
rectplein::trace(int couleur = -1) par exemple, on s’expose à des ennuis

car la méthode change appellera alors rectangle::trace(), seule méthode de


ce nom ayant zéro argument dans la classe rectplein.

Pour éviter tout ennui, on redéfinira une méthode virtuelle avec


exactement la même liste d’arguments, quitte à fournir aussi des
homonymes ayant des arguments supplémentaires (ou en moins). On
évitera aussi les arguments par défaut dans les méthodes virtuelles, pour la
même raison.

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.

Les méthodes en ligne peuvent être virtuelles, mais le compilateur ne les


placera pas en ligne dans ce cas.

Les destructeurs cependant peuvent être déclarés virtuels, et c’est même


préférable en général. En effet, nous verrons plus loin qu’un pointeur pr sur
une classe de base (rectangle*) peut en fait pointer sur un objet d’une classe
dérivée (rectplein*) ; dès lors, si l’on écrit delete pr, le mauvais destructeur
sera appelé, à moins que l’on ait pris la précaution de le déclarer virtuel.
Nous recommandons de toujours le faire si l’on compte dériver des classes à
partir de la classe courante, même si le destructeur ne fait rien.

Polymorphisme et classes abstraites


Nous avons vu au chapitre 6 que la protection des données particulière à
la programmation objets permet une certaine forme de polymorphisme :
une classe peut être implantée de différentes façons.

L’héritage permet de perfectionner ce processus, en faisant cohabiter


deux implantations différentes (ou plus) d’une même classe, sous une forme
homogène. Pour cela, il existe des règles de compatibilité particulières à
l’héritage.

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.

Le contraire n’est naturellement pas vrai : la classe rectangle n’a pas de


champ couleur, il lui est impossible de se comporter de la même façon que
rectplein. L’affectation inverse rp = r est donc impossible, sauf si l’on

définit un opérateur d’affectation adéquat, et un constructeur pour les


initialisations. On pourrait le faire ainsi par exemple :

class rectplein : public rectangle {


// ...
public :
rectplein(rectangle& r, couleur = 0)
: rectangle(r) {
coul = couleur; trace();
}
// ...
rectplein& operator=(rectangle& r) {
efface;
*(rectangle*)this = r;
trace();
}
}

Noter toutefois que l’affectation explicite par changement de type dans


operator= est un appel à l’affectation rectangle::operator=, qui provoque a

priori aussi un effacement et un appel à rectangle::trace() indésirables


(quoique sans gravité ici). Il est donc préférable d’éviter les affectations
ayant de tels effets de bord, dans la mesure du possible.

Compatibilité des pointeurs


Les pointeurs d’une classe de base sont compatibles avec ceux des classes
dérivées :

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.

Contrairement à l’affectation d’une instance de la classe dérivée vers la


classe de base, qui fait perdre de l’information (les membres spécifiques à la
classe dérivée sont perdus), l’affectation identique avec les pointeurs ne fait
rien perdre : les membres dérivés sont simplement momentanément
inaccessibles. Les méthodes virtuelles de la classe de base sont cependant
appelées correctement. Par exemple, si l’on a défini un destructeur virtuel et
deux méthodes virtuelles trace et efface, les appels suivants seront corrects :

rectangle r, *pr = new rectangle(r);


pr->trace(); // appel de rectangle::trace
delete pr; // appel de rectangle::~rectangle
pr = new rectplein();
pr->trace(); // appel de rectplein::trace
delete pr; // appel de rectplein::~rectplein

Cela explique pourquoi l’on parle de polymorphisme. On retiendra


l’importance qu’il y a à déclarer des destructeurs virtuels, même s’ils ne font
rien : il n’en sera pas forcément de même dans les classes dérivées, et le
compilateur, comme l’exemple ci-dessus le montre clairement, ne peut pas
déterminer correctement sur quel genre d’objet pointe pr, et donc quel
destructeur appeler s’il n’est pas virtuel. Noter que pour la même raison,
l’appel de sizeof(*pr) donnera toujours la taille de la classe de base
rectangle, même si pr pointe sur un objet rectplein. On se méfiera de cet
opérateur qui ne peut de surcroît pas être redéfini.

Polymorphisme par héritage


Nous allons réutiliser notre exemple du chapitre 6, où nous avions donné
deux implantations différentes d’un type liste reproduisant
(extérieurement) une liste chaînée. La représentation interne de ces classes
n’était pas forcément une vraie liste chaînée.

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();

virtual void avance(int combien = 1);


void recule(int combien = 1)
{ avance(-combien); }

virtual element& valeur(void)


{ if (courant) return courant->contenu();
}

unsigned nombre_elt(void) { return nombre; }


void affiche(unsigned combien = 65535);

virtual int insere(const element&);


virtual void supprime(int n = 1);
};

class listetab : public liste {


element *tab, *courant;

public :

235
listetab() { courant = tab = 0; }
listetab(int n, const element*); // consructeur
avec table
~listetab();

void avance(int combien = 1);


element& valeur(void)
{ if (courant) return *courant; }

int insere(const element&);


void supprime(int n = 1);
};

On observe d’abord que l’on gagne du temps et du code, puisque


certaines méthodes n’ont pas besoin d’être redéfinies ; il suffit généralement
de bien choisir les méthodes virtuelles.

Exercice 8.1 On a supposé que la méthode d’affichage utilisait la


méthode valeur, au lieu de faire référence directement
aux membres privés comme c’est le cas dans
Voir solution
l’implantation du chapitre 6. Écrire la méthode
correspondante.

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

On avance à la fin de reste positions pour remettre


le début de la liste sur sa valeur de départ.

Exercice 8.2 Quelles sont les méthodes virtuelles et celles qui ne


le sont pas dans les classes liste et listetab ? Que
pensez-vous de ce choix ?
Voir solution

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

évident, ne serait-ce que parce que la classe dérivée ne


les implante pas de la même façon ; ce sont
manifestement des opérations qui dépendent tout à
fait du type de liste implémentée. Les méthodes recule
et nombre_elt, vu leur extrême simplicité, n’ont pas
besoin d’être virtuelles. Quant à la méthode affiche, il
n’y a pas de raison en principe de la redéfinir
ultérieurement (ce n’est pas le cas en tous cas dans
listetab) ; ce choix est cependant plus discutable, il

dépend de ce que l’on estime acceptable comme type


de donnée dérivée de liste. Si seules les listes sont
acceptées, il n’y a aucun problème. Si des données plus
complexes (matrices par exemple) sont acceptables, il
faut déclarer la méthode comme virtuelle, car une
matrice n’est pas affichée de la même façon qu’une
liste.

Cela fait, rien n’empêche plus de définir un tableau de pointeurs :

element table[5] = { 1, 3, 5, 7, 11 };
liste *listes[3] = { new liste(5, table),
new listetab(2, table +3),
new listetab(3, table) };

for (int i = 0; i < 3; i++)


if (listes[i]) listes[i]->affiche();
// ...
for (i = 0; i < 3; i++) delete listes[i];

Bien remarquer que ce sont les bonnes fonctions d’affichage et de


destruction qui sont appelées dans cet exemple.

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 ?

Solution de l’exercice 8.3


On affiche :

1 3 5 7 11
7 11
1 3 5

Il y a une liste qui occupe 4 octets avec des


pointeurs courts (petits modèles de mémoire), et 6
avec des longs, plus deux listetab qui occupent
chacune 8 octets avec des pointeurs courts, et 14 avec
des longs. La première liste utilise 5 noeuds de 4+S
octets chacun avec des pointeurs courts, ou 8+S avec
des longs. Les deux autres listes utilisent des tableaux
de 2*S et 3*S éléments respectivement. La place
mémoire totale occupée dans le tas est donc de 4 + 2*8
+ 5*(4 + S) + 2*S + 3*S, soit 40 + 10*S avec des
pointeurs courts, et de 74 + 10*S avec des longs. Ce
décompte ne tient pas compte toutefois du fait que les
blocs alloués dans le tas occupent en fait plus de place
que la taille allouée.

L’inconvénient de cette méthode simple, c’est que le champ noeud* est


inutilisé dans listetab, ce qui gaspille de la mémoire (en faible quantité
cependant). Évidemment, on aurait pu le réutiliser pour tenir le rôle de tab
par exemple, mais cela aurait exigé de désagréables changements de types à
tout moment. En outre, il aurait fallu le déclarer protégé et non privé. Et il
est clair que dans tous les exemples la situation ne sera pas si simple.

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).

Lorsqu’une classe est abstraite, elle ne peut être utilisée directement ; en


particulier, on ne peut pas déclarer d’objets de cette classe, ni d’arguments
ou de résultats de fonction. Si vous tentez de le faire, vous obtiendrez
Error : Cannot create a variable for abstract class 'xxx', on ne peut pas

créer une variable de la classe abstraite 'xxx'.

On peut par contre utiliser des références, des pointeurs et dériver de


nouvelles classes, et c’est en fait l’usage de ces classes abstraites. Voici
comment déclarer une classe abstraite liste, puis les deux classes de listes
concrètes identiques à celles du chapitre 6 :

class liste { // classe abstraite


protected :
int nombre;

public :
virtual ~liste() { nombre = 0; };

virtual void avance(int combien = 1) = 0;


// pure
void recule(int combien = 1)
{ avance(-combien); }

virtual element& valeur(void) = 0; // pure


unsigned nombre_elt(void) { return nombre; }
void affiche(unsigned combien = 65535);

240
virtual int insere(const element&) = 0; //
pure
virtual void supprime(int n = 1) = 0; //
pure
};

class listech : public liste { // liste chaînée


noeud* courant;

public :
listech() { nombre = 0; courant = 0; }
listech(int n, const element*); // c. avec table
~listech();

void avance(int combien = 1);


element& valeur(void)
{ if (courant) return courant->contenu();
}

int insere(const element&);


void supprime(int n = 1);
};

class listetab : public liste {


element *tab, *courant;

public :
listetab() { courant = tab = 0; }
listetab(int n, const element*); // c. avec table
~listetab();

void avance(int combien = 1);


element& valeur(void)
{ if (courant) return *courant; }

int insere(const element&);


void supprime(int n = 1);
};

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.

Les classes abstraites n’ont généralement pas de constructeur, sauf si


l’initialisation des membres est un peu compliquée (ici il suffit de mettre la
valeur adéquate dans le champ nombre, et les constructeurs de listch et
listtab le font). Par contre, il est généralement souhaitable d’y placer un

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.

Exercice 8.4 Comment créer un opérateur d’affectation pour les


listes ? Et les constructeurs de copie ?

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 :

class liste { // classe abstraite


// ...

virtual liste&
operator=(liste&) = 0;
};

class listech : public liste {


// ...
listech(liste& ls) { nombre =
0; *this = ls; }
listech(listech& lc) { nombre
= 0; *this = lc; }
liste& operator=(liste&);
};

class listetab : public liste {


/ /...
listetab(liste& ls) { nombre =
0; *this = ls; }
listetab(listetab& lt) {
nombre = 0; *this = lt;}
liste& operator=(liste&);
};

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.

Cela n’est pas possible directement, car il peut exister plusieurs


pointeurs sur une même instance de classe dans un programme. Si celle-ci
change, elle changera probablement de position en mémoire, et les
pointeurs vont se retrouver incorrects ; une telle chose n’est pas prévisible
directement dans les méthodes des classes.

Ce polymorphisme automatique peut cependant être implanté.

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 {
// ...
};

La classe C hérite de manière publique de A et de manière privée de B (il


faut préciser à chaque classe le type de dérivation, sinon c’est le type par
défaut qui s’applique). Elle a trois sortes de membres : les siens propres ;
ceux hérités de A ; ceux hérités de B. Les règles d’héritage sont les mêmes
que dans l’héritage simple. Le constructeur de C appelle les constructeurs
de A et B, implicitement ou non :

C::C() : 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.

Par contre, lorsqu’il s’agit de deux champs, ou de deux méthodes ayant


les mêmes arguments, le compilateur se trouve face à une ambiguïté
insoluble. Pour la résoudre, il faut utiliser le nom d’une des classes de base
et l’opérateur de résolution de portée. Par exemple, si les classes A et B ont
toutes deux un champ x, il faudra écrire :

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.

Il est possible d’éviter ce comportement lorsqu’il n’est pas souhaitable. Il


faut pour cela que les classes A et B aient été dérivées de manière virtuelle de
Z:

class Z { ... };

class A : virtual public Z { ... };

class B : virtual Z { ... };

class C : public A, B { ... };

La classe C dans ce cas ne contient qu’une instance de Z. Les classes A et B


sont identiques à ce qu’elles étaient auparavant, sauf que le compilateur sait
que l’instance de Z peut être à un emplacement inhabituel (c’est le cas dans
C) ; les deux classes doivent être dérivées virtuellement de 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.

Prenons d’abord le cas simple suivant :

class A {
int a1;
public :
// ... méthodes
};

class B : A {
int b1;
public :
// ... méthodes
};

La configuration en mémoire d’une instance de B est alors la suivante


(chaque petit carré représente un octet) :

La partie grisclair représente ce qui est hérité de la classe A, tandis que la


partie blanche indique ce qui est défini directement dans B.

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

même façon sur une instance de A ou de B, et n’utilisent dans ce dernier cas


que la partie gris clair.

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

Dans ce cas, les instances de B sont représentées différemment en


mémoire :

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.

Ce processus s’appelle lien dynamique ou lien tardif (en anglais late


binding). On notera que les méthodes non virtuelles en sont exclues (b4).

Nous allons voir comment cela fonctionne plus exactement en imaginant


une nouvelle classe :

class C : B {
int c1;
public :
void b2();
void b3();
virtual void c2();

248
};

Cette classe recouvre les deux méthodes virtuelles de B. Une instance de C


a l’allure suivante :

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.

Compliquons encore le jeu avec un héritage multiple :

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 :

On remarque que, comme la classe D n’a pas recouvert la méthode


virtuelle b2, c’est l’adresse de B::b2 qui figure en première place dans la
table.

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 :

Dans ce cas, il y a deux pointeurs cachés, dans chacune des deux


instances de base C et D contenues dans E. Les tables sur lesquelles ils
pointent sont semblables à ce qu’elles étaient dans C et D, sauf que les
méthodes recouvertes de E y remplacent celles de C ou D. On notera que la
méthode E::b3 figure deux fois dans la table de E, parce qu’elle recouvre à
la fois C::b3 et D::b3.

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.

Lorsqu’on utilise un héritage virtuel, la situation est beaucoup plus


complexe. Supposons que les classes C et D aient été déclarées en héritage
virtuel de B :

class C : virtual B { ... }

class D : virtual B { ... }

Dans ce cas, l’allure d’une instance de C en mémoire est bien différente :

Trois pointeurs sont ajoutés ; le premier, en tête, indique l’emplacement


dans l’instance du début de la partie héritée de B (cet emplacement variera
dans les classes dérivées de C). À la fin de l’objet, un pointeur désigne une
table formellement identique à celle de B, mais avec les adresses des
méthodes virtuelles recouvertes. Au milieu, un troisième pointeur donne les
adresses des méthodes virtuelles de C (dans l’ordre de déclaration) ; les
méthodes b2 et b3 sont ici remplacées par b2* et b3*, qui sont identiques à ceci
près qu’un petit bout de code avant fait remplacer this par le pointeur de
tête, afin que les méthodes aient la bonne adresse.

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 :

La partie initiale correspond à C ; elle comprend le pointeur de tête sur la


base héritée de B, le champ c1 et un pointeur sur les trois méthodes
virtuelles de C, toutes trois recouvertes dans E ; notons que la partie B de C
n’existe plus (pas de duplication). La suite correspond à D : on trouve le
pointeur de tête sur la partie B, le champ d1 et un pointeur sur les deux
méthodes virtuelles de D, dont une recouverte dans E (b3). Vient ensuite le
nouveau champ e1 ; puis enfin la partie héritée de B, en un seul exemplaire,
avec à la fin un pointeur sur les méthodes virtuelles de B, toutes deux
modifiées dans E (une directement par E, l’autre indirectement par C). Tout
cela est compliqué par le fait que les méthodes doivent être augmentées de
petits bouts de code destinés à récupérer la bonne adresse de this. L’adresse
« this bis » est celle qui est utilisée pour les méthodes de D.

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.

Les programmeurs C noteront que nous ne présentons pas ici les


entrées-sorties standard de C. Celles-ci sont en effet bien moins pratiques et
de ce fait, pratiquement obsolètes en C++.

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).

Le schéma ci-après montre la répartition des classes ; les flèches grises


indiquent une dérivation. On peut distinguer les catégories suivantes :

o Les tampons d’entrées-sorties, divisés en trois classes streambuf,


strstreambuf et filebuf.
o Les flots d’entrées-sorties, que l’on peut répartir en quatre groupes :

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.

Flots généraux : classe ios


La classe ios est la base des flots. Il ne s’agit pas d’une classe abstraite,
mais peu s’en faut. Elle ne permet qu’un petit nombre d’opérations, et n’a
pas en principe à être utilisée telle quelle. Cependant elle fournit un certain
nombre de constantes énumérées pour la gestion des flots, avec les petites
fonctions membres en ligne afférentes.

Une instance de ios est normalement toujours rattachée à un tampon


streambuf. La fonction membre streambuf* rdbuf() renvoie un pointeur sur ce
tampon.

Une instance de ios occupe 34 octets de mémoire.

État des flots


Une première énumération dans ios contient une liste de masques
unitaires (c’est-à-dire d’entiers dont un seul bit vaut 1) ; utilisés sur un
champ particulier, ils indiquent l’état du flot. Ce champ d’état n’est pas
accessible directement mais peut être lu par la fonction membre int
rdstate(void). Voici les bits indicateurs qui peuvent être positionnés :

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.

La fonction membre int bad(void) renvoie 1 si l’un des deux bits


ios::badbit ou ios::hardfail est à 1, 0 sinon. La fonction membre int
fail(void) renvoie 1 si l’un des trois bits ios::badbit ou ios::failbit ou

ios::hardfail est à 1, et 0 sinon.

La fonction membre void clear(int i = 0) permet de modifier l’état du


flot. Par exemple, l’écriture fl.clear(ios::failbit) positionne le bit
ios::failbit du flot fl, indiquant une erreur grave.

Signalons les deux opérateurs suivants :

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

plus agréables que l’appel à fl.good.

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.

Par exemple l’écriture suivante :

fstream fl("EXEMP.CPP", ios::in|ios::out|ios::app);

ouvre le fichier EXEMP.CPP en lecture et écriture, avec ajout des nouvelles


données à la fin (voir aussi le paragraphe sur les flots sur disques).

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.

Le champ de format peut être lu par la méthode long flags(void) et


modifié par long flags(long). Deux autres méthodes peuvent être utilisées
aussi dans ce but. La méthode long unsetf(long) met à zéro les bits du
champ de format qui valent 1 dans son argument. La méthode long
setf(long) a l’effet contraire. Mais on utilisera surtout long setf(long,
long) ; le premier argument indique la nouvelle valeur des bits à modifier ;

le deuxième argument indique les bits à modifier effectivement (ceux à 1 ;


ceux à 0 ne sont pas changés), et peut être pris dans la liste de constantes
suivante :

ios::basefield égal à ios::dec | ios::oct | ios::hex

ios::adjustfield égal à ios::left|ios::right|ios::internal

ios::floatfield égal à ios::scientific | ios::fixed

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.

Trois autres champs de formatage peuvent être utilisés. Le champ de


largeur indique sur combien de caractères de large la donnée doit être

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).

Le champ de remplissage indique quel caractère est utilisé pour le


remplissage lorsqu’il y en a un. Par défaut, c’est l’espace blanc qui est utilisé.
Ce champ peut être lu par char fill(void) et modifié par char fill(char).

Le champ de précision indique combien de décimales sont écrites au


maximum dans les nombres à virgule flottante. Par défaut, le plus grand
nombre de décimales significatives est écrit. Ce champ peut être lu par int
precision(void) et modifié par int precision(int).

L’ensemble de ces champs de formats donne un nombre de possibilités


très impressionnant, et qu’il est exclu de passer entièrement en revue. Voici
simplement quelques exemples :

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).

On trouve encore les quatre méthodes suivantes qui permettent, si vous


créez votre propre système d’entrées-sorties, d’ajouter des champs
personnels à la classe :

static long bitalloc();


static int xalloc();
long & iword(int);
void* & pword(int);

La première indique le premier bit libre dans le champ de format sous la


forme d’un masque. La seconde crée un champ utilisateur de type int, et
renvoie un numéro ; ce numéro doit être réutilisé dans la troisième et la
quatrième pour obtenir ce champ utilisateur sous sa forme int ou comme
un pointeur.

Enfin on peut associer un flot de sortie à une instance de ios, à l’aide de


la méthode ostream* tie(ostream*) ; le flot de sortie courant peut être obtenu
par ostream* tie(void). Cela permet par exemple de définir un canal
d’erreur. Ce champ n’est pas utilisé par les implantations standard de la
classe ios.

Précisons enfin que s’il existe un constructeur public


ios::ios(streambuf&), qui associe le tampon au flot, le constructeur par
défaut ios::ios() et l’opérateur d’affectation ios::operator=(ios&) sont
déclarés privés et de surcroît non définis, ce qui interdit la recopie d’une
instance de ios dans une autre ; une telle copie serait en effet probablement
erronée, voire catastrophique.

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 :

class ostream : public virtual ios { ...

On y trouve un constructeur ostream::ostream(streambuf*) (qui associe le


tampon au flot) et un destructeur virtuel, comme dans ios. Comme dans ios
encore, l’opérateur d’affectation et le constructeur de copie n’y sont pas
utilisables, car ils ne sont pas redéfinis (et comme ils ne sont pas accessibles
dans ios, on ne peut utiliser ceux par défaut).

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).

Une instance de ostream occupe 38 octets en mémoire.

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.

On peut connaître la valeur de cet indicateur de position par la fonction


membre streampos tellp(void) ; le type streampos est identique à long.

Il y a deux moyens de modifier cet indicateur, autrement qu’en faisant


des écritures. Le premier consiste à appeler la méthode ostream&
seekp(streampos) avec la nouvelle valeur souhaitée. Le second consiste à
donner un déplacement par rapport à une position de référence (type
streamoff, qui est aussi égal à long). On utilise pour cela ostream&
seekp(streamoff, seek_dir). Le type seek_dir est l’énumération de ios décrite

précédemment et contenant trois éléments :

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.

Selon les cas, le déplacement est ajouté à 0, à la position courante, ou au


nombre de caractères du fichier pour obtenir la nouvelle position. Par
exemple :

ofstream fl;
// ...
fl.seekp(-10, ios::cur);

fait reculer l’indicateur de position de dix caractères.

Écriture non formatée


Les flots de sortie permettent une écriture non formatée, c’est-à-dire
sans examen des caractères, bien adaptée aux fichiers binaires par exemple.

La fonction membre ostream& put(char) écrit un caractère dans le flot de


sortie. La fonction membre ostream& write(const char*, int n) (qui en fait
existe en deux versions pour caractères signed et unsigned) écrit n caractères
dans le flot. Dans les deux cas, les champs de formats ne sont pas utilisés, et
le tampon n’est pas vidé, sauf s’il est plein.

É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 :

cout << i << " ce jour " << d << '\n';

262
ce qui équivaut à :

cout.operator<<(i).operator<<("ce jour").
operator<<(d).operator<<('\n');

et donc à l’appel de quatre fonctions différentes.

Les paramètres de formatage sont utilisés ici pleinement (voir le


paragraphe correspondant sur la classe ios).

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.

Flots d’entrée : classe istream


La classe istream, utilisée pour les flots d’entrée, dérive de manière
virtuelle et publique de ios ; comme ostream, elle ne possède qu’un
constructeur iostream:: iostream(streambuf*).

Une instance de istream occupe 40 octets en mémoire.

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).

L’indicateur de position peut être modifié par istream& seekg(streampos)


et par istream& seekg(streamoff, seek_dir), de la même façon que pour les
flots de sortie.

Lecture non formatée


Une lecture non formatée est possible dans un flot d’entrée, via les
méthodes istream& get(char&) et sa variante int get(void) pour un caractère
unique. Pour une série de caractères, on utilisera l’une des méthodes
suivantes :

istream& get(char*, int max, char = '\n');


istream& read(char*, int max);
istream& getline(char*, int max, char = '\n');

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.

Il existe aussi une fonction membre istream& get(streambuf&, char =


'\n') qui prend ses données dans un autre tampon.

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).

Lorsqu’un caractère a été retiré du tampon par erreur, il est possible de


l’y replacer en utilisant istream& putback(char).

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&

operator>>(char*) pour les chaînes de caractères.

L’effet obtenu est le suivant, en fonction du type de l’opérande :

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

modifié en spécifiant une entrée décimale, octale ou hexadécimale


obligatoire à l’aide du champ de format (voir exemple ci-après). Les
suffixes entiers U, L et UL ne sont pas acceptés. Lorsque le nombre de
chiffres entrés est important, le résultat est obtenu modulo 65536
(pour les entiers courts ; pour les longs, modulo le carré de 65536).
o Pour les nombres à virgule flottante, les espaces initiaux sont sautés,
et le nombre est lu conformément aux règles d’écriture des nombres à
virgule flottante jusqu’à ce qu’un caractère soit incorrect. Lorsque la
valeur rentrée est supérieure à la valeur maximale possible, c’est cette
dernière qui est placée dans la variable.
o Pour les caractères, les espaces initiaux sont sautés (pour éviter cela,
utiliser get) et un caractère unique est lu (attention avec cin il faut
tout de même taper un retour chariot après pour finir l’entrée).
o Pour les chaînes de caractères char*, les espaces initiaux sont sautés,
et les caractères suivants placés dans la chaîne jusqu’à la rencontre
d’un caractère d’espacement ; un zéro final est ajouté. Pour éviter un

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 >> i >> j;


// si vous écrivez : 145 789634
// alors i devient 145 et j 3202 == 789634 % 65536

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

cin.width(39); // n’oubliez pas le zéro final


cin >> chaine;
// si vous écrivez : "Bonjour !"
// alors la chaine devient "\"Bonjour" (8 caractères
// plus zéro final), car la lecture s’arrête au
// premier espace rencontré

La suppression des espaces initiaux, notamment pour les lectures de


chaînes de caractères, peut être invalidée en mettant à 0 le bit ios::skipws
du champ de format.

266
On peut évidemment aussi redéfinir l’opérateur d’entrée pour les
nouvelles classes.

Exercice 9.1 Définir un opérateur d’entrée pour la classe


fraction du chapitre 7.

Voir solution

Solution de l’exercice 9.1


On suppose que la fraction est entrée sous la forme num/den, où num et den
sont deux entiers :

istream& operator>>(istream& is, fraction& f)


{
int i, j;
is >> i;
if (!is) return is;
char c; is >> c; // a-t-on une barre (/) ?
if (c != '/') { // non
f.num = i; f.den = 1; // f vaut i/1
return is.putback(c); // remettre en place
}
is >> j;
if ( (is) && (j) ) // si tout est ok
{ f.num = i; f.den = j; }
return is;
}

Cette fonction doit être déclarée amie de la classe fraction.

Flots mixtes : classe iostream


La classe iostream est utilisée lorsqu’on souhaite faire à la fois des
lectures et des écritures. Elle hérite tout simplement de ostream et istream, et
sa définition est très simple :

class iostream : public istream, public ostream {


public:
iostream(streambuf*);

267
virtual ~iostream();
protected:
iostream();
};

Le constructeur par défaut est protégé, comme dans ostream et istream, de


sorte qu’il n’est pas possible de déclarer une instance sans l’initialiser avec
un tampon streambuf*, sauf pour les classes descendantes (voir
iostream_withassign ci-après).

Les deux opérateurs >> et << restent bien entendu disponibles, ainsi que
tous les autres membres.

Une instance de cette classe occupe 44 octets de mémoire.

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.

Les classes ostream_withassign, istream_withassign et iostream_withassign


se distinguent de leur homologues en ce que l’opérateur d’affectation y est
redéfini. On peut ainsi affecter un ostream à une instance de
ostream_withassign, etc.

Voici par exemple comment est définie ostream_withassign :

class ostream_withassign : public ostream


{
public:
ostream_withassign();
virtual ~ostream_withassign();
ostream_withassign& operator= (ostream&);
ostream_withassign& operator= (streambuf*);
};

Le constructeur par défaut ne fait rien ; l’opérateur d’affectation avec un


argument streambuf* est sensiblement identique au constructeur équivalent
de ostream.

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.

Il est parfaitement possible de modifier ces flots. Par exemple, pour


envoyer les messages d’erreur vers un fichier error.msg, il suffit d’écrire :

ofstream ferr("ERROR.MSG");
if (ferr) // si on a pu ouvrir le fichier...
cerr = ferr;

Les classes _withassign occupent la même place mémoire que leurs


homologues de base.

Flots sur disques et fichiers


Les lectures et écritures sur disques sont évidemment un aspect essentiel
des entrées-sorties. On dispose pour cela de quatre classes fstreambase,
ifstream, ofstream et fstream équivalant à ios, istream, ostream et iostream

respectivement. Ces quatre classes sont définies dans le fichier <fstream.h>,


qu’il faut donc inclure dans votre programme si vous souhaitez les utiliser
(cela inclut automatiquement <iostream.h>).

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 classe ofstream sert pour les fichiers de sortie. Elle comprend


essentiellement les méthodes suivantes :

class ofstream : public fstreambase, public ostream


{
public:
ofstream();
ofstream(const char*, int = ios::out,
int = filebuf::openprot);
~ofstream();
void open(const char*, int = ios::out,
int = filebuf::openprot);
void close(); // héritée de fstreambase en fait
};

Le constructeur par défaut ne fait rien. Lorsqu’on l’a utilisé, il faut


employer la méthode open pour ouvrir le fichier, en donnant son nom
complet (avec le chemin d’accès dans le système d’exploitation), et
éventuellement un mode d’ouverture (par exemple ios::app si l’on ne veut
pas détruire le fichier de départ, mais seulement y ajouter des éléments) ; le
troisième paramètre régit le niveau de protection, il n’a pas lieu d’être
changé.

Une manière plus rapide d’ouvrir un fichier consiste à employer le


constructeur adéquat, ce qui permet déclaration et ouverture
simultanément.

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.

À titre d’exemple, voici une fonction qui recopie un fichier dans un


autre :

int copiefichier(char *dest, char *srce)


// copie le fichier srce dans dest
// renvoie 1 si ok, 0 sinon
{
ifstream fi(srce, ios::in|ios::binary);
if (!fi) return 0; // srce impossible à lire
ofstream fo(dest, ios::out|ios::binary);
if (!fo) return 0;
char tampon;
while ( fo && fi.get(tampon) )
fo.put(tampon);
return fo.good() && fi.eof();
}

En fin de fonction, on teste l’état des fichiers ; normalement, le fichier de


sortie doit se trouver dans un état normal (sinon c'est qu’une erreur
d’écriture s’est produite), et le fichier d’entrée doit avoir ses bits fail et good
positionnés, indiquant un état anormal dû à l’échec de la dernière lecture ;
pour vérifier que le lecture est cependant achevée, on utilise la fonction eof.
On notera que les fichiers sont automatiquement fermés, puisque le
compilateur appelle les destructeurs pour ces objets automatiques.

Les classes fstreambase, ofstream, ifstream et fstream occupent


respectivement 74, 78, 80 et 84 octets de mémoire.

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.

Les classes strstreambase, istrstream, ostrstream et strstream sont les


homologues en mémoire de ios, istream, ostream et iostream. Elles sont
définies dans <strstrea.h> ; vous devez donc inclure ce fichier dans votre
programme (cela inclut automatiquement <iostream.h>).

Ces classes utilisent le type de tampon spécial strstreambuf. La classe de


base sert uniquement pour les dérivations des trois autres, qui ont l’allure
suivante :

class istrstream : public strstreambase, public


istream {
public:
istrstream(char*;;
istrstream(char*, int);
~istrstream();
};

class ostrstream : public strstreambase, public


ostream {
public:
ostrstream(char*, int, int = ios::out);
ostrstream();
~ostrstream();
char* str();
int pcount();
};

class strstream : public strstreambase, public


iostream {
public:
strstream();
strstream(char*, int, int);
~strstream();
char* str();

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.

Les méthodes str donnent simplement le début du tampon mémoire


utilisé. La méthode pcount de ostrstream indique le nombre de caractères en
attente dans le tampon.

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.

Voici par exemple une fonction qui renvoie la chaîne de caractères


correspondant à l’écriture d’un nombre à virgule flottante (avec un
paramètre optionnel pour le nombre de décimales souhaitées), et une
fonction inverse qui renvoie la valeur stockée dans une chaîne :

char *chainedouble(double d, int precis = -1)


{
static char tampon[30];
ostrstream os(tampon, 30);
if (precis >= 0) os.precision(precis);
os << d;
return tampon;
}

double valeurdouble(char *s)


{
double d;
istrstream is(s);
is >> d;
return d;
}

Les classes strstreambase, istrstream, ostrstream et strstream occupent


respectivement 68, 74, 72 et 78 octets en mémoire.

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.

Cependant, des écritures faisant fréquemment intervenir des


modifications de ces champs deviennent vite assez lourdes. Pour les
simplifier, on dispose de manipulateurs. Ceux qui sont définis dans
<iostream.h> sont les suivants :

endl (sorties) Passe à la ligne et vide le tampon.


ends (sorties) Insère un caractère nul.
flush (sorties) Vide le tampon.
dec Mode décimal.
hex Mode hexadécimal.
oct Mode octal.
ws (entrées) Supprime les espaces.

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

sera plus élégante ainsi :

cout << dec << i << hex << j << endl;

avec le même effet.

Le fichier <iomanip.h> fournit des manipulateurs supplémentaires


prenant des paramètres :

setbase(int) Fixe la base d’écriture ou de lecture ; les valeurs admises

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.

On pourra donc écrire par exemple :

int i = 32;
cout << setfill('*') << setw(9) << hex << i;
// écrit : *******20
double d = 1/3.141592;
cout << setprecision(3) << d;
// écrit : 0.318

Nous terminons ce chapitre avec un exercice assez difficile, où le lecteur


pourra exercer sa sagacité...

Exercice 9.2 Sans aller regarder dans les fichiers d’en-têtes,


comment implanteriez-vous ces manipulateurs afin de
permettre de telles écritures ? Indication : c’est
Voir solution
beaucoup plus facile pour les manipulateurs sans
paramètres.

Solution de l’exercice 9.2


Une première possibilité consiste à définir un type spécial struct manip
par exemple, et de redéfinir les opérateurs pour ce type afin d’avoir l’effet
souhaité ; les manipulateurs seraient alors des constantes de ce type.
malheureusement cela conduit à « fermer » le processus, en ce sens qu’il est
alors impossible de définir de nouveaux manipulateurs.

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 :

class ostream : virtual public ios {


// ...
ostream& operator<< (ostream& (*f)(ostream&))
{ return (*f)(*this); }
// ...
};

ostream& endl(ostream& os)


{
os << '\n' // nouvelle ligne
os.flush() // vider le tampon
return *this;
}

ostream& ends(ostream& os)


{
os << '\0' // caractère nul
return *this;
}

ostream& flush(ostream& os)


{
os.flush() // vider le tampon
return *this;
}

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.

Les manipulateurs avec paramètres sont nettement plus complexes, mais


le principe de base est le même. Il faut cependant utiliser une classe
intermédiaire dans ce cas. Voici, en simplifiant, une implantation de setw :

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

ios& setw_fonc(ios& io, int w)


{
io.width(w);
return io;
}

smanip setw(int w)
{
return smanip(setw_fonc, w);
}

Le comportement est en fait un peu plus complexe, et de plus le fichier


<iomanip.h> est rendu pratiquement illisible par l’emploi de macros et de

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.

Enfin nous détaillons l’usage de macros (héritage du C), et expliquons


pourquoi elles sont largement obsolètes en C++.

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.

Le préprocesseur recherche dans une ligne des macros pour les


transformer (voir paragraphe suivant), et des directives de compilation ; ces
directives commencent par le symbole # et se terminent avec la fin de la
ligne :

#directive [paramètres]

On peut placer des espaces blancs avant et après la directive mais,


contrairement au compilateur, les sauts de lignes et les commentaires ne
sont pas considérés comme des blancs par le préprocesseur. Par
conséquent, on ne doit pas couper une ligne de directive, ni y placer un
commentaire qui pourrait poser problème. Notons qu’il ne faut pas de
point-virgule en fin de ligne.

Si la directive ne tient pas sur une seule ligne, il suffit d’écrire le


caractère \ juste avant le saut de ligne ; dans ce cas, la ligne courante est
considérée comme la suite de la précédente, la paire \ + saut de ligne étant
ignorée. Ainsi :

#define CHAINE "Ceci est une très très longue \


chaîne de caractères"

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.

Nous détaillons ci-après les principales directives de compilation.

Directive d’inclusion #include


Nous avons déjà utilisé la directive d’inclusion. Elle indique au
préprocesseur de remplacer la ligne courante par l’ensemble des lignes du
fichier nommé en paramètre. On l’utilise essentiellement en pratique pour
inclure les en-têtes de librairies (fichiers *.h), comme on le verra plus en
détail dans le paragraphe sur l’éditeur de liens.

Il existe trois variantes de la directive d’inclusion, indiquant au


préprocesseur comment trouver le fichier à inclure. L’écriture :

#include <fichier>

indique au préprocesseur d’aller chercher le fichier dans le ou les


répertoires d’inclusion spécialement définis comme tels dans
l’environnement du système ou du compilateur. On l’utilise essentiellement
pour les fichiers en-têtes fournis avec le compilateur (librairies).

L’écriture :

#include "fichier"

indique au préprocesseur de chercher le fichier d’abord dans le répertoire


courant, puis éventuellement dans le ou les répertoires d’inclusion. On
l’utilise surtout pour les fichiers d’en-têtes faisant partie du projet courant
et définis pour lui.

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 " ".

Directive d’inclusion #include


Nous avons déjà utilisé la directive d’inclusion. Elle indique au
préprocesseur de remplacer la ligne courante par l’ensemble des lignes du
fichier nommé en paramètre. On l’utilise essentiellement en pratique pour
inclure les en-têtes de librairies (fichiers *.h), comme on le verra plus en
détail dans le paragraphe sur l’éditeur de liens.

Il existe trois variantes de la directive d’inclusion, indiquant au


préprocesseur comment trouver le fichier à inclure. L’écriture :

#include <fichier>

indique au préprocesseur d’aller chercher le fichier dans le ou les


répertoires d’inclusion spécialement définis comme tels dans
l’environnement du système ou du compilateur. On l’utilise essentiellement
pour les fichiers en-têtes fournis avec le compilateur (librairies).

L’écriture :

#include "fichier"

indique au préprocesseur de chercher le fichier d’abord dans le répertoire


courant, puis éventuellement dans le ou les répertoires d’inclusion. On
l’utilise surtout pour les fichiers d’en-têtes faisant partie du projet courant
et définis pour lui.

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 " ".

Définition de paramètres par #define


L’écriture suivante :

#define identificateur

permet de « définir » un paramètre de nom identificateur qui pourra être


utilisé dans une clause #if (voir ci-après). Le nom doit être un identificateur
au format normal de C++ : suite de lettres, de chiffres et de caractères de
soulignement (_) ne commençant pas par un chiffre. La directive #define
sert aussi à la définition de macros (voir paragraphe à ce sujet).

Un identificateur peut au contraire être rendu « indéfini » en utilisant la


clause #undef :

#undef identificateur

Même s’il n’avait pas été défini auparavant, aucune erreur n’est produite.

Contrôle de compilation par #if


On peut contrôler ce qui sera compilé effectivement ou non, avec une
clause adéquate. Si l’on écrit :

#if condition
.....
#endif

la condition, qui doit être une constante numérique au format normal de


C++, est évaluée par le préprocesseur ; si elle est non nulle, la clause #if est
ignorée ; si elle vaut zéro, tout ce qui se trouve entre #if et #endif est ignoré
(et donc non compilé en particulier).

On peut utiliser dans l’expression le pseudo-opérateur unaire defined qui


renvoie 1 si l’identificateur qui le suit est défini (par #define comme indiqué

281
au paragraphe précédent), et 0 sinon. Par exemple, on peut écrire (les
parenthèses sont facultatives) :

#if defined(__cplusplus) && !defined(__IOSTREAM_H)


......
#endif

L’écriture :

#if defined(identificateur)

peut être abrégée en :

#ifdef identificateur

De même, l’écriture :

#if !defined(identificateur)

peut être abrégée en :

#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

inline void ecrire (char *messg)


{ cout << messg; }

#elif defined(_VIDEO)

void ecrire(char *messg)


{
gotoxy(1, 25);
printf("%s", messg);
clreoln();
}
#else
#define ecrire(messg) printf("%s", messg);
#endif

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 :

__cpluplus Définie si le compilateur est en mode C++. Si on la rend indéfinie,


le compilateur repasse en mode C standard, et refuse les
déclarations internes, les nouveaux mots réservés, etc.
__MSDOS__ Toujours définie ; indique que le système d’exploitation est MS-
DOS.
__DATE__ Date du début de la compilation.
__HEURE__ Heure du début de la compilation.
__FILE__ Nom du fichier courant.
__TURBOC__ Numéro de version de Turbo C++ sous la forme d’une constante
hexadécimale : 0x0100 pour la version 1.0, etc.
__STDC__ Définie si la compilation se fait en standard ANSI, non définie
sinon (valeur par défaut).
__CDECL__ Indique des formats d’appel de fonctions en C (par opposition au
format de Pascal). Son opposé est __PASCAL__.

Messages d’erreur par #error


La directive #error provoque une erreur de compilation, accompagnée
éventuellement d’un message précisé en paramètre. Voici un exemple
simple :

#ifndef __cplusplus
#error Ce programme ne fonctionne qu'avec C++
#endif

Directives particulières #pragma

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++.

Placée avant une fonction, la directive :

#pragma argsused

invalide le message Warning : Parameter 'xxx' is never used, le paramètre


'xxx' n’est jamais utilisé. Elle ne vaut que pour la fonction qui la suit, mais
peut être répétée.

La directive :

#pragma startup fonction [priorité]

indique au compilateur d’exécuter la fonction de démarrage fonction avant


main. Il doit s’agir d’une fonction sans paramètre et sans résultat : void
fonction(void). Le numéro de priorité qui suit est facultatif, sa valeur par

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.

De manière similaire, la directive :

#pragma exit fonction [priorité]

indique au compilateur d’exécuter la fonction de sortie fonction après la fin


du programme ; il doit aussi s’agir d’une fonction sans paramètre et sans
résultat. Le sens du numéro de priorité est identique. Les fonctions de sortie
ne sont pas exécutées si le programme est interrompu par _exit ou abort,
mais elles le sont s’il est interrompu par exit ou en cas de terminaison
normale (fin de main).

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)

Le préprocesseur examine chaque ligne de code à la recherche du nom


d’une macro ; s’il la trouve, il remplace le nom de la macro par sa valeur. Si
la macro a un ou plusieurs paramètres, comme CARRE ci-dessus, ils sont
remplacés littéralement par leur valeur effective. Ce processus se poursuit
dans une ligne jusqu’à ce qu’il n’y ait plus de noms de macros, de sorte
qu’une macro peut en contenir une autre, etc. (mais sans faire de cycle !).
Par exemple, l’écriture suivante :

if ( CARRE(d) > PI)


printf(ERRMSG);

sera transformée ainsi par le préprocesseur :

if ( (d)*(d) > 3.141592)


printf("Une erreur s'est produite.\n");

Noter que dans le cas de CARRE, les occurrences de x dans la valeur de la


macro ont été remplacées littéralement par d. Les occurrences qui se
trouveraient dans des chaînes de caractères ne seraient toutefois pas
remplacées.

On peut « coller » deux paramètres, ou un paramètre et un


identificateur, à l’aide du symbole ##. Ainsi, si l’on écrit :

#define VAR(x) variable_##x

les occurrences de VAR(1) par exemple seront remplacées par variable_1.

Enfin, en plaçant un # devant un paramètre, on demande son


remplacement par la chaîne de caractères de son nom :

#define AFFICHE(x) printf("Valeur de " #x " = %d\n"


\

285
, (x) )

Noter le caractère de continuation \ + saut de ligne pour faire une


directive plus longue, comme indiqué précédemment. Les occurrences de
AFFICHE(index) seront remplacées par :

printf("Valeur de " "index" " = %d\n" , (index) )

qui équivaut à :

printf("Valeur de index = %d\n" , (index) )

Un outil à employer avec prudence


Les macros sont la source de nombreuses erreurs très difficiles à repérer,
puisqu’on ne dispose pas de la version étendue du code. Par exemple, on
peut se demander pourquoi dans la macro CARRE ci-dessus nous avons placé
ces parenthèses. Mais si l’on écrit :

#define CARRE(x) x * x
// ...
j = CARRE(i+1);

la dernière ligne deviendra :

j = i+1 * i+1;

qui est interprété comme i + (1*i) +1, soit 2*i+1.

Même avec une définition correcte de CARRE, on peut avoir des


surprises :

#define CARRE(x) (x)*(x)


// ...
int i = 3, j = CARRE(i++);

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.

En C++, les macros qui définissent des constantes diverses seront


avantageusement remplacées par des déclarations de constantes :

const Pi = 3.141592;
const Errmsg = "Une erreur s'est produite.\n";

Les macros qui définissent de courtes actions, avec ou sans paramètres,


seront remplacées par des fonctions en ligne :

inline long carre(long l) { return l*l; }


inline double carre(double d) { return d*d; }

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 à :

inline void echange(int& i, int& j)


{ int k = i; i = j; j = k; }

inline void echange(long& i, long& j)


{ long k = i; i = j; j = k; }

inline void echange(double& i, double& j)


{ double k = i; i = j; j = k; }

Inversement, certaines macros ne peuvent pas être évitées. C’est le cas


par exemple de AFFICHE dans la section précédente, qui utilise le nom des
variables. On peut toutefois la rendre plus sûre en utilisant les flots de
sortie :

#define AFFICHE(x) cout << "Valeur de " #x " = "


<< ;;

qui peut alors fonctionner quel que soit le type de x.

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).

Revenons à notre exemple de la liste chaînée (chapitre 6). Cette liste


utilise une classe element qui peut désigner n’importe quoi ; c’est
caractéristique d’une classe générique.

Pour changer facilement le type d’élément de liste, il y a deux


possibilités. La première consiste à créer un fichier séparé liste.h ne
contenant pas la définition de element, par exemple comme ceci (en
simplifiant beaucoup la définition de la classe noeud) :

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 :

typedef double element;


#include "liste.h"

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 noeud(typ) _Paste2(noeud_, typ)

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

Le fichier <generic.h> contient un certain nombre de macros pour coller


des éléments, dont voici les principales :

#define _Paste2(x, y) x##y


// coller x et y ensemble
#define declare(x, y) _Paste2(x, declare)(y)
// déclarer l’objet x avec paramètre y
#define implement(x, y) _Paste2(x, implement)(y)
// définir l’objet x avec paramètre y

À présent, écrivons le programme suivant :

#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);
// ...
}

Ce programme sera transformé ainsi par le préprocesseur :

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++.

Les implémentations, lorsqu’il y en a (fonctions qui ne sont pas en ligne


en particulier), seront définies dans une seconde macro listeimplement(typ)
et on écrira implement(typ) dans les fichiers ayant besoin de ces
implantations.

On gagne ainsi une certaine facilité d’utilisation, moyennant un surcoût


au moment de l’écriture des classes génériques.

Éditeur de liens, fichiers multiples


Nous avons vu qu’avant d’utiliser une fonction il fallait la déclarer, mais
pas forcément la définir. De ce fait, lorsque le compilateur rencontre un
appel d’une fonction dont il ne connaît pas la définition, et donc pas
l’adresse exacte, il crée une demande de lien entre l’appel et la fonction à
joindre.

Lorsque la compilation proprement dite est terminée, l’éditeur de liens


prend la relève ; en deux passes, il va réaliser les liens, c’est-à-dire trouver
les fonctions dont on ne connaissait pas l’adresse et mettre cette dernière au
bon endroit.

Pour cela l’éditeur de liens examine deux types de fichiers compilés : le


ou les fichiers du projet courant, et ceux des librairies standard. S’il ne
trouve pas la fonction qu’il cherche, il proteste en affichant un message
d’erreur.

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.

Les fonctions standard sont implémentées dans un certain de librairies


nommées xxx.lib. Les déclarations des routines sont reproduites dans des
fichiers d’en-têtes (*.h) ; ces fichiers sont très nombreux (39 en Turbo C++).
Lorsqu’on désire utiliser des routines standard, on doit inclure un ou
plusieurs de ces fichiers d’en-têtes dans le fichier courant, comme on l’a
déjà vu à plusieurs reprises. L’éditeur de liens se chargera d’aller retrouver
l’implantation des routines dans les librairies.

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.

Pour simplifier alors le travail, on répartit les différentes routines dans


plusieurs fichiers source (*.cpp). Chacun de ces fichiers peut alors être
compilé indépendamment, produisant un fichier objet (*.obj). L’ensemble
des fichiers objets est ensuite regroupé par l’éditeur de liens pour former un
programme exécutable unique (*.exe, sur PC).

Dans la pratique, les différents fichiers doivent d’abord répondre à une


certaine logique. Par exemple, on place fréquemment dans un fichier séparé
une classe entièrement implémentée, ou deux ou trois si elles sont reliées. Il
ne faut surtout pas répartir au hasard les fonctions dans plusieurs fichiers,
car il serait vite très 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.

Dans le fichier source proprement dit, on trouvera généralement une


directive d’inclusion du fichier en-tête correspondant, suivie éventuellement
d’autres directives d’inclusion, soit pour les librairies standard, soit pour les
autres fichiers en-têtes du même programme utilisés par le fichier courant.
On trouvera ensuite l’implantation des fonctions qui ne sont pas en ligne.

Dans le fichier contenant main, on trouvera toutes les inclusions d’en-


têtes nécessaires, suivies par main et éventuellement quelques fonctions
étroitement liées à elle.

Prenons un exemple simple (et artificiel). Imaginons un programme


faisant des calculs sur des matrices de fractions, implantées par une classe
spéciale du même genre que la classe liste, mais adaptée aux fractions. Un
tel programme pourrait être réparti dans cinq modules différents :

o fraction.cpp : Fichier contenant la classe fraction et les opérations sur


elle.
o listefra.cpp : Fichier contenant les classes noeud et liste nécessaires à
la gestion de listes chaînées de fractions.
o matrfra.cpp : Fichier contenant la classe matrice utilisant les listes de

fractions, et les opérations sur elle.


o iosfract.cpp : Fichier contenant diverses fonctions d’écriture et de

lecture de fractions et de matrices.

293
o mainfra.cpp :
Fichier contenant main et la gestion de base du
programme (commandes, etc.).

En réalité, les quatre premiers modules sont répartis chacun en un en-


tête et un source. Voici l’allure qu’ils pourraient avoir :

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"

// ici implantation des fonctions membres de


// la classe fraction qui ne sont pas en ligne.

listefra.h
#ifndef _LISTEFRA_H
#define _LISTEFRA_H

#include "fraction.h"

class noeud_fra; // définition dans listefra.cpp

class liste_fra {
// listes de fractions
};

#endif

listefra.cpp

294
#include "fraction.h"

class noeud_fra {
// ...classe utilisée seulement par liste_fra
};

// implantations des fonctions de noeud_fra et


// liste_fra qui ne sont pas en ligne

matrfra.h
#ifndef _MATRFRA_H
#define _MATRFRA_H

#include "fraction.h"

class matrice_fra {
// ...
};

#endif

matrfra.cpp
#include "listefra.h"
#include "matrfra.h"

// implantations des fonctions membres de matrice_fra


// qui ne sont pas en ligne

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"

// implantations des routines définies dans iosfract.h

mainfra.cpp
#include "iosfract.h"

main()
{
// ...
}

Voilà notre programme bien silhouetté. On notera que chaque fichier


n’inclut que les en-têtes dont il a besoin. Par exemple, les listes de fractions
ne sont utilisées que par l’implantation des matrices ; elles n’apparaissent
donc que dans matrfra.cpp, et non dans matrfra.h ni dans les autres. De
même, les flots d’entrées-sorties déclarés dans <iostream.h> ne sont
employés que par l’implantation des fonctions d’entrées-sorties des
fractions et matrices.

On remarquera aussi une écriture classique dans les fichiers en-têtes,


avec la clause #ifndef _XXX_H suivie par un #define _XXX_H. Ceci permet de
n’inclure qu’une seule fois un même fichier d’en-tête dans un fichier. Cela
peut paraître inutile, mais remarquez que listefra.h et matrfra.h incluent
tous deux fraction.h, et sont tous deux inclus dans matrfra.cpp. Dès lors, si
l’on oublie d’utiliser cette clause conditionnelle, c sera inclus deux fois dans
c, ce qui non seulement augmentera le temps de compilation mais aussi
risque de provoquer une erreur car le compilateur n’acceptera pas que l’on
définisse deux fois certains éléments du fichier (variables globales, etc.).

On peut à présent compiler chacun de ces fichiers, soit séparément, soit


ensemble (avec un projet, voir ci-après). On obtient alors sur disque cinq
fichiers objets fraction.obj, ..., mainfra.obj. Reste à les relier entre eux,

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

utilisés par l’éditeur de liens.

Sur les système d'exploitations en ligne de commande, on gére un tel


ensemble à l’aide d’un fichier de « fabrication » (*.make) géré par l’utilitaire
make. Dans les systèmes graphiques, les compilateurs intégrés comme Turbo

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.

Lorsque tout y est, il ne reste plus qu’à lancer la commande adéquate


(Make dans le cas de Turb C++). Voici alors ce que l’environnement intégré
fait :

o pour chaque fichier dans la fenêtre de projet il vérifie si une


modification a été faite depuis la dernière compilation en comparant
les dates et heures du fichier objet et des sources. Il examine aussi les
modifications indirectes comme la modification d’un fichier en-tête
inclus (directement ou non) ;

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).

Le seul travail du programmeur consiste donc à ne pas oublier de fichier


dans la fenêtre de projet.

La gestion de programmes ayant de multiples fichiers est


considérablement facilitée par ce système. Le gain est évident dès que le
programme dépasse quelques centaines de lignes (ce qui arrive très vite).

Par exemple, imaginons que l’on modifie un détail dans iosfract.cpp,


puis que l’on relance un Make. Dans ce cas, seul le fichier iosfract.cpp sera
recompilé, et l’édition de liens sera refaite.

Si l’on modifie un fichier en-tête, les fichiers qui l’utilisent seront


recompilés. Par exemple une modification de fraction.h provoquera la
recompilation des cinq fichiers source, car tous utilisent directement ou non
ce fichier d’en-tête. Par contre, si l’on modifie listefra.h, seuls listefra.cpp
et matrfra.cpp seront recompilés.

Objets externes ou statiques


Un objet comme une fonction, une variable, etc., peut être automatique,
dynamique, statique ou externe. Nous connaissons déjà les variables
automatiques et dynamiques. Nous nous intéressons ici plutôt aux variables
définies en dehors d’une fonction, et aux fonctions elles-mêmes.

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.

Imaginons par exemple que le fichier iosfract.cpp ait besoin de partager


avec le programme principal mainfra.cpp une variable globale glob
(indiquant par exemple la dernière erreur d’entrée-sortie produite). Dans ce
cas, on n’écrira pas, dans le fichier iosfract.h :

int glob = 1;

sinon la variable serait dupliquée en deux exemplaires différents dans les


deux fichiers objets (ce qui n’est pas le but recherché), et l’éditeur de liens
signalerait une erreur. Il faut indiquer explicitement au compilateur que la
variable glob est partagée entre deux fichiers objets, et que c’est l’éditeur de
liens qui s’en occupera. Il faut donc placer dans le fichier d’en-tête une
déclaration externe :

extern int glob;

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.

Déclarés en dehors d’une fonction, les variables et les types (y compris


les classes) sont externes par défaut ; les fonctions également. Elles peuvent
être déclarées statiques si elles ne sont utilisées que dans le fichier courant :

static void fonc(void)


{ ...

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).

Les déclarations de types sont externes par défaut. On peut cependant


les répéter dans plusieurs fichiers objets, à condition que ce soit de manière
identique (en pratique en passant par un fichier en-tête) ; c’est ce que nous
avons fait avec les types fraction et matrice_fra dans notre exemple.

D’une façon générale, on évitera de placer dans un fichier en-tête une


définition de fonction (sauf en ligne), de variables (mais une déclaration
externe est possible) et de tableaux, classes ou structures constants.

300
ANNEXES
Liste des mots réservés de C++
Les mots suivants sont réservés en C++ :

Asm auto break case catch


char class const continue default
delete do double else enum
extern float for friend goto
If inline int long new
operator private protected public register
return short signed sizeof static
struct switch template this typedef
union unsigned virtual void volatile
while

Les mots suivants sont de plus réservés en Turbo C++ :

cdecl _cs _ds _es _export far huge


interrupt _loadds near pascal _regparam
_saveregs _seg _ss

Les mots suivants sont réservés par d’autres compilateurs, il est


préférable de ne pas les utiliser :

entry fortran handle _handle overload

Liste des opérateurs de C++


Les liens renvoient aux sections où ces opérateurs sont décrits. L’ordre
indiqué est celui de précédence, indiquée aussi dans la deuxième colonne ;
les opérateurs de précédence 15 ont une priorité plus grande que ceux de
précédence 14, etc. Les opérateurs de priorité égale sont traités de gauche à
droite ou de droite à gauche selon le sens indiqué. La colonne Nb.Op.
indique le nombre d’opérandes ; ceux-ci sont également évalués dans le
sens indiqué. Une description plus détaillée se trouve aux sections
indiquées par les liens dans la colonne des noms.

301
Les opérateurs qui ne peuvent être redéfinis sont sur fond orange.

Opérateur Préc. Sens Nb. Nom Description


Op.
() 15 -> varie Appel de fonction Appelle la fonction dont le nom se
trouve devant les parenthèses, avec
les arguments contenus dans celles-
ci.
[] 15 -> 2 Indice de tableau Renvoie un élément d’indice calculé
entre crochets dans le tableau dont le
nom se trouve devant ceux-ci.
-> 15 -> 2 Déréférencement + Déréférence le pointeur situé devant
adressage l’opérateur puis adresse le membre
situé à droite.
:: 15 -> 1 ou 2 Résolution de portée En opérateur unaire, devant un
identificateur, indique une variable
globale.
En opérateur binaire, limite la portée
de recherche de l’identificateur situé
à droite de l’opérateur à la classe
située à gauche.
::* 15 -> 2 Résolution de portée Indique la classe pour un pointeur
pour les pointeurs sur sur membre.
membre
. 15 -> 2 Adressage Adresse le membre dont le nom suit
le point dans la classe dont le nom
précède.
! 14 <- 1 Négation logique Change un booléen en son opposé.
~ 14 <- 1 Négation par bits Change tous les bits d’un entier en
leur opposé.
+ 14 <- 1 + unaire Aucun effet.
- 14 <- 1 - unaire Change un nombre en son opposé.
++ 14 <- 1 Incrémentation Incrémente un entier ou un pointeur.
Peut être placé devant ou derrière
(actionavant ou après le reste des
calculs).
-- 14 <- 1 Décrémentation Décrémente un entier ou un pointeur.
Peut être placé devant ou derrière
(actionavant ou après le reste des
calculs).
& 14 <- 1 Référence Renvoie un référence sur
l’identificateur dont le nom suit.
* 14 <- 1 Déréférencement Renvoie une référence sur la variable
pointée par le pointeur dont le nom
suit.

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

You might also like