C++
La règle des trois, cinq et zéro
Recherche…
Règle de cinq
C ++ 11 introduit deux nouvelles fonctions membres: le constructeur de déplacement et l'opérateur d'affectation de mouvement. Pour toutes les mêmes raisons pour lesquelles vous souhaitez suivre la règle des trois en C ++ 03, vous devez généralement suivre la règle des cinq en C ++ 11: si une classe requiert UNE des cinq fonctions membres spéciales et si la sémantique est déplacée sont désirés, alors il est très probable qu'ils nécessitent tous les cinq d'entre eux.
Notez, cependant, que ne pas suivre la règle de cinq n'est généralement pas considéré comme une erreur, mais comme une opportunité d'optimisation manquée, tant que la règle de trois est toujours suivie. Si aucun constructeur de déplacement ou opérateur d'attribution de déplacement n'est disponible lorsque le compilateur en utilisera normalement un, il utilisera plutôt la sémantique de la copie si possible, ce qui entraînera une opération moins efficace en raison d'opérations de copie inutiles. Si la sémantique de déplacement n'est pas souhaitée pour une classe, elle n'a pas besoin de déclarer un constructeur de déplacement ou un opérateur d'affectation.
Même exemple que pour la règle de trois:
class Person
{
char* name;
int age;
public:
// Destructor
~Person() { delete [] name; }
// Implement Copy Semantics
Person(Person const& other)
: name(new char[std::strlen(other.name) + 1])
, age(other.age)
{
std::strcpy(name, other.name);
}
Person &operator=(Person const& other)
{
// Use copy and swap idiom to implement assignment.
Person copy(other);
swap(*this, copy);
return *this;
}
// Implement Move Semantics
// Note: It is usually best to mark move operators as noexcept
// This allows certain optimizations in the standard library
// when the class is used in a container.
Person(Person&& that) noexcept
: name(nullptr) // Set the state so we know it is undefined
, age(0)
{
swap(*this, that);
}
Person& operator=(Person&& that) noexcept
{
swap(*this, that);
return *this;
}
friend void swap(Person& lhs, Person& rhs) noexcept
{
std::swap(lhs.name, rhs.name);
std::swap(lhs.age, rhs.age);
}
};
L'opérateur d'affectation de copie et de déplacement peut également être remplacé par un seul opérateur d'affectation, qui prend une instance par valeur plutôt qu'une référence ou une référence de valeur pour faciliter l'utilisation de l'idiome de copie et d'échange.
Person& operator=(Person copy)
{
swap(*this, copy);
return *this;
}
Étendre de la règle des trois à la règle des cinq est important pour des raisons de performance, mais n'est pas strictement nécessaire dans la plupart des cas. L'ajout du constructeur de copie et de l'opérateur d'affectation garantit que le déplacement du type n'entraînera pas de fuite de mémoire (la construction par déplacement reviendra simplement à la copie dans ce cas), mais effectuera des copies imprévues.
Règle de zéro
Nous pouvons combiner les principes de la règle des cinq et de la RAII pour obtenir une interface beaucoup plus simple: la règle de zéro: toute ressource devant être gérée doit être propre. Ce type devrait suivre la règle des cinq, mais tous les utilisateurs de cette ressource ne pas besoin d'écrire l' une des cinq fonctions membres spéciales et peut tout simplement default
tous.
En utilisant la classe Person
introduite dans l' exemple Rule of Three , nous pouvons créer un objet de gestion de ressources pour les cstrings
de cstrings
:
class cstring {
private:
char* p;
public:
~cstring() { delete [] p; }
cstring(cstring const& );
cstring(cstring&& );
cstring& operator=(cstring const& );
cstring& operator=(cstring&& );
/* other members as appropriate */
};
Et une fois que ceci est séparé, notre classe de Person
devient beaucoup plus simple:
class Person {
cstring name;
int arg;
public:
~Person() = default;
Person(Person const& ) = default;
Person(Person&& ) = default;
Person& operator=(Person const& ) = default;
Person& operator=(Person&& ) = default;
/* other members as appropriate */
};
Les membres spéciaux en Person
n'ont même pas besoin d'être déclarés explicitement; le compilateur va par défaut ou les supprimer de manière appropriée, en fonction du contenu de Person
. Par conséquent, ce qui suit est également un exemple de la règle de zéro.
struct Person {
cstring name;
int arg;
};
Si cstring
devait être un type à déplacement uniquement, avec un opérateur de delete
/ copie constructeur / affectation, alors Person
ne serait automatiquement déplacé.
Le terme règle de zéro a été introduit par R. Martinho Fernandes
Règle de trois
La règle de trois stipule que si un type doit avoir un constructeur de copie défini par l'utilisateur, un opérateur d'attribution de copie ou un destructeur, il doit avoir les trois .
La raison de cette règle est qu'une classe nécessitant l'un des trois gère une ressource (descripteurs de fichiers, mémoire allouée dynamiquement, etc.) et que les trois sont nécessaires pour gérer cette ressource de manière cohérente. Les fonctions de copie traitent de la manière dont la ressource est copiée entre les objets, et le destructeur détruirait la ressource, conformément aux principes de RAII .
Considérons un type qui gère une ressource de chaîne:
class Person
{
char* name;
int age;
public:
Person(char const* new_name, int new_age)
: name(new char[std::strlen(new_name) + 1])
, age(new_age)
{
std::strcpy(name, new_name);
}
~Person() {
delete [] name;
}
};
Étant donné que name
était alloué dans le constructeur, le destructeur le désalloue pour éviter toute fuite de mémoire. Mais que se passe-t-il si un tel objet est copié?
int main()
{
Person p1("foo", 11);
Person p2 = p1;
}
Tout d'abord, p1
sera construit. Ensuite, p2
sera copié à partir de p1
. Cependant, le constructeur de copie généré par C ++ copiera chaque composant du type tel quel. Ce qui signifie que p1.name
et p2.name
pointent tous deux vers la même chaîne.
Lorsque main
fin main
, les destructeurs seront appelés. Le destructeur de p2
sera appelé; il va supprimer la chaîne. Ensuite, le destructeur de p1
sera appelé. Cependant, la chaîne est déjà supprimée . L'appel de la delete
sur la mémoire déjà supprimée entraîne un comportement indéfini.
Pour éviter cela, il est nécessaire de fournir un constructeur de copie approprié. Une approche consiste à implémenter un système compté de référence, dans lequel différentes instances Person
partagent les mêmes données de chaîne. Chaque fois qu'une copie est effectuée, le nombre de références partagées est incrémenté. Le destructeur décrémente ensuite le compte de référence, ne libérant la mémoire que si le compte est à zéro.
Ou nous pourrions implémenter la sémantique des valeurs et le comportement de copie en profondeur :
Person(Person const& other)
: name(new char[std::strlen(other.name) + 1])
, age(other.age)
{
std::strcpy(name, other.name);
}
Person &operator=(Person const& other)
{
// Use copy and swap idiom to implement assignment
Person copy(other);
swap(copy); // assume swap() exchanges contents of *this and copy
return *this;
}
La mise en œuvre de l'opérateur d'assignation de copie est compliquée par la nécessité de libérer un tampon existant. La technique de copie et d'échange crée un objet temporaire contenant un nouveau tampon. Changer le contenu de *this
et copy
donne la propriété à la copy
du tampon d'origine. La destruction de la copy
, à mesure que la fonction retourne, libère le tampon précédemment détenu par *this
.
Protection d'auto-assignation
Lors de l'écriture d'un opérateur d'affectation de copie, il est très important qu'il soit capable de travailler en cas d'auto-affectation. Autrement dit, il doit permettre cela:
SomeType t = ...;
t = t;
L'auto-assignation ne se produit généralement pas de manière aussi évidente. Il se produit généralement par une voie détournée par divers systèmes de code, où l'emplacement de l'affectation a simplement deux Person
pointeurs ou références et n'a aucune idée qu'ils sont le même objet.
Tout opérateur d'affectation de copie que vous écrivez doit pouvoir en tenir compte.
La manière typique de le faire est d’emballer toute la logique d’affectation dans un état comme celui-ci:
SomeType &operator=(const SomeType &other)
{
if(this != &other)
{
//Do assignment logic.
}
return *this;
}
Remarque: Il est important de penser à l'auto-affectation et de vous assurer que votre code se comporte correctement lorsqu'il se produit. Cependant, l'auto-assignation est une occurrence très rare et l'optimisation pour l'empêcher peut en fait pessimiser le cas normal. Étant donné que le cas normal est beaucoup plus courant, pessimiser l’auto-assignation peut réduire l’efficacité de votre code (soyez donc prudent en l’utilisant).
À titre d'exemple, la technique normale pour implémenter l'opérateur d'affectation est l' copy and swap idiom
. La mise en œuvre normale de cette technique ne prend pas la peine de tester pour l'auto-assignation (même si l'auto-attribution est coûteuse car une copie est faite). La raison en est que la pessimisation du cas normal s’est révélée beaucoup plus coûteuse (comme cela arrive plus souvent).
Les opérateurs d'attribution de mouvement doivent également être protégés contre l'auto-assignation. Cependant, la logique de nombreux opérateurs de ce type est basée sur std::swap
, qui peut gérer le transfert de / vers la même mémoire. Donc, si votre logique d'affectation de mouvement n'est rien d'autre qu'une série d'opérations d'échange, vous n'avez pas besoin de protection d'auto-assignation.
Si ce n'est pas le cas, vous devez prendre des mesures similaires à celles ci-dessus.