Règle des 0, 3 ou 5
Le compilateur fournit des implémentations par défaut pour les éléments suivants.
- Destructeur
- Constructeur de copie
- Opérateur d’affectation par copie
- Constructeur de déplacement
- Opérateur d’affectation par déplacement
Pour une classe donnée, il faut généralement définir à la main :
- aucun des cinq éléments ci-dessus;
- les trois premiers (destructeur, constructeur de copie et opérateur d’affectation par copie);
- ou les cinq.
Pourquoi ?
Parce que ces cinq éléments sont interdépendants. Ils définissent la façon dont la classe interagit avec la mémoire.
Pour éviter les problèmes ces interactions doivent êtres cohérentes entre elles.
La règle des 0, 3 ou 5 n’est pas absolue et seul votre jugement permet de savoir ce dont vous avez besoin.
En particulier, il suffit parfois de se poser la question: est-ce que ma modification impacte l’un des quatre autres éléments?
Règle des 0
La plupart des classes suivent la règle des 0, c’est-à-dire ne modifient aucun des cinq éléments en haut de page. En effet, le comportement généré par les implémentations par défaut correspond à la gestion usuelle de la mémoire et satisfait la plupart des cas.
Règle des 3
Au contraire, on veut que certaines classes ne suivent pas la gestion par défaut de la mémoire. Hormis les exercices sadiques que nous vous imposons, il s’agit usuellement des classes qui définissent des structures de données.
Exercice
Par exemple, le fichier RuleOfThree.cpp contient une classe
qui stocke un entier à travers un pointeur ownant (et l’on maintiendra l’invariant que ce n’est jamais un nullptr
)
Pour éviter les fuite mémoire, elle redéfinit son destructeur comme
indiqué ci-dessous.
class RuleOfThree {
public:
int* int_ptr;
RuleOfThree(int i) {
int_ptr = new int{i};
}
~RuleOfThree() { delete int_ptr; }
};
Que va-t-il se passer si on copie RuleOfThree
, par exemple avec le code suivant ?
void f(RuleOfThree s) {
std::cout << *(s.int_ptr) << std::endl;
// s is destroyed at the end of the function f
}
int main() {
RuleOfThree r{42};
f(r); // Calling f copies r
// the copy is destroyed at the end of the call
std::cout << *(r.int_ptr) << std::endl;
}
Puisqu’on n’a pas défini de constructeur de copie pour RuleOfThree
, le compilateur en génère un et, pour rappel, l’implémentation par défaut consiste à copier chacun des champs d’un RuleOfThree
dans le champs correspondant de la copie.
C’est pourquoi le champ int_ptr
de l’original et la copie pointeront tous les deux vers la même case mémoire.
Quand s
est détruit à la fin de la fonction f
, la case mémoire s.int_ptr
est désallouée et donc l’affichage de *(r.int_ptr)
On peut s’en rendre compte en compilant et en éxécutant le fichier (le message d’erreur peut varier).
$ g++ RuleOfThree.cpp -o RuleOfThree
$ ./RuleOfThree
42
free(): double free detected in tcache 2
Aborted (core dumped)`
On voit ici que le programme indique qu’un segment de mémoire a été libéré plusieurs fois.
Si on utilise valgrind, on aura un message plus “clair”.
g++ RuleOfThree.cpp -o RuleOfThree
valgrind ./RuleOfThree
Valgrind indique quelque chose comme “Address … is 0 bytes inside a block of size 4 free’d”, ce qui veut dire qu’on accède à de la mémoire libérée. Et on pourra ensuite traquer où cela se produit.
Ecrivez le code du constructeur par copie de RuleOfThree
pour régler ce problème.
class RuleofThree {
/* .. */
RuleOfThree(const RuleOfThree& other)
: int_ptr{new int{*(other.int_ptr)}};
// ^^^^^^^^^^^^^^^^^^^^^^^^^
// We allocate a new int on the heap and copy the value pointed by other.int_ptr
{}
/* .. */
};
Des problèmes vont aussi se produire en cas d’utilisation de l’opérateur d’affectation par copie. Quel problème va-t-on rencontrer avec le code suivant?
int main() {
RuleOfThree r{42};
RuleOfThree t{9001};
r = t;
*(t.int_ptr) = 0;
std::cout << *(r.int_ptr) << std::endl; // devrait afficher 9001
std::cout << *(t.int_ptr) << std::endl; // devrait afficher 0
}
Décommentez la fin de la fonction main
dans le fichier RuleOfThree.cpp
,
et écrivez l’opérateur d’affectation par copie pour qu’il n’y ait pas de
problème mémoire.
Après l’affectation par copie, les deux r.int_ptr
et t.int_ptr
pointent vers la même case mémoire.
Donc les deux dernières instructions vont afficher la même chose, à savoir 0.
De plus, la mémoire pointée par r.int_ptr
avant la copie n’a jamais été désallouée.
Notre code produit une fuite mémoire, comme nous l’indique Valgrind dans son “LEAK SUMMARY” avec une indication comme “definitely lost: 4 bytes in 1 blocks”.
Ecrivez le code de l’opérateur d’affectation par copie de RuleOfThree
pour résoudre ces problèmes.
class RuleofThree {
/* .. */
RuleOfThree& operator=(const RuleOfThree& other)
{
if (this != &other) {
// this object existed before so int_ptr is already allocated
// we simply copy the value pointed by other.int_ptr
*int_ptr = *(other.int_ptr);
}
return *this;
}
/* .. */
};
Règle des 5
Les classes qui suivent la règle des 5 sont celles qui suivent déjà la règle des 3 et qui veulent tout de même pouvoir être déplacés efficacement.
En effet, quand on définit un des trois éléments de la règle des 3, le compilateur ne génère pas d’implémentation par défaut ni pour le constructeur de déplacement ni pour l’opérateur d’affectation par déplacement. Ça veut en particulier dire que les instances de cette classe seront toujours copiée quand elles pourraient être déplacées.
Dans le cas de la classe RuleOfThree
plus haut, ce n’est pas très grave, car
l’objet pointé est petit.
Exercice
Implémenter la règle des 5 pour la classe RuleOfFive
se trouvant dans le fichier RuleOfFive.cpp.
class RuleOfFive {
/* .. */
RuleOfFive(const RuleOfFive& other)
: vect_ptr{new std::vector<int>{*(other.vect_ptr)}}
{}
RuleOfFive& operator=(const RuleOfFive& other)
{
(*vect_ptr) = *(other.vect_ptr);
return *this;
}
RuleOfFive(RuleOfFive&& other)
: vect_ptr{new std::vector<int>{std::move(*(other.vect_ptr))}}
{}
RuleOfFive& operator=(RuleOfFive&& other)
{
(*vect_ptr) = std::move(*(other.vect_ptr));
return *this;
}
/* .. */
};