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 !)
class CPoint
{ int m_X; int m_Y; public :
}; // 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 ()
if (this == & Point) return *this; m_X = Point.m_X;
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.
class CString
{ char * m_String; unsigned m_Lg; public :
}; // CString #include <cstring> // contient strlen() et strcpy() CString::CString (char * const 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 #include <cstring> // contient strlen() et strcpy() CString & CString::operator = (const CString & String)
strcpy ((m_String = new char [(m_Lg = String.m_Lg)
+ 1]),
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);
return *this; } // CString() |
int A = 12;
// ... A += 4; |
est équivalent à A = A + 4. De façon générale,
l'instruction suivante :
A $= expr; |
où $ 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 :
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 & operator += (int Incr)
} // 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.
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'.