In C++17 introduceerde de standaard gegarandeerde copy elision (verplichte copy elision), wat fundamenteel verandert hoe prvalues (pure rvalues) worden gematerialiseerd. Wanneer een prvalue van een klastype een object van hetzelfde type initialiseert — zoals bij het retourneren van een functie bij waarde of het doorgeven van een tijdelijke aan een functie — wordt het object direct in de doellocatie geconstrueerd. Als gevolg hiervan wordt de copy constructor of move constructor niet aangeroepen, en belangrijk is dat noch hun toegankelijkheid (publiek vs. privé) noch hun loutere bestaan (mits de klasse compleet en destructibel is) vereist is voor de operatie om goed gevormd te zijn. Dit staat in scherp contrast met eerdere standaarden waar elision slechts een optionele optimalisatie was die nog steeds toegankelijke en aanwezige constructors vereiste voor compilatie.
struct Immovable { Immovable() = default; Immovable(const Immovable&) = delete; Immovable(Immovable&&) = delete; }; Immovable factory() { return Immovable{}; // OK in C++17: geen move/copy aangeroepen } void consume(Immovable x); // Parameter direct geïnitieerd vanuit prvalue
Ons team bouwde een kernel-mode driver waar resource handles die hardwarecontexten omhulden niet gedupliceerd of verplaatst konden worden in het geheugen vanwege geregistreerde kerneladressen. We hadden een fabrieksfunctie nodig om deze handles bij waarde te produceren voor RAII-beheer, maar de handles hadden expliciet zowel copy- als move-constructors verwijderd om ongewenste ongeldigverklaring van kernel-mapping te voorkomen. Voor C++17 was dit ontwerp niet compatibel met retourneren bij waarde, omdat zelfs met NRVO de compiler conceptueel vereiste dat de move constructor toegankelijk was, wat leidde tot compilatiefouten.
Oplossing 1: Heap allocatie via std::unique_ptr
We overwoogden om de handle in een std::unique_ptr te wikkelen, waardoor de pointer kon worden verplaatst terwijl het onderliggende object vastgelegd bleef. Deze benadering bood veiligheid en functioneerde in C++14.
Voordelen: Standaardgeheugenbeheer, voorkomt lekken, breed ondersteund in legacy codebases.
Nadelen: Introduceert dynamische allocatie-overhead en pointer-indirectie, wat beperkend is in kernelcontexten waar deterministische lage latentie vereist is; fragementeert ook de CPU-cache en vereist overwegingen voor uitzonderingafhandeling bij allocatie-fouten.
Oplossing 2: Initialisatie via out-parameter
Een referentie naar een door de aanroeper gealloceerd object in de fabrieksfunctie doorgeven om deze ter plaatse te initialiseren.
Voordelen: Garantie van nul-kopie ongeacht de C++ standaardversie; geen heap allocatie; compatibel met immoveerbare types.
Nadelen: Vernietigt de vloeiende API-stijl (auto h = create(); wordt Handle h; create(h);); verhoogt het risico op gebruik voor initialisatie en componeert slecht met standaardalgoritmen en range-gebaseerde for-lussen.
Oplossing 3: Maak gebruik van de gegarandeerde copy elision van C++17
We herstructureerden de fabriek om het immoveerbare type bij waarde te retourneren, vertrouwend op verplichte elision om de prvalue direct in de opslag van de aanroeper te construeren.
Voordelen: Elimineert heapgebruik; behoudt waarde-semantiek; handhaaft kosteloze abstractie op compileertijd; move/copy constructors hoeven niet te bestaan of toegankelijk te zijn.
Nadelen: Geldt strikt voor pure rvalues (kan geen bestaande benoemde variabelen retourneren); vereist compiler met C++17 ondersteuning; subtiele verschillen in uitzonderingafhandeling tijdens constructie moeten worden begrepen.
We selecteerden Oplossing 3 omdat de fabriek verse temporaries produceerde die pure prvalues waren, perfect passend bij het gegarandeerde elisionscenario. Dit stelde de handles in staat om strikt immoveerbaar te blijven terwijl de ergonomische waarde-semantiek en compatibiliteit met auto-declaraties behouden bleef.
De driver verzond met microseconde-schaal initialisatie voor duizenden gelijktijdige verbindingen. Assembly-inspectie bevestigde dat de handle direct in het stack-frame van de aanroeper werd geconstrueerd zonder enige verplaatsing of kopie-code. Het typesysteem handhaafde resourceveiligheid bij constructie, en we elimineerden volledig de heap-concurrentie uit het hete pad.
Is gegarandeerde copy elision van toepassing op benoemde retourwaarden (lvalues) binnen de functie, of is het strikt beperkt tot prvalues?
Gegarandeerde copy elision is uitsluitend van toepassing op prvalues (pure rvalues), zoals tijdelijk aangemaakte variabelen in de return-instructie zonder naam. Named Return Value Optimization (NRVO) blijft een optionele compileroptimalisatie; hoewel het breed wordt geïmplementeerd, biedt het niet dezelfde garanties met betrekking tot constructor toegankelijkheid of bijwerkingen. Als een kandidaat probeert een benoemde lokale variabele te retourneren en aanneemt dat dit gegarandeerde elision zal triggeren zelfs als de move constructor is verwijderd, zal het programma ongeldig zijn omdat benoemde variabelen lvalues zijn en move/copy-operaties vereisen, tenzij de compiler optionele NRVO toepast, wat niet verplicht is.
Kan een klasse met expliciet verwijderde copy en move constructors bij waarde worden geretourneerd vanuit een functie onder de regels van gegarandeerde copy elision?
Ja. In C++17, als de geretourneerde expressie een prvalue is (bijv., return MyClass{};), worden de copy en move constructors nooit overwogen voor de initialisatie. Omdat het object direct in de opslag van de aanroeper wordt geconstrueerd, worden de verwijderde constructors niet odr-gebruikt en veroorzaken ze geen compilatiefouten. Echter, proberen een benoemde variabele van een dergelijk type te retourneren zal mislukken, aangezien die bewerking conceptueel vereist dat de lvalue in de return-slot wordt verplaatst, wat de verwijderde move constructor zou aanroepen en zou resulteren in een ongeldig programma.
Hoe werkt gegarandeerde copy elision in combinatie met uitzonderingveiligheid, specifiek wat betreft de levensduur van de prvalue tijdelijke tijdens stack-ontspanning?
Onder gegarandeerde copy elision wordt er geen apart tijdelijk object aangemaakt voordat de levensduur van het doelobject begint. De prvalue wordt direct gematerialiseerd op zijn uiteindelijke bestemming. Bijgevolg, als er een uitzondering optreedt tijdens de constructie van de prvalue, komt het stack-ontspanningmechanisme geen apart tijdelijk object tegen dat vernietigd moet worden; in plaats daarvan ziet het het gedeeltelijk geconstrueerde doelobject. Dit betekent dat vanuit het perspectief van de aanroeper, het object of volledig geconstrueerd bestaat of helemaal niet, wat de garanties van uitzonderingveiligheid vereenvoudigt en ervoor zorgt dat er geen dubbele vernietiging of resourcelekkage optreedt vanwege een verlaten tijdelijk object tijdens de uitzonderingafhandeling voordat de levensduur van het doelobject officieel begint.