Introduction à la sémantique de mouvement

Ce qui suit est une introduction, sans plus, à un mécanisme conceptuellement pertinent et fort utile, à la fois sur le plan de la vitesse d'exécution et sur celui de la sécurité, que nous propose C++ 11. Avant de lire ce qui suit, il est préférable que vous soyez familière ou familier avec :

Depuis C++ 11, nous avons accès à une nouvelle métaphore identitaire pour les objets. En effet, outre la copie et le partage, nous avons désormais droit à un support plein et entier de la sémantique de mouvement.

Qu'est-ce que la sémantique de mouvement?

La sémantique de mouvement permet de déplacer des états d'un objet à un autre. Par exemple, lors d'une affectation a = b, la sémantique de copie ferait de a après l'affectation une copie de b avant l'affectation, alors que la sémantique de mouvement ferait en sorte que a après l'affectation arrache à b les états que b contenait avant l'affectation, tout en laissant b dans un état légal, et reconnaissable comme tel.

Notez que « légal » ne signifie pas nécessairement utilisable – il est possible que l'objet dont les états ont été arrachés ne soit plus utilisable en pratique – mais il demeure que l'objet dont les états se sont faits arracher doit pouvoir se finaliser correctement. À ce sujet, voir la section sur le mouvement destructif pour comprendre les enjeux qui sous-tendent les attentes quant à l'état d'un objet qui aurait subi un mouvement.

Pour un type implémentant la sémantique de mouvement, les opérations de mouvement entreront en jeu chaque fois que le compilateur verra une opportunité de remplacer une copie par un mouvement. Par exemple, examinez ce qui suit :

#include <vector>
using namespace std;
vector<double> f(vector<double> v) {
   // modifier v...
   return v;
}
int main() {
   vector<double> v;
   // ...remplir v...
   v = f(v);
}

En particulier, examinez l'affectation v = f(v). Dans cette expression, la valeur retournée par f(v) peut être vue comme une temporaire anonyme. En fait, f(v) est une rvalue, une expression qui peut apparaître du côté droit d'une affectation, et ce qu'elle retourne peut être considéré comme une référence sur une rvalue depuis C++ 11. Dans ce cas, il est clair pour le compilateur que cette valeur est destinée à mourir; il est donc inutile d'en copier le contenu (ce que nous ferions si nous souhaitions la garder intacte), et on peut tout simplement en arracher le contenu... dans la mesure où on la laisse dans un état tel que son destructeur s'exécutera sans erreur.

Opérations de mouvement

Un type qui supporte le mouvement est dit déplaçable (en anglais : Movable). Deux opérations de mouvement sont possibles, soit :

À titre d'exemple, voici une classe simple nommée X et supportant à la fois la copie et le mouvement :

#include <string>
class X {
   // sémantique attendue: X est responsable de *ps; copier un X implique dupliquer *ps
   std::string *ps {}; // quelle étrange idée... mais c'est un exemple simpliste, sans plus
public:
   X() = default;
   X(const std::string &src) : ps{ new std::string(src) } {
   }
   void swap(X &autre) {
      using std::swap;
      swap(ps, autre.ps);
   }
   //
   // Sainte-Trinité
   //
   X(const X &autre) : ps{ autre.ps? new std::string{ *(autre.ps) } : nullptr } {
   }
   X& operator=(const X &autre) {
      X{ autre }.swap(*this);
      return *this;
   }
   ~X() {
      delete ps;
   }
   //
   // opérations de mouvement
   //
   X(X && autre) noexcept : ps{ autre.ps } {
      autre.ps = {};
   }
   X& operator=(X && autre) noexcept {
      // précondition : &autre != this
      delete ps;
      ps = autre.ps;
      autre.ps = {};
      return *this;
   }
};

La signature du constructeur de mouvement pour X est X::X(X&&), alors que la signature de l'affectation de mouvement est X& X::operator=(X&&). Dans les deux cas, le paramètre n'est pas const puisqu'on en arrachera les entrailles; en effet, pour modéliser un transfert de contenu, nous déplaçons ici les états de l'objet source (autre) vers l'objet de destination (*this), et nous modifions le paramètre pour qu'il ne soit plus « en possession » des états qui en ont été arrachés.

Remarquez la complexité comparative des opérations de copie et des opérations de mouvement correspondantes.

Opérations de copieOpérations de mouvement
X::X(const X &autre)
   : ps { autre.ps? new std::string {*(autre.ps) } : nullptr }
{
}

La constructeur de copie, ici, doit réaliser un test pour éviter de déréférencer un pointeur nul, puis effectuer une allocation dynamique de mémoire, suivi de l'initialisation de cette mémoire avec le constructeur d'une std::string. Sa complexité est ; de plus, l'allocation dynamique de mémoire en fait une opération dont le temps d'exécution est a priori indéterminé, et qui est susceptible de lever une exception.

X::X(X &&autre) noexcept
   : ps { autre.ps }
{
   autre.ps = {}
}

Le constructeur de mouvement effectue deux copies de pointeurs. Sa complexité est et elle ne lèvera jamais d'exceptions.

X& X::operator=(const X &autre) {
   X{ autre }.swap(*this);
   return *this;
}

L'affectation est ici exprimée en termes du constructeur de copie, du destructeur et de l'opération swap(), suivant l'idiome d'affectation sécuritaire. Puisque swap() s'exécute en temps constant, la complexité de l'affectation rejoint celle du costructeur de copie.

X& operator=(X && autre) noexcept {
   // précondition : &autre != this
   delete ps;
   ps = autre.ps;
   autre.ps = {};
   return *this;
}
// ce qui suit n'est pas une bonne idée, malgré les apparences
/*
X& X::operator=(X &&autre) noexcept {
   swap(autre);
   return *this;
}
*/

L'affectation de mouvement effectue deux copies de pointeurs et un appel à delete. Sa complexité est si delete est en temps constant, et elle ne lèvera jamais d'exceptions.

Deux notes :

  • Si votre code est susceptible de contenir les expressions a = std::move(a) ou swap(a,a), il est sage d'insérer dans l'affectation de mouvement un test pour valider la précondition à l'effet que this!=&autre
  • Implémenter l'affectation de mouvement par swap(autre) semble correct, mais entraîne un délai malsain pour le nettoyage des ressources sous l'égide de *this avant l'affectation. Conséquemment, évitez cette approche

Que signifie std::move()?

Le code en exemple utilise std::move(). Cette fonction est en fait une opération de transtypage (un Cast); contrairement à ce que son nom suggère, elle ne déplace rien. Son rôle est de prendre une référence (peu importe sa nature) sur la donnée reçue en paramètre et de retourner une référence sur une rvalue menant vers cette donnée... En gros, de marquer cette entité comme étant susceptible de subir un mouvement.

Pour un exemple d'utilisation de std::move(), réexaminons un des exemples présentés plus haut. Nous avions :

#include <vector>
using namespace std;
vector<double> f(vector<double> v) {
   // modifier v...
   return v;
}
int main() {
   vector<double> v;
   // ...remplir v...
   v = f(v);
}

L'expression v = f(v) est particulièrement intéressante ici. En effet, nous passons v par valeur à f(), et nous remplacerons v par ce que f() retournera à la fin de cette expression. Conséquemment, pendant l'exécution de f(), nous n'avons pas besoin de garder v intact (dans la mesure où f(v) ne lèvera pas d'exceptions, évidemment!). Nous le savons... mais le compilateur ne le sait pas, du moins pas à l'appel de f() car dans v = f(v), l'appel est résolu avant l'affectation.

Tenant compte de ce savoir, nous pouvons accélérer significativement l'exécution de ce programme avec std::move() :

#include <vector>
using namespace std;
vector<double> f(vector<double> v) {
   // modifier v...
   return v;
}
int main() {
   vector<double> v;
   // ...remplir v...
   v = f(std::move(v));
}

Ici, la copie de v à l'appel de f() sera remplacée par un mouvement, et il ne reste plus la moindre copie de vector<double> dans le programme.

Clairement, le mouvement est plus rapide et plus sécuritaire que la copie. Dans la plupart des cas, le mouvement est une optimisation. Si un type est copiable et ne supporte pas les opérations de mouvement, alors pour ce type, la copie sera appliquée. Le compilateur, lorsqu'il aura le choix entre appliquer un mouvement ou une copie sur un objet et lorsque les deux opérations seraient appropriées, privilégiera habituellement le mouvement s'il est implémenté pour un type donné.

Dans certains cas, le mouvement améliore l'utilisabilité d'un type. Certains types sont Move-Only, au sens où ils sont incopiables (bloquent la copie) mais sont déplaçables. Parmi les types Move-Only, on trouve plusieurs types clés du standard, par exemple std::istream, std::unique_ptr, std::mutex, std::thread, etc. Heureusement, les conteneurs de C++ 11 utiliseront le mouvement si possible dans le cadre de la gestion de leurs éléments, ce qui permet d'écrire des conteneurs d'éléments de types déplaçables.

En C++ contemporain, on n'utilisera presque jamais new tel quel, et on confiera typiquement la gestion des pointés alloués dynamiquement à des pointeurs intelligents offrant la sémantique souhaitée.

Pour la classe X ci-dessus, les pointeurs intelligents standards de C++ 11 ne conviennent pas. En effet, std::unique_ptr est incopiable alors que X implémente les opérations de copie, et std::shared_ptr partage le pointé plutôt que de le dupliquer comme nous le faisons ici.

Si nous décidions toutefois que X pourrait être un type Move-Only, nous pourrions le réécrire comme suit et profiter de la prise en charge du pointé par un pointeur intelligent :

#include <string>
#include <memory>
class X {
   // sémantique attendue: X est responsable de *ps; mais est incopiable
   std::unique_ptr<std::string> ps; // quelle étrange idée... mais ça reste mieux qu'un pointeur brut
public:
   X() = default;
   X(const std::string &src) : ps{ new std::string(src) } {
   }
   //
   // la Sainte-Trinité est implicite: ps est incopiable et gère lui-même le pointé
   //
   //
   // opérations de mouvement
   //
   X(X&&) = default;
   X& operator=X(X&&) = default;
};

Évidemment, si X se limitait à ceci, nous utiliserions probablement tout simplement un unique_ptr<string>, n'est-ce pas? Notez ici que :

On utilisera typiquement std::move() sur les entités qui subissent un mouvement, même si ces objets ne sont pas déplaçables. Ce geste un peu mécanique a au moins deux fonctions :

Exemple concret

Imaginez la classe FluxEntree dont une ébauche est proposée ci-dessous.

#include <fstream>
#include <string>
#include <memory>
class FluxEntree {
   std::unique_ptr<std::ifstream> flux;
public:
   FluxEntree(const std::string &nom) : flux{ new std::ifstream{ nom } } {
   }
   // services divers...
};

Ici, il faut que la classe FluxEntree impose un comportement choisi en vue des opérations de copie, puisqu'elle est responsable du pointeur flux qu'elle encapsule (le fait que son destructeur libère ce vers quoi pointe cet attribut en fait foi).

En effet, le comportement par défaut lors d'une copie de FluxEntree serait de dupliquer le pointeur, ce qui aurait pour conséquence de rendre à la fois l'original et la copie responsables de la libération du pointé en question – une situation désastreuse puisque la seconde destruction entraînera un comportement indéfini, ce qui rend le programme implicitement incorrect.

En pratique, cela signifiera habituellement l'une de ces options :

Ainsi, un programme comme celui-ci ne compilerait tout simplement pas (et heureusement!).

#include "FluxEntree.h"
void f(FluxEntree) {
}
int main() {
   FluxEntree fe{"yo.txt"};
   f(fe); // impliquerait une copie, or fe est incopiable
          // donc ce programme ne compilera pas
}

Ce qui est intéressant toutefois est que le problème de fond provient de la responsabilité unique qu'entretient un FluxEntree sur le flux qu'il encapsule. C'est là l'invariant qu'il ne faut pas briser, et auquel une copie contreviendrait.

Un mouvement, par contre, ne briserait pas cet invariant. Si a = b déplaçait les états de b vers a plutôt que de les copier, alors l'invariant de propriété unique sur le flux demeurerait respecté (la responsabilité serait simplement déplacée de b vers a).

Utilité de la sémantique de mouvement

Mais à quoi peut servir une sémantique de mouvement? Pour reprendre l'exemple de FluxEntree plus haut, pourquoi donc y implémenterait-on une sémantique de déplacement?

Un cas simple serait celui d'une fonction de fabrication. Imaginez le code suivant :

#include "FluxEntree.h"
#include <string>
#include <iostream>
using namespace std;
class NomInvalide {};
bool nom_valide(const string&);
FluxEntree creer_flux_entree(const string &nom) {
   if (!nom_valide(nom)) throw NomInvalide{};
   return { nom }; // équivalent à FluxEntree fe{ nom }; return fe;
}
void utiliser_flux_entree(FluxEntree flux) {
   // ...
}
int main() {
   cout << "Nom du fichier à consommer? " << flush;
   if (string nom; cin >> nom)
      try {
         utiliser_flux_entree(creer_flux_entree(nom));
      } catch(NomInvalide&) {
         cerr << "Le fichier " << nom
              << "n'a pu être ouvert en lecture" << endl;
      }
}

La fonction creer_flux_entree() semble légitime; elle valide le nom, puis créer un FluxEntree à partir de ce nom et le retourne. Le code client (ici : main()) relaie ce FluxEntree à une autre fonction, le sachant correct. Jamais, si le nom est jugé valide, n'y a-t-il plus d'un objet devant mener vers le flux ouvert.

On peut permettre ce type de code en implémentant la sémantique de mouvement sur FluxEntree. Voici comment nous y parviendrions :

#include <fstream>
#include <string>
#include <algorithm>
#include <memory>
class FluxEntree {
   std::unique_ptr<std::ifstream> flux;
public:
   FluxEntree(const FluxEntree&) = delete;
   FluxEntree& operator=(const FluxEntree&) = delete;
   FluxEntree(const std::string &nom) : flux{ new std::ifstream{ nom } } {
   }
   FluxEntree(FluxEntree&&) = default;
   FluxEntree &operator=(FluxEntree&&)= default;
   // services divers...
};

Mouvement destructif ou non?

Un débat est encore en cours au moment d'écrire ces lignes, à savoir : le mouvement devrait-il être destructif ou non? On pourrait exprimer cette question autrement, c'est-à-dire : suite à un mouvement, l'objet qui a subi le mouvement (le Moved-From) est-il un objet ou s'agit-il plutôt d'une sorte de coquille vide?

La nuance est importante car elle définit ce qu'il est possible de faire avec l'objet (je devrais écrire « l'objet », avec guillemets) ayant subi le mouvement. Supposons par exemple un type Tableau<T>, sorte de tableau générique dynamique dont les états sont elems (un T* pointant sur le premier élément du tableau), nelems (un entier non-signé représentant le nombre d'éléments du tableau) et cap (un entier non-signé représentant la capacité du tableau) .

L'objet Moved-From reste un objet L'objet Moved-From est une coquille vide

Après un mouvement, l'objet doit maintenir ses invariants. Typiquement, cela signifie que l'objet Moved-From redeviendra une sorte d'objet « par défaut »

Tableau(Tableau &&autre)
   : elems{ autre.elems }, nelems{ autre.nelems }, cap{ autre.cap }
{
   autre.elems = {};
   autre.nelems = {};
   autre.cap = {};
}
Tableau& operator=(Tableau &&autre) {
   // précondition : this != &autre
   delete [] elems;
   elems = autre.elems;
   nelems = stdautre.nelems;
   cap = autre.cap;
   autre.elems = {};
   autre.nelems = {};
   autre.cap = {};
   return *this;
}

Après un mouvement, « l'objet » Moved-From est une entité qui ne garantit plus ses invariants, outre le nécessaire pour que le destructeur puisse s'exécuter correctement.

On pourrait se demander quel est l'intérêt de ne sauver que deux opérations, mais pour le mouvement, qui est une optimisation, passer de six opérations à quatre opérations réduit le temps d'exécution par un facteur de , ce qui n'est pas négligeable

Tableau(Tableau &&autre)
   : elems{ autre.elems }, nelems{ autre.nelems }, cap{ autre.cap }
{
   autre.elems = {};
}
Tableau& operator=(Tableau &&autre) {
   // précondition : this != &autre
   delete [] elems;
   elems = autre.elems;
   nelems = autre.nelems;
   cap = autre.cap;
   return *this;
}

Puisque les invariants d'un objet Moved-From demeurent valides, il est possible de « réinitialiser » cet objet avec une affectation. L'affectation, rappelons-le, remplace typiquement le contenu de l'objet à gauche de l'affectation, ce qui implique qu'il y ait là quelque chose à remplacer. Si l'objet Moved-From maintient ses invariants, alors cette opéation est nécessairement raisonnable.

// ...
void f(Tableau<int>);
Tableau<int> g();
// ...
int main() {
   Tableau<int> tab;
   // ...
   f(std::move(tab)); // devient est Moved-From
   // ...
   tab = g(); // Ok, car tab est un objet correct
   // ...
}
// ...

Puisque les invariants d'un objet Moved-From ne sont plus maintenus, il n'est pas garanti qu'il soit possible de « réinitialiser » cet objet avec une affectation; ici, c'est du cas par cas.

L'option privilégiée pour réutiliser la zone dans laquelle l'objet Moved-From se trouvait est sans doute le new positionnel (Placement-new)

// ...
void f(Tableau<int>);
Tableau<int> g();
// ...
int main() {
   Tableau<int> tab;
   // ...
   f(std::move(tab)); // devient est Moved-From
   // ...
   // tab = g(); // Ok? Dépend du type
   new (&tab) Tableau<int>{g()}; // Ok!
   // ...
}
// ...

Le problème de fond ici est que le standard n'est pas clair sur l'état attendu d'un objet Moved-From. Pour cette raison, il est préférable pour le moment de maintenir les invariants d'un objet Moved-From, juste au cas. Si le portrait se clarifie, éventuellement, il sera toujours temps d'optimiser.

Il est tentant d'optimiser l'affectation de mouvement en présumant que le paramètre ne pourra pas être *this, et en ne prenant pas de précautions en ce sens. Après tout, qui de sain d'esprit écrirait quelque chose comme a = std::move(a) n.'est-ce pas?

Malheureusement, les choses ne sont pas si simples, et des opérations telles que std::swap(a,a) sont possibles en pratique. Pour cette raison, mieux vaut demeurer prudent(e)s.

Les règles depuis C++ 11

La Sainte-Trinité, qu'il importe de connaître en C++ depuis au moins 1998, s'est raffinée avec l'avènement de la sémantique de mouvement, et les règles qu'elle définit se sont clarifiées. Voir Sainte-Trinite.html#resume_pratiques_cles pour plus d'information sur le sujet.

Lectures complémentaires

Le mouvement vu par par son inventeur, Howard Hinnant :

Pour une autre explication (détaillée mais pas toujours très lisible), voir cet intéressant texte de Mikhail Semenov en 2012 : http://www.codeproject.com/Articles/397492/Move-Semantics-and-Perfect-Forwarding-in-Cplusplus

Textes de Scott Meyers sur la question :

Textes d'Andrew Koening portant sur :

Explications complémentaires :

À propos du débat sur le mouvement destructif, quelques échanges très intéressants :

Appliquer std::move() à un objet const peut surprendre. À cet effet, voir le schéma ci-dessous proposé par Ruzena Gurkanyak (source)


Valid XHTML 1.0 Transitional

CSS Valide !