Previous Contents Next

14   Classes C++

Les regroupements de données obtenus par struct permettent de définir de nouveaux types mais restent insatisfaisants. D'une part, ils n'intègrent pas directement les fonctions de manipulation du type de données que l'on souhaite implémenter. D'autre part, il est toujours possible d'accéder directement aux différents champs des objets des struct, ce qui rend les programmes peu sûrs et peu adaptables : le choix du codage (le nom et le type des champs) ne devrait intervenir que dans la définition des fonctions de manipulation (pour le type fraction : addition, produit etc.) et les autres fonctions du programme devraient manipuler les objets de notre type uniquement au moyen de ces fonctions de manipulation. Ainsi, un changement de codage n'entrainerait que des changements locaux dans le code source. Les classes C++ permettent cela (ouf !).

Une classe C++ est un regroupement de données (champs) et de fonctions (appelées fonctions membres ou méthodes) sur ces données. Il est aussi possible de décider pour chacun de ces éléments (données ou fonctions) si il est accessible, ou non, aux autres fonctions extérieures à la classe.

Prenons l'exemple d'une pile d'entiers. Un objet de ce type doit être muni des opérations suivantes : ``empiler un entier'', ``récupérer la valeur du dernier entier empilé'' (la tête de la pile), ``dépiler'' et ``tester si la pile est vide''. Nous considérons que nos piles sont de taille bornée (cette borne étant fixée à la création de la pile). Nous pouvons utiliser un tableau d'entiers pour stocker les éléments. La classe C++ correspondante pourrait être 5:
//=========================
class  PileE {
 private :
   vector<int> Tab;
   int NbElem;       //  nb d'éléments dans la pile 
 public :
   PileE(int Max);
   void Empiler (int val);
   int Tete();
   void Depiler();
   int EstVide();
};
//=========================
Un objet P de type PileE serait donc composé de deux champs de données : un tableau P.Tab pour stocker le contenu de la pile et un entier P.NbElem correspondant au nombre d'éléments présents dans P à un moment donné. De plus, l'objet P est muni de 5 fonctions membres (ou méthodes) qui sont déclarées dans la définition de la classe. Laissons de coté, pour le moment, la première fonction (appelée constructeur) PileE(int Max)). Le mécanisme d'appel de ces fonctions se fait de la manière suivante ``P.Empiler(expr)'' (où expr renvoie un entier), ``P.Tete()'', ``P.Depiler()'' ou encore ``P.EstVide()''. Les fonctions membres sont donc appelées sur un objet de la classe (on parle aussi d'appliquer une méthode à un objet), c'est donc un argument supplémentaire qui n'apparaît pas comme les autres dans le profil de la fonction 6. Le nom complet de ces fonctions est :

void PileE::Empiler(int val);
int  PileE::Tete();
void PileE::Depiler();
int  PileE::EstVide();
Leur définition utilise les champs et les méthodes de l'objet sur lequel les fonctions seront appelées. Ainsi on peut définir 7:

//=========================
void PileE::Empiler(int val) {
if (NbElem >= Tab.size()) 
    { cout << "Erreur !" << endl; exit(1);}
Tab[NbElem]=val;
NbElem++;}

void PileE::Depiler() {
if (NbElem==0) 
    {cout << "Erreur !" << endl; exit(1);}
NbElem--;}

int PileE::Tete() {
if (NbElem==0) 
    {cout << "Erreur !" << endl; exit(1);}
return Tab[NbElem-1];}
  
int PileE::EstVide()
{return (NbElem == 0);}
//=========================
L'idée de ce codage est de stocker le contenu de la pile dans Tab[0], ..., Tab[NbElem-1], le dernier élément empilé se trouvant en Tab[NbElem-1]. La fonction PileE::Empiler commence par tester si il reste de la place dans la pile, puis affecte le nouvel élément dans la première case libre du tableau et incrémente NbElem (les deux champs Tab et NbElem dans ces définitions correspondent aux champs de l'objet sur lequel ces fonctions seront utilisées dans le programme). Avec ces définitions, nous pouvons maintenant utiliser des piles d'entiers:

//=========================
int main() {
int i,v;
PileE P(100);  // appel du constructeur avec 100 comme argument

for (i=0;i<20;i++) {
  cin >> v;
  P.Empiler(v);
  }
while (! P.EstVide()) {
  cout << P.Tete();
  P.Depiler();
  }
}
//=========================
Ce petit programme se contente de lire 20 nombres et les empile dans P, puis les dépile en les affichant au fur et à mesure... Il aurait été impossible d'utiliser directement les champs Tab et NbElem de P dans la procédure main ci-dessus car ces deux champs sont déclarés private et ne sont donc accessibles qu'aux fonctions membres de la classe PileE.

Il reste un problème concernant l'initialisation de la variable P. En effet, pour que le programme ci-dessus soit correct il faut que le tableau P.Tab soit construit (avec une certaine taille) et que le champ P.NbElem soit initialisé avec la valeur 0 lors de la création de l'objet P, ce type d'initialisation est le rôle du ou des constructeurs de la classe. Dans notre exemple, il existe un seul constructeur : PileE(int Max) (dont le nom complet est PileE::PileE(int Max)). Le nom d'un constructeur est celui de la classe et il n'a pas de type car un constructeur retourne toujours un objet de sa classe. Un constructeur est automatiquement appelé lorsqu'un objet de la classe est créé. l'instruction ``PileE P(100);'' entraîne son exécution avec 100 comme argument. L'objectif des constructeurs est d'initialiser les champs correctement. Ici nous devons créer une pile de taille Max et mettre NbElem à 0 :

//=========================
PileE::PileE(int Max)
{   Tab = vector<int>(Max);
    NbElem = 0; }
//=========================
La notation utilisée pour la création du tableau vient du fait que nous devons créer un objet de la classe vector et pour cela on appelle un de ces constructeurs (vector<int>( , )) de manière explicite.

14.1   Champs et méthodes

Le schéma général de la définition d'une classe est celui-ci :

//=========================
class  MaClasse {
 private :
    // definition de champs et methodes prives:

    type Nom-de-champ;
    type Nom-de-fct (liste parametres);
    type Nom-de-fct (liste parametres) { corps }
 
 public :
    // definition des constructeurs:
    MaClasse(liste parametres1);
    MaClasse(liste parametres2) {corps}

    // definition de champs et methodes publics:
    type Nom-de-champ;
    type Nom-de-fct (liste parametres);
    type Nom-de-fct (liste parametres) { corps }
};
//=========================
Ce schéma appelle plusieurs remarques :

14.2   Constructeurs et destructeurs

Le plus simple pour comprendre les différents mécanismes d'appel, c'est de définir une classe jouet et de (re)définir différents constructeurs en y plaçant des ``cout <<'' pour signaler leur mis en oeuvre, et de tester le tout avec un petit programme. Un exemple se trouve ici.

14.3   Fonctions amies **

Déclarer une fonction F comme étant ``amie'' (friend) d'une classe C1, c'est autoriser, dans la définition de F, l'accès aux champs privés de C1. On peut aussi déclarer une classe C2 comme étant ``amie'' de la classe C1; dans ce cas, toutes les fonctions membres de C2 peuvent accéder aux champs privés de C1. Ces déclarations se font dans la définition de F :
class C {
   ...
  friend type-de-F F(param-de-F);
  friend class C2;
   ...
};

14.4   Conversions entre types **

La section 6.2 présente des conversions implicites ou explicites entre différents types élémentaires. Il peut être utile de faire de même pour les types que l'on définit.

Certains constructeurs définissent des fonctions de conversion : un constructeur de T1 qui prend un unique paramètre de type T2 définit implicitement une conversion de T2 dans T1. Dans ce cas, une fonction prenant un paramètre de type T1 pourra être appelée avec un argument de type T2 sans provoquer d'erreur de compilation : l'argument sera converti en T1 puis la fonction sera exécutée. Néanmoins ce mécanisme (de conversion implicite) ne se compose pas : le compilateur ne cherche que les conversions en une étape (sauf pour les types élémentaires).

Par exemple, une classe fraction qui aurait un constructeur prenant un entier en paramètre (par ex. dans le but de renvoyer une fraction avec le dénominateur 1) définit une conversion des int vers les fraction et une fonction prenant deux paramètres de type fraction en argument, peut être appelée avec une fraction et un entier, ce dernier sera correctement converti. Un exemple de programme avec conversions est donné ici .

Le mécanisme de conversion mentionné ci-dessus nécessite d'accéder à la définition du type cible T1. Ce n'est pas toujours possible, par exemple lorsqu'on utilise un type élémentaire. Dans ce cas, on définit une fonction membre operator T1() dans la classe T2. Comme un constructeur, la déclaration de cette fonction ne fait pas apparaître de type, là encore le type est implicite, il s'agit de T1. Par exemple :

class fraction {
private :
   int num, den;
public :
   ...
   fraction(int v) 
     {num=v;den=1;}
   double()
     {return double(num)/den;} 
};
Ici le type fraction est muni de deux fonctions de conversions implicites ; une de int vers fraction, la seconde de fraction vers double. Évidemment, plus on définit de conversions, plus le risque d'avoir des expressions ambigues (où plusieurs choix sont possibles) augmente... Il est possible de signifier au compilateur qu'un constructeur à un argument ne doit pas être utilisé dans les conversions, pour cela il faut ajouter le mot clé explicit devant la déclaration du constructeur.


Previous Contents Next