Déplacement

Sur cette page vous allez apprendre ce qu’est le déplacement en C++. En un mot, il s’agit de permettre de vider un objet pour en construire un autre ailleurs, et on utilise pour cela la fonction std::move.

Usage de la fonction std::move sur un exemple

Considérons le bout de code ci-dessous.

#include <iostream>
#include <vector>
//                       Passage par valeur
//                              vvvvvvvvvvv
void f(std::vector<std::string> my_own_data) 
{
    for (auto& s : my_own_data)
        std::cout << &s << " ";
//                   ^^
//                   On affiche l'adresse des chaines de caractères
    std::cout << std::endl;
}

int main() 
{
    std::vector<std::string> data = {"Hello", "World", "!"};
    std::cout << "Affichage 0: ce qu'il y a dans data" << std::endl;
    for (auto& s : data)
        std::cout << &s << " ";
    std::cout << std::endl;

    std::cout << std::endl << "Affichage 1: my_own_data sans std::move" << std::endl;
    f(data); //les chaines sont à des adresses mémoire différentes

    std::cout << std::endl << "Affichage 2: my_own_data avec std::move" << std::endl;
    f(std::move(data)); // les chaines sont aux mêmes adresses mémoire qu'à l'affichage 0

    std::cout << std::endl << "Affichage 3: ce qu'il reste dans data" << std::endl;
    for (auto& s : data) // data est maintenant vide, rien ne s'affiche
        std::cout << &s << " ";

    //Les variables locales sont détruites (donc en particulier data)
}

Dans le code au dessus, la fonction f prend son argument par valeur. C’est un peu stupide car rien ne le justifie, mais c’est pour l’exemple.

Lors du premier appel à f, le compilateur copie data pour construire my_own_data. Les chaînes de caractères dans my_own_data sont des copies de celles de data, elles sont donc à des adresses mémoires différentes de celles de data.

Lors du deuxième appel à f, on sait qu’on n’aura plus besoin de data dans main (elle va être détruite juste après). Vu que f a apparemment besoin de sa propre copie de data, on décide de déplacer data dans my_own_data. Cela signifie que cette fois, le compilateur va prendre les ressources de data pour construire my_own_data. Les chaînes de caractères dans my_own_data sont en fait exactement les mêmes que celles qui étaient dans data à l’affichage 0: elles ont juste été réattribuées à my_own_data.

Le tableau dynamique data est vide à l’affichage 3, car ses données ont été prise par my_own_data. En revanche, après le std::move, data est toujours un objet valide: on peut itérer dessus ou rajouter de nouveaux éléments.

Rappelez-vous qu’un std::vector est un tableau dynamique, donc ses éléments sont en fait sur le tas, et ce sont elles qui vont être copiées ou réattribuées à my_own_data, comme le montrent les deux diagrammes en dessous.

Effet sur la mémoire du premier appel à f (copie)

test test

Effet sur la mémoire du deuxième appel à f (déplacement)

test test

Constructeur de déplacement

Maintenant qu’on a compris le principe du déplacement, encore faut-il pouvoir définir comment se déplacent nos propres classes. Etant donné une classe MaClasse le constructeur de déplacement a le prototype suivant.

//               Attention à la double & 
//               vv
MaClasse(MaClasse&& other) 
{
    /* ... */
}

La notation MaClasse&& (avec deux &) signifie R-value-reference. Le type MaClasse& (avec une &) que l’on appelle couramment une réference est en fait une L-value-reference.

Astuce

Le compilateur génère une implémentation par défaut pour le constructeur de déplacement et elle fait ce qu’il faut dans la plupart des cas. Dans d’autres cas, le compilateur ne va pas la générer mais elle est quand même très bien. On peut demander explicitement au compilateur de le générer avec MaClasse(MaClasse&& other) = default;.

Quand on déplace un objet (et donc quand on implémente un constructeur de déplacement à la main), il faut faire attention à laisser l’objet d’origine dans un état valide.
Pourquoi? Parce que l’objet d’origine va sans doute être supprimé juste après, donc il faut faire attention qu’il ne supprime pas les ressources qu’on lui a pris. Il est aussi possible qu’il soit réutilisé juste après

Quand le constructeur de déplacement est-il appelé?

Pour faire court: quand le compilateur doit construire un nouvel objet à partir d’une R-value du même type (et que le compilateur ne fait pas d’élusion de déplacement, voir plus bas).

En pratique, c’est surtout quand on utilise std::move en effet, c’est une fonction qui permet de transformer une L-value en R-value et dit donc au compilateur d’utiliser le constructeur de déplacement plutôt que le constructeur de copie.

Exercice

On considère que MyClass est une classe avec un constructeur par défaut et des constructeurs de copies et de déplacements; Dans chacun des bouts de code ci-dessous: combien de fois le constructeur de copie de MyClass est-il appelé? (On ne demande pas le nombre de fois où le constructeur de déplacement est appelé à cause de l’élusion de déplacement que l’on verra plus bas.)

/*1*/
MyClass a = MaClass{};

Le constructeur de copie ne sera pas appelé.


/*2*/
void f(MyClass a) {/* ... */}
int main() 
{
    MyClass a;
    f(a);
}

Le constructeur de copie sera appelé une fois.


/*3*/
void f(MyClass a) {/* ... */}
int main() 
{
    MyClass a;
    f(std::move(a));
}

Le constructeur de copie ne sera pas appelé car std::move(a) est une R-value.


/*4*/
void f(MyClass a) {/* ... */}
int main() 
{
    f(MyClass{});
}

Le constructeur de copie ne sera pas appelé car MyClass{} est une R-value.


Dans la suite, on considère le code suivant pour MyOtherClass.

MyOtherClass
{
public:
    MyClass _att;
    MyOtherClass() = default;
    MyOtherClass(MyClass arg) : _att{arg} {}
}

Combien de fois le constructeur de copie de MyClass est-il appelé par chacun des bouts de code suivants.

/*5*/
int main {
    MyClass x;
    MyOtherClass y{x};
}   

Le constructeur de copie sera appelé deux fois: une fois pour construire arg et une fois pour construire _att à partir de arg.

/*6*/
int main {
    MyOtherClass x;
    MyOtherClass y = x;
}   

Le constructeur de copie de MyClass sera appelé une fois. En effet, l’implémentation par défaut du constructeur de copie appelle le constructeur de copie pour chaque attribut.

/*7*/
int main {
    MyOtherClass x;
    MyOtherClass y{x._att}
}   

Le constructeur de copie sera appelé deux fois. x._att est une L-value donc il est appelé une première fois pour construire arg et une seconde fois pour construire _att à partir de arg.

/*8*/
int main {
    MyOtherClass x;
    MyOtherClass y = MyOtherClass{MyOtherClass{MyOtherClass{std::move(x)}}};
}   

Aucun constructeur de copie n’est appelé. En effet toutes les expressions suivantes sont des R-value:

  • std::move(x)
  • MyOtherClass{std::move(x)}
  • MyOtherClass{MyOtherClass{std::move(x)}}
  • MyOtherClass{MyOtherClass{MyOtherClass{std::move(x)}}}

Elusion de déplacement (Copy/Move Elision)

L’élusion de déplacement est une optimisation consistant à éluder (=éviter) un déplacement en utilisant directement la mémoire où l’objet sera finalement stocké. Dans certaines circonstances, le compilateur doit éluder le déplacement d’un objet; et dans certaines circonstances, le compilateur peut éluder le déplacement d’un objet. Nous ne décrirons pas ces circonstances dans le cours (détails sur cppref), mais ne soyez pas surpris si le constructeur de déplacement n’est parfois pas appelé: c’est sans doute une élusion de déplacement.

Voici deux exemples.


Dans le code en dessous, le compilateur doit effectuer une élusion de déplacement.

int main() {
    MyClass m = MyClass();
}

Dans le code ci-dessus, l’expression MyClass() produit une R-value en utilisant le constructeur par défaut de MyClass. Ensuite, le compilateur devrait construire le MyClass stocké dans m en utilisant le constructeur de déplacement. En réalité, le constructeur de déplacement ne sera pas appelé: le constructeur par défaut sera directement utilisé pour instancier m.


Dans le code en dessous, le compilateur peut effectuer une élusion de déplacement (mais n’est pas obligé).

MyClass f() {
    MyClass x;
    /* Des opérations sur x... */
    return x;
}

int main() {
    MyClass n = f();
}

Dans le code ci-dessus, la fonction f crée un MyClass sur la pile, fait des opérations dessus, puis retourne le MyClass. Ensuite, dans le main, l’expression f() est une R-value donc le constructeur de déplacement devrait être appelé pour construire n. En fait, suivant le contenu de f, le compilateur peut décider de ne pas allouer de mémoire pour x et de construire directement le MyClass dans la mémoire allouée pour n.