Zwracanie wartości z funkcji
Często zdarza się, że funkcje, które piszemy są tak naprawdę fabrykami obiektów. Jest to dobra praktyka, bo zamiast tworzenia kilku-kilkunastu obiektów w danym miejscu kodu jedynie po to aby je przekazać dalej, wystarczy, że wywołamy funkcję.
Foo createFoo() {
MyDependency a;
AnotherDependency b;
BarDependency c;
Foo ret(a, b, c);
return ret;
}
Foo myFoo = createFoo();
Jako programiści C++ zaczynamy się jednak w tym miejscu zastanawiać jaki sposób
zwracania wartości ret
jest optymalny. Poprzez wartość? A może utworzyć obiekt
Foo
na stercie i zwrócić go przez wskaźnik? A może wykonać reklamowany
std::move(ret)
- kilkukrotnie również z takimi poradami się spotkałem.
Return value optimization
Prawdopodobnie jedną z pierwszych porad, które dostaniemy gdy zadamy to pytanie naszej ulubionej wyszukiwarce będzie informacja o istnieniu mechanizmu copy elision lub return value optimization (będącego niejako podzbiorem copy elision). Standard opisuje kilka przypadków, w których niepotrzebna kopia (lub, pomimo nazwy, przeniesienie obiektu - move) może zostać na drodze optymalizacji wyeliminowana [ISO/IEC 14882:2011(E), p. 12.8-31].
W zaprezentowanym powyżej kodzie teoretycznie powinny wykonać się dwie kopie operacje:
- kopia obiektu
ret
to wartości zwracanej z funkcji; kopiaprzeniesienie tymczasowej wartości zwracanej z funkcji do obiektumyFoo
.
Wobec tego interesować nas będą 2 szczególne przypadki, w których standard dozwala kompilatorom na wykonanie copy/move elision:
- w dyrektywie
return
, gdy typ zwracany z funkcji jest taki sam jak typ lokalnego obiektu zwracanego, obiekt ten jest zmienną automatyczną (został utworzony na stosie) i zarówno typ funkcji jak i typ obiektu zwracanego nie posiadają kwalifikatorówconst
ivolatile
; - gdy dowolny obiekt tymczasowy (np. obiekt zwracany dyrektywą
return
) zostaje przypisany do obiektu tego samego typu.
Poszczególne optymalizacje mogą się ze sobą łączyć, dlatego w naszym przykładzie
zmienna lokalna ret
zostanie utworzona bezpośrednio w miejscu zmiennej
myFoo
, ALE tylko wtedy gdy zwracamy obiekt dokładnie takiego samego typu co
typ zwracany z funkcji. Czyli użycie np. std::move(ret)
pozbawi nas możliwości
zastosowania copy elision, które jest najoptymalniejszym rozwiązaniem naszego
problemu.
Czyli nie przenosić?
Można jednak powiedzieć, że standard jedynie dozwala twórcom kompilatorów implementowanie mechanizmów copy elision, ale nie wymaga tego od nich. W praktyce wszystkie szeroko znane kompilatory implementują tę optymalizację, jednak możemy się zastanowić, co byłoby w przypadku gdyby tego nie robiły. Może wtedy przenoszenie wartości ma sens?
Na szczęście również wtedy nie ma to sensu, bowiem standard w paragrafie 12.8-32
opisuje i taką sytuację. Jeśli zachodzą warunki wystarczające i konieczne do
zastosowania copy elision, to niezależnie od tego czy kompilator wykona
optymalizację czy nie, wymagane jest aby najpierw spróbować obiekt przenieść, a
potem dopiero skopiować. Czyli najpierw obiekt zostanie przetestowany pod kątem
istnienia konstruktora przenoszącego (Foo(Foo&&)
), a następnie pod kątem
istnienia konstruktora kopiującego (Foo(const Foo&)
). A zatem ręczne użycie
std::move
jest w takim wypadku redundantne w stosunku do standardu.
Co jeśli obiekt nie posiada ani konstruktora kopiującego, ani przenoszącego (co może wynikać np. z powodu przechowywania przez niego stringstreama, który ma pewnego buga w implementacji GCC)? Wówczas pozostaje już tylko utworzenie go na stercie i zwrócenie pointera:
std::unique_ptr<Foo> createFoo() {
MyDependency a;
AnotherDependency b;
BarDependency c;
std::unique_ptr<Foo> ret = std::make_unique(a, b, c);
return ret;
}
std::unique_ptr<Foo> myFoo = createFoo();
Warto przy okazji zauważyć, że w powyższym przykładzie zastosowanie ma opisany
wyżej paragraf 12.8-32 standardu, jako że sam unique_ptr
nie posiada
konstruktora kopiującego.
Jaka jest wobec tego końcowa konkluzja? Nie przenosić, nie zwracać wskaźników
jeśli nie oczekujemy semantyki wskaźnika (czyli np. tego, że obiekt może nie
istnieć - być nullptr
), zwracać jak Bjarne przykazał - poprzez wartość.
Ogólnie: przestać “kombinować”, bo C++ zmierza w dobrym kierunku, w którym
najprostsze rozwiązania są tymi najlepszymi.