Les move & smart pointers
Les objets ne peuvent pas changer de possesseur : par exemple, si on souhaite passer un std::vector<int>
d'un objet qui n'en a plus besoin à un autre, on est obligé de le copier, ce qui cause des problèmes de performance et ne correspond pas à ce qu'on souhaite faire. De plus, certains objets ne peuvent pas être copiés (comme les std::thread
).
Pour pallier à ce problème, un nouveau type de référence a été introduit, qui connote un objet "consommable" : son contenu peut alors être déplacé, généralement pour créer un autre objet de même type. Il s'agit des move references, ou rvalue references (les références classiques étant appelées des lvalue references) :
Les objets peuvent être construits par move (T::T(T&& moved)
) ou assignés par move (T& operator=(T&& moved)
). Un objet pouvant être move doit, après avoir été move, avoir un destructeur valide (car son destructeur sera appelé) et ré-assignable.
Etant donné qu'un nouvel objet objet est créé, il ne sert à rien de move des objets qui ne transfèrent pas de ressources : move un int
ou un std::array<int, 12>
revient juste à copier l'objet.
Lorsqu'on créé une nouvelle classe, on va généralement chercher à respecter la Rule of Five, qui spécifie que si l'on implémente explicitement une des méthodes spéciales suivantes, alors on doit toutes les implémenter :
Constructeur par copie
Constructeur par move
Affectation par copie
Affectation par move
Destructeur
Les catégories des expressions
Les lvalue references (&) et rvalue references (&&) tiennent leurs noms des types des expressions auxquelles elles correspondent. Il existe 5 types d'expressions :
Les lvalues désignent les objets non temporaires.
Les prvalues désignent les objets temporaires (comme un objet retourné par une fonction). Il s'agit des expressions qui sont acceptées par les rvalue references.
Les xvalues désignent les objets consommables (le x vient de 'expire').
Les glvalues regroupent les lvalues et les xvalues. Il s'agit des expressions qui sont acceptées par les lvalue references.
Les rvalues regroupent les xvalues et les prvalues. Il s'agit des objets qui peuvent être move.
Puisque l'on souhaite plutôt faire correspondre les xvalues aux rvalue references, on les change en prvalues grâce à std::move
.
Les const lvalue references (T const&
) acceptent également les temporaires, puisqu'elles ne modifient pas l'objet reçu. Cela rend le code suivant valide :
std::unique_ptr
Les constructeurs et affectations par move sont utilisés pour permettre à des objets (std::string
, std::vector
, ...) de gérer leurs ressources et de les passer à travers différents scopes. Des pointeurs utilisent ces opérations pour gérer l'objet pointé automatiquement : ils sont appelés smart pointers. La librairie standard met à disposition std::unique_ptr
, std::shared_ptr
et std::weak_ptr
.
std::unique_ptr
va gérer un objet (via un pointeur) en le détruisant dans son propre destructeur. Si on est l'unique propriétaire d'un objet, on va donc le stocker par valeur ou via un std::unique_ptr
. Ce smart pointer ne fait rien d'autre et n'amène donc pas de surcoût par rapport à une gestion manuelle du pointeur.
Il s'agit du choix par défaut lorsque l'on veut stocker un objet qui n'est pas movable (il suffira de move le pointeur lui-même) ou un objet polymorphique (std::unique_ptr
est covariant, et permet l'indirection pour stocker différents types sous la même appellation).
std::unique_ptr
est paramétrisé par le type du function object qui va être exécuté dans le destructeur du pointeur : std::unique_ptr<T, Deleter = std::default_deleter<T>>
. Il est donc possible de faire utiliser un destructeur custom à un std::unique_ptr
.
La fonction template std::make_unique<T>
est préférée au constructeur de std::unique_ptr<T>
pour plusieurs avantages, dont certains ne sont plus valides en C++17. La déduction du type template de std::unique_ptr
ne peut pas se faire via son constructeur, donc std::make_unique
évite de mentionner deux fois le type.
std::shared_ptr et std::weak_ptr
Si l'on n'est pas l'unique propriétaire d'un objet, std::unique_ptr
ne suffit plus, car on souhaite détruire l'objet une fois que tous ses possesseurs sont détruits. std::shared_ptr
remédie à ce problème en utilisant un compteur de référence thread-safe (des std::shared_ptr
pointant sur le même objet peuvent être créés et détruits sur différents threads).
Par rapport à un std::unique_ptr
, Il apporte un surcoût lors de sa création, de sa destruction et lors de la création du control block (stockant notamment le compteur de référence, via une allocation dynamique). De plus, il prend deux fois plus de place : il possède deux pointeurs, pour accéder à l'objet et au compteur de référence.
std::make_shared
comporte les mêmes avantages que std::make_unique
, et apporte de meilleures performances comparé au constructeur de std::shared_ptr
car il alloue l'objet et le control block en une fois.
Un std::shared_ptr
peut être créé ou assigné depuis un std::unique_ptr
, ce qui permet de créer des fonctions retournant des std::unique_ptr
pour tous les usages. Contrairement à celui-ci, le destructeur de std::shared_ptr
n'est pas stocké dans son type mais dans le control block.
std::weak_ptr
est un pointeur ne prenant pas part au comptage de référence : il est donc possible qu'il pointe vers un objet déjà supprimé. Pour l'utiliser, on doit le changer en std::shared_ptr
afin d'être sûr que l'objet ne se fera pas détruire pendant son utilisation. Il est utile notamment pour régler les problèmes de références cycliques.
Les forwarding references
Lorsqu'un argument template est de type T&&
avec T
un paramètre template, l'argument n'est pas une rvalue reference (malgré son écriture) mais une forwarding reference. Cela permet de capturer des glvalues (auquel cas T
sera déduit en ValueType&
) et des prvalues (auquel cas T
sera égal à ValueType
).
Il faut que T soit un paramètre template de la fonction, pour qu'il ne soit pas déduit autre part :
Ces références sont généralement utilisées avec std::forward<T>(t)
, qui va move la référence uniquement si elle désigne une rvalue. Sinon, la fonction renvoie simplement la référence.
Les forwarding references sont utiles mais peuvent prêter à confusion : est-ce que T
désigne un int
? un int&
? un int const&&
? Il faut donc faire attention aux types manipulés afin d'éviter des copies inattendues, qui peuvent passer inaperçues (un type copiable ne fera pas mal fonctionner le programme, mais le fera ralentir).
Pour aller plus loin
Avant C++11 (apportant les move et les rvalue references), les objets étaient move en implémentant une fonction swap(T& lhs, T& rhs)
qui échange le contenu de deux objets. Cette pratique est encore utile pour des raisons d'exception safety.
Il ne faut pas move les types retournés par les fonctions en pensant optimiser la fonction (en évitant une copie) car la Return Value Optimisation (RVO, garantie en C++17) assure que l'objet, lorsqu'il est temporaire, n'est construit qu'une seule fois : il ne sera donc pas move ou copié. De plus, les compilateurs peuvent faire la même optimisation pour des variables non temporaires (Named Return Value Optimisation) et vont move l'objet si il ne peut pas être construit du côté de l'appelant de la fonction.
Attention au phénomène de reference collapsing lors de la manipulation de types template.
Des explication plus précises des catégories d'expressions existent.
Lorsqu'on souhaite accepter un objet qui peut être consommé ou copié, 3 alternatives existent :
Dédoubler la fonction pour accepter l'objet via une rvalue reference ou une lvalue reference.
Passer l'objet par valeur, puis le move dans la fonction. Cela coûte un move en plus par rapport à la première solution, mais évite de dédoubler la fonction.
Rendre la fonction template pour recevoir l'objet avec une forwarding reference. On a les performances de la première solution sans dédoubler la fonction, mais on a un paramètre template (pouvant initialement accepter n'importe quel type).
Les classes peuvent avoir des ref-qualifiers, ce qui contraindra la méthode à être appelée sur un temporaire ou un non-temporaire.
std::shared_ptr
ne peut pas recevoir de destructeur custom via std::make_shared
. Cependant, on peut utiliser std::allocate_shared
pour quand même bénéficier des performances de std::make_shared
.
Challenges
(*) Ecrire force_move(value)
qui va cast l'objet en prvalue (comme std::move
) ou lancer une erreur à la compilation si l'objet est const. Exemple :
(***) Ecrire une classe template unique_function<T(Args...)>
qui permet de manipuler n'importe quel function object pouvant être move, de manière similaire à std::function<T(Args...)>
. Exemple :
Bonus : ajouter à unique_function
la Small Buffer Optimisation (SBO).
Last updated