III Références, constructeur de recopie, affectation

Plan du chapitre :

  1. Utilisation de références sur les objets
    1. Passage d'objets par référence
    2. Manipuler les références
  2. Clonage d'objets : recopie et affectation
    1. Constructeur de recopie
      1. Motivation
      2. Syntaxe
      3. Que se passe t'il si vous ne fournissez pas de constructeur de recopie ?
      4. Quand doit on fournir un constructeur de recopie ?
    2. Opérateur d'affectation
      1. Motivation
      2. Syntaxe
    3. Un exemple classique
    4. Initialisation ou Affectation ?
  3. Forme canonique de Coplien
  4. Un exemple : la classe Chaine

Liste des programmes :

  1. Fonction avec un objet en paramètre par valeur
  2. Appel d'une fonction prenant comme paramètre par valeur un objet
  3. Appel par référence
  4. Exemple d'utilisation de références
  5. Un exemple de forme canonique de Copien ; la chasse Chaine
  6. Deuxième version de la classe Chaine avec factorisation du code

Liste des tableaux :

  1. Initialisation ou Affectation ?
  2. Forme canonique de Coplien

3.1 Utilisation de références sur les objets

Les références sur les objets sont à considérer dans plusieurs cas :

3.1.1 Passage d'objets par référence

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 :

  1. Tout d'abord, du point de vue de l'appelant, rien ne permet de distinguer que l'on a passé l'objet par référence plutôt que par valeur
  2. La syntaxe des références est des plus biscornues. En effet, typiquement l'opérateur "&" servait à prendre une adresse. Ici, il désigne une référence sur un objet.
  3. A l'intérieur de la fonction, on manipule la référence comme l'on manipulerait un objet

3.1.2 Manipuler les références

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 :

const Type &referenceConstante

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.

3.2 Le clonage d'objets : recopie et affectation

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.

3.2.1 Le constructeur par recopie

3.2.1.1 Motivation

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.

3.2.1.2 Syntaxe

La syntaxe du constructeur par recopie d'une classe T est la suivante :

T::T(const T& o);

Il est extrèmement important de passer l'objet recopié par référence sous peine d'entrainer un appel récursif infini !

3.2.1.3 Que se passe t'il si vous ne fournissez pas de constructeur de recopie ?

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.

3.2.1.4 Quand doit on fournir un constructeur de recopie ?

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 :

3.2.2 L'opérateur d'affectation

3.2.2.1 Mise en place

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.

3.2.2.2 Syntaxe

Le prototype de l'opérateur d'affectation d'une classe Test le suivant :

T & operator=(const T& o);

3.2.3 Un exemple classique

3.2.4 Initialisation ou Affectation

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 t5(t1); qui fait exactement la même chose mais est moins ambigue du point de vue de la syntaxe.
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 ?

3.3 Forme canonique de Coplien

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.

3.4 Un exemple : la classe Chaine

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