W C++17 standard wprowadził gwarantowane wyrzucenie kopii (obowiązkowe wyrzucenie kopii), co zasadniczo zmienia sposób, w jaki prvalue (czyste rvalue) są materializowane. Gdy prvalue typu klasy inicjalizuje obiekt tego samego typu — na przykład podczas zwracania wartości z funkcji lub przekazywania tymczasowego do funkcji — obiekt jest konstruowany bezpośrednio w docelowej pamięci. W związku z tym konstruktor kopiujący lub konstruktor przenoszący nie są wywoływane, a co ważne, ich dostępność (publiczna vs. prywatna) ani sama ich obecność (pod warunkiem, że klasa jest kompletna i zniszczalna) nie są wymagane do poprawnego działania. To ostre kontrastuje z wcześniejszymi standardami, w których wyrzucenie było tylko opcjonalną optymalizacją, która nadal wymagała, aby dostępne i obecne były konstruktory do kompilacji.
struct Immovable { Immovable() = default; Immovable(const Immovable&) = delete; Immovable(Immovable&&) = delete; }; Immovable factory() { return Immovable{}; // OK w C++17: brak wywołań przeniesienia/kopii } void consume(Immovable x); // Parametr inicjalizowany bezpośrednio z prvalue
Nasz zespół budował sterownik kernel-mode, w którym uchwyty zasobów otaczające konteksty sprzętowe nie mogły być duplikowane ani przenoszone w pamięci z powodu zarejestrowanych adresów jądra. Potrzebowaliśmy funkcji fabrycznej do produkcji tych uchwytów przez wartość w celu zarządzania RAII, ale uchwyty jawnie usunęły zarówno konstruktory kopiujące, jak i przenoszące, aby zapobiec przypadkowemu unieważnieniu mapowań jądra. Przed C++17 ten projekt był niekompatybilny ze zwracaniem przez wartość, ponieważ nawet przy NRVO, kompilator koncepcyjnie wymagał, aby konstruktor przenoszący był dostępny, co skutkowało błędami kompilacji.
Rozwiązanie 1: Alokacja na stercie za pomocą std::unique_ptr
Rozważaliśmy owinięcie uchwytu w std::unique_ptr, co pozwoliłoby przenieść wskaźnik, podczas gdy bazowy obiekt pozostawał przypięty. To podejście zapewniało bezpieczeństwo i działało w C++14.
Zalety: Standardowe zarządzanie pamięcią, zapobiega wyciekom, szeroko wspierane w starszym kodzie.
Wady: Wprowadza narzut alokacji dynamicznej i pośrednictwa wskaźnika, co jest niekorzystne w kontekstach jądra, gdzie wymagana jest deterministyczna niska latencja; również fragmentuje pamięć podręczną CPU i wymaga rozważenia obsługi wyjątków w przypadku niepowodzenia alokacji.
Rozwiązanie 2: Inicjalizacja parametru wyjściowego
Przekazywanie odniesienia do obiektu przydzielonego przez wywołującego do fabryki, aby zainicjować go na miejscu.
Zalety: Gwarancja zerowej kopii bez względu na wersję standardu C++; brak alokacji na stercie; kompatybilność z typami nieprzenośnymi.
Wady: Niszczy styl API fluent (auto h = create(); staje się Handle h; create(h);); zwiększa ryzyko użycia przed inicjalizacją i źle komponuje się ze standardowymi algorytmami oraz pętlami for opartymi na zakresie.
Rozwiązanie 3: Wykorzystanie gwarantowanego wyrzucenia kopii C++17
Refaktoryzowaliśmy fabrykę, aby zwracała typ nieprzenośny przez wartość, polegając na obowiązkowym wyrzuceniu, aby skonstruować prvalue bezpośrednio w pamięci wywołującego.
Zalety: Eliminuje użycie sterty; zachowuje semantykę wartości; narzuca zerowe koszty abstrakcji w czasie kompilacji; konstruktory kopiujące/przenoszące nie muszą istnieć ani być dostępne.
Wady: Dotyczy wyłącznie czystych rvalue (nie można zwracać istniejących nazwanych zmiennych); wymaga kompilatora z obsługą C++17; subtelne różnice w obsłudze wyjątków podczas konstrukcji muszą być zrozumiane.
Wybraliśmy Rozwiązanie 3, ponieważ fabryka produkowała świeże tymczasowe, które były czystymi prvalue, idealnie odpowiadającymi scenariuszowi gwarantowanego wyrzucenia. Pozwoliło to uchwytom pozostać ściśle nieprzenośnymi, jednocześnie zachowując ergonomiczną semantykę wartości i kompatybilność z deklaracjami auto.
Sterownik został wdrożony z inicjalizacją w skali mikrosekund dla tysięcy jednoczesnych połączeń. Inspekcja assemblera potwierdziła, że uchwyt został skonstruowany bezpośrednio w ramce stosu wywołującego, bez jakiejkolwiek relocacji czy kodu kopiowania. System typów wymusił bezpieczeństwo zasobów poprzez konstrukcję, a całkowicie wyeliminowaliśmy konkurencję ze sterty z gorącej ścieżki.
Czy gwarantowane wyrzucenie kopii dotyczy nazwanych wartości zwracanych (lvalue) wewnątrz funkcji, czy jest ściśle ograniczone do prvalue?
Gwarantowane wyrzucenie kopii dotyczy wyłącznie prvalue (czystych rvalue), takich jak tymczasowe tworzone w instrukcji zwrotu bez nazwy. Optymalizacja nazwanego zwracania wartości (NRVO) pozostaje opcjonalną optymalizacją kompilatora; chociaż jest szeroko implementowana, nie zapewnia tych samych gwarancji dotyczących dostępności konstruktorów ani efektów ubocznych. Jeśli kandydat spróbuje zwrócić lokalną zmienną nazwaną i zakłada, że spowoduje to wywołanie gwarantowanego wyrzucenia, nawet jeśli konstruktor przenoszący jest usunięty, program będzie niepoprawny, ponieważ zmienne nazwane są lvalue i wymagają operacji przenoszenia/kopiowania, chyba że kompilator zastosuje opcjonalne NRVO, co nie jest narzucone.
Czy klasa z explicite usuniętymi konstruktorami kopiującymi i przenoszącymi może być zwrócona przez wartość z funkcji w ramach zasad gwarantowanego wyrzucenia kopii?
Tak. W C++17, jeśli zwracany wyrażenie to prvalue (np. return MyClass{};), konstruktory kopiujące i przenoszące nigdy nie są brane pod uwagę podczas inicjalizacji. Ponieważ obiekt jest konstruowany bezpośrednio w pamięci wywołującego, usunięte konstruktory nie są używane w ODR i nie powodują błędów kompilacji. Jednak próba zwrócenia zmiennej lokalnej takiego typu zakończy się niepowodzeniem, ponieważ ta operacja koncepcyjnie wymaga przeniesienia lvalue do slotu zwrotu, co wywołałoby usunięty konstruktor przenoszący i skutkowałoby niepoprawnym programem.
Jak gwarantowane wyrzucenie kopii wchodzi w interakcję z bezpieczeństwem wyjątków, szczególnie w odniesieniu do czasu życia prvalue tymczasowego podczas rozwijania stosu?
W przypadku gwarantowanego wyrzucenia kopii nie jest tworzony osobny obiekt tymczasowy przed rozpoczęciem czasu życia obiektu docelowego. Prvalue jest materializowane bezpośrednio w jego ostatecznym przeznaczeniu. W konsekwencji, jeśli wystąpi wyjątek podczas konstrukcji prvalue, mechanizm rozwijania stosu nie napotyka osobnego tymczasowego, który wymaga zniszczenia; zamiast tego widzi częściowo skonstruowany obiekt docelowy. To oznacza, że z perspektywy wywołującego, obiekt albo istnieje w pełni skonstruowany, albo w ogóle, co upraszcza gwarancje bezpieczeństwa wyjątków i zapewnia, że nie wystąpi podwójne zniszczenie ani wyciek zasobów z powodu porzuconego tymczasowego podczas obsługi wyjątków przed rozpoczęciem czasu życia obiektu docelowego.