INTRODUCTION A C++

Christian Millour : chris161 chez club-internet.fr

Avertissement (07/11/2006)

Ce tutorial "avancé" proposé par Christian Millour est un peu ancien car il date d'avant la normalisation de la bibliothèque STL. Il est donc probablement nécessaire d'adapter les inclusions de bibliothèques pour faire tourner les exemples.
Il reste cependant très intéressant. Il propose en particulier quelques exemples petits mais complets et autonomes de classes (avec Makefile et test de chaque classe...) :
Version modifiée le 06/04/00 16h30 par maurice.diamantini--ensta.fr
    (suppression des exemples au format .zip pour ne garder que les .tgz)

Préambule

C++ est un langage très riche et polyvalent. De son héritage C il a conservé une certaine concision et la possibilité offerte de programmer au plus proche de la machine, éventuellement dans un style purement procédural. Dans le même esprit, il permet la définition de types de données nouveaux pratiquement indistinguables des types prédéfinis. Il supporte le polymorphisme dynamique et la programmation objet. Il permet une gestion élégante des erreurs et situations anormales grâce à la gestion d'exceptions. Enfin il supporte la programmation générique, dont la bibliothèque standard constitue une superbe illustration.

La maîtrise des différents mécanismes offerts par le langage requiert clairement du temps, des efforts et de la pratique. Cependant aucune de ces facilités n'est imposée au programmeur : à de rares exceptions près un programme C est un programme C++, et quiconque connaît C est immédiatement opérationnel en C++. De fait, pour un programmeur C une transition facile et indolore consiste à intégrer graduellement dans son code les constructions et mécanismes propres à C++, au fur et à mesure de ses besoins et de la progression de sa maîtrise du langage. Rien ne force par exemple à utiliser les exceptions, ou à suivre une approche de conception orientée objet. L'utilisation de la bibliothèque standard ne nécessite qu'une compréhension relativement superficielle de la programmation générique.

Lorsque j'ai moi même effectué cette transition de C à C++ j'ai été frappé de voir comment certaines constructions ou facilités de C++ me permettaient de suppléer des insuffisances de C dans la poursuite d'une règle que je m'étais fixé pour le développement et que l'on pourraît exprimer comme suit :

Point d'intervention unique : toute modification, qu'il s'agisse de la valeur d'une constante, du type d'une donnée ou d'un frament d'algorithme, doit pouvoir être effectuée en un point et un seul et se propager automatiquement au reste du code, quelle que soit la taille ou la complexité du projet concerné.
Cette règle semble relever du bon sens, surtout lorsqu'on s'est colleté avec des problèmes de maintenance, mais sa mise en oeuvre rigoureuse ne va pas forcément de soi. Je crois être venu à la conception objet en grande partie pour y adhérer. Mais c'était après l'avoir exploitée dans des cas plus simples.

Cette expérience a orienté la structure de ce document. Après quelques rappels on commencera par évoquer en quoi en comment C++ peut être utilisé comme un meilleur C. On abordera ensuite successivement les notion de type utilisateur, de polymorphisme dynamique (la forme la plus courante de la programmation dite orientée objet), et enfin  de programmation générique. 
 

Rappels

Organisation mémoire

La figure ci-dessous présente une subdivision typique de la mémoire lors de l'exécution d'un programme. La région Code reçoit le code exécutable. La région Static Data reçoit typiquement les données globales. La région Stack (la pile) accueille les variables temporaires et les données nécessaires à l'exécution des appels de fonctions. Enfin la région Heap (le tas) accueille les autres données, en particulier les blocs alloués dynamiquement.

Chaque variable ou zone de données utilisée dans un programme réside dans l'une ou l'autre de ces régions :

int i;                         // variable globale (Static Data)
int* ip;                       // variable globale (Static Data)
int it[10];                    // tableau global (Static Data)
int f(int n, double d) {          // n et d résideront dans la pile (Stack)
    int m;                        // variable locale (Stack)
    ip = (int*)malloc(n*sizeof(int)); // la zone mémoire est allouée dans
                                      // le tas. Son adresse est stockée
                                      // dans ip.
    ....
}

Appel de fonctions

En C comme en C++, l'exécution d'un appel de fonction donne normalement lieu à l'insertion sur la pile d'un enregistrement d'activation, ou frame, dont la forme générale est la suivante :

Les valeurs temporaires, telles que celles apparaissant lors de l'évaluation d'expressions, peuvent être placées dans la région temporaries. Les variables locales peuvent être placées dans la région local data. La région saved machine status peut recevoir des informations relatives à l'état de la machine juste avant l'invocation de la fonction, telles que la valeur de certains registres ou celle du compteur de programme, qui devront être restaurées au retour de la fonction. La région access link peut faire référence à des données non locales résidant dans d'autres enregistrements d'activation. La région control link pointe sur l'enregistrement d'activation de la fonction appelante. La région actual parameters recoit les valeurs effectives des paramètres utilisés lors d'un appel spécifique, tandis que la région returned value est utilisée pour retourner une valeur à la fonction appelante.

La forme effective de cet enregistrement dépend du compilateur et du niveau d'optimisation. En pratique, la plupart de ces régions peuvent être vides, les informations correspondantes étant passées via des registres. Cette représentation est donc assez théorique, mais permet de se faire une idée de l'origine du coût lié à un appel de fonction.

C++ comme un meilleur C

Commentaires

Comme en C, toute zone de texte encadrée par /* et */ est un commentaire. En outre, C++ considère de plus comme un commentaire tout texte compris entre // et la fin de la ligne :
/* commentaire simple C */
/* commentaire
    multiligne
    de style C */
// commentaire de style C++
Si des commentaires apparaissent dans des directives du préprocesseur, il vaut généralement mieux les exprimer selon le style C :
#if defined(FOO)
...
#endif /* defined(FOO) */

Déclaration et initialisation

En C, les déclarations de variables se font impérativement en début de bloc. On peut initialiser une variable au moment de sa déclaration mais seulement par une constante manifeste (en particulier les appels de fonctions sont exclus). En conséquence, il est fréquent que la première utilisation d'une variable soit relativement distante dans le code de son point de déclaration.
void f(int angle) {
    int i = 0, j;
    char* msg = "hello, world\n";
    double d;
    ...
    j = strlen(msg);
    d = sin(angle);
    for (j = 0; j < i ; j++ ) ...
    ...
}
En C++, les déclarations sont des instructions comme les autres, et les valeurs d'initialisation peuvent être des expressions quelconques. Ceci permet de ne déclarer les variables qu'au moment 1)  où on en a besoin, et 2) où on peut les initialiser:
void f(int angle) {
    int i = 0;
    char const * msg = "hello, world\n";
    int j = strlen(msg);
    double d = sin(angle);
    ...
}
En effet les variables non-initialisées peuvent être source de problèmes et donc à éviter. Ce principe a conduit à enrichir la forme de l'instruction for : dans la plupart des cas en effet la variable utilisée comme compteur de boucle ne présente d'intérêt que pour la boucle elle même. On peut alors la déclarer comme suit:
for (int i = 0; i < n; ++i) {
    ... // utilise i
}
// i indéfini ici
de même
if (Foo * fp = make_foo(...)) {
    ... // utilise fp
} else {
    ... // fp indéfini ici
}
... // fp indéfini ici
en supposant que make_foo(...) retourne 0 (pointeur nul) en cas d'échec, et un pointeur non nul en cas de succès, le corps du if ne sera exécuté qu'en cas de succès.

Plus besoin de typedef pour les struct

Etant donné
struct P2 { int x, y; };
en C toute référence à P2 doit se faire par son nom complet, à savoir struct P2 :
struct P2 f(struct P2 p);   /* style C */
en C++ on peut omettre le mot struct.
P2 f(P2 p);           // C++
ce qui permet de s'affranchir de l'idiome classique de C
typedef struct sP2 { int x, y; } P2; // plus nécessaire

Passage de paramètres par valeurs

En C comme en C++, le passage de paramètres aux fonctions se fait normalement par valeur : la fonction appelée reçoit une copie des paramètres passés par la fonction appelante, qu'elle peut modifier librement sans rien changer du contexte de l'appelant :
void punition(int n) {
    for ( ; n > 0; --n)
       printf("je ne ferais plus l'andouille en cours\n");
    /* à ce point n vaut 0 */
}

int main() {
    int nlignes = 100;
    punition(nlignes);
    /* à ce point nlignes vaut toujours 100 */
}

Pour les objets de type struct cette copie peut s'avérer relativement coûteuse :
struct Matrice { double a11, a12, a13, a21, a22, a23, a31, a32, a33; };
double determinant(struct Matrice m) {
    ...
}
lors d'un appel à determinant la copie de l'argument résulte effectivement en une copie de 9 double sur la pile.

Références et passage de paramètres par référence

Il n'existe pas en C d'équivalent direct au passage de paramètres par variable de Pascal. Si on veut qu'une fonction C modifie son argument, il faut lui passer un pointeur contenant l'adresse de l'objet à modifier :
void incr(int* ip) { /* version C */
    *ip += 1;
}

void swap(int* ap, int* bp) { /* version C */
    int tmp = *ap; *ap = *bp; *bp = tmp;
}

de telles  fonctions s'utilisent comme suit :
int i = 2, j = 0;
incr(&i);
/* --> i vaut maintenant 3 */
swap(&i,&j);
/* --> i vaut maintenant 0 et j vaut 3 */
C++ introduit un nouveau type, référence vers T, noté T&. Une référence à un objet s'utilise comme l'objet initial, c'est simplement un autre nom qui permet de le désigner. On peut utiliser les références pour implémenter un passage d'arguments par variable (on parlera alors de passage par référence) et réécrire les fonctions ci-dessus sous la forme
void incr(int& ip) { // version C++
    i += 1;
}

void swap(int& a, int& b) { // version C++
    int tmp = a; a = b; b = tmp;
}

et les utiliser ainsi :
int i = 2, j = 0;
incr(i);
// --> i vaut maintenant 3
swap(i,j);
// --> i vaut maintenant 0 et j vaut 3
Les références sont proches des pointeurs, et sont de fait la plupart du temps implémentées par des pointeurs. Une référence est en quelque sorte homogène à un pointeur déréférencé. On peut déclarer des variables de type référence, mais contrairement aux pointeurs on est alors obligé de les initialiser :
int i = 2;
// int& ir;    // ILLEGAL
int& ir = i;   // OK, ir désigne i
int j = ir;    // j vaut maintenant 2
ir = 3;        // modifie i
int* jp = &j;
int& jr = *jp; // OK, jr désigne j

int* kp;       // DANGEREUX (kp est indéfini)
int& kr = *kp; // légal, mais comportement non spécifié à l'exécution

L'utilisation d'une référence mal initialisée comme ci-dessus relève selon la norme d'un undefined behavior (comportement indéfini à l'exécution), ce qui veut dire que le code généré peut faire n'importe quoi (par exemple reformater votre disque dur).

Passage de paramètres par référence à const

En C, le passage par pointeur ne sert pas qu'au passage par variable. Il est également utilisé pour éviter la copie. Par exemple
typedef struct sMatrice {
    double a11, a12, a21, a22;
} Matrice;

double determinant(Matrice * mp) {
    return mp->a11 * mp->a22 - mp->a21 * mp->a12;
}

En C++ on peut écrire
struct Matrice {
    double a11, a12, a21, a22;
};

double determinant(Matrice const & m) {
    return m.a11 * m.a22 - m.a21 * m.a12;
}

On parle alors de passage par référence à const, ou par const référence. Le mot clé const signifie ici que l'objet passé en paramètre n'est pas modifié lors de l'exécution de la fonction. Ceci est vérifié par le compilateur :
void f(Matrice const & m) {
    m.a11 = 0; // illégal : on tente de modifier m !
};
En C++, le passage par valeur est utilisé le plus souvent pour les types prédéfinis, dont la copie est peu coûteuse. Les struct sont généralement passés soit par référence, soit par référence à const.

Surcharge

En C, une fonction est repérée (au niveau de l'éditeur de liens) uniquement par son nom. Cela signifie qu'il ne peut exister qu'une seule fonction portant un nom donné. Par exemple si on utilise le nom min pour la fonction qui calcule le minimum de deux int, il faudra en trouver un autre, par exemple min_f, pour celle qui calcule le minimum de deux float, et un autre pour celle qui calcule celui de deux double, etc. comme dans l'exemple min.c suivant :
int min(int a, int b) {
    return (a < b) ? a : b;
}
float min_f(float a, float b) {
    return (a < b) ? a : b;
}
double min_d(double a, double b) {
    return (a < b) ? a : b;
}
Si l'on compile ce fichier et que l'on énumère les synboles externes générés (qui seront utilisés par l'éditeur de liens pour accéder aux fonctions) on voit bien que ces symboles ne tiennent compte que du nom de la fonction :
$ gcc -c min.c && nm --extern-only min.o
00000000 T _min
0000003c T _min_d
00000018 T _min_f
$
En C++, une fonction est repérée par sa signature qui prend en compte (entre autres, voir ci-après) non seulement le nom de la fonction mais également le nombre et le type de ses paramètres (le type de retour ne participe pas à la signature). En particulier, dans l'exemple précédent on n'est plus obligé de donner un nom différent aux fonctions qui calculent le minimum de deux int, deux float, deux double, etc. et une version C++ peut prendre la forme min.cc suivante :
int min(int a, int b) {
    return (a < b) ? a : b;
}
float min(float a, float b) {
    return (a < b) ? a : b;
}
double min(double a, double b) {
    return (a < b) ? a : b;
}
L'inspection des symboles produit par la compilation montre qu'ils encodent effectivement le nombre et le type des paramètres :
$ g++ -c min.cc && nm -n --extern-only min.o
00000000 T _min__Fii
0000001c T _min__Fff
00000044 T _min__Fdd
$
Cet encodage est connu sous le nom de name mangling. Il vise à la compacité et a vocation à être généré et interprété informatiquement. Les outils fournissent généralement une traduction plus explicite pour l'utilisateur:
$ nm -n --extern-only --demangle min.o
00000000 T min(int, int)
0000001c T min(float, float)
00000044 T min(double, double)
$
Outre son nom et le type de ses paramètres, la signature d'une fonction comprend également s'il y a lieu l'espace de nommage (namespace) dans laquelle elle est définie, et la classe à laquelle elle appartient s'il s'agit d'une fonction membre. On reviendra dans la suite sur ces points.

Fonctions en ligne (inline)

Le mot clé inline dans la déclaration d'une fonction est une directive au compilateur pour qu'il émette si possible le code de la fonction en ligne, en lieu et place de l'appel fonctionnel tel que décrit précédemment. A la différence de l'expansion d'une macro, les paramètres sont évalués une foi et une seule, comme pour un
appel de fonction classique.
#define MIN(a,b) (((a) < (b)) ? (a) : (b))
z = MIN(f(x), f(y)); // génère
                     //    z = (((f(x)) < (f(y))) ? (f(x)) : (f(y)))

inline int min(int a, int b) { return a < b ? a : b; }
z = min(f(x), f(y)); // génère un code équivalent à
                     //    int tmp1 = f(x);
                     //    int tmp2 = f(y);
                     //    z = tmp1 < tmp2 ? tmp1 : tmp2;

Le compilateur n'est pas obligé d'honorer cette directive, et peut donc émettre un appel de fonction quand même. En pratique son usage doit être réservé à des fonctions simples.

Patrons (template)

Considérons les trois fonctions suivantes :
inline void swap(int& a, int& b) {
    int tmp = a; a = b; b = tmp;
}
inline void swap(float& a, float& b) {
    float tmp = a; a = b; b = tmp;
}
inline void swap(double& a, double& b) {
    double tmp = a; a = b; b = tmp;
}
Leur code est identique, à l'exception du type de données manipulées. Le mécanisme des template permet de n'écrire ce code qu'une fois :
template <class T>
inline void swap(T & a, T & b) {
    T tmp; tmp = a; a = b; b = tmp;
}
à chaque invocation de swap le compilateur génèrera (si nécessaire) et utilisera la version appropriée, dont le nom est swap<T> pour le type T.
int i, j;
double f, g;
...
swap(i,j); // invoque swap<int>
swap(f,g); // invoque swap<double>
On peut si nécessaire expliciter la version employée :
template <class T>
inline T min(T a, T b) { return a < b ? a : b; }

int i, j;
double d;
...
i = min(i,j);          // OK, invoque min<int>
d = min(i,d);          // ERREUR
d = min((double)i, d); // OK, invoque min<double>
d = min<double>(i, d); // OK (i est converti en double)
i = min<int>(i,d);     // OK (d est converti en int)

L'utilisation des patrons ne se limite pas aux fonctions, et les paramètres ne sont pas forcément des types :
// définit un tuple de N données élémentaires de type T
template <class T, int N>
struct Tuple {
    T rep[N];
};

Tuple<float,3> f;
Tuple<int, 2> g;

// carré de la norme d'un Tuple<T,N>
template <class T, int N>
T sqnorm(Tuple<T,N> const & x) {
    T res = 0;
    for (int i = 0; i < N; ++i) res += x.rep[i];
    return res;
}

Les template peuvent coexister avec des fonctions :
template <class T>                  // version générique
inline T min(T a, T b) {
    return a < b ? a : b;
}

inline bool min(bool a, bool b) {   // autre algoritme pour les booleens
    return a & b;
}

Dans ce cas la fonction sera préférée au template lorsqu'elle sera applicable.

Il est par ailleurs possible de spécialiser un template pour un jeu de paramètres particuliers. On y reviendra dans la suite.

En finir avec les macros ?

En C l'utilisation des macros est généralement motivée par deux types de considérations : Malheureusement le préprocesseur ne réalise qu'une substitution textuelle, ce qui peut provoquer des résultats parfois surprenants. Une erreur classique des débutants consiste à ne pas parenthéser suffisamment les expressions produites : Mais même avec les parenthèses adéquates on peut avoir des problèmes lorsque les expressions utilisées comme argument ont des effets de bord : Enfin même en l'absence d'effets de bords, l'utilisation de macros peut s'avérer anti-productive : Dans l'expression ci-dessus, f est invoquée trois fois alors que deux fois suffisent.

En C++, les template et la directive inline offrent des alternatives qui satisfont aux exigences précitées d'efficacité et de généricité, et on y utilise normalement beaucoup moins les macros qu'en C.

Fonctions membres

En C++ on peut définir des fonctions membres à l'intérieur des struct.
struct Complex {
    double real, imag;           // données membres
    double magnitude() const {   // fonction membre const
        return hypot(real, imag);
    }
    void scale_by(double d) {    // fonction membre non const
        real *= d;
        imag *= d;
    }
    void set_real(double real) {
        this->real = real;
    }
    ...
};
Ces fonctions membres peuvent manipuler directement les données membres de l'instance. A l'intérieur d'une fonction membre, le mot-clé this a pour type "pointeur sur l'instance", soit Complex* dans l'exemple ci-dessus. Il peut être utilisé si nécessaire pour lever des ambiguités lors d'accès aux données membres (cas de set_real ci-dessus). En dehors de ce cas on peut manipuler les données membres en utilisant simplement leur nom.

On accède aux fonctions membres avec une syntaxe analogue aux données membres :

Complex c;
c.real = 1.0;
c.imag = 2.0;
Complex *cp = &c;

double r = c.real;          // notation pointée (objets et références)
double m = c.magnitude();
c.scale_by(2.0);

r = cp->real();             // notation fléchée (pointeurs)
m = cp->magnitude();
cp->scale_by(2.0);

Le qualificatif const pour une fonction membre indique que son exécution ne modifie pas l'état de l'instance courante. C'est le cas pour magnitude ci-dessus, qui se contente de lire les valeurs des données membres. Par contre, scale_by les modifie, elle ne peut donc pas être const. Une fonction membre const peut être invoquée que l'instance soit const ou non. Par contre seules les fonctions membres const peuvent être invoqués pour des instances constantes, qu'il s'agisse effectivement d'objets ou de paramètres de fonctions :
extern const Complex c; // peut être en ROM
    ...
    double m = c.magnitude(); // OK, instance const et fonction membre const
    c.scale_by(2.0);          // ILLEGAL, instance const et fonction membre non const

void f(Complex const & x) {
    double m = c.magnitude(); // OK, instance const et fonction membre const
    c.scale_by(2.0);          // ILLEGAL, instance const et fonction membre non const
}

Pour distinguer les fonctions membres des autres, leur nom complet s'écrit avec un préfixe qui est le nom de leur classe, suivi de l'opérateur de portée ::. Lorsque la définition de la fonction n'est pas donnée dans le corps de la classe, il faut utiliser son nom complet :
struct Complex {
    double real, imag;           // données membres
    int magnitude() const;       // déclaration de fonction membre
    void scale_by(double d);     // déclaration de fonction membre
    ...
};

double Complex::magnitude() const {     // définition
    return hypot(real, imag);
}

void Complex::scale_by(double d) {      // définition
    real *= d;
    imag *= d;
}


Une fonction membre définie au moment de sa déclaration dans le corps de la classe est implicitement inline.

A ce stade, l'intérêt de définir et d'utiliser une fonction membre plutôt qu'une fonction libre n'est pas flagrant :

double magnitude(Complex const & c) {
   return hypot(c.real, c.imag);
}
m = magnitude(c);   // invocation de la fonction libre
m = c.magnitude();  // invocation de la fonction membre
On verra cependant que les fonctions membres sont essentielles pour l'encapsulation, et indispensables au polymorphisme.

Entrées/sorties formatées avec les iostream

En C les entrées/sorties formatées se font via la famille de fonctions printf / scanf de la bibliothèque standard, qui prennent comme paramètre une spécification de format :
#include <stdio.h>
int i;
float f;
...
printf("la valeur de i est %d, celle de f est %f", i, f);
Un invénient de ces fonctions est que si on modifie le type d'une variable, il faut également modifier la spécification de format. Par ailleurs ce schema n'est pas extensible aux struct.

Les iostream de C++ permettent d'effectuer des entrées/sorties formatées sans avoir à spécifier de format, grâce aux opérateurs d'insertion << et d'extraction >>. On peut insérer sur un ostream (écriture) et extraire d'un istream (lecture). C++ prédéfinit deux ostream, cout et cerr, qui correspondent respectivement à stdout et stderr de C, et un istream , cin, qui correspond à stdin.  Ils s'utilisent comme suit :

cout << "entrez un nombre : ";
int i;
cin >> i;
cout << "vous avez saisi la valeur " << i << endl;
Ce mécanisme est extensible aux struct : il suffit de définir les fonctions appropriées. Celles-ci ont pour nom operator << pour l'insertion, et operator >> pour l'extraction. Ce sont des fonctions libres.
#include <iostream>
#include <cmath> /* hypot */
struct Pt { int x, y; };
ostream & operator << (ostream & os, Pt const & p) { // opérateur d'insertion pour les Pt
    return cout << "[" << p.x << "," << p.y << "]";
}
int main() {
    Pt p; p.x = 3; p.y = 4;
    cout << "le point " << p << " est à la distance "
         << hypot(p.x, p.y) << " de l'origine" << endl;
    // génère la ligne suivante sur la sortie standard :
    // le point [2,3] est à la distance 5 de l'origine
}
Cette uniformité de traitement de tous les types vis à vis de l'insertion ou de l'extraction s'avère très pratique à l'usage. En particulier, elle permet l'écriture de macros comme celle ci :
#define SHOW(X) cout << #X << " = " << (X) << endl
    ...
    int a = 2, b = 3;
    Pt p; p.x = 1; p.y = 2;
    SHOW(a);    // génère la ligne   a = 2       sur la sortie standard
    SHOW(a+b);  // génère la ligne   a+b = 5     sur la sortie standard
    SHOW(p);    // génère la ligne   p = [1,2]   sur la sortie standard
Par ailleurs, il existe différents types d'iostream : les fstream qui travaillent sur des fichiers, les strstream qui travaillent sur des chaînes de caractères, etc. Chacun peut se définir ses propres variantes d'iostream, soit en variant la source ou la destination des caractères (fichiers, chaînes de caractères, sockets...) soit en variant le comportement (par exemple impression préfixée par un numéro de ligne, ou lecture sautant les commentaires). Les opérateurs << et >> fonctionneront avec tous les types d'iostream.

Constructeurs

On a vu au début de cette section qu'une des qualités de C++ est qu'il permet d'initialiser les variables au moment de leur déclaration. Or pour les objets dont le type est un struct, les phases de déclaration et d'initialisation sont jusqu'à présent restées distinctes :
struct P2 { double x, y; };
P2 p;                  // déclaration
p.x = 2.0; p.y = 3.0;  // initialisation
Pour pallier ce problème, C++ permet l'introduction de fonctions membres un peu particulières, les constructeurs. Ils portent le même nom que le struct auquel ils appartiennent, et n'ont pas de type de retour. Grâce à la surcharge, on peut en définir autant que nécessaire, pourvu que leurs signatures diffèrent :
struct P2 {
    double x, y;
    P2(double xx, double yy) { // constructeur à deux paramètres
        x = xx;
        y = yy;
    }
    P2(double diag) {          // constructeur à un paramètre
        x = y = diag;
    }
    P2() {                     // constructeur par défaut (sans paramètre)
        x = y = 0;
    }
    P2(P2 const & rhs) {       // constructeur de copie
        x = rhs.x;
        y = rhs.y;
    }
};

P2 p(2,3);   // utilisation du constructeur à deux paramètres P2::P2(double, double)
P2 q(1);     // utilisation du constructeur à un paramètre P2::P2(double)
P2 r;        // utilisation du constructeur par défault P2::P2()
P2 s(p);     // utilisation du constructeur de copie P2::P2(P2 const &)
P2 t[4];     // chaque élément du tableau est initialisé par le constructeur par défaut

Que se passe-t-il lorsque les membres à initialiser sont eux-mêmes des structs dotés de constructeurs ? Considérons
struct L2 {
    P2 a, b;
    L2(P2 const & aa, P2 const & bb) {
        a = aa; b = bb; // inefficace
    }
    ...
};
Cette écriture est inefficace. En effet, au moment où on rentre dans le corps du constructeur, les sous-objets a et b ont déjà été initialisés, en invoquant leur constructeur par défaut. Le travail éventuellement effectué par ce dernier va être immédiatement annulé par les affectations de a et b. Pour pallier ce problème, C++ offre une syntaxe alternative qui permet l'initialisation directe des membres avant l'entrée dans le corps du constructeur proprement dit:
struct L2 {
    P2 a, b;
    L2(P2 const & aa, P2 const & bb)
        : a(aa), b(bb) // construction directe de a et b par P2::P2(P2 const &)
    {
                       // le corps du constructeur est vide
    }
    ...
};
Lorsque les données membres sont des types prédéfinis, la méthode d'initialisation utilisée n'a pas d'importance. Cependant par souci d'homogénéité on préfère utiliser systématiquement la seconde, ce qui conduit par exemple a réécrire comme suit les constructeurs de P2
struct P2 {
    double x, y;
    P2(double xx, double yy) : x(xx), y(yy) {}
    P2(double diag) : x(diag), y(diag) {}
    P2() : x(0), y(0) {}
    P2(P2 const & rhs) : x(rhs.x), y(rhs.y) {}
};


Lorsque le constructeur par défault existe, les initialisations exprimées comme une affectation sont normalement optimisées en un appel direct au constructeur adéquat :

P2 u = 1.0;  // utilisation du constructeur à un paramètre P2::P2(double)
P2 v = u;    // utilisation du constructeur de copie P2::P2(P2 const &)

Destructeur

Les constructeurs permettent d'initialiser un objet au moment de sa création. On peut de même vouloir effectuer certaines opérations (nettoyage, libération de ressources) au moment ou l'objet disparaît. C'est le rôle du destructeur. C'est encore une fonction membre un peu particulière, sans valeur de retour, et dont le nom est égal au nom du struct auquel il appartient précédé du caractère ~.
struct Foo {
    ...
    ~Foo() { ... } // destructeur
};
Il n'existe qu'un seul destructeur et il ne prend pas de paramètres. Il est invoqué automatiquement par le compilateur lorsque l'objet disparaît.

Affectation

On peut également définir la sémantique exacte d'affectation des structs. Par défaut, elle se résoud en une copie membres à membres. On peut la modifier ou l'enrichir :
#include <cstring> // strdup()
#include <cstdlib> // free()
struct NaiveString {
    char const * ptr;
    NaiveString(char const * p) : ptr(strdup(p)) {} // constructeur à un paramètre
    ~NaiveString() { free(p); }
    NaiveString const & operator = (NaiveString const & rhs) {
        if (this != &rhs) { // protection contre l'auto-affectation
            free(ptr);
            ptr = strdup(rhs.ptr);
        }
        return *this;
    }
    ...
};
Dans l'exemple ci-dessus, NaiveString est une esquisse d'implémentation d'un type utilisateur "chaîne de caractères" de type C (NTBS, ou Null-Terminated Byte Strings) et utilisant les fonctions C de manipulation de NTBS. Une NaiveString a pour représentation une NTBS dont elle est propriétaire : notez que la chaîne passée au constructeur est copiée, et qu'elle est libérée lors de la destruction. L'opérateur d'affectation des NaiveString est la fonction membre avec le nom un peu bizarre "operator =". Etant données
NaiveString a("Hello");
NaiveString b("World");
l'affectation de b à a s'exprime simplement
a = b;
mais elle est vue par le compilateur comme
a.operator = (b);
On note que operator =  retrourne une référence à l'objet en partie gauche de l'affectation (son adresse est par définition this, donc l'objet lui même ou une référence à l'objet est obtenue en déréférençant ce pointeur, soit *this). Ceci permet d'écrire des expressions telles que
a = (b = c); // réellement a.operator = (b.operator = (c));
Notez la protection contre l'auto-affectation, qui tend à se produire plus fréquemment qu'on ne le croit. Par exemple étant donnée
NaiveString const & min(NaiveString const & lhs, NaiveString const & rhs) {
    return (strcmp(lhs.ptr, rhs.ptr) < 0) ? lhs : rhs;
}
L'expression
a = min(a,b);
peut résulter en une auto-affectation. Dans ce cas non seulement on peut ne rien faire, mais il faut surtout ne rien faire ! Si on libérait ptr par free avant d'effectuer le strdup, on risquerait une erreur.

Allocation dynamique

En C, l'allocation dynamique est réalisée par la famille de fonctions malloc, calloc, realloc, free, etc. qui allouent (ou libèrent) des zones de mémoire "brute", à charge pour l'utilisateur de spécifier une taille adéquate et de transtyper le résultat :
struct P2 *p = (struct P2 *)malloc(sizeof(struct P2)); /* allocation d'un P2 */
struct P2 *t = (struct P2 *)malloc(sizeof(struct P2) * N); /* allocation d'un tableau de N P2 */
En C++, l'allocation est effectuée par operator new pour les objets, et operator new[] pour les tableaux. Dans le cas de objets, on peut spécifier des paramètres passés au constructeur ; dans le cas de tableaux le constructeur par défaut est utilisé pour chaque élément.
P2* p = new P2;      // allocation, puis initialisation par le constructeur par défaut
P2* q = new P2(1,2); // allocation, puis initialisation par un constructeur
P2* t = new P2[N];   // allocation d'u tableau de N P2, tous initialisés par le constructeur par défaut
La libération s'effectue par operator delete pour les objets, operator delete [] pour les tableaux. Le destructeur est invoqué pour chaque instance ou élément du tableau.
delete p;
delete q;
delete [] t;
Il existe plusieurs variantes de operator new. L'utilisateur peut même se définir ses propres versions (par exemple pour allouer des objets dans une certaine zone, ou pour imposer un certain mode d'allocation pour certains struct).

Membres statiques

Le mot clé static, utilisé lors de la déclaration de données ou fonctions membres, permet de définir des membres partagés par l'ensemble des instances d'un type struct donné. On peut par exemple définir une donnée membre statique qui compte le nombre d'instances existantes à un moment donné :
struct Counted {
    static int count;
    Counted() { ++count; ... }
    Counted(Counted const & r) { ++ count; ... }
    ~Counted() { --count; ... }
    ...
};
A chaque fois que l'on construit (resp. détruit) un Counted, on incrément (resp. décrémente) Counted::count, qui est partagé par toutes les instances. La variable Counted::count doit être définie de manière unique dans un fichier compilé (typiquement la déclaration ci-dessus résiderait dans un fichier d'entête Counted.hh, dont le fichier d'implémentation Counted.cc contiendrait entre autres
#include "Counted.hh"
int Counted::count = 0;
...

Types utilisateur

En C, on ne peut guère manipuler que les types prédéfinis (entiers, flottants, caractères, pointeurs, ...), plus des agrégats simples (structs). C++ offre la possibilité de définir de nouveaux types, que l'on peut manipuler aussi facilement que les types prédéfinis.

Encapsulation : class, public:, private:, friend

Avec les fonctions membres et les constructeurs on a commencé à enrichir les possibilités des struct. Cet enrichissement est tel qu'il mérite l'introduction d'un nouveau mot clé, class, que l'on utilisera à la place de struct, réservant ce dernier aux struct C. Par ailleurs on parlera de classes pour qualifier les types ainsi définis.

La seule différence entre struct et class est l'accessibilité par défaut des membres (données ou fonctions). Il existe trois niveaux d'accessibilité :

Exemples :
class Foo {
    int a, b;
public:
    Foo(int, int);
    void bar();
private:
    void baz();
    Foo();
friend ostream & operator << (ostream & os, Foo const & f);
};

ostream & operator << (ostream & os, Foo const & f) {
    return os << f.a   // OK : bien que operator<<(ostream&, Foo const &)
              << " "   // soit une fonction libre, elle est amie de Foo
              << f.b;  // et peut donc accéder aux membres privés a et b
}

    ...
    Foo f(1,2);    // OK, constructeur public
    f.bar();       // OK, fonction membre publique
    f.baz();       // ILLEGAL ! fonction membre privée
    Foo g;         // ILLEGAL ! le constructeur par défaut est privé
    Foo t[4];      // ILLEGAL ! nécessite d'accéder au constructeur par défaut

Le mot clé friend permet d'accorder à des classes ou fonctions les mêmes droits d'accès aux membres que la classe elle même.

Une utilisation classique de ces protections est d'interdire la manipulation directe des données membres, en forçant le passage par des accesseurs ou modifieurs. Cela peut permettre le contrôle systématique d'invariants lors des modification. Cela isole par ailleurs l'utilisateur des détails d'implémentation.

template <class _FLT>
class complex
{
public:
  complex (_FLT r = 0, _FLT i = 0): re (r), im (i) { }
  /* ... */
  _FLT real () const { return re; }
  _FLT imag () const { return im; }
private:
  _FLT re, im;
  /* ... */
};
Avec la définition ci-dessus, on ne peut pas modifier un complex, seulement en construire un. Par contre on peut accéder en lecture à sa partie réelle ou sa partie imaginaire.  Une implémentation alternative pourraît être employée (par exemple stockant le module et la phase) sans que le code client ait à être modifié.

Passage par référence et temporaires

Un exemple complet : arithmétique d'intervalle

L'ensemble du projet est disponible au format tgz (tar compressé avec gzip).

Introduction

Un intervalle [l,h] peut être considéré comme un type arithmétique, à condition de le pourvoir d'opérations appropriées, par exemple
[a,b] + [c,d] = [a+c,b+d]
[a,b] - [c,d] = [a-d,b-c]
[a,b] * [c,d] = [min(ac,ad,bc,bd),max(ac,ad,bc,bd)]
[a,b] / [c,d] = [a,b] * (1/[c,d])
          {  [1/d,1/c] si 0 < c
1/[c,d] = {  indéfini  si c <= 0 <= d
          {  [1/c,1/d] si d < 0
Un tel type numérique peut être utile pour propager des incertitudes dans des calculs. Par exemple, combien cela me coûtera-t-il d'acheter entre 3 et 5 kilos de cerises, pour un prix compris entre 15 et 25 francs le kilo ? En arithmétique d'intervalles, le résultat est donné immédiatement par un produit d'intervalles : [3,5] pour la quantité multiplié par [15,25] pour le prix au kilo donnent [45,125] et je vais donc dépenser entre 45 et 125 francs.  L'intérêt de l'arithmétique d'intervalles est évidemment plus évident lorsque les calculs effectués sont plus complexes... En pratique, pour un coût à peine double ou triple en moyenne du coût d'un calcul scalaire, elle offre un encadrement strict du résultat en fonction des marges d'erreurs sur les données ou les coefficients, et ceci quelle que soit la complexité des transformations effectuées. Ce n'est vraiment pas cher payer. Il y a bien entendu des limites : dans certains cas l'encadrement n'est pas aussi fin qu'on le désirerait. Si le sujet vous intéresse, consultez un bouquin d'analyse numérique.

Dans notre cas, on va donc s'attacher à définir un type intervalle, appelons le IV, et le pourvoir des opérations appropriées. On veut par exemple pouvoir écrire avec des IV comme avec des float

    IV a, b, c, d;                   float a, b, c, d
    ....                             ....
    a = (b + c) * d;                 a = (b + c) * d;

Manifestement, il va falloir un moyen d'expliquer au compilateur comment interpréter x + y si x et y sont des IV.

Mais au fait, qu'est ce que ça va être un IV ? En terme de données on a besoin d'une borne inférieure et d'une borne supérieure. En C, on pourrait créer une struct :

struct IV {
    double l; /* borne inférieure */
    double h; /* borne supérieurs */
};
c'est un début mais on n'a matérialisé qu'un état, reste à lui  ajouter un comportement (on veut faire des opérations entre IV !)

De l'importance des noms

Petite mais importante digression : avec ses 4 malheureuses lignes, la struct IV définie ci-dessus suffit à réveiller mon module interne de surveillance de qualité logicielle : UN SEUL POINT D'INTERVENTION EN CAS DE MODIFICATION. Mais qu'est ce que je pourrais bien vouloir modifier dans un cas si simple ? Ah, si, le type des bornes, c'est vrai. Si jamais je veux passer à des long double par exemple, il faudra que j'intervienne en deux points. Qu'à cela ne tienne, on va faire un typedef :
typedef Borne double;
struct IV {
    Borne l; /* borne inférieure */
    Borne h; /* borne supérieurs */
};
Cette approche présente deux défauts : On peut mitiger un peu le problème en inventant une discipline de nommage :
typedef IV_Borne double;
struct IV {
    IV_Borne l; /* borne inférieure */
    IV_Borne h; /* borne supérieurs */
};
mais ce n'est pas très esthétique : on ne devrait pas avoir besoin du préfixe IV_ à l'intérieur même de la définition de IV (les redondances sont souvent aussi nuisibles que les répétitions). De plus on consomme quand même un nom global.

Non, ce qu'il faudrait en fait, c'est que le typedef soit local au type IV. Surprise ;-) , on peut justement le faire en C++ :

struct IV {
    typedef Borne double;
    Borne l;             // borne inférieure
    Borne h;             // borne supérieurs
};
Du coup, son nom complet devient IV::Borne, sauf que l'on a pas besoin de l'utiliser sous cette forme à l'intérieur même de la déclaration du struct (qui connaît ses déclarations propres).

Voilà une solution élégante : pas de pollution de l'espace global des noms, facilité de maintenance, et en prime pas besoin d'inventer de fastidieuse discipline de nommage. De plus elle se généralise : on pourraît par exemple écrire

struct Vect {
    struct Point {            // local à Vect
        typedef Coord double; // local à Point
        Coord x, y;
    };
    Point a, b;
  };
qui définit un type Vect, défini par deux Vect::Point, dont les coordonnées sont de type Vect::Point::Coord.

Le fichier d'entête IV.hh

Ce fichier va contenir l'ensemble des déclarations nécessaires pour qu'un client puisse utiliser le type IV. On constate en premier lieu que ce fichier est encadré par des directives du préprocesseur :
#ifndef IV_H
#define IV_H
...
#endif /* IV_H */
Ces directives permettent d'éviter les déclarations redondantes dans le cas ou ce fichier serait inclus plusieurs fois lors d'une compilation, ce qui se produirait par exemple dans le cas suivant : A la première inclusion de IV.hh, la variable IV_H est indéfinie. L'ensemble du corps du #ifndef est exécuté. Lors des tentatives ultérieures d'inclusion, la variable IV_H est définie, et le préprocesseur élimine le corps du #ifndef.

Le corps du fichier proprement dit est consacré à la déclaration de la classe IV, puis de fonctions libres conceptuellement associées à IV. Dans la partie publique, on reconnaît un typedef déjà discuté, une déclaration de classe locale (UndefinedInverse), le groupe constructeurs / opérateur d'affectation / destructeur, des fonctions membres const ( inverse(), operator -(), l(), h() ), des fonctions membres non-const (les opérateurs d'affectation arithmétiques). Dans la partie privée, on trouve une méthode à usage interne et la représentation de la classe. On note que certaines fonctions membres sont définies au moment de leur déclaration, ce qui les rend automatiquement inline. On aurait pu obtenir le même résultat en les définissant en dehors de la classe et en les qualifiant explicitement inline :

class IV {
  ...
  Rep l() const;
  ...
  IV const & operator /= (IV const & rhs);
  ...
};

inline IV::Rep IV::l() const { return m_l; }
inline IV const & IV::operator /= (IV const & rhs) { return operator *= (rhs.inverse()); }
...

On constate de même que certaines des fonctions libres qui suivent la classe sont définies en ligne. A l'intérieur d'un fichier d'entête, seules les fonctions inline peuvent être définies. En effet, si on donne la définition d'une fonction non-inline dans un fichier d'entête et que ce fichier est inclus dans plusieurs autres, l'éditeur de liens se plaindra de trouver plusieurs définitions pour la fonction.

On note que les opérations relationnelles et arithmétiques entre IV telles que operator+(IV const & lhs, IV const & rhs) sont déclarées comme des fonctions libres. Grâce à la surcharge ce n'est pas un problème. Elles font partie du périmètre sémantique de IV et il est normal qu'elles soient déclarées dans ce fichier d'entête.

Plus important, seuls les opérateurs d'affectation arithmétiques retournent une référence, tous les autres opérateurs arithmétiques retournent des IV par valeur, y compris les fonctions membres IV::operator -() et IV::inverse(). C'est pratiquement inévitable.

Le fichier de définition IV.cc

#include "IV.hh"
#include <algorithm> // swap
#include <iomanip>

IV::IV()
  : m_l(0)
  , m_h(0)
{
}

IV::IV(Rep m)
  : m_l(m)
  , m_h(m)
{
}

IV::IV(Rep l, Rep h)
  : m_l(l)
  , m_h(h)
{
  normalize();
}

IV::IV(IV const & rhs)
  : m_l(rhs.l())
  , m_h(rhs.h())
{
}

IV const & IV::operator= (IV const & rhs)
{
  //if (this != &rhs) {
  m_l = rhs.l();
  m_h = rhs.h();
  //}
  return *this;
}

IV::~IV()
{
}

IV IV::operator - () const
{
  return IV(-h(),-l());
}

IV IV::inverse() const
{
  if (*this == 0) throw UndefinedInverse();
  return IV(1/l(), 1/h());
}

IV const & IV::normalize()
{
  if (m_l > m_h)
    swap(m_l, m_h);
  return *this;
}

IV const & IV::operator += (IV const & rhs)
{
  m_l += rhs.l();
  m_h += rhs.h();
  return *this;
}

IV const & IV::operator -= (IV const & rhs)
{
  m_l -= rhs.h();
  m_h -= rhs.l();
  return *this;
}

namespace {
    template <class T> inline T min(T a, T b) { return (a < b) ? a : b; }
    template <class T> inline T max(T a, T b) { return (a > b) ? a : b; }
}

IV const & IV::operator *= (IV const & rhs)
{
  Rep tmp_l = min(min(l() * rhs.l(), l() * rhs.h()), min(h() * rhs.l(), h() * rhs.h()));
  Rep tmp_h = max(max(l() * rhs.l(), l() * rhs.h()), max(h() * rhs.l(), h() * rhs.h()));
  m_l = tmp_l;
  m_h = tmp_h;
  return *this;
}

bool operator == (IV lhs, IV rhs)
{
  return (lhs.l() == rhs.l()) && (rhs.h() == lhs.h());
}

IV operator+(IV const & lhs, IV const & rhs) {
  return IV(lhs) += rhs;
}

IV operator-(IV const & lhs, IV const & rhs) {
  return IV(lhs) -= rhs;
}

IV operator*(IV const & lhs, IV const & rhs) {
  return IV(lhs) *= rhs;
}

IV operator/(IV const & lhs, IV const & rhs) {
  return IV(lhs) /= rhs;
}

// si les bornes basse et haute diffèrent, produit
// le format 'low:high' sinon produit 'low'
ostream & operator << (ostream & os, IV const & x)
{
  os <<  x.l();
  if (x.l() != x.h())
    os << ":" << x.h();
  return os;
}

// pour faciliter les entrées, on admet deux formats :
// 'low:high' ou bien 'low'.
// Du coup, cette fonction est un peu compliquée.
// Si la lecture échoue, l'istream sera dans l'état
// 'fail' en sortie
istream & operator >> (istream & is, IV & x)
{
  IV::Rep l;
  if (is >> l) {
    char c;
    if (is >> c) {  // la lecture de c a réussi ...
      if (c == ':') {  // ... on est en format low:high
        IV::Rep h;
        if (is >> h)
          x = IV(l,h);
      } else {
        is.putback(c);
        x = IV(l);
      }
    } else {   // la lecture de c a échoué ...
      if (is.eof()) {  // ... mais ce n'est pas une erreur
                       //     si on est en fin de fichier
          is.clear();  // donc on restaure un état valide
          x = IV(l);
      }
    }
  }
  return is;
}

un anonymous namespace, ou espace de nommage anonyme, tel que
namespace {
  ...
}
permet de définir des variables, fonctions et classes locales à l'unité de compilation (le fichier .cc). En C ceci est réalisé avec le mot clé static, qui est applicable également en C++ mais ne s'applique qu'aux variables et fonctions, et donc pas aux classes. Le mécanisme ci-dessus est plus général et est donc la forme idiomatique à utiliser en C++.

Les opérateurs unaires sont définis à partir des opérateurs d'affectation arithmétique correspondants. On pourrait développer leur définition, par exemple

IV operator+(IV const & lhs, IV const & rhs) {
  IV res = lhs;
  res += rhs;
  return res;
}
mais la forme fournie, plus compacte et ne faisant pas intervenir de variables nommées, se prête mieux à certaines optimisations :
IV operator+(IV const & lhs, IV const & rhs) {
  return IV(lhs) += rhs;
}
La forme IV(lhs) crée un temporaire anonyme, dont on invoque la fonction membre operator += . Le résultat de cette invocation est copié pour fournir la valeur de retour de l'opérateur binaire.

Cette forme est intéressante dans la mesure ou la sémantique d'addition, qui doit apparaitre dans operator + et operator +=, n'est spécifiée qu'une seule fois (ce qui peut faciliter la maintenance ultérieure). Ceci dit, pour un cas aussi simple la forme suivante serait également tout à fait acceptable :

IV operator+(IV const & lhs, IV const & rhs) {
  return IV(lhs.l() + rhs.l(), lhs.h() + rhs.h());
}

Héritage non polymorphique

Considérons des points en deux et trois dimensions :
struct P2 {
    double x, y;
    P2(double x, double y) : x(x), y(y) {}
    double sqdist() const { // carré de la distance à l'origine
        return x*x + y*y;
    }
};

struct P3 {
    double x, y, z;
    P3(double x, double y, double z) : x(x), y(y), z(z) {}
    double sqdist() const { // carré de la distance à l'origine
        return x*x + y*y + z*z;
    }
};

Il doit y avoir un moyen de factoriser la partie commune entre ces deux types. Une première approche consisterait à dire qu'un P3 "contient" un P2 :
struct P3 {
    P2 xy;
    double z;
    P3(double x, double y, double z) : xy(x,y), z(z) {}
    double sqdist() const { // carré de la distance à l'origine
        return xy.sqdist() + z*z;
    }
};
mais cette approche est dans ce cas un peu fastidieuse à l'usage :
P3 a(1,2,3);
a.xy.x = 2;        // lourd
cout << a.xy.y;    // lourd
Une alternative consiste à dire qu'un P3 "est une sorte de" P2 :
struct P3 : public P2 {
    double z;
    P3(double x, double y, double z) : P2(x,y), z(z) {}
    double sqdist() const { // carré de la distance à l'origine
        return P2::sqdist() + z*z;
    }
};
On dit alors que P3 hérite de P2, ou P3 est dérivé de P2, ou P2 est une classe de base de P3.

Du point de vue occupation mémoire, les deux approches sont équivalentes :

Lorsque l'héritage est public comme ci-dessus, on peut employer un P3 en lieu et place d'un P2. En particulier on peut écrire directement

P3 a(1,2,3);
a.x = 2;
cout << a.y;
On peut en particulier convertir directement un pointeur ou une référence sur un P3 en un pointeur ou une référence sur un P2, et réciproquement. La conversion de P3* vers P2* est sûre, celle de P2* vers P3* peut poser problème si le P2 désigné n'est en fait pas un P3 :
P3 a(1,2,3);
P2 *pp2;
pp2 = &a;       // OK
P3 * pp3 = pp2; // OK
P2 t[10];
pp3 = &t[1];    // légal mais potentiellement dangereux...
Attention au tronçonnage et aux appels de fonctions membres :
P3 a(1,2,3);
P2 b = a;    // tronconnage, ou slicing : b reçoit la sous partie P2 de a, soit (1,2).

P3 *pp3 = &a;
double x = pp3->sqdist();   // x vaut 14
P2 *pp2 = &a;
double y = pp2->sqdist();   // x vaut 5

dans les appels à sqdist() ci-dessus, c'est le type statique du pointeur qui compte, pas le type dynamique de l'instance sur laquelle il pointe.

En pratique cette forme d'héritage non polymorphique est peu utilisée.

Polymorphisme dynamique

Introduction

Chaque animal doit se nourrir, même si chacun le fait d'une façon différente : certains broutent, d'autres dévorent, il y en a qui picorent, d'autres qui sucent le sang... Il serait intéressant de considérer un type Animal, ayant une méthode manger, dont l'implémentation serait déférée aux animaux réels :
class Animal {
    ...
    void manger();
};
class Vache : public Animal {
    ...
    void manger() { /* broute */ }
};
class Poule : public Animal {
    ...
    void manger() { /* picore */ }
};
Considérons une ferme dont les animaux sont regroupés au sein d'une collection. Les implémentations des différents animaux sont vraisemblablement tellement distinctes qu'il est difficile d'envisager que la collection les contienne par valeur. Par contre il est facile de créer une collection homogène de pointeur sur des Animal. Dès lors on aimerait pouvoir écrire
void repas_des_fauves() {
    Animal *p;
    pour p pointant successivement sur tous les animaux de la collection faire
        p->manger();
}
malheureusement comme on l'a vu dans la section précédente c'est ici le type statique de p, soit Animal*, qui importe. On ne pourra pas par ce biais faire brouter une Vache.

Méthodes virtuelles

La solution consiste à déclarer virtuelle la méthode manger() dans la classe Animal. Comme de plus on n'a pas d'implémentation par défaut de cette méthode, on peut même la virtuelle pure, en remplaçant son corps par = 0; la classe Animal est alors dite abstraite, et on ne peut pas créér d'instance d'Animal.
class Animal { // abstraite, car contient des méthodes virtuelle pures
    ...
    virtual void manger() = 0; // méthode virtuelle pure
};

Animal a;       // illégal
Animal t[10];   // illégal
Animal *p;      // OK

Dans les classes dérivées on va fournir une implémentation pour cette méthode. Une classe dont toutes les méthodes ont une implémentation est dite concrète, et on peut en créer des instances :
class Vache : public Animal {
    ...
    virtual void manger() { /* broute */ }
};

Vache v(...);    // OK
Animal* p = &v;  // OK
Animal& r = v;   // OK

Plus intéressant, l'invocation d'une méthode virtuelle au travers d'un pointeur ou d'une référence à la classe de base va invoquer effectivement la méthode définie dans la classe dérivée:
Vache uneVache(...);
Vache uneAutreVache(...);
Poule unePoule(...);
Animal* pA = & uneVache;
pA->manger();    // invoque Vache::manger sur l'instance uneVache
pA = & uneAutreVache;
pA->manger();    // invoque Vache::manger sur l'instance uneAutreVache
pA = & unePoule;
pA->manger();    // invoque Poule::manger sur l'instance unePoule
La réalisation effective de ce mécanisme varie d'un compilateur à l'autre. Une approche classique consiste à stocker dans la classe de base un pointeur vptr sur la table de fonctions virtuelles associée à cette classe. Selon le type concret de l'instance, ce pointeur désignera la table adaptée :

Les tables de fonctions virtuelles, ou vtbl, associées aux classes dérivées de Animal ont toutes la même structure : la méthode manger se trouve à la même position dans chacune. L'invocation de la méthode manger à partir d'un pointeur pA sur un Animal procède alors comme suit : on déréférence le pointeur pA pour obtenir le vptr, on trouve l'adresse de la méthode à l'index adéquat (connu du compilateur) dans la vtbl qu'il désigne, et on appelle cette méthode en lui passant la valeur de pA comme adresse de l'instance (paramètre caché this).

Manipulation des instances de classes polymorphiques

Les instances de classes polymorphiques sont manipulées pratiquement exclusivement au travers de pointeurs ou de références. En général la copie directe et l'affectation n'ont pas de sens, ou en tous cas pas sur des classes de bases, et a fortiori lorsque ces dernières sont abstraites. Elles sont généralement remplacées par le clonage :
class Base {
    ...
    virtual Base* clone() const {
        return new Base(this); // utilise le constructeur de copie de Base
    }
};

class Derived : public Base {
    ...
    virtual Base* clone() const {
        return new Derived(this); // utilise le constructeur de copie de Derived
    }
};
 

Base * pb = new Derived(...);
Base * copie = pb->clone();    // invoquera Derived::clone

Les classes possédant des méthodes virtuelles doivent généralement déclarer leur destructeur virtuel, même s'il ne fait rien. En effet, si le destructeur de Base n'est pas virtuel, la destruction par delete est problématique :
class Base {};                         // pas de destructeur virtuel
class Derived : public Base {...};
Base * pb = new Derived(...);
delete pb;                             // MAUVAIS : invoque uniquement ~Base au lieu de ~Derived
 

class Base { virtual ~Base() {}};      // destructeur virtuel
class Derived : public Base {...};
Base * pb = new Derived(...);
delete pb;                             // OK : invoque ~Derived

Notons qu'une fois qu'une méthode a été déclarée virtuelle, elle le reste dans toutes les classes dérivées.

Exemple : manipulation d'expressions arithmétiques

Cet exemple (EX) est disponible au format tgz (tar compressé avec gzip).

On s'intéresse à des expressions arithmétiques simples, représentées sous forme d'arbres. Par exemple la figure suivante illustre l'expression 1+2*3 :

Chaque noeud de l'arbre est lui même une expression.

On peut déclarer un type abstrait Expression comme suit (fichier Expression.hh):

#ifndef Expression_H
#define Expression_H

class Expression {
public:
  typedef int Val;
  virtual ~Expression();
  virtual Val eval() const = 0;
};

#endif /* Expression_H */

l'implémenter avec (fichier Expression.cc) :
#include "Expression.hh"

Expression::~Expression() {
}

et créer un programme client comme suit (fichier tEX_1.cc) :
#include <iostream>
#include "Expression.hh"

extern Expression* f();

int main() {
  Expression * e = f();
  cout << e->eval() << endl;
}

Ce programme ne nécessite que la connaissance de la déclaration du type abstrait Expression. On peut le compiler dès maintenant pour créer tEX_1.o, il ne va plus changer.  Cette situation est typique de l'utilisation du polymorphisme dynamique : le code client ne s'adresse qu'à l'interface définie par les classes de base, et n'a souvent aucune connaissance des types dérivés.

La fonction f() est ici typique d'un processus de production associé, par exemple un analyseur syntaxique, qui fournit l'Expression à exploiter.

Pour fabriquer l'expression 1 + 2*3, on a besoin de représenter les valeurs numériques, les sommes et les produits. On crée trois classes dérivées de Expression,
respectivement Constante, Somme et Produit.

Fichier Constante.hh:

#ifndef Constante_H
#define Constante_H

#include "Expression.hh"

class Constante : public Expression {
  Val value;
public:
  Constante(Val val);
  virtual Val eval() const;
};

#endif /* Constante_H */

Fichier Constante.cc:
#include "Constante.hh"

Constante::Constante(Val v) : value(v) {}

Expression::Val Constante::eval() const { return value; }

Fichier Somme.hh:
#ifndef Somme_H
#define Somme_H

#include "Expression.hh"

class Somme : public Expression {
  Expression* l;
  Expression* r;
public:
  Somme(Expression* l, Expression* r);
  virtual Val eval() const;
};

#endif /* Somme_H */

Fichier Somme.cc:
#include "Somme.hh"

Somme::Somme(Expression* l, Expression* r) : l(l), r(r) {}

Expression::Val Somme::eval() const { return l->eval() + r->eval(); }

Fichier Produit.hh:
#ifndef Produit_H
#define Produit_H

#include "Expression.hh"

class Produit : public Expression {
  Expression* l;
  Expression* r;
public:
  Produit(Expression* l, Expression* r);
  virtual Val eval() const;
};

#endif /* Produit_H */

Fichier Produit.cc:
#include "Produit.hh"

Produit::Produit(Expression* l, Expression* r) : l(l), r(r) {}

Expression::Val Produit::eval() const { return l->eval() * r->eval(); }

On peut dès lors écrire une fonction f() qui produit l'expression 1 + 2*3 (fichier f.cc)
#include "Constante.hh"
#include "Somme.hh"
#include "Produit.hh"

Expression * f() {
    static Constante a1(1);
    static Constante a2(2);
    static Constante a3(3);
    static Produit p23(&a2, &a3);
    static Somme s1p23(&a1, &p23);
    return &s1p23;
}

L'utilisation de variables statiques permet d'éviter pour l'instant de se préoccuper de gestion mémoire. On y reviendra dans la suite.

On peut maintenant compiler l'ensemble de ces fichiers. L'exécution du programme de test donne bien la valeur attendue :

$ make
make depend
g++ -Wall -W -pedantic -Werror   -MM Expression.cc Constante.cc Somme.cc Produit.cc f.cc tEX_1.cc  > .depend
make all
g++ -Wall -W -pedantic -Werror -c tEX_1.cc -o tEX_1.o
g++ -Wall -W -pedantic -Werror -c Expression.cc -o Expression.o
g++ -Wall -W -pedantic -Werror -c Constante.cc -o Constante.o
g++ -Wall -W -pedantic -Werror -c Somme.cc -o Somme.o
g++ -Wall -W -pedantic -Werror -c Produit.cc -o Produit.o
g++ -Wall -W -pedantic -Werror -c f.cc -o f.o
g++ tEX_1.o Expression.o Constante.o Somme.o Produit.o f.o -o tEX_1
./tEX_1
7
$

Enrichissement de l'exemple : impression formatée des expressions

Cet exemple (EX2) est disponible au format tgz (tar compressé avec gzip).

Supposons qu'on veuille enrichir les fonctionnalités de nos Expression en les dotant de capacités d'impression formatée, pour pouvoir écrire par exemple (fichier tEX_1.cc)

#include <iostream>
#include "Expression.hh"

extern Expression* f();

int main() {
  Expression * e = f();
  cout << *e << " = " << e->eval() << endl;
}

et obtenir à l'exécution
$ ./tEX_1
(1 + (2 * 3)) = 7
$
Il est clair que que chaque classe dérivée d'Expression a sa propre forme formatée. Or l'opérateur d'insertion est une fonction libre et ne peut pas directement être polymorphique. On résoud ce problème en lui faisant invoquer une fonction membre virtuelle (fichier Expression.hh) :
#ifndef Expression_H
#define Expression_H

#include <iostream>

class Expression {
public:
  typedef int Val;
  virtual ~Expression();
  virtual Val eval() const = 0;
  virtual ostream& print(ostream & os) const = 0;
};

inline ostream & operator<<(ostream & os, Expression const & e) {
  return e.print(os);
}

#endif /* Expression_H */

Par exemple, la classe Constante devient (fichier Constante.hh)
#ifndef Constante_H
#define Constante_H

#include "Expression.hh"

class Constante : public Expression {
  Val value;
public:
  Constante(Val val);
  virtual ostream & print(ostream &) const;
  virtual Val eval() const;
};

#endif /* Constante_H */

avec les définitions (fichier Constante.cc)
#include "Constante.hh"

Constante::Constante(Val v) : value(v) {}

ostream & Constante::print(ostream & os) const {
  return os << value;
}

Expression::Val Constante::eval() const { return value; }

Incidemment, il n'est pas nécessaire de définir l'opérateur d'insertion pour les classes dérivées. La version définie pour la classe de base fonctionnera directement :
Constante c(10);
cout << c;       // OK. invoque operator << (ostream &, Expression const &)
Il suffit maintenant de définir print dans chacune des autres classes dérivées. On peut prévoir cependant que entre Somme et Produit, seul le nom de l'opérateur va changer. Peut-être serait-il intéressant de factoriser le comportement commun dans une classe de base intermédiaire, représentant un opérateur binaire (fichier Binary.hh) :
#ifndef Binary_H
#define Binary_H

#include "Expression.hh"

class Binary : public Expression {
protected:
  Expression* l;
  Expression* r;
public:
  Binary(Expression* l, Expression* r);
  virtual char opchar() const = 0;
  virtual ostream & print(ostream &) const;
};

#endif /* Binary_H */

avec ses définitions associées (fichier Binary.cc) :
#include "Binary.hh"

Binary::Binary(Expression* l, Expression* r) : l(l), r(r) {}

ostream & Binary::print(ostream & os) const {
  return os << '(' << *l << ' ' << opchar() << ' ' << *r << ')';
}

On voit que cette classe prend en charge la gestion des pointeurs sur les opérandes : cette responsabilité devra être retirée de Somme et Produit. Les pointeurs l et r sont déclarés protected pour que les classes dérivées puissent y accéder (notamment pour implémenter eval). Enfin, on remarque que cette classe introduit une nouvelle méthode virtuelle pure, opchar, pour capturer le nouveau comportement variant entre les opérateurs binaires, à savoir le caractère utilié pour représenter l'opérateur. L'autre comportement variant est eval, mais cette méthode est déjà déclarée au niveau de Expression et il est inutile de la déclarer à nouveau.

La classe Somme devient alors (fichier Somme.hh)

#ifndef Somme_H
#define Somme_H

#include "Binary.hh"

class Somme : public Binary {
public:
  Somme(Expression* l, Expression* r);
  virtual char opchar() const;
  virtual Val eval() const;
};

#endif /* Somme_H */

avec les définitions (fichier Somme.cc)
#include "Somme.hh"

Somme::Somme(Expression* l, Expression* r)
    : Binary(l,r) // construction du sous-objet Binary de base
{}

char Somme::opchar() const { return '+'; }

Expression::Val Somme::eval() const { return l->eval() + r->eval(); }

Noter le changement de forme du constructeur : l'appel Binary(l,r) est l'invocation du constructeur de la classe de base Binary.

Variations sur l'impression

Cet exemple (EX3) est disponible au format tgz (tar compressé avec gzip).

L'opérateur d'insertion de la section précédente réalise une impression en mode infixe. On pourrait vouloir imprimer en mode préfixé (l'opérateur précède les opérandes) ou postfixé (l'opérateur suit les opérandes) :

infix   : (1 + (2 * 3))
prefix  : + 1 * 2 3
postfix : 1 2 3 * +
Le mécanisme employé sera le même que précédemment : des méthodes virtuelles pour chaque style d'impression. Mais l'opérateur d'insertion pose problème : on ne peut en effet en définir qu'un seul pour les Expression. En supposant que cet opérateur utilise le style infixe, la fonction main pourrait être écrite
int main() {
  Expression * e = f();
  cout << "infix   : " << *e << endl;

  cout << "prefix  : ";
  e->prefix_print(cout);
  cout << endl;

  cout << "postfix : ";
  e->postfix_print(cout);
  cout << endl;

  cout << "eval : " << e->eval() << endl;
}

mais le résultat est lourd et inélégant pour les formes infixe et postfixe. Il serait agréable de pouvoir conserver l'utilisation de l'opérateur d'insertion.

Pour cela, on peut utiliser des petites classes auxiliaires, telles que prefix et postfix ci après (fichier Expression.hh) :

#ifndef Expression_H
#define Expression_H

#include <iostream>

class Expression {
public:
  typedef int Val;
  virtual ~Expression();
  virtual Val eval() const = 0;
  virtual ostream& infix_print(ostream & os) const = 0;
  virtual ostream& prefix_print(ostream & os) const = 0;
  virtual ostream& postfix_print(ostream & os) const = 0;
};

inline ostream & operator<<(ostream & os, Expression const & e) {
  return e.infix_print(os);
}

struct prefix {
  Expression const & e;
  prefix(Expression const & e) : e(e) {}

  // ici on abuse du mot clé friend pour définir une fonction
  // libre dans le corps de la classe. Elle est du coup inline.
  // C'est une alternative à une définition inline extérieure
  // à la classe comme celle pour Expression ci-dessus.
  friend ostream & operator<<(ostream & os, prefix const & x) {
    return x.e.prefix_print(os);
  }
};

struct postfix {
  Expression const & e;
  postfix(Expression const & e) : e(e) {}
  friend ostream & operator<<(ostream & os, postfix const & x) {
    return x.e.postfix_print(os);
  }
};

#endif /* Expression_H */

Avec ces auxilaires, notre programme de test devient (fichier tEX_1.cc)
#include <iostream>
#include "Expression.hh"

extern Expression* f();

int main() {
  Expression * e = f();
  cout << "infix   : " << *e << endl;
  cout << "prefix  : " << prefix(*e) << endl;
  cout << "postfix : " << postfix(*e) << endl;
  cout << "eval : " << e->eval() << endl;
}

Ce qui a quant même meilleure allure. L'expression prefix(*e) crée un une instance temporaire anonyme de la classe prefix, et c'est  operator << (ostream &, prefix const &) qui est utilisé pour l'insertion. Cet opérateur délègue le travail à l'expression à laquelle il est associée, en invoquant la fonction membre d'impression appropriée. Avec ces auxiliaires, l'implémentation des méthodes dans Binary.cc est limpide :
#include "Binary.hh"

Binary::Binary(Expression* l, Expression* r) : l(l), r(r) {}

ostream & Binary::infix_print(ostream & os) const {
  return os << '(' << *l << ' ' << opchar() << ' ' << *r << ')';
}

ostream & Binary::prefix_print(ostream & os) const {
  return os << opchar() << ' ' << prefix(*l) << ' ' << prefix(*r);
}

ostream & Binary::postfix_print(ostream & os) const {
  return os << postfix(*l) << ' ' << postfix(*r) << ' ' << opchar();
}

Gestion de mémoire

Dans les exemples de manipulation d'expression de la section précédente, on ne se préoccupait pas de gestion mémoire : toutes les instances manipulées étaient statiques. Cette approche est irréaliste. Par exemple si l'arbre d'expressions est produit par un analyseur syntaxique, il faudra d'une manière ou d'une autre allouer dynamiquement les noeuds de cet arbre.

Gestion simple

Cet exemple (EX4) est disponible au format tgz (tar compressé avec gzip).

S'il est garanti que l'arbre d'expressions est effectivement un arbre et non un graphe plus général, on peut mettre en place une stratégie selon laquelle chaque noeud non-feuille de l'arbre est propriétaire de ses enfants, et qu'il détruit ces derniers lors de sa propre destruction. Cela implique de n'utiliser que des instances dynamiques
(on ne peut pas les mélanger avec des instances automatiques ou statiques).

Pour implémenter ce mécanisme on ajoute un destructeur explicite à Binary dans Binary.hh:

class Binary : public Expression {
    ...
    virtual ~Binary();
    ...
};
et son implémentation dans Binary.cc :
Binary::~Binary() {
  delete l;
  delete r;
}
Il nous faut également modifier la fonction f dans f.cc:
Expression * f() {
  return new Somme(new Constante(1),
                   new Produit(new Constante(2),
                               new Constante(3)));
}
et pour bien faire libérer l'expression e après usage dans tEX_1.cc:
int main() {
  Expression * e = f();
  cout << "infix   : " << *e << endl;
  cout << "prefix  : " << prefix(*e) << endl;
  cout << "postfix : " << postfix(*e) << endl;
  cout << "eval : " << e->eval() << endl;
  delete e;
}
L'instruction delete e va provoquer la destruction récursive de l'arbre à partir de la racine e.

Comptage de référence

Cet exemple (EX5) est disponible au format tgz (tar compressé avec gzip).

La gestion simple évoquée dans la section précédente convient lorsque les expressions sont représentées par des arbres simples. Elle est cependant inadaptée aux graphes. Par exemple l'expression 2+2*2 peut être réalisée par un graphe acyclique orienté, ou DAG (directed acyclic graph) :

Manifestement la destruction récursive de la section précédente n'est pas adaptée : la Constante 2 serait détruite trois fois...

L'utilisation du comptage de référence permet de pallier ce problème (la section suivante explore une alternative possible). L'idée est de maintenir pour chaque Expression un compte des pointeurs qui la désignent, et de ne la détruire effectivement que lorsque ce compte passe à zéro. Ce comportement peut être implanté par une classe de base (Counted.hh) :

#ifndef Counted_H
#define Counted_H

#include <iostream>

class Counted {
  int refcount;
  int add_reference() {
    return ++refcount;
  }
  int rem_reference() {
    return --refcount;
  }
  friend class CountedPtrBase;
protected:
  Counted() : refcount(0) {}
  virtual ~Counted() {}
};

#endif /* Counted_H */

Evidemment, on ne peut pas utiliser des pointeurs ordinaires. On va se créér notre propre type de pointeur, pour tenir à jour le compte (fichier CounterPtr.hh) :
#ifndef CountedPtr_H
#define CountedPtr_H

#include "Counted.hh"

class CountedPtrBase {
  Counted * pointee;
  void add_reference() const {
    if (pointee) pointee->add_reference();
  }
  void rem_reference() const {
    if (pointee && (pointee->rem_reference() == 0))
 delete pointee;
  }
protected:
  CountedPtrBase(Counted * p)
    : pointee(p) {
    add_reference();
  }
  CountedPtrBase(CountedPtrBase const & p)
    : pointee(p.pointee) {
    add_reference();
  }
  CountedPtrBase const & operator = (CountedPtrBase const & p) {
    // l'ordre des instructions réalise la protection contre l'auto-affectation
    p.add_reference();
    rem_reference();
    pointee = p.pointee;
    return *this;
  }
  ~CountedPtrBase() {
    rem_reference();
  }
  Counted & operator*() const { return *pointee; }
  Counted * operator->() const { return pointee; }
};
 
 

template <class T>
class CountedPtr : public CountedPtrBase {
public:
  CountedPtr(T* p)
    : CountedPtrBase(p)
    {}
  CountedPtr(CountedPtr const & rhs)
    : CountedPtrBase(rhs)
    {}
  template <class U> CountedPtr(CountedPtr<U> const & rhs)
    : CountedPtrBase(rhs.operator->())
    {}
  CountedPtr operator = (CountedPtr const & rhs) {
    return CountedPtrBase::operator=(rhs);
  }
  T & operator*() const {
    return static_cast<T &>(CountedPtrBase::operator *());
  }
  T * operator->() const {
    return static_cast<T *>(CountedPtrBase::operator ->());
  }
};

#endif /* CountedPtr_H */

Ce fichier définit la classe CoutedPtrBase, qui va interagir avec Counted, et un patron de pointeurs typés CounterPtr<T>. A chaque fois que l'on crée ou copie un CountedPtr, on incrémente le compte de références du Counted qu'il désigne. A chaque fois qu'on détruit un CountedPtr, on décrémente ce compte. S'il passe à zéro, on détruit l'instance pointée.

Il faut cependant garantir qu'on ne puisse pas acquérir un pointeur normal sur une instance. Un premier pas consiste à rendre les constructeurs privés (ou protégés) et à filtrer la création par une fonction de construction :

class Constante : public Expression {
  Val value;
protected:
  Constante(Val val);
  virtual ~Constante();
public:
  ...
  static CountedPtr<Expression> makeConstante(Val val) {
    return new Constante(val);
  }
  ...
};
Ce n'est pas tout à fait suffisant : il y a quantité de moyens d'obtenir un pointeur sur un objet. On s'en contentera cependant pour l'instant.

Evidemment, les noeuds du graphe doivent eux-même accéder à leurs enfant au travers de CountedPtr. Il nous faut modifier tous les constructeurs des opérateurs binaires, à commencer par Binary.hh :

class Binary : public Expression {
protected:
  CountedPtr<Expression> l;
  CountedPtr<Expression> r;
  Binary(CountedPtr<Expression> l, CountedPtr<Expression> r);
  virtual ~Binary() {}
public:
  virtual char opchar() const = 0;
  virtual ostream & infix_print(ostream &) const;
  virtual ostream & prefix_print(ostream &) const;
  virtual ostream & postfix_print(ostream &) const;
};

...

class Somme : public Binary {
protected:
  Somme(CountedPtr<Expression> l, CountedPtr<Expression> r);
  virtual ~Somme();
  ...
};

...

Somme::Somme(CountedPtr<Expression> l, CountedPtr<Expression> r)
  : Binary(l,r)
{
}

Mais moyennant ces efforts on pourra créer une expression représentée par un DAG, ici 2+2*2 :
CountedPtr<Expression> g() {
  CountedPtr<Expression> p = Constante::makeConstante(2);
  return Somme::makeSomme(p, Produit::makeProduit(p, p));
}
et la manipuler avec
int main() {
  CountedPtr<Expression> e = g();
  cout << "infix   : " << *e << endl;
  cout << "prefix  : " << prefix(*e) << endl;
  cout << "postfix : " << postfix(*e) << endl;
  cout << "eval : " << e->eval() << endl;
}
Notez qu'il n'est pas nécessaire de détruire explicitement l'expression produite par g(). La destruction de e en fin de bloc va déclencher la destruction de l'expression qu'il désigne.

Utilisation d'un ramasse-miettes (garbage collector)

Cet exemple (EX6) est disponible au format tgz (tar compressé avec gzip). La distribution comprend la version 4.14 du ramasse-miettes de Boehm, Demers et Weiser.

L'utilisation d'un ramasse-miettes est une alternative à l'utilisation du comptage de références. Un ramasse-miettes est un système capable de détecter si les instances allouées dynamiquement sont référencées, et qui se charge de les désallouer lorsqu'elles ne le sont plus. Le C++ standard ne comprend pas de ramasse-miettes,

Dans notre cas, cela simplifie considérablement la gestion. On peut repartir de la version EX3 et simplement exprimer le fait que les Expressions sont gérées par le ramasse-miettes, ce qui peut s'exprimer très simplement par

#include "gc/gc_cpp.h"

class Expression : public gc { ... };

C'est tout ! A ceci près que les destructeurs des instances ne seront pas invoqués par le ramasse-miette au moment où il décidera de les récupérer. Si on veut que ces
destructeurs soient invoqués, on peut utiliser une classe de base alternative :
#include "gc/gc_cpp.h"

class Expression : public gc_cleanup { ... };

Dans l'un ou l'autre cas on peut manipuler des expressions sous forme d'arbres ou de DAG sans se soucier de la gestion mémoire. Pour vérifier que les toutes les instances sont bien récupérées, on peut modifier Expression de façon à compter le nombre d'instances allouées et non détruites à un instant donné :
class Expression : public gc_cleanup {
public:
  static int count; // nombre d'instances vivantes
  static int alloc; // nombre total d'instances allouées

  Expression() { ++ count; ++alloc; }
  virtual ~Expression() { --count; }
  ...
};

Dans le programme tEX_2.cc ci-après, le destructeur du sentry s est exécuté après complétion du main(). Il effectue des cycles de ramassage jusqu'à épuisement (on ne devrait pas avoir à forcer ce comportement, c'est peut-être un problème lié à la plateforme de test).
#include <iostream>
#include "Expression.hh"
#include "Constante.hh"
#include "Somme.hh"
#include "gc/gc.h"

extern Expression* f();

Expression * tree(int depth) {
  return ((depth == 0)
   ? (Expression *)(new Constante(1))
   : (Expression *)(new Somme(tree(depth-1), tree(depth-1))));
}

Expression * dag(int depth) {
  if (depth == 0)
    return new Constante(1);
  Expression *e = dag(depth -1);
  return new Somme(e,e);
}

struct sentry {
  ~sentry() {
    cerr << endl << "allocated expressions : " << Expression::alloc << endl;
    for (int i = 0; i < 20; ++i) {
      GC_gcollect();
      cerr << "i " << i << " GC_gc_no " << GC_gc_no << " undestroyed expressions : " << Expression::count << endl;
      if (Expression::count == 0)
 break;
    }
  }
};

// l'objet s sera détruit après exécution de main.
sentry s;

int main() {
  {
    Expression * e = tree(10);
    cerr << "infix   : " << *e << endl;
    cerr << "prefix  : " << prefix(*e) << endl;
    cerr << "postfix : " << postfix(*e) << endl;
    cerr << "eval : " << e->eval() << endl;
  }
  {
    Expression * e = dag(10);
    cerr << "infix   : " << *e << endl;
    cerr << "prefix  : " << prefix(*e) << endl;
    cerr << "postfix : " << postfix(*e) << endl;
    cerr << "eval : " << e->eval() << endl;
  }
}

On peut vérifier que toutes les instances sont collectées:
salaam:/cm/ENSTA/POlY/EX6 $ ./tEX_2
infix   : ((((((((((1 + 1) + (1 + 1)) ...
prefix  : + + + + + + + + + + 1 1 + 1 ...
postfix : 1 1 + 1 1 + + 1 1 + 1 1 + + ...
eval : 1024
infix   : ((((((((((1 + 1) + (1 + 1)) ...
prefix  : + + + + + + + + + + 1 1 + 1 ...
postfix : 1 1 + 1 1 + + 1 1 + 1 1 + + ...
eval : 1024

allocated expressions : 2058
i 0 GC_gc_no 5 undestroyed expressions : 2056
i 1 GC_gc_no 6 undestroyed expressions : 2053
i 2 GC_gc_no 7 undestroyed expressions : 2048
i 3 GC_gc_no 8 undestroyed expressions : 2039
i 4 GC_gc_no 9 undestroyed expressions : 2022
i 5 GC_gc_no 10 undestroyed expressions : 1989
i 6 GC_gc_no 11 undestroyed expressions : 1924
i 7 GC_gc_no 12 undestroyed expressions : 1795
i 8 GC_gc_no 13 undestroyed expressions : 1538
i 9 GC_gc_no 14 undestroyed expressions : 1025
i 10 GC_gc_no 15 undestroyed expressions : 0
salaam:/cm/ENSTA/POlY/EX6 $
 

Programmation générique

Bibliographie