Skip to main content

La couche objet - Partie 4 : Les entrées/sorties, Méthodes virtuelles, Dérivation, Classe abstraites

Des entrées - sorties simplifiées

Les flux d'entrée / sortie de la bibliothèque standard C++ constituent sans doute l'une des applications les plus intéressantes de la surcharge des opérateurs. Comme nous allons le voir, la surcharge des opérateurs << et >> permet d'écrire et de lire sur ces flux de manière très intuitive.

En effet, la bibliothèque standard C++ définit dans l'en-tête iostream des classes extrêmement puissantes permettant de manipuler les flux d'entrée / sortie. Ces classes réalisent en particulier les opérations d'entrée / sortie et vers les périphériques d'entrée et les périphériques de sortie standards (généralement, le clavier et l'écran), mais elles ne s'arrêtent pas là : elles permettent également de travailler sur des fichiers ou encore sur des tampons en mémoire.

Les classes d'entrée / sortie de la bibliothèque standard C++ permettent donc d'effectuer les mêmes opérations que les fonctions printf et scanf de la bibliothèque C standard. Cependant, grâce au mécanisme de surcharge des opérateurs, elles sont beaucoup plus faciles d'utilisation. En effet, les opérateurs << et >> de ces classes ont été surchargés pour chaque type de donnée du langage, permettant ainsi de réaliser des entrées / sorties typées extrêmement facilement. L'opérateur <<, également appelé opérateur d'insertion, sera utilisé pour réaliser des écritures sur un flux de données, tant que l'opérateur >>, ou opérateur d'extraction, permettra de réaliser la lecture d'une nouvelle donnée dans le flux d'entrée. Ces deux opérateurs renvoient tous les deux le flux de données utilisé, ce qui permet de réaliser plusieurs opérations d'entrée / sortie successivement sur le même flux.

La bibliothèque standard définit quatre instances particulières de ses classes d'entrée / sortie : cin, cout, cerr et clog. Ces objets sont des instances des classes istream et ostream, prenant respectivement en charge l'entrée et la sortie des données des programmes. L'objet cin correspond au flux d'entrée standard stdin du programme, et l'objet cout aux flux de sortie standard stdout. Enfin, les objets cerr et clog sont associés au flux d'erreurs standard stderr. Théoriquement, cerr doit être utilisé pour l'écriture des messages d'erreur des programmes, et clog pour les messages d'information. Cependant, en pratique, les données écrites sur ces deux flux sont écrites dans le même flux, et l'emploi de l'objet clog est assez rare.

L'utilisation des opérateurs d'insertion et d'extraction sur ces flux se résume donc à la syntaxe suivante

cin >> variable [>> variable [...]];
cout << valeur [<< valeur [...]];

Comme on le voit, il est possible d'effectuer plusieurs entrées ou plusieurs sortie successivement sur un même flux.

DE plus, la bibliothèque standard définie ce que l'on appelle des manipulateurs permettant de réaliser des opérations simples sur les flux d'entrée / sortie . Le manipulateur le plus utilisé est sans nul doute le manipulateur endl qui, comme son nom l'indique, permet de signaler une fin de ligne et d'effectuer un saut de ligne lorsqu'il est employé sur un flux de sortie.

#include <iostream>

using namespace std;

int main(void)
{
int i;
// Lit un entier :
cin >> i;
// Affiche cet entier et le suivant :
cout << i << " " << i+1 << endl;
return 0;
}

Méthodes virtuelles

Les méthodes virtuelles n'ont strictement rien à voir avec les classes virtuelles, bien qu'elles utilisent le même mot clé virtual. Ce mot clé est utilisé dans un contexte et dans un sens différent.

Nous savons qu'il est possible de redéfinir les méthodes d'une classe mère dans une classe fille. Lors de l'appel d'une fonction ainsi redéfinie, la fonction appelée est la dernière fonction définie dans la hiérarchie de classe. Pour appeler la fonction de la classe mère alors qu'elle a été redéfinie, il faut préciser le nom de la classe à laquelle elle appartient avec l'opérateur de résolution de portée.

Bien que simple, cette utilisation de la redéfinition des méthodes peut poser des problèmes. Supposons qu'une classe B hérite de sa classe mère A. Si A possède une méthode x appelant une autre méthode y redéfinie dans la classe fille B, que se passe-t-il lorsqu'un objet de classe B appelle la méthode x ? La méthode appelée étant celle de la classe A, elle appellera la méthode y de la classe A. Par conséquent, la redéfinition de y ne sert à rien dès qu'on l'appelle à partir d'une des fonctions d'une des classes mères.

Une première solution consisterait à redéfinir la méthode x dans la classe B. Mais ce n'est ni élégant, ni efficace. Il faut en fait forcer le compilateur à ne pas faire le lien dans la fonction x de la classe A avec la fonction y de la classe A. Il faut que x appelle soit la fonction y de la classe A si elle est appelée pour un objet de la classe B. Le lien avec l'une des méthodes y ne doit être fait qu'au moment de l'exécution, c'est à dire qu'on doit faire une édition de liens dynamique.

Le C++ permet de faire cela. Pour cela, il suffit de déclarer virtuelle la fonction de la classe de base qui est redéfinie dans la classe fille, c'est à dire la fonction y. Cela se fait en faisant précéder par le mot clé virtual dans la classe de base.

#include <iostream>

using namespace std;

// Définit la classe de base des données.

class DonneeBase
{
protected:
int Numero; // Les données sont numérotées.
int Valeur; // et sont constituées d'une valeur entière
// pour les données de base.
public:
void Entre(void); // Entre une donnée.
void MiseAJour(void); // Met à jour la donnée.
};

void DonneeBase::Entre(void)
{
cin >> Numero; // Entre le numéro de la donnée.
cout << endl;
cin >> Valeur; // Entre sa valeur.
cout << endl;
return;
}

void DonneeBase::MiseAJour(void)
{
Entre(); // Entre une nouvelle donnée
// à la place de la donnée en cours.
return;
}

/* Définit la classe des données détaillées. */

class DonneeDetaillee : private DonneeBase
{
int ValeurEtendue; // Les données détaillées ont en plus
// une valeur étendue.

public:
void Entre(void); // Redéfinition de la méthode d'entrée.
};

void DonneeDetaillee::Entre(void)
{
DonneeBase::Entre(); // Appelle la méthode de base.
cin >> ValeurEtendue; // Entre la valeur étendue.
cout << endl;
return;
}

Si d est un objet de la classe DonneDetaille, l'appel de d.Entre ne causera pas de problème. En revanche, l'appel de d.MiseAJour ne fonctionnera par correctement, car la fonction Entre appelée dans MiseAjour est la fonction de la classe DonneeBase, et non la fonction redéfinie dans DonneeDetaille.

Il fallait déclarer la fonction Entre comme une fonction virtuelle. Il n'est nécessaire de le faire que dans la classe de base. Celle-ci doit donc être déclarée comme suit

class DonneeBase
{
protected:
int Numero;
int Valeur;

public:
virtual void Entre(void); // Fonction virtuelle.
void MiseAJour(void);
};

Cette fois, la fonction Entre appelée dans MiseAJour est soit la fonction de la classe DonneeBase, si MiseAJour est appelée pour un objet de classe DonneeBase, soit celle de la classe DonneeDetaille si MiseAJour est appelée pour un objet de la classe DonneeDetaillee.

En résumé, les méthodes virtuelles sont des méthodes qui sont appelées selon la vraie classe de l'objet qui l'appelle. Les objets qui contiennent des méthodes virtuelles peuvent être manipulés en tant qu'objets des classes de base, tout en effectuant les bonnes opérations en fonction de leur type. Ils apparaissent donc comme étant des objets de la classe de base et des objets de leur classe complète indifféremment, et on peut les considérer soit comme les uns, soit comme les autres. Un tel comportement est appelé polymorphisme (c'est à dire qui peut avoir plusieurs aspects différents). Nous verrons une application du polymorphisme dans le cas des pointeurs sur les objets.

Dérivation

Nous allons voir ici les règles de dérivation. Ces règles permettent de savoir ce qui est autorisé et ce qui ne l'est pas lorsqu'on travaille avec des classes de base et leurs classes filles (ou classes dérivées).

La première règle, qui est aussi la plus simple, indique qu'il est possible d'utiliser un objet d'une classe dérivée partout où l'on peut utiliser un objet de ses classes mères. Les méthodes et données des classes mères appartiennent en effet par héritage aux classes filles. Bien entendu, on doit avoir les droit d'accès sur les membres de la classe de base que l'on utilise.

La deuxième règle indique qu'il est possible de faire une affectation d'une classe dérivée vers une classes mère. Les données qui ne servent pas à l'initialisation sont perdues, puisque la classe mère ne possède pas les champs correspondants. En revanche, l'inverse est strictement interdit. En effet, les données de la classe fille qui n'existent pas dans la classe mère ne pourraient pas recevoir de valeur, et l'initialisation ne se ferait pas correctement.

Enfin la troisième règle dit que les pointeurs des classes dérivées sont compatibles avec les pointeurs des classes mères. Cela signifie qu'il est possible d'affecter un pointeur de classe dérivée à un pointeur d'une de ses classes de base. Il faut bien entendu que l'on ait en outre le droit d'accéder à la classe de base, c'est à dire qu'au moins un de ses membres puisse être utilisé. Cette condition n'est pas toujours vérifiée, en particulier pour les classes de base dont l'héritage est private.

Un objet dérivé pointé par un pointeur d'une des classes mères de sa classe est considéré comme un objet de la classe du pointeur qui le pointe. Les données spécifiques à sa classe ne sont pas supprimées, elles sont seulement momentanément inaccessibles. Cependant, le mécanisme des méthodes virtuelles continue de fonctionner correctement. En particulier, le destructeur de la classe de base doit être déclaré en tant que méthode virtuelle. Cela permet d'appeler le bon destructeur en cas de destruction de l'objet.

Il est possible de convertir un pointeur de classe de base en un pointeur de classe dérivée si la classe de base n'est pas virtuelle. Cependant, même lorsque la classe de base n'est pas virtuelle, cela est dangereux, car la classe dérivée peut avoir des membres qui ne sont pas présents dans la classe de base, et l'utilisation de ce pointeur peut conduire à des erreurs très graves. C'est pour cette raison qu'un transtypage est nécessaire pour ce type de conversion.

Soient par exemple les deux classes définies comme suit

#include <iostream>

using namespace std;

class Mere
{
public:
Mere(void);
~Mere(void);
};

Mere::Mere(void)
{
cout << "Constructeur de la classe mère." << endl;
return;
}

Mere::~Mere(void)
{
cout << "Destructeur de la classe mère." << endl;
return;
}

class Fille : public Mere
{
public:
Fille(void);
~Fille(void);
};

Fille::Fille(void) : Mere()
{
cout << "Constructeur de la classe fille." << endl;
return;
}

Fille::~Fille(void)
{
cout << "Destructeur de la classe fille." << endl;
return;
}

Avec ces définitions, seule la première des deux affectations suivantes est autorisée

Mere m;   // Instanciation de deux objets.
Fille f;

m=f; // Cela est autorisé, mais l'inverse ne le serait pas :
f=m; // ERREUR !! (ne compile pas)

Les mêmes règles sont applicables pour les pointeurs d'objets

Mere *pm, m;
Fille *pf, f;
pf=&f; // Autorisé.
pm=pf; // Autorisé. Les données et les méthodes
// de la classe fille ne sont plus accessibles
// avec ce pointeur : *pm est un objet
// de la classe mère.
pf=&m; // ILLÉGAL : il faut faire un transtypage :
pf=(Fille *) &m; // Cette fois, c'est légal, mais DANGEREUX !
// En effet, les méthodes de la classe filles
// ne sont pas définies, puisque m est une classe mère.

L'utilisation d'un pointeur sur la classe de base pour accéder à une classe dérivée nécessite d'utiliser des méthodes virtuelles. En particulier, il est nécessaire de rendre virtuels les destructeurs. Par exemple, avec la définition donnée ci-dessus pour les deux classes, le code suivant est faux :

Mere *pm;
Fille *pf = new Fille;
pm = pf;
delete pm; // Appel du destructeur de la classe mère !

Pour résoudre le problème, il faut que le destructeur de la classe mère soit virtuel (il est inutile de déclarer virtuel le destructeur des classes filles)

class Mere
{
public:
Mere(void);
virtual ~Mere(void);
};

On notera que bien que l'opérateur delete soit une fonction statique, le bon destructeur est appelé, car le destructeur est déclaré virtual. En effet l'opérateur delete recherche le destructeur à appeler dans la classe de l'objet le plus dérivé. De plus, l'opérateur delete restitue la mémoire de l'objet complet, et pas seulement celle du sous-objet référencé par le pointeur utilisé dans l'expression delete. Lorsqu'on utilise la dérivation, il est donc très important de déclarer les destructeurs virtuels pour que l'opérateur delete utilise le vrai type de l'objet à détruire.

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. Le fonction doit également être déclarée virtuelle.

virtual type nom(paramètres) =0;

Par exemple nous voulons créer 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 distribué à 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.

Pointeurs sur les membres d'une classe

Nous avons déjà vu les pointeurs sur les objets. Il nous reste à voir les pointeurs sur les membres des classes.

Les classes regroupent les caractéristiques des données et des fonctions des objets. Les membres des classes ne peuvent donc pas être manipulés sans passer par la classe à laquelle ils appartiennent. Par conséquent, il faut, lorsqu'on veut faire un pointeur sur un membre, indiquer le nom de sa classe pour cela on utilise l'opérateur de portée.

Une fois le pointeur déclaré, on pourra l'initialiser en prenant l'adresse du membre de la classe du type correspondant. Pour cela, il faudra encore spécifier le nom de la classe avec l'opérateur de résolution de portée.