Le constructeur par déplacement

Andrzej Krzemieński

Traducteur

Kurtcpp

Quelques-unes des nouvelles caractéristiques de C++11 - comme la sémantique de déplacement et les références de r-valeurs - sont déjà disponibles dans certains compilateurs et ont déjà été décrites en détail dans un certain nombre d'articles, notamment de Danny Kalev, Dave Abrahams, ou de Howard E. Hinnant, Bjarne Stroustrup & Bronek Kozicki.
Dans ce billet, je compte décrire un seul aspect de ces nouvelles caractéristiques : le constructeur par déplacement. Si vous n'êtes pas familier avec les références de r-valeurs, cela sera un départ en douceur. Si vous l'êtes déjà, cet article pourra tout de même vous être utile, du fait que j'ai tenté d'aborder le sujet sous un angle différent.

NdT : L'article original a été publié en 2011. De nos jours, quasiment tous les compilateurs offrent ces fonctionnalités dans leurs dernières versions.

Commentez

- Titre : Le constructeur par déplacement

- Auteur : Andrzej Krzemieński

- Traducteur : Kurtcpp

- Parution : 9 septembre 2016

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2016 Andrzej Krzemieński. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Le constructeur de copie

- II. La copie qui n'en est pas une

- III. Constructeur par déplacement

- IV. Passer ou renvoyer par valeur (redéfinition)

- V. Plus…

- VI. Remerciements

I - Le constructeur de copie

Tout d'abord, focalisons-nous sur ce bon vieux constructeur par copie. Son objectif est, pour un objet donné, de créer un second objet qui serait égal à l'original, de manière à obtenir deux objets de même valeur à la fin de l'opération. Ceci est coûteux pour les types qui allouent plus de mémoire que ce que retourne l'opérateur sizeof. La mémoire n'est pas la seule ressource qui puisse alourdir l'opération de copie, mais c'est un exemple suffisant pour notre propos. Prenez le cas de la classe-template std::vector. Typiquement, la taille d'un vecteur (lorsqu'elle est mesurée par sizeof) pourrait être celle de trois pointeurs, car trois pointeurs sont suffisants pour représenter un vecteur : l'un indique le début de la mémoire allouée pour le vecteur, un autre indique la fin de cet emplacement mémoire, le troisième indique la portion de cette mémoire qui est réellement utilisée par les éléments du vecteur. Allouer et peupler cette mémoire a pris du temps. Et à présent, si nous voulons que le nouveau vecteur ait la même valeur, nous allons devoir allouer une zone de mémoire similaire et invoquer les constructeurs de copie pour chaque élément du vecteur, qui peuvent eux-mêmes requérir des allocations de mémoire additionnelles. Une telle copie en profondeur peut être coûteuse, mais est acceptable s'il est impératif pour vous d'avoir deux objets avec la même valeur. Cela devient un problème, toutefois, lorsqu'il ne vous faut pas deux copies du même objet, mais que vous êtes tout de même forcé à utiliser le constructeur de copie.

II - La copie qui n'en est pas une

Nous sommes tous familiers avec ces situations problématiques. Elles se produisent lorsque nous voulons qu'une fonction renvoie par valeur un objet de grande taille. Par exemple :

1. vector<string> updateNames(vector<string> ans)

2. {

3. while (hasNewName()) {

4. ans.push_back(getNewName());

5. }

6. return ans;

7. }

8. // ...

9. auto allNames = updateNames(getSavedNames());

La fonction de cet exemple prend ses arguments et retourne par valeur. Si vous n'êtes pas familier avec l'élision de copie, vous pourriez attraper une migraine à imaginer combien de fois le constructeur de copie de std::vector<std::string> est invoqué, malgré le fait qu'à aucun endroit de ce programme nous n'ayons besoin d'avoir deux copies du vecteur peuplé. Le fait est que la fonction updateNames va allouer un emplacement mémoire au vecteur, et la variable allNames a besoin d'être située dans un autre emplacement mémoire. Il nous faut donc créer une copie. Mais l'objet original ne sera plus jamais lu après la copie. Nous n'avons rien gagné à créer une seconde copie ; le problème est qu'il n'existait pas d'autre manière de faire apparaître notre vecteur au nouvel emplacement mémoire.

Si toutefois vous êtes conscient du fait que l'élision de copie peut éliminer les duplicatas superflus, vous pourriez croire à tort que le compilateur ne fera plus de copie. Ce n'est pas le cas. Tout d'abord, bien que les compilateurs soient autorisés à procéder à l'élision de copie, ce n'est pas une obligation pour eux ; et bien que l'élision de copie soit parfois permise, elle n'est pas toujours possible. De plus, j'ai arrangé le code de façon à ce qu'une élision de copie ne soit pas permise : le compilateur n'a pas le droit d'élider la copie d'un argument de fonction passé par valeur (ans, dans l'exemple) vers une variable temporaire renvoyée par la fonction. Mais là encore, nous n'avons pas vraiment besoin de deux copies du vecteur. Nous avons seulement besoin que la valeur de ans soit transmise hors de la fonction.

Ainsi (et quelles que soient les optimisations appliquées), afin de renvoyer la valeur de ans, un constructeur de copie doit être invoqué pour copier la valeur de ans dans un vecteur temporaire, exécutant de ce fait une copie coûteuse qui n'est pas vraiment nécessaire. Si seulement il existait une manière de déplacer la valeur (ainsi que la mémoire allouée) depuis le premier objet vers le second, et de laisser le premier vide - il n'aura de toute façon plus besoin de sa valeur, puisque la seule opération qu'il subira encore sera sa destruction.

III - Constructeur par déplacement

En C++11, il existe une solution pour cela. En plus du constructeur par copie, vous pouvez fournir un constructeur par déplacement pour votre classe. L'objectif d'un constructeur par déplacement est de voler autant de ressources que possible à l'objet original, aussi rapidement que possible, car l'original n'a plus besoin d'avoir une valeur significative, puisque de toute façon il va sous peu être détruit (ou parfois recevoir une nouvelle valeur). Notez que ce vol est assorti de certaines contraintes. Bien que nous puissions voler les ressources, il nous faut laisser l'objet original dans un état où il peut être correctement détruit ou remplacé par une nouvelle valeur (c'est-à-dire sans déclencher de fuites de mémoire ni de comportement indéfini). Comment implémente-t-on un tel vol ? Faisons-le pour un vecteur (rappelez-vous qu'il est implémenté avec trois pointeurs) :

1. template <typename T>

2. class vector {

3. T * begin_;

4. T * end_;

5. T * capacityEnd_;

7. public:

8. // NdT : le noexcept ne figurait pas dans l'article original, voir la note de traduction suivante à ce sujet

9. vector(vector && tmp) noexcept // voici un constructeur par déplacement

10. : begin_(tmp.begin_)  // vol de begin_

11. , end_(tmp.end_)      // vol de end_

12. , capacityEnd_(tmp.capacityEnd_) // vol

13. {

14. tmp.begin_       = nullptr; // remise en état  de tmp

15. tmp.end_         = nullptr; //

16. tmp.capacityEnd_ = nullptr; //

17. }

18. // ...

19. }

C'est la syntaxe basée sur && qui déclare un constructeur par déplacement. Si nous n'avions pas écrit la partie « remise en état », à sa mort, l'objet tmp libèrerait la mémoire dans son destructeur (dont l'appel doit toujours avoir lieu) et corromprait l'état de l'objet nouvellement créé. Nous avons ainsi mis en place le déplacement rapide d'un vecteur (qui pourrait contenir des millions d'éléments potentiellement énormes) avec seulement six affectations de pointeurs. Non seulement cela est rapide, mais de plus notre constructeur par déplacement (contrairement à un constructeur par copie) a la garantie de ne pas déclencher d'exceptions.

NdT : Cette garantie est d'autant plus importante qu'une exception serait plus grave lors d'une opération de déplacement que lors d'une opération de copie : puisque le déplacement est par nature destructif, si une exception était lancée, on se retrouverait dans un état où l'on aurait perdu des données. Ce problème est suffisamment gênant pour que, dans la bibliothèque standard, on préfère souvent copier des objets plutôt que de les déplacer quand on ne peut pas être certains que le déplacement va se passer sans exceptions.

La fonction std::move_if_noexcept est utilisée à cet effet, et regarde si le constructeur de déplacement est déclaré noexcept afin de choisir quel constructeur appeler. Si vous déclarez un constructeur de déplacement et que ce dernier peut être déclaré noexcept (ce qui est généralement le cas), n'oubliez donc pas de le déclarer comme tel, sinon il ne sera pas utilisé partout où il pourrait l'être, ce qui serait dommage.

Comment le compilateur saura-t-il alors quand utiliser le constructeur par déplacement et quand utiliser celui par copie ? Dans la majorité des cas, la réponse est triviale : si le compilateur peut détecter que la source du constructeur par copie ne sera plus utilisée (ni en lecture ni en écriture), mais seulement détruite, il peut choisir le constructeur par déplacement : nul ne sera gêné par le fait que nous altérions un objet qui ne sera plus utilisé.

À quel moment de telles situations ont-elles lieu ?

1. Quand nous construisons un objet par copie (resp. par déplacement) à partir d'un objet temporaire, car de tels objets temporaires ne sont créés que pour être copiés (resp. déplacés) puis détruits, sauf bien entendu si vous étendez artificiellement la durée de vie d'un objet temporaire en le rattachant à une référence.

2. Quand nous renvoyons par valeur un objet de portée automatique, ou un paramètre de fonction capturé par valeur.

3. Quand nous levons par valeur (avec throw) une exception qui est un objet de portée automatique (et que la portée de cet objet ne couvre pas l'emplacement du catch).

4. Quand nous attrapons une exception par valeur sans la propager.

Pour en revenir à notre premier exemple : dans la fonction updateNames, en atteignant l'instruction return, le compilateur peut voir que l'argument de la fonction ne sera plus utile et peut donc voler toutes ses ressources.

Parfois, le compilateur ne peut pas déterminer s'il serait préférable d'utiliser le constructeur par déplacement : dans ces cas, il faut lui fournir une indication. En guise d'exemple d'une telle indication, voici l'implémentation d'une fonction générique swap :

1. template <typename T>

2. void swap(T& x, T& y)

3. {

4. T tmp(std::move(x)); // indication pour utiliser le déplacement

5. x = std::move(y);

6. y = std::move(tmp);

7. }

Ici, à la première ligne, nous donnons au compilateur l'instruction d'utiliser le constructeur par déplacement si possible. Sans cette indication supplémentaire, il devrait utiliser le constructeur par copie : il ne peut risquer d'altérer la valeur de x, qui pourrait encore être utilisée par la suite. Cependant, nous avons la certitude que la valeur de x n'est plus utile, car à la ligne suivante nous voulons lui assigner (par déplacement) une nouvelle valeur. Si la classe T n'offre pas de constructeur par déplacement, le compilateur recourra alors à l'invocation du constructeur par copie. Il faut toutefois rester prudent lorsque l'on force l'usage du constructeur par déplacement. Après ledit déplacement, l'objet x est dans un état valide mais non spécifié. Il ne faut donc pas tenter de l'utiliser. Les seules opérations sûres consistent à le détruire ou à lui affecter une valeur.

Le compilateur va automatiquement générer des constructeurs par déplacement pour vos classes « simples », de même qu'il définit des constructeurs par copie. Le constructeur par déplacement qui est implicitement défini effectue un déplacement superficiel, membre par membre. Une classe « simple », au sens où on l'entend ici, est une classe pour laquelle vous n'avez pas défini de destructeur - si vous l'aviez fait, le constructeur par déplacement serait probablement incorrect, c'est pourquoi le compilateur n'en générera pas. De même, le constructeur par déplacement ne sera pas généré si vous fournissez un constructeur par copie, ou une affectation (par copie ou par déplacement).

IV - Passer ou renvoyer par valeur (redéfinition)

Avec le constructeur par déplacement, nous pouvons revoir ce que signifie passer ou renvoyer par valeur. Les expressions « par valeur » et « par copie » sont parfois interchangeables car, en C++03, le passage par valeur impliquait souvent d'effectuer une copie. En C++11, vous constatez que ceci n'est pas correct, car vous pouvez renvoyer des valeurs (ou passer des arguments) par valeur sans pour autant appeler le constructeur par copie. L'expression « par valeur » signifie simplement que nous ne sommes pas intéressés par l'objet original mais par sa valeur. Nous pouvons obtenir la valeur originale de plusieurs façons : en invoquant le constructeur par copie, en invoquant le constructeur par déplacement ou en n'invoquant aucun des deux, dans le cas où notre compilateur utilise des techniques d'optimisation telles que RVO ou l'élision de copie. Il est à noter que l'élision de copie, si elle est utilisée, améliore la rapidité du programme plus efficacement que les optimisations basées sur la construction par déplacement. Dans l'exemple ci-dessus avec le constructeur par déplacement de vector, il nous fallait recourir à six affectations de pointeurs. De son côté, l'élision de copie ne nécessite qu'une seule instruction. Toutefois, le fonctionnement de l'élision de copie n'est pas toujours garanti. Elle peut ne pas être prise en charge par votre compilateur, elle peut être désactivée à certains niveaux d'optimisation pour accélérer la compilation et, de surcroît, elle n'est même pas applicable dans tous les cas. L'optimisation liée à la construction par déplacement, en revanche, offre la garantie de toujours fonctionner.

De plus, avec le constructeur par déplacement, vous pouvez passer par valeur des objets qui n'ont pas ou ne peuvent pas avoir de constructeur par copie. De telles opérations n'étaient pas possibles en C++03 et peuvent maintenant être utilisées pour appliquer la sémantique de propriété exclusive. Les types basés sur cette sémantique (c.-à-d. qui peuvent être déplacés mais pas copiés) incluent std::unique_ptr, std::thread, std::ostream, std::future et std::unique_lock.

V - Plus…

Intéressé ? Le C++11 ne s'arrête pas là. Il offre l'opérateur d'affectation par déplacement, et un outil plus générique pour intercepter les objets temporaires : les rvalue-references. Suivez simplement les liens au début de ce billet. Vous pouvez également lire mon autre billet qui présente des détails supplémentaires sur les constructeurs par déplacement.

VI - Remerciements

Toute l'équipe de Developpez.com remercie sincèrement Andrzej Krzemieński qui nous a aimablement permis de traduire et de publier son tutoriel sur notre site. Nous tenons également à remercier kurtcpp pour la traduction, JolyLoic pour la relecture technique et f-leb pour la relecture orthographique.