image/svg+xml $ $ ing$ ing$ ces$ ces$ Res Res ea ea Res->ea ou ou Res->ou r r ea->r ch ch ea->ch r->ces$ r->ch ch->$ ch->ing$ T T T->ea ou->r

Principe de l'héritage

Une classe peut hériter d'une autre classe par le mécanisme d'héritage. Par héritage, une classe obtient les membres de la classe ancêtre : champs et méthodes. Voici un exemple avec une classe Point3D héritant de Point2D :

class Point3D: public Point2D {
public:
	/** On évoque le constructeur de l'ancêtre et on initialise z (et distancetoOrigin dans le corps du constructeur) */
	Point3D(int x, int y, int z): Point2D(x, y), z(z) {
		distanceToOrigin = sqrt(x * x + y * y + z * z); // requiert que distanceToOrigin soit déclaré au moins protected
	}
	
	// getX() et getY() sont hérités par défaut
	
	virtual double setX(double x) override {
		// requiert que setX soit virtual dans la classe ancêtre
		Point2D::setX(x);
		distanceToOrigin = sqrt(x * x + y * y + z * z);
	}
	
	virtual double setY(double y) override {
		// requier aussi que setY soit virtual dans la classe ancêtre
		Point2D::setY(y);
		distanceToOrigin = sqrt(x * x + y * y + z * z);
	}
}

Examinons certains comportements liés à l'héritage.

Accessibilité

Une classe hérite de tous les champs et méthodes de son ancêtre. Cela ne signifie pas qu'elle puisse les utiliser dans tous les cas. Elle est en effet limitée par l'accessibilité déclarée pour les membres de la classe ancêtre. Ainsi un membre privé de la classe ancêtre ne peut être utilisé par la classe héritante. Les membres protégés et publics sont accessibles à la classe héritante.

Ainsi si l'on souhaite rendre un membre accessible aux classes héritantes sans qu'il puisse être manipulé par d'autres classes, on le déclarera en section protected.

Une classe qui hérite d'un membre protected a la possibilité d'élever son accessibilité à public. Voici un exemple avec une classe B héritant de A et rendant le champ value d'accessibilité protected de A en public dans B :

class A {
protected:
	int value;
}

class B: public A {
public:
	using A::value;
}

Il n'est pas possible de réduire la visibilité d'un membre car cela irait à l'encontre du principe de sous-typage.

Héritage de constructeurs

Les constructeurs ne sont pas hérités par défaut. Il faut donc explicitement indiquer que l'on souhaite hériter d'un constructeur. Deux techniques peuvent être utilisés à cet effet :

  1. Si la classe B hérite de A, B peut hériter des constructeurs en utilisant l'instruction using A::A (fonctionnalité disponible depuis C++11)
  2. Nous pouvons redéclarer les constructeurs avec une initialisation appelant le constructeur de l'ancêtre.

Considérons la classe IntWrapper conservant une valeur int :

class IntWrapper {
public:
	int x;
	
	IntWrapper(int x): x(x) {}
}

Voici un exemple de la première technique utilisant using :

class UsingIntWrapper: IntWrapper {
public:
	using IntWrapper::IntWrapper;
}

... et de la seconde technique utilisant le redéclaration du constructeur avec initialisation :

class RedeclIntWrapper: IntWrapper {
public:
	RedeclIntWrapper(int x): IntWrapper(x) { }
}

Redéfinition de méthode

Une méthode d'une classe ancêtre peut être redéfinie dans une classe héritante.

Sous-typage

Le sous-typage est un mécanisme qui permet d'abstraire le type réel des objets manipulés en considérant un type ancêtre. Il ne peut fonctionner que pour des types déclarés comme pointeurs ou références.

Nous présentons ici un exemple de gestion d'un petit zoo constitué d'animaux. Tous les animaux héritent de la classe Animal : ils possèdent tous un nom. Certains animaux ont des caractéristiques supplémentaires : une description de fourrure pour les ours, une taille pour les girafes, une longueur pour les serpents...

#include <iostream>
#include <typeinfo>

using namespace std;

class Animal {
public:
	string name;
	
	Animal(string name): name(name) {}
};

class Bear: public Animal {
public:
	string furDescription;
	
	Bear(string name, string furDescription): Animal(name), furDescription(furDescription) {}
};

class Giraffe: public Animal {
public:
	int height;
	
	Giraffe(string name, int height): Animal(name), height(height) {}
};

class Snake: public Animal {
public:
	int length;
	
	Snake(string name, int length): Animal(name), length(length) {}
};

#include "zoo.hpp"

int main() {
	Bear winnie("Winnie", "yellow");
	Giraffe sophie("Sophie", 400);
	Snake oscar("Oscar", 700);
	
	Animal * zoo[] = { &winnie, &sophie, &oscar };
	
	for (int i = 0; i < 3; i++) {
		cout << "Name of animal #" << i << ": " << zoo[i]->name << endl;
	}
	
	// Could also use a reference
	Animal& animal = sophie;
	cout << "Name of the referenced animal: " << animal.name << endl;
	
	// Not possible since we cannot be sure that Animal is really a Giraffe
	// Giraffe& giraffe = animal; // DO NOT compile
	
	// The giraffe can be converted to an Animal
	Animal a = sophie;
	
	zoo[0] = NULL;
	cout << zoo[0]->name << endl; // should trigger a segmentation error since the pointer is null
}

Une variable pointeur de classe A peut être affecté d'un pointeur de classe B si B hérite de A (directement ou indirectement). La même remarque s'applique pour les références. Le pointeur ou la référence de type A peut donc pointer vers des objets de type A ou de types dérivés : les objets sont conservés avec leur type réel en mémoire, aucune information n'est perdue.

Le code suivant est également possible mais en convertissant un Bear en Animal nous perdons l'information sur la fourrure (la conversion est à sens unique, nous ne pouvons pas revenir à un Bear) :

Bear b("Foo", "red");
Animal a = b;
// Bear b2 = a; // impossible !

Nous avons en mémoire sur la pile deux objets : b de type Bear et a de type Animal qui est une copie indépendante de Bear sans la fourrure. L'affection Animal a = b est rendue possible car Animal dispose par défaut d'un constructeur de copie :

Animal(const Animal&) { ... }

Méthodes virtuelles

Le concept de méthode virtuelle permet la mise en œuvre du mécanisme de polymorphisme. Cela signifie que plusieurs méthodes de même nom et de même signature (mêmes types d'arguments) peuvent coexister dans une hiérarchie de classes.

Considérons la hiérarchie de classes A, B et C :

class A           { public: int m() { return 1; } };
class B: public A { public: int m() { return 2; } };
class C: public B { public: int m() { return 3; } };

Déclarons des objets de type A, B et C dans la pile :

int main() {
	A a;
	B b;
	C c;
	cout << a.m() << "," << b.m() << c.m() << endl;
}

Comme nous pouvons nous y attendre, nous appelons les méthodes m de A, de B puis de C. Nous avons ainsi redéfini dans B et C la méthode m de A. Les implantations dans les classes dérivées viennent masquer l'implantation de la classe ancêtre.

Il est possible aussi de baser les implantations redéfinies de m sur l'implantation de la classe ancêtre. Dans l'exemple qui suit la méthode m de A retourne 1, celle de B retourne le double de A (2) et celle de C le double de B (4) :

class A           { public: int m() { return 1; } };
class B: public A { public: int m() { return A::m() * 2; } };
class C: public B { public: int m() { return B::m() * 2; } };

Réalisons maintenant des allocations dynamiques avec des types pointeurs :

int main() {
	A * a = new A();
	A * b = new B();
	A * c = new C();
	cout << a->m() << "," << b->m() << c->m() << endl;
	delete a;
	delete b;
	delete c;
}

Nous remarquons qu'aussi bien a->m(), b->m() et c->m() retournent 1 car dans les trois cas, nous appelons en fait A::m(). Ceci s'explique par le fait que les pointeurs sont tous les trois déclarés A * : le type déclaré du pointeur est pris en compte pour la sélection de la méthode à appeler à la compilation. Le type réel n'est donc pas considéré car cela nécessiterait de réaliser un choix de méthode à l'exécution et non plus à la compilation.

Peut-on remédier à cette situation et choisir la méthode m() correspondant au type réel de l'objet pointé ? Oui en utilisant le mécanisme de méthode virtuelle. Nous déclarons une méthode virtuelle par le mot-clé virtual. Lorsqu'une méthode virtuelle est appelée, une table de méthodes est consultée à l'exécution pour appeler la méthode de l'objet réel (et non du type déclaré) :

class A           { public: virtual int m() { return 1; } };

L'appel d'une méthode virtuelle ralentit l'exécution car elle nécessite une indirection supplémentaire pour sélectionner la méthode avec la table de méthodes (vtable). Il s'agit néanmoins d'une fonctionnalité essentielle en programmation orientée objet car elle permet de créer des algorithmes sur des types abstraits dont on connaît le nom de méthode mais pas encore l'implantation. La plupart des langages de programmation orientés objet modernes utilisent par défaut des méthodes virtuelles. Ainsi en Java, il n'existe que des méthodes virtuelles.

Il est possible de déclarer une méthode virtuelle dans une classe sans fournir son implantation. On dit alors que la méthode est abstraite :

class A           { public: virtual int m() = 0; };

Il devient alors impossible de créer un objet d'une classe possédant au moins une méthode virtuelle abstraite. Ainsi le code suivant ne compile pas car le compilateur ne possède pas d'implantation de A::m :

A a; // impossible car possède une méthode abstraite
a.m(); // impossible

Il est possible de manipuler une méthode abstraite par le biais d'une référence ou d'un pointeur :

int doubleM(A * a) {
	return 2 * a->m();
}

Le pointeur a doit pointer vers un objet de classe héritant de A dans laquelle la méthode m() a été implantée.

Lorsque l'on redéfinit une méthode virtuelle d'une classe ancêtre, il est conseillé d'utiliser le mot-clé virtual pour la déclarer (ce n'est pas obligatoire). D'autre part, on peut rajouter le mot-clé override (non-obligatoire mais conseillé) pour indiquer au compilateur que notre volonté est de réaliser une redéfinition (si nous nous trompons dans le nom de la méthode, le compilateur signalera une erreur). Ainsi pour la classe B nous pouvons par exemple déclarer :

class B: public A { public: virtual int m() override { return 2; } }

RTTI et cast dynamique

RTTI

C++ propose une fonctionnalité de RunTime Type Information (RTTI) qui permet de connaître à l'exécution le type réel d'un objet.

Pour un objet manipulé directement sans pointeur ni référence, l'obtention du type de l'objet peut être réalisé statiquement au moment de la compilation. Nous pouvons ainsi obtenir (sans suspens) le type de l'objet en utilisant typeid :

#include <iostream>
#include <typeinfo>

using namespace std;

int main() {
	Bear b("Winnie the poo", "yellow");
	const type_info& ti = typeid(b);
	cout << b.name() << endl; // Affiche (sans suspens) Bear
}

typeid s'avère plus intéressant lorsque nous ne connaissons pas à la compilation le type réel d'un objet. C'est le cas pour un objet accessible depuis un pointeur ou une référence. Voici un exemple où nous allouons dynamiquement aléatoirement un objet de type Bear ou Giraffe ; le type réel de l'objet ne peut donc être connu qu'à l'exécution.

#include <memory>

Animal * createAnimal() {
	srand(time(NULL));
	int alea = rand() % 2; // 0 ou 1
	if (alea)
		return new Bear("Winnie" ,"yellow");
	else
		return new Giraffe("Sophie", 400);
}

int main() {
	srand(time(NULL));
	int alea = rand() % 2; // 0 ou 1
	shared_ptr<Animal> animal(createAnimal());
	const type_info& animalType = typeid(*animal);
	cout << "Kind of animal: " << animalType.name() << endl;
	cout << "Name of the animal: " << animal->name << endl;
}

Si nous testons le code précédent, nous constatons deux comportements possibles :

  1. L'affichage du type déclaré du pointeur Animal pour toutes les exécutions
  2. L'affichage du type réel Bear ou Giraffe

Le premier comportement a lieu lorsque le mécanisme de RTTI n'est pas activé pour les classes. Les objets n'embarquent pas l'information de leur type, il est donc impossible de connaître le type réel. Le RTTI n'est pas activé si la classe instanciée ne contient pas au moins une méthode virtuelle. La présence d'une méthode virtuelle (dans la classe ou ses ancêtre) est suffisante pour activer le RTTI. Nous pouvons pour cela ajouter une méthode virtuelle spécialement à cet effet dans la classe Animal :

class Animal {
....
	// Une méthode uniquement destinée à activer le RTTI
	virtual void phonyMethod() {}
...

Cast dynamique

Le mécanisme de cast dynamique permet de convertir une expression de type pointeur (ou référence) vers une classe A en une expression pointant vers une classe B, B étant une classe héritée de A. Cela permet d'accéder à des membres du type hérité.

Considérons la méthode Animal * createAnimal() allouant un animal aléatoirement de l'exemple précédent. Si nous souhaitons obtenir le nom de l'animal, champ de la classe Animal, ceci est possible directement sans cast :

Animal * a = createAnimal();
cout << "name of the animal: " << a->name << endl;
delete a;

En revanche si le type réel de a était Giraffe, il serait impossible directement d'accéder à height qui est une propriété de Giraffe et non de Animal :

Animal * a = createAnimal();
cout << "height: " << a->height << endl; // impossible !

Pour que cette opération fonctionne, il est nécessaire de réaliser un cast dynamique. Le cast dynamique repose sur le RTTI ; l'information de type doit donc être embarquée dans l'objet stocké en mémoire. Comme nous l'avons vu précédemment cela requiert que la classe (ou ses ancêtres) dispose au moins d'une méthode virtuelle.

Animal * a = createAnimal();
Giraffe * g = dynamic_cast<Giraffe*>(a);
if (g) {
	// c'est bien une girafe
	cout << "Height of the Giraffe: " << g->height << endl;
} else {
	// ceci n'est pas une girage, g est donc nul
	cout << "Not a giraffe" << endl;
}

dynamic_cast<type> retourne donc un pointeur de type plus spécialisé ; si le cast échoue, un pointeur nul est retourné (ce qu'il faut systématiquement vérifier).

Héritage multiple

Le langage C++ propose un système d'héritage multiple : une classe peut hériter de plusieurs classes ancêtres. Cela complexifie le système d'héritage : beaucoup de langages orientés objet postérieurs à C++ ont abandonné la notion d'héritage multiple dans un souci de simplification.

Nous examinons ici les deux principales difficultés survenant au cours de l'héritage multiple : les conflits de nommage ainsi que l'héritage de membres par plusieurs chemins.

Conflits de nommage

Considérons les classes suivantes :

/** Véhicule avec un numéro de série */
class Vehicle {
private:
	const int id;
public:
	Vehicle(int id): id(id) {}
	int getId() const { return id; } 
};

/** Dispositif avec un numéro d'immatriculation (voiture, avion...) */
class Registered {
private:
	const int id;
public:
	Registered(int id): id(id) {}
	int getId() const { return id; }
};

Crééons maintenant une classe Car héritant de Vehicle et Registered. Cette classe dispose de deux champs (restant privés) id l'un pour le numéro de série de la voiture (héritage de Vehicle) l'autre pour la plaque d'immatriculation :

class Car: public Vehicle, public Registered {
public:
	Car(int serialId, int plateId): Vehicle(serialId), Registered(plateId) {}
};

Maintenant, créeons une voiture et essayons d'appeler la méthode getID() :

Car car(123, 456);
cout << car.getId() << endl;

L'appel à car.getId() provoque une erreur du compilateur : une ambiguïté subsiste quant au choix de méthode à appeler, Vehicle::getId() ou Registered::getId() ? Une solution serait d'indiquer explicitement de quelle classe provient la méthode getId() à appeler :

Car car(123, 456);
cout << "Serial number of the car: " << car.Vehicle::getId() << endl;
cout << "Registration plate of the car: " << car.Registered::getId() << endl;

Une autre possibilité est de lever l'ambiguïté dans Car en redéfinissant getId() pour appeler soit la méthode de Vehicle, soit de Registered (un choix doit être fait) :

class Car: public Vehicle, public Registered {
public:
	Car(int serialId, int plateId): Vehicle(serialId), Registered(plateId) {}
	
	/** Return the serial number of the car (inheritance from Vehicle) */
	int getId() const { return Vehicle::getId(); }
	
	/** Return the plate id of the car */
	int getPlateId() const { return Registered::getId(); }
};

En conclusion, s'il existe une ambiguïté pour une méthode (méthodes de même nom dans les classes ancêtres), il faut soit indiquer explicitement la classe d'où provient la méthode quand on l'appelle, soit redéfinir dans la classe héritée la méthode posant problème avec un appel explicite à la méthode voulue d'un ancêtre.

Héritage de membres par plusieurs chemins

Il est interdit d'hériter directement plusieurs fois de la même classe, ainsi le compilateur ne tolère pas class B: A, A {};.

Néanmoins une classe peut indirectement hériter d'une même classe par plusieurs chemins. L'exemple type de cette situation est celui de diamant :

Exemple du diamant
#include <iostream>

using namespace std;

class Person {
private:
	string name;
public:
	string getName() const { return name; }
	
	Person(string name): name(name) {}
};

class Student: public Person {
private:
	string university;
public:
	string getUniversity() const { return university; }
	
	Student(string name, string university): Person(name), university(university) {}
};

class Apprentice: public Person {
private:
	string company;
public:
	string getCompany() const { return company; }
	
	Apprentice(string name, string company): Person(name), company(company) {}
};

class ApprenticeStudent: public Student, public Apprentice {
public:
	ApprenticeStudent(string name, string university, string company): Student(name, university), Apprentice(name, company) {}
};

int main() {
	ApprenticeStudent as("Stroustrup", "Cambridge", "Bell labs");
	cout << as.getCompany() << endl;
	cout << as.getUniversity() << endl;
	// cout << as.getName() << endl // not possible because ambiguous!
	cout << as.Student::getName() << endl;
	cout << as.Apprentice::getName() << endl;
}

Ici la classe ApprenticeStudent hérite de la classe Person par l'intermédiaire de Student et de Apprentice. Dans cette situation, ApprenticeStudent possède deux copies indépendantes de Person (par Student et Apprentice). L'utilisation du champ name de Person doit être explicité (Student::name pour passer par la copie de Student ou Apprentice::name pour passer par la copie de Apprentice). L'appel de la méthode getName() de Person doit aussi être précisé : Student::getName() ou Apprentice::getName().

Dans le cas présent, il ne paraît pas naturel d'avoir deux exemplaires du champ name, le nom d'une personne en tant qu'étudiant ou apprenti étant le même. C++ propose un mécanisme permettant de n'intégrer qu'une seule copie des champs de Person même si l'on en hérite par plusieurs chemins grâce à l'héritage virtuel :

Héritage virtuel

Afin d'hériter virtuellement de Person, on le spécifie dans la déclaration d'héritage de Student et Apprentice :

class Student: public virtual Person { ... };
class Apprentice: public virtual Person { ... };

Concernant les constructeurs de Person, Student, Apprentice et ApprenticeStudent, nous avons :

Person::Person(string name): name(name);
Student::Student(string name, string university): Person(name), university(university);
Apprentice:Apprentice(string name, string company): Person(name), company(company);
ApprenticeStudent::ApprenticeStudent(string name, string university, string company): Student(name, university), Apprentice(name, company), Person(name);

On constate que nous devons explicitement initialiser ApprenticeStudent avec le constructeur de Person. En effet avec l'héritage virtuel c'est ApprenticeStudent qui est responsable de l'initialisation de Person. L'appel aux constructeurs Student(name, university) et Apprentice(name, company) n'initialise que university et company (l'initialisation de name n'est fait qu'une seule fois).

Pour en savoir plus sur les aspects techniques de la compilation en présence d'héritage multiple, on pourra se référer à ce rapport de Bjarne Stroustrup.