L'orienté objet

Le C++ permet de faire de la programmation orientée objet, de manière similaire aux langages Java ou C#, en définissant des classes polymorphiques.

Le runtime polymorphism permet d'utiliser des classes différentes héritant d'une classe commune de la même façon, sans connaître leur type exact : pour cela, on définit (ou redéfinit) des fonctions de la classe mère dans la classe fille, en utilisant des fonctions virtuelles :

class Base {
public:
    virtual int get_value() const { return 10; };
};

class Derived : public Base {
public:
    // override n'est pas nécessaire, mais vérifie à la compilation qu'il s'agit
    // bien d'une redéfinition de fonction virtuelle.
    inline int get_value() const override { return 42; };
};

// Cette fonction peut prendre une référence de type Derived et appeler
// Derived::get_value().
int read_base(Base const& base) {
    return base.get_value();
}

L'héritage

Une classe hérite d'une autre classe avec cette notation : class Child : public Mother, protected Father { ... }; . On peut hériter de plusieurs classes à la fois, et restreindre la portée des fonctions de la classe mère avec public(accessible de partout, portée par défaut pour les struct), protected (accessible dans les classes filles) et private(uniquement accessible dans la classe courante. Il s'agit de la portée par défaut pour les class).

Pour résoudre le problème du diamant, l'héritage va copier-coller la classe mère dans chaque classe qui l'hérite. Si on ne souhaite qu'une seule fois la classe mère, on peut faire de l'héritage virtuel. C'est ce que font par exemple les classes std::istream, std::ostream et std::iostream :

class std::ios_base { ... };

// Classe "grand-mère" commune (héritée deux fois).
template <class CharT, class Traits = std::char_traits<CharT>>
class std::basic_ios : public std::ios_base { ... };

// Première classe mère, utilisant 'virtual'.
template <class CharT, class Traits = std::char_traits<CharT>>
class std::basic_istream : public virtual std::basic_ios<CharT, Traits> { ... };

// Seconde classe mère, utilisant 'virtual'.
template <class CharT, class Traits = std::char_traits<CharT>>
class std::basic_ostream : public virtual std::basic_ios<CharT, Traits> { ... };

// Classe fille, qui va gérer ses deux classes mère et la classe grand-mère.
template <class CharT, class Traits = std::char_traits<CharT>>
class std::basic_iostream :
    public std::basic_istream<CharT, Traits>,
    public std::basic_ostream<CharT, Traits> { ... };

Les classes std::iostream, std::ifstream ou encore std::stringstream sont des alias :

namespace std {
    using iostream     = basic_iostream<char, char_traits<char>>;
    using ifstream     = basic_ifstream<char, char_traits<char>>;
    using stringstream = basic_stringstream<char, char_traits<char>>;
}

Les même alias existent pour les classes utilisant des wide caracters (wchar_t) : par exemple, using wostream = basic_ostream<wchar_t, char_traits<wchar_t>>;

L'autre méthode pour éviter le problème consiste à n'hériter que d'une classe n'étant pas une interface. Hériter plusieurs fois de la même interface ne posera pas de problèmes car aucune donnée n'est recopiée, et aucune fonction n'est implémentée.

En C++, une interface est une classe qui ne contient que des fonctions virtuelles pures (non définies) :

class IPerson {
public:
    // ' = 0' indique au compilateur que la fonction doit être définie par
    // les classes concrètes (pouvant être instanciées).
    virtual void set_name(std::string const& name) = 0;
    virtual std::string const& get_name() const = 0;
};

Les tables virtuelles

Dès qu'une classe a une fonction virtuelle, elle va avoir une table virtuelle contenant un pointeur vers chacune de ses fonctions virtuelles. Chaque instance de la classe va alors avoir un pointeur vers cette table : il s'agit d'une classe polymorphique.

Lorsqu'on créé une classe héritant de cette classe, une nouvelle table virtuelle est crée, contenant les fonctions de la classe mère, remplacées par celles définies par la classe fille.

L'appel à une fonction virtuelle d'un objet va donc :

  1. Accéder au pointeur de la table virtuelle de la fonction stocké dans l'objet.

  2. Lire la table virtuelle, à l'index où se trouve le pointeur de fonction de la 'vraie' classe de l'objet.

  3. Appeler la fonction.

Ces indirections rendent les fonctions virtuelles impossible à inline pour le compilateur, si on ne possède pas l'objet avec son vrai type. Marquer une redéfinition de fonction virtuelle final (pour qu'elle ne puisse pas être redéfinie) ou une classe final (pour qu'elle ne puisse pas être héritée) va permettre au compilateur d'inline des fonctions virtuelles quand il sait que la fonction appelée ne peut pas être redéfinie.

Les classes polymorphiques doivent généralement avoir un destructeur virtuel, afin de permettre aux classes filles d'implémenter un destructeur qui sera toujours appelé, même à travers la classe mère (comme pour n'importe quelle fonction virtuelle).

Passage et stockage par valeur

On ne peut pas passer les objets polymorphiques par valeur : cela appellerait le constructeur par copie de la classe de base, même en passant un type dérivé. Dans ce dernier cas, seule la classe mère de l'objet serait copiée : c'est le problème de l'object slicing.

De manière similaire, on ne peut pas stocker un objet polymorphique par valeur, car on souhaite pouvoir stocker n'importe quel type héritant d'une classe commune. Dans ce cas, on utilise généralement une indirection (comme un pointeur, ou un smart pointer) qui permet d'accéder à l'objet :

class MyClass {
public:
    // Ne peut stocker qu'un objet de type 'Base'.
    Base badObject;
    // Peut pointer vers n'importe quel objet héritant de 'Base'.
    Base* betterObject;
};

Cela amène à faire de nombreuses allocations sur le tas (new T, make_unique<T>, ...) et beaucoup d'indirections, ce qui ralentit le programme à la création des objets (à cause d'allocations plus lentes) et à leur utilisation (à cause d'accès en mémoire plus dispersés).

Comme on possède l'objet via une indirection vers sa classe mère, on va le détruire en appelant le destructeur de la classe mère. Il faut alors que son destructeur soit une fonction virtuelle pour éviter de ne détruire qu'une partie de l'objet (celle de la classe mère), selon le même principe que l'object slicing.

Run-Time Type Information (RTTI)

Des informations peuvent être stockées dans les tables virtuelles des classes polymorphiques pour pouvoir vérifier le vrai type d'un objet. On peut utiliser typeid avec le type, le pointeur ou la référence souhaité pour obtenir un objet de type const std::type_info&. Celui-ci permet d'identifier le type de façon unique :

const std::type_info& info = typeid(ptr); // ou typeid(Type), ou typeid(reference).

On peut upcast les références ou pointeurs (de la classe fille vers la classe mère) en toute sécurité (via static_cast ou implicitement). Cependant, si l'on souhaite faire un downcast (de la classe mère vers la classe fille), on peut utiliser un static_cast, mais le type obtenu n'est pas vérifié (puisque le cast se fait à la compilation).

On peut alors à la place effectuer un dynamic_cast qui va utiliser les RTTI des types (de départ et de destination) pour vérifier la validité du cast : si l'objet n'est pas ou n'hérite pas du type de destination, un dynamic_cast sur un pointeur va renvoyer un pointeur null et un dynamic_cast sur une référence va lancer une exception.

Le mot-clé friend est utilisé à l'intérieur d'une classe, pour permettre à une fonction ou à une classe donnée de pouvoir accéder à toute la classe courante (les membres et méthodes public, protected et private). Cela peut être utile pour obtenir une meilleure encapsulation :

class List;

// Seuls Node et List pourront accéder aux membres de Node.
class Node {
    friend class List;
    int value;
    Node* next;
};

Pour aller plus loin

Les pointeurs, les références et les smart pointers de la librairie standard peuvent changer de type, pour passer du type courant à une des classes dont hérite le type : Derived& => Base&. Il s'agit de types covariants. Si la conversion est possible dans l'autre sens, il s'agit de types contravariants.

La programmation fonctionnelle approche le runtime polymorphism d'une autre manière, ressemblant à l'utilisation des templates. Cette approche règle plusieurs problèmes de l'approche en POO.

Les tables virtuelles peuvent être redéfinies manuellement, ce qui permet de meilleures performances et/ou d'autres abstractions (comme pour la librairie link au-dessus).

Le pattern Attorney-Client permet aux classes choisies d'avoir un accès spécifique à la classe courante via le mot-clé friend.

Les smart pointers peuvent être utilisés par les fonctions template dynamic_pointer_cast et static_pointer_cast, qui correspondent respectivement à dynamic_cast et static_cast.

Last updated