Opérateur d'affectation =

    L'opérateur standard d'affectation = renvoie non pas une valeur mais une référence, ce qui permet les affectations multiples quel que soit le parenthésage. De plus l'associativité est G->D. Ainsi, les écritures suivantes sont valides :
 
A = B = C = 0;       // initialise successivement C à 0, puis B à C, puis A à B
A = (B = (C = 0));   // idem
B = 5;
(((A = B) = C) = 0); // initialise successivement A à B, puis A à C, puis A à 0

    Il faut donc noter que, à la première ligne, l'affectation C = 0 ne renvoie pas la valeur 0 mais la référence à C.

    A la troisième ligne, dont le seul intérêt ici est de montrer "comment ça marche", l'affectation A = B, la première effectuée à cause du parenthésage, renvoie la référence de A. Après exécution de cette affectation, la troisième ligne devient équivalente à ((A = C) = 0). Comme on le voit, si l'affectation A = B renvoyait une valeur, celle-ci ne pourrait servir de variable réceptrice à l'affectation suivante : ((5 = C) = 0) n'aurait aucun sens. On dit qu'une constante ne peut servir de left-value (membre gauche, sous-entendu ... d'une affectation).

    Rappelons qu'en C/C++, les fonctions peuvent avoir (et ont souvent) des effets de bord, c'est-à-dire effectuent des opérations qui n'apparaissent pas obligatoirement dans leur profil (comme par exemple ouvrir un fichier, effectuer une lecture au clavier). Ainsi, malgré les apparences, les instructions suivantes ne sont pas équivalentes :
 
(A = f (x)) = B; 
A = B;

    En effet, f(x) peut modifier B ... (programmation pas très sécurisée !)

Opérateur d'affectation par défaut dans une classe

Rappelons tout d'abord que l'affectation fait partie des quatre méthodes fournies par défaut par le compilateur pour toute classe (avec le constructeur par copie, le constructeur par défaut et le destructeur). Cette affectation se contente d'affecter les données membres de l'opérande de droite dans celles de l'opérande de gauche. Par exemple, pour la classe CPoint suivante :
 
class CPoint
{
    int m_X;
    int m_Y;

  public :
    // ... divers constructeurs

}; // CPoint

l'affectation dans la séquence suivante :
 
CPoint P1 (10, 20);
CPoint P2;
// ...
P2 = P1;

serait équivalente à
 
P2.m_X = P1.m_X;  // affectations interdites car 
P2.m_Y = P1.m_Y;  // données membre privées

    Il serait possible (mais inutile) de surcharger cette méthode de la façon suivante, en respectant exactement la sémantique de l'opérateur :
 
class CPoint
{
  public :
    // ...

    CPoint & operator = (const CPoint & Point) throw ();

}; // CPoint

CPoint & operator = (const CPoint & Point) throw ()
{
    // pour éviter l'autoaffectation : P = P

    if (this == & Point) return *this;

    m_X = Point.m_X;
    m_Y = Point.m_Y;

    return *this;

} // operator = ()

    L'auto-affectation n'est pas un problème dans ce cas : les données membres sont recopiées sur elles-mêmes. Le test permet seulement de supprimer des opérations inutiles. En fait, la probabilité d'une auto-affectation étant très faible, le test ne fait qu'alourdir le temps de traitement moyen. Comme nous le verrons ci-dessous, les conséquences peuvent être beaucoup plus fâcheuses dans certains cas.

Nécessité de la surcharge de l'affectation

    Lorsque certaines composantes d'un objet ne sont pas directement incluses dedans (en général parce qu'elles sont de taille variable), la donnée membre n'est qu'un pointeur vers une zone mémoire dynamique. Un cas classique est l'implémentation d'une classe CString [1] suivante :
 
 
class CString
{
    char *   m_String;
    unsigned m_Lg;

  public :
    CString (char * const String);
    ~CString (void) { delete [] m_String; }
    // ...

}; // CString 

#include <cstring>   // contient strlen() et strcpy()

CString::CString (char * const String)
{
    m_String = new char [m_Lg = String.strlen () + 1]; [2]
    strcpy (m_String, String);

} // CString()

    Considérons la séquence suivante :
 
CString S1 ("Coucou");
{
    CString S2 ("Nouveau");
    S1 = S2;                // {1}
    // ...
}                           // {2}
// ...                      // {3}
 

    L'affectation standard (instruction {1}) a pour seul effet de dupliquer les données membres, m_String et m_Lg, mais pas le tableau de caractères associé, comme le montre la figure suivante :

    Lors de la sortie du bloc (ligne {2}), le destructeur de l'objet S2 est automatiquement appelé, et l'espace de mémoire dynamique pointé par la donnée membre m_String de S2 est libéré, mais elle continue à être pointée par m_String de l'objet S1 (figure suivante) :

    A partir de la ligne {3}, l'objet S1 est invalide car il pointe sur un espace mémoire désalloué.

    L'opérateur d'affectation standard doit donc être surchargé, pour dupliquer aussi les zones pointées :
 
class CString
{
    // ...

  public :
    // ...
    CString & operator = (const CString & String);

}; // CString 

#include <cstring>   // contient strlen() et strcpy()

CString & CString::operator = (const CString & String)
{
    if (this == &String) return *this;
 
    delete [] m_String;

    strcpy ((m_String = new char [(m_Lg = String.m_Lg) + 1]), 
             String.m_String);

    return *this;

} // CString()

    Cette version, très compacte et "élégante", est cependant très mauvaise car elle ne respecte pas un principe intangible selon lequel un objet doit être rendu inchangé si une opération qui le modifie n'est pas complète. Supposons que l'allocation mémoire échoue par manque d'espace. L'opérateur new lève l'exception standard bad_alloc. L'ancien tableau de caractères a été effacé, et la longueur a été modifiée. Une meilleure version serait la suivante :
 
CString & CString::operator = (const CString & String)
{
    if (this == &String) return *this;
 
    char * NewString = new char [String.m_Lg + 1];

   delete [] m_String;

   strcpy (m_String = NewString, String.m_String);
   m_Lg  = String.m_Lg;

    return *this;

} // CString()

Autres opérateurs d'affectation

    De nombreux autres opérateurs d'affectation sont disponibles en C/C++, qui combinent l'affectation simple et un autre opérateur, comme le montre le tableau général. Ils ont tous la même sémantique. Par exemple :
 
int A = 12;
// ...
A += 4;

est équivalent à A = A + 4. De façon générale, l'instruction suivante :
 
A $= expr;

$ désigne l'un  quelconque des opérateurs autorisés, est équivalente à :
 
A = A $ (expr);

et non à :
 
A = A $ expr;

Par exemple
 
A /= 3 / 3;

est équivalente à :
 
A = A / (3 / 3);

et non à :
 
A = A / 3 / 3;

    Tous ces opérateurs peuvent aussi être surchargés.

Remarques :

  1. On peut effectuer des affectations multiples avec les opérateurs de base +=, -=, etc, comme le montre l'exemple suivant :
     
    int i = 3;
    int j = 4;
    int k = 5;

    i += j += k += 1;

    cout << i << ' ' << j << ' ' << k << endl;

    Les affectations sont bien effectuées dans l'ordre suivant : (i += (j += (k += 1))); et l'affichage obtenu est le suivant : 13 10 6. Il est cependant souvent plus difficile de réaliser les mêmes opérations avec des opérateurs surchargés car leur sémantique est parfois altérée. Considérons par exemple la classe CDuree contenant une donnée membre m_Duree en secondes, et sa décomposition en jours, heures, etc... (non figurées ici) :
     
    class CDuree
    {
        unsigned m_Duree;
        // ...

      public :
        CDuree (unsigned Duree) : m_Duree (Duree) {}

        CDuree & operator += (int Incr)
        {
            m_Duree += Incr;
            return *this;

        } // operator +=

        // ...

    }; // CDuree

        L'opérateur += n'a pas exactement la même sémantique que l'opérateur original car les opérandes de gauche et de droite ne sont pas de même type : l'opérande de gauche est de type CDuree, celui de droite est un int. L'expression ci-dessous, dans laquelle D1, D2 et D3 sont des CDurees supposées initialisées, est syntaxiquement fausse :
     
    D1 += D2 += D3 += 1;

    car interprétée comme D1 += (D2 += (D3 += 1))); qui nécessite un opérateur += entre deux CDurees.

        La solution consiste à ajouter une nouvelle surcharge à cet opérateur :
     
    CDuree & operator += (const CDuree & IncrDuree)
    {
        m_Duree += IncrDuree.m_Duree;
        return *this;

    } // operator +=

        Ce nouvel opérateur rend caduque le précédent. En effet, si on supprime le premier opérateur +=, le compilateur utilise le constructeur de CDuree pour transformer l'opérande 1, et l'affectation multiple est alors transformée en :
     
    D1 += (D2 += (D3 += CDuree (1))));

        Reste que cette solution, si elle est apparemment élégante car elle allège le code source (une seule surcharge au lieu de 2), alourdit probablement le traitement car elle oblige à la construction d'un objet intermédiaire dont le seul rôle est de permettre l'addition. Il faut en effet penser que cet objet internédiaire contient aussi d'autres données membres, qu'il faut initialiser pour rien, et que la construction n'est pas triviale (calcul des jours, heures, minutes, secondes, normalisation, etc...).

        On rappelle aussi que, si le constructeur est précédé du qualificatif explicit, les conversions implicites sont interdites et les deux surcharges de l'opérateur += sont nécessaires.

  2. La surcharge d'un opérateur += peut ne pas être triviale et, dans certaines circonstantes, receler quelques pièges vicieux. Considérons par exemple la classe CArbre offrant la surcharge de += suivante :
     
    CArbre & operator += (const CArbre & Tree);

    qui consiste à ajouter à l'arbre courant les noeuds apportés par l'arbre Tree passé en paramètre. Comment traiter le cas suivant :
     
    Arbre += Arbre;

    L'opérateur += standard, appliqué à un entier, a bien pour effet de le multiplier par 2. Appliqué à un arbre tel qu'il a été défini ci-dessus (passé par référence), les ajouts se font directement dans l'arbre passé en paramètre qui est pourtant garanti const !! Selon l'algorithme utilisé, on peut même craindre que, l'arbre source étant modifié au fur et à mesure des transferts, un élément nouvellement ajouté soit retrouvé dans l'arbre source et introduit une nouvelle fois, ceci à l'infini.

    Une solution consisterait à passer le paramètre par valeur, c'est-à-dire en en faisant une copie. Cette solution serait extrêmement coûteuse dans le cas général. Elle est donc injustifiée.

    Une autre solution serait d'interdire l'auto-affectation, en comparant les adresses mémoire des deux objets, l'arbre source passé en paramètre et l'arbre courant. Cela reviendrait à modifier la sémantique de l'opérateur += de base qui permet cette opération.

    La solution proposée est de ne faire une copie de l'arbre passé en paramètre que lorsqu'il s'agit d'une auto-affectation, et de travailler sur l'arbre original dans tous les autres cas.


[1] Dans la pratique, cette classe est totalement inutile : il faut utiliser la classe standard string, construite d'ailleurs sur le même principe. Pour simplifier, la gestion des exceptions n'est pas envisagée.

[2] Ne pas oublier d'ajouter 1 pour le drapeau de fin de chaîne, le caractère '\0'.