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

Zarządzanie zasobami w C++ #2 – semantyka przenoszenia (std::move)


2020-02-06, 00:00

W poprzedniej części tego cyklu pokazane zostały metody poprawnej i eleganckiej obsługi alokacji i zwalniania zasobów – wzorzec projektowy RAII. Pominięte zostały jednak kwestie kopiowania, współdzielenia i przenoszenia zasobów. Rzadko jest przecież tak, że zasób jest potrzebny jedynie przez jeden blok kodu. Zwłaszcza że tendencja w inżynierii oprogramowaniu jest taka, że bloki kodu powinny być małe. Zajmijmy się więc tym tematem.

Kopiowanie obiektu powiązanego z zasobem

Załóżmy chwilowo, że żyjemy w erze prehistorycznej dla języka C++, kiedy nie było w nim jeszcze obsługi r-wartości. Wróćmy do przykładu z poprzedniej części artykułu:

class buffer {
    std::byte* value_;

public:
    buffer(std::size_t size)
    {
        value_ = new std::byte[size]; // throws
    }

    ~buffer() { delete[] value_; }

    void* get()
    {
        return value_;
    }
};

Uruchom w Wandbox

Kompilator dla tego typu klas generuje domyślny konstruktor kopiujący i domyślny operator kopiujący. Co więc się stanie, jeżeli go użyjemy?

int main()
{
    {
        auto b1 = buffer{100};
        auto b2 = b1;
    }
    std::cout << "Program finished" << std::endl;
}

Uruchom w Wandbox

Proste kopiowanie obiektu takiej klasy doprowadziło do tego, że zasób jest zwalniany dwukrotnie. W tym konkretnym przypadku zachowanie jest niezdefiniowane (ang. Undefined Behavior, UB), natomiast w przypadku ogólnym jest to po prostu niepożądane.

Z problemem tym możemy poradzić sobie na różne sposoby. Jednym z nich byłoby uczynienie obiektów klasy niekopiowalnymi. Funkcjonalność takiej klasy stałaby się wówczas mocno ograniczona. Inną opcją jest odpowiednia implementacja operacji kopiowania. Możemy na przykład przenieść zarządzanie zasobem (ang. ownership) do drugiego obiektu:

class buffer {
    std::byte* value_;

public:

    buffer(std::size_t size)
    {
        value_ = new std::byte[size]; // throws
    }

    buffer(buffer& other)
    {
        value_ = other.value_;
        other.value_ = nullptr;
    }

    ~buffer() { delete[] value_; }

    buffer& operator=(buffer& other)
    {
        value_ = other.value_;
        other.value_ = nullptr;
        return *this;
    }

    void* get()
    {
        return value_;
    }
};

Uruchom w Wandbox

Nasz przykładowy program zadziałał poprawnie. Czy jednak klasa, którą napisaliśmy, jest elegancka, tak jak chcielibyśmy, żeby była? Od razu odpowiem na to pytanie – nie jest, bo:

  • Kopiowanie, które zaimplementowaliśmy, tak naprawdę nie jest kopiowaniem. Świadczy o tym nawet fakt, że kopiowany obiekt jest w trakcie operacji modyfikowany (gdyby dodać mu modyfikator const, kod przestałby się kompilować).
  • Klasa, którą stworzyliśmy, musi teraz obsługiwać specjalny, pusty stan.
  • Gdybyśmy mieli intuicyjnie nazwać operację, która została zaimplementowana w naszym konstruktorze kopiującym oraz operatorze przypisania, to lepszym słowem niż „kopiowanie”, byłoby „przenoszenie” (ang. move).

W bibliotece standardowej do niedawna znajdowała się klasa, która działała w taki właśnie nieelegancki sposób: std::auto_ptr. Było to rozwiązanie na tyle złe, że została ona ze standardu usunięta (C++17), co w standardzie C++ jest prawdziwą rzadkością.

Nasuwające się pytanie brzmi – co zamiast?

Przenoszenie zasobów pomiędzy obiektami – geneza pomysłu

Przenoszenie zarządzania zasobem do innego obiektu jest operacją bardzo częstą. Moglibyśmy więc do każdej klasy, która obsługuje przenoszenie zasobów dopisać odpowiednią funkcję. Wracając do przykładu:

class buffer {
    std::byte* value_;

public:

    buffer(std::size_t size)
    {
        value_ = new std::byte[size]; // throws
    }

    ~buffer() { delete[] value_; }

    buffer& swap(buffer& other)
    {
        std::swap(value_, other.value_);
        return *this;
    }

    buffer& move(buffer& other)
    {
        return swap(other);
    }

    void* get()
    {
        return value_;
    }
};

Uruchom w Wandbox

Przenoszenie może działać na zasadzie zamiany wartości, gdyż spodziewamy się, że „stary” obiekt i tak „za chwilę” zostanie poddany destrukcji. Gdy się jednak dokładniej przyjrzymy, rozwiązanie to dostarcza jedynie kolejnych problemów. Przenoszenie nie może być osiągnięte na poziomie tworzenia obiektu w sposób wystarczająco czytelny. Musielibyśmy stworzyć najpierw nowy obiekt, żeby natychmiast przypisać mu nową wartość. Sprawa komplikuje się również, gdy chcemy obiekt przenieść przy zwracaniu go z funkcji. Twórcy języka stwierdzili, że jest to zadanie na tyle karkołomne, że jego implementacja na poziomie bibliotek jest niewystarczająca. W myśl powiedzenia „potrzeba matką wynalazków” wprowadzili więc odpowiednią obsługę na poziomie języka.

Nowe kategorie wartości

…a właściwie już całkiem leciwe, bo wprowadzone w standardzie C++11. Chodzi mi tutaj raczej o pokazanie toku myślenia, którym kierowali się twórcy C++, kiedy wprowadzali semantykę przenoszenia.

Aby skategoryzować wyrażenia, których rezultat może zostać bezpiecznie przeniesiony, zostały dodane nowe kategorie wartości. Interesujące dla nas są r-wartości (ang. rvalues), które dzielą się na wartości wygasające (ang. xvalues, eXpiring values), oraz wartości właśnie otrzymane (ang. prvalues, Pure Right values). Kategorie wartości należy wyobrażać sobie jako właściwości wyrażenia, a nie samych obiektów, które te wyrażenia produkują. Wyrażenia ograniczają się do kodu źródłowego (w skompilowanym programie ich już nie ma), natomiast wartości mają swoje odzwierciedlenie jako fizyczne fragmenty pamięci w działającym już programie. Na pierwszy rzut oka wydaje się to dość skomplikowane. Przyjrzyjmy się krótkim przykładom takich wartości oraz zastanówmy, dlaczego zasoby, które produkują, mogą zostać bezpiecznie przeniesione.

Wartości wygasające (xvalues)

Powiedzmy, że mamy obiekt, który po aktualnej operacji na pewno zostanie zniszczony. Na przykład taki, który jest zwracany z bloku kodu poprzez instrukcję return:

my_resource calculate_resource()
{
    my_resource val{};
    //...
    return val; // <- !!
}

Zmienna val nie zostanie już nigdy użyta po instrukcji return, przez co przeniesienie zasobu, którym zarządza, jest bezpieczne.

Wartości właśnie stworzone (prvalues)

Z drugiej strony mamy wartości, które są reprezentowane wyrażeniem wywołania jakiejś funkcji. W szczególności konstruktor jest taką funkcją. W przykładach takie wartości występują po prawej stronie operatora przypisania, stąd też ich nazwa:

my_resource r1 = my_resource{};
my_resource r2 = calculate_resource();

Wartości z kategorii prvalue nie mają w kodzie źródłowym swojej nazwy, przez co nie można się do nich odnieść po zakończeniu danej instrukcji. Jeżeli nie zostaną one przypisane do jakiejś zmiennej, od razu nastąpi ich destrukcja – są to zmienne tymczasowe. Dlatego też przenoszenie zasobów przez nich zarządzanych jest bezpieczne.

Kategorie wartości zdecydowanie dokładniej są omówione we wpisie Dawida Pilarskiego. Tutaj przytoczyłem jedynie przykłady, potrzebne do zrozumienia całości problematyki przenoszenia zasobów.

Referencje do r-wartości

Wynik każdego wyrażenia wyrażającego r-wartość może zostać przypisana do referencji do r-wartości (ang. rvalue reference), co przedłuży czas życia obiektu. W odróżnieniu od „zwykłej” referencji, jej deklaracja posiada 2 znaki ampersand (&&).

my_resource&& r3 = my_resource{};

Bardzo ważne jest to, że obiekt przypisany do referencji do r-wartości przestaje być tymczasowy. Takie przypisanie nie wymaga żadnej operacji kopiowania ani przenoszenia. Działa więc to podobnie do zwykłej referencji, tylko dla innych kategorii wartości.

Różnica pomiędzy przypisaniem r1 oraz r3 jest zagadnieniem dość skomplikowanym i od C++17 wręcz pomijalnym, dlatego nie należy sobie nią zbytnio zaprzątać głowy. Zostanie ona szerzej opisana w następnej części cyklu. W poprzednim przykładzie zmienne są tymczasowe, zostaną po wykonaniu wyrażenia zniszczone, przez co przeniesienie zasobów do innych obiektów (w przykładach r1 i r2), jest bezpieczne.

W tym miejscu muszę zaznaczyć, że dla prostoty pomijam optymalizacje, które kompilator może (a czasem nawet jest zobowiązany) wykonać. Ten aspekt również zostanie omówiony w kolejnej części.

Obsługa przenoszenia zasobów

No dobrze, mamy więc na poziomie języka dobrze zdefiniowanych kandydatów na obiekty nadające się do przenoszenia. Jak z nich skorzystać? Wróćmy do przykładu z buforem. Napiszmy dla niego tak zwany konstruktor przenoszący (ang. move constructor) oraz przenoszący operator przypisania (ang. move assignment operator):

class buffer {
    std::byte* value_;

public:

    buffer(std::size_t size)
    {
        value_ = new std::byte[size]; // throws
    }

    buffer(buffer&& other) // move constructor
    {
        value_ = other.value_;
        value_ = nullptr;
    }

    buffer(const buffer&) = delete;

    ~buffer() { delete[] value_; }

    buffer& swap(buffer& other)
    {
        std::swap(value_, other.value_);
        return *this;
    }

    buffer& operator=(buffer&& other) // move assignment operator
    {
        return swap(other);
    }

    buffer& operator=(buffer&) = delete;

    void* get()
    {
        return value_;
    }
};

Przykładowe użycie:

buffer make_int_buffer()
{
    buffer b{ sizeof(int) };
    // ...
    return b;
}

int main()
{
    {
        auto b1 = buffer{ 100 };
        auto b2 = make_int_buffer();
    }
    std::cout << "Program finished" << std::endl;
}

Uruchom w Wandbox

Jak widać, przekazanie kompilatorowi informacji, że chcemy użyć jedynie obiektów, które nadają się do przenoszenia (są reprezentowane przez r-wartości) odbywa się w sposób całkowicie jawny. Nasz obiekt można przypisać jedynie do referencji do r-wartości. Kopiowanie bufora nie jest możliwe (konstruktor kopiujący oraz operator przypisania zostały jawnie usunięte), więc wszystkie przypisania i konstrukcje na podstawie innych obiektów odbywają się właśnie przez przenoszenie.

Przenoszący operator przypisania został napisany poprzez zamianę zasobu. Jest on skopiowany z poprzedniego przykładu, gdzie mieliśmy zaimplementowaną metodę move. Czy taka jego implementacja jest bezpieczna? Zanim odpowiemy na to pytanie, zastanówmy się nad inną, ale powiązaną kwestią…

Co się dzieje z obiektem przeniesionym?

Jedyne, co standard C++ pośrednio mówi na ten temat to stwierdzenie, że na obiekcie przeniesionym musi się dać poprawnie zawołać destruktor. To dlatego, że prędzej czy później ten destruktor zostanie zawołany dla każdej zmiennej automatycznej (a w dobrze napisanym programie dla każdej innej również). Jeżeli więc przenoszący operator przypisania po prostu wymieni się zasobami z drugim obiektem (jak w przykładzie), to nic złego się nie stanie, ponieważ przeniesiony obiekt zostanie poddany poprawnej destrukcji. Z tym że nie musi się to stać natychmiast, ponieważ twórcy C++ dali nam jedną dodatkową możliwość…

Manualne przenoszenie zasobów (std::move)

Zdarzają się sytuacje, w których chcemy zmodyfikować obiekt przed przeniesieniem go do innego bloku (na przykład do innej funkcji). Popatrzmy na przykład:

void consume_resource(my_resource&& res)
{
    // ...
}

int main()
{
    my_resource res{};
    res.do_stuff();
    consume_resource(res); // error !!!
}

Powyższy kod nie zadziała poprawnie, ponieważ funkcja consume_resource akceptuje jedynie r-wartości, a nasz obiekt res r-wartości nie reprezentuje. Możemy jednak to osiągnąć, przez zwykłe rzutowanie:

void consume_resource(my_resource&& res)
{
    // ...
}

int main()
{
    my_resource res{};
    res.do_stuff();
    consume_resource(static_cast<my_resource&&>(res)); // ok
}

Mimo że nasz obiekt domyślnie nie nadaje się do przenoszenia (bo po wywołaniu funkcji consume_resource moglibyśmy go chcieć nadal używać), to programista może mieć intencje przeniesienia właśnie takiego obiektu. Rzutowanie na referencję do r-wartości to kolejny element układanki pozwalającej nam swobodnie przenosić zarządzany zasób pomiędzy obiektami.

Ponieważ rzutowanie na referencję do r-wartości może wydawać się uciążliwe i mało przejrzyste, możemy sobie ułatwić tę pracę, pisząc prostą funkcję szablonową, której zadaniem będzie jedynie to rzutowanie (dla dowolnego typu) wykonać:

template <typename T>
constexpr T&& r_cast(T& value) noexcept
{
    return static_cast<T&&>(value);
}

…po czym jej użyć:

int main()
{
    my_resource res{};
    res.do_stuff();
    consume_resource(r_cast(res)); // nice!
}

Uruchom w Wandbox

Pomocniczą funkcję r_cast napisałem celowo. Jest to funkcja, która implementuje podobną funkcjonalność, co std::move z biblioteki standardowej (tyle że std::move implementuje ją lepiej, dla większej liczby przypadków). W czasach gdy C++11 dopiero raczkował, programiści mieli często problemy, żeby powiedzieć, czym zajmuje się funkcja std::move. Prosta odpowiedź brzmi – rzutowaniem.

Wsparcie biblioteki standardowej – czyli nigdy więcej nie używaj new i delete

Ponieważ przed standardem C++11 nie mieliśmy jeszcze mechanizmu przenoszenia zasobów, zmyślny wskaźnik, który wtedy znajdował się w bibliotece standardowej – std::auto_ptr – wykonywał swoje zadanie raczej kiepsko. Kopiowanie obiektów tej klasy zajmowały się tak naprawdę przenoszeniem. Klasa ta została zastąpiona całkowicie przez inną – std::unique_ptr – która swoje zadanie robi jak należy. Przenoszenie zostało w niej poprawnie zaimplementowane a mechanizm kopiowania usunięty. Działa on mniej-więcej analogicznie jak w klasie buffer z podanego powyżej przykładu.

Oprócz std::unique_ptr mamy też do dyspozycji klasę std::shared_ptr, która jest dużo cięższa i bardziej skomplikowana pod względem budowy, ale pozwala na dzielenie jednego zasobu przez więcej obiektów. Co za tym idzie pozwala również na kopiowanie.

W poradnikach dotyczących pisania czystego kodu w języku C++ pojawiają się sugestie, że użycie operatora new i delete w kodzie dotyczącym logiki biznesowej jest niezalecane albo wręcz traktowane jako zła praktyka. Oczywiście uogólnienie jest zbyt mocne, ponieważ istnieją biblioteki, które narzucają inny model zarządzania pamięcią niż w bibliotece standardowej (jak na przykład model drzewa obiektów znany z Qt). Ponadto, o ile std::shared_ptr jest klasą z wyraźnym narzutem w stosunku do operowania zwykłymi wskaźnikami, to std::unique_ptr jest z nimi porównywalny. Ogólnie rzecz biorąc jest to jednak praktyka zgodnie uznawana za dobrą. Podsumowując:

Używaj std::make_unique oraz std::make_shared zamiast operatora new. Dodatkowo używaj kontenera std::vector zamiast operatora new[]. Nigdy nie przechowuj adresu do obiektu zaalokowanego na stercie pod „gołym” wskaźnikiem. Jeżeli chcesz postąpić inaczej, miej do tego bardzo dobry powód.

Rzadko się o tym wspomina, ale std::unique_ptr oraz std::shared_ptr potrafią zarządzać zasobami innego typu niż pamięć. Można to osiągnąć poprzez nadpisanie metody czyszczącej. Zapraszam do przeczytania anglojęzycznego artykułu Bartka Filipka na ten temat.

Pojęcie „Nowoczesny C++”

Wprowadzenie do C++ semantyki przenoszenia było krokiem innowacyjnym. Na tyle innowacyjnym, że z powodu jego wprowadzenia ukuło się określenie „Nowoczesny C++” (ang. Modern C++), które dodatkowo odnosi się do zbioru zasad o dobrych praktykach, które weszły w życie wraz z C++11 i poprawkami w C++14. Wywodzi się ono z książki „Effective Modern C++”. Nie jest to stwierdzenie, że język C++ jest nowoczesny (to może być dyskusyjne) tylko sformułowanie rozróżniające ten język od C++ sprzed 2011 roku. Można by było zrobić język C+++, który byłby wstecznie kompatybilny z językiem C++ (który obecnie jest nazywany C++98 lub C++03), ale na tak drastyczny krok twórcy się nie zdecydowali. Dlatego zwrot „Nowoczesny C++” jest pomocny i jest w społeczności szeroko używany. Przykładowo, jeżeli młody programista chciałby nauczyć się języka C++, warto polecić mu materiały traktujące o „Nowoczesnym C++”, co daje pewną gwarancję, że takie źródło nie nauczy go złych praktyk sprzed nowego standardu.

Podsumowanie

W tej części opisany został mechanizm, który jest chyba najważniejszą rzeczą, jaka weszła do standardu C++11, a być może najważniejszą, jaka kiedykolwiek weszła do standardu C++. Wcześniej w języku była dość wyraźna luka, która tym mechanizmem została załatana. W kolejnej części cyklu zostaną opisane trochę bardziej zaawansowane zagadnienia, związane głównie z optymalizacjami dotyczącymi zarządzaniem zasobami, tym razem głównie pamięcią. Standard C++17 przyniósł ze sobą zmiany w tej materii, dlatego temat jest dość ciekawy.



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