Programmation Orientée objet en C++

Ce cours est destiné à des étudiants en DUT Informatique connaissant un langage procédural (ADA, C, etc.). L’objectif ici est de fournir un support de cours permettant à des étudiants de réaliser un auto apprentissage du C++.

Le cours est émaillé de nombreux exemples illustrant le syntaxe ainsi que les pièges à éviter.

Ce cours est réalisé dans le cadre d'un module d'initiation à la programmation objet d'un volume de 30H.

Vocabulaire


Vous trouverez ici quelques définitions simples, voire simplistes dont le seul but est de permettre de se familiariser rapidement avec le vocabulaire du domaine. Les précisions seront apportées au fur et à mesure.

  • Méthode : équivalent de fonction/procédure dans le langage objet.
  • Classe : structure rassemblant à la fois les données et les méthodes pour les manipuler (c’est l’équivalent du type).
  • Objet : un objet est une instance de classe (équivalent de la variable). C'est la représentation 'physique' du modèle décrit dans la classe.
  • Encapsulation : c’est le fait de n’accéder aux données que par le biais de méthodes.
  • Héritage : les classes sont structurées de manière arborescente. L’héritage permet de définir des classes filles à partir de classes mères.
  • Polymorphisme : permet à un objet d’une classe fille (héritée) de prendre la place d’un objet d’une classe mère.
  • Surcharge/Surdéfinition : permet d’avoir plusieurs méthodes du même nom mais avec des paramètres différents (nombre et/ou type).
  • Constructeur/Destructeur : ce sont deux méthodes appelées systématiquement lors de la création (instanciation) d'un objet et de sa destruction (libération). Le constructeur porte le même nom que la classe, le destructeur également, mais précédé du signe ~.

Principes de la programmation objet


1. La programmation procédurale


La programmation procédurale (C, Pascal, Basic, …) est constituée d’une suite d’instructions (souvent réunies en fonctions) exécutées par une machine. Ces instructions ont pour but d’agir sur des données pour produire une effet quelconque.

Les fonctions, procédures et autres suites d’instructions accèdent à une zone où sont stockées les données. Il y a donc une dissociation entre les données et les fonctions ce qui pose des difficultés lorsque l’on désire changer les structures de données.

Dans les langages procéduraux, les procédures s’appellent entre elles et peuvent donc agir sur les même données provoquant ainsi des effets de bord. De ces problèmes sont issus une autre manière de programmer : la programmation par objet.

2. Les objets


Un programme est vu comme un ensemble d’entités (une société d’entités). Au cours de son exécution, ces entités collaborent en s’envoient des messages dans un but commun.

Nous avons dans ce schéma un lien fort entre les données et les fonctions qui y accèdent (tant pour les consulter que pour les créer).

Mais qu’appelle-t-on un objet ? Que représente un objet ?

Définition :

Un objet représente une entité du monde réel, ou de monde virtuel dans le cas d’objets immatériels, qui se caractérise par une identité, des états significatifs et par un comportement.

L’identité d’un objet permet de distinguer les objets les uns par rapport aux autres. Son état correspond aux valeurs de tous les attributs à un instant donné. Ces propriétés sont définies dans la classe d’appartenance de l’objet. Enfin, le comportement d’un objet se défini par l’ensemble des opérations qu’il peut exécuter en réaction aux messages envoyés (un message = demande d’exécution d’une opération ou invocation de méthode) par les autres objets. Ces opérations sont définies dans la classe d’appartenance de l’objet.

Prennons un exemple

3. L'héritage


L’héritage (ou dérivation de classe) permet de construire une classe à partir d’une ou de plusieurs autres. La classe héritée contient les attributs et méthodes de la classe dont elle dérive. L'avantage est important puisqu'il permet à partir de classes existantes de les enrichir (de les spécialiser) en ajoutant de nouvelles méthodes et de nouveaux attributs, et ce, sans repartir de zéro.

Exemple :

Une classe dont on dérive (ou sous-classe) est appelée classe de base (ou super-classe), celle qui l’utilise est appelé classe dérivée. Les classes automobile et camion sont donc des classes dérivées, la classe de base est véhicule.

L’héritage multiple offre la possibilité de pouvoir reprendre intégralement des travaux déjà réalisés. Il permet en outre la possibilité de regrouper en un seul endroit ce qui est commun à plusieurs.

4. Le polymorphisme


Les méthodes dessiner() et effacer() sont polymorphes. Leur nom est similaire dans les trois classes dérivées, mais dessiner un cercle est différent de dessiner un carré ou un triangle. Ainsi le polymorphisme se résume par : un même nom, plusieurs implémentations.
A chaque fois que l’on appelle une des deux méthodes dessiner ou effacer, il est nécessaire que l’on appelle celle qui est associée à la forme géométrique correspondante. Ce choix ne peut se faire qu’à l’exécution, on a donc une liaison dynamique. La détermination du choix de la bonne fonction se fait de manière automatique. On ne s’en s’occupe pas au moment du codage.

En programmation procédurale, on aurait :

dessiner(type_dessin) {
  switch (type_dessin)
  case Carre :
    … break ;
  case Cercle :
    … break ;
  case Triangle :
    … break ;
}

En POO (Programmation Orientée Objet), on écrit :

classe Carre {
  Dessiner ( ) ;
}

classe Cercle {
  Dessiner ( ) ;
}

classe Triangle {
  Dessiner ( ) ;
}

Selon les langages, il est nécessaire de préciser si une méthode doit être polymorphe ou pas. C’est le cas en C++, ça ne l’est pas en Java ou en SmallTalk.

Les avantages du polymorphisme sont les suivants :

  • Les fonctions ayant la même sémantique ont le même nom,
  • Le programmation est plus souple. Si on veut ajouter une classe Rectangle… y’a qu’à ! Il suffit d’ajouter les méthodes dessiner et effacer dans cette classe. Dans le cas de la programmation procédurale, il aurait fallu reprendre le code et l’enrichir.

En résumé : Le polymorphisme permet d'accéder à un objet (et donc aux méthodes) d'une sous-classe à partir de la classe de base, et ce même si cet objet est inconnu lors de la compilation (il devra néanmoins être connu au moment de l'exécution !).

5. Les langages orientés objets


Un langage est orienté objet s’il possède les mécanismes supportant le style de programmation orienté objet, c’est à dire, s’il autorise les facilités inhérentes à ce type de programmation.

Le C++ est sans contexte le langage objet le plus utilisé. C’est néanmoins un langage hybride autorisant à la fois la programmation procédurale dite « classique » mais aussi et surtout la programmation orientée objet.

Ses avantages sont sans contexte son efficacité et sa portabilité. Il possède un typage fort et est supporté par nombres d’ateliers de génie logiciel. Néanmoins, tout n’est pas rose dans ce bas monde, il possède quelques défauts (ou qualité selon ses affinités !) comme l’absence de gestion automatique de la mémoire (le fameux ramasse-miette que l’on retrouve en Lisp et en Java par exemple) ou l'introspection. Il est également complexe et réservé aux expert (là encore, ce point est discutable). Ces critiques peuvent se discuter et font souvent l'objet de guerilla C++/Java par exemple !

Le C++ a été conçu par Bjarne Stroustrup aux laboratoires ATT & Bell. C’est une extension (d’ou l’opérateur ++ d’incrémentation de C) du langage C. Il vise trois objectif : efficacité, vérification de types, programmation orientée objet.

Quelques points de repères historiques :

  • 1978 : La langage C de Kernighan et Ritchie, ATT & Bell Laboratory
  • 1980 : SmallTalk 80 de Goldberg du Xerox Palo Alto Research Center
  • 1983 : C++ de Stroustrup de ATT & Bell – c’est aussi le C ANSI qui est crée à cette époque
  • 1985 : Commercialisation C++ Version 1 – liaison dynamique, surcharge des opérateurs, références
  • 1987 : Début des efforts de normalisation
  • 1989 : C++ Version 2 – héritage multiple
  • 1991 : C++ Version 3 – classes paramétrées (templates), exceptions
  • milieu 1990 : Normalisation ANSI su C++
  • 1995 : JAVA

Du C au C++


1. Les commentaires


Tout comme en C, le C++ permet d’utiliser les symboles /* et */ pour baliser un commentaire. Le C++ autorise en plus le symbole // permettant d’ignorer ce qui suit jusqu’à la fin de la ligne.

Exemple :

/*
Ceci est un
Commentaire
Sur plusieurs lignes */

int main (void) { // ici débute le commentaire jusqu’à la fin de la ligne
{
  …
}	

2. Les flux - E/S avec cout, cin et cerr


Comme pour le langage C, les instructions d’entrées/sorties ne font pas partie des instructions du langage. Elles sont dans une librairie standardisée qui implémente les flux à partir de classes.

Alors qu’en C les entrées/sorties « classiques » s’effectuent à l’aide des fonctions scanf et printf, et bien qu’il soit possible de les utiliser en C++, il est préférable d’utiliser une gestion par flot (ou flux, ou stream). Quatre flots sont définis dans la librairie iostream.h.

  • cout, sortie standard
  • cin, entrée standard
  • cerr, erreur non tamponnée (pas de mise en tampon gestion non bufferisée)

L'opérateur << permet d'envoyer des valeurs dans un flot de sortie, tandis que >> permet d'extraire des valeurs d'un flot d'entrée.

Exemple :

#include <iostream.h>
cin >> age >> date_naissance // saisie de l’age puis de la date de naissance
cout << "J’ai" << age << " ans " << endl ; // endl permet un retour à la ligne

L’intérêt des flots est une vitesse d'exécution plus rapide. La fonction printf doit analyser à l'exécution la chaîne de formatage, tandis qu'avec les flots, la traduction est faite à la compilation. Nous le verrons plus tard, mais on peut également utiliser les flux avec les types utilisateurs (surcharge possible des opérateurs >> et <<).

3. Définition de variables


Il est possible en C++ de déclarer des variables n’importe où dans le code. Leur portée sera celle du bloc courant. Cette manière de procéder permet de définir une variable le plus près possible de son utilisation améliorant ainsi la lisibilité. Cette utilisation sera particulièrement appréciable dans les fonctions où de nombreuses variables locales sont nécessaires. On pourra aussi initialiser un objet avec une valeur obtenue auparavant par calcul ou par saisie.

Exemple :

int i;
cin >> i;
int j;
cin >> j;
const int k=j; //définition d'une constante initialisée par saisie.

4. Visibilité des variables


Il existe un opérateur de résolution de portée noté :: permettant d’accéder à des variables globales en lieu et place de la variable locale du même nom.

Exemple :

int indice = 11;

int main() {
  int indice = 34;
  { 
    int indice = 23;
    ::indice = ::indice + 1;
    cout << ::indice<< " " << indice<< endl;
  }
  cout << ::indice<< " " << v<< endl;
}

Résultat de l’exécution :

12 23
12 34

Bien que l’on puisse y voir un intérêt certain, cette pratique est à proscrire. Elle n’améliore pas la lisibilité des programmes, et bien au contraire complique les modifications et/ou les mises à jour.

5. Les types composés


Tout comme en C, il est possible de définir de nouveaux types en définissant des structures (struct), des énumérations (enum) ou des unions (union). La différence réside dans le fait qu’il n’est plus utile de spécifier le mot clé typedef pour nommer un type.

Exemple :

struct Fiche { // définition du type Fiche
  char nom[50], prenom[50];
  int age;
}; // en C, il faut ajouter la ligne typedef struct FICHE FICHE;

enum Booleen { VRAI, FAUX }; // en C, il faut ajouter la ligne typedef enum Booleen Booleen

Booleen trouve;
trouve = FAUX; // OK
trouve = 0; // Produit une erreur car le C++ réalise une vérification de type (pas d’erreur en C).

enum Semaine { DIMANCHE, LUNDI, MARDI, MERCREDI, JEUDI, VENDREDI, SAMEDI };
enum Drapeau { BLEU, BLANC, ROUGE };

//.....

int main (void) {
  Semaine j; // définition d'une variable de type Semaine
  Drapeau d ; // Définition d’une variable de type Drapeau
  j = LUNDI;
  d = BLEU ;
  d = j; // ERREUR en C++ (légal en C)
  //...
}

6. Allocation de mémoire


En C, l’allocation de mémoire se réalise en utilisant les fameuses et merveilleuses ( !) fonctions malloc (et ses variantes calloc, realloc) et free. Bien qu’il soit toujours possible de les utiliser en C++, deux nouveaux opérateurs ont pris leur place : new et delete.

Exemple :

int *ptr_int ;
ptr_int = new int ; // allocation dynamique d’un entier
ptr_int = new int (5); // allocation dynamique d’un entier avec initialisation à 5
ptr_int = new int [5]; // allocation dynamique de 5 entiers

delete ptr_int ; // libération d’un entier
delete[ ] ptr_int ; // libération d’un tableau d’entiers.
//Tout ce qui est alloué avec new [ ] doit être libéré avec delete [ ].

Il est important de toujours libérer l’espace mémoire alloué à l’aide d’un new dès qu’il n’est plus utile. Ainsi, à chaque new doit correspondre son alter ego : delete. Dans le cas contraire, on prendra de la mémoire, sans jamais la libérer, et dans le cas de programmes importants (ou par exemple lors d’allocations dans des appels récursifs) on pourrait arriver à rapidement manquer d’espace mémoire.

Les classes


Une classe représente le modèle structurel d’un objet. Elle est composée :

  • d’un ensemble d’attributs (ou champs, ou données membres) décrivant sa structure.
  • d’un ensemble d’opérations (ou méthodes, ou fonctions membres) qui lui sont applicables.

L'encapsulation consiste à masquer l'accès à certains attributs et méthodes d'une classe. Elle est réalisée à l'aide des mots clés :

  • private : les membres privés ne sont accessibles que par les fonctions membres de la classe. La partie privée est aussi appelée réalisation.
  • protected : les membres protégés sont comme les membres privés. Mais ils sont aussi accessibles par les fonctions membres des classes dérivées (voir l'héritage).
  • public : les membres publics sont accessibles par tous. La partie publique est appelée interface.

Les mots réservés private, protected et public peuvent figurer plusieurs fois dans la déclaration de la classe.

1. Entête dans le .hpp


Tout comme il est préconisé de le faire en C pour une fonction, l’entête d’une classe est généralement placé dans un fichier avec l’extension .hpp (.h pour le C). Voici la déclaration type d’une classe :

class maClasse {
  private :
    // on place ici les données et méthodes inaccessibles de l’extérieur 
    // (cette partie ne sera pas non plus accessible par héritage - nous verrons cela plus loin)
    int a ; // déclaration d’une variable privée

  protected :
    // partie visible uniquement par les classes dérivées

  public :
    // partie visible de l’extérieur, c’est ici qu’on place généralement les méthodes
    // permettant d’accéder aux élément de la section private.
    int b ;
    int valeurA(void) ; // méthode pour accéder à la variable a - principe d’encapsulation.
    void modifierA(int) ; // idem
    int additionnerA(int) ; // idem
    int init( ) ;
} ;

2. Définition dans le .cpp


C’est dans le fichier comportant l'extention .cpp que l’on écrit le corps de la classe décrite dans le fichier .hpp.

Afin d’éviter toute ambiguïté, on prendra soin de préciser pour chaque implémentation le nom de la classe s’y rapportant.

#include "maClasse.hpp"

void maClasse : : modifierA(int valeur) {
  // corps de la méthode
}

int maClasse : : additionnerA(int valeur) {
  //…
}

//…

3. Instanciation et Utilisation


Il existe deux manières d’instancier des classes selon que l’on passe par des pointeurs ou non.

#include " maClasse.hpp "

int main(void) {
  maClasse c1;
  c1.modifierA(10) ; // on fixe la valeur de a à 10.
  c1.b = 15 ;
  c1.a = 12 ; // NON, attribut privé
}

Bien que l’on ne respecte pas le concept d’encapsulation (il faudrait une méthode pour cela), il est possible d’accéder directement à b et de le manipuler puisqu’il est déclaré en public. Par contre, l’accès direct à a est interdit puisqu’il est défini dans la section private. Si l’on veut y accéder, il est obligatoire de passer par ses méthodes (modifierA, valeurA, …).

4. Utilisation avec des pointeurs


La modification ne se fait bien évidemment que pour une instanciation de classe (un objet). Si l’on reprend l’exemple précédent, voici la manière de déclarer et d’utiliser l’objet c1.

#include « maClasse.hpp »

void main(void) {
  maClasse *c1;
  c1 = new maClasse ;
  c1->modifierA(10) ; // on fixe la valeur de a à 10.
  (*c1).b = 15 ; // plus élégant, c1->b=15
  c1->.a = 12 ; // NON
  delete c1 ;
}
Informations sur les pointeurs :

Un pointeur se déclare de la manière suivante…

int *monPointeur ; // Pointeur sur un entier
maClasse *monPointeur ; // Pointeur sur une classe de type maClasse
void *monPointeur ; // Pointeur vers un objet non encore connu

… et s’initialise ainsi :

maClasse monObjet ; // Création d’un objet de type maClasse
maClasse *monPointeur ; // Création d’un pointeur sur un objet de type maClasse
monPointeur = &monObjet ; // le pointeur désigne l’objet monObjet

Il ne faut pas confondre le symbole & utilisé ici pour obtenir l’adresse de monObjet à celui utilisé pour obtenir une référence lors d’un passage de paramètres par exemple.

Les pointeurs peuvent également être utilisés (et c’est une utilisation fréquente) dans le cadre d’objets dynamiques avec les opérateurs new et delete.

maClasse *monPointeur ; // on déclare un pointeur sur une classe de type maClasse
monPointeur = new maClasse ; // On alloue la mémoire correspondant à maClasse
delete monPointeur ; // on libère cette mémoire

On peut également déclarer des tableaux d’objets :

maClasse *monPointeur ; // on déclare un pointeur sur une classe de type maClasse
monPointeur = new maClasse[10] ; //on alloue l’espace mémoire pour 10 objets de type maClasse
delete [10]monPointeur ; //on libère l’espace correspondant à ces 10 objets.

Afin d’accéder à l’objet pointé, on utilisera le pointeur de la manière suivante :

(*monPointeur).nomMéthode(…) 
//ou
monPointeur->nomMéthode(…)

Si le pointeur fait référence à un tableau d’objets, il est possible d’accéder aux objets suivants en utilisant l’arithmétique des pointeurs :

maClasse *mp ;
monPointeur++ ; // le prochain accès à monPointeur se fera sur le deuxième objet du tableau
monPointeur[2] ; // désigne le 3ème objet.
mp = &monPointeur[4] ; //mp recevra l’adresse du 5ème objet. On y accédera par exemple avec (*mp).nomMéthode(…)

5. Fonctions


Tout comme cela est possible en C, les méthodes en C++ permettent de réaliser un passage de paramètres par valeur ainsi que par adresse (ou référence) en utilisant le caractère &. La déclaration est conforme au standard C-ANSI.

Une fonction se définit par :

  • son nom,
  • sa liste typée de paramètres formels,
  • le type de la valeur qu'elle retourne.

Passage de Paramètres

Passage de paramètres par référence :

Lors de retour par références (et ceci est également valable en C), il est nécessaire que l’objet retourné existe avant l’appel à la fonction.

L’intérêt du passage par référence contrairement au passage par valeur est que toute modification sur le paramètre passé par référence sera conservée lors de la fin de la fonction.

Exemple :

void maFonction1 (int a) {
  a++ ;
  cout << a ;
}

void maFonction2 (int &a) {
  a++ ;
  cout << a ;
}

void main (void) {
  int a = 5;

  maFonction1(a);
  cout << a << endl;
  maFonction2(a) ;
  cout << a << endl;
}

Affichera :

6 5
6 6

Paramètres par défaut :

Il est possible en C++ de prévoir une valeur par défaut aux paramètres des méthodes. Voici quelques exemple de définition de fonctions avec des valeurs par défaut (les paramètres par défaut sont toujours les derniers) :

maFonction (int, int = 10) ; // par défaut le second paramètre sera 10.
maFonction (int, int * = &n) ; // par défaut, le second paramètre sera l’adresse de la variable globale de type entier n.

6. Constructeurs/Destructeurs


Contrairement aux données locales d’une fonction, les données membres d'une classe ne peuvent pas être initialisées. Il faut donc prévoir une méthode se chargeant de ce travail et penser à l’appeler lors de chaque instanciation d’objet. Dans le cas d’un oubli d’appel de cette méthode d’initialisation, le fonctionnement de l’objet risque de produire des résultats surprenants !

De manière symétrique, nous pouvons prévoir une méthode qui serait la dernière appelée pour cet objet dont le rôle serait la destruction propre de ce même objet. Elle permettrait par exemple la libération en mémoire des variables allouées dynamiquement, suppression de graphiques, fermetures de fichiers, …

Afin d’éviter ces oublis, il existe une manière de prévoir ces initialisations ainsi que ces terminaisons, ce sont les constructeurs et les destructeurs. Ainsi, lors de la création de l’objet la constructeur sera appelé (il porte le même nom que la classe, ne retourne rien, même pas void). Lors de la destruction de cette classe, son destructeur (~nomClasse) sera invoqué.
Ainsi, en considérant une classe appelée maClasse, si l’on désire avoir les variables a et b initialisées, il faut :

Ajouter le constructeur dans la section public

.hpp :

public :
maClasse( int, int) ;

.cpp :

maClasse : : maClasse( int v1, int v2 = 10) {
  a = v1 ; b = v2 ;
}

Le constructeur étant une méthode (publique) comme une autre, il lui est possible d’avoir des paramètres, éventuellement même des paramètres par défaut.

Exemple :

maClasse c1(12) ; // a = 12, b=10
maClasse c2(5,15) ; // a = 5, b=15

Concernant le destructeur, le fonctionnement est similaire

.hpp

~maClasse( ) ;
.cpp
maClasse::~maClasse( ) {
  //…
}

7. Objets utilisés en paramètres, en retour


Nous avons vu que chaque méthode peut avoir des paramètres et qu’elle peut retourner une valeur ou une référence à une valeur. En C++, les méthodes peuvent également avoir en paramètre des objets (ou des références à des objets) et retourner des objets ou des références à des objets.

8. Adresse d'un objet : this


Lorsqu’il est nécessaire de référencer l’objet courant, le pointeur this permet de spécifier que l’on appelle une méthode de l’objet courant (this.maMethode() )

9. Constructeur par recopie


Il est tout à fait correct d’écrire ceci :

maClasse c1 ;
maClasse c2 = c1 ;

Le problème est le suivant : la recopie de l’objet c1 dans l’objet c2 est faite sans tenir compte des zones pointées. Ainsi, les pointeurs pointent (forcément !) sur la même zone mémoire. L’intervention d’un des objets sur cette zone produira des effets de bords sur l’autre. Il est ainsi nécessaire de parfois avoir un constructeur spécial, appelé constructeur par recopie qui s’occupera de réaliser une copie correcte de l’objet et d’éviter ainsi ces effets de bord.

Le constructeur par recopie est automatiquement appelé lorsqu’un nouvel objet est déclaré avec comme valeur initiale celle d’un autre objet ( maClasse c2 = c1 ;).

Ce type de constructeur est utile avec les objets utilisant l’allocation dynamique lorsque l’on désire faire passer un objet en paramètre par valeur à des fonctions. Puisque le passage est par recopie, on se retrouverait dans le cas où plusieurs objets pointeraient sur les mêmes zones mémoires.

Ajoutons un constructeur par recopie :

.hpp

maClasse (maClasse &) ;

.cpp

maClasse::maClasse (maClasse &mc) {
  //traitement nécessaire pour allouer une nouvelle zone mémoire, y recopier les informations nécessaires
}

10. Objets contenant d'autres objets


On reste toujours dans le problème des constructeurs, puisqu’il s’agit encore ici d’un problème analogue. Imaginons que notre classe maClasse contienne des attributs d’autres classes :

maClasse {
  maClasse2 m1 ;
  maClasse3 m2 ;
}

Lors de l’instanciation d’un objet de la classe maClasse, son constructeur va être appelé. Or, si les objets m1 et m2 ne sont pas initialisés, il y a fort à parier que des problèmes font se produire puisque m1 et m2 n’auront pas les valeurs escomptées. Il faut donc appeler leurs constructeurs et qui plus est, il faut les appeler avant celui de maClasse.

Le constructeur de maClasse s’écrirait ainsi :

maClasse : : maClasse(int v1, int v2) : m1(…), m2(…) {
  // si les constructeurs des objets m1 et m2 nécessitent des paramètres, il sera nécessaire de les y mettre également.
  //…
}

11. Surcharge/Surdéfinition d'opérateurs


Nous avons déjà montré qu’il était possible de surcharger des fonctions. Nous allons maintenant voir qu’il est également possible de surcharger des opérateurs. La surcharge d’opérateurs permet une syntaxe et une utilisation, plus intuitive de la classe.

Ces surdéfinitions peuvent se faire au niveau global (non spécifiques à une classe) ou bien comme fonctions membres de classes, c’est ce que nous allons voir ici.

.hpp

class maClasse {
  //…

  maClasse operator + (maClasse &) ;
  maClasse operator = (maClasse &) ;
} ;

.cpp

maClasse : : maClass operator + (maClasse & c1) {
  //…
  // corps de l’opérateur +
}

maClasse operator = (maClasse &c1) {
  //…
}

L’utilisation de l’opérateur + pourra se faire ainsi :

void main (void) {
  maClasse c1,c2,c3 ;
  c3 = c1+ c2 ; 
  // le « = » sera interprété comme c3 = operator + (c1,c2) ; 
  // le « + » sera interprété comme c3.operator=( c2.operator+( c1 ) );
}
Liste des opérateurs surchargeables :

La plupart des opérateurs sont surchargeables. Voici leur liste :

( ), [ ], ->
+ (unaire), - (unaire), ++, --, !, * (unaire), & (unaire)
*, /, %, +, -, <<, >>,
<, <=, >, ==, !=
&, ^, | |, &&, |
= 

Les opérateurs :: . .* ?: sizeof ne peuvent être surdéfinis. De même, il n’est pas possible de changer la priorité, l’associativité, la pluralité (unaire, binaire, ternaire) ni de créer de nouveaux opérateurs.

La valeur de retour d’un opérateur surdéfini peut être une référence à un objet ou bien directement une valeur. Cela dépendra en fait du type d’opérateur surchargé.

12. Héritage


Partant de l’idée qu’il est souvent plus facile d’adapter que de réinventer, le principe d’héritage aussi appelé dérivation permet de créer de nouvelles classes à partir de classes déjà existantes, appelées classes de bases ou classes mères.
Cet héritage permet de pouvoir réutiliser l’ensemble des données et méthodes des classes mères qui ne sont pas définies dans la section private. Il bien évidemment possible d’ajouter à cette classe fille de nouvelles données et/ou méthodes ou même de surdéfinir les méthodes des classes mères.

Voici la syntaxe du .hpp :

Classe maClasse1 : public maClasse2 {
  //…ajout de données, ajout et/ou surcharge de méthodes
}

Voici un exemple de surdéfinition de fonction dans la classe dérivé :

Exemple :

class maClasse1 {
  public:
    void f1();
    void f2();
  protected:
    int a;
};

class maClasse2 : public maClasse1 {
  public:
    void f2( ); / / on surdéfinit la méthode f2
    void f2 (int) // idem
    void f3( ); // on ajoute une méthode
};

void maClasse2::f3( ) {
  maClasse1 :: f2( ); // on appelle la fonction f2 mais non pas de la classe fille, mais de sa classe mère
  maClasse1 :: a = 12; // accès au membre a de la classe mère
  f1( ); // appel de f1 de la mère (elle n’a pas été surchargée dans la classe fille)
  f2( ); // appel de f2 de la classe fille
}
//…
Constructeurs – Destructeurs

Si la classe fille possède un constructeur, celui-ci ne sera appelé qu’une fois le constructeur de la classe mère exécuté. Dans le cas des destructeurs, l’ordre est inverse, celui de la classe fille est appelé avant celui de la classe mère.

Exemple :

class MaClasse1 {
  public:
    MaClasse1( ) {
      cout<< "MaClasse1" << endl;
    }
    ~MaClasse1( ) {
      cout<< "~MaClasse1" << endl; 
    }
};

class MaClasse2 : public MaClasse1 {
  public:
    MaClasse2( ) {
      cout<< "MaClasse2" << endl;
    }
    ~MaClasse2() {
      cout<< "~MaClasse2" << endl;
    }
};

void main() {
  MaClasse2 *c2 = new MaClasse2;
  // ...
  delete c2;
}

Affichera :

MaClasse1
MaClasse2
~MaClasse2
~MaClasse1

Dans le cas ou les constructeurs nécessiteraient des paramètres, on aurait à définir :

maClasse2::maClasse2(paramètres): maClasse1(paramètres)

Remarque : Il est tout à fait possible de convertir une instance de classe dérivée en une instance de la classe de base. L'inverse est par contre interdit puisque le compilateur ne saurait pas comment initialiser les membres de la classe dérivée.

Inclusion des fichiers

Lors d’héritage, il risque de se produire l’inclusion de même fichiers dans différentes classes.

Prenons l’exemple de deux classes C1 et C2 héritant d’une même classe C. Chacune de ces deux classes va réaliser un #include "C.hpp". Imaginons un programme qui a besoin de déclarer ces deux objets, il fera un #include "C1.hpp" et #include "C2.hpp" ce qui provoquera une erreur du compilateur puisqu’il verra deux fois la même classe ( C ) et donc soulèvera le problème d’une double définition.

On prendra pour cela l’habitude pour chaque classe, d’inclure les directives de compilation en entête du .hpp.

#ifndef nomDeLaClasse
#define nomDeLaClasse
…
… corps du .hpp
#endif
;

Lors de la première inclusion du .hpp, une variable correspondant au nom de la classe sera déclarée (peu importe sa valeur, nous n’en avons pas mis ici). A partir de la seconde inclusion, le .hpp ne sera pas pris en compte puisque la variable sera déjà définie.

Héritage multiple

Jusqu’à présent nous avons expliqué comment dériver une classe à partir d’une classe de base. En C++ (contrairement à Java par exemple), il est possible de réaliser ce qui est appelé un héritage multiple, c’est à dire, de dériver une classe à partir de plusieurs autres.

La syntaxe est la suivante :

Classe maClasse : public maClasse1 : public maClasse2 {
  …
}

L’ordre des classes mères n’est pas anodin puisqu’il définit l’ordre d’exécution des constructeurs (s’ils existent). Nous aurons ici tout d’abord celui de maClasse1, puis maClasse2 et enfin maClasse. Pour les destructeurs, l’ordre est inverse.

Classes virtuelles

Imaginons l’héritage multiple qui suit :

Dans l’exemple ci-dessus, la classe maClasse4 va hériter deux fois du membre a de maClasse1. Une fois par maClasse2 et une fois par maClasse3. Nous aurons donc deux fois le membre a ce qui va provoquer une ambiguïté.

Ce problème se résout en utilisant l’opérateur de résolution de portée ( :: ) :

Exemple :

void main() {
  maClasse4 c4;
  c4.a = 0; // ERREUR, ambiguïté
  c4.maClasse2 :: a = 1; // OK
  c4.maClasse3 :: a = 2; // OK
}

Néanmoins, cette solution n’est pas satisfaisante puisque nous nous retrouvons avec deux instances du membre a. La solution pour n’en n’avoir qu’une est d’utiliser l’héritage virtuel.

Si l’on veut que maClasse2 et maClasse3 n’héritent qu’une seule fois de maClasse1, il faut qu’elles héritent virtuellement de cette dernière.

Il ne faut pas confondre l’héritage virtuel avec les membres de classes virtuels. Ici, le mot clé virtual ne sert qu’à indiquer au compilateur les classes à ne pas dupliquer.

Polymorphisme

Alors que l’héritage permet la réutilisation de code écrit pour une classe de base, le polymorphisme permettra l'utilisation d'une même instruction pour appeler dynamiquement des méthodes différentes dans la hiérarchie des classes. Ceci est rendu possible en C++ à l’aide de l’utilisation des fonctions virtuelles.

Un objet d’une classe dérivée est objet de la classe de base, pas le contraire. Ainsi un objet de classe dérivée peut être utilisé là où un objet de la classe mère peut l’être.

Prenons le cas de la maClasse2 héritée de maClasse1 :

maClasse1 *ptr1 obj1;
maClasse2 *ptr2 ,obj2;

On a le droit de faire :

ptr1 = ptr2 ;
obj1 = obj2 ;

Dans le premier cas, l’élément pointé par ptr1 sera considéré comme étant de la classe maClasse1 à cause de la déclaration de ptr1, on ne pourra donc pas utiliser les méthodes de maClasse2.

Si une méthode de la classe mère est redéfinie dans la classe fille, c’est celle de la classe mère qui sera utilisée (puisque le pointeur pointe sur un objet de la classe maClasse1). Mais il est possible de modifier ceci en déclarant la méthode comme virtual dans la classe maClasse1. Ce mot clé indique au compilateur que cette méthode peut être redéfinie dans les classes dérivées et qu’il faudra décider au moment de l’appel au cours de l’exécution laquelle il convient de choisir selon l’objet pointé. Tout ceci ne peut bien évidemment marcher que dans le cas d’objets dynamiques (impossible de faire ceci avec obj1 = obj2).

Pour déclarer une méthode virtuelle, voici la syntaxe :

//Fonctions virtuelles

class ObjGraph {
public:
  void print( ){ cout <<"ObjGraph"; }
};

class Bouton: public ObjGraph {
  public:
    void print( ){ cout << "Bouton"; }
};

class Fenetre: public ObjGraph {
  public:
    void print( ){ cout << "Fenetre"; }
};

void traitement(ObjGraph &og) {
  // ...
  og.print( ); og étant de type ObjGraph, on appelle la méthode print de l’objet ObjetGraph.
  // ...
}

void main( ) {
  Bouton b;
  Fenetre fen;
  traitement(b);
  traitement(fen);
}

Le résultat sera :

traitement(b); // affichage de ObjGraph
traitement(fen); // affichage de ObjGraph
virtual void maClasse::mafonction(paramètres) ;

Si dans la fonction traitement() nous voulons appeler la méthode print() selon la classe à laquelle appartient l'instance, nous devons définir, dans la classe de base, la méthode print() comme étant virtuelle :

class ObjGraph {
  public:
    // ...
    virtual void print( ) const {
      cout<< "ObjetGraphique" << endl;
    }
};

Pour plus de clarté, le mot-clé virtual peut être répété devant les méthodes print() des classes Bouton et Fenetre :

class Bouton: public ObjGraph {
  public:
    virtual void print( ) const {
      cout << "Bouton" << endl;
    }
};

class Fenetre: public ObjGraph {
  public:
    virtual void print( ) const {
      cout << "Fenetre" << endl;
    }
};

C’est ce comportement que l’on appelle le polymorphisme. Lorsque le compilateur rencontre une méthode virtuelle, il sait qu'il faut attendre l'exécution pour déterminer la bonne méthode à appeler.

Lorsque l’on utilise des fonctions virtuelles, il ne faut pas oublier d’également déclarer le destructeur virtuel correspondant (un constructeur, par contre, ne peut pas être déclaré comme virtuel).

Il ne faut pas oublier de définir le destructeur comme virtual lorsque l'on utilise une méthode virtuelle :

class ObjGraph {
  public:
    //...
    virtual ~ObjGraph( ) { cout << "fin de ObjGraph\n"; }
};

class Fenetre : public ObjGraph {
  public:
    // ...
    ~Fenetre( ) { cout << "fin de Fenêtre "; }
};

void main( ) {
  Fenetre *fen = new Fenetre;
  ObjGraph *og = fen;
  // ...
  delete og; // affichage de : « fin de fen, fin de ObjGraph »
}
Classes abstraites

Il arrive souvent que la méthode virtuelle définie dans la classe de base serve de cadre générique pour les méthodes virtuelles des classes dérivées. Ceci permet de garantir une bonne homogénéité de votre architecture de classes.

Une classe est dite abstraite si elle contient au moins une méthode virtuelle pure (une méthode virtuelle pure se déclare en ajoutant un = 0 à la fin de sa déclaration).

On ne peut pas créer d'instance d'une classe abstraite de même qu’une classe abstraite ne peut pas être utilisée comme argument ou type de retour d'une fonction. Par contre, les pointeurs et les références sur une classe abstraite sont parfaitement légitimes et justifiés.

Exemple :

class maClasse {
  public:
    virtual void mafonction ( ) const = 0;
};

void main( ) {
  maClasse c; // ERREUR
}
Membres ou fonctions statiques

Il est parfois nécessaire de n’avoir qu’une seule instance d’un membre d’une classe. Ce membre sera unique quel que soit le nombre d’objets déclarés. On dit qu’il est static.

Exemple :

maClasse {
  static int nboccurences ;
  int a ;
  //…
}

void main ( ) {
  maClasse c1,c2,c3 ; on obtient 3 fois la variable a, mais une seule fois la variable nboccurences (=3)
}

Nous avons ici un entier qui servira à compter le nombre d’instances d’objets de cette classe. Il est initialisé une seule et unique fois (int maClasse :: nboccurences = 0 ;) puis à chaque nouvel objet le constructeur pourra par exemple l’incrémenter et chaque destructeur le décrémenter.

Il est également possible d’avoir des fonction static. Elles sont généralement utilisées pour agir sur des membres communs (des membres static). Elle n’est pas liée aux instances de classes (aux objets) mais uniquement à la classe. Sa syntaxe est la suivante :

Static void maClasse :: mafonction( …) ;