Précédent Index Suivant

Chapitre 3   Programmation objet

3.1   Classes et instances

De manière classique, la classe regroupe des variables d'instance et des méthodes, ainsi que d'éventuelles variables et méthodes de classe. Contrairement à Java, on distingue en C++ la définition de la classe de sa mise en oeuvre. La première regroupe la déclaration des variables et les signatures des méthodes ; elle se met dans un fichier header, qu'on inclut quand on veut accéder à l'interface de cette classe dans une autre classe ou dans un programme. Dans ce fichier header, on ne met a priori pas les corps des méthodes, sauf celles qui sont inline.

Illustrons cela en déclarant une classe d'objets postaux, ayant quatre variables d'instance : poids, valeur, recommande et tarif :
   class ObjetPostal {
   protected:
       int poids;
       int valeur;
       bool recommande;
       double tarif;
   public:
       // Constructeur
       ObjetPostal(int p = 20);
       // Méthodes inline
       bool aValeurDeclaree() const { return (valeur > 0); }
       int getPoids() const { return poids; }
       void recommander() { recommande = true; }
   };
Comme en Java, les variables d'instance et les méthodes peuvent être privées, protégées ou publiques. La différence entre données protégées et données privées est que seules les premières restent accessibles dans les sous-classes de la classe. Les quatre variables poids, valeur, recommande et tarif sont protégées : elles ne sont accessibles que par les méthodes définies dans la classe ObjetPostal et dans celles de ses sous-classes éventuelles.

Les méthodes dont la définition est incluse dans la déclaration de la classe, comme aValeurDeclaree, recommander et getPoids, sont implantées par des fonctions inline pour un gain d'efficacité à l'exécution. Le fait qu'elles soient définies à l'intérieur de la déclaration de classe suffit à les rendre inline, sans nécessité de mot clé particulier.

La fonction ObjetPostal(int), de même nom que la classe, est un constructeur de la classe. Elle est simplement déclarée ici, et sera définie ailleurs. Nous y reviendrons au § 3.2.

À noter aussi l'utilisation de const dans la déclaration des méthodes aValeurDeclaree et getPoids. Cette qualification garantit que ces méthodes ne modifient pas l'état interne de l'objet. Elle est le complément naturel du passage par référence constante d'un objet à une fonction (cf. § 2.4) ; en effet, sur un objet passé en référence constante à une fonction, on ne pourra appliquer que des méthodes elles-mêmes déclarées constantes. Nous vous encourageons fortement à prendre l'habitude dès le départ d'utiliser de manière systématique ces déclarations const partout où cela a un sens. Ce sera une garantie contre beaucoup d'effets de bord indésirables, dans la mesure où la correction du point de vue de la constance des objets est vérifiée dès la compilation.

La classe ObjetPostal peut être utilisée comme un nouveau type dans le programme :
   ObjetPostal* z = new ObjetPostal(200);
   ...
   delete z;
Attention : la variable z est ici un pointeur sur l'instance, et non l'instance elle-même. Nous revenons au paragraphe suivant sur l'instanciation.

Dans le corps d'une méthode, les variables d'instance de la classe sont désignées simplement par leur nom. L'accès aux variables et méthodes d'autres objets se fait classiquement par la notation pointée, ou par la notation ``flèche'' dans le cas d'un pointeur :
   ObjetPostal op;
   ...
   op.recommander();
   ...
   ObjetPostal* z = new ObjetPostal(200);
   ...
   if (z->aValeurDeclaree()) {
      ...
   }

3.2   Constructeurs et destructeurs

Toute classe peut comporter une ou plusieurs fonctions publiques particulières portant le même nom que la classe et appelées les constructeurs. Elles précisent comment doit être créée --- ou plutôt initialisée --- une instance de la classe, en donnant en particulier les valeurs initiales de certaines variables d'instance.

Revenons sur le constructeur ObjetPostal déclaré précédemment dans la classe de même nom. Dans le cas présent, seule cette fonction est définie hors du fichier header, dans le fichier de définition qui porte le nom de la classe et typiquement le suffixe .C ou .cpp :
   #include <ObjetPostal.h>   // inclusion de la déclaration

   ObjetPostal::ObjetPostal(int p) {
      poids = p; 
      valeur = 0; 
      recommande = false;
   }
À noter que dans la déclaration de la classe, le paramètre p a la valeur par défaut 20 ; l'appel du constructeur sans paramètre est donc équivalent à son appel avec la valeur 20. À noter aussi l'utilisation de l'opérateur de résolution de portée ::, nécessaire dès que l'on n'est plus ``dans'' la définition de la classe, pour rattacher la fonction à sa classe d'appartenance.

En fait, il n'est jamais nécessaire d'appeler explicitement un constructeur pour créer une instance. C'est le compilateur qui se charge de choisir le constructeur à utiliser, en fonction des paramètres d'instanciation. Si aucun constructeur ne s'applique, un constructeur par défaut est appelé, qui initialise les variables à des valeurs nulles. Il est cependant fortement recommandé de toujours prévoir un constructeur, en tout cas dès que la classe n'est pas triviale. En particulier, nous verrons que pour être correctement utilisée par les containers et les algorithmes de la STL, une classe doit être munie d'un constructeur par défaut et d'un constructeur par copie (cf. § 4.3.1).

Conformément à ce qui vient d'être dit, la déclaration :
   ObjetPostal x;
dans une méthode ou un programme, crée un objet postal dont le poids est de 20 (valeur par défaut). En revanche, la déclaration
   ObjetPostal x(140);
crée une instance de la classe ObjetPostal de poids 140 grammes.

En fait, un constructeur comme ce dernier, avec un seul paramètre, tient lieu de fonction de conversion implicite de type. Par exemple, la déclaration suivante est valide :
   ObjetPostal x = 30;
Elle est traduite par l'application de la fonction de conversion d'un entier en objet postal, équivalente à la déclaration suivante :
   ObjetPostal x(30);
Ce mécanisme de conversion implicite reste néanmoins limité aux constructeurs ayant un seul argument, ou pour lesquels les autres arguments ont tous des valeurs par défaut. Nous conseillons de s'en tenir à la forme explicite ObjetPostal x(30);

La place mémoire occupée par une instance locale est automatiquement restituée quand la variable qui la désigne cesse d'exister, c'est-à-dire à la sortie du bloc de programme dans lequel la variable est définie. Cependant, il arrive qu'un constructeur effectue une allocation dynamique de mémoire, typiquement pour une des variables d'instance. Pour restituer la place ainsi allouée quand l'objet doit disparaître, il faut définir un destructeur, déclaré comme une fonction portant le nom de la classe précédé du caractère ~. Ce destructeur est appelé automatiquement quand l'objet cesse d'exister.

Supposons par exemple qu'un sac postal est caractérisé par une capacité maximale, un nombre d'objets contenus et un tableau d'objets postaux dont la taille est fixée dynamiquement. La place nécessaire pour ce tableau étant allouée par le constructeur, elle doit être restituée par un destructeur1 :

   // SacPostal.h
   class SacPostal {
   private:
       int nbelts;        // nombre d'objets dans le sac
       int capacite;      // capacité du sac
       ObjetPostal* sac;  // le tableau représentant le sac
   public:
       SacPostal(int);    // le constructeur
       ~SacPostal();      // le destructeur
       // et les autres méthodes...
   };
   // SacPostal.cpp
   SacPostal::SacPostal(int cap)
   {
       capacite = cap; 
       nbelts = 0; // sac vide
       sac = new ObjetPostal[cap]; // allocation du tableau
   }

   SacPostal::~SacPostal()
   {
       delete [] sac;      // restitution de la place
                           // occupée par le tableau sac
   }
   // etc.
La déclaration d'une variable courrierDeLyon de type SacPostal peut se faire comme suit :
   SacPostal courrierDeLyon(250);
Le constructeur SacPostal::SacPostal(int) est automatiquement appelé et un tableau de 250 objets postaux est alloué dynamiquement. Le compilateur engendre aussi un appel automatique au destructeur SacPostal::~SacPostal() quand la variable courrierDeLyon cesse d'exister, c'est-à-dire pour l'exemple donné à la sortie du bloc dans lequel elle est définie.

Les constructeurs et destructeurs peuvent aussi être appelés explicitement, lorsqu'on fait de l'allocation dynamique de mémoire, comme dans l'exemple suivant :
   SacPostal* ps = new SacPostal(55);  // constructeur appelé
   ...
   delete ps;                          // destructeur appelé

3.3   Les amis

Avec la notion d'amis, C++ donne d'affiner le contrôle des droits d'accès mieux que par les simples notions de variables publiques ou privées. Par exemple, si la classe SacPostal est déclarée amie de la classe ObjetPostal, toutes ses instances sont autorisées à accéder aux variables privées d'ObjetPostal :
   class SacPostal;

   class ObjetPostal {
   friend class SacPostal;
       ...
   };
Cette ``amitié'' peut être plus sélective et se limiter à une ou plusieurs fonctions précises. Supposons qu'en fait seule la méthode affranchir de la classe SacPostal ait besoin d'accéder aux champs privés de ObjetPostal. Seule cette méthode est alors déclarée amie, à la place de la classe :
   class SacPostal;

   class ObjetPostal {
   friend void SacPostal::affranchir();
       ...
   };
   ...
   // Et dans la définition de la classe SacPostal
   void SacPostal::affranchir() {
      ObjetPostal* x;
      ...
      if (x->poids < 20)  // L'accès à poids est autorisé car la
        x->tarif = 0.53;  // méthode est amie de la classe ObjetPostal
   }
Associées aux notions de données publiques, protégées et privées, les classes et les fonctions amies permettent de contrôler de manière fine les protections et les accès aux variables d'instance.

3.4   L'héritage

C++ permet de réaliser de l'héritage multiple entre classes ; nous nous limiterons cependant dans ce polycopié à l'exposé de l'héritage simple. Une sous-classe, appelée classe dérivée, hérite classiquement des attributs de sa superclasse :
   class Colis : public ObjetPostal {
   protected:
       int volume;
   public:
       Colis(int p, int v) : ObjetPostal(p), volume(v) {}
   };
   class Lettre : public ObjetPostal {
   protected:
       bool urgent;
   public:
       Lettre(int p) : ObjetPostal(p), urgent(false) {}
   };
   class CourrierInterne : public Lettre {
   public: 
       CourrierInterne(int p) : Lettre(p) {
              tarif = 0.0;  // pas d'affranchissement pour le courrier interne
       }
   };
Dans une classe, les attributs hérités deviennent privés par défaut, même s'ils étaient publics dans la superclasse. Toutes les autres classes, y compris ses sous-classes, ne peuvent y accéder directement. Cependant les attributs publics hérités restent publics si l'héritage est dit public grâce au mot clé public, comme dans les exemples précédents. En pratique, l'héritage est public dans la grande majorité des cas, et ce n'est que lorsqu'on souhaite hériter de l'implantation tout en masquant l'interface de la classe qu'on fait de l'héritage ``privé''. Pensez donc à mettre le mot clé public, dans la grande majorité des cas !

À noter que le constructeur d'une classe appelle le(s) constructeur(s) de sa (ses) superclasse(s). Si on ne mentionne rien, c'est le constructeur par défaut de superclasse qui est appelé (le constructeur sans paramètres). Mais dans la plupart des cas (je conseille de le faire systématiquement) on indiquera explicitement comment ``construire'' la ou les partie(s) héritée(s). Ainsi, la définition du constructeur de CourrierInterne indique quel constructeur de la superclasse Lettre appeler, et avec quels paramètres, grâce à l'expression : Lettre (p) qui suit la définition du constructeur CourrierInterne(int), et qui indique qu'il faut appeler le constructeur Lettre(int) avec la valeur de p, avant la mise à zéro du champ tarif. Le constructeur de Lettre va à son tour appeler le constructeur de ObjetPostal. Ce mécanisme est similaire à l'emploi de super en Java.

Il faut préférer l'initialisation des variables d'instance à leur affectation, surtout quand ce sont des objets. Notez bien que les initialisations doivent être faites dans l'ordre de la déclaration des variables d'instance correspondantes.

3.5   Liaison dynamique

En C++, la liaison dynamique n'est pas systématique, contrairement à Java. Pour assurer cette liaison dynamique quand elle est souhaitée, on utilise le mécanisme des fonctions virtuelles. Ainsi, pour que la méthode affranchir de la classe ObjetPostal puisse être redéfinie dans les sous-classes et invoquée uniformément et dynamiquement sur une collection d'objets postaux divers, instances de ces différentes sous-classes, elle doit être déclarée comme virtuelle (mot-clé virtual) dans la classe ObjetPostal :
   class ObjetPostal {
   friend void SacPostal::affranchir();
   protected:
       int poids;
       int valeur;
       bool recommande;
       double tarif;
   public:
       // Constructeur
       ObjetPostal(int p = 20);
       // Destructeur virtuel -- voir ci-après
       virtual ~ObjetPostal() {} // rien de spécial à faire ici
       // Méthodes inline
       bool aValeurDeclaree() const { return (valeur > 0); }
       int getPoids() const { return poids; }
       void recommander() { recommande = true; }
       // Méthode affranchir, implantation par défaut
       // Sera redéfinie dans les sous-classes
       virtual void affranchir() { tarif = 0.0; }
       ...
   };
Alternativement, on peut décider de ne pas donner d'implantation par défaut à la méthode affranchir, en écrivant :
   class ObjetPostal {
      ...
      virtual void affranchir() = 0;
Cela fait de la classe ObjetPostal une classe abstraite, qui ne peut être instanciée. Pour être instanciables, ses sous-classes doivent obligatoirement définir une méthode affranchir. Notons aussi qu'il est conseillé dans la plupart des cas de rendre abstraites toutes les classes qui ne sont pas aux feuilles de l'arbre d'héritage.

Il est utile de savoir qu'un destructeur peut aussi être déclaré virtuel. Supposons que les classes Colis, Lettre et CourrierInterne soient munies de constructeurs et de destructeurs spécifiques. Si une instance de SacPostal peut contenir des objets postaux de toutes sortes, le destructeur de la classe SacPostal doit appeler un destructeur spécifique pour chaque objet contenu dans le sac. Pour cela, il faut déclarer virtuel le destructeur de la classe ObjetPostal dans la définition de la classe, comme dans l'exemple ci-dessus. La fonction SacPostal::~SacPostal() s'écrit alors :
   SacPostal::~SacPostal()
   {
       delete [] sac;
   }
ce qui a pour effet d'appeler successivement le destructeur de chaque élément du sac. Le destructeur de la classe ObjetPostal étant virtuel, c'est bien le destructeur spécifique à chaque objet du sac qui est appelé par l'instruction delete. Ceci est d'autant plus important que l'utilisation de hiérarchies de classes liées par la relation d'héritage est habituellement liée à l'emploi de containers dans lesquels on souhaite pouvoir regrouper des instances de différentes sous-classes de la même classe, ce qui est le cas typique où le destructeur de la classe mère de ces sous-classes doit être déclaré comme virtuel pour mettre en place la liaison dynamique.

3.6   Le mot-clé this

On a parfois besoin de désigner dans une fonction membre l'objet qui est manipulé par la méthode. Comment le désigner alors qu'il n'existe aucune variable le représentant dans la fonction membre ? Les fonctions membres travaillent en effet directement sur les attributs définis par la classe. C++ résoud ce problème à l'aide du mot-clé this, qui permet à tout moment dans une fonction membre d'accéder à un pointeur2 sur l'objet manipulé. Voici un exemple d'application :

#include <iostream>
#include "Article.H"

// Fonction présente pour les besoins du test

void testAffichage(Article* unArt)
{
  cout << "Article : " << unArt->nom() << endl;
}

// Méthode de la classe Article

void Article::methodeQuelconque()
{
  // Comment appeler la fonction 'testAffichage' ?
  // Avec le mot-clé 'this' !

  testAffichage(this);
}

3.7   L'accès à la superméthode

L'accès à une méthode masquée peut se faire en C++ par un appel direct à cette méthode, grâce à l'opérateur de résolution de portée. Si par exemple l'affranchissement d'un courrier par avion est le même que celui d'une lettre, augmenté de quelques opérations spécifiques, on peut écrire :
   class Lettre : public ObjetPostal {
   protected:   
       bool urgent;
   public:
       // ...
       void affranchir() { 
           tarif = 0.53 + (urgent ? 0.2 : 0.0); }
   };
   class ParAvion : public Lettre {
   public:
       void affranchir();
   };

   void ParAvion::affranchir()
   {
       // affranchissement ordinaire
       Lettre::affranchir();
       // 1,20 EUR de supplement pour courrier aerien
       tarif += 1.2;
   }

3.8   Variables de classe

Les variables de classe sont déclarées avec le mot-clé static. Par exemple, la classe Lettre peut être munie de la variable de classe tarifLettre, indiquant le tarif d'affranchissement par défaut :
   class Lettre : public ObjetPostal {
   protected:
       bool urgent;
   public:
       static double tarifLettre;
   };
   ...
   // Dans la définition de la classe Lettre (Lettre.cpp par exemple)
   // Déclaration et initialisation de la variable de classe
   double Lettre::tarifLettre = 0.53;

3.9   La surcharge d'opérateurs

C++ autorise la surcharge des opérateurs. Par exemple, définissons un opérateur + permettant d'ajouter le contenu de deux sacs postaux dans un nouveau sac. Comme cet opérateur doit accéder aux champs privés de ses opérandes, il est déclaré ami de la classe SacPostal3. Notons aussi que nous devons faire évoluer notre classe SacPostal du fait de l'héritage. En effet, si le tableau sac restait un tableau d'objets de type ObjetPostal, nous ferions une conversion implicite vers le type ObjetPostal chaque fois que nous mettrions une lettre ou un colis dans le sac, et nous perdrions ainsi les caractéristiques propres à la lettre ou au colis... Nous choisissons donc de faire de sac un tableau dynamique de pointeurs sur des objets de type ObjetPostal4.

// SacPostal.h
   class SacPostal {
   private:
       int nbelts;
       int capacite;
       ObjetPostal** sac;  // tableau de pointeurs
   public:
       SacPostal(int);
       ~SacPostal();
       friend SacPostal& operator+(const SacPostal&, const SacPostal&);
   };
// Dans le fichier SacPostal.cpp

   SacPostal::SacPostal(int cap)
   {
       capacite = cap; 
       nbelts = 0; // sac vide
       sac = new ObjetPostal*[cap]; // allocation du tableau
   }

   SacPostal::~SacPostal()
   {
       delete [] sac;      // restitution de la place
                           // occupée par le tableau sac
                           // je choisis de ne pas détruire les objets postaux
                           // pointés eux-mêmes...
   }

   SacPostal& operator+(const SacPostal& sac1, const SacPostal& sac2) {
       // création d'un gros sac de capacité ad hoc
       SacPostal* grosSac = new SacPostal(sac1.capacite + sac2.capacite);
       // nombre d'éléments de ce gros sac
       grosSac->nbelts = sac1.nbelts + sac2.nbelts;
       // mettre les éléments de sac1 dans grosSac
       int i = 0;
       for ( ; i < sac1.nbelts ; i++)
           grosSac->sac[i] = sac1.sac[i];
       // puis mettre les éléments de sac2 dans grosSac
       for (int j = 0 ; j < sac2.nbelts ; i++, j++)
           grosSac->sac[i] = sac2.sac[j];
       return *grosSac;
   }
Le nouvel opérateur s'emploie ensuite de manière transparente sur les instances de la classe SacPostal :
   SacPostal sacSeichamps(200);
   SacPostal sacVandoeuvre(1500);
   ...
   SacPostal sacNancy = sacSeichamps + sacVandoeuvre;
   ...
À noter au passage le type retournée par l'opérateur : une référence à un objet de type SacPostal, ce qui explique l'instruction return *grosSac; à la fin de la fonction opérateur.

NB : il existe un opérateur qu'il est conseillé de définir de manière systématique, au même titre que le constructeur de copie (cf. § 4.3.1), à savoir l'opérateur de copie :

class SacPostal {
  // ...
  SacPostal& operator=(SacPostal const&);
  // ...
};
Celui-ci permet d'affecter un objet à un autre objet, en écrivant par exemple :
  sacVandoeuvre = sacSeichamps;
Il s'écrira de la manière suivante (noter l'expression return *this, pour permettre l'enchaînement des affectations a = b = c;) :
SacPostal& SacPostal::operator=(SacPostal const& unSac) {
  // libérer le tableau alloué jusqu'ici
  delete [] sac;
  // nouvelle capacité
  capacite = unSac.capacite;
  // créer un nouveau tableau
  sac = new ObjetPostal*[capacite];
  // le remplir
  nbelts = unSac.nbelts;
  for (int i = 0 ; i < nbelts ; i++) {
    sac[i] = unSac.sac[i];
  }
  return *this;
}

3.10   Les exceptions

Un mécanisme normalisé de gestion d'exceptions a été ajouté à C++ il y a quelques années. Il est analogue à bien des égards à celui de Java. Toute exception est instance d'une classe qui dérive de la classe de base exception. La hiérarchie des exceptions standard est la suivante :

exception @<<<
ì
ï
í
ï
î
bad_allocbad_castbad_typeidlogic_error @<<<
ì
ï
í
ï
î
domain_error
invalid_argument
length_error
out_of_range
ios_base::failureruntime_error @<<<
ì
í
î
range_error
overflow_error
underflow_error
bad_exception

Comme en Java, une exception est provoquée par l'instruction throw et traitée par la construction try / catch.


1
Plus généralement, quand on écrit quelque part new (resp. new T[]), on doit s'assurer qu'au cours de l'exécution on passera à un moment par delete (resp. delete []), sous peine d'avoir des ``fuites'' de mémoire.
2
Et non à une référence comme en Java !
3
On aurait aussi pu définir l'opérateur + dans la classe SacPostal.
4
Bien entendu, la lecture de la suite de ce polycopié vous montrera des solutions bien plus appropriées, en recourant à des containers comme par exemple un vecteur.

Précédent Index Suivant