Bonjour,
J’ai réfléchi à un moyen d’expliquer simplement la mémoire et la différence entre copie et déplacement au moyen d’une analogie. Qu’en pensez-vous ?
Constructeurs et mémoire
Lorsque l’on exécute un programme, celui-ci se voit attribuer par l’OS une zone de mémoire pour allouer ses variables. Cette zone s’appelle la pile (Stack en anglais).
Toute variable créée dans cette zone à une durée de vie correspondant à son « scope ».
int main()
{
int a;
…
for (int i=0 ; i !=10 ; ++i) {
int j;
…
}
…
}
Cette partie de mémoire est très rapide d’accès, mais elle a 2 défauts. Sa taille est limitée à quelques Mo et le compilateur doit connaître la taille de toutes les variables que vous utiliserez dans chaque scope pour pouvoir générer le code machine de votre exécutable.
Heureusement il existe une seconde zone de mémoire que l’on appelle le tas (Heap ou Free Store en anglais). Sa taille correspond à toute la quantité de mémoire disponible sur votre PC qui n’est pas déjà attribuée à d’autres programmes.
Mais attention ! Pour allouer de la mémoire dans cette zone, il faut le demander à l’OS. Cette demande est assez compliquée à gérer correctement et il vaut mieux quand on est débutant (et même après) laisser les objets de la STL s’en charger.
Prenons le cas du type vecteur (std::vector<T>) qui est un container et faisons une analogie avec une entreprise de transport routier. Chaque vecteur représente un semi-remorque pouvant transporter des objets de type T.
Un semi-remorque est composé d’un tracteur (avec le moteur et la cabine) et d’une remorque accrochée à celui-ci. Tous les tracteurs sont "identiques" et les remorques peuvent avoir des tailles variables.
Lorsque l’on crée une variable de type vecteur, sans préciser sa taille, le constructeur par défaut est appelé.
Le vecteur crée le tracteur sur la pile, celui-ci à une taille fixe connu à la compilation, mais aucune remorque.
Lorsque on ajoute des données dans ce vecteur avec l’instruction
le vecteur va demander à l’OS de lui allouer une remorque sur le tas pour pouvoir y ranger la donnée x, et va accrocher cette remorque au tracteur.
Au fur et à mesure des prochains push_back(), la remorque va se remplir. Lorsque celle-ci sera pleine, le vecteur demandera à l’OS une remorque plus grande, "copiera" le chargement d’une remorque à l’autre et demandera à l’OS de détruire la première. L’opération se répétera à chaque fois que la remorque sera pleine, tant qu’il reste de la mémoire de libre dans la machine.
Lorsque le vecteur sortira de son scope, la fin d’une fonction où il a été créé par exemple, sont destructeur sera appelé automatiquement et celui-ci demandera
à l’OS de détruire la remorque en cours. Le tracteur lui sera automatiquement détruit puisque alloué sur la pile.
Voyons maintenant concrètement ce que cela donne avec la classe std::string, un container spécialisé dans le transport des caractères.
Le constructeur par défaut:
Alloue un tracteur sur la pile mais pas de remorque
Un constructeur avec argument:
std::string s{«Hello, »};
Alloue un tracteur s sur la pile et une remorque sur le tas capable de stocker « Hello, ». La chaine « Hello, » est alors copiée dans la remorque toute neuve.
Le constructeur de copie :
std::string s{«Hello, »);
std::string t{s};
Alloue un tracteur t sur la pile, une remorque aussi grande que celle de s sur le tas, et recopie le chargement de s dans la remorque de t. Les remorques de s et t ont donc un chargement identique mais ne sont pas attachées au même tracteur.
L’opérateur d’affectation par copie:
std::string s{“Hello,”};
std::string t{“World !”};
t = s ;
Dans un premier temps, détruit la remorque de t si elle n’est pas assez grande et en alloue nouvelle sur sur le tas. Ensuite, recopie le chargement de la remorque de s dans la remorque de t. Les 2 remorques contiennent « Hello, »
Maintenant, imaginons le cas suivant. On crée une fonction que concatène 2 chaines et renvoie le résultat par valeur:
std::string concat(const std::string& s1,const std::string& s2)
{
auto tmp{s1};
tmp += s2;
return tmp;
}
int main()
{
std::string s{“Hello,”};
std::string t{“World !”};
...
s = concat(s , t);
...
}
Quand la fonction concat se termine, on va utiliser l’opérateur de copie. Le chargement de tmp sera copié dans la remorque de s juste avant que le camion tmp ne soit détruit. Quel gachi. Pourquoi recopier le chargement d’une remorque à l’autre si c’est pour détruire la première juste après ?
Depuis C++ 11 est apparu la notion de déplacement. Il s’effectue grâce au constructeur et à l’opérateur de déplacement qui sont appelés automatiquement si l’objet passé en argument, de se constructeur ou de cet opérateur, va être détruit juste après l’affectation. C’est bien le cas de tmp ici qui est la valeur de retour de la fonction.
On précise un type d’objet prêt à disparaître, en le faisant suivre de &&. La définition du constructeur de déplacement de std::string est donc:
std::string& std::string(std::string&&);
et son opérateur de déplacement:
std::string& std::string.operator=(std::string&&);
Revenons donc à notre code, sachant que l’on utilise un compilateur et une STL récente avec une classe std::string utilisant le déplacement.
std::string concat(const std::string& s1,const std::string& s2)
{
auto tmp{s1};
tmp += s2;
return tmp;
}
int main()
{
std::string s{“Hello,”};
std::string t{“World !”};
...
s = concat(s , t);
...
}
Au moment du return de la fonction concat, la remorque du camion s va être détruite. On va alors décrocher celle du tracteur de tmp pour l’accrocher au tracteur de s et tmp qui n’est plus composé que d’un tracteur seul va être détruit automatiquement puisque alloué sur la pile.
Le retour d’objet par valeur est donc très efficace depuis C++ 11, à condition que la classe de l’objet retourné définisse un constructeur et un opérateur de déplacement. C’est le cas de beaucoup de classes dans la STL.
Il existe aussi une fonction std::move(X) qui vas marqué l’objet passé en paramètre (ici X) comme étant prêt à disparaître. On peut donc forcé le déplacement d’une
variable si le compilateur n’est pas à même de reconnaître une variable sur le point de disparaître.
Il même possible d’interdire la copie d’un objet en autorisant seulement son déplacement. On utilise cela par exemple lorsque l’on veut être sûr qu’il n’y ai pas plusieurs remorques identiques dans notre programme, pour reprendre notre analogie.
Note: Le language RUST, très à la mode, utilise par défaut le déplacement, c’est au programmeur d’indiquer quand il veut une copie, de même que toute les variables
sont constantes par défaut à moins de préciser le contraire.