🔝 Retour au Sommaire
La sémantique de mouvement est l'une des innovations les plus importantes introduites par C++11. Elle a transformé la manière dont le langage gère le transfert de données, en ajoutant une distinction fondamentale : quand un objet n'est plus nécessaire après une opération, le C++ peut désormais voler ses ressources plutôt que les copier, puis détruire l'original devenu inutile.
Vous avez déjà croisé cette mécanique au chapitre précédent : std::unique_ptr interdit la copie mais autorise le déplacement via std::move, et c'est précisément la sémantique de mouvement qui rend cela possible. Ce chapitre vous donne les fondations théoriques et pratiques complètes du mécanisme.
À la fin de ce chapitre, vous serez capable de :
- Distinguer les lvalues et les rvalues, et comprendre pourquoi cette distinction est au cœur du mouvement.
- Comprendre ce que fait réellement
std::move(et ce qu'il ne fait pas). - Implémenter des constructeurs de déplacement et des opérateurs d'affectation par déplacement pour vos propres classes.
- Utiliser
std::forwardpour le perfect forwarding dans les fonctions templates. - Comprendre les optimisations du compilateur (RVO, NRVO, copy elision) et leur interaction avec la sémantique de mouvement.
Ce chapitre s'appuie sur plusieurs concepts que vous devez maîtriser :
- Constructeurs et destructeurs (section 6.2) — Vous devez comprendre les constructeurs de copie, les listes d'initialisation, et le cycle de vie des objets.
- La Règle des 5 (section 6.5) — Le mouvement est deux des cinq opérations spéciales. Vous devez savoir pourquoi elles forment un ensemble cohérent.
- Pointeurs et références (sections 4.3 et 5.3) — La sémantique de mouvement repose sur un nouveau type de référence (
&&). - Smart pointers (chapitre 9) — Vous avez déjà utilisé
std::moveavecunique_ptr. Ce chapitre explique le mécanisme sous-jacent.
Pour comprendre pourquoi la sémantique de mouvement existe, il faut d'abord mesurer ce qu'elle remplace : la copie profonde.
Considérons une classe qui gère un buffer dynamique :
class Buffer {
size_t taille_;
char* data_;
public:
Buffer(size_t taille) : taille_(taille), data_(new char[taille]) {
std::print("[Buffer] Allocation de {} octets\n", taille_);
}
// Constructeur de copie — copie profonde
Buffer(const Buffer& other) : taille_(other.taille_), data_(new char[other.taille_]) {
std::memcpy(data_, other.data_, taille_);
std::print("[Buffer] Copie de {} octets\n", taille_);
}
~Buffer() {
delete[] data_;
std::print("[Buffer] Libération de {} octets\n", taille_);
}
};Chaque copie alloue un nouveau bloc mémoire et duplique l'intégralité des données. Pour un buffer de 100 Mo, c'est 100 Mo alloués et 100 Mo copiés — à chaque fois.
Maintenant, observons ce qui se passe quand une fonction retourne un Buffer (dans un scénario sans optimisation du compilateur) :
Buffer creer_buffer() {
Buffer local(1024 * 1024); // 1 Mo — allocation
// ... remplissage ...
return local; // Copie vers la valeur de retour — 1 Mo copié
} // local détruit — 1 Mo libéré
void exemple() {
Buffer b = creer_buffer(); // Copie vers b — 1 Mo copié (encore)
} // b détruit — 1 Mo libéréSans optimisation, ce code effectue trois allocations et deux copies d'un mégaoctet pour un seul buffer utile. L'objet local est créé, copié, puis immédiatement détruit. La copie intermédiaire est créée, copiée dans b, puis immédiatement détruite. Tout ce travail est gaspillé.
Le problème est que le compilateur (avant C++11) n'avait aucun moyen standardisé de dire : « cet objet va être détruit juste après, autant prendre directement ses ressources plutôt que les copier ».
La sémantique de mouvement ajoute exactement cette capacité. Quand un objet est temporaire ou marqué comme déplaçable, au lieu de copier ses données, le nouvel objet peut s'emparer de ses ressources internes et laisser l'ancien dans un état vide mais valide :
class Buffer {
size_t taille_;
char* data_;
public:
// ... constructeur et constructeur de copie inchangés ...
// Constructeur de déplacement — vole les ressources
Buffer(Buffer&& other) noexcept
: taille_(other.taille_), data_(other.data_)
{
other.taille_ = 0;
other.data_ = nullptr; // L'ancien objet est vidé
std::print("[Buffer] Déplacement de {} octets (coût : ~0)\n", taille_);
}
~Buffer() {
delete[] data_; // delete nullptr est un no-op — sûr
}
};Le constructeur de déplacement ne copie aucune donnée. Il copie deux valeurs scalaires (un size_t et un pointeur — 16 octets sur x86_64), puis met l'objet source à zéro. Pour un buffer de 100 Mo, le déplacement coûte exactement autant que pour un buffer de 16 octets : quasiment rien.
Copie profonde (ancien monde) :
source destination
┌──────────┐ ┌──────────┐
│ data_ ───┼──> [ABCDEF] │ data_ ───┼──> [ABCDEF] ← nouveau bloc, copie octet par octet
│ taille_ │ │ taille_ │
└──────────┘ └──────────┘
(source inchangée)
Déplacement (C++11) :
source destination
┌──────────┐ ┌──────────┐
│ data_ ───┼──> nullptr │ data_ ───┼──> [ABCDEF] ← même bloc, juste le pointeur transféré
│ taille_ │ 0 │ taille_ │
└──────────┘ └──────────┘
(source vidée)
Pour que le compilateur sache quand il peut déplacer plutôt que copier, il a besoin d'un critère. Ce critère est la catégorie de valeur de l'expression :
-
Une lvalue (« left value ») est une expression qui désigne un objet avec un emplacement mémoire persistant et un nom. Exemple : une variable, un élément d'un tableau, le résultat d'un déréférencement. L'objet pourrait être utilisé après l'opération — le copier est la seule option sûre.
-
Une rvalue (« right value ») est une expression qui désigne un objet temporaire ou un objet dont le propriétaire a signalé qu'il n'en a plus besoin. Exemple : le résultat d'un appel de fonction, un littéral, ou le résultat de
std::move(). L'objet va disparaître — ses ressources peuvent être volées sans risque.
Buffer a(1024); // a est une lvalue — elle a un nom, elle persiste
Buffer b = a; // a est une lvalue → copie (on ne peut pas voler a)
Buffer c = Buffer(1024); // Buffer(1024) est une rvalue → déplacement possible
Buffer d = std::move(a); // std::move(a) convertit a en rvalue → déplacement
// a est maintenant dans un état vidéstd::move ne déplace rien. C'est un cast : il convertit une lvalue en rvalue, donnant la permission au compilateur (et aux constructeurs/opérateurs) de traiter l'objet comme déplaçable. Le déplacement réel est effectué par le constructeur de déplacement ou l'opérateur d'affectation par déplacement.
La sémantique de mouvement ne concerne pas uniquement les classes que vous écrivez. Elle imprègne tout le C++ moderne :
La STL entière en bénéficie. Les conteneurs (std::vector, std::string, std::map…) implémentent tous des constructeurs et opérateurs de déplacement. Un std::vector de 10 millions d'éléments se déplace en copiant trois pointeurs internes — pas en dupliquant 10 millions d'éléments.
std::vector<int> v(10'000'000, 42);
// Copie : alloue 40 Mo, copie 10 millions d'entiers
std::vector<int> copie = v;
// Déplacement : copie 3 pointeurs (~24 octets), v devient vide
std::vector<int> deplace = std::move(v);Les smart pointers en dépendent. std::unique_ptr est un type move-only — sa copie est supprimée, seul le déplacement existe. C'est la sémantique de mouvement qui rend possible le concept de possession exclusive transférable (section 9.1.2).
Le retour de valeurs en profite. Les fonctions qui retournent des objets lourds par valeur — un pattern qui était coûteux avant C++11 — sont désormais efficaces grâce au déplacement (et encore plus grâce au RVO, voir section 10.5).
Les conteneurs deviennent plus efficaces. Quand un std::vector réalloue son buffer interne (parce que sa capacité est dépassée), il déplace ses éléments vers le nouveau buffer au lieu de les copier — à condition que le constructeur de déplacement soit noexcept.
Un point revient sans cesse quand on parle de sémantique de mouvement : le mot-clé noexcept. Les constructeurs de déplacement et les opérateurs d'affectation par déplacement doivent être marqués noexcept chaque fois que c'est possible.
La raison est pratique : la STL utilise std::move_if_noexcept dans ses opérations internes. Si votre constructeur de déplacement n'est pas noexcept, les conteneurs reviendront à la copie par sécurité, annulant tout le bénéfice du mouvement :
class Widget {
public:
// ✅ noexcept → vector utilisera le déplacement lors des réallocations
Widget(Widget&& other) noexcept;
// ⚠️ Sans noexcept → vector copiera au lieu de déplacer
// Widget(Widget&& other);
};Ce sujet est approfondi en section 10.3.
Ce chapitre est organisé en cinq sections qui construisent progressivement la maîtrise de la sémantique de mouvement :
10.1 — L-values vs R-values (&&)
Les catégories de valeurs en C++ : lvalue, rvalue, et les sous-catégories (prvalue, xvalue, glvalue). Les références rvalue (T&&) et leur rôle dans la surcharge de fonctions.
10.2 — std::move : Transfert de propriété sans copie
Ce que std::move fait réellement (un cast, pas un déplacement), quand l'utiliser, et les pièges courants (utiliser un objet après move, move sur des objets const).
10.3 — Move constructors et move assignment operators
Comment implémenter le constructeur de déplacement et l'opérateur d'affectation par déplacement pour vos propres classes. L'importance de noexcept. L'interaction avec la Règle des 5.
10.4 — Perfect Forwarding avec std::forward
Les forwarding references (T&& dans un contexte template), le problème de la perte de catégorie de valeur, et comment std::forward le résout. Indispensable pour écrire des fonctions template génériques comme make_unique.
10.5 — Return Value Optimization (RVO) et Copy Elision
Les optimisations du compilateur qui éliminent les copies et les déplacements avant même qu'ils ne se produisent. NRVO, RVO, et la mandatory copy elision de C++17. Pourquoi return std::move(x) est presque toujours une erreur.
- Standard minimum : C++17. Les exemples compilent avec
-std=c++17. Les comportements spécifiques à C++11/14 sont signalés quand ils diffèrent. - Compilateurs : GCC 15 et Clang 20 sur Ubuntu, conformément à l'environnement de la formation.
- Les annotations
// lvalueet// rvaluedans les exemples indiquent la catégorie de valeur d'une expression. - Les annotations
// copieet// moveindiquent quelle opération est invoquée par le compilateur.
En résumé — Avant C++11, le C++ n'avait qu'un seul mécanisme de transfert de données : la copie. La sémantique de mouvement ajoute un second mécanisme, drastiquement plus efficace, pour les situations où l'objet source n'est plus nécessaire. Comprendre quand et comment le compilateur choisit entre copie et déplacement est une compétence fondamentale du développeur C++ moderne.