Plan du chapitre :
Liste des programmes :
Liste des tableaux :
Les références sur les objets sont à considérer dans plusieurs cas :
Considérons la fonction suivante :
void affichageObjetGraphique(ObjetGraphique o) { o.afficher(); }
Programme 3.1 Fonction avec un objet en paramètre par valeur
Examinons ce qui se passe lors de l'appel de cette fonction dans le cas suivant :
int main(int, char **) { ObjetGraphique unObjet(...); affichageObjetGraphique(unObjet); return 0; }
Programme 3.2 Appel d'une fonction prenant comme paramètre par valeur un objet
Rappelons que dans le cas du passage de paramètre par valeur, il y a recopie du paramètre sur la pile. Dans le cas d'un objet, cette recopie nécessite un appel à un constructeur particulier, le constructeur de recopie que nous allons étudier prochainement. Pour l'instant, retenez que cette opération peut s'avérer particulièrement coûteuse.
En outre, l'objet temporaire créé sur la pile devra être détruit à la fin, ce qui implique un appel à un destructeur.
Avec un appel par référence, il n'y a pas de recopie car l'on passe l'adresse. C'est toujours ca de gagné ! Au point de vue du code généré, passer un objet par référence ou un pointeur sur cet objet par valeur revient exactement au même. Les références sont une facilité apportée au programmeur afin de limiter les erreurs. En outre, une différence majeure permet de différencier un pointeur d'une référence : une référence est toujours associée à une variable, elle ne peut pas être non initialisée à la manière d'un pointeur qui ne pointe sur rien.
Avec un passage par référence, la fonction précédente et son appel s'écrivent :
void affichageObjetGraphique(ObjetGraphique &o) { o.afficher(); } ... int main(int, char **) { ObjetGraphique unObjet(...); affichageObjetGraphique(unObjet); return 0; }
Programme 3.3 Appel par référence
Trois points méritent que l'on s'y attarde :
Il faut voir que la manipulation des références est beaucoup sure que celle des pointeurs.
Le fragment de code suivant illustre certaines caractéristiques des références :
int main(int, char **) { int i=5; int j=6; int &r=i; // r référence sur i int *p; // p pointeur non initialise : correct int *q=&i; // q pointeur sur i int &z; // Erreur ! z est une référence non initialisée r=10; // Affecte 10 a la variable referencee par r soit i r=j; // Affecte la valeur de j a la variable referencee par r soit i // une erreur frequente est de croire qu'a la suite de cette instruction // r reference j. r reste liée à i r++; // effectue i++ return 0; }
Programme 3.4 Exemple d'utilisation de références
Vous allez me dire pourquoi ne pas toujours utiliser le passage par référence afin d'éviter des invocations inutiles de constructeurs de recopie et de destructeurs ? La réponse est simple : on va toujours passer les objets par référence mais on va utiliser un garde fou dans le cas où un objet n'est pas destiné à être modifié. Cette précaution s'appelle une référence constante. La syntaxe est la suivante :
Il est très important de noter qu'un objet passé par référence constante dans une fonction ou une méthode y est considéré comme constant. Vous ne pourrez donc invoquer dessus que des méthodes constantes. C'est pourquoi il est si important de déclarer toutes les méthodes qui le supportent.
Recopier un objet dans un autre est opération assez fréquente. Deux fonctionnalités y sont dédiées en C++ : le constructeur par recopie et l'opérateur d'affectation.
Le constructeur par recopie est très important car il permet d'initialiser un objet par clonage d'un autre. Attention, j'ai bien dit initialiser, ce qui signifie que l'objet est en cours de construction. En particulier, le constructeur par recopie est invoqué dès lors que l'on passe un objet par valeur à une fonction ou une méthode.
La syntaxe du constructeur par recopie d'une classe T est la suivante :
Il est extrèmement important de passer l'objet recopié par référence sous peine d'entrainer un appel récursif infini !
Si vous ne fournissez pas explicitement de constructeur par recopie, le compilateur en génère automatiquement un pour vous. Celui-ci effectue une recopie binaire optimisée de votre objet ... ce qui est parfait si celui-ci ne contient que des éléments simples.
En revanche, si votre objet contient des pointeurs, ce sont les valeurs des pointeurs qui vont être copiées et non pas les variables pointées, ce qui dans de nombreux cas, conduira directement à la catastrophe.
Vous devez fournir un constructeur de recopie dès lors que le clonage d'un objet par recopie binaire brute peut entraîner un disfonctionnement de votre classe, c'est à dire, en particulier :
L'opérateur d'affectation et le constructeur de recopie sont très proches dans le sens où ils sont requis dans les mêmes circonstances et qu'ils effectuent la même opération : cloner un objet dans un autre. Il y a tout de même une différence fondamentale :
L'opérateur d'affectation écrase le contenu d'un objet déjà existant et donc totalement construit
Ce qui signifie que dans la majorité des cas, il faudra commencer par "nettoyer" l'objet à la manière d'un constructeur avant d'effectuer l'opération de clonage dessus.
Le prototype de l'opérateur d'affectation d'une classe Test le suivant :
Il est parfois délicat de savoir si l'on a affaire à une affectation ou une initialisation car la syntaxe du signe "=" peut être trompeuse. Il existe pourtant une règle simple :
Toute opération d'initialisation ou d'affectation dans une déclaration est l'affaire d'un constructeur
Le tableau suivant résume quelques cas qui doivent être lus séquentiellement et où T et U sont des classes quelconques :
Instruction | Description | Méthode mise en jeu |
---|---|---|
T t1; |
Initialisation par le constructeur par défaut |
T::T(void); |
T t2(params); |
Initialisation par un constructeur quelconque |
T::T(liste params); |
T t3(t1); |
Initialisation par le constructeur de recopie |
T::T(const T&); |
T t4(); |
Piège à c... c'est le prototype de la fonction t4 qui ne prend pas de paramètre mais renvoie un objet de type T. | |
T t5=t1 |
Initialisation par le constructeur de recopie Cette ligne est à remplacer par |
T::T(const T&); |
t5=t2 |
Affectation à l'aide de l'opérateur d'affectation |
T & T::operator=(const T&); |
Tableau 3.1 : Affectation ou Initialisation ?
On dit qu'une classe T est sous forme canonique de Coplien si elle fournit les éléments suivants :
Prototype | Fonctionnalité |
---|---|
T::T() | Constructeur par défaut |
T::T(const T&) | Constructeur par recopie |
T& T::operator=(const T&) | Opérateur d'affectation |
T::~T() | Destructeur |
Tableau 3.2 : Forme canonique de Coplien
Si ces éléments sont codés correctement, alors l'utilisation de cette classe vis à vis de la mémoire est sécurisé. Dès qu'une classe utilise de la mémoire dynamique ou des ressources critiques, il est indispensable de la mettre sous forme canonique de Coplien.
Nous allons ici créer une classe permettant des manipulations simples sur les chaînes de caractères et illustrant le bien fondé de la forme canonique de Coplien. Notez bien que cette classe est présentée uniquement à but pédagogique car la librairie standard du C++ propose la classe string qui lui est bien supérieure.
Une bonne classe chaine de caractères doit proposer un stockage dynamique, c'est à dire pouvant s'accroître avec le temps. Nous allons donc avoir besoin d'un pointeur et d'allocation dynamique.
#ifndef __CHAINE_HXX__ #define __CHAINE_HXX__ #include <string.h> class Chaine { public: // Constructeur par defaut Chaine(int taille=16): longueur_(0), capacite_(taille) { if (taille) tableau=new char [taille]; else tableau=0; } // Constructeur prenant un pointeur sur char Chaine(const char *pStr) { if (pStr) { int taille=strlen(pStr); longueur_=capacite_=taille; if (taille) { tableau = new char [taille+1]; strcpy(tableau,pStr); } else tableau=0; } else { longueur_=0; capacite_=0; tableau=0; } } // Constructeur de recopie Chaine(const Chaine &uneChaine) : longueur_(uneChaine.longueur_), capacite_(uneChaine.capacite_) { tableau = new char [capacite_+1]; if (longueur_) strcpy(tableau,uneChaine.tableau); } ~Chaine(void) { delete [] tableau; } // operateur d'affectation Chaine &operator=(const Chaine &uneChaine) { if (this != &uneChaine) { delete [] tableau; longueur_=uneChaine.longueur_; capacite_=uneChaine.capacite_; tableau = new char [capacite_+1]; if (longueur_) strcpy(tableau,uneChaine.tableau); } return *this; } const char operator[](int index) const { return tableau[index]; } char &operator[](int index) { return tableau[index]; } private: int longueur_; int capacite_; char *tableau; }; #endif
Programme 3.5 Exemple de forme canonique de Coplien : la classe Chaine
Ainsi, les objets de la classe Chaine sont à l'abri des problèmes de mémoire. Notez au passage que l'opérateur d'affectation vérifie que l'on essaye pas d'affecter un objet à lui même. C'est le but de l'instruction if (this != &uneChaine) qui compare les adresses de l'objet courant et de celui dont on veut lui affecter la valeur.
Vous noterez également que le code de l'opérateur d'affectation et celui du constructeur de recopie sont étonament proches. En fait, l'affectation peut se ramener à une opération de nettoyage (comme dans le destructeur) suivie d'une recopie. Aussi, nous allons pouvoir factoriser le code dupliqué. Le programme suivant réécrit le constructeur de recopie, l'opérateur d'affectation et le destructeur à l'aide de deux méthodes privées que nous appellerons clonage et nettoyage
#ifndef __CHAINE_HXX__ #define __CHAINE_HXX__ #include <string.h> class Chaine { public: // Constructeur par defaut Chaine(int taille=16): longueur_(0), capacite_(taille) { if (taille) tableau=new char [taille]; else tableau=0; } // Constructeur prenant un pointeur sur char Chaine(const char *pStr) { if (pStr) { int taille=strlen(pStr); longueur_=capacite_=taille; if (taille) { tableau = new char [taille+1]; strcpy(tableau,pStr); } else tableau=0; } else { longueur_=0; capacite_=0; tableau=0; } } // Constructeur de recopie Chaine(const Chaine &uneChaine) { clonage(uneChaine); } ~Chaine() { nettoyage(); } // operateur d'affectation Chaine &operator=(const Chaine &uneChaine) { if (this != &uneChaine) { nettoyage(); clonage(uneChaine); } return *this; } const char operator[](int index) const { return tableau[index]; } char &operator[](int index) { return tableau[index]; } private: int longueur_; int capacite_; char *tableau; void clonage(const Chaine &uneChaine) { longueur_=uneChaine.longueur_; capacite_=uneChaine.capacite_; tableau = new char [capacite_+1]; if (longueur_) strcpy(tableau,uneChaine.tableau); } void nettoyage(void) { delete [] tableau; } }; #endif
Programme 3.6 Deuxième version de la classe Chaine avec factorisation du code