Penser en C++

Volume 1


précédentsommairesuivant

14. Héritage & composition

Une des caractéristiques les plus contraignantes du C++ est la réutilisation du code. Mais pour être révolutionnaire, il vous faut être capable de faire beaucoup plus que copier du code et le modifier.

C'est l'approche du C, et ça n'a pas très bien marché. Comme avec presque tout en C++, la solution tourne autour de la classe. Vous réutilisez le code en créant de nouvelles classes, mais au lieu de les créer à partir de rien, vous utilisez des classes existantes que quelqu'un d'autre a écrites et déboguées.

Le truc consiste à utiliser les classes sans polluer le code existant. Dans ce chapitre, vous verrez deux façons d'accomplir cela. La première est tout à fait immédiate : Vous créez simplement des objets de vos classes existantes dans la nouvelle classe. Ceci est appelé composition parce que la nouvelle classe est composée d'objets de classes existantes.

La seconde approche est plus subtile. Vous créez une nouvelle classe comme une sorte de classe existante. Vous prenez littéralement la forme de la classe existante et vous lui ajoutez du code, sans modifier la classe existante. Ce processus magique est appelé héritage, et la majorité du travail est fait par le compilateur. L'héritage est l'une des pierres angulaires de la programmation orientée objet et a d'autres implications qui seront explorées dans le chapitre 15.

Il se trouve que l'essentiel de la syntaxe et du comportement sont similaires pour la composition et l'héritage (ce qui est raisonnable ; ce sont deux manières de fabriquer de nouveaux types à partir de types existants). Dans ce chapitre, vous en apprendrez plus sur ces mécanismes de réutilisation de code.

14.1. Syntaxe de la composition

En fait vous avez toujours utilisé la composition pour créer des classes. Vous avez simplement composé des classes de façon primaire avec des types pré-définis (et parfois des objets string). Il s'avère être presque aussi facile d'utiliser la composition avec des types personnalisés.

Considérez une classe qui est intéressante pour quelque raison :

 
Sélectionnez
//: C14:Useful.h
// Une classe à réutiliser
#ifndef USEFUL_H
#define USEFUL_H
 
class X {
  int i;
public:
  X() { i = 0; }
  void set(int ii) { i = ii; }
  int read() const { return i; }
  int permute() { return i = i * 47; }
};
#endif // USEFUL_H ///:~

Les données membres sont private dans cette classe, de la sorte il est sans danger d'inclure un objet de type X en temps qu'objet public dans une nouvelle classe, ce qui rend l'interface immédiate :

 
Sélectionnez
//: C14:Composition.cpp
// Réutilisation de code par composition
#include "Useful.h"
 
class Y {
  int i;
public:
  X x; // Objet embarqué
  Y() { i = 0; }
  void f(int ii) { i = ii; }
  int g() const { return i; }
};
 
int main() {
  Y y;
  y.f(47);
  y.x.set(37); // Accès à l'objet embarqué
} ///:~

L'accession aux fonctions membres de l'objet embarqué (désigné comme un sous-objet) ne nécessite qu'une autre sélection de membre.

Il est plus habituel de rendre les objets embarqués private, de sorte qu'ils deviennent partie de l'implémentation sous-jacente (ce qui signifie que vous pouvez changer l'implémentation si vous voulez). Les fonctions d'interface public pour votre nouvelle classe impliquent alors l'utilisation de l'objet embarqué, mais elles n'imitent pas forcément l'interface de l'objet :

 
Sélectionnez
//: C14:Composition2.cpp
// Objets privés embarqués
#include "Useful.h"
 
class Y {
  int i;
  X x; // Objet embarqué
public:
  Y() { i = 0; }
  void f(int ii) { i = ii; x.set(ii); }
  int g() const { return i * x.read(); }
  void permute() { x.permute(); }
};
 
int main() {
  Y y;
  y.f(47);
  y.permute();
} ///:~

Ici, la fonction permute( ) est transportée dans l'interface de la nouvelle classe, mais les autres fonctions membres de X sont utilisés dans les membres de Y.

14.2. Syntaxe de l'héritage

La syntaxe pour la composition est évidente, mais pour réaliser l'héritage il y a une forme différente et nouvelle.

Lorsque vous héritez, vous dites, “Cette nouvelle classe est comme l'ancienne.” Vous posez cela en code en donnant le nom de la classe comme d'habitude, mais avant l'accolade ouvrante du corps de la classe, vous mettez un ':' et le nom de la classe de base(ou des classes de base, séparées par virgules, pour l'héritage multiple). Lorsque vous faites cela, vous obtenez automatiquement toutes les données membres et toutes les fonctions membres dans la classe de base. Voici un exemple:

 
Sélectionnez
//: C14:Inheritance.cpp
// Héritage simple
#include "Useful.h"
#include <iostream>
using namespace std;
 
class Y : public X {
  int i; // Différent du i de X
public:
  Y() { i = 0; }
  int change() {
    i = permute(); // Appel avec nom différent
    return i;
  }
  void set(int ii) {
    i = ii;
    X::set(ii); // Appel avec homonyme
  }
};
 
int main() {
  cout << "sizeof(X) = " << sizeof(X) << endl;
  cout << "sizeof(Y) = "
       << sizeof(Y) << endl;
  Y D;
  D.change();
  // l'interface fonctionelle de X intervient:
  D.read();
  D.permute();
  // Les fonctions redéfinies masquent les anciennes:
  D.set(12);
} ///:~

Vous pouvez voir Y héritant de X, comme signifiant que Y contiendra toutes les données de X et toutes les fonctions membres de X. En fait, Y contient un sous-objet de type X juste comme si vous aviez créé un objet membre de type X à l'intérieur de Y au lieu d'hériter de X. A la fois les objets membres et les classes de bases sont désignés comme sous-objets.

Tous les éléments private de X sont toujours private dans Y; c'est à dire que, le simple fait de dériver Y de X ne signifie pas que Y peut briser le mécanisme de protection . Les éléments private de X sont toujours là, ils occupent de l'espace - vous ne pouvez pas y accéder directement.

Dans main( ) vous pouvez voir que les données membres de Y sont combinés avec ceux de X parce que sizeof(Y) est deux fois plus gros que sizeof(X).

Vous remarquerez que le nom de la classe de base est précédé de public. Pour l'héritage, tout est par défaut fixé à private. Si la classe de base n'était pas précédée par public, cela signifierait que tous les membres public de la classe de base seraient private dans la classe dérivée. Ce n'est presque jamais ce que vous souhaitez (51); le résultat désiré est de conserver tous les membres public de la classe de base comme public dans la classe dérivée. Vous faites cela en utilisant le mot-clé public pendant l'héritage.

Dans change( ), la fonction de la classe de base permute( ) est appelée. La classe dérivée à un accès direct à toutes les fonctions de la classe de base public.

La fonction set( ) dans la classe dérivée redéfinit la fonction set( ) dans la classe de base. C'est à dire que, si vous appelez les fonctions read( ) et permute( ) pour un objet de type Y, vous aurez les versions de la classe de base; de ces fonctions (vous pouvez voir cela se produire dans main( )). Mais si vous appelez set( ) pour un objet Y, vous obtenez la version redéfinie. Cecla signifie que si vous n'aimez pas la version d'une fonction que vous obtenez par héritage, vous pouvez changer ce qu'elle fait. (Vous pouvez aussi ajouter complètement de nouvelles fonctions comme change( ).)

Toutefois, quand vous redéfinissez une fonction, vous pouvez toujours vouloir appeler la version de la classe de base. Si, à l'intérieur de set( ),vous appelez simplement set( ) vous obtiendrez pour la version locale de la fonction - un appel récursif. Pour appeler la version de la classe de base, vous devez explicitement nommer la classe de base en utilisant l'opérateur de résolution de portée.

14.3. La liste d'initialisation du construteur

Vous avez vu comme il est important en C++ de garantir une initialisation correcte, et ce n'est pas différent pour la composition et l'héritage. Quand un objet est créé, le compilateur garantit que les constructeurs pour tous ses sous-objets sont appelés. Dans les exemples jusqu'ici, tous les sous-objets ont des constructeurs par défaut, et c'est ce que le compilateur appelle automatiquement. Mais que se passe-t-il si vos sous-objets n'ont pas de constructeurs par défaut, ou si vous voulez changer un argument par défaut dans un constructeur ? C'est un problème parce que le constructeur de la nouvelle classe n'a pas la permission d'accéder aux éléments de données private du sous-objet, de sorte qu'il ne peut les initialiser directement.

La solution est simple: Appeler le constructeur pour le sous-objet. C++ propose une syntaxe spéciale pour cela, la liste de l'initialisation du constructeur. La forme de la liste d'initialisation du constructeur se fait l'écho du processus d'héritage. Avec l'héritage, vous placez les classes de base après un ':' et avant l'accolade ouvrante du corps de la classe. Dans la liste d'initialisation du constructeur, vous placez les appels aux constructeurs des sous-objets après la liste d'arguments du constructeur et un ':', mais avant l'accolade ouvrante du corps de la fonction. Pour une classe MyType, dérivant de Bar, cela peut ressembler à ceci :

 
Sélectionnez
MyType::MyType(int i) : Bar(i) { // ...

si Bar a un constructeur qui prend un unique argument int.

14.3.1. Initialisation d'un objet membre

Il s'avère que vous utilisez exactement la même syntaxe pour l'initialisation d'objets membres quand vous utilisez la composition. Pour la composition, vous donnez les noms des objets au lieu de noms de classes. Si vous avez plus d'un appel de constructeur dans une liste d'initialisation, vous séparez les appels avec des virgules :

 
Sélectionnez
MyType2::MyType2(int i) : Bar(i), m(i+1) { // ...

C'est le début d'un constructeur pour la classe MyType2, qui est hérité de Bar et contient un objet membre appelé m. Notez que tandis que vous pouvez voir le type de la classe de base dans la liste d'initialisation du constructeur, vous ne voyez que les identificateurs des objets membres.

14.3.2. Types prédéfinis dans la liste d'initialisation

La liste d'initialisation du constructeur vous permet d'appeler explicitement les constructeurs pour les objets membres. En fait,il n'y a pas d'autre façon d'appeler ces constructeurs. L'idée est que les constructeurs sont tous appelés avant d'entrer dans le corps du constructeur de la nouvelle classe. De la sorte, tous les appels que vous faites à des fonctions membres de sous-objets iront toujours à des objets initialisés. Il n'y a aucun moyen d'accéder à la parenthèse ouvrante du constructeur sans qu'un appel soit fait à quelque constructeur pour tous les objets membres et tous les objets des classes de base, même si le compilateur doit faire un appel caché à un constructeur par défaut. C'est un renforcement supplémentaire de la garantie que C++ donne qu'aucun objet (ou partie d'un objet) ne peut prendre le départ sans que son constructeur n'ait été appelé.

L'idée que tous les objets membres sont initialisés au moment où on atteint l'accolade ouvrante est également une aide pratique à la programmation. Une fois que vous atteignez l'accolade ouvrante, vous pouvez supposez que tous les sous-objets sont correctement initialisés et vous concentrer sur les tâches spécifiques que vous voulez voir réalisées par le constructeur. Cependant, il y a un accroc : qu'en est-il des objets membres de types prédéfinis, qui n'ont pas de constructeurs ?

Pour rendre la syntaxe cohérente, vous avez l'autorisation de traiter les types prédéfinis comme s'ils avaient un constructeur unique, qui prend un simple argument : une variable du même type que la variable que vous initialisez. De la sorte vous pouvez dire

 
Sélectionnez
//: C14:PseudoConstructor.cpp
class X {
  int i;
  float f;
  char c;
  char* s;
public:
  X() : i(7), f(1.4), c('x'), s("allo") {}
};
 
int main() {
  X x;
  int i(100);  // Appliqué aux définitions ordinaires
  int* ip = new int(47);
} ///:~

L'action de ces “appels à des pseudo-construteurs” est de réaliser une simple affectation. C'est une technique pratique et un bon style de codage, aussi vous le verrez souvent utilisé.

Il est même possible d'utiliser la syntaxe du pseudo-constructeur pour créer à l'extérieur d'une classe une variable d'un type prédéfini :

 
Sélectionnez
int i(100);
int* ip = new int(47);

Cela fait que les types prédéfinis se comportent un peu plus comme des objets. Souvenez vous, cependant, que ce ne sont pas de vrais constructeurs. En particulier, si vous ne faites pas explicitement un appel à un pseudo-constructeur, aucune initialisation n'a lieu.

14.4. Combiner composition & héritage

Bien sûr, vous pouvez utiliser la composition et l'héritage ensemble. L'exemple suivant montre la création d'une classe plus complexe utilisant l'un et l'autre.

 
Sélectionnez
//: C14:Combined.cpp
// Héritage & composition
 
class A {
  int i;
public:
  A(int ii) : i(ii) {}
  ~A() {}
  void f() const {}
};
 
class B {
  int i;
public:
  B(int ii) : i(ii) {}
  ~B() {}
  void f() const {}
};
 
class C : public B {
  A a;
public:
  C(int ii) : B(ii), a(ii) {}
  ~C() {} // Appelle ~A() and ~B()
  void f() const {  // Redéfinition
    a.f();
    B::f();
  }
};
 
int main() {
  C c(47);
} ///:~

C hérite de B et a un objet membre (“est composé de ”) de type A. Vous pouvez voir que la liste d'initialisation du constructeur contient des appels au constructeur de la classe de base et le constructeur du membre objet.

La fonction C::f( ) redéfinit B::f( ), de laquelle elle hérite, et appelle également la version de la classe de base. De plus, elle appelle a.f( ). Notez que le seul moment où vous pouvez parler de la redéfinition de fonctions est pendant l'héritage ; avec un objet membre vous ne pouvez que manipuler l'interface publique de l'objet, pas la redéfinir. De plus, appeler f( ) pour un objet de classe C n'appelerait pas a.f( ) si C::f( ) n'avait pas été défini, tandis que cela appeleraitB::f( ).

Appels au destructeur automatique

Bien qu'il vous faille souvent faire des appels explicites aux constructeurs dans la liste d'initialisation, vous n'avez jamais besoin de faire des appels explicites aux destructeurs parce qu'il n'y a qu'un seul destructeur pour toute classe, et il ne prend aucun argument. Toutefois le compilateur assure encore que tous les destructeurs sont appelés, et cela signifie tous les destructeurs dans toute la hiérarchie, en commençant avec le destructeur le plus dérivé et en remontant à la racine.

Il est important d'insister sur le fait que les constructeurs et les destructeurs sont tout à fait inhabituels en cela en cela que chacun dans la hiérarchie est appelé, tandis qu'avec une fonction membre normale seulement cette fonction est appelée, mais aucune des versions des classes de base. Si vous voulez appeler aussi la version de la classe de base d'une fonction membre normale que vous surchargez, vous devez le faire explicitement.

14.4.1. Ordre des appels des constructeurs & et des destructeurs

Il est intéressant de connaître l'ordre des appels de constructeurs et de destructeurs quand un objet a de nombreux sous-objets. L'exemple suivant montre exactement comment ça marche :

 
Sélectionnez
//: C14:Order.cpp
// Ordre des constructeurs/destructeurs
#include <fstream>
using namespace std;
ofstream out("order.out");
 
#define CLASS(ID) class ID { \
public: \
  ID(int) { out << #ID " : constructeur\n"; } \
  ~ID() { out << #ID " : destructeur\n"; } \
};
 
CLASS(Base1);
CLASS(Member1);
CLASS(Member2);
CLASS(Member3);
CLASS(Member4);
 
class Derived1 : public Base1 {
  Member1 m1;
  Member2 m2;
public:
  Derived1(int) : m2(1), m1(2), Base1(3) {
    out << "Derived1 : constructeur\n";
  }
  ~Derived1() {
    out << "Derived1 : destructeur\n";
  }
};
 
class Derived2 : public Derived1 {
  Member3 m3;
  Member4 m4;
public:
  Derived2() : m3(1), Derived1(2), m4(3) {
    out << "Derived2 : constructeur\n";
  }
  ~Derived2() {
    out << "Derived2 : destructeur\n";
  }
};
 
int main() {
  Derived2 d2;
} ///:~

Tout d'abord, un objet ofstream est créé pour envoyer toute la sortie vers un fichier. Ensuite, pour éviter un peu de frappe et démontrer une technique de macro qui sera remplacée par une technique nettement améliorée dans le chapitre 16, une macro est créée pour construire certaines des classes, qui sont ensuite utilisées dans l'héritage et la composition. Chacun des constructeurs et des destructeurs se signale au fichier de traçage. Notez que les constructeurs ne sont pas les constructeurs par défaut ; chacun d'eux a un argument de type int. L'argument lui-même n'a pas d'identificateur ; sa seule raison d'être est de vous forcer à appeler explicitement les constructeurs dans la liste d'initialisation. (Eliminer l'identificateur a pour objet de supprimer les messages d'avertissement du compilateur.)

La sortie de ce programme est

 
Sélectionnez
Base1 : constructeur
Member1 : constructeur
Member2 : constructeur
Derived1 : constructeur
Member3 : constructeur
Member4 : constructeur
Derived2 : constructeur
Derived2 : destructeur
Member4 : destructeur
Member3 : destructeur
Derived1 : destructeur
Member2 : destructeur
Member1 : destructeur
Base1 : destructeur

Vous pouvez voir que la construction démarre à la racine de la hiérarchie de classe, et qu'à chaque niveau le constructeur de la classe de base est appelé d'abord, suivi par les constructeurs des objets membres. Les destructeurs sont appelés exactement en ordre inverse des constructeurs - c'est important à cause des dépendances potentielles (dans le constructeur ou le destructeur de la classe dérivée, vous devez pouvoir supposer que le sous-objet de la classe de base est toujours disponible, et a déjà été construit - ou bien n'est pas encore détruit).

Il est également intéressant que l'ordre des appels de constructeurs pour les objets membres n'est pas du tout affecté par l'ordre des appels dans la liste d'initialisation du constructeur. L'ordre est déterminé par l'ordre dans lequel les objets membres sont déclarés dans la classe. Si vous pouviez changer l'ordre des appels de constructeurs au moyen de la liste d'initialisation du constructeur, vous pourriez avoir deux suites d'appels différentes dans deux constructeurs différents, mais le pauvre destructeur ne saurait pas comment renverser correctement l'ordre des appels pour la destruction, et vous pourriez finir avec un problème de dépendance.

14.5. Masquage de nom

Si vous dérivez une classe et fournissez une nouvelle définition pour une de ses fonctions membres, il y a deux possibilités. La première est que vous fournissez exactement la même signature et le même type de retour dans la définition de la classe dérivée que dans la définition de la classe de base. On parle alors de redéfinition( redefining en anglais) d'une fonction membre dans le cas d'une fonction membre ordinaire, et de supplantation( overriding en anglais) d'une fonction membre quand celle de la classe de base est virtual(52)(les fonctions virtual sont le cas normal, et seront traitées en détail dans le chapitre 15). Mais que se passe-t-il si vous changez la liste d'arguments ou le type de retour d'une fonction membre dans la classe dérivée ? Voici un exemple :

 
Sélectionnez
//: C14:NameHiding.cpp
// Masquage de noms par héritage
#include <iostream>
#include <string>
using namespace std;
 
class Base {
public:
  int f() const { 
    cout << "Base::f()\n"; 
    return 1; 
  }
  int f(string) const { return 1; }
  void g() {}
};
 
class Derived1 : public Base {
public:
  void g() const {}
};
 
class Derived2 : public Base {
public:
  // Redéfinition :
  int f() const { 
    cout << "Derived2::f()\n"; 
    return 2;
  }
};
 
class Derived3 : public Base {
public:
  // Change le type de retour:
  void f() const { cout << "Derived3::f()\n"; }
};
 
class Derived4 : public Base {
public:
  // Change la liste d'arguments :
  int f(int) const { 
    cout << "Derived4::f()\n"; 
    return 4; 
  }
};
 
int main() {
  string s("salut");
  Derived1 d1;
  int x = d1.f();
  d1.f(s);
  Derived2 d2;
  x = d2.f();
//!  d2.f(s); // version chaîne cachée
  Derived3 d3;
//!  x = d3.f(); // version à retour entier cachée
  Derived4 d4;
//!  x = d4.f(); // f() version cachée
  x = d4.f(1);
} ///:~

Dans Base vous voyez une fonction redéfinie f( ), et Derived1 ne fait aucun changement à f( ) mais redéfinit g( ). Dans main( ), vous pouvez voir que les deux versions surchargées de f( ) sont disponibles dans Derived1. Toutefois, Derived2 redéfinit une version surchargée de f( ) mais pas l'autre, et le résultat est que la seconde forme surchargée n'est pas disponible. Dans Derived3, changer le type de retour masque les deux versions de la classe de base, et Derived4 montre que changer la liste d'arguments masque également les deux versions de la classe de base. En général, nous pouvons dire que chaque fois que vous redéfinissez un nom de fonction de la classe de base, toutes les autres versions sont automatiquement masquées dans la nouvelle classe. Dans le chapitre 15, vous verrez que l'addition du mot clé virtual affecte un peu plus la surcharge des fonctions.

Si vous changez l'interface de la classe de base en modifiant la signature et/ou le type de retour d'une fonction membre de la classe de base, alors vous utilisez la classe d'une manière différente de celle supposée être normalement supportée par le processus d'héritage. Cela ne signifie pas nécessairement que vous ayez tort de procéder ainsi, c'est juste que le but fondamental de l'héritage est de permettre le polymorphisme, et si vous changez la signature ou le type de retour de la fonction, alors vous changez vraiment l'interface de la classe de base. Si c'est ce que vous aviez l'intention de faire alors vous utilisez l'héritage principalement pour réutiliser le code, et non pour maintenir l'interface commune de la classe de base (ce qui est un aspect essentiel du polymorphisme). En général, quand vous utilisez l'héritage de cette manière cela signifie que vous prenez une classe généraliste et que vous la spécialisez pour un besoin particulier - ce qui est considéré d'habitude, mais pas toujours, comme le royaume de la composition.

Par exemple, considérez la classe Stack du chapitre 9. Un des problèmes avec cette classe est qu'il vous fallait réaliser un transtypage à chaque fois que vous récupériez un pointeur du conteneur. C'est non seulement fastidieux, c'est aussi dangereux - vous pourriez transtyper en tout ce que vous voudriez.

Une approche qui semble meilleure à première vue consiste à spécialiser la classe générale Stack en utilisant l'héritage. Voici un exemple qui utilise la classe du chapitre 9 :

 
Sélectionnez
//: C14:InheritStack.cpp
// Spécialisation de la classe Stack
#include "../C09/Stack4.h"
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
 
class StringStack : public Stack {
public:
  void push(string* str) {
    Stack::push(str);
  }
  string* peek() const {
    return (string*)Stack::peek();
  }
  string* pop() {
    return (string*)Stack::pop();
  }
  ~StringStack() {
    string* top = pop();
    while(top) {
      delete top;
      top = pop();
    }
  }
};
 
int main() {
  ifstream in("InheritStack.cpp");
  assure(in, "InheritStack.cpp");
  string line;
  StringStack textlines;
  while(getline(in, line))
    textlines.push(new string(line));
  string* s;
  while((s = textlines.pop()) != 0) { // Pas de transtypage !
    cout << *s << endl;
    delete s;
  }
} ///:~

Etant donné que toutes les fonctions membres dans Stack4.h sont 'inline', aucune édition de liens n'est nécessaire.

StringStack spécialise Stack de sorte que push( ) n'acceptera que des pointeurs String. Avant, Stack acceptait des pointeurs void, aussi l'utilisateur n'avait pas de vérification de type à faire pour s'assurer que des pointeurs corrects étaient insérés. De plus, peek( ) et pop( ) retournent maintenant des pointeurs String au lieu de pointeurs void, de sorte qu'aucun transtypage n'est nécessaire pour utiliser le pointeur.

Aussi étonnant que cela puisse paraître, cette sécurité de contrôle de type supplémentaire est gratuite dans push( ), peek( ), et pop( )! Le compilateur reçoit une information de type supplémentaire qu'il utilise au moment de la compilation, mais les fonctions sont 'inline' et aucun code supplémentaire n'est généré.

Le masquage de nom entre en jeu ici parce que, en particulier, la fonction push( ) a une signature différente : la liste d'arguments est différente. Si vous aviez deux versions de push( ) dans la même classe, cela serait de la redéfinition, mais dans ce cas la redéfinition n'est pas ce que nous voulons parce que cela vous permettrait encore de passer n'importe quel type de pointeur dans push( ) en tant que void*. Heureusement, C++ cache la version de push(void*) dans la classe de base au profit de la nouvelle version qui est définie dans la classe dérivée, et pour cette raison, il nous permet seulement de push(er)( ) des pointeurs string sur la StringStack.

Parce que nous pouvons maintenant garantir que nous savons exactement quels types d'objets sont dans le conteneur, le destructeur travaille correctement et le problème la propriété est résolu - ou au moins, un aspect de ce problème. Ici, si vous push(ez)( ) un pointeur string sur la StringStack, alors (en accord avec la sémantique de la StringStack) vous passez également la propriété de ce pointeur à la StringStack. Si vous pop(ez)( ) le pointeur, non seulement vous obtenez l'adresse, mais vous récupérez également la propriété de ce pointeur. Tous les pointeurs laissés sur la StringStack, lorsque le destructeur de celle-ci est appelé sont alors détruits par ce destructeur. Et comme ce sont toujours des pointeurs string et que l'instruction delete travaille sur des pointeurs string au lieu de pointeurs void, la destruction adéquate a lieu et tout fonctionne correctement.

Il y a un inconvénient : cette classe travaille seulement avec des pointeurs string. Si vous voulez une Stack qui travaille avec d'autres sortes d'objets, vous devez écrire une nouvelle version de la classe qui travaillera seulement avec votre nouvelle sorte d'objets. Cela devient rapidement fastidieux, et finit par se résoudre avec des templates, comme vous le verrez dans le chapitre 16.

Nous pouvons faire une remarque supplémentaire à propos de cet exemple : il change l'interface de la Stack dans le processus d'héritage. Si l'interface est différente, alors une StringStack n'est pas vraiment une Stack, et vous ne pourrez jamais utiliser correctement une StringStack en tant que Stack. Ce qui fait qu'ici, l'utilisation de l'héritage peut être mise en question ; si vous ne créez pas une StringStack qui est-une sorte de Stack, alors pourquoi héritez vous ? Une version plus adéquate de StringStack sera montrée plus loin dans ce chapitre.

14.6. Fonctions qui ne s'héritent pas automatiquement

Toutes les fonctions de la classe de base ne sont pas automatiquement héritées par la classe dérivée. Les constructeurs et destructeurs traitent la création et à la destruction d'un objet, et ils ne peuvent savoir quoi faire qu'avec les aspects de l'objet spécifiques à leur classe particulière, si bien que tous les constructeurs et destructeurs dans la hiérarchie en dessous d'eux doivent être appelés. Ainsi, les constructeurs et destructeurs ne s'héritent pas et doivent être créés spécialement pour chaque classe dérivée.

En plus, le operator= ne s'hérite pas parce qu'il réalise une opération similaire à celle d'un constructeur. C'est-à-dire que le fait que vous sachiez comment affecter tous les membres d'un objet du côté gauche du = depuis un objet situé à droite ne veut pas dire que l'affectation aura encore le même sens après l'héritage.

Au lieu d'être héritées, ces fonctions sont synthétisées par le compilateur si vous ne les créez pas vous-même. (Avec les constructeurs, vous ne pouvez créer aucun constructeur afin que le compilateur synthétise le constructeur par défaut et le constructeur par recopie.) Ceci a été brièvement décrit au Chapitre 6. Les constructeurs synthétisés utilisent l'initialisation membre à membre et l' operator= synthétisé utilise l'affectation membre à membre. Voici un exemple des fonctions qui sont synthétisées par le compilateur :

 
Sélectionnez
//: C14:SynthesizedFunctions.cpp
// Fonctions qui sont synthétisées par le compilateur
#include <iostream>
using namespace std;
 
class GameBoard {
public:
  GameBoard() { cout << "GameBoard()\n"; }
  GameBoard(const GameBoard&) { 
    cout << "GameBoard(const GameBoard&)\n"; 
  }
  GameBoard& operator=(const GameBoard&) {
    cout << "GameBoard::operator=()\n";
    return *this;
  }
  ~GameBoard() { cout << "~GameBoard()\n"; }
};
 
class Game {
  GameBoard gb; // Composition
public:
  // Appel au contructeur de GameBoard par défaut :
  Game() { cout << "Game()\n"; }
  // Vous devez explicitement appeler le constructeur par recopie
  // de GameBoard sinon le constructeur par défaut
  // est appelé automatiquement à la place :
  Game(const Game& g) : gb(g.gb) { 
    cout << "Game(const Game&)\n"; 
  }
  Game(int) { cout << "Game(int)\n"; }
  Game& operator=(const Game& g) {
    // Vous devez appeler explicitement l'opérateur 
    // d'affectation de GameBoard ou bien aucune affectation
    // ne se produira pour gb !
    gb = g.gb;
    cout << "Game::operator=()\n";
    return *this;
  }
  class Other {}; // Classe imbriquée
  // Conversion de type automatique :
  operator Other() const {
    cout << "Game::operator Other()\n";
    return Other();
  }
  ~Game() { cout << "~Game()\n"; }
};
 
class Chess : public Game {};
 
void f(Game::Other) {}
 
class Checkers : public Game {
public:
  // Appel au constructeur par défaut de la classe de base :
  Checkers() { cout << "Checkers()\n"; }
  // Vous devez appeler explicitement le constructeur par recopie
  // de la classe de base ou le constructeur par défaut
  // sera appelé à sa place :
  Checkers(const Checkers& c) : Game(c) {
    cout << "Checkers(const Checkers& c)\n";
  }
  Checkers& operator=(const Checkers& c) {
    // Vous devez appeler explicitement la version de la 
    // classe de base de l'opérateur=() ou aucune affectation 
    // de la classe de base ne se produira :
    Game::operator=(c);
    cout << "Checkers::operator=()\n";
    return *this;
  }
};
 
int main() {
  Chess d1;  // Constructeur par défaut
  Chess d2(d1); // Constructeur par recopie
//! Chess d3(1); // Erreur : pas de constructeur de int
  d1 = d2; // Operateur= synthétisé
  f(d1); // La conversion de type EST héritée
  Game::Other go;
//!  d1 = go; // Operateur= non synthétisé
           // pour les types qui diffèrent
  Checkers c1, c2(c1);
  c1 = c2;
} ///:~

Les constructeurs et l' operator= pour GameBoard et Game se signalent eux-mêmes si bien que vous pouvez voir quand ils sont utilisés par le compilateur. En plus, l' operator Other( ) réalise une conversion de type automatique depuis un objet Game vers un objet de la classe imbriquée Other. La classe Chess hérite simplement de Game et ne crée aucune fonction (pour voir comment réagit le compilateur). La fonction f( ) prend un objet Other pour tester la fonction de conversion de type automatique.

Dans main( ), le constructeur par défaut et le constructeur par recopie synthétisés pour la classe dérivée Chess sont appelés. La version Game de ces constructeurs est appelée comme élément de la hiérarchie des appels aux constructeurs. Même si cela ressemble à de l'héritage, de nouveaux constructeurs sont vraiment synthétisés par le compilateur. Comme vous pourriez le prévoir, aucun constructeur avec argument n'est automatiquement créé parce que cela demanderait trop d'intuition de la part du compilateur.

L' operator= est également synthétisé comme une nouvelle fonction dans Chess, utilisant l'affectation membre à membre (ainsi, la version de la classe de base est appelée) parce que cette fonction n'a pas été écrite explicitement dans la nouvelle classe. Et, bien sûr, le destructeur a été automatiquement synthétisé par le compilateur.

A cause de toutes ces règles concernant la réécriture des fonctions qui gèrent la création des objets, il peut paraître un peu étrange à première vue que l'opérateur de conversion de type automatique soit hérité. Mais ce n'est pas complètement déraisonnable - s'il y a suffisamment d'éléments dans Game pour constituer un objet Other, ces éléments sont toujours là dans toutes classe dérivée de Game et l'opérateur de conversion de type est toujours valide (même si vous pouvez en fait avoir intérêt à le redéfinir).

operator= est synthétisé uniquement pour affecter des objets de même type. Si vous voulez affecter des objets d'un type vers un autre, vous devez toujours écrire cet operator= vous-même.

Si vous regardez Game de plus près, vous verrez que le constructeur par recopie et les opérateurs d'affectation contiennent des appels explicites au constructeur par recopie et à l'opérateur d'affectation de l'objet membre. Vous voudrez normalement procéder ainsi parce que sinon, dans le cas du constructeur par recopie, le constructeur par défaut de l'objet membre sera utilisé à la place, et dans le cas de l'opérateur d'affectation, aucune affectation ne sera faite pour les objets membres !

Enfin, Regardez Checkers, qui rédige explicitement le constructeur par défaut, le constructeur par recopie et les opérateurs d'affectation. Dans le cas du constructeur par défaut, le constructeur par défaut de la classe de base est automatiquement appelé, et c'est typiquement ce que vous voulez. Mais, et c'est un point important, dès que vous décidez d'écrire votre propre constructeur par recopie et votre propre opérateur d'affectation, le compilateur suppose que vous savez ce que vous faites et n'appelle pas automatiquement les versions de la classe de base, comme il le fait dans les fonctions synthétisées. Si vous voulez que les versions de la classe de base soient appelées (et vous le faites typiquement) alors vous devez les appeler explicitement vous-même. Dans le constructeur par recopie de Checkers, cet appel apparaît dans la liste d'initialisation du constructeur :

 
Sélectionnez
Checkers(const Checkers& c) : Game(c) {

Dans l'opérateur d'affectation de Checkers, l'appel à la classe de base est la première ligne du corps de la fonction :

 
Sélectionnez
Game::operator=(c);

Ces appels devraient être des parties de la forme canonique que vous utilisez à chaque fois que vous faites hériter une classe.

14.6.1. Héritage et fonctions membres statiques

Les fonctions membres static agissent de la même façon que les fonctions membres non- static:

  1. Elles sont héritées dans la classe dérivée.
  2. Si vous redéfinissez un membre statique, toutes les autres fonctions surchargées dans la classe de base se retrouvent cachées.
  3. Si vous modifiez la signature d'une fonction de la classe de base, toutes les versions de la classe de base avec ce nom de fonction se retrouvent cachées (c'est en fait une variation sur le point précédent).

Toutefois, les fonctions membres static ne peuvent pas être virtual(sujet traité en détail au Chapitre 15).

14.7. Choisir entre composition et héritage

La composition et l'héritage placent tous deux des sous-objets dans votre nouvelle classe. Tous deux utilisent la liste d'initialisation du constructeur pour construire ces sous-objets. A présent, peut-être que vous vous demandez quel est la différence entre les deux, et quand choisir l'un ou l'autre.

La composition est généralement utilisée quand vous voulez trouver les fonctionnalités d'une classe existante au sein de votre nouvelle classe, mais pas son interface. C'est-à-dire que vous enrobez un objet pour implémenter des fonctionnalités de votre nouvelle classe, mais l'utilisateur de votre nouvelle classe voit l'interface que vous avez définie plutôt que l'interface de la classe d'origine. Pour ce faire, vous suivez la démarche type d'implantation d'objets de classes existantes private dans votre nouvelle classe.

Parfois, cependant, il est logique de permettre à l'utilisateur de la classe d'accéder directement à la composition de votre nouvelle classe, c'est-à-dire de rendre l'objet membre public. Les objets membres utilisent eux-mêmes le contrôle d'accès, et c'est donc une démarche sure et quand l'utilisateur sait que vous assemblez différents morceaux, cela rend l'interface plus compréhensible. Une classe Car(Voiture, NdT) est un bon exemple :

 
Sélectionnez
//: C14:Car.cpp
// Composition publique
 
class Engine {
public:
  void start() const {}
  void rev() const {}
  void stop() const {}
};
 
class Wheel {
public:
  void inflate(int psi) const {}
};
 
class Window {
public:
  void rollup() const {}
  void rolldown() const {}
};
 
class Door {
public:
  Window window;
  void open() const {}
  void close() const {}
};
 
class Car {
public:
  Engine engine;
  Wheel wheel[4];
  Door left, right; // 2-portes
};
 
int main() {
  Car car;
  car.left.window.rollup();
  car.wheel[0].inflate(72);
} ///:~

Comme la composition d'un Car fait partie de l'analyse du problème (et pas simplement de la conception sous-jacente), rendre les membres public aide le programmeur-client à comprendre comment utiliser la classe et requiert un code moins complexe de la part du créateur de la classe.

En y réfléchissant un peu, vous verrez aussi que cela n'aurait aucun sens de composer un Car en utilisant un objet “Véhicule” - une voiture ne contient pas un véhicule, c' est un véhicule. La relation est un est exprimée par l'héritage, et la relation a un est exprimée par la composition.

14.7.1. Sous-typer

A présent, supposez que vous vouliez créer un objet de type ifstream qui non seulement ouvre un fichier mais en plus conserve le nom du fichier. Vous pouvez utiliser la composition et inclure un objet ifstream et un objet string dans la nouvelle classe :

 
Sélectionnez
//: C14:FName1.cpp
// Un fstream avec nom de fichier
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
 
class FName1 {
  ifstream file;
  string fileName;
  bool named;
public:
  FName1() : named(false) {}
  FName1(const string& fname) 
    : fileName(fname), file(fname.c_str()) {
    assure(file, fileName);
    named = true;
  }
  string name() const { return fileName; }
  void name(const string& newName) {
    if(named) return; // N'écrasez pas
    fileName = newName;
    named = true;
  }
  operator ifstream&() { return file; }
};
 
int main() {
  FName1 file("FName1.cpp");
  cout << file.name() << endl;
  // Erreur: close() n'est pas un membre :
//!  file.close();
} ///:~

Il y a un problème, cependant. On essaye de permettre l'utilisation de l'objet FName1 partout où un objet ifstream est utilisé en incluant un opérateur de conversion de type automatique de FName1 vers un ifstream&. Mais dans main, la ligne

 
Sélectionnez
file.close();

ne compilera pas car la conversion de type automatique n'a lieu que dans les appels de fonctions, pas pendant la sélection de membre. Cette approche ne fonctionnera donc pas.

Une deuxième approche consiste à ajouter la définition de close( ) à FName1:

 
Sélectionnez
void close() { file.close(); }

Ceci ne fonctionnera que si il n'y a que quelques fonctions que vous voulez apporter depuis la classe ifstream. Dans ce cas, vous n'utilisez que des parties de la classe et la composition est appropriée.

Mais que se passe-t-il si vous voulez que tous les éléments de la classe soient transposés ? On appelle cela sous-typage parce que vous créez un nouveau type à partir d'un type existant, et vous voulez que votre nouveau type ait exactement la même interface que le type existant (plus toutes les autres fonctions membres que vous voulez ajouter), si bien que vous puissiez l'utiliser partout où vous utiliseriez le type existant. C'est là que l'héritage est essentiel. Vous pouvez voir que le sous-typage résoud parfaitement le problème de l'exemple précédent :

 
Sélectionnez
//: C14:FName2.cpp
// Le sous-typage résoud le problème
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
 
class FName2 : public ifstream {
  string fileName;
  bool named;
public:
  FName2() : named(false) {}
  FName2(const string& fname)
    : ifstream(fname.c_str()), fileName(fname) {
    assure(*this, fileName);
    named = true;
  }
  string name() const { return fileName; }
  void name(const string& newName) {
    if(named) return; // N'écrasez pas
    fileName = newName;
    named = true;
  }
};
 
int main() {
  FName2 file("FName2.cpp");
  assure(file, "FName2.cpp");
  cout << "name: " << file.name() << endl;
  string s;
  getline(file, s); // Cela fonctionne aussi !
  file.seekg(-200, ios::end);
  file.close();
} ///:~

A présent, toute fonction membre disponible pour un objet ifstream l'est également pour un objet FName2. Vous pouvez constater aussi que des fonctions non membres comme getline( ) qui attendent un ifstream peuvent également fonctionner avec un FName2. C'est le cas parce qu'un FName2est un type d' ifstream; il n'en contient pas simplement un. C'est un problème très important qui sera abordé à la fin de ce chapitre et dans le suivant.

14.7.2. héritage privé

Vous pouvez hériter d'une classe de base de manière privée en laissant de coté le public dans la liste des classes de base, ou en disant explicitement private(probablement une meilleure stratégie parce qu'alors c'est clair pour l'utilisateur que c'est ce que vous voulez). Quand vous héritez de façon privée, vous “implémentez en termes de” c'est-à-dire que vous créez une nouvelle classe qui a toutes les données et les fonctionnalités de la classe de base, mais ces fonctionnalités sont cachées, si bien qu'elles font seulement partie de l'implémentation sous-jacente. L'utilisateur de la classe n'a aucun accès à la fonctionnalité sous-jacente, et un objet ne peut pas être traité comme une instance de la classe de base (comme c'était le cas dans FName2.cpp).

Peut-être vous demandez-vous le but de l'héritage privée, parce que l'alternative d'utiliser la composition pour créer un objet privé dans la nouvelle classe semble plus appropriée. L'héritage privé est inclus dans le langage pour des raisons de complétude, mais ne serait-ce que pour réduire les sources de confusion, vous aurez généralement intérêt à utiliser la composition plutôt que l'héritage privé. Toutefois, il peut y avoir parfois des situations où vous voulez produire une partie de la même interface que la classe de base et ne pas autoriser le traitement de l'objet comme s'il était du type de la classe de base. L'héritage privé fournit cette possibilité.

Rendre publics les membres hérités de manière privée

Quand vous héritez de manière privée, toutes les fonctions membres public de la classe de base deviennent private. Si vous voulez que n'importe lesquelles d'entre elles soit visibles, dites simplement leur nom (sans argument ni valeur de retour) avec le mot-clef using dans la section public de la classe dérivée :

 
Sélectionnez
//: C14:PrivateInheritance.cpp
class Pet {
public:
  char eat() const { return 'a'; }
  int speak() const { return 2; }
  float sleep() const { return 3.0; }
  float sleep(int) const { return 4.0; }
};
 
class Goldfish : Pet { // Héritage privé
public:
  using Pet::eat; // Nommez les membres à rendre public
  using Pet::sleep; // Les deux fonctions surchargées sont exposées
};
 
int main() {
  Goldfish bob;
  bob.eat();
  bob.sleep();
  bob.sleep(1);
//! bob.speak();// Erreur : fonction membre privée
} ///:~

Ainsi, l'héritage privé est utile si vous voulez dissimuler une partie des fonctionnalités de la classe de base.

Notez qu' exposer le nom d'une fonction surchargée rend publiques toutes les versions de la fonction surchargée dans la classe de base.

Vous devriez bien réfléchir avant d'utiliser l'héritage privé au lieu de la composition ; l'héritage privé entraîne des complications particulières quand il est combiné avec l'identification de type à l'exécution (RTTI NdT) (c'est le sujet d'un chapitre du deuxième volume de ce livre, téléchargeable depuis www.BruceEckel.com).

14.8. protected

A présent que vous avez été initiés à l'héritage, le mot-clef protected prend finalement un sens. Dans un monde idéal, les membres private seraient toujours strictement private, mais dans les projets réels vous voulez parfois dissimuler quelque chose au monde en général et en même temps autoriser l'accès pour les membres des classes dérivées. Le mot-clef protected est une concession au pragmatisme; il signifie “Ceci est private en ce qui concerne l'utilisateur de la classe, mais disponible à quiconque hérite de cette classe.”

La meilleure approche est de laisser les données membres private- vous devriez toujours préserver votre droit de modifier l'implémentation sous-jacente. Vous pouvez alors autoriser un accès controllé aux héritiers de votre classe grâce aux fonctions membres protected:

 
Sélectionnez
//: C14:Protected.cpp
// Le mot-clef protected 
#include <fstream>
using namespace std;
 
class Base {
  int i;
protected:
  int read() const { return i; }
  void set(int ii) { i = ii; }
public:
  Base(int ii = 0) : i(ii) {}
  int value(int m) const { return m*i; }
};
 
class Derived : public Base {
  int j;
public:
  Derived(int jj = 0) : j(jj) {}
  void change(int x) { set(x); }
}; 
 
int main() {
  Derived d;
  d.change(10);
} ///:~

Vous trouverez des exemples de la nécessité de protected dans des exemples situés plus loin dans ce livre, et dans le Volume 2.

14.8.1. héritage protégé

Quand vous héritez, la classe de base est private par défaut, ce qui signifie que toutes les fonctions membres publiques sont private pour l'utilisateur de la nouvelle classe. Normalement, vous rendrez l'héritage public afin que l'interface de la classe de base soit également celle de la classe dérivée. Toutefois, vous pouvez également utiliser le mot-clef protected pendant l'héritage.

La dérivation protégée signifie “ implémenté en termes de” pour les autres classes mais “est un” pour les classes dérivées et les amis. C'est quelque chose dont on ne se sert pas très souvent, mais cela se trouve dans le langage pour sa complétude.

14.9. Surcharge d'opérateur & héritage

A part l'opérateur d'affectation, les opérateurs sont automatiquement hérités dans une classe dérivée. Ceci peut se démontrer en héritant de C12:Byte.h:

 
Sélectionnez
//: C14:OperatorInheritance.cpp
// Hériter des opérateurs surchargés
#include "../C12/Byte.h"
#include <fstream>
using namespace std;
ofstream out("ByteTest.out");
 
class Byte2 : public Byte {
public:
  // Les constructeurs ne s'héritent pas :
  Byte2(unsigned char bb = 0) : Byte(bb) {}  
  // opérateur = ne s'hérite pas, mais 
  // est fabriqué pour les assignations liées aux membres.
  // Toutefois, seul l'opérateur SameType = SameType
  // est fabriqué, et vous devez donc
  // fabriquer les autres explicitement :
  Byte2& operator=(const Byte& right) {
    Byte::operator=(right);
    return *this;
  }
  Byte2& operator=(int i) { 
    Byte::operator=(i);
    return *this;
  }
};
 
// Fonction test similaire à celle contenue dans C12:ByteTest.cpp:
void k(Byte2& b1, Byte2& b2) {
  b1 = b1 * b2 + b2 % b1;
 
  #define TRY2(OP) \
    out << "b1 = "; b1.print(out); \
    out << ", b2 = "; b2.print(out); \
    out << ";  b1 " #OP " b2 produces "; \
    (b1 OP b2).print(out); \
    out << endl;
 
  b1 = 9; b2 = 47;
  TRY2(+) TRY2(-) TRY2(*) TRY2(/)
  TRY2(%) TRY2(^) TRY2(&) TRY2(|)
  TRY2(<<) TRY2(>>) TRY2(+=) TRY2(-=)
  TRY2(*=) TRY2(/=) TRY2(%=) TRY2(^=)
  TRY2(&=) TRY2(|=) TRY2(>>=) TRY2(<<=)
  TRY2(=) // Assignment operator
 
  // Instructions conditionnelles :
  #define TRYC2(OP) \
    out << "b1 = "; b1.print(out); \
    out << ", b2 = "; b2.print(out); \
    out << ";  b1 " #OP " b2 produces "; \
    out << (b1 OP b2); \
    out << endl;
 
  b1 = 9; b2 = 47;
  TRYC2(<) TRYC2(>) TRYC2(==) TRYC2(!=) TRYC2(<=)
  TRYC2(>=) TRYC2(&&) TRYC2(||)
 
  // Assignation en chaîne :
  Byte2 b3 = 92;
  b1 = b2 = b3;
}
 
int main() {
  out << "member functions:" << endl;
  Byte2 b1(47), b2(9);
  k(b1, b2);
} ///:~

Le code test est identique à celui utilisé dans C12:ByteTest.cpp sauf que Byte2 est utilisé à la place de Byte. De cette façon, on vérifie que tous les opérateurs fonctionnent avec Byte2 via l'héritage.

Quand vous examinez la classe Byte2, vous constatez que le constructeur doit être explicitement défini, et que, seul, operator= qui assigne un Byte2 à un Byte2 est fabriqué automatiquement; tout autre opérateur d'assignation dont vous avez besoin devra être fabriqué par vos soins.

14.10. Héritage multiple

Vous pouvez hériter d'une classe, et il semblerait donc logique d'hériter de plus d'une seule classe à la fois. C'est, de fait, possible, mais savoir si cela est cohérent du point de vue de la conception est un sujet de débat perpétuel. On s'accorde généralement sur une chose : vous ne devriez pas essayer de le faire avant d'avoir programmé depuis quelques temps et de comprendre le langage en profondeur. A ce moment-là, vous réaliserez probablement que peu importe à quel point vous pensez avoir absolument besoin de l'héritage multiple, vous pouvez presque toujours vous en tirer avec l'héritage unique.

A première vue, l'héritage multiple semble assez simple : vous ajoutez plus de classes dans la liste des classes de base pendant l'héritage, en les séparant par des virgules. Toutefois, l'héritage multiple introduit nombre de possibilités d'ambigüités, et c'est pourquoi un chapitre est consacré à ce sujet dans le Volume 2.

14.11. Développement incrémental

Un des avantages de l'héritage et de la composition est qu'ils soutiennent le développement incrémental en vous permettant d'introduire du nouveau code sans causer de bug dans le code existant. Si des bugs apparaissent, ils sont restreints au sein du nouveau code. En héritant de (ou composant avec) une classe existante fonctionnelle et en ajoutant des données et fonctions membres (et en redéfinissant des fonctions membres existantes pendant l'héritage) vous ne touchez pas au code existant - que quelqu'un d'autre peut encore être en train d'utiliser - et n'introduisez pas de bugs. Si un bug se produit, vous savez qu'il se trouve dans votre nouveau code, qui est beaucoup plus court et plus facile à lire que si vous aviez modifié le corps du code existant.

La façon dont les classes sont séparées est assez étonnante. Vous n'avez même pas besoin du code source des fonctions membres pour réutiliser le code, simplement du fichier d'en-tête décrivant la classe et le fichier objet ou le fichier de la bibliothèque avec les fonctions membres compilées. (Ceci est vrai pour l'héritage et la composition à la fois.)

Il est important de réaliser que le développement de programmes est un processus incrémental, exatement comme l'apprentissage chez l'homme. Vous pouvez faire toute l'analyse que vous voulez, mais vous ne connaîtrez toujours pas toutes les réponses quand vous démarrerez un projet. Vous aurez plus de succès - et plus de retour immédiat - si vous commencez à faire “croître” votre projet comme une créature organique, évolutionnaire plutôt qu'en le construisant entièrement d'un coup, comme un gratte-ciel (53).

Bien que l'héritage pour l'expérimentation soit une technique très utile, après que les choses soient un peu stabilisées vous avez besoin d'examiner à nouveau la hiérarchie de votre classe pour la réduire à une structure rationnelle. (54). Rappelez-vous que par dessus tout cela, l'héritage a pour fonction d'exprimer une relation qui dit, “Cette nouvelle classe est un type de cette ancienne classe”. Votre programme ne devrait pas être occupé à pousser des bits ici et là, mais plutôt à créer et manipuler des objets de types variés pour exprimer un modèle dans les termes qui vous sont fixés par l'espace du problème.

14.12. Transtypage ascendant

Plus tôt dans ce chapitre, vous avez vu comment un objet d'une classe dérivée de ifstream possède toutes les caractéristiques et le comportement d'un objet ifstream. Dans FName2.cpp, toute fonction membre de ifstream peut être appelée pour un objet FName2.

L'aspect le plus important de l'héritage n'est toutefois pas qu'il fournisse des fonctions membres à la nouvelle classe. C'est la relation exprimée entre la nouvelle classe et la classe de base. Cette relation peut être résumée en disant "La nouvelle classe est un type de la classe existante".

Cette description n'est pas simplement une jolie manière d'expliquer l'héritage. En guise d'exemple, considérez une classe de base appelée Instrument qui représente les instruments musicaux et une classe dérivée appelée Wind(vent, ndt). Comme l'héritage signifie que toutes les fonctions de la classe de base sont aussi disponibles dans la classe dérivée, tout message qui peut être envoyé à la classe de base peut également l'être à la classe dérivée. Donc si la classe Instrument a une fonction membre play( )(jouer, ndt), Wind en aura une également. Ceci signifie que nous pouvons dire avec justesse qu'un objet Wind est également un type d' Instrument. L'exemple suivant montre comment le compilateur supporte cette notion :

 
Sélectionnez
//: C14:Instrument.cpp
// Héritage & transtypage ascendant
enum note { middleC, Csharp, Cflat }; // Etc.
 
class Instrument {
public:
  void play(note) const {}
};
 
// Les objets Wind sont des Instruments
// parce qu'ils ont la même interface :
class Wind : public Instrument {};
 
void tune(Instrument& i) {
  // ...
  i.play(middleC);
}
 
int main() {
  Wind flute;
  tune(flute); // Transtypage ascendant
} ///:~

Ce qui est intéressant dans cet exemple est la fonction tune( ), qui accepte une référence Instrument. Toutefois, dans main( ) la fonction tune( ) est appelée en lui passant une référence vers un objet Wind. Etant donné que le C++ est très exigeant avec la vérification de type, il semble surprenant qu'une fonction qui accepte un type en accepte facilement un autre, jusqu'à ce que l'on réalise qu'un objet Wind est également un objet Instrument, et il n'y a aucune fonction que tune( ) pourrait appeler pour un Instrument qui ne soit pas également dans Wind(c'est ce que garantit l'héritage). Dans tune( ), le code fonctionne pour Instrument et tout ce qui en dérive, et l'action de convertir une référence ou un pointeur Wind en une référence ou un pointeur Instrument est appelée transtypage ascendant( upcasting an anglais).

14.12.1. Pourquoi "ascendant" ?

L'origine du mot est historique et repose sur la façon dont les diagrammes d'héritage des classes sont traditionnellement dessinés : avec la racine en haut de la page, croissant vers le bas. (Bien sûr, vous pouvez dessiner vos diagrammes de la façon qui vous convient.) Le diagramme d'héritage pour Instrument.cpp est alors :

Image non disponible

Transtyper (casting, ndt) depuis une classe dérivée vers une classe de base entraîne un déplacement vers le haut (up, ndt) sur le diagramme d'héritage, et on parle donc communément de transtypage ascendant. Le transtypage ascendant est toujours fiable parce que vous partez d'un type spécifique vers un type plus général : la seule chose qui peut arriver à l'interface de la classe est qu'elle peut perdre des fonctions membres, pas en gagner. C'est pourquoi le compilateur autorise le transtypage ascendant sans transtypage (cast, ndt) explicite ou tout autre notation spéciale.

14.12.2. Le transtypage ascendant et le constructeur de copie

Si vous autorisez le compilateur à synthétiser un constructeur de copie pour une classe dérivée, il appellera automatiquement le constructeur de copie de la classe de base, puis les constructeurs de copie de tous les objets membres (ou bien réalisera une copie de bits des types prédéfinis) si bien que vous obtiendrez le bon comportement :

 
Sélectionnez
//: C14:CopyConstructor.cpp
// Créer correctement le constructeur de copie
#include <iostream>
using namespace std;
 
class Parent {
  int i;
public:
  Parent(int ii) : i(ii) {
    cout << "Parent(int ii)\n";
  }
  Parent(const Parent& b) : i(b.i) {
    cout << "Parent(const Parent&)\n";
  }
  Parent() : i(0) { cout << "Parent()\n"; }
  friend ostream&
    operator<<(ostream& os, const Parent& b) {
    return os << "Parent: " << b.i << endl;
  }
};
 
class Member {
  int i;
public:
  Member(int ii) : i(ii) {
    cout << "Member(int ii)\n";
  }
  Member(const Member& m) : i(m.i) {
    cout << "Member(const Member&)\n";
  }
  friend ostream&
    operator<<(ostream& os, const Member& m) {
    return os << "Member: " << m.i << endl;
  }
};
 
class Child : public Parent {
  int i;
  Member m;
public:
  Child(int ii) : Parent(ii), i(ii), m(ii) {
    cout << "Child(int ii)\n";
  }
  friend ostream&
    operator<<(ostream& os, const Child& c){
    return os << (Parent&)c << c.m
              << "Child: " << c.i << endl;
  }
};
 
int main() {
  Child c(2);
  cout << "calling copy-constructor: " << endl;
  Child c2 = c; // Appelle le constructeur de copie
  cout << "values in c2:\n" << c2;
} ///:~

operator<< pour Child est intéressant à cause de la façon dont il appelle le operator<< pour la partie Parent qu'il contient : en transtypant l'objet Child en un Parent&(si vous transtypez vers un objet de la classe de base au lieu d'une référence vous obtiendrez généralement des résultats indésirables) :

 
Sélectionnez
return os &lt;&lt; (Parent&)c &lt;&lt; c.m

Puisque le compilateur le voit ensuite comme un Parent, il appelle la version Parent de operator<<.

Vous pouvez constater que Child n'a pas de constructeur de copie défini explicitement. Le compilateur doit alors synthétiser le constructeur de copie (comme c'est une des quatre fonctions qu'il synthétise, ainsi que le constructeur par défaut - si vous ne créez aucun constructeur -, operator= et le destructeur) en appelant le constructeur de copie de Parent et le constructeur de copie de Member. Ceci est démontré dans la sortie du programme.

 
Sélectionnez
Parent(int ii)
Member(int ii)
Child(int ii)
calling copy-constructor:
Parent(const Parent&)
Member(const Member&)
values in c2:
Parent: 2
Member: 2
Child: 2

Toutefois, si vous essayez d'écrire votre propre constructeur de copie pour Child et que vous faites une erreur innocente et le faites mal :

 
Sélectionnez
Child(const Child& c) : i(c.i), m(c.m) {}

alors le constructeur par défaut sera automatiquement appelé pour la partie classe de base de Child, puisque c'est ce sur quoi le compilateur se rabat quand il n'a pas d'autre choix de constructeur à appeler (souvenez-vous qu'un constructeur doit toujours être appelé pour chaque objet, que ce soit un sous-objet ou une autre classe). La sortie sera alors :

 
Sélectionnez
Parent(int ii)
Member(int ii)
Child(int ii)
calling copy-constructor:
Parent()
Member(const Member&)
values in c2:
Parent: 0
Member: 2
Child: 2

Ce n'est probablement pas ce à quoi vous vous attendiez, puisqu'en général vous aurez intérêt à ce que la partie classe de base de l'objet soit copiée depuis l'objet existant vers le nouvel objet ; cette étape devrait être une partie de la construction de copie.

Pour pallier ce problème vous devez vous souvenir d'appeler correctement le constructeur de copie de la classe de base (comme le fait le compilateur) à chaque fois que vous écrivez votre propre constructeur de copie. Ceci peut paraître étrange à première vue, mais c'est un autre exemple de transtypage ascendant :

 
Sélectionnez
  Child(const Child& c)
    : Parent(c), i(c.i), m(c.m) {
    cout << "Child(Child&)\n";
 }

La partie bizarre est celle où le contructeur de copie de Parent est appelé : Parent(c). Qu'est-ce que cela signifie de passer un objet Child au constructeur d'un Parent? Mais Child hérite de Parent, si bien qu'une référence vers un Childest une référence vers un Parent. L'appel au constructeur de copie de la classe de base réalise l'upcast d'une référence vers Child en une référence vers Parent et l'utilise pour réaliser la construction de copie. Quand vous écrirez vos propres constructeurs de copie, vous aurez presque toujours intérêt à procéder ainsi.

14.12.3. Composition vs. héritage (révisé)

Une des façons les plus claires pour déterminer si vous devriez utiliser la composition ou bien l'héritage est en vous demandant si vous aurez jamais besoin d'upcaster depuis votre nouvelle classe. Plus tôt dans ce chapitre, la classe Stack a été spécialisée en utilisant l'héritage. Toutefois, il y a des chances pour que les objets StringStack soient toujours utilisés comme des conteneurs de string et jamais upcastés, si bien qu'une alternative plus appropriée est la composition :

 
Sélectionnez
//: C14:InheritStack2.cpp
// Composition vs. héritage
#include "../C09/Stack4.h"
#include "../require.h"
#include <iostream>
#include <fstream>
#include <string>
using namespace std;
 
class StringStack {
  Stack stack; // Encapsulé au lieu d'être hérité
public:
  void push(string* str) {
    stack.push(str);
  }
  string* peek() const {
    return (string*)stack.peek();
  }
  string* pop() {
    return (string*)stack.pop();
  }
};
 
int main() {
  ifstream in("InheritStack2.cpp");
  assure(in, "InheritStack2.cpp");
  string line;
  StringStack textlines;
  while(getline(in, line))
    textlines.push(new string(line));
  string* s;
  while((s = textlines.pop()) != 0) // Pas de transtypage !
    cout << *s << endl;
} ///:~

Le fichier est identique à InheritStack.cpp, sauf qu'un objet Stack est encapsulé dans StringStack, et des fonctions membres sont appelées pour l'objet encapsulé. Il n'y a toujours aucun temps ou espace système excédentaire parce que le sous-objet occupe la même quantité d'espace, et toutes les vérifications de type additionnelles ont lieu à la compilation.

Bien que cela tende à être plus déroutant vous pourriez également utiliser l'héritage private pour exprimer l'idée "implémenté en termes de". Ceci résoudrait également le problème de manière adéquate. Une situation où cela devient important, toutefois, est lorsque l'héritage multiple doit être assuré. Dans ce cas, si vous voyez une forme pour laquelle la composition peut être utilisée plutôt que l'héritage, vous devriez pouvoir éliminer le besoin d'héritage multiple.

14.12.4. Transtypage ascendant de pointeur & de reference

Dans Instrument.cpp, le transtypage ascendant se produit lors de l'appel de la fonction - on prend la référence vers un objet Wind en-dehors de la fonction et elle devient une référence vers un Instrument à l'intérieur de la fonction. Le transtypage ascendant peut également se produire pendant une simple assignation à un pointeur ou une référence :

 
Sélectionnez
Wind w;
Instrument* ip = &w; // Upcast
Instrument& ir = w; // Upcast

Comme l'appel de fonction, aucun de ces deux cas ne requiert de transtypage explicite.

14.12.5. Une crise

Bien sûr, tout upcast perd de l'information de type concernant un objet. Si vous écrivez :

 
Sélectionnez
Wind w;
Instrument* ip = &amp;w;

le compilateur ne peut traiter ip que comme un poiteur vers un Instrument et rien d'autre. C'est-à-dire qu'il ne peut savoir qu' ip est en fait un pointeur vers un objet Wind. Si bien que quand vous appelez la fonction membre play( ) en disant :

 
Sélectionnez
ip->play(middleC);

le compilateur peut savoir uniquement qu'il est en train d'appeler play( ) pour un pointeur vers un Instrument, et appelle la version de la classe de base de Instrument::play( ) au lieu de ce qu'il devrait faire, à savoir appeler Wind::play( ). Ainsi, vous n'obtiendrez pas le comportement correct.

Ceci est un vrai problème ; il est résolu au Chapitre 15 en introduisant la troisième pierre angulaire de la programmetion orientée objet : le polymorphisme (implémenté en C++ par les fonctions virtual(virtuelles, ndt)).

14.13. Résumé

L'héritage et la composition vous permettent tous deux de créer un nouveau type à partir de types existants, et tout deux enrobent des sous-objets des types existants dans le nouveau type. En général, toutefois, on utilise la composition pour réutiliser des types existants comme éléments de l'implémentation sous-jacente du nouveau type et l'héritage quand on veut forcer le nouveau type à être du même type que la classe de base (l'équivalence de type garantit l'équivalence d'interface). Comme la classe dérivée possède l'interface de la classe de base, elle peut être transtypée vers la classe de base, ce qui est critique pour le polymorphisme comme vous le verrez au Chapitre 15.

Bien que le code réutilisé via la composition et l'héritage est très pratique pour le développement rapide de projets, vous aurez souvent intérêt à re-concevoir votre hiérarchie de classe avant d'autoriser d'autres programmeurs à en dépendre. Votre but est une hiérarchie dans laquelle chaque classe a un usage spécifique et n'est ni trop grande (englobant tellement de fonctionnalités qu'elle n'est pas pratique à réutiliser) ni petite au point d'être gênante (vous ne pouvez vous en servir toute seule ou sans ajouter de fonctionnalité).

14.14. Exercices

Les solutions aux exercices choisis peuvent être trouvées dans le document électronique The Thinking in C++ Annotated Solution Guide, disponible sur www.BruceEckel.com pour une somme modique.

  1. Modifiez Car.cpp de sorte qu'il hérite aussi d'une classe appelée Vehicle, en mettant les fonction membres appropriées dans Vehicle(c'est à dire construire quelques fonctions membres). Ajoutez un constructeur autre que celui par défaut à Vehicle, que vous devez appeler à l'intérieur du constructeur de Car
  2. Créez deux classes, A et B, avec des constructeurs par défaut qui s'annoncent eux-même. Héritez une nouvelle classe C à partir de A, et créez un objet membre de type B dans C, mais ne créez pas de constructeur pour C. Créez un objet de classe C et observez les résultats.
  3. Faites une hiérarchie de classes à trois niveaux avec des constructeurs par défaut, accompagnés de destructeurs, qui s'annoncent tous les deux sur cout. Vérifiez que pour un objet de type le plus dérivé, les trois constructeurs et destructeurs sont appelés automatiquement. Expliquez l'ordre dans lequel les appels sont effectués.
  4. Modifiez Combined.cpp pour ajouter un autre niveau d'héritage et un nouvel objet membre. Ajoutez du code pour montrez quand les destructeurs et les constructeurs sont appelés.
  5. Dans Combined.cpp, créez une classe D qui hérite de B et a un objet membre de classe C. Rajoutez du code pour montrer quand les constructeurs et les destructeurs sont appelés
  6. Modifiez Order.cpp pour ajouter un autre niveau d'héritage Derived3 avec des membres de classes Member4 et Member5. Faites une trace de la sortie du programme.
  7. Dans NameHiding.cpp, vérifiez que dans Derived2, Derived3, et Derived4, aucune des versions de f( ) provenant de la classe de base ne sont disponibles.
  8. Modifiez NameHiding.cpp en ajoutant trois fonctions surchargées h( ) à Base, et montrez qu'en redéfinir une dans une classe dérivée masque les autres.
  9. Héritez une classe StringVector de vector<void*> et redéfinissez les fonctions membres push_back( ) et operator[] pour accepter et produire des string*. Qu'est-ce qui arrive si vous essayez de push_back( ) un void*?
  10. Ecrivez une classe contenant un long et utilisez la syntaxe d'appel du pseudo-constructeur dans le constructeur pour initialiser le long.
  11. Créez une classe appelée Asteroid. Utilisez l'héritage pour spécialiser la classe PStash dans le Chapitre 13 ( PStash.h& PStash.cpp) de sorte qu'elle accepte et retourne des pointeurs Asteroid. Egalement, modifiez PStashTest.cpp pour tester vos classes. Changez la classe pour que PStash soit un objet membre.
  12. Refaites l'exercice 11 avec un vector au lieu d'un PStash.
  13. Dans SynthesizedFunctions.cpp, modifiez Chess pour lui donner un constructeur par défaut, un constructeur par recopie, et un opérateur d'affectation. Prouvez que vous les avez écrits correctement.
  14. Créez deux classes appelées Traveler et Pager sans constructeur par défaut, mais avec des constructeur qui prennent un argument de type string, qu'ils copient simplement dans une variable membre string. Pour chaque classe, écrivez le constructeur par copie et l'opérateur d'affectation corrects. Héritez maintenant une classe BusinessTraveler de Traveler et donnez lui un objet membre de type Pager. Ecrivez le constructeur par défaut correct, le constructeur qui prend un argument string, un constructeur par recopie, et un opérateur d'affectation.
  15. Créez une classe avec deux fonctions membres static. Héritez de cette classe et redéfinissez l'une des deux fonctions membres. Montrez que l'autre est cachée dans la classe dérivée.
  16. Examinez plus les fonctions de ifstream. Dans FName2.cpp, essayez les sur l'objet file.
  17. Utilisez l'héritage private et protected pour faire deux nouvelles classes d'une classe de base. Essayez ensuite de transtyper les objets dérivés dans la classe de base. Expliquez ce qui arrive.
  18. Dans Protected.cpp, rajoutez une fonction membre à Derived qui appelle le membre protectedread( ) de Base.
  19. Changez Protected.cpp pour que Derived utilise l'héritage protected. Voyez si vous pouvez appeler value( ) pour un objet Derived.
  20. Créez une classe SpaceShip avec une méthode fly( ). Héritez Shuttle de SpaceShip et ajoutez une méthode land( ). Créez un nouvel objet Shuttle, transtypez le par pointeur ou référence en SpaceShip, et essayez d'appeler la méthode land( ). Expliquez les résultats.
  21. Modifiez Instrument.cpp pour ajouter une méthode prepare( ) à Instrument. Appelez prepare( ) dans tune( ).
  22. Modifiez Instrument.cpp pour que play( ) affiche un message sur cout, et Wind redéfinisse play( ) pour afficher un message différent sur cout. Exécutez le programme et expliquez pourquoi vous ne voudriez probablement pas ce comportement. Maintenant mettez le mot-clé virtual(que vous apprendrez dans le chapitre 15) devant la déclaration de play( ) dans Instrument et observez le changement de comportement.
  23. Dans CopyConstructor.cpp, héritez une nouvelle classe de Child et donnez-lui un membre m. Ecrivez un constructeur, un constructeur par recopie, un opérateur =, et un opérateur << pour ostream adaptés, et testez la classe dans main( ).
  24. Prenez l'exemple de CopyConstructor.cpp et modifiez le en ajoutant votre propre constructeur par recopie à Childsans appeler le constructeur par recopie de la classe de base et voyez ce qui arrive. Corrigez le problème en faisant un appel explicite correct au constructeur par recopie de la classe de base dans liste d'initialisation du constructeur par recopie de Child
  25. Modifiez InheritStack2.cpp pour utiliser un vector<string> au lieu d'un Stack.
  26. Créez une classe Rock avec un constructeur par défaut, un constructeur par recopie, un opérateur d'affectation, et un destructeur qui annoncent tous sur cout qu'ils ont été appelés. Dans main( ), créez un vector<Rock>(c'est à dire enregistrer des objets Rock objects par valeur) et ajoutez quelques Rock s. Exécutez le programme et expliquez la sortie que vous obtenez. Notez si les destructeurs sont appelés pour les objets Rock dans le vector. Maintenant répétez l'exercice avec un vector<Rock*>. Est-il possible de créer un vector<Rock&>?
  27. Cet exercice crée le modèle de conception appelé proxy. Commencez avec une classe de base Subject et donnez lui trois fonctions : f( ), g( ), et h( ). Maintenant héritez une classe Proxy et deux classes Implementation1 et Implementation2 de Subject. Proxy devrait contenir un pointeur vers un Subject, et toutes les méthodes pour Proxy devraient juste prendre le relais et faire les même appels à travers le pointeur de Subject. Le constructeur de Proxy prend un pointeur vers un Subject qui est installé dans Proxy(d'habitude par le constructeur). Dans main( ), créez deux objets Proxy différents qui utilisent deux implémentations différentes. Maintenant modifiez Proxy pour que vous puissiez changer dynamiquement d'implémentation
  28. Modifiez ArrayOperatorNew.cpp du chapitre 13 pour montrer que, si vous héritez de Widget, l'allocation marche toujours correctement. Expliquez pourquoi l'héritage dans Framis.cpp du chapitre 13 ne marcherait pas correctement.
  29. Modifiez Framis.cpp du chapitre 13 en héritant de Framis et créant des nouvelles versions de new et delete pour vos classes dérivées. Démontrez qu'elles fonctionnent correctement.

précédentsommairesuivant
En Java, le compilateur ne vous laissera pas décroître l'accès à un membre pendant le processus d'héritage.
En français, il est courant et généralement admis d'utiliser le terme redéfinir dans les deux cas, supplanter étant d'un usage nettement moins répandu. En conséquence, le terme supplanter n'a pas été retenu dans le cadre de la traduction de cet ouvrage, et le terme redéfinir est utilisé indifféremment dans les deux cas.
Pour en apprendre davantage sur cette idée, reportez vous à Extreme Programming Explained, de Kent Beck (Addison-Wesley 2000).
Cf. Refactoring: Improving the Design of Existing Code de Martin Fowler (Addison-Wesley 1999).

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2005 Bruce Eckel. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.