Zwracanie wartości z funkcji

  • cpp, pl
  • 7
  • finished

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;
  • kopia przeniesienie tymczasowej wartości zwracanej z funkcji do obiektu myFoo.

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ów const i volatile;
  • 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.