In C++17 führte der Standard garantierte Kopierelision (obligatorische Kopierelision) ein, die fundamental ändert, wie prvalues (reine rvalues) materialisiert werden. Wenn ein prvalue eines Klassentyps ein Objekt desselben Typs initialisiert - wie zum Beispiel beim Rückgabewert einer Funktion oder beim Übergeben eines temporären Wertes an eine Funktion - wird das Objekt direkt im Zielspeicher konstruiert. Folglich wird der Kopier-Konstruktor oder Verschiebe-Konstruktor nicht aufgerufen, und wichtig ist, dass weder ihre Zugänglichkeit (öffentlich vs. privat) noch ihr bloßes Vorhandensein (vorausgesetzt die Klasse ist vollständig und zerstörbar) für die Operation erforderlich sind, damit sie wohlgeformt ist. Dies steht in scharfem Kontrast zu früheren Standards, bei denen die Elision lediglich eine optionale Optimierung war, die dennoch zugängliche und vorhandene Konstruktoren für die Kompilierung erforderte.
struct Immovable { Immovable() = default; Immovable(const Immovable&) = delete; Immovable(Immovable&&) = delete; }; Immovable factory() { return Immovable{}; // OK in C++17: kein Move/Kopie aufgerufen } void consume(Immovable x); // Parameter direkt aus prvalue initialisiert
Unser Team baute einen Kernelmodus-Treiber, bei dem Ressourcen-Handles, die Hardware-Kontexte umschließen, aufgrund registrierter Kerneladressen nicht dupliziert oder im Speicher verschoben werden konnten. Wir benötigten eine Fabrikfunktion, um diese Handles durch Wert für die RAII-Verwaltung zu erzeugen, aber die Handles hatten sowohl Kopier- als auch Verschiebe-Konstruktoren explizit gelöscht, um eine versehentliche Ungültigkeit der Kernelzuordnungen zu verhindern. Vor C++17 war dieses Design nicht mit der Rückgabe durch Wert kompatibel, da selbst bei NRVO der Compiler konzeptionell den Zugriff auf den Verschiebe-Konstruktor erforderte, was zu Kompilierungsfehlern führte.
Lösung 1: Heap-Zuweisung über std::unique_ptr
Wir erwogen, das Handle in einem std::unique_ptr zu verpacken, was es erlaubte, den Zeiger zu bewegen, während das zugrunde liegende Objekt fixiert blieb. Dieser Ansatz bot Sicherheit und funktionierte in C++14.
Vorteile: Standardmäßige Speicherverwaltung, verhindert Lecks, wird in vielen Legacy-Codebasen unterstützt.
Nachteile: Führt dynamischen Zuweisungsaufwand und Zeigerindirektion ein, die in Kernel-Umgebungen, in denen deterministische niedrige Latenz erforderlich ist, hinderlich ist; fragmentiert auch den CPU-Cache und erfordert Überlegungen zur Ausnahmebehandlung bei Zuweisungsfehlern.
Lösung 2: Initialisierung von Out-Parametern
Übergeben eines Verweises auf ein vom Aufrufer zugewiesenes Objekt in die Fabrik, um es direkt vor Ort zu initialisieren.
Vorteile: Null-Kopie-Garantie unabhängig von der C++-Standardversion; keine Heap-Zuweisung; kompatibel mit nicht bewegbaren Typen.
Nachteile: Zerstört den flüssigen API-Stil (auto h = create(); wird zu Handle h; create(h);); erhöht das Risiko einer Verwendung vor der Initialisierung und harmoniert schlecht mit Standard-Algorithmen und bereichsbasierten for-Schleifen.
Lösung 3: Nutzung der garantierten Kopierelision von C++17
Wir haben die Fabrik umgestaltet, um den nicht bewegbaren Typ durch Wert zurückzugeben und uns auf die obligatorische Elision zu verlassen, um das prvalue direkt im Speicher des Aufrufers zu konstruieren.
Vorteile: Beseitigt die Nutzung des Heaps; bewahrt Wertsemantik; erzwingt Null-Kosten-Abstraktion zur Kompilierzeit; Kopier-/Verschiebe-Konstruktoren müssen nicht existieren oder zugänglich sein.
Nachteile: Gilt streng für reine rvalues (kann keine vorhandenen benannten Variablen zurückgeben); erfordert einen Compiler mit C++17-Unterstützung; subtile Unterschiede in der Ausnahmebehandlung während der Konstruktion müssen verstanden werden.
Wir wählten Lösung 3, da die Fabrik frische temporäre Werte erzeugte, die pure prvalues waren und perfekt zum Szenario der garantierten Elision passten. Dies erlaubte es den Handles, strikt unbewegbar zu bleiben, während gleichzeitig ergonomische Wertsemantik und Kompatibilität mit auto-Deklarationen gewährleistet wurden.
Der Treiber wurde mit Mikrosekunden-schneller Initialisierung für Tausende von gleichzeitigen Verbindungen veröffentlicht. Die Assemblierungsinspektion bestätigte, dass das Handle direkt im Stapelrahmen des Aufrufers konstruierte wurde, ohne irgendeinen Umzug oder Kopiercode. Das Typsystem gewährleistete die Ressourcensicherheit durch Konstruktion und wir beseitigten die Heap-Konkurrenz vollständig aus dem heißen Pfad.
Gilt die garantierte Kopierelision für benannte Rückgabewerte (lvalues) innerhalb der Funktion, oder ist sie streng auf prvalues beschränkt?
Die garantierte Kopierelision gilt ausschließlich für prvalues (reine rvalues), wie temporäre Werte, die in der Rückgabebefehl ohne einen Namen erstellt werden. Die Optimierung von Named Return Value (NRVO) bleibt eine optionale Compiler-Optimierung; während sie weit verbreitet implementiert ist, bietet sie nicht die gleichen Garantien bezüglich der Zugänglichkeit von Konstruktoren oder Nebeneffekten. Wenn ein Kandidat versucht, eine benannte lokale Variable zurückzugeben und annimmt, dass dies die garantierte Elision auslöst, selbst wenn der Verschiebe-Konstruktor gelöscht ist, wird das Programm nicht wohlgeformt sein, da benannte Variablen lvalues sind und Move-/Kopieroperationen benötigen, es sei denn, der Compiler wendet die optionale NRVO an, was nicht vorgeschrieben ist.
Kann eine Klasse mit explizit gelöschten Kopier- und Verschiebe-Konstruktoren wertmäßig von einer Funktion unter den Regeln der garantierten Kopierelision zurückgegeben werden?
Ja. In C++17, wenn der zurückgegebene Ausdruck ein prvalue ist (z.B. return MyClass{};), werden die Kopier- und Verschiebe-Konstruktoren niemals für die Initialisierung berücksichtigt. Da das Objekt direkt im Speicher des Aufrufers konstruiert wird, werden die gelöschten Konstruktoren nicht odr-used und verursachen keine Kompilierungsfehler. Der Versuch, eine benannte Variable eines solchen Typs zurückzugeben, wird jedoch fehlschlagen, da diese Operation konzeptionell erfordert, dass das lvalue in den Rückgabespeicher verschoben wird, was den gelöschten Verschiebe-Konstruktor aufrufen und zu einem nicht wohlgeformten Programm führen würde.
Wie interagiert die garantierte Kopierelision mit der Ausnahme-Sicherheit, insbesondere in Bezug auf die Lebensdauer des prvalue-Temporärs während des Stack-Unwindings?
Unter der garantierten Kopierelision wird kein separates temporäres Objekt erstellt, bevor die Lebensdauer des Zielobjekts beginnt. Das prvalue wird direkt an seinem endgültigen Ziel materialisiert. Folglich, falls eine Ausnahme während der Konstruktion des prvalue auftritt, stößt der Mechanismus des Stack-Unwindings nicht auf ein separates temporäres Objekt, das zerstört werden muss; stattdessen sieht er das teilweise konstruierte Zielobjekt. Dies bedeutet, dass aus der Perspektive des Aufrufers das Objekt entweder vollständig konstruiert oder überhaupt nicht existiert, was die Garantien zur Ausnahme-Sicherheit vereinfacht und sicherstellt, dass keine doppelte Zerstörung oder Ressourcenlecks aufgrund eines aufgegebenen Temporärs während der Ausnahmebehandlung auftreten, bevor die Lebensdauer des Zielobjekts offiziell beginnt.