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
-
· Les constructeurs sont des méthodes particulières appelées
automatiquement à chaque définition d'un objet de la classe, c'est-à-dire à
chaque fois que de la mémoire est allouée pour stocker un élément de la
classe. Si il existe plusieurs constructeurs, ils doivent avoir une liste de
paramètres différents 9.
- · Comment passer des arguments à un constructeur ? Il suffit
de faire suivre le nom de la variable (au sens large) par la liste des
arguments : ``MaClasse A(i,f);'' définit une variable A et
appelle le constructeur à deux arguments compatible avec les types de
i et f. Si un tel constructeur n'existe pas, il y aura
une erreur à la compilation. Lorsqu'aucun argument n'est donné, le
constructeur sans argument est appelé.
- · * Lorsqu'aucun constructeur n'est défini
explicitement, le compilateur crée un constructeur sans argument
MaClasse::MaClasse()
qui se contente d'appeler les constructeurs
(sans argument) de chaque champ de l'objet. Mais il suffit d'avoir défini
un seul constructeur pour que le constructeur sans argument ne soit pas
créé automatiquement, il faut alors le définir explicitement pour pouvoir
définir des variables sans donner de paramètres. Par exemple, dans notre
exemple de pile, il n'est pas possible d'écrire ``PileE P;
'',
car il n'existe pas de constructeur ``PileE::PileE()
''. Il serait
facile de résoudre ce point en adaptant ``PileE::PileE(int Max)
'' en
donnant une valeur par défaut à Max.
- · ** Il existe un constructeur particulier appelé
``constructeur copieur'' de la forme 10 MaClasse (const MaClasse &) qui
est utilisé pour le passage des paramètres et les initialisations :
-
Lorsqu'une fonction a un paramètre A du type MaClasse, un
appel de cette fonction avec une variable V de MaClasse
conduit à l'initialisation de A avec V via le
constructeur copieur.
-
Lorsqu'on définit une variable V et qu'on l'initialise avec une
valeur E, par l'instruction ``MaClasse V=E;'', le
processus sous-jacent est différent de celui mis en oeuvre lorsque l'on
définit V (``MaClasse V;'') et qu'ensuite on lui affecte
la valeur E (``V=E;'') : le premier cas consiste en une
initialisation et à l'appel du constructeur copieur, le second cas
consiste en une affectation et un appel de l'opérateur =
(voir section 15)... Ça peut paraître curieux mais c'est
comme ça ! Ce genre de subtilité peut être oublié en première lecture
car...
Le constructeur copieur créé par défaut se contente de faire une
initialisation champ à champ avec les valeurs des champs de la variable
donnée en argument. Ça tombe bien, c'est en général ce qu'il nous faut...
- · * De même qu'il existe des constructeurs mis en oeuvre pour
la création de nouveaux objets, il est possible de définir un (et un seul)
destructeur qui est appelé automatiquement lorsqu'une variable locale est
détruite (par exemple à la fin d'une fonction). Les
destructeurs sont surtout utiles lorsqu'on manipule des structures de
données dynamiques (section 19).
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.