Le type de retour de l'opérateur d'affectation

Souvent, lorsque l'on implémente cet opérateur, on oublie de retourner une référence sur l'objet lui-même : *this. En faisant cela, on se prive des avantages de l'affectation chainée. Vous avez oublié ce qu'était l'affectation chainée ? Voici un exemple :

Object a, b, c;
// affectations chainées. à lire de droite à gauche : a = (b = (c = Object(12)));
a = b = c = Object(12); 
// ceci revient à écrire.
c = Object(12);
b = c;
a = b;

Il est rare d'utiliser cette notation, mais lorsqu'on crée une classe, il faut penser à ses utilisateurs. Une fonction d'affectation doit se comporter comme on s'y attend, elle doit retourner une référence sur l'objet lui-même, à gauche de l'opérateur d'affectation. L'erreur que font beaucoup de gens est de ne rien retourner...

Object& operator=(const Object& o)
{
  this->member1 =  o.member1 ;
  return *this ;
}

Cette bonne pratique doit s'appliquer à tous les opérateurs d'affectation : +=, -=, *=, /=... même si les paramètres ne sont pas du même type : Object& operator*=(int factor)

Suivez ces conseils et votre code sera meilleur. Il s'agit juste d'une convention, vous pouvez déclarer vos opérateurs void, votre code compilera mais les utilisateurs seront déstabilisés si l'opérateur n'a pas le même comportement que d'habitude.

L'auto-affectation

Il est fortement recommandé de prendre en compte le cas de l'auto-affectation lors de la conception de ses classes. Si vous ne le faites pas, vous risquer d'exposer les utilisateurs de vos classes à des bugs subtils qui peuvent avoir des conséquences désastreuses. Mais d'abord, c'est quoi l'auto-affectation ?

C'est ça !

o = o;

Vous l'aurez compris, elle se produit quand un objet est assigné à lui-même. Bien évidemment, personne n'écrit du code pareil, mais parce qu'un même objet peut être désigné par des pointeurs ou des références distinctes, des auto-affectations peuvent avoir lieu sans que l'on s'en rende compte. Ce code est parfaitement légal et à première vue, on peut penser que cette instruction n'aura aucun effet, nous verrons que cela peut engendrer des problèmes de mémoire. Vos objets seront un jour ou l'autre confrontés à ce genre de problème, vous devez donc prendre les précautions adéquates dès la conception. Voici un cas classique d'auto-affectation qui n'est pas visible au premier coup d'oeil.

Object *a = new Object; 
Object *b, *c;
b = a;
 
// quelques lignes plus tard...
c = a;
*b = *c; // auto-affectation.

Attention également aux objets faisant partie d'une hiérarchie (héritage), ils peuvent être sujets à ce genre de problème.

class Derived : Base;
// obj1 et obj2 peuvent désigner le même objet.
void func(Base *obj1, Derived *obj2);

Pourquoi l'auto-affectation est-elle un problème ? Lorsque vous faites une copie d'un objet, il faut d'abord libérer la mémoire occupée par les membres de l'objet destination pour éviter les fuites. Ensuite, il faut copier les membres de l'objet source dans l'objet destination en allouant la mémoire requise. Si les deux objets (source et destination) sont le même objet, vous aurez perdu définitivement le contenu de la mémoire que vous aviez libérée. Voici un exemple d'opérateur ne gérant pas l'auto-affectation :

class Object
{
private:
  MyClass *m_myClass;
 
public:
  // ... constructeurs, etc.
 
  Object& operator=(const Object& src)
  {
    delete m_myClass; // on libère les ressources afin de ne pas provoquer de fuites mémoire.
    m_myClass = new MyClass(*src.m_myClass); // copie du membre par appel du constructeur par copie.
    return *this; // retour de la référence *this.
  }
};

Si this et src désignent le même objet, alors on a détruit this->m_myClass et src.m_myClass. On a donc définitivement perdu m_myClass. Au final, l'auto-affectation, qui n'aurait pas dû changer l'état de l'objet, lui fait maintenant contenir un pointeur vers un objet MyClass effacé mais non nul. Pour éviter cela, il suffit de faire un test.

Object& operator=(const Object& src)
{
  // si this a la même adresse mémoire que src, on retourne la référence sans rien modifier.
  if (this != &src)
  {
    // on ne fait les copies que si this et src représentent des objets différents.
    delete m_myClass; // on libère les ressources afin de ne pas provoquer de fuites mémoire.
    m_myClass = new MyClass(*src.m_myClass); // copie du membre par appel du constructeur par copie.
  }
 
  return *this; // retour de la référence *this.
}

Pour les classes dérivées, il suffit de procéder à l'identique en testant les adresses mémoire.

// Fichier main.cpp
#include <iostream>
 
class Base {};
 
class Derived : public Base {};
 
// transmission d'obj2 par pointeur.
void func(Base *obj1, Derived *obj2)
{
  if (obj1 != obj2)
  {
    // les deux objets sont différents.
  }
}
 
// transmission d'obj2 par référence.
void func(Base *obj1, Derived& obj2)
{
  if (obj1 != &obj2) // récupération de l'adresse mémoire de obj2
  {
    // les deux objets sont différents.
  }
}
 
int main(int argc, char **argv)
{
  Derived o;
  func(&o, o); // méthode 1.
  func(&o, &o); // méthode 2.
  return EXIT_SUCCESS;
}

Conclusion

Vous n'avez maintenant plus aucune raison d'écrire d'opérateurs d'affectation faux. Vous connaissez la manière de gérer le problème d'auto-affectation et savez comment retourner le bon type afin de pouvoir profiter de l'affectation chainée.

Vous pourrez approfondir le sujet avec la sélection des livres suivants : Pour mieux développer avec C++, Standards de programmation en C++ et Le langage C++.