cours d'innitiation à la programmation en c++

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

Upload: mercythrone

Post on 18-Jun-2015

526 views

Category:

Documents


2 download

TRANSCRIPT

Page 1: cours d'innitiation à la  programmation en C++

1

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

Page 2: cours d'innitiation à la  programmation en C++

2

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.

Page 3: cours d'innitiation à la  programmation en C++

3

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

Page 4: cours d'innitiation à la  programmation en C++

4

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

Page 5: cours d'innitiation à la  programmation en C++

5

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

Page 6: cours d'innitiation à la  programmation en C++

6

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

Page 7: cours d'innitiation à la  programmation en C++

7

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 :

Page 8: cours d'innitiation à la  programmation en C++

8

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

Page 9: cours d'innitiation à la  programmation en C++

9

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.

Page 10: cours d'innitiation à la  programmation en C++

10

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

Page 11: cours d'innitiation à la  programmation en C++

11

'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

Page 12: cours d'innitiation à la  programmation en C++

12

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;

Quelle est l’erreur produite, en supposant les deux

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

Voir solution

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

Page 13: cours d'innitiation à la  programmation en C++

13

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

Page 14: cours d'innitiation à la  programmation en C++

14

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.

Page 15: cours d'innitiation à la  programmation en C++

15

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

Page 16: cours d'innitiation à la  programmation en C++

16

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

Page 17: cours d'innitiation à la  programmation en C++

17

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

sans utiliser de fonctions ?

Voir solution

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

Page 18: cours d'innitiation à la  programmation en C++

18

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) ; voi d clrscr (voi d);

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

Page 19: cours d'innitiation à la  programmation en C++

19

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"; els e 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 :

Page 20: cours d'innitiation à la  programmation en C++

20

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;

Page 21: cours d'innitiation à la  programmation en C++

21

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 :

Page 22: cours d'innitiation à la  programmation en C++

22

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 {

Page 23: cours d'innitiation à la  programmation en C++

23

cout << nombre << '\t'; nombre += 7; } whil e (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 ;

Page 24: cours d'innitiation à la  programmation en C++

24

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

Page 25: cours d'innitiation à la  programmation en C++

25

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.

Page 26: cours d'innitiation à la  programmation en C++

26

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 =

Page 27: cours d'innitiation à la  programmation en C++

27

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 !

Page 28: cours d'innitiation à la  programmation en C++

28

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; unsigned long x = u *v;

Voir solution

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 :

Page 29: cours d'innitiation à la  programmation en C++

29

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.

Page 30: cours d'innitiation à la  programmation en C++

30

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

Page 31: cours d'innitiation à la  programmation en C++

31

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 variable unsigned , on ajoute 216 ou 232 à -x pour conserver un signe positif.

-i donne -3

-l donne 100000

-u donne 30536

-ul donne 1294967296 ¤

~

Opérateur unaire inversant tous les bits (1 changé en 0, 0 en 1). L’effet obtenu est que ~x = -x-1 si x est une variable signée, ~x = M-x si x est non signé, où M désigne la plus grande valeur possible de x , soit 65 535 pour une variable à deux octets, et 4 294 967 295 pour une à quatre octets.

~i donne -4

~j donne 11 ~l donne 99999

~u donne 30535 ~ul donne 1294967695

*

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 quotient de la division euclidienne des deux opérandes, s’ils sont positifs ; sinon le résultat dépend de la machine et il est préférable de se méfier. Les résultats suivants sont obtenus avec Turbo C++ sur PC.

l/i donne -33333

u/i donne 11666 l/j donne 8333

(-l)/j donne -8333 u/j donne 0 (ici j est

transformé en unsigned de valeur 65 524)

%

Opérateur binaire de division modulo. Renvoie le reste de la division euclidienne des opérandes s’ils sont positifs. On a toujours la relation x == (x/y)*y

+ (x%y) .

l%i donne -1

u%i donne 2 l%j donne 4

(-l)%j donne -4 u%j donne 35000 (idem)

+

Opérateur binaire symétrique d’addition. i+j donne -9

u+i donne 35003 ul+l donne 2999900000

l+u donne -65000

-

Opérateur binaire de soustraction. i-j donne 15

u-i donne 34997 ul-l donne 3000100000 l-ul donne 1294867296

(débordement)

<< Opérateur binaire de décalage des bits à gauche : x <<

y vaut x décalé de y bits à gauche ; les bits de poids fort sortants sont perdus, les entrants valent 0, si y est

i << 2 donne 12

l << i donne -80000 u << 4 donne 35712

Page 32: cours d'innitiation à la  programmation en C++

32

positif (si y est négatif, le résultat est indéfini). Cette opération donne donc x * 2y, avec troncature.

(troncature)

>> Opérateur binaire de décalage des bits à droite. Comme précédemment, mais les bits sont décalés vers la droite. L’opération x >> y est donc égale à x / 2y.

i >> 2 donne 0

l >> i donne -12500 u >> 4 donne 2187

& Opérateur binaire symétrique de « et » logique. Les bits des deux opérandes sont conjugués en « et » logique (multiplication logique).

i&6 donne 2

i&j donne 0 l&u donne 2080

^ Opérateur binaire symétrique de « ou exclusif » logique. Les bits des opérandes sont conjugués en « ou exclusif » logique (différence logique).

i^6 donne 5

i^j donne -9 l^u donne -69160

| Opérateur binaire symétrique de « ou » logique. Les bits des opérandes sont conjugués en « ou » logique (addition logique).

i|6 donne 7

i|j donne -9 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

Page 33: cours d'innitiation à la  programmation en C++

33

'\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 '\x hh ' 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

Page 34: cours d'innitiation à la  programmation en C++

34

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 ?

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

Voir solution

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

Page 35: cours d'innitiation à la  programmation en C++

35

" (\" ) 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 :

Page 36: cours d'innitiation à la  programmation en C++

36

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

Priorité forte + Opérateur unaire sans effet.

- Opérateur unaire de changement de signe.

Priorité moyenne

* Opérateur binaire symétrique de multiplication.

/ Opérateur binaire de division ; il s’agit de la division de deux décimaux au sens usuel.

Priorité faible + Opérateur binaire symétrique d’addition.

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

Page 37: cours d'innitiation à la  programmation en C++

37

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

Page 38: cours d'innitiation à la  programmation en C++

38

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; float f = i/j; j = 10-f++*--i;

Voir solution

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

Page 39: cours d'innitiation à la  programmation en C++

39

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

Page 40: cours d'innitiation à la  programmation en C++

40

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

Page 41: cours d'innitiation à la  programmation en C++

41

Voir solutionopérateurs est non nul, 0 sinon. Comment peut-on le

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

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

j sont deux entiers ?

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

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

Voir solution

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.

Page 42: cours d'innitiation à la  programmation en C++

42

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.

Page 43: cours d'innitiation à la  programmation en C++

43

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;

Page 44: cours d'innitiation à la  programmation en C++

44

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 ;

Page 45: cours d'innitiation à la  programmation en C++

45

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; d = (double) i/j; d = double(i/j); d = double(i)/ double(j);

Voir solution

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

Page 46: cours d'innitiation à la  programmation en C++

46

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 :

Page 47: cours d'innitiation à la  programmation en C++

47

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.

Page 48: cours d'innitiation à la  programmation en C++

48

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

Page 49: cours d'innitiation à la  programmation en C++

49

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 ?

if (0 < x < 100) instruction ;

Voir solution

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

Page 50: cours d'innitiation à la  programmation en C++

50

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;

Page 51: cours d'innitiation à la  programmation en C++

51

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

Page 52: cours d'innitiation à la  programmation en C++

52

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

Page 53: cours d'innitiation à la  programmation en C++

53

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

Page 54: cours d'innitiation à la  programmation en C++

54

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 :

Page 55: cours d'innitiation à la  programmation en C++

55

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.

Exercice 3.1

Écrire une fonction qui affiche les éléments

d’indices debut à fin (compris) d’un tableau d’entiers, à

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

Page 56: cours d'innitiation à la  programmation en C++

56

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

Page 57: cours d'innitiation à la  programmation en C++

57

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

Page 58: cours d'innitiation à la  programmation en C++

58

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 .

Page 59: cours d'innitiation à la  programmation en C++

59

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 .

Page 60: cours d'innitiation à la  programmation en C++

60

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]; int *(r[7]);

Voir solution

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 :

Page 61: cours d'innitiation à la  programmation en C++

61

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

Page 62: cours d'innitiation à la  programmation en C++

62

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; cout << ++*p; cout << *(p++); cout << *p++;

Voir solution

Page 63: cours d'innitiation à la  programmation en C++

63

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.

Page 64: cours d'innitiation à la  programmation en C++

64

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

Page 65: cours d'innitiation à la  programmation en C++

65

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

d’entiers désignée par un pointeur int* ?

Voir solution

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

dictionnaire), et une valeur > 0 si s1 > s2 .

On supposera les caractères unsigned , comme dans

la librairie <string.h> .

Voir solution

Solution de l’exercice 3.5

Voici un exemple de solution :

Page 66: cours d'innitiation à la  programmation en C++

66

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;

Page 67: cours d'innitiation à la  programmation en C++

67

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 ,

uniquement avec des pointeurs. Écrire une seconde

version utilisant memmove et strlen .

Voir solution

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

Page 68: cours d'innitiation à la  programmation en C++

68

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

dans tous les cas.

Voir solution

Page 69: cours d'innitiation à la  programmation en C++

69

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, bien sûr, rien n’est fait si source ==

dest . Noter aussi que dans le sens descendant,

l’initialisation de d et s les place une case trop loin

Page 70: cours d'innitiation à la  programmation en C++

70

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.

Page 71: cours d'innitiation à la  programmation en C++

71

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

Page 72: cours d'innitiation à la  programmation en C++

72

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.

Page 73: cours d'innitiation à la  programmation en C++

73

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

Page 74: cours d'innitiation à la  programmation en C++

74

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

Page 75: cours d'innitiation à la  programmation en C++

75

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

strcpy (longueur et copie de chaînes) de <string.h> .

Voir solution

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

Page 76: cours d'innitiation à la  programmation en C++

76

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

Page 77: cours d'innitiation à la  programmation en C++

77

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

Page 78: cours d'innitiation à la  programmation en C++

78

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 'cons t 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++;

Page 79: cours d'innitiation à la  programmation en C++

79

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 :

Page 80: cours d'innitiation à la  programmation en C++

80

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

Page 81: cours d'innitiation à la  programmation en C++

81

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 :

Page 82: cours d'innitiation à la  programmation en C++

82

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

Page 83: cours d'innitiation à la  programmation en C++

83

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 :

Page 84: cours d'innitiation à la  programmation en C++

84

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

Page 85: cours d'innitiation à la  programmation en C++

85

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

Page 86: cours d'innitiation à la  programmation en C++

86

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

Page 87: cours d'innitiation à la  programmation en C++

87

}

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

Page 88: cours d'innitiation à la  programmation en C++

88

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.

Page 89: cours d'innitiation à la  programmation en C++

89

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; typede f 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 :

Page 90: cours d'innitiation à la  programmation en C++

90

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.

Page 91: cours d'innitiation à la  programmation en C++

91

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

Page 92: cours d'innitiation à la  programmation en C++

92

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

Page 93: cours d'innitiation à la  programmation en C++

93

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 :

Page 94: cours d'innitiation à la  programmation en C++

94

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

Page 95: cours d'innitiation à la  programmation en C++

95

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

Page 96: cours d'innitiation à la  programmation en C++

96

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

Page 97: cours d'innitiation à la  programmation en C++

97

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

Page 98: cours d'innitiation à la  programmation en C++

98

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.

Page 99: cours d'innitiation à la  programmation en C++

99

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

Page 100: cours d'innitiation à la  programmation en C++

100

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

Page 101: cours d'innitiation à la  programmation en C++

101

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

Page 102: cours d'innitiation à la  programmation en C++

102

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

Page 103: cours d'innitiation à la  programmation en C++

103

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;

Page 104: cours d'innitiation à la  programmation en C++

104

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;

Page 105: cours d'innitiation à la  programmation en C++

105

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 :

Page 106: cours d'innitiation à la  programmation en C++

106

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 = NUL L);

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

Page 107: cours d'innitiation à la  programmation en C++

107

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

Page 108: cours d'innitiation à la  programmation en C++

108

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

Page 109: cours d'innitiation à la  programmation en C++

109

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.

Page 110: cours d'innitiation à la  programmation en C++

110

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

Page 111: cours d'innitiation à la  programmation en C++

111

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

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

Voir solution

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

Page 112: cours d'innitiation à la  programmation en C++

112

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é

Page 113: cours d'innitiation à la  programmation en C++

113

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,

Page 114: cours d'innitiation à la  programmation en C++

114

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;

Page 115: cours d'innitiation à la  programmation en C++

115

}

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 .

Page 116: cours d'innitiation à la  programmation en C++

116

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

Page 117: cours d'innitiation à la  programmation en C++

117

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

Page 118: cours d'innitiation à la  programmation en C++

118

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

Page 119: cours d'innitiation à la  programmation en C++

119

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

Page 120: cours d'innitiation à la  programmation en C++

120

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

Page 121: cours d'innitiation à la  programmation en C++

121

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 ?

Page 122: cours d'innitiation à la  programmation en C++

122

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 :

Page 123: cours d'innitiation à la  programmation en C++

123

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

Page 124: cours d'innitiation à la  programmation en C++

124

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

Page 125: cours d'innitiation à la  programmation en C++

125

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

Page 126: cours d'innitiation à la  programmation en C++

126

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

Page 127: cours d'innitiation à la  programmation en C++

127

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)); long (*) (double[], int) (*p3) (long);

Voir solution

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

Exercice 5.5Comment déclarer un tableau de pointeurs sur des

fonctions renvoyant un int et acceptant un double

Page 128: cours d'innitiation à la  programmation en C++

128

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

Page 129: cours d'innitiation à la  programmation en C++

129

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

Page 130: cours d'innitiation à la  programmation en C++

130

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,

en appelant random(1000) .

Voir solution

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

Page 131: cours d'innitiation à la  programmation en C++

131

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

Page 132: cours d'innitiation à la  programmation en C++

132

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

Page 133: cours d'innitiation à la  programmation en C++

133

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;

Page 134: cours d'innitiation à la  programmation en C++

134

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

Page 135: cours d'innitiation à la  programmation en C++

135

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.

Page 136: cours d'innitiation à la  programmation en C++

136

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.

Page 137: cours d'innitiation à la  programmation en C++

137

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 :

Page 138: cours d'innitiation à la  programmation en C++

138

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

Page 139: cours d'innitiation à la  programmation en C++

139

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

Page 140: cours d'innitiation à la  programmation en C++

140

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 .

Page 141: cours d'innitiation à la  programmation en C++

141

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; } in t 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 :

Page 142: cours d'innitiation à la  programmation en C++

142

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

Page 143: cours d'innitiation à la  programmation en C++

143

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 :

Page 144: cours d'innitiation à la  programmation en C++

144

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,

Page 145: cours d'innitiation à la  programmation en C++

145

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

Page 146: cours d'innitiation à la  programmation en C++

146

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

Page 147: cours d'innitiation à la  programmation en C++

147

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

Page 148: cours d'innitiation à la  programmation en C++

148

Voir solutionle problème (un meilleur moyen est donné plus loin

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,

Page 149: cours d'innitiation à la  programmation en C++

149

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

Page 150: cours d'innitiation à la  programmation en C++

150

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.

Page 151: cours d'innitiation à la  programmation en C++

151

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.

Page 152: cours d'innitiation à la  programmation en C++

152

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

Page 153: cours d'innitiation à la  programmation en C++

153

};

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

Page 154: cours d'innitiation à la  programmation en C++

154

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

Page 155: cours d'innitiation à la  programmation en C++

155

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

Page 156: cours d'innitiation à la  programmation en C++

156

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

Page 157: cours d'innitiation à la  programmation en C++

157

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 :

Page 158: cours d'innitiation à la  programmation en C++

158

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 :

Page 159: cours d'innitiation à la  programmation en C++

159

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

Page 160: cours d'innitiation à la  programmation en C++

160

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.

Page 161: cours d'innitiation à la  programmation en C++

161

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

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

Voir solution

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)

Page 162: cours d'innitiation à la  programmation en C++

162

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

Page 163: cours d'innitiation à la  programmation en C++

163

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

Page 164: cours d'innitiation à la  programmation en C++

164

// ... };

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

Page 165: cours d'innitiation à la  programmation en C++

165

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

Page 166: cours d'innitiation à la  programmation en C++

166

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

Page 167: cours d'innitiation à la  programmation en C++

167

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.

Page 168: cours d'innitiation à la  programmation en C++

168

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

Voir solution

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

Page 169: cours d'innitiation à la  programmation en C++

169

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

Page 170: cours d'innitiation à la  programmation en C++

170

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

Page 171: cours d'innitiation à la  programmation en C++

171

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 .

Page 172: cours d'innitiation à la  programmation en C++

172

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;

Page 173: cours d'innitiation à la  programmation en C++

173

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.

Page 174: cours d'innitiation à la  programmation en C++

174

Exercice 6.4

É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

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)

Page 175: cours d'innitiation à la  programmation en C++

175

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

Page 176: cours d'innitiation à la  programmation en C++

176

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

Page 177: cours d'innitiation à la  programmation en C++

177

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

Page 178: cours d'innitiation à la  programmation en C++

178

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

Page 179: cours d'innitiation à la  programmation en C++

179

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

Page 180: cours d'innitiation à la  programmation en C++

180

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

Page 181: cours d'innitiation à la  programmation en C++

181

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.

Page 182: cours d'innitiation à la  programmation en C++

182

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;

Page 183: cours d'innitiation à la  programmation en C++

183

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

Page 184: cours d'innitiation à la  programmation en C++

184

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

Page 185: cours d'innitiation à la  programmation en C++

185

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

Page 186: cours d'innitiation à la  programmation en C++

186

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

Page 187: cours d'innitiation à la  programmation en C++

187

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

Page 188: cours d'innitiation à la  programmation en C++

188

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

Page 189: cours d'innitiation à la  programmation en C++

189

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

Page 190: cours d'innitiation à la  programmation en C++

190

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

intermédiaire de 1 (nom américain), soit un nom en

trois parties à l’orientale.

Voir solution

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

Page 191: cours d'innitiation à la  programmation en C++

191

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

Page 192: cours d'innitiation à la  programmation en C++

192

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.

Page 193: cours d'innitiation à la  programmation en C++

193

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

Page 194: cours d'innitiation à la  programmation en C++

194

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

Page 195: cours d'innitiation à la  programmation en C++

195

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 }

Page 196: cours d'innitiation à la  programmation en C++

196

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

Page 197: cours d'innitiation à la  programmation en C++

197

autre soit 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 :

Page 198: cours d'innitiation à la  programmation en C++

198

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,

Page 199: cours d'innitiation à la  programmation en C++

199

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

Page 200: cours d'innitiation à la  programmation en C++

200

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

Page 201: cours d'innitiation à la  programmation en C++

201

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 :

Page 202: cours d'innitiation à la  programmation en C++

202

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

Page 203: cours d'innitiation à la  programmation en C++

203

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 }

Page 204: cours d'innitiation à la  programmation en C++

204

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.

Page 205: cours d'innitiation à la  programmation en C++

205

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 :

Page 206: cours d'innitiation à la  programmation en C++

206

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

Page 207: cours d'innitiation à la  programmation en C++

207

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

définition de la classe liste pour y insérer la

déclaration friend ?

Voir solution

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.

Page 208: cours d'innitiation à la  programmation en C++

208

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

Page 209: cours d'innitiation à la  programmation en C++

209

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

l’ancienne ? Quel comportement est préférable selon

vous ?

Voir solution

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

Page 210: cours d'innitiation à la  programmation en C++

210

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

Page 211: cours d'innitiation à la  programmation en C++

211

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

Page 212: cours d'innitiation à la  programmation en C++

212

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 }

Page 213: cours d'innitiation à la  programmation en C++

213

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.

Page 214: cours d'innitiation à la  programmation en C++

214

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

Page 215: cours d'innitiation à la  programmation en C++

215

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;

Page 216: cours d'innitiation à la  programmation en C++

216

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

Page 217: cours d'innitiation à la  programmation en C++

217

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;

Page 218: cours d'innitiation à la  programmation en C++

218

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.

Page 219: cours d'innitiation à la  programmation en C++

219

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

Page 220: cours d'innitiation à la  programmation en C++

220

{ 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

Page 221: cours d'innitiation à la  programmation en C++

221

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

Page 222: cours d'innitiation à la  programmation en C++

222

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)

Page 223: cours d'innitiation à la  programmation en C++

223

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

Page 224: cours d'innitiation à la  programmation en C++

224

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

Page 225: cours d'innitiation à la  programmation en C++

225

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.

Page 226: cours d'innitiation à la  programmation en C++

226

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

Page 227: cours d'innitiation à la  programmation en C++

227

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

Page 228: cours d'innitiation à la  programmation en C++

228

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

Page 229: cours d'innitiation à la  programmation en C++

229

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.

Page 230: cours d'innitiation à la  programmation en C++

230

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

Page 231: cours d'innitiation à la  programmation en C++

231

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

Page 232: cours d'innitiation à la  programmation en C++

232

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

Page 233: cours d'innitiation à la  programmation en C++

233

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é

Page 234: cours d'innitiation à la  programmation en C++

234

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.

Page 235: cours d'innitiation à la  programmation en C++

235

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 :

Page 236: cours d'innitiation à la  programmation en C++

236

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

l’implantation du chapitre 6. Écrire la méthode

correspondante.

Voir solution

Page 237: cours d'innitiation à la  programmation en C++

237

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

Page 238: cours d'innitiation à la  programmation en C++

238

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.

Page 239: cours d'innitiation à la  programmation en C++

239

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

éléments de liste ?

Voir solution

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.

Page 240: cours d'innitiation à la  programmation en C++

240

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

Page 241: cours d'innitiation à la  programmation en C++

241

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.

Page 242: cours d'innitiation à la  programmation en C++

242

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

Page 243: cours d'innitiation à la  programmation en C++

243

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(), courant = np)) && (reste--) ) { ls.avance();

Page 244: cours d'innitiation à la  programmation en C++

244

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 { // ... };

Page 245: cours d'innitiation à la  programmation en C++

245

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;

Page 246: cours d'innitiation à la  programmation en C++

246

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

Page 247: cours d'innitiation à la  programmation en C++

247

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.

Page 248: cours d'innitiation à la  programmation en C++

248

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

Page 249: cours d'innitiation à la  programmation en C++

249

};

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

Page 250: cours d'innitiation à la  programmation en C++

250

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 .

Page 251: cours d'innitiation à la  programmation en C++

251

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 :

Page 252: cours d'innitiation à la  programmation en C++

252

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.

Page 253: cours d'innitiation à la  programmation en C++

253

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 :

Page 254: cours d'innitiation à la  programmation en C++

254

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

Page 255: cours d'innitiation à la  programmation en C++

255

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.

Page 256: cours d'innitiation à la  programmation en C++

256

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 :

Page 257: cours d'innitiation à la  programmation en C++

257

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

Page 258: cours d'innitiation à la  programmation en C++

258

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

Page 259: cours d'innitiation à la  programmation en C++

259

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

Page 260: cours d'innitiation à la  programmation en C++

260

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.

Page 261: cours d'innitiation à la  programmation en C++

261

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 :

Page 262: cours d'innitiation à la  programmation en C++

262

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

Page 263: cours d'innitiation à la  programmation en C++

263

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

Page 264: cours d'innitiation à la  programmation en C++

264

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

Page 265: cours d'innitiation à la  programmation en C++

265

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

Page 266: cours d'innitiation à la  programmation en C++

266

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.

Page 267: cours d'innitiation à la  programmation en C++

267

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

Page 268: cours d'innitiation à la  programmation en C++

268

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 .

Page 269: cours d'innitiation à la  programmation en C++

269

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.

Page 270: cours d'innitiation à la  programmation en C++

270

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

Page 271: cours d'innitiation à la  programmation en C++

271

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.

Page 272: cours d'innitiation à la  programmation en C++

272

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

Page 273: cours d'innitiation à la  programmation en C++

273

};

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.

Page 274: cours d'innitiation à la  programmation en C++

274

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

Page 275: cours d'innitiation à la  programmation en C++

275

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

beaucoup plus facile pour les manipulateurs sans

paramètres.

Voir solution

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.

Page 276: cours d'innitiation à la  programmation en C++

276

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

Page 277: cours d'innitiation à la  programmation en C++

277

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

Page 278: cours d'innitiation à la  programmation en C++

278

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

Page 279: cours d'innitiation à la  programmation en C++

279

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

Page 280: cours d'innitiation à la  programmation en C++

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

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

Page 281: cours d'innitiation à la  programmation en C++

281

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é

Page 282: cours d'innitiation à la  programmation en C++

282

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

Page 283: cours d'innitiation à la  programmation en C++

283

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

Page 284: cours d'innitiation à la  programmation en C++

284

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

Page 285: cours d'innitiation à la  programmation en C++

285

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" \

Page 286: cours d'innitiation à la  programmation en C++

286

, (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 i vaut 5, et non 4, parce que la macro a été étendue sous la forme

(i++)*(i++) et provoque deux incrémentations. Ce genre d’erreur est

particulièrement ardu à repérer.

Page 287: cours d'innitiation à la  programmation en C++

287

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.

Page 288: cours d'innitiation à la  programmation en C++

288

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"

Page 289: cours d'innitiation à la  programmation en C++

289

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"

Page 290: cours d'innitiation à la  programmation en C++

290

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

Page 291: cours d'innitiation à la  programmation en C++

291

// ... }

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.

Page 292: cours d'innitiation à la  programmation en C++

292

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.

Page 293: cours d'innitiation à la  programmation en C++

293

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.

Page 294: cours d'innitiation à la  programmation en C++

294

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

Page 295: cours d'innitiation à la  programmation en C++

295

#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

Page 296: cours d'innitiation à la  programmation en C++

296

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

Page 297: cours d'innitiation à la  programmation en C++

297

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

Page 298: cours d'innitiation à la  programmation en C++

298

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

Page 299: cours d'innitiation à la  programmation en C++

299

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

Page 300: cours d'innitiation à la  programmation en C++

300

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.

Page 301: cours d'innitiation à la  programmation en C++

301

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.

Page 302: cours d'innitiation à la  programmation en C++

302

Les opérateurs qui ne peuvent être redéfinis sont sur fond orange.

Opérateur Préc. Sens Nb. Op.

Nom Description

() 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 + adressage

Déréférence le pointeur situé devant 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 pour les pointeurs sur

membre

Indique la classe pour un pointeur sur 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.

Page 303: cours d'innitiation à la  programmation en C++

303

( 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

pointeur sur membre Appel d’une méthode de classe par l’intermédiaire d’un pointeur de fonction.

->* 13 -> 2 Déréférencement + adressage d’un

pointeur sur membre

Déréférence le pointeur, puis appelle une méthode de classe par 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

Page 304: cours d'innitiation à la  programmation en C++

304

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 bits

Applique l’opération logique OU 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.