Mercredi 28 novembre 2007 3 28 /11 /Nov /2007 14:44
Par Missa Dioma
Ecrire un commentaire - Voir les 0 commentaires
Lundi 26 novembre 2007 1 26 /11 /Nov /2007 14:42
Pour   continuer  ,   cliquer  ici
Par Missa Dioma - Publié dans : Vidéo en ligne
Ecrire un commentaire - Voir les 0 commentaires
Vendredi 23 novembre 2007 5 23 /11 /Nov /2007 10:42
Intégrales multiples - Réponses :

 

 

 

Exercice 1 - Réponse:

Exercice 2 - Réponse:

Exercice 3 - Réponse:

On passe en coordonnées polaires

Exercice 4 - Réponse :

Exercice 5 - Réponse :

Exercice 6 - Réponse :

Exercice 7 - Réponse :

Exercice 8 - Réponse :

Sous réserve de la convergence de l'intégrale

Exercice 9 - Réponse :

Exercice 10 - Réponse :

Exercice 12 - Réponse :

Exercice 13 - Réponse :

Exercice 14 - Réponse :

Exercice 15 - Réponse :


Exercice 16 - Réponse :


Exercice 17 - Réponse :

On passe en coordonées cylindriques


Exercice 18 - Réponse :

On passe en coordonées cylindriques


Exercice 19 - Réponse :

On passe en coordonées sphériques


Exercice 20 - Réponse :

On passe en coordonées sphériques


Exercice 21 - Réponse :

 

 

 

 

 

 

  Pour  en  savoir  plus  , cliquer  ici

 

 

 

 

Par Missa Dioma - Publié dans : Math2
Ecrire un commentaire - Voir les 0 commentaires
Vendredi 23 novembre 2007 5 23 /11 /Nov /2007 09:58








Exercice 1:



Exercice 2:


Exercice 3 :

Exercice 4:


Exercice 5:

Exercice 6:

Exercice 7:

Exercice 8 :

Exercice 9 :

Exercice 10 :


Exercice 11 :


 
 
 Exercice 12:


Exercice 13 :

Exercice 14 :

Exercice 15 :

Effectuer le changement de variables

u=x+y+z, v=y+z, w=z

Exercice 16 :

Exercice 17 :

Exercice 18 :

Exercice 19 :

Exercice 20 :

Exercice 21 :

Exercice 22 :


Par Missa Dioma - Publié dans : Math2
Ecrire un commentaire - Voir les 0 commentaires
Vendredi 23 novembre 2007 5 23 /11 /Nov /2007 09:48

 

Le triangle rectangle 

  

 

Cosinus, sinus et tangente d'un angle

Relations trigonométriques

Distance dans un repère orthonormé du plan

 

1) Cosinus, sinus et tangente d'un angle :

1.a) Définition





1.b) Dans un triangle rectangle

1.c) Exemple

2) Relations trigonométriques :

2.a) Propriété
x désignant un angle aigu quelconque , on a :

2.b) Exemple

3) Distance dans un repère orthonormé du plan :


3.a) Propriété

3.b) Exemple :

EXERCICE 1 :

 

SOLUTION :

 

 

Par Missa Dioma - Publié dans : Math1
Ecrire un commentaire - Voir les 0 commentaires
Mercredi 21 novembre 2007 3 21 /11 /Nov /2007 14:00

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.

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.

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"

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

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.

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.

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 :

  • fraction.cpp : Fichier contenant la classe fraction et les opérations sur elle.

  • listefra.cpp : Fichier contenant les classes noeud et liste nécessaires à la gestion de listes chaînées de fractions.

  • matrfra.cpp : Fichier contenant la classe matrice utilisant les listes de fractions, et les opérations sur elle.

  • iosfract.cpp : Fichier contenant diverses fonctions d’écriture et de lecture de fractions et de matrices.

  • 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

#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

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

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

  • si une modification a été faite, le compilateur est appelé et recrée le fichier objet correspondant ;

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

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.

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.

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

 

Par Missa Dioma - Publié dans : Informatique
Ecrire un commentaire - Voir les 0 commentaires
Mercredi 21 novembre 2007 3 21 /11 /Nov /2007 13:53

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.

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 :

  • ios::beg : référence = début du fichier

  • ios::cur : référence = position courante

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

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 :

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

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

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

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

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

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.

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

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.

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

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

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.

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

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.

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

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

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

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

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 :

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 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 " = %dn"                      , (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" " = %dn" ,  (index) )

qui équivaut à :

printf("Valeur de index = %dn" , (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.

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.

Les classes génériques fournissent un autre exemple d’utilisation pratique des macros en C++.

Par Missa Dioma - Publié dans : Informatique
Ecrire un commentaire - Voir les 0 commentaires
Mercredi 21 novembre 2007 3 21 /11 /Nov /2007 13:31

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é

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.

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

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 ?

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.

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 ?

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é

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.

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

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 ?

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();           nombre++;           }      ls.avance(reste);    // rétablir début      if (np) courant = np->suivant();      return *this; }  liste& listetab::operator=(liste&  ls) // copie la liste dans this {      supprime(nombre);      if ( !ls.nombre_elt() ||          !(tab = new element[ls.nombre_elt()]) )              return *this;      courant = tab;      nombre = ls.nombre_elt();      for (int i = nombre; i; i--) {          *courant++ = ls.valeur();          ls.avance();          }      courant = tab;      return *this; }

On notera que dans le cas de la classe listech par exemple, il faut définir un constructeur de copie pour un argument de liste& et un pour un argument listech&, bien qu’il y ait une conversion automatique de la seconde classe vers la première ; en effet, le premier constructeur avec son argument liste& n’est pas considéré comme un constructeur de copie par C++, puisqu’il n’inclut aucun argument de type listech& ; de ce fait, le compilateur fournit un constructeur implicite qui fait une copie membre à membre, ce qui n’est pas souhaitable ici. Le même raisonnement vaut pour listetab évidemment.

Remarquer également que la copie n’est pas parfaitement identique dans ses effets dans les deux cas, s’il n’y a pas assez de mémoire : listech recopie tout ce qui est possible, tandis que listetab ne recopie rien.

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

Lorsqu’il s’agit de méthodes, il est préférable de recouvrir les

Arbre de dérivation :

Il n’est pas permis de faire des cycles en cours de dérivation ; c’est-à-dire qu’une classe C ne peut pas hériter d’elle-même, ni directement, ni indirectement par l’intermédiaire d’une ou plusieurs autres classes.

Par contre, si deux classes A et B dérivent toutes deux d’une classe Z, on peut dériver une classe C de A et B ; la classe comprendra alors deux instances de Z. (Il faut alors utiliser l'opérateur de résolution de portée :: pour distinguer les membres hérités par l'intermédiaire de l'une ou l'autre.)

Assez étrangement, si une classe B dérive de A, on ne peut pas dériver une classe C de A et B ; le compilateur affiche une erreur (Error : 'A' is inaccessible because also in 'B', 'A' est inaccessible car également dans 'B').

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 :

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.

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

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

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.

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 :

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.

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' ENTRES-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 :

  • Les tampons d’entrées-sorties, divisés en trois classes streambuf, strstreambuf et filebuf.

  • Les flots d’entrées-sorties, que l’on peut répartir en quatre groupes :

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

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.

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 :

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

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

 

Par Missa Dioma - Publié dans : Informatique
Ecrire un commentaire - Voir les 0 commentaires
Mercredi 21 novembre 2007 3 21 /11 /Nov /2007 13:23

Opérateurs new et delete :

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

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

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

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 {      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/ HERITAGES :

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

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

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.

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 :

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

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

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

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 :

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.

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.changer est de type rectangle, la méthode appellera les fonctions rectangle::efface et 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 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

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.


 

Par Missa Dioma - Publié dans : Informatique
Ecrire un commentaire - Voir les 0 commentaires
Mercredi 21 novembre 2007 3 21 /11 /Nov /2007 13:13

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

formule

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      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 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.octets0xA0, 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 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];          }       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.

Solution de l’exercice 6.8 :

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

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

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.

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

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

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 }

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 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 num/den, 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 :

somme fractions

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 :

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

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 :

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 :

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

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.

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 ?

Solution de l’exercice 7.3 :

Il faut utiliser un objet intermédiaire :

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

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 ?

Solution de l’exercice 7.4 :

Il suffit d’écrire :

class liste {      element *tab, *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;      element *anccourant = l.courant;      l.avance(i);      element e = courant->contenu;      courant = anccourant;      return e; }

Si l’on n’avait pas pu accéder à la déclaration des classes, il aurait fallu utiliser la méthode valeur devenue indispensable pour accéder au contenu de la liste :

element operator[](liste& l, int i  = 0); // donne le i-ième élément de la liste après  courant {      l.avance(i);      element e = l.valeur();      l.recule(i);      return e; }

Cet opérateur est correct avec les deux versions de la liste, puisque celles-ci ont le même comportement extérieur, comme on l’a indiqué au chapitre 6.

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.

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

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 ?

Solution de l’exercice 7.5 :

Il suffit d’envisager une première classe exemple avec un opérateur d’affectation redéfini, puis une seconde classe autre, contenant un membre exemple ex. Dans ce cas, dans l’ancienne version, une affectation au1 = au2 recopiait en bloc les deux objets, donc recopiait en bloc les membres ex. Dans la nouvelle version, cette affectation provoquera, entre autres, un appel de exemple::operator=(au1.ex, au2.ex), ce qui provoquera une copie correcte des membres ex. Ce comportement est préférable car, dans l’ancienne version, il fallait penser à redéfinir l’opérateur d’affectation pour toutes les classes contenant des membres pour lesquels cet opérateur avait été redéfini. Ce n’est plus nécessaire désormais.

L’ancien comportement était plus rapide, mais source d’erreurs ; il posait aussi des problèmes avec l’héritage (voir chapitre 8).

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

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

 

Par Missa Dioma - Publié dans : Informatique
Ecrire un commentaire - Voir les 0 commentaires

Présentation

Créer un Blog

Recherche

Calendrier

Février 2012
L M M J V S D
    1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29        
<< < > >>
Créer un blog gratuit sur over-blog.com - Contact - C.G.U. - Rémunération en droits d'auteur - Signaler un abus