En C++17, la norme a introduit l'élision de copie garantie (élision de copie obligatoire), ce qui change fondamentalement la manière dont les prvalues (valeurs pures) sont matérialisées. Lorsqu'une prvalue de type classe initialise un objet du même type—comme lors du retour d'une fonction par valeur ou du passage d'un temporaire à une fonction—l'objet est construit directement dans le stockage de destination. Par conséquent, le constructeur de copie ou le constructeur de déplacement n'est pas invoqué, et il est important de noter que ni leur accessibilité (publique vs. privée) ni leur simple existence (à condition que la classe soit complète et destructible) ne sont requises pour que l'opération soit bien formée. Cela contraste fortement avec les normes antérieures où l'élision n'était qu'une optimisation facultative qui exigeait tout de même des constructeurs accessibles et présents pour la compilation.
struct Immovable { Immovable() = default; Immovable(const Immovable&) = delete; Immovable(Immovable&&) = delete; }; Immovable factory() { return Immovable{}; // OK en C++17 : pas de mouvement/copie invoqué } void consume(Immovable x); // Paramètre initialisé directement depuis prvalue
Notre équipe construisait un pilote mode noyau où les poignées de ressources enveloppant des contextes matériels ne pouvaient pas être dupliquées ou déplacées en mémoire en raison des adresses de noyau enregistrées. Nous avions besoin d'une fonction de fabrique pour produire ces poignées par valeur pour la gestion RAII, mais les poignées avaient explicitement supprimé à la fois les constructeurs de copie et de mouvement pour prévenir l'invalidation accidentelle des mappages de noyau. Avant C++17, ce design était incompatible avec le retour par valeur car même avec NRVO, le compilateur nécessitait conceptuellement que le constructeur de déplacement soit accessible, ce qui entraînait des erreurs de compilation.
Solution 1 : Allocation sur le tas via std::unique_ptr
Nous avons envisagé d'envelopper la poignée dans un std::unique_ptr, permettant au pointeur d'être déplacé tandis que l'objet sous-jacent restait fixé. Cette approche offrait sécurité et fonctionnait en C++14.
Avantages : Gestion de mémoire standard, prévient les fuites, largement supportée dans les bases de code héritées.
Inconvénients : Introduit une surcharge d'allocation dynamique et une indirection de pointeur, ce qui est prohibitif dans les contextes de noyau où une faible latence déterministe est requise ; fragmente également le cache CPU et nécessite des considérations de gestion des exceptions pour l'échec d'allocation.
Solution 2 : Initialisation par paramètre de sortie
Passage d'une référence à un objet alloué par l'appelant dans la fabrique pour être initialisé sur place.
Avantages : Garantie sans copie quelle que soit la version de la norme C++; pas d'allocation sur le tas ; compatible avec les types immuables.
Inconvénients : Détruit le style d'API fluide (auto h = create(); devient Handle h; create(h);); augmente le risque d'utilisation avant initialisation et se compose mal avec les algorithmes standard et les boucles for basées sur les plages.
Solution 3 : Tirer parti de l'élision de copie garantie de C++17
Nous avons refactorisé la fabrique pour retourner le type immuable par valeur, en comptant sur l'élision obligatoire pour construire la prvalue directement dans le stockage de l'appelant.
Avantages : Élimine l'utilisation du tas ; préserve la sémantique de valeur ; impose une abstraction à coût nul au moment de la compilation ; les constructeurs de mouvement/copie n'ont pas besoin d'exister ou d'être accessibles.
Inconvénients : S'applique strictement aux valeurs pures (ne peut pas retourner des variables nommées existantes) ; nécessite un compilateur avec support C++17 ; des différences subtiles dans la gestion des exceptions lors de la construction doivent être comprises.
Nous avons choisi Solution 3 car la fabrique produisait des temporaires frais qui étaient de pures prvalues, correspondant parfaitement au scénario d'élision garantie. Cela a permis aux poignées de rester strictement immuables tout en maintenant une sémantique de valeur ergonomique et une compatibilité avec les déclarations auto.
Le pilote a été expédié avec une initialisation à l'échelle de la microseconde pour des milliers de connexions simultanées. L'inspection de l'assemblage a confirmé que la poignée était construite directement dans le cadre de pile de l'appelant sans aucun code de relocalisation ou de copie. Le système de type a imposé la sécurité des ressources par construction, et nous avons éliminé complètement la contention du tas du chemin critique.
L'élision de copie garantie s'applique-t-elle aux valeurs de retour nommées (lvalues) à l'intérieur de la fonction, ou est-elle strictement limitée aux prvalues ?
L'élision de copie garantie s'applique exclusivement aux prvalues (valeurs pures), telles que les temporaires créés dans l'instruction de retour sans nom. L'Optimisation de la Valeur de Retour Nommée (NRVO) demeure une optimisation facultative du compilateur ; bien qu'elle soit largement implémentée, elle ne fournit pas les mêmes garanties concernant l'accessibilité des constructeurs ou les effets secondaires. Si un candidat tente de retourner une variable locale nommée et suppose qu'elle déclenchera l'élision garantie même si le constructeur de déplacement est supprimé, le programme sera mal formé car les variables nommées sont des lvalues et nécessitent des opérations de déplacement/copie à moins que le compilateur n'applique l'optimisation NRVO facultative, ce qui n'est pas mandaté.
Une classe avec des constructeurs de copie et de mouvement explicitement supprimés peut-elle être retournée par valeur d'une fonction sous les règles d'élision de copie garantie ?
Oui. En C++17, si l'expression retournée est une prvalue (par exemple, return MyClass{};), les constructeurs de copie et de mouvement ne sont jamais pris en compte pour l'initialisation. Comme l'objet est construit directement dans le stockage de l'appelant, les constructeurs supprimés ne sont pas utilisés et ne provoquent pas d'erreurs de compilation. Cependant, tenter de retourner une variable nommée de ce type échouera, car cette opération nécessite conceptuellement de déplacer la lvalue dans l'emplacement de retour, ce qui invoquerait le constructeur de mouvement supprimé et donnerait lieu à un programme mal formé.
Comment l'élision de copie garantie interagit-elle avec la sécurité d'exception, notamment en ce qui concerne la durée de vie du temporaire prvalue lors du déroulement de la pile ?
Sous l'élision de copie garantie, aucun objet temporaire séparé n'est créé avant le début de la durée de vie de l'objet cible. La prvalue est matérialisée directement à son emplacement final. Par conséquent, si une exception se produit pendant la construction de la prvalue, le mécanisme de déroutement de la pile ne rencontre pas un temporaire séparé qui nécessite destruction ; au lieu de cela, il voit l'objet de destination partiellement construit. Cela signifie que du point de vue de l'appelant, l'objet existe soit complètement construit, soit pas du tout, simplifiant les garanties de sécurité d'exception et garantissant qu'aucune double destruction ou fuite de ressource ne se produit en raison d'un temporaire abandonné lors de la gestion des exceptions avant le début officiel de la durée de vie de l'objet de destination.