Aller au contenu

Recherche de chemin à travers l’algorithme A* en C++ 2/4 : Les fondamentaux du C++

Partie 1 : Classe, héritage, classe virtuelle

La couche objet sans doute la plus grande innovation du C++ par rapport au C. Le but de la programmation objet est de permettre une abstraction entre l’implémentation des modules et leur utilisation, apportant ainsi un plus grand confort dans la programmation. Elle s’intègre donc parfaitement dans le cadre de la modularité. Enfin l’encapsulation des données permet une meilleure protection et donc une plus grande fiabilité des programmes.

Généralités

Théoriquement, il y a une nette distinction entre les données et les opérations qui leur sont appliqués. En tout cas, les données et le code ne se mélangent pas dans la mémoire de l’ordinateur, sauf cas particulier. Cependant l’analyse des problèmes à traiter se présente d’une manière plus naturelle si l’on considère les données avec leurs propriétés. Les données constituent les variables et les propriétés les opérations qu’on peut leur appliquer. De ce point de vue, les données et le code sont logiquement inséparables, même s’ils sont placés en différents endroits de la mémoire de l’ordinateur.

Ces considérations conduisent à la notion d’objet. Un objet est un ensemble de données sur lesquelles des procédures peuvent être appliquées. Ces procédures ou fonctions applicables aux données sont appelées méthodes. La programmation d’un objet se fait donc en indiquant les données de l’objet et en définissant les procédures qui peuvent lui être appliquées. Il se peut qu’il y ait plusieurs objets identiques, dont les données ont bien entendu des valeurs différentes, mais qui utilisent le même jeu de méthodes. On dit que ces différents objets appartiennent à la même classe d’objets. Une classe constitue donc une sorte de type et les objets de cette classe en sont des instances. La classe définit donc la structure des données, alors appelés champs ou variables d’instances, que les objets correspondants auront, ainsi que les méthodes de l’objet. À chaque instanciation, une allocation de mémoire est faite pour les données du nouvel objet créé. L’initialisation de l’objet est détruit, une autre méthode est appelée : le destructeur. L’utilisateur peut définir ses propres constructeurs et destructeurs d’objets si nécessaire.

Comme seules les valeurs des données des différents objets d’une classe diffèrent, les méthodes sont mises en commun pour tous les objets d’une même classe (c’est-à-dire que les méthodes ne sont pas recopiées). Pour que les méthodes appelées pour un objet sachent sur quelles données elles doivent travailler, un pointeur sur l’objet contenant ces données leur est passé en paramètre. Ce mécanisme est complètement transparent pour le programmeur en C++. Nous voyons donc que non seulement la programmation orientée objet est plus logique, mais elle est également plus efficace (les méthodes sont mises en commun, les données sont séparées).

Enfin, les données des objets peuvent être protégées : c’est-à-dire que seules les méthodes de l’objet peuvent y accéder. Ce n’est pas une obligation, mais cela accroît la fiabilité des programmes. Si une erreur se produit, seules les méthodes de l’objet doivent être vérifiées. De plus, les méthodes constituent ainsi une interface entre les données de l’objet et l’utilisateur de l’objet. Cet utilisateur n’a donc pas à savoir comment les données sont gérées dans l’objet, il ne doit utiliser que les méthodes. Les avantages sont immédiats : il ne risque pas de faire des erreurs de programmation en modifiant les données lui-même, l’objet est réutilisable dans un autre programme parce qu’il a une interface standardisée, et on peut modifier l’implémentation interne de l’objet sans avoir à refaire tout le programme, pourvu que les méthodes gardent le même nom, les mêmes paramètres et la même sémantique. Cette notion de protection des données et de masquage de l’implémentation interne aux utilisateurs de l’objet constitue ce que l’on appelle l’encapsulation. Les avantages de l’encapsulation seront souvent mis en valeur dans la suite au travers d’exemples.

Extensions de la notion de type du C

Il faut avant tout savoir que la couche objet n’est pas un simple ajout au langage C, c’est une véritable extension. En effet, les notions qu’elle a apportées ont été intégrées au C à tel point que le typage des données de C a fusionné avec la notion de classe. Ainsi, les types prédéfinis char, int, double, etc représentent à présent l’ensemble des propriétés des variables ayant ce type. Ces propriétés constituent la classe de ces variables, et elles sont accessibles par les opérateurs. Par exemple, l’addition est une opération pouvant porter sur des entiers qui renvoie un objet de la classe entier. Par conséquent, les types de base se manipuleront exactement comme des objets. Du point de vue du C++, les utiliser revient déjà à faire de la programmation orientée objet.

De même, le programmeur peut, à l’aide de la notion de classe d’objets, définir de nouveaux types. Ces types comprennent la structure des données représentées par ces types et les opérations qui peuvent leur être appliquées. En fait, le C++ assimile complètement les classes avec les types et la définition d’un nouveau type se fait donc en définissant la classes des variables de ce type.

Déclaration de classes en C++

Afin de permettre la définition des méthodes qui peuvent être appliquées aux structures des classes C++, la syntaxe des structures C a été étendue. Il est à présent possible de définir complètement des méthodes dans la définition de la structure. Cependant, il est préférable de la reporter et de ne laisser que leur déclaration dans la structure. En effet, cela accroît la lisibilité et permet de masquer l’implémentation de la classe à ses utilisateurs en ne leur montrant que sa déclaration dans un fichier d’en tête. Ils ne peuvent donc ni la voir, ni la modifier. La syntaxe est la suivante :

class Nom{
    attribute; 
    methode;
}

Où Nom est le nom de la classe. Elle peut contenir divers champs de divers types. Les méthodes peuvent être des définitions de fonctions, ou seulement leurs déclarations. Si on ne donne que leurs déclarations, on devra les définir plus loin. Pour cela, il faudra spécifier la classe à laquelle elles appartiennent avec la syntaxe suivante

type classe::nom(paramètres){
    //définition de la méthode
}

La syntaxe est donc identique à la définition d’une fonction normale, à la différence près que leur nom est précédé du nom de la classe à laquelle elles appartiennent et deux deux points. Cet opérateur :: est appelé l’opérateur de résolution de portée. Il permet, d’une manière générale, de spécifier le bloc auquel l’objet qui le suit appartient. Ainsi, le fait de précéder le nom de la méthode par le nom de la classe permet au compilateur de savoir de quelle classe cette méthode fait partie. Rien n’interdit, en effet, d’avoir des méthodes de même signature, pourvu qu’elles soient dans des classes différentes.

De même, l’opérateur de résolution de portée permettra d’accéder à une variable globale lorsqu’une autre variable homonyme aura été définie dans le bloc en cours. Les champs d’une classe peuvent être accédés comme des variables normales dans les méthodes de cette classes.

L’accès aux méthodes de la classe se fait comme pour accéder aux champs des structures. On donne le nom de l’objet et le nom du champ ou de la méthode, séparés par un point. Par exemple :

int i; 
for (i=0; i<100; ++i)
    if(clientele[i].dans_le_rouge()) relance(clientele[i]);

Lorsque les fonctions membres d’une classe sont définies dans la déclaration de cette classe, le compilateur les implémente en inline. Si les méthodes ne sont pas définies dans la classe, la déclaration de la classe sera mise dans un fichier d’en-tête, et la définition des méthodes sera reportée dans un fichier C++, ce qui sera compilé et lié aux autres fichiers utilisant la classe client. Bien entendu, il est toujours possible de déclarer les fonctions membres comme étant des fonctions inline même lorsqu’elles sont définies en dehors de la déclaration de la classe. Pour cela, il faut utiliser le mot clé inline, et placer le code de ces fonctions dans le fichier d’en-tête.

Encapsulation des données

Les divers champs d’une structure sont accessibles en n’importe quel endroit du programme. Une opération telle que celle-ci est donc faisable clientele[0].Sole=25 000;. Le solde d’un client peut donc être modifié sans passer par une méthode dont ce serait le but. Elle pourrait par exemple vérifier que l’on n’affecte pas un solde supérieur au solde maximal autorisé par le programme (la borne supérieure des valeurs des entiers signés).

Il est possible d’empêcher l’accès des champs ou de certaines méthodes à toute fonction autre que celles de la classe. Cette opération s’appelle l’encapsulation. Pour la réaliser il faut utiliser les mots clés suivants :

  • public : les accès sont libres
  • private : les accès sont autorisés dans les fonctions de classe seulement
  • protected : les accès sont autorisés dans les fonctions de la classe et de ses descendantes (voir la section suivante) seulement. Le mot clé protected n’est utilisé que dans le cadre de l’héritage des classes. La section suivante détaillera ce point.

Pour changer les droits d’accès des champs et des méthodes d’une classe, il faut faire précéder ceux-ci du mot clé indiquant les droits d’accès suivi des deux points. Par exemple pour protéger les données relatives au client, on changera simplement la déclaration de la classe en :

class client
{
private:   // Données privées :
 
    char Nom[21], Prenom[21];
    unsigned int Date_Entree;
    int Solde;
    // Il n'y a pas de méthode privée.
 
public:    // Les données et les méthodes publiques :
 
    // Il n'y a pas de donnée publique.
    bool dans_le_rouge(void);
    bool bon_client(void)
};

Outre la vérification de la validité des opérations l’encapsulation a comme intérêt fondamental de définir une interface stable pour la classe au niveau des méthodes et données membres publiques et protégées. L’implémentation de cette interface, réalisée en privé, peut être modifiée à loisir sans pour autant perturber les utilisateurs de cette classe, tant que cette interface n’est pas elle-même modifiée.

Par défaut, les classes construites avec struct ont tous les membres publics. Les classes construites avec ̀class` ont tous les membres privés.

Héritage

L’héritage permet de donner à une classe toutes les caractéristiques d’une ou de plusieurs autres classes. Les classes dont elle hérite sont appelées classes mères, classes de base ou classes antécédentes. La classe elle-même est appelée classe file, classe dérivée ou classe descendante.

Les propriétés héritées sont les champs et les méthodes des classes de base.

Pour faire un héritage en C++, il faut faire suivre le nom de la classe fille par la liste des classes mères dans la déclaration avec les restrictions d’accès aux données, chaque élément étant séparé des autres par une virgule. La syntaxe est la suivante

class Classe_mere
{

};
class Classe_fille : public Classe_mere

On peut utiliser les mots clés private, protected et public dans l’héritage à la place de public.

Il est possible de redéfinir les fonctions et les données des classes de base dans une classe dérivée. Par exemple, si une classe B dérive de la classe A, et que toutes deux contiennent une donnée d, les instances de la classe B utiliseront la donnée d de la classe B et les instances de la classe A utiliseront la donnée d de la classe A. Cependant, les objets de classe B contiendront également un sous-objet, lui même instance de la classe de base A. Par conséquent, ils contiendront la donnée d de la classe A, mais cette dernière sera cachée par la donnée d de la classe la plus dérivée, à savoir la classe B.

Ce mécanisme est général : quand une classe dérivée redéfinit un membre d’une classe de base, ce membre est caché et on ne peut plus accéder directement qu’au membre redéfini (celui de la classe dérivée). Cependant, il est possible d’accéder aux données cachées si l’on connaît leur classe, pour cela, il faut nommer le membre complètement à l’aide de l’opérateur de résolution de portée. Le nom complet d’un membre est constitué du nom de sa classe suivi de l’opérateur de résolution de portée, suivis du nom du membre.

Classes virtuelles

Supposons à présent qu’une classe D hérite de deux classes mères, les classes B et C. Supposons également que ces deux classes héritent d’une classe mère commune appelée classe A. On sait que B et C héritent des données et des méthodes publiques et protégées de A. De même, D hérite des données de B et C, et par leur intermédiaire des données de A. Il se pose donc le problème suivant : quelles sont les données que l’on doit utiliser quand on référence les champs de A ? Celles de B ou celles de C ? On peut accéder aux deux sous-objets de classe A en spécifiant le chemin à suivre dans l’arbre généalogique à l’aide de l’opérateur de résolution de portée. Cependant, cela n’est ni pratique ni efficace, et en général, on s’attend à ce qu’une seule copie de A apparaisse dans D. Le problème est résolu en déclarant virtuelle la classe de base commune dans la spécification de l’héritage pour les classes filles. Les données de la classe de base ne seront alors plus dupliquées. Pour déclarer une classe mère comme une classe virtuelle, il faut faire précéder son nom du mot clé virtual dans l’héritage des classes filles.

class A
{
protected:
    int Donnee;        // La donnée de la classe de base.
};
 
// Héritage de la classe A, virtuelle :
class B : virtual public A
{
protected:
    int Valeur_B;      // Autre donnée que "Donnee" (héritée).
};
 
// A est toujours virtuelle :
class C : virtual public A
{
protected:
    int valeur_C;      // Autre donnée
                       // ("Donnee" est acquise par héritage).
};
 
class D : public B, public C   // Ici, Donnee n'est pas dupliqué.
{
    /* Définition de la classe D. */
};

Premièrement, il est impossible de transtyper directement un pointeur sur un objet d’une classe de base virtuelle en un pointeur sur un objet de ses classes dérivées. Il faut impérativement utiliser l’opérateur de transtypage dynamique. Cet opérateur sera décrit plus tard.

Deuxièmement, chaque classe dérivée directement ou indirectement d’une classe virtuelle doit en appeler le constructeur explicitement dans son constructeur si celui-ci prend des paramètres. En effet, elle ne peut pas se fier au fait qu’une autre de ses classes de base, elle-même dérivée de la classe de base virtuelle, appelle un constructeur spécifique, car il est possible que plusieurs classes de base cherchent à initialiser différemment chacune un objet commun hérité de classe virtuelle. Pour reprendre l’exemple donnée ci-dessus, si les classes B et C appellaient toutes les deux un constructeur non trivial de la classe virtuell A et que la classe D appellait elle même les constructeurs de B et C, le sous objet hérité de A serait construit plusieurs fois. Pour éviter cela, le compilateur ignore purement et simplement les appels au constructeur des classes de bases virtuelles dans les classes de base dérivées. Il faut donc systématiquement le spécifier, à chaque niveau de la hiérarchie de classe. La notion de constructeur sera vue plus tard.

Partie 2 : Constructeur, destructeurs, pointeur this, statique, surcharge

Constructeurs et destructeurs

Le constructeur et le destructeur sont deux méthodes particulières qui sont appelées respectivement à la création et à la destruction d’un objet. Toute classe a un constructeur et un destructeur par défaut, fournis par le compilateur. Ces constructeurs et destructeurs appellent les constructeurs par défaut et les destructeurs des classes de base et des données membre de la classe, mais en dehors de cela, ils ne font absolument rien. Il est donc souvent nécessaire de les redéfinir afin de gérer certaines actions qui doivent avoir lieu lors de la création d’un objet et de leur destruction. Par exemple, si l’objet doit contenir des objets allouées dynamiquement, il faut leur réserver de la mémoire à la création de l’objet ou au moins mettre les pointeurs correspondants à NULL. À la destruction de l’objet, il convient de restituer la mémoire allouée, s’il en a été alloué. On peut trouver bien d’autres situations où une phase d’initialisation et une phase de terminaison son nécessaires.

Dés qu’un constructeur ou un destructeur a été défini par l’utilisateur, le compilateur ne définit plus automatiquement le constructeur ou le destructeur par défaut correspondant. En particulier, si l’utilisateur définit un constructeur prenant des paramètres, il ne sera plus possible de construire un objet simplement, sans fournir les paramètres à ce constructeur, à moins bien entendu de définir également un constructeur qui ne prenne pas de paramètres.

Définition des constructeurs et des destructeurs

Le constructeur se définit comme une méthode normale. Cependant, pour que le compilateur puisse la reconnaître en tant que constructeur, les deux conditions suivantes doivent vérifiées :

  • elle doit porter le même nom que la classe
  • elle ne doit avoir aucun type, pas même le type void

Le destructeur doit également respecter ces règles. Pour différencier du constructeur, son nom sera toujours précédé du signe tilde.

Un constructeur est appelé automatiquement lors de l’instanciation de l’objet. Le destructeur est appelé automatiquement lors de sa destruction. Cette destruction a lieu lors de la sortie du bloc de portée courante pour les objets de classe de stockage auto. Pour les objets alloués dynamiquement, le constructeur et le destructeur sont appelés automatiquement par les expressions qui utilisent les opérateurs new, new[], delete, delete[]. C’est pour cela qu’il est recommandé de les utiliser à la place des fonctions malloc et free du C pour créer dynamiquement des objets. De plus, il ne faut pas utiliser delete ou delete[] sur des pointeurs de type void, car il n’existe pas d’objets de type void. La compilateur ne peut donc pas déterminer quel est le destructeur à appeler avec ce type de pointer.

Le constructeur est appelé après l’allocation de la mémoire de l’objet et le destructeur est appelé avant la libération de cette mémoire. La gestion de l’allocation dynamique de mémoire avec les classes est ainsi simplifiée. Dans le cas des tables, l’ordre de construction est celui des adresses croissantes, et l’ordre de destruction est celui des adresses décroissantes. C’est dans cet ordre que les constructeurs et destructeurs de chaque élement du tableau sont appelés.

Les constructeurs pourront avoir des paramètres. Ils peuvent donc être surchargés, mais pas les destructeurs. Cela est dû au fait qu’en général on connaît le contexte dans lequel un objet est créé, mais qu’on ne peut pas connaître le contexte dans lequel il est détruit : il ne peut donc y avoir qu’un seul destructeur. Les constructeurs qui ne prennent pas de paramètre ou dont tous les paramètres ont une valeur par défaut, remplacent automatiquement les constructeurs par défaut définis par le compilateur lorsqu’il n’y a aucun constructeur dans les classes. Cela signifie que ce sont ces constructeurs qui seront appelés automatiquement par les constructeurs par défaut des classes dérivées.

class chaine    // Implémente une chaîne de caractères.
{
    char * s;   // Le pointeur sur la chaîne de caractères.
 
public:
    chaine(void);           // Le constructeur par défaut.
    chaine(unsigned int);   // Le constructeur. Il n'a pas de type.
    ~chaine(void);          // Le destructeur.
};
 
chaine::chaine(void)
{
    s=NULL;                 // La chaîne est initialisée avec
    return ;                // le pointeur nul.
}
 
chaine::chaine(unsigned int Taille)
{
    s = new char[Taille+1]; // Alloue de la mémoire pour la chaîne.
    s[0]='\0';              // Initialise la chaîne à "".
    return;
}
 
chaine::~chaine(void)
{
    if (s!=NULL) delete[] s; // Restitue la mémoire utilisée si
                             // nécessaire.
    return;
}

Pour passer les paramètres au constructeur, on donne la liste des paramètres entre parenthèses juste après le nom de l’objet lors de son instanciation :

chaine s1;        // Instancie une chaîne de caractères
                  // non initialisée.
chaine s2(200);   // Instancie une chaîne de caractères
                  // de 200 caractères.

Les constructeurs devront parfois effectuer des tâches plus compliquées que celles données dans cet exemple. En général, ils peuvent faire toutes les opérations faisables dans une méthode normale, sauf utiliser les données non initialisées bien entendu. En particulier, les données des sous-objets d’un objet ne sont pas initialisées tant que les constructeurs des classes de base ne sont pas appelés. C’est pour cela qu’il faut toujours appeler les constructeurs des classes de base avant d’exécuter le constructeur de la classe en cours d’instanciation. Si les constructeurs des classes de base ne sont pas appelés explicitement, le compilateur appellera, par défaut, les constructeurs des classes mères qui ne prennent pas de paramètre ou dont tous les paramètres ont une valeur par défaut.

Comment appeler les constructeurs et les destructeurs des classes mères lors de l’instanciation et de la destruction d’une classe dérivée ? Le compilateur ne peut en effet pas savoir quel constructeur il faut appeler parmi les différents constructeurs surchargés potentiellement présents … Pour appeler un autre constructeur d’une classe de base que le constructeur ne prenant pas de paramètre, il faut spécifier explicitement ce constructeur avec ses paramètres après le nom du constructeur de la classe fille, en les séparant de deux points.

En revanche, il est inutile de préciser le destructeur à appeler, puisque celui-ci est unique. Le programmeur ne doit donc pas appeler lui-même les destructeurs des classes mères, le langage s’en charge.

/* Déclaration de la classe mère. */
 
class Mere
{
    int m_i;
public:
    Mere(int);
    ~Mere(void);
};
 
/* Définition du constructeur de la classe mère. */
 
Mere::Mere(int i)
{
    m_i=i;
    printf("Exécution du constructeur de la classe mère.\n");
    return;
}
 
/* Définition du destructeur de la classe mère. */
 
Mere::~Mere(void)
{
    printf("Exécution du destructeur de la classe mère.\n");
    return;
}
 
/* Déclaration de la classe fille. */
 
class Fille : public Mere
{
public:
    Fille(void);
    ~Fille(void);
};
 
/* Définition du constructeur de la classe fille
   avec appel du constructeur de la classe mère. */
 
Fille::Fille(void) : Mere(2)
{
    printf("Exécution du constructeur de la classe fille.\n");
    return;
}
 
/* Définition du destructeur de la classe fille
   avec appel automatique du destructeur de la classe mère. */
 
Fille::~Fille(void)
{
    printf("Exécution du destructeur de la classe fille.\n");
    return;
}

Lors de l’instanciation d’un objet de la classe fille, le programme affichera dans l’ordre les messages suivants :

  1. Exécution du constructeur de la classe mère
  2. Exécution du constructeur de la classe fille

et lors de la destruction de l’objet

  1. Exécution du destructeur de la classe fille
  2. Exécution du destructeur de la classe mère

Si l’on n’avait pas précisé que le constructeur à appeler pour la classe Mère était le constructeur prenant un entier en paramètre, le compilateur aurait essayé d’appeler le constructeur par défaut de cette classe. Or, ce constructeur n’étant plus généré automatiquement par le compilateur (à cause de la définition d’un constructeur prenant un paramètre), il y aurait eu une erreur de compilation.

Il est possible d’appeler plusieurs constructeurs si la classé dérive de plusieurs classes de base. Pour cela, il suffit de lister les constructeurs un à un, en séparant leurs appels par des virgules. On notera cependant que l’ordre dans lequel les constructeurs sont appelés n’est pas forcément l’ordre dans lequel ils sont listés dans la définition du constructeur de la classe fille. En effet, le C++ appelle toujours les constructeurs dans l’ordre d’apparition de leurs classes dans la liste des classes de base de la classe dérivée.

Une fonction virtuelle peut donc toujours être appelée dans un constructeur, mais la fonction effectivement appelée est celle de la classe du sous-objet en cours de construction : pas celle de la classe de l’objet complet. Ainsi, si une classe A hérite d’une classe B et qu’elles ont toutes les deux une fonction virtuelle f, l’appel de f dans le constructeur de B utilisera la fonction f de B, pas celle de A (même si l’objet que l’on instancie est de classe A).

La syntaxe utilisée pour appeler les constructeurs des classes de base peut également être utilisée pour initialiser les données membres de la classe. En particulier, cette syntaxe est obligatoire pour les données membres constantes et pour les références, car le C++ ne permet pas l’affectation d’une valeur à des variables de ce type. Encore une fois, l’ordre d’appel des constructeurs des données membres ainsi initialisées n’est pas forcément l’ordre dans lequel ils sont listés dans le constructeur de classe. En effet, le C++ utilise cette fois l’ordre de déclaration de chaque donnée membre.

class tableau
{
    const int m_iTailleMax;
    const int *m_pDonnees;
public:
    tableau(int iTailleMax);
    ~tableau();
};
 
tableau::tableau(int iTailleMax) :
    m_iTailleMax(iTailleMax)    // Initialise la donnée membre constante.
{
    // Allocation d'un tableau de m_iTailleMax entrées :
    m_pDonnees = new int[m_iTailleMax];
}
 
tableau::~tableau()
{
    // Destruction des données :
    delete[] m_pDonnees;
}

Constructeurs de copie

Il faudra parfois créer un constructeur de copie. Le but de ce type de constructeur est d’initialiser un objet lors de son instanciation à partir d’un autre objet. Toute classe dispose d’un constructeur de copie par défaut généré automatiquement par le compilateur, dont le seul but est de recopier les champs de l’objet à recopier un à un dans les champs de l’objet à instancier. Toutefois, ce constructeur par défaut ne suffira pas toujours, et le programmeur devra parfois en fournir un explicitement.

Ce sera notamment le cas lorsque certaines données des objets auront été allouées dynamiquement. Une copie brutale des champs d’un objet dans un autre ne ferait que recopier les pointeurs, pas les données pointées. Ainsi, la modification de ces données pour un objet entraînerait la modification des données de l’autre objet, ce qui ne serait sans doute pas l’effet désiré.

La définition des constructeurs de copie se fait comme celle des constructeurs normaux. Le nom doit être celui de la classe, et il ne doit y avoir aucun type. Dans la liste des paramètres cependant, il devra toujours y avoir une référence sur l’objet à copier.

Pour la classe chaine définie ci-dessus, il faut un constructeur de copie. Celui-ci peut être déclaré de la façon suivante

chaine(const chaine &Source);

où Source est l’objet à copier. Si l’on rajoute la donnée membre Taille dans la déclaration de la classe, la définition de ce constructeur peut être :

chaine::chaine(const chaine &Source)
{
    int i = 0;                   // Compteur de caractères.
    Taille = Source.Taille;
    s = new char[Taille + 1];    // Effectue l'allocation.
    strcpy(s, Source.s);         // Recopie la chaîne de caractères source.
    return;
}

Le constructeur de copie est appelé dans toute instanciation avec initialisation, comme celles qui suivent

chaine s2(s1);
chainse s2 = s1;

Dans les deux exemples, c’est le constructeur de copie qui est appelé. En particulier, à la deuxième ligne, le constructeur normal n’est pas appelé et aucune affectation entre objets n’a lieu.

Utilisation des constructeurs dans les transtypages

Les constructeurs sont utilisés dans les conversions de type dans lesquelles le type cible est celui de la classe du constructeur. Ces conversions peuvent être soit implicites (dans une expression), soit explicite (à l’aide d’un transtypage). Par défaut, les conversions implicites sont légales, pourvu qu’il existe un constructeur dont le premier paramètre a le même type que l’objet source. Par exemple, la classe Entier suivante :

class Entier
{
    int i;
public:
    Entier(int j)
    {
        i=j;
        return ;
    }
};

dispose d’un constructeur de transtypage pour les entiers. Les expressions

int j=2;
Entier e1, e2=j;
e1=j;

sont donc légales, la valeur entière située à la droite de l’expression étant convertie implicitement en un objet du type de la classe Entier.

Si, pour une raison quelconque, ce comportement n’est pas souhaitable, on peut forcer le compilateur à n’accepter que les conversions explicites (à l’aide de transtypage). Pour cela, il suffit de placer le mot clé explicit avant la déclaration du constructeur. Par exemple, le constructeur de la classe chaine vue ci-dessus prenant un entier en paramètre risque d’être utilisé dans des conversions implicites. Or ce constructeur ne permet pas de construire une chaîne de caractères à partir d’un entier, et ne doit pas être utilisé dans les opérations de transtypage. Ce constructeur doit donc être déclaré explicit

class chaine
{
    size_t Taille;
    char * s;
 
public:
    chaine(void);
    // Ce constructeur permet de préciser la taille de la chaîne
	// à sa création :
    explicit chaine(unsigned int);
    ~chaine(void);
};

Avec cette déclaration, l’expression suivante

int j=2;
chainse s=j;

n’est plus valide, alors qu’elle l’était lorsque le constructeur n’était pas explicit.

Pointeur this

Nous allons à présent voir comment les fonctions membres, qui appartiennent à la classe, peuvent accéder aux données d’un objet, qui est une instance de cette classe. Cela est indispensable pour bien comprendre les paragraphes suivants.

À chaque appel d’une fonction membre, le compilateur passe implicitement un pointeur sur les données de l’objet en paramètre. Ce paramètre est le premier paramètre de la fonction. Ce mécanisme est complètement invisible au programmeur, et nous ne nous attarderons pas dessus.

En revanche, in faut savoir que le pointeur sur l’objet est accessible à l’intérieur de la fonction membre. Il porte le nom this. Par conséquent, *this représente l’objet lui-même. Nous verrons une utilisation de this dans le paragraphe suivant

this est un pointeur constant, c’est à dire qu’on ne peut pas le modifier (il est donc impossible de faire des opérations arithmétiques dessus). Cela est tout à fait normal, puisque le faire reviendrait à sortir de l’objet en cours (celui pour lequel la méthode en cours d’exécution travaille).

Il est possible de transformer ce pointeur constant en un pointeur constant sur des données constantes pour chaque fonction membre. Le pointeur ne peut toujours pas être modifié et les données de l’objet ne peuvent pas être modifiées non plus. L’objet est donc considéré par la fonction membre concernée comme un objet constant. Cela revient à dire que la fonction membre s’interdit la modification des données de l’objet. On parvient à ce résultat en ajoutant le mot clé const à la suite de l’en te de la fonction membre.

Il est à noter qu’une méthode qui n’est pas déclarée comme étant const modifie a priori les données de l’objet sur lequel elle travaille. Donc, si elle est appelée sur un objet déclaré const, une erreur de compilation se produit. Ce comportement est normal. On devra donc toujours déclarer const une méthode qui ne modifie pas réellement l’objet, afin de laisse à l’utilisateur le choix de déclarer const ou non les objets de sa classe.

Données et fonctions membres statiques

Nous allons voir dans ce paragraphe l’emploi du mot clé static dans les classes. Ce mot clé intervient pour caractériser les données membres statiques des classes, les fonctions membres statiques des classes et les données statiques des fonctions membres.

Données membres statiques

Une classe peut contenir des données membres statiques. Ces données sont soit des données membres propres à la classe, soit des données locales statiques des fonctions membres de la classe. Dans tous les cas, elles appartiennent à la classe, et non pas aux objets de cette classe. Elles sont donc communes à tous ces objets.

Il est impossible d’initialiser les données d’une classe dans le constructeur de la classe, car le constructeur n’initialise que les données des nouveaux objets. Les données statiques ne sont pas spécifiques à un objet particulier et ne peuvent donc pas être initialisées dans le constructeur. En fait, leur initialisation doit se faire lors de leur définition, en dehors de la déclaration de la classe. Pour préciser la classe à laquelle les données sont définies appartiennent, on devra utiliser l’opérateur de résolution de portée.

class test
{
    static int i;       // Déclaration dans la classe.
    ...
};
 
int test::i=3;         // Initialisation en dehors de la classe.

La variable test::i sera partagée par tous les objets de classe test, et sa valeur initiale est 3.

Les variables statiques des fonctions membres doivent être initialisées à l’intérieur des fonctions membres. Elles appartiennent également à la classe, et non pas aux objets. De plus, leur portée est réduite à celle du bloc dans lequel elles ont été déclarées. Ainsi le code suivante :

#include <stdio.h>
 
class test
{
public:
    int n(void);
};
 
int test::n(void)
{
    static int compte=0;
    return compte++;
}
 
int main(void)
{
    test objet1, objet2;
    printf("%d ", objet1.n());   // Affiche 0
    printf("%d\n", objet2.n());  // Affiche 1
    return 0;
}

affichera 0 et 1, parque la variable statique compte est la même pour les deux objets

Fonctions membres statiques

Les classes peuvent également contenir des fonctions membres statiques. Cela peut surprendre à première vue, puisque les fonctions membres appartiennent déjà à la classe, c’est à dire à tous les objets. En fait, cela signifie que ces fonctions membres ne recevront pas le pointeur sur l’objet this, comme c’est le cas pour les autres fonctions membres. Par conséquent, elles ne pourront accéder qu’aux données statiques de l’objet

class Entier
{
    int i;
    static int j;
public:
    static int get_value(void);
};
 
int Entier::j=0;
 
int Entier::get_value(void)
{
    j=1;         // Légal.
    return i;    // ERREUR ! get_value ne peut pas accéder à i.
}

La fonction get_value de l’exemple ci-dessus ne peut pas accéder à la donnée membre non statique i, parce qu’elle ne travaille sur aucun objet. Son champ d’action est uniquement la classe Entier. En revanche, elle peut modifier la variable statique j, puisque celle-ci appartient à la classe Entier et non aux objets de cette classe.

L’appel des fonctions membre statiques se fait exactement comme celui des fonctions membres non statiques, en spécifiant l’identificateur d’un des objets de la classe et le nom de la fonction membre, séparés par un point. Cependant, comme les fonctions membres ne travaillent pas sur les objets des classes mais plutôt sur les classes elles-mêmes, la présence de l’objet lors de l’appel est facultatif. On peut donc se contenter d’appeler une fonction statique en qualifiant son nom du nom de la classe à laquelle elle appartient à l’aide de l’opérateur de résolution de portée.

class Entier
{
    static int i;
public:
    static int get_value(void);
};
 
int Entier::i=3;
 
int Entier::get_value(void)
{
    return i;
}
 
int main(void)
{
    // Appelle la fonction statique get_value :
    int resultat=Entier::get_value();
    return 0;
}

Les fonctions membres statiques sont souvent utilisées afin de regrouper un certain nombre de fonctionnalités en rapport avec leur classe. Ainsi, elles sont facilement localisable et les risques de conflits de noms entre deux fonctions membres homonymes sont réduits. Nous verrons également plus tard comment éviter les conflits de noms globaux dans le cadre des espaces de nommage.

Opérateurs

Les opérateurs sont des outils à manipuler avec précautions puisqu’ils peuvent rendre votre code illisible et incompréhensible, les opérateurs ne seront pas abordés dans ce cours mais ils le sont ici

Partie 3 : Les entrées sorties, méthodes virtuelles, classes 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 : cincoutcerr 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.

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.

Exercices

Installation du projet mkdir build && cd build && cmake ..

Compilation du projet make

Exécution du programme principal ./bin/project

Création de la classe personne

Dans les deux fichiers personne.hpp et personne.cpp, écrire le code de la classe Personne.

Dans le programme principal main.cpp écrire une fonction void afficherNom(Personne& p) qui affiche sur la sortie standard le nom de la personne. Le code ci-dessous doit afficher « Alexandre » dans la console.

int main(int argc, char *argv[])
{
    Personne a = Personne("Alexandre", 20);
    afficherNom(a);
    return 0;
}

Création de la classe Etudiant et Enseignant

Écrire la classe Etudiant dérivée de Personne. Cette classe redéfinie la méthode getNom() en renvoyant le nom de la personne précédée par eleve :

Écrire la classe Enseignant dérivée de Personne. Cette classe redéfinie la méthode getNom() en renvoyant le nom de la personne précédée par enseignant :

int main(int argc, char *argv[])
{
    Personne a = Personne("Alexandre", 20);
    Etudiant b = Etudiant("Bruno", 20, "Info", "Algo Num");
    Enseignant c = Enseignant("Cyril", 30, 35);

    std::cout << a.getNom() << std::endl;
    std::cout << b.getNom() << std::endl;
    std::cout << c.getNom() << std::endl;

    afficherNom(a);
    afficherNom(b);
    afficherNom(c);
    return 0;
}

Exécutez ce code, expliquez pourquoi les lignes 7 à 9 affichent respectivement Alexandre, eleve: Bruno, enseignant : Cyril alors que les lignes 11 à 13 affichent Alexandre, Bruno, Cyril.

Comment remédier à ce problème ?

Création de la classe vacataire

En utilisant l’héritage multiple créer la classe Vacataire héritant de la classe Etudiantet de la classe Enseignant. Cette classe redéfinie la méthode getNom() en appelant la méthode getNom() des deux classes de bases et en renvoyant la concaténation des deux.

Essayez d’executer le code ci desous, pourquoi ne fonctionne-t-il pas ?

int main(int argc, char *argv[])
{
    Personne a = Personne("Alexandre", 20);
    Etudiant b = Etudiant("Bruno", 20, "Info", "Algo Num");
    Enseignant c = Enseignant("Cyril", 30, 35);
    Vacataire d = Vacataire("Dorian", 25, 20,"Info", "Algo Num");
    afficherNom(d);
    return 0;
}

Nous allons chercher à remédier au problème de afficherNom(d), avant ça rajoutez la ligne suivante dans le constructeur de la classe Personne : std::cout << "Constructeur de Personne appelé" << std::endl;. Ensuite, executez le programme principal suivant

int main(int argc, char *argv[])
{
    Vacataire d = Vacataire("Dorian", 25, 20,"Info", "Algo Num");
    return 0;
}

Combien de personnes sont construites ? Est-ce le comportement souhaité ? Comment remédier à ce problème ?