Czy wiesz, że jesteśmy również na Slacku? Dołącz do nas już teraz klikając tutaj!

Zarządzanie zasobami w C++ #4 – referencje uniwersalne i std::forward


2021-03-01, 00:00

Język C++ nie był by sobą, gdyby nie szablony. Kiedy chcemy napisać jakiś szablon klasy lub funkcji, która manipuluje zasobem, chcielibyśmy dać jej użytkownikowi możliwość zdecydowania, czy zasób ten daje nam na wyłączność, czy musimy go sobie skopiować. To zadanie ułatwiać ma nam uniwersalna referencja, która jest domeną wyłącznie funkcji szablonowych.

Kopiowanie wartości parametrów funkcji do pól składowych

Przed epoką semantyki przenoszenia w C++ dobrą praktyką było, aby wartości do skopiowania (najczęściej settery i konstruktor) były przez funkcje przyjmowane przez stałą referencję:


class person
{
public:
    person(const std::string& name, const std::string& surname)
        : m_name(name)
        , m_surname(surname)
    {}

private:
    std::string m_name, m_surname;
};

Dla niektórych przypadków użycia taka implementacja jest całkiem sensowna. Na przykład dla takiej, gdzie obiekty wzorcowe (do skopiowania) tak czy siak są konstruowane, i mogą później zostać użyte (ich przeniesienie nie jest pożądane):

auto name = std::string{"Jan"};
auto surname = std::string{"Kowalski"};
// ...
person p(name, surname);
// ...

Istnieją jednak pesymistyczne przypadki wywołania takiej funkcji, które wymagają więcej niż jednej alokacji dla każdego z argumentów:

person p("Jan", "Kowalski");

Tworzymy tutaj obiekty klasy std::string tylko po to, żeby je zaraz skopiować, a następnie oryginał zniszczyć. Marnotrawstwo, prawda? Inny przypadek użycia również daje pesymistyczny efekt:

auto name = std::string{"Jan"};
auto surname = std::string{"Kowalski"};
// ...
person p(std::move(name), std::move(surname));

Robimy tutaj kopie pomimo tego, że moglibyśmy obiekty przenieść (użyć konstruktora przenoszącego).

Od standardu C++11 możemy jednak ten problem rozwiązać ładniej, a mianowicie wymusić stworzenie nowego obiektu już na etapie wywołania funkcji i późniejsze przeniesienie jego wartości do odpowiedniego pola klasy:

class person
{
public:
    person(std::string name, std::string surname)
        : m_name{std::move(name)}
        , m_surname{std::move(surname)}
    {}

private:
    std::string m_name, m_surname;
};

W porównaniu do poprzedniej implementacji, zaoszczędziliśmy jedną (kosztowną) operację kopiowania na rzecz (lekkiej) operacji przeniesienia.

Przykład funkcji generycznej

Pisząc klasy generyczne, a konkretnie szablony, często nie wiemy jakimi typami zostaną one zainstancjonowane. Tym bardziej nie wiemy, czy ich funkcje składowe przyjmują argumenty przez wartości, czy też przez referencje do r-wartości lub l-wartości (jeżeli nie masz jeszcze utrwalonych kategorii wartości z C++11, zobacz wpis Dawida Pilarskiego na ich temat). Pojawia się w związku z tym pewien problem.

Rozpatrzmy najprostszy przykład – funkcji generycznej construct, której zadaniem jest konstrukcja obiektu z podanych parametrów. Naiwna implementacja byłaby następująca:

template <typename T, typename... Args>
T construct_v1(Args... args)
{
    return T{args...};
}

A tak wygląda konstrukcja przykładowej klasy person z jej użyciem:

auto name = std::string{"Jan"};
const auto surname = std::string{"Kowalski"};
// ...
auto p = construct<person>(std::move(name), surname);

Jak można zauważyć, funkcja taka posiada solidny mankament – wszystkie parametry konstruktora zostaną przekazane przez kopię, niezależnie od tego, czy jest to potrzebne. Zarówno r-wartości (które najwydajniej jest przekazywać przez przeniesienie) jaki i l-wartości (które warto przekazywać przez stałą referencję) zostaną skopiowane.

Zastanówmy się więc, w jaki sposób możemy zoptymalizować naszą funkcję szablonową. Co by się stało, gdybyśmy wszystkie parametry przesyłali przez stałą referencję? Zobaczmy:

template <typename T, typename... Args>
T construct_v2(const Args&... args)
{
    return T{args...};
}

Implementacja taka sprawdza się, gdy przekazujemy do funkcji l-wartości. Pierwszy z argumentów zostanie jednak niepotrzebnie skopiowany (już podczas wywoływania konstruktora), podczas gdy jest r-wartością i mógłby zostać przeniesiony. Rozwiązanie to jest więc nadal nie do końca optymalne. Została nam jeszcze jedna oczywista opcja – spróbujmy napisać naszą funkcję w taki sposób, aby przyjmowała referencje do r-wartości:

template <typename T, typename... Args>
T construct_v3(Args&&... args)
{
    return T{std::move(args)...};
}

Osoby niewtajemniczone na pierwszy rzut oka mogą pomyśleć, że wyżej przedstawione wywołanie takiej funkcji nie powinno się skompilować. Przypomnijmy:

class person
{
public:
    person(std::string name, std::string surname)
        : m_name{std::move(name)}
        , m_surname{std::move(surname)}
    {}

private:
    std::string m_name, m_surname;
};

// ...

auto name = std::string{"Jan"};
const auto surname = std::string{"Kowalski"};
// ...
auto p = construct_v3<person>(std::move(name), surname);

Standardowo, próba przekazania obiektu typu const std::string do funkcji, która spodziewa się std::string&& się nie powiedzie – otrzymamy błąd kompilacji. Tyle, że tutaj nie mamy do czynienia z sytuacją standardową, lecz szablonem…

Referencje uniwersalne

Specjalnie na potrzeby takie, jak w poprzednim przykładzie, została wymyślona dodatkowa zasada dedukcji typów. Sprowadza się ona do czegoś, co nazywamy referencjami uniwersalnymi (ang. universal reference) lub inaczej referencjami przekazującymi (ang. forwarding reference). Można zdefiniować je następująco:

W przypadku dedukcji typu dla wyrażenia T&& (niekwalifikowanych jako const ani volatile), gdzie T jest typem dedukowanym, wyrażenie T&& może być zainstancjonowane jako referencja do l-wartości lub do r-wartości, w zależności od podanego argumentu. Typ T będzie wydedukowany jako referencja do l-wartości, jeżeli argument będzie taką właśnie referencją.

To tyle teorii, zobaczmy jak to działa w praktyce. Pomoże nam w tym pewne narzędzie, które przy okazji bardzo polecam do codziennych eksperymentów – CppInsights.io. Pokazuje ono, w jaki sposób kompilator “widzi” nasz kod. W naszym konkretnym przypadku pokaże, w jaki sposób została stworzona instancjacja szablonu. Popatrzmy na wynik analizy. W przypadku 2 pierwszych implementacji nie ma niespodzianki:

template<>
person construct_v1<person, std::basic_string<char>, std::basic_string<char> >(std::basic_string<char> __args0, std::basic_string<char> __args1)
{
  return person{std::basic_string<char>(__args0), std::basic_string<char>(__args1)};
}

template<>
person construct_v2<person, std::basic_string<char>, std::basic_string<char> >(const std::basic_string<char> & __args0, const std::basic_string<char> & __args1)
{
  return person{std::basic_string<char>(__args0), std::basic_string<char>(__args1)};
}

Parametry zostają wydedukowane i przekazane dokładnie tak, jak prosiliśmy. Rzućmy okiem na instancjacje trzeciego szablonu (dla poprawienia czytelności zastąpię rozwinięcie std::std::basic_string<char> zwykłym std::string):

template<>
person construct_v3<person, std::string, const std::string&>(std::string&& __args0, const std::string& args)
{
  return person{std::string(std::move(__args0)), std::string(std::move(args))};
}

Interesujące jest dla nas zachowanie kompilatora podczas dedukcji typów dla drugiego argumentu funkcji. Typ wydedukowany Argv_2 to const std::string& (zamiast zwykłego std::string, pojawiającego się we wszystkich poprzednich przypadkach). Typ samego argumentu to również const std::string&. Uniwersalna referencja “zadziałała” zgodnie z zamysłem.

Dzięki takiemu zachowaniu dostaliśmy narzędzie do bardziej optymalnego przekazywania argumentów do funkcji szablonowych. Z tym, że musimy z tego narzędzia odpowiednio skorzystać. Wróćmy do przykładu i funkcji construct_v3. Pierwszy argument został do konstruktora przekazany optymalnie (przez dwie operacje przeniesienia std::move). Jak jest w przypadku drugiego argumentu? Wywołanie std::move na obiekcie typu const std::string& jest wyrażeniem typu const std::string&&. Przeniesienie obiektu o takim typie, ze względu na kwalifikator const nie może się udać. A nawet w naszym przypadku nie powinno – przecież dostaliśmy stałą referencję, więc programista wywołując w ten sposób funkcję nie może się spodziewać, że zmodyfikujemy (konkretnie przeniesiemy) jego obiekt. Zostanie więc tutaj utworzona kopia. Tego potrzebowaliśmy. Z tym, że nie rozpatrzyliśmy jeszcze wszystkich przypadków…

Pozostałe przypadki

Podczas naszych rozważań używaliśmy typu, którego konstruktor przyjmuje wartości przez kopię. Nasza klasa ma jednak działać dla dowolnego typu. Rozważmy więc całkowicie inny przypadek – tym razem typu, którego konstruktor przyjmuje referencję:

class string_builder
{
public:
    string_builder(std::string& destination)
        : m_destination{std::move(destination)}
    {}

private:
    std::string m_destination;
};

Sprawdźmy teraz jak zadziała nasza funkcja:

auto str = std::string{};
auto b = construct_v3<string_builder>(str);

Kompilator pokaże błąd:

error: cannot bind non-const lvalue reference of type ‘std::string&’ to an rvalue of type ‘std::remove_reference<std::__cxx11::basic_string<char>&>::type’ {aka ‘std::string’}

To zrozumiałe – próbujemy tutaj stworzyć obiekt w mniej-więcej taki sposób:

auto str = std::string{};
string_builder b{std::move(str)};

Operacja przypisania r-wartości do niestałej referencji do l-wartości jest zabroniona (komunikat błędu dokładnie o tym mówi). Poprawna konstrukcja powinna wyglądać po prostu tak:

auto str = std::string{};
string_builder b{str};

Okazuje się, że żadna z wersji funkcji generycznej construct nie nadaje się do tworzenia naszej klasy. Innymi słowy, nie jest ona tak generyczna jak byśmy chcieli. Jak więc uzyskać taki efekt w kontekście szablonowym? Albo konkretniej – jak pominąć operację przeniesienia std::move jedynie dla “zwykłych” referencji?

Pomysł jest dość prosty – może nam w tym pomóc wydedukowany typ szablonu dla parametrów przekazywanych jako referencje uniwersalne. Referencje należy przekazać dalej bez zmian, a pozostałe typy rzutować poprzez std::move. Napiszmy więc funkcję, która przekształca wyrażenie w taki sposób, aby posiadało ono odpowiednią kategorię wartości:

template <typename T>
decltype(auto) forward_arg(std::remove_reference_t<T>& val)
{
    if constexpr(std::is_lvalue_reference_v<T>)
        return val;
    else
        return std::move(val);
}

A następnie użyjmy tej funkcji dla każdego argumentu z osobna:

template <typename T, typename... Args>
T construct_v4(Args&&... args)
{
    return T{forward_arg<Args>(args)...};
}

Zanim zobaczymy wygenerowany kod dla tych szablonów, kilka słów wyjaśnienia. Szczególną uwagę należy zwrócić na typ zwracany funkcji forward_arg. Jest on dedukowany, ale nie przez auto, a przez decltype(auto). To ważne, gdyż zwykłe auto gubi kategorie wartości wyrażenia zwracanego. Dedukcja decltype(auto) ją zachowuje – tak jakbyśmy na wyrażeniu stojącym po słowie kluczowym return zastosowali specyfikator decltype – odpowiednio decltype((val)) bądź decltype(std::move(val)). Te dwa typy oczywiście będą różne, ale to nie szkodzi, ponieważ if constexpr z C++17 jest wykonywany podczas kompilacji, a konkretniej instancjacji szablonu dla konkretnego typu. Zobaczmy teraz kod wygenerowany dla poszczególnych instancjacji.

Przy konstrukcji klasy string_builder (przyjmującej referencję):

template<>
std::string& forward_arg<std::string&>(std::string& val)
{
  if constexpr(std::is_lvalue_reference_v<std::string&>) return val;


}

template<>
string_builder construct_v4<string_builder, std::string&>(std::string& args)
{
  return string_builder{forward_arg<std::string&>(args)};
}

Zgodnie z oczekiwaniem referencja l-wartościowa została pozostawiona bez zmian i przekazana dalej. Widzimy, że w forward_arg został spełniony pierwszy warunek. Zobaczmy instancjacje dla klasy person:

// instantiation 1
template<>
std::string&& forward_arg<std::string>(std::string& val)
{
  if constexpr(std::is_lvalue_reference_v<std::string>) ;
  else /* constexpr */ return std::move(val);


}

// instantiation 2
template<>
const std::string& forward_arg<const std::string&>(const std::string& val)
{
  if constexpr(std::is_lvalue_reference_v<const std::string&>) return val;


}

template<>
person construct_v4<person, std::string, const std::string&>(std::string&& __args0, const std::string& args)
{
  return person{std::string(forward_arg<std::string>(__args0)), std::string(forward_arg<const std::string&>(args))};
}

Dla pierwszego, przeniesionego argumentu została zinstancjonowana funkcja której typ zwracany to std::string&&, co spowoduje wywołanie konstruktora przenoszącego. Dla drugiego argumentu z kolei typ pozostanie const std::string& więc ostatecznie zgodnie z oczekiwaniami zostanie zrobiona kopia. Można więc stwierdzić, że szablon zadziałał prawidłowo w każdym przypadku.

Perfect forwarding

Jeżeli nie jesteś zbyt obeznany z szablonami, to implementacja construct_v4, a konkretnie funkcji forward_arg może się wydawać dość skomplikowana, a używanie w kodzie czegoś podobnego mogłoby być bardzo “błędogenne”. Na szczęście nie musisz takiej funkcji pisać za każdym razem. Taka funkcja (zaimplementowana w nieco odmienny sposób) jest dostępna w bibliotece standardowej pod nazwą std::forward. Przyjmowanie argumentu przez referencję przekazującą i przekazywanie jej dalej przez std::forward to technika, którą nazywamy perfect forwarding (nie spotkałem się z jej polskim tłumaczeniem). Ostateczna wersja naszego szablonu z użyciem std::forward wyglądałaby następująco:

template <typename T, typename... Args>
T construct(Args&&... args)
{
    return T{std::forward<Args>(args)...};
}

Nadmienię że nie przypadkowo podane przeze mnie przykłady używały variadic template. Perfect forwarding to technika, która jest używana, kiedy chcemy wywołanie funkcji opakować w jakieś dodatkowe działania albo odwlec w czasie, a w takim przypadku za zwyczaj nie znamy liczby parametrów tej funkcji. Najbardziej znane funkcje tego typu w bibliotece standardowej to std::make_shared i std::make_unique, których zadanie jest bardzo podobne do tego z przykładowej funkcji construct.

Notka dotycząca nazewnictwa

W języku angielskim określenia universal reference oraz forwarding reference są tożsame. Niespójność wynika z historii powstawania standardów. W standardzie C++11, wraz z wprowadzeniem nowych kategorii wartości, pojawiły się zasady dotyczące dedukcji typów w szablonach w kontekście używania ich z nowymi kategoriami wartości. Zasady te nie zostały jakoś dodatkowo formalnie nazwane. Kiedy Scott Meyers pisał wpis na blogu (a później książkę), która w bardziej przystępny sposób opisywała te zasady, wymyślił nazwę universal reference. Dopiero kiedy powstawał standard C++14, wprowadzono nowe nazewnictwo – forwarding reference. W polskiej literaturze nie spotkałem się z tłumaczeniem dosłownym forwarding reference, dlatego pisząc ten artykuł użyłem mniej zgodnego ze standardem spolszczenia – “referencja uniwersalna”.



Mariusz Jaskółka

Entuzjasta języka C++, który to język poznaje od 14 roku życia. Uważa, że za używanie zwrotu "C/C++" powinno się wsadzać do więzienia. Prywatnie lubi grzybobranie, klimaty ogniskowe, Bieszczady, śpiewanie klasyków.



Podobne wpisy


Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.
Polityka Prywatności