Jusqu'à présent, nous avons vu comment définir des classes, qui permettront de créer des objets dans notre programme. Or, pour l'instant, nous ne pouvons exprimer que certaines relations entre nos objets: les relations d'association, d'agrégation et de composition, qui s'expriment par le fait qu'une donnée membre d'un objet peut être elle aussi un objet. L'héritage est l'outil qui va nous permettre d'exprimer entre nos classes une relation de type "est une sorte de". Cet outil est très puissant, car grâce à lui nous pourrons déclarer des classes d'objets très généraux, puis progressivement "spécialiser" ces classes d'objets. Cette spécialisation est aussi une "extension" de la classe de base: si l'on reste dans les généralités, on n'a pas grand-chose à dire. Plus les choses se précisent, plus on doit détailler. Donc à chaque étape du processus, on rajoutera du code. Le fait de partir d'une classe de base générale permet d'exprimer directement dans le code des concepts abstraits
Vous pouvez télécharger ici un petit programme jouet qui "implémente" le programme des machines à café
Reprenons l'exemple des machines à café abordé lors
de l'introduction à la programmation objet pour les gens normaux .
Posons-nous la question suivante: "qu'est-ce qu'un cafetière ?" On peut aussi poser la question autrement: "Quels sont les points communs à toutes les cafetières ?".
Une petite réflexion montre qu'une cafetière est une machine qui est capable de:
Nous devrons donc avoir deux fonctions: faire_le_cafe
d'une part, lire_etat
d'autre part. Mais quel code mettrons-nous dans ces fonctions, puisque à ce stade nous n'avons aucune idée de la manière dont le café serait fait ? La réponse est simple: nous ne mettrons... rien ! Par contre, nous pouvons d'ores et déjà implémenter les mécanismes concernant les réservoirs cités ci-dessus. D'où la modélisation UML suivante:
et le code suivant:
class Cafetiere { public: virtual void faire_le_cafe() = 0; virtual int lire_etat() = 0; void encaisser_monnaie(); protected: void verser_eau(); void sucre(); void donner_gobelet(); void donner_cuiller(); private: ReservoirEau eau; ReservoirSucre sucre; ReservoirGobelets gobelets; ReservoirCuillers cuillers; }
La syntaxe virtual void faire_le_cafe() = 0
permet de définir une fonction virtuelle pure: on se contente ici de dire au compilateur que les objets Cafetiere
seront dotés de cette fonction, mais nous lui dirons ultérieurement ce que ces fonctions devront faire concrètement. Le diagramme UML met ces fonctions en italique.
Le fait que la classe Cafetiere
contienne dans sa définition au moins une fonction virtuelle pure entraine que cette classe est abstraite. D'où le nom de la classe en italiques dans le diagramme UML.
Notre cafetière peut se décliner en plusieurs modèles:
Nous savons comment faire le café pour du café soluble ou pour du café en poudre. Nous pouvons donc redéfinir nos fonctions virtuelles, comme on le voit sur le diagramme et le code ci-dessous:
et le code suivant:
class CafetiereSoluble: public Cafetiere { public: void faire_le_cafe() override; int lire_etat() override; protected: void verser_cafe(); private: ReservoirCafeSoluble cafe; } class CafetierePoudre: public Cafetiere { public: void faire_le_cafe() override; int lire_etat() override; protected: void verser_cafe_dans_filtre(); private: ReservoirCafePoudre cafe; }
On a ainsi défini une relation d'héritage entre nos objets. On dit que CafetierePoudre
hérite de Cafetiere
, ou encore que Cafetiere
est une super-classe
de CafetierePoudre
On dit que la classe CafetiereSoluble
est
dérivée de Cafetiere
,
Cafetiere
étant une classe de base par rapport à CafetiereSoluble
. et CafetierePoudre
. CafetiereSoluble
et CafetierePoudre
sont en quelque sorte des "sous-types" de
Cafetiere
, de sorte que les variables de type
CafetiereSoluble
sont aussi des variables de type
Cafetiere
. Les CafetiereSoluble
et CafetierePoudre
sont donc des sortes de Cafetiere, ils constituent une extension de Cafetiere
Le mot-clé override n'est pas obligatoire, mais il est fortement recommandé: en effet il informe le compilateur qu'on redéfinit une fonction virtuelle, cela permet au compilateur de faire des vérifications.
Puisque CafetiereSoluble
et CafetierePoudre
étendent le type Cafetiere
, ils encapsulent des objets tels que eau
, qui représente le réservoir d'eau, etc. Mais parce que ces objets sont déclarés dans la section private
de Cafetiere
, CafetiereSoluble
ni CafetierePoudre
n'auront accès à ces variables.
On peut être surpris, à première vue, que les classes dérivées n'aient pas accès à la section
private
de leurs classes de bases... et pourtant, c'est la moindre des choses:
private
n'auraient dès lors plus aucune
raison d'être.private
: en
effet, celles-ci peuvent changer du jour au lendemain; seul
l'interface est "garanti" par le concepteur de la bibliothèque.L'idéal pour une classe dérivée est donc de
n'utiliser que les fonctions-membres publiques de la classe de base,
au même titre que n'importe quelle autre fonction. Cependant,
certaines fonctionnalités ou certaines données n'ont pas à être utilisées par tout le monde, mais seulement par les classes dérivées: ainsi dans notre cafetière, tous les modèles de cafetière doivent avoir accès au réservoir d'eau, mais les utilisateurs de la
machine à café n'ont pas de raison, eux, d'y avoir accès. D'où la nécessité de créer une nouvelle section: protected
; tout ce qui sera déclaré dans cette section sera utilisable par les classes dérivées, et uniquement par elles. Ainsi, dans notre exemple, on mettra dans protected
des fonctions d'accès aux éléments constitutifs de la machine à café (verser_eau()
). On considère qu'une bonne
conception doit respecter les règles suivantes:
private
, se trouvent les données
encapsulées par l'objet, ainsi que quelques fonctions membres correspondant au fonctionnement interne de l'objet.protected
, ne se trouvent
que des fonctions-membres. public
, ne se trouvent
que des fonctions-membresAinsi, il est toujours possible de dériver une classe à partir d'une autre. La classe dérivée aura toujours accès aux fonctions publiques de sa classe de base. Mais il est souhaitable de prévoir la dérivation, en définissant une section protected
"réservée" aux classes dérivées.
En reprenant les classes définies ci-dessus, que se passe-t-il lorsqu'on écrit:
CafetiereSoluble ma_cafetiere;
Il se passe les choses suivantes:
CafetiereSoluble
et de a (ou ses) classes de base.Cafetiere
CafetiereSoluble
Autrement dit, tant qu'on travaille avec les constructeurs par défaut, tout se passe bien: le système se charge d'appeler les constructeur par défaut des classes de base, dans le bon ordre.
Nous sommes en présence d'objets ayant une sémantique d'identité, en conséquence il n'y a pas lieu de définir le constructeur de copie. On pourra éventuellement définir un clônage polymorphique (voir plus bas).
rien n'empêche de définir sur Cafetiere
un constructeur à qui on passe des paramètres, par exemple les quantités initiales d'eau, de sucre, ainsi que le nombre de gobelets et de cuillers:
class Cafetiere { public: Cafetiere(float e, int g, int c,float s) : eau(e), gobelets(g),
cuillers(c), sucre(s) {}; ... }
De même, rien n'empêche de définir un constructeur pour CafetiereSoluble
, à qui on donnera en plus la quantité de café soluble à incorporer dans le réservoir adhoc. Là encore, le système ne peut pas savoir quel constructeur de la classe de base doit être appelé: c'est donc de la responsabilité du constructeur de la classe dérivée d'appeler le constructeur de sa classe de base, en utilisant la liste d'initialisation:
class CafetiereSoluble: public Cafetiere { public: CafetiereSoluble(float e, int g, int c,float s, float f) : Cafetiere(e,g,c,s), cafe(f) {}; ... } class CafetierePoudre: public Cafetiere { public: CafetierePoudre(float e, int g, int c,float s, float f) : Cafetiere(e,g,c,s), cafe(f) {}; ... }
On ne peut pas initialiser directement les membres de la classe de base dans la classe dérivée: le code suivant:
CafetierePoudre(float e, int g, int c, float s, float f) : eau(e),gobelets(g),cuillers(c),sucre(s), cafe(f){};
ne compilera pas, car eau
, gobelets
, cuillers
, sucre
sont des membres de la classe Cafetiere
,
pas de CafetierePoudre
. Il faut obligatoirement passer par un constructeur de Cafetiere
.
Lorsqu'un objet de type CafetiereSoluble
est détruit, il
se passe la séquence suivante:
CafetiereSoluble
est appeléCafetiere
est appelé
Il sera la plupart du temps nécessaire de définir un destructeur virtuel au niveau de la classe de base.
Observons le code ci-dessous, dans lequel on gère trois machines à café, de deux types différents, par l'intermédiaire d'un tableau de pointeurs:
vector<shared_ptr<Cafetiere>> machines; machines.push_back(make_shared<CafetiereSoluble>(1.5,500,500,2,0.5)); machines.push_back(make_shared<CafetiereSoluble>(1.5,500,500,2,0.5)); machines.push_back(make_shared<CafetierePoudre>(1.5,500,500,2,0.5)); for (int i=0; i<3; ++i) { if (machines[i]->lire_etat() == 1) { machines[i]->faire_le_cafe(); machines[i]->encaisser_monnaie(); }; };
Le code ci-dessus déclare un vecteur de type pointeur sur Cafetiere
, puis le remplit avec deux machines à café soluble et une en poudre. Souvenez-vous que les fonctions lire_etat
et faire_le_cafe
ont été délarées avec le mot-clé virtual
: ce mot signifie que le compilateur ne cherche pas à savoir exactement quelle fonction lire_etat
sera appelée, ni quelle fonction faire_le_cafe()
sera appelée, puisque l'allocation mémoire se fait de manière dynamique: il retient donc simplement qu'il devra appeler la version de la fonction faire_le_cafe
qui va bien, en fonction du type d'objet qui sera appelé à l'exécution du programme. Le mot polymorphisme décrit la propriété de ces deux fonctions d'adopter "plusieurs formes", suivant le contexte du programme. On parle aussi d'édition de liens dynamique. Remarquons qu'une fois de plus, on retrouve une
manière de penser parfaitement naturelle: si je vous passe une
casserole (classe de base) en vous demandant de la laver (fonction qui
opère sur le type générique casserole), je vous passe en réalité une casserole bien particulière, et pas toujours la même (hier c'était le vieux chaudron à confiture qui me vient de ma grand-mère, aujourd'hui c'est une casserole en aluminium, dans les deux cas il s'agit d'un type de casserole particulier). Dans les deux cas vous allez la laver... mais suivant le type de casserole, vous vous y prendrez différemment. Par contre, la fonction encaisser_monnaie
fait l'objet d'une édition de liens statiques (il n'y a pas le mot virtual
devant, donc il n'y a pas ici de polymorphisme).
Lorsque vous redéfinissez une fonction virtuelle, la nouvelle
définition doit avoir soit le même type de retour que la fonction
originale, soit un type dérivé (on parle dans ce cas de "types de retour covariants"). Sinon, le compilateur refusera votre code. Bien entendu, elles devront avoir également les mêmes signatures, sinon il
s'agit de deux fonctions différentes, et le mécanisme d'édition de liens dynamiques ne s'applique pas.
Pourquoi les fonctions ne sont-elles pas automatiquement virtuelles ? C'est le cas dans d'autres langages orientés objets, java par exemple.
Le problème avec les fonctions virtuelles, c'est qu'elles sont moins performantes que les fonctions classiques.
C'est normal: le mécanisme d'édition de liens dynamiques est très puissant, mais il a un coût.
Donc, il est recommandé de ne les utiliser que lorsque c'est nécessaire,
et pas lorsque la performance est rédhibitoire.
Si vous surchargez une fonction virtuelle, la fonction surchargée ne sera pas virtuelle
automatiquement: il vous faut le spécifier explicitement.
Le code suivant ne sera jamais compilé:
Cafetiere C1;
En effet, la présence des fonctions virtuelles pures faire_le_cafe
et lire_etat
empêche le
compilateur de générer un objet de type Cafetiere. En d'autres termes, on ne peut pas dire au compilateur "donne-moi de la mémoire et initialise cette cafetière". Il veut savoir précisément de quoi il s'agit. La classe Cafetiere
est une classe de base abstraite, (c'est-à-dire une classe qui a au moins une fonction virtuelle pure). En tant que telle, on peut la passer en paramètres (par référence ou par pointeur),mais pas par valeur à une fonction, mais on ne peut pas déclarer d'objet de cette classe.
Ainsi, le code suivant ne pose aucun problème:
Cafetiere* C1;
En effet, il est toujours possible de déclarer un pointeur sur une
classe abstraite. Le type réel de la classe est précisé lorsque le pointeur sera initialisé, le plus souvent en utilisant l'opérateur new
..
Le code suivant est utilisable également:
void une_fonction(Cafetiere& c) {...}; CafetiereSoluble cs(1.5,500,500,2,0.5); CafetierePoudre cp(1.5,500,500,2,0.9); une_fonction(cs); une_fonction(cp);
Parce qu'on passe la variable par référence et pas par valeur, il est possible d'utiliser le polymorphisme, de même qu'avec des pointeurs.
ça n'existe pas: le principe d'une fonction virtuelle est que la fonction réellement appelée à l'exécution est la fonction correspondant au type de l'objet existant. Encore faut-il que l'objet soit existant, ce qui n'est pas le cas lors de l'appel d'un constructeur.
là, ça existe: et c'est même fort utile. En effet, le code suivant risque d'entraîner des résultats catastrophiques:
for (auto m: machines) { m.reset(); }
Le problème ici est que seul le destructeur
de la classe de base a été appelé. Si le destructeur de la classe
modele_poudre
devait faire quelque chose de particulier
(rendre la mémoire allouée pour le
reservoir_cafe_en_poudre
, par exemple), c'est raté. Le
destructeur n'a pas été appelé, et il y a un réservoir à café qui
traîne au fond de la mémoire... Il est donc obligatoire de
déclarer pour la classe Cafetiere
un destructeur
virtuel... quitte à ce que celui-ci renoie sur le destructeur par défaut (règle du zéro):
class Cafetiere { virtual ~Cafetiere() = default; };
Toute classe de base contenant au moins une fonction virtuelle (et a fortiori une classe de base abstraite)
doit proposer un destructeur virtuel.
Dans certains cas, on veut bénéficier d'une opération de copie d'un objet dans l'autre, en s'arrangeant pour que l'objet de destination prenne le type de l'objet copié. Il s'agit d'un clônage polymorphique, il ne s'obtient pas avec l'operateur=, mais avec une fonction clone, définie dans chaque classe de la hiérarchie de la manière suivante:
class Cafetiere { ... virtual Cafetiere * Cafetiere clone() = 0; } class CafetiereSoluble { ... CafetiereSoluble * Cafetiere clone() override { return new CafetiereSoluble(*this); } }; void f(const Cafetiere& c2) { Cafetiere * = c2.Clone(); ... }; main() { modele_grain G; f (G); }
La fonction clone doit être redéfinie dans toutes les classes dérivées situées tout en bas de la hiérarchie (les "feuilles"). A chaque fois, la définition sera la même, mais
on doit la réécrire.
Cela marche grâce à faculté du C++ de définir des types de retour covariants.
Pour une mise en œuvre du clônage polymorphique ainsi que des shared_ptr, vous pouvez télécharger ce code jouet
Il est possible, bien que d'une utilisation délicate, de déclarer qu'une classe dérive de plusieurs superclasses. L'utilisation la plus intéressante de l'héritage multiple est la définition d'interfaces:
Imaginons la situation suivante, extrêmement courante:
traitement
appelle la fonction-membre run()
, qui dure plusieurs minutes.traitement
et un framework particulier.Le pattern observateur permet de répondre à ce cahier des charges:
class Observateur { public: virtual void update(float r) = 0; }; class Diffuseur { public: void notifier(float r) { for(int i=0;i<abonnes.length();i++) { abonnes[i]->update(r); }; }; void abonne(Observateur * o) { if (o!=NULL) abonnes.push_back(o); }; private: vector<Observateur*>abonnes; }; class Traitement: public BaseTraitement, public Diffuseur { public: void run() { while(...) { ... notifier(x); }; }; } class ProgressionBar: public ProgressionBarWidget, public Observateur { public: virtual void update(float r) { ... }; };
Une fois n'est pas coutune, vous pouvez télécharger ici un petit programme que vous devriez pouvoir compiler. On voit que:
Diffuseur
comprend une fonction abonne
, qui permettra à des objets dérivant de la classe Observateur
de s'abonner, afin d'être notifés d'un événement quelconque.Traitement
dérive de la classe Diffuseur
, elle pourra donc appeler la fonction notifier
. Celle-ci appelle update
sur tous ses abonnés. Comme seuls des objets dérivant de Observateur
peuvent s'abonner, elle est sûre de trouver cette fonction implémentée.Observateur
ne comprend qu'une seule fonction virtuelle pure (void update(float)
), c'est donc une classe interface.ProgressionBar
dérive de Observateur
, elle devra donc impémenter la fonction update
, ce qui lui permettra d'être notifiée en temps utile.ProgressionBar
et Traitement
dérivent par ailleurs d'autres classes, suivant la manière dont l'application a été conçue: elles utilisent donc l'héritage multiple.