C++17 : redéfinition des r-values

Le changement du langage le plus significatif apporté par le C++17

Andrzej Krzemieński

Traducteur

Kalith

Dans ce billet, j’explore ce que je considère comme étant le changement le plus significatif apporté au langage C++ par la dernière norme, C++17. Je le considère comme le plus significatif, car il transforme la façon dont vous concevez vos types de gestion de ressources, et la façon dont vous pensez à la phase d’initialisation. Ce changement est souvent appelé « omission de copie garantie » (NDT : « guaranteed copy elision »), mais (à l’exception de cette phrase) je ne vais pas utiliser ce nom, car il ne reflète pas vraiment l’essence de cette nouvelle fonctionnalité. En effet, de mon point de vue, une meilleure façon de considérer cette fonctionnalité est de se rendre compte que le C++ a complètement changé le sens des r-values (ou pour être précis, des pr-values).

Commentez

- Titre : C++17 : redéfinition des r-values

- Auteur : Andrzej Krzemieński

- Traducteur : Kalith

- Parution : 7 mars 2021

- 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 ® 2021 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. Avant C++17

- II. Après C++17

- III. Plus qu’un simple retour par valeur

- IV. Déplacement destructif

- V. Remerciements

I - Avant C++17

En C++98, la définition suivante

1. X f()

2. {

3. return X(0);

4. }

crée, à l’intérieur de la fonction, un objet temporaire de type X. Cet objet est ensuite utilisé pour initialiser, par copie, un autre objet temporaire qui va survivre après la fin de la fonction. En dehors de la fonction, cet objet temporaire restant peut être lui-même utilisé pour initialiser, encore par copie, l’objet de destination. Le compilateur est autorisé à omettre toutes ces copies, et à transcrire le code comme si les trois objets mentionnés ci-dessus n’étaient finalement qu’un seul et même objet. Mais, conceptuellement, les objets temporaires et les copies sont bel et bien là, comme on peut l’observer si l’on déclare le constructeur de copie de X comme étant privé.

En C++11, le constructeur par déplacement change la donne, car il vous permet d’avoir quelque chose qui ressemble suffisamment à une copie pour permettre de transférer les entrailles d’un objet temporaire dans un autre objet, tout en n’ayant pas besoin de créer un clone de cet état : il modifie l’objet temporaire d’origine, qui se retrouve alors dans un nouvel état « vide », sans ressource associée. Mais cette solution a un coût.

Pour commencer, les objets temporaires sont toujours là. Quand bien même les déplacements (comme les copies) peuvent être omis par le compilateur, ces déplacements et les objets temporaires associés sont conceptuellement toujours présents : là encore, il suffit de supprimer le constructeur par déplacement avec = delete pour s’en convaincre ; le retour par valeur ne compilera plus.

De plus, bien que les déplacements soient souvent bien plus rapides que les copies, il faut tout de même prendre le temps de construire l’état « vide » que l’on va attribuer aux objets sources. Et, parfois, il n’est pas possible pour le compilateur d’omettre ces déplacements.

Enfin, utiliser les déplacements impose l’existence d’un état « vide », ce qui affaiblit les invariants de classe, tel que décrit dans cet autre billet. Un objet d’un type sans sémantique de déplacement devrait toujours représenter un état dont les ressources ont été acquises (une « session »), tandis qu’un objet d’un type avec sémantique de déplacement peut également représenter un état « vide » sans session, et il nous incombe alors de vérifier si une session existe ou non avant d’utiliser un tel objet.

Dans une certaine mesure, il est possible de contourner le problème en bidouillant un peu. Par exemple, en C++11 il est possible de retourner un mutex par valeur. En principe, le typestd::mutex ne dispose pas de la sémantique de mouvement ; en effet, la partie la plus importante d’un mutex est son adresse en mémoire, et on ne peut pas la conserver dans un déplacement. En revanche, il est possible d’écrire ceci :

1. std::mutex make_mutex()

2. {

3. return {};

4. }

6. std::mutex&& m = make_mutex();

À la ligne 3, on n’a pas un retour avec un objet, mais un retour avec initialisation. La syntaxe{} ne correspond pas à la création d'un temporaire, elle indique seulement la manière d’initialiser l’objet temporaire qui va exister en dehors de la fonction. Ensuite, à la ligne 6, on n’initialise pas un objet, mais une référence r-value : cette référence peut être rattachée à un temporaire, mais elle est elle-même une l-value. La référence permet aussi d’étendre la durée de vie du temporaire. En pratique, ce code est donc presque équivalent à déclarer m comme un objet.

Mais cela reste de la bidouille dont l’intérêt est limité.

II - Après C++17

Le C++17 étend l’astuce décrite ci-dessus pour en faire une solution élégante. Maintenant, la première fonction que l’on a présentée dans ce billet a un tout autre sens :

1. X f()

2. {

3. return X(0);

4. }

Un objet va être créé, avec 0 pour argument, mais on ne sait pas encore clairement de quel objet il s’agit. La fonction ne retourne pas un temporaire. Elle ne crée aucun temporaire au cours de son exécution. Elle retourne simplement une « recette » qui indique comment l’objet final (en dehors de la fonction) doit être construit. Et si on l’invoque comme suit :

1. X x = f();

ce sera l’objet x qui sera créé avec cette recette, et c’est le seul et unique objet de type X qui sera créé. L’appel est alors équivalent à :

1. X x(0);

Il n’y a aucun objet temporaire impliqué dans cette expression. Le type n’a pas besoin de disposer de la sémantique de mouvement. Il est alors possible de retourner un mutex comme suit :

1. std::mutex make_mutex()

2. {

3. return std::mutex{};

4. }

6. std::mutex m = make_mutex();

De fait, il est maintenant possible de retourner par valeur des objets qui n’ont pas la sémantique de déplacement. Ou, inversement, vous pouvez déclarer vos types sans sémantique de déplacement tout en préservant la possibilité de les retourner par valeur dans certains cas. Pourquoi voudrait-on faire une chose pareille ? Principalement pour renforcer les invariants de nos classes de gestion de ressources.

Considérons l’exemple donné dans cet autre billet d’une classe de gestion de socket (NDT Maîtriser le concept de socket n’est pas nécessaire pour suivre la discussion ; sachez seulement qu’un socket valide représente une « session » ouverte avec un autre processus, potentiellement sur un autre ordinateur, et que l’on peut utiliser pour échanger des données). Une implémentation autour des sockets Unix qui implémente la sémantique de déplacement pourrait ressembler à ça :

1. #include <sys/socket.h> // entête Linux

2. #include <unistd.h>     // entête Linux

3. #include <stdexcept>

5. class Socket

6. {

7. int socket_id;

9. public:

10. explicit Socket()

11. : socket_id{ socket(AF_INET, SOCK_STREAM, 0) }

12. {

13. if (socket_id < 0)

14. throw std::runtime_error{translate(socket_id)};

15. }

17. Socket(Socket&& r) noexcept

18. : socket_id{ std::exchange(r.socket_id, -1) }

19. {}

21. bool is_valid() const { return socket_id != -1; }

23. // invariant de classe : !is_valid() || id() >= 0

25. ~Socket()

26. {

27. if (is_valid())

28. close(socket_id);

29. }

31. int id() const { return socket_id; }

32. // pré-condition :  is_valid()

33. // post-condition : return >= 0

35. Socket(Socket const&) = delete;

36. };

Dans le constructeur par déplacement (ligne 17), on transfère la ressource (socket_id) de l’objet source vers l’objet destination, et l’on doit donner quelque chose en échange à l’objet source qui lui permet de savoir qu’il ne représente plus une « session » avec ressources : on lui assigne la valeur -1. Avec cette implémentation, les objets de type Socket ont la possibilité de représenter une session ou pas, et il est donc nécessaire d’introduire une fonction observatrice (ligne 21) qui peut nous dire dans quel état un socket se trouve. L’invariant (ligne 23) est faible : la durée de vie de l’objet n’est pas identique à la durée de vie d’une session. De fait, toutes les fonctions doivent prendre ce fait en considération et proposer une implémentation valide dans le cas où l’objet ne représente pas une session, voir par exemple les lignes 27 et 32. Dans le destructeur, il y a un if. Dans la fonction id() il y a une précondition : la fonction nous fait confiance pour ne jamais être appelée sur un objet sans session, mais il y a toujours un risque pour que nous le fassions quand même.

En revanche, si l’on retire la sémantique de déplacement, l’implémentation de la classe est plus simple et moins sujette aux bogues :

1. class Socket

2. {

3. int socket_id;

5. public:

6. explicit Socket()

7. : socket_id{ socket(AF_INET, SOCK_STREAM, 0) }

8. {

9. if (socket_id < 0)

10. throw std::runtime_error{translate(socket_id)};

11. }

13. Socket(Socket&& r) = delete;

15. // invariant de classe : id() >= 0

17. ~Socket()

18. {

19. close(socket_id);

20. }

22. int id() const { return socket_id; }

23. // post-condition : return >= 0

24. };

Le constructeur par déplacement a disparu : il n’est plus possible d’avoir une valeur -1. L’invariant de la classe est maintenant fort : si vous avez accès à l’objet, la session est ouverte ; toujours. Il est inutile de le vérifier dans le destructeur, et la précondition sur id() n’existe plus, car il est tout simplement impossible de l’appeler sur un objet sans session valide. Jusqu’à présent, pratiquement personne ne concevait ses classes de gestion de ressources de la sorte, car les objets de ces classes ne pouvaient pas être retournés par valeur. Mais maintenant avec le C++17, on peut le faire !

Cependant, ce mécanisme ne permet pas de retourner des objets de type non déplaçable par toutes les fonctions fabrique. Il est possible d’initialiser et de retourner une instance de notre nouvelle classe Socket comme suit :

1. Socket make_socket()

2. {

3. return Socket{};

4. }

Mais il n’est pas possible de faire ceci :

1. Socket make_socket()

2. {

3. Socket s {};

4. prepare_socket(s);

5. return s;

6. }

En effet, ce nouveau mécanisme ne fonctionne qu’avec des pr-value. Pour simplifier, une pr-value est équivalente à une r-value telle que définie au sens C++03 : typiquement, soit une expression littérale, soit un appel à une fonction retournant une valeur, soit le nom d’un type suivi de parenthèses ou crochets avec arguments (une « recette », comme décrit plus haut). Comme une « recette » constante est identique à une recette non constante, il est possible d’initialiser un objet constant à partir d’une pr-value non constante, et vice versa. Par exemple :

1. Socket make_socket()

2. {

3. return Socket{};

4. }

6. const Socket new_socket()

7. {

8. return make_socket(); // appel de function retournant par valeur,

9. }                         // c'est toujours une pr-value

11. Socket s = new_socket();

Il est également possible de retourner plus d’une recette dans une même fonction :

1. const Socket select_socket(bool cond)

2. {

3. if (cond) return Socket{};

4. return make_socket();

5. }

Pour ceux d’entre vous qui se demandent comment un compilateur peut implémenter ça : lors de l’appel à la fonction select_socket(), le compilateur lui fournit un pointeur supplémentaire qui indique l’emplacement en mémoire du socket qui doit être créé, et initialise cet objet à cet emplacement en utilisant la recette choisie. Quiconque appelle select_socket() pour initialiser son objet passera l’adresse de cet objet en devenir à la fonction select_object().

Une recette peut être transférée d’une fonction à une autre, comme dans les exemples ci-dessus, mais finalement il y aura toujours un objet qui se retrouvera initialisé par la recette. Si vous ne fournissez pas d’objet explicitement, un objet temporaire sera automatiquement créé, comme dans les cas suivants :

1. int main()

2. {

3. make_socket();

4. }

6. int main()

7. {

8. return make_socket().id();

9. }

III - Plus qu’un simple retour par valeur

Cette nouvelle fonctionnalité, que l’on pourrait appeler « pr-values sans temporaires », peut être utilisée pour résoudre un autre problème : l’initialisation conditionnelle. Pour illustrer le problème, il nous faut changer notre classe Socket une fois de plus. Comme il est maintenant possible de retourner des sockets par valeur sans utiliser le constructeur de déplacement, au lieu de fournir un constructeur public, nous allons plutôt fournir des fonctions « usines » (NDT « factory functions ») :

1. class Socket

2. {

3. private:

4. explicit Socket(int AddressFamily);

5. Socket() = delete;

6. Socket(Socket&&) = delete;

8. public:

9. static Socket make_inet() { return Socket{AF_INET}; }

10. static Socket make_unix() { return Socket{AF_UNIX}; }

12. // ...

13. };

Cette approche est supérieure à l’utilisation de constructeurs, car elle nous permet d’exprimer notre intention de manière plus explicite (deux fonctions avec des noms différents et exactement les mêmes paramètres – ici aucun, plutôt qu’un seul constructeur avec un entier comme paramètre). Maintenant, supposons que l’on souhaite utiliser notre nouvelle classe Socketdans une autre classe Client :

1. struct Params

2. {

3. bool isUnixDomain;

5. // ...

6. };

8. class Client

9. {

10. Socket _socket;

12. public:

13. explicit Client (Params params);

14. };

Selon la valeur du paramètre isUnixDomain, on va utiliser une fonction ou l’autre :

1. Client::Client(Params params)

2. : _socket(params.isUnixDomain ? Socket::make_unix()

3. : Socket::make_inet())

4. {}

Et ça fonctionne : aucun constructeur par déplacement n’est nécessaire, un seul et unique objet est initialisé, _socket. Cette syntaxe était valide avant C++17, en revanche elle rendait obligatoire l’exécution d’un déplacement.

Malheureusement, bien que cette nouvelle fonctionnalité marche pour initialiser des sous-objets membres de la classe, le standard n’est pas clair sur le fait de pouvoir utiliser ou non ce mécanisme pour initialiser des classes de base et pour les constructeurs délégués. GCC accepte de le faire pour les constructeurs délégués, mais il se pourrait que ça ne soit pas portable.

Et si l’on souhaitait construire nos sockets sur place (emplace) dans un std::vector ? Ça ne fonctionnerait pas, car ajouter un nouvel élément pourrait requérir d’augmenter la mémoire utilisée par le vector, ce qui impliquerait de déplacer les éléments qu’il contient. Mais si on utilise un autre conteneur qui n’a pas la possibilité d’augmenter sa taille de la sorte ? Essayons d’implémenter notre propre conteneur : une version simplifiée de std::optional où l’on fournit un espace de stockage brut pour un objet de type T. Par défaut, aucun objet n’est alloué, et on peut par la suite construire un nouvel objet sur place dans l’espace de stockage préalloué :

1. template <typename T>

2. class Opt

3. {

4. std::aligned_storage_t<sizeof(T), alignof(T)> _storage;

5. bool _initialized = false;

7. void* address () { return &_storage; }

8. T* pointer() { return static_cast<T*>(address()); }

10. public:

12. Opt() = default;

13. Opt(Opt&&) = delete;

14. ~Opt() { if (_initialized) pointer()->T::~T(); }

16. template <typename... Args>

17. void emplace(Args&&... args)

18. {

19. assert (!_initialized);

20. new (address()) T(std::forward<Args>(args)...);

21. _initialized = true;

22. }

23. };

Comme on souhaite pouvoir stocker des objets qui n’ont pas de sémantique de déplacement, on rend Opt explicitement non déplaçable. La construction sur place (ligne 20) fonctionne bien aussi sans créer de temporaires quand on lui passe une pr-value. En revanche, la fonction emplace()elle-même prend ses arguments par référence, ce qui implique la création d’un temporaire suivie d’un déplacement. Par conséquent, le code suivant ne fonctionne pas :

1. Opt<Socket> os;

2. os.emplace(Socket::make_inet()); // erreur

Il est possible de contourner le problème en créant un temporaire d’un type différent de Socket, avec un opérateur de conversion vers Socket qui va permettre de créer une pr-value directement lors de la construction sur place. Voici comment implémenter un tel type :

1. template <typename F>

2. class rvalue

3. {

4. F fun;

5. public:

6. using T = std::invoke_result_t<F>;

7. explicit rvalue(F f) : fun(std::move(f)) {}

8. operator T () { return fun(); }

9. };

La métafonction std::invoke_result_t remplace std::result_of en C++17, et l’expression std::invoke_result_t<F> produit le type de retour de l’objet fonction F appelée sans argument. Avec cet outil à notre disposition, il est possible de créer un Socket sur place dans notre conteneur comme suit :

1. Opt<Socket> os;

2. os.emplace(rvalue{&Socket::make_inet});

Laissez-moi vous expliquer. Tout d’abord, on crée un objet de type rvalue<F>. Le paramètre template F est automatiquement déduit à partir de l’argument du constructeur. C’est une autre fonctionnalité nouvelle du C++17, appelée déduction des arguments templates d’une classe. À ce stade, l’objet temporaire rvalue ne stocke qu’un pointeur vers une fonction. On peut se permettre de créer des temporaires de ce type, car ils sont très légers et facilement déplaçables. Ce n’est qu’à l’intérieur de la fonction emplace() que cet objet temporaire est converti en Socket, et c’est seulement à cet instant que l’on appelle la fonction usine Socket::make_inet() pour produire une pr-value, qui sera elle-même utilisée pour l’initialisation en place dans l’espace de stockage brut de l’objet Opt.

Dans cet exemple, il est possible d’utiliser un simple pointeur sur la fonction, car celle-ci ne prend aucun paramètre. Mais en général, on utilisera plutôt une fermeture (« lambda ») qui permet plus de possibilités :

1. Opt<Socket> os;

2. os.emplace(rvalue{[&]{ return Socket::make_inet(); }});

IV - Déplacement destructif

Mais ce n’est pas tout. Comme écrit plus haut, pour des objets sans sémantique de déplacement, il est impossible d’utiliser la construction sur place dans un vector, car cela pourrait mener à une réallocation de la mémoire et à une série de déplacements. En revanche, ces déplacements pourraient néanmoins être possibles si les objets avaient la possibilité d’exécuter un « déplacement destructif », dans lequel on ne se soucie pas de l’état dans lequel est laissé l’objet à la source du déplacement. Avec les pr-values du C++17, il est possible d’implémenter un tel déplacement destructif sans changer le langage.

Pour ce faire, on va spécifier que tout type T pour lequel on souhaite implémenter un déplacement destructif possède la fonction suivante, qui peut éventuellement être trouvée par ADL (NDT : « Argument Dependent Lookup ») :

1. T destructive_move(T& old) noexcept;

Du point de vue de l’implémentation, le mécanisme est très similaire à celui utilisé pour swap : si vous voulez que les objets d’un type T puissent être échangés efficacement, c’est à vous de proposer une surcharge de la fonction swap() pour ce type.

La sémantique du déplacement destructif est la suivante : une fois cette fonction invoquée sur un objet, cet objet est considéré comme étant détruit, le destructeur aura déjà été invoqué à l’intérieur de la fonction et ne doit pas être appelé une nouvelle fois, et une pr-value (« recette pour créer un objet ») est retournée par la fonction.

À la différence du constructeur par déplacement qui peut potentiellement lever une exception, une opération de déplacement destructif ne devrait jamais le faire ; d’où la présence du noexcept dans la signature de destructive_move().

La spécification ci-dessus, où l’on indique que le destructeur ne doit pas être appelé une seconde fois pour l’objet détruit, ne peut être satisfaite que si la fonction est utilisée par des types « conteneurs » qui gèrent la durée de vie des objets manuellement. C’est le cas, par exemple, pour notre type Opt. Pour illustrer le concept, ajoutons une fonction eject() à cette classe. Cette fonction va retourner l’objet contenu par valeur, et va rendre l’objet Opt vide :

1. template <typename T>

2. class Opt

3. {

4. // ...

6. public:

7. // ...

9. T eject()

10. {

11. assert (_initialized);

12. _initialized = false;

13. return destructive_move(*pointer());

14. }

15. };

La fonction eject() retourne une pr-value, une recette. Elle marque l’objet Opt comme ne contenant plus rien à l’issue de l’appel. Le destructeur de l’objet T n’est pas invoqué ; on considère que l’implémentation de destructive_move() fait tout ce qu’il faut pour que cet objet soit effectivement détruit. Une fois la fonction invoquée, l’objet contenu est arrivé à la fin de sa vie.

À quoi pourrait ressembler l’implémentation de destructive_move() pour notre classe de Socket ? Jetons un œil à la nouvelle implémentation de la classe :

1. class Socket

2. {

3. int socket_id;

4. // invariant de classe : id() >= 0

6. struct destructive_t {}; // pour indiquer un constructeur

7. // particulier

9. explicit Socket(int AddressFamily)

10. : socket_id{ socket(AddressFamily, SOCK_STREAM, 0) }

11. {

12. if (socket_id < 0)

13. throw std::runtime_error{""};

14. }

16. explicit Socket(Socket& s, destructive_t)

17. : socket_id{std::exchange(s.socket_id, -1)}

18. {

19. s.Socket::~Socket();

20. }

22. public:

23. Socket(Socket&& r) = delete;

25. ~Socket() {

26. if (BOOST_LIKELY(socket_id != -1))

27. close(socket_id);

28. }

30. int id() const { return socket_id; }

31. // post-condition : return >= 0

33. static Socket make_inet() { return Socket{AF_INET}; }

34. static Socket make_unix() { return Socket{AF_UNIX}; }

36. friend Socket destructive_move(Socket& s) {

37. return Socket{s, destructive_t{}};

38. }

39. };

La classe vide destructive_t (à la ligne 6) sert simplement d’étiquette pour la surcharge du constructeur, ce qui nous permet de sélectionner un constructeur particulier.

Le nouveau « constructeur par destruction » (à la ligne 16) prend un autre Socket par référence l-value. À bien des égards, ce constructeur ressemble à un constructeur par déplacement, mais il va plus loin. Il « vole » le contenu de s (dans notre cas, ce contenu est juste le socket_id), il met une valeur « invalide » à la place (tout comme l’ancien constructeur par déplacement), et appelle immédiatement le destructeur de s, ce qui termine alors sa vie. Le destructeur doit alors vérifier si lesocket_id est valide avant d’invoquer la fonction close() (à la ligne 26). Cela ressemble un peu à la situation précédente où l’on avait des sockets qui pouvaient représenter une session invalide, mais c’est en réalité très différent. Cet état invalide ne peut être créé que par le « constructeur par destruction », qui est privé, et cet état ne dure pas longtemps, car l’objet est alors immédiatement détruit via l’appel au destructeur. De fait, mis à part le destructeur, personne ne peut observer cet état qui offre donc moins de garanties que l’état « déplacé » résultant d’une opération de déplacement, qui est valide, mais non spécifiée.

Dans le destructeur, on utilise BOOST_LIKELY (une macro équivalente au __builtin_expect de GCC et clang) qui indique au compilateur que, à moins de trouver la preuve du contraire, il peut supposer que la condition sera vraie. Une annotation similaire ([[likely]]) sera probablement ajoutée dans une version future du C++ (voir ici).

Dans le cas d’une destruction suivant un déplacement destructif, ce test sera certainement optimisé par le compilateur, car il sera situé seulement à quelques instructions de là où socket_id est mis à -1. Notre invariant est toujours déclaré comme étant fort, bien que ça soit techniquement incorrect, car il sera parfois violé au début de l’appel au destructeur. Cet aspect serait géré plus proprement si le langage proposait un support natif pour les déplacements destructifs, auquel cas fournir un objet comme argument d’un constructeur « destructif » serait reconnu comme signifiant la fin de vie dudit objet, et l’on n'aurait alors pas besoin ni d’appeler manuellement le destructeur ni de conserver la valeur spéciale -1.

La nouvelle fonction amie destructive_move() (à la ligne 36) utilise le « constructeur par destruction » dans la pr-value qu’elle retourne. Le contrat offert par cette fonction est le suivant : après avoir été invoquée, en aucun cas on ne doit essayer de détruire l’objet qui a été passé en argument par référence.

Avec cette implémentation, voilà tout ce qu’on a à faire pour éjecter un objet non déplaçable de notre classe Opt :

1. Opt<Socket> os;

2. os.emplace(rvalue{&Socket::make_inet});

3. Socket s = os.eject();

Et on peut reconstruire sur place l’objet éjecté :

1. Opt<Socket> os, ot;

2. os.emplace(rvalue{&Socket::make_inet});

3. ot.emplace(rvalue( [&]{ return os.eject(); } ));

Cela démontre comment on peut, dans une certaine mesure, faire se déplacer un objet d’un type formellement non déplaçable tout en conservant un invariant fort. Une technique similaire pourrait être utilisée pour stl2::vector.

Et c’est tout pour aujourd’hui. Je voudrais remercier Tomasz Kamiński de m’avoir fait prendre conscience du potentiel de cette nouvelle fonctionnalité, « pr-values sans temporaires ».

V - Remerciements

Nous tenons à remercier Andrzej Krzemieński qui nous a autorisés à publier ce tutoriel.

Nous remercions également Kalith pour la traduction, Luc-Hermitte pour la relecture et les deux correcteurs orthographiques Jacques_jean et ClaudeLELOUP.