8.15. Méthodes virtuelles pures - Classes abstraites

Une méthode virtuelle pure est une méthode qui est déclarée mais non définie dans une classe. Elle est définie dans une des classes dérivées de cette classe.

Une classe abstraite est une classe comportant au moins une méthode virtuelle pure.

Étant donné que les classes abstraites ont des méthodes non définies, il est impossible d'instancier des objets pour ces classes. En revanche, on pourra les référencer avec des pointeurs.

Le mécanisme des méthodes virtuelles pures et des classes abstraites permet de créer des classes de base contenant toutes les caractéristiques d'un ensemble de classes dérivées, pour pouvoir les manipuler avec un unique type de pointeur. En effet, les pointeurs des classes dérivées sont compatibles avec les pointeurs des classes de base, on pourra donc référencer les classes dérivées avec des pointeurs sur les classes de base, donc avec un unique type sous-jacent : celui de la classe de base. Cependant, les méthodes des classes dérivées doivent exister dans la classe de base pour pouvoir être accessibles à travers le pointeur sur la classe de base. C'est ici que les méthodes virtuelles pures apparaissent. Elles forment un moule pour les méthodes des classes dérivées, qui les définissent. Bien entendu, il faut que ces méthodes soient déclarées virtuelles, puisque l'accès se fait avec un pointeur de classe de base et qu'il faut que ce soit la méthode de la classe réelle de l'objet (c'est-à-dire la classe dérivée) qui soit appelée.

Pour déclarer une méthode virtuelle pure dans une classe, il suffit de faire suivre sa déclaration de « =0 ». La fonction doit également être déclarée virtuelle :

virtual type nom(paramètres) =0;

=0 signifie ici simplement qu'il n'y a pas d'implémentation de cette méthode dans cette classe.

Note : =0 doit être placé complètement en fin de déclaration, c'est-à-dire après le mot clé const pour les méthodes const et après la déclaration de la liste des exceptions autorisées (voir le Chapitre 9 pour plus de détails à ce sujet).

Un exemple vaut mieux qu'un long discours. Soit donc, par exemple, à construire une structure de données pouvant contenir d'autres structures de données, quels que soient leurs types. Cette structure de données est appelée un conteneur, parce qu'elle contient d'autres structures de données. Il est possible de définir différents types de conteneurs. Dans cet exemple, on ne s'intéressera qu'au conteneur de type sac.

Un sac est un conteneur pouvant contenir zéro ou plusieurs objets, chaque objet n'étant pas forcément unique. Un objet peut donc être placé plusieurs fois dans le sac. Un sac dispose de deux fonctions permettant d'y mettre et d'en retirer un objet. Il a aussi une fonction permettant de dire si un objet se trouve dans le sac.

Nous allons déclarer une classe abstraite qui servira de classe de base pour tous les objets utilisables. Le sac ne manipulera que des pointeurs sur la classe abstraite, ce qui permettra son utilisation pour toute classe dérivant de cette classe. Afin de différencier deux objets égaux, un numéro unique devra être attribué à chaque objet manipulé. Le choix de ce numéro est à la charge des objets, la classe abstraite dont ils dérivent devra donc avoir une méthode renvoyant ce numéro. Les objets devront tous pouvoir être affichés dans un format qui leur est propre. La fonction à utiliser pour cela sera print. Cette fonction sera une méthode virtuelle pure de la classe abstraite, puisqu'elle devra être définie pour chaque objet.

Passons maintenant au programme...

Exemple 8-26. Conteneur d'objets polymorphiques

#include <iostream>

using namespace std;

/*************  LA CLASSE DE ABSTRAITE DE BASE   *****************/

class Object
{
    unsigned long int new_handle(void);

protected:
    unsigned long int h;         // Handle de l'objet.

public:
    Object(void);                // Le constructeur.
    virtual ~Object(void);       // Le destructeur virtuel.
    virtual void print(void) =0; // Fonction virtuelle pure.
    unsigned long int handle(void) const;  // Fonction renvoyant
                                 // le numéro d'identification
                                 // de l'objet.
};

// Cette fonction n'est appelable que par la classe Object :

unsigned long int Object::new_handle(void)
{
    static unsigned long int hc = 0;
    return hc = hc + 1;          // hc est le handle courant.
                                 // Il est incrémenté
}                                // à chaque appel de new_handle.

// Le constructeur de Object doit être appelé par les classes dérivées :

Object::Object(void)
{
    h = new_handle();            // Trouve un nouveau handle.
    return;
}

Object::~Object(void)
{
    return ;
}

unsigned long int Object::handle(void) const
{
    return h;                    // Renvoie le numéro de l'objet.
}

/******************** LA CLASSE SAC   ******************/

class Bag : public Object       // La classe sac. Elle hérite
                                // de Object, car un sac peut
                                // en contenir un autre. Le sac
                                // est implémenté sous la forme
                                // d'une liste chaînée.
{
    struct BagList
    {
        BagList *next;
        Object  *ptr;
    };

    BagList *head;               // La tête de liste.

public:
    Bag(void);        // Le constructeur : appel celui de Object.
    ~Bag(void);       // Le destructeur.
    void print(void); // Fonction d'affichage du sac.
    bool has(unsigned long int) const;
                      // true si le sac contient l'objet.
    bool is_empty(void) const;   // true si le sac est vide.
    void add(Object &);          // Ajoute un objet.
    void remove(Object &);       // Retire un objet.
};

Bag::Bag(void) : Object()
{
    return;  // Ne fait rien d'autre qu'appeler Object::Object().
}

Bag::~Bag(void)
{
    BagList *tmp = head;   // Détruit la liste d'objet.
    while (tmp != NULL)
    {
        tmp = tmp->next;
        delete head;
        head = tmp;
    }
    return;
}

void Bag::print(void)
{
    BagList *tmp = head;
    cout << "Sac n° " << handle() << "." << endl;
    cout << "    Contenu :" << endl;

    while (tmp != NULL)
    {
        cout << "\t";        // Indente la sortie des objets.
        tmp->ptr->print();   // Affiche la liste objets.
        tmp = tmp->next;
    }
    return;
}

bool Bag::has(unsigned long int h) const
{
    BagList *tmp = head;
    while (tmp != NULL && tmp->ptr->handle() != h)
        tmp = tmp->next;     // Cherche l'objet.
    return (tmp != NULL);
}

bool Bag::is_empty(void) const
{
    return (head==NULL);
}

void Bag::add(Object &o)
{
    BagList *tmp = new BagList;   // Ajoute un objet à la liste.
    tmp->ptr = &o;
    tmp->next = head;
    head = tmp;
    return;
}

void Bag::remove(Object &o)
{
    BagList *tmp1 = head, *tmp2 = NULL;
    while (tmp1 != NULL && tmp1->ptr->handle() != o.handle())
    {
        tmp2 = tmp1;        // Cherche l'objet...
        tmp1 = tmp1->next;
    }
    if (tmp1!=NULL)         // et le supprime de la liste.
    {
        if (tmp2!=NULL) tmp2->next = tmp1->next;
        else head = tmp1->next;
        delete tmp1;
    }
    return;
}

Avec la classe Bag définie telle quelle, il est à présent possible de stocker des objets dérivant de la classe Object avec les fonctions add et remove :

class MonObjet : public Object
{
    /*  Définir la méthode print() pour l'objet...  */
};

Bag MonSac;

int main(void)
{
    MonObjet a, b, c;    // Effectue quelques opérations
                         // avec le sac :
    MonSac.add(a);
    MonSac.add(b);
    MonSac.add(c);
    MonSac.print();
    MonSac.remove(b);
    MonSac.add(MonSac);  // Un sac peut contenir un sac !
    MonSac.print();      // Attention ! Cet appel est récursif !
                         // (plantage assuré).
    return 0;
}

Nous avons vu que la classe de base servait de moule aux classes dérivées. Le droit d'empêcher une fonction membre virtuelle pure définie dans une classe dérivée d'accéder en écriture non seulement aux données de la classe de base, mais aussi aux données de la classe dérivée, peut donc faire partie de ses prérogatives. Cela est faisable en déclarant le pointeur this comme étant un pointeur constant sur objet constant. Nous avons vu que cela pouvait se faire en rajoutant le mot clé const après la déclaration de la fonction membre. Par exemple, comme le handle de l'objet de base est placé en protected au lieu d'être en private, la classe Object autorise ses classes dérivées à le modifier. Cependant, elle peut empêcher la fonction print de le modifier en la déclarant const :

class Object
{
    unsigned long int new_handle(void);

protected:
    unsigned long int h;

public:
    Object(void);                      // Le constructeur.
    virtual void print(void) const=0;  // Fonction virtuelle pure.
    unsigned long int handle(void) const; // Fonction renvoyant
                                       // le numéro d'identification
                                       // de l'objet.
};

Dans l'exemple donné ci-dessus, la fonction print peut accéder en lecture à h, mais plus en écriture. En revanche, les autres fonctions membres des classes dérivées peuvent y avoir accès, puisque c'est une donnée membre protected. Cette méthode d'encapsulation est donc coopérative (elle requiert la bonne volonté des autres fonctions membres des classes dérivées), tout comme la méthode qui consistait en C à déclarer une variable constante. Cependant, elle permettra de détecter des anomalies à la compilation, car si une fonction print cherche à modifier l'objet sur lequel elle travaille, il y a manifestement une erreur de conception.

Bien entendu, cela fonctionne également avec les fonctions membres virtuelles non pures, et même avec les fonctions non virtuelles.