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

Zarządzanie zasobami w C++ #1 – RAII i wyjątki


2020-01-23, 00:00

Nie trzeba dużo szukać, aby znaleźć oferty dla „Programisty C/C++”. Mam jednak subiektywne odczucie, że sformułowanie „C/C++” jest zbyt daleko idącym uproszczeniem. Czasami możemy usłyszeć też, że C++ to „C z klasami”. To z kolei jest niewątpliwie pewnym niedomówieniem, ponieważ same klasy w C++ byłyby niczym gdyby nie ich specjalne właściwości, których próżno szukać nawet w innych językach obiektowych. Oprócz klas mamy wyjątki oraz szablony, które nie kryją się pod i tak szerokim pojęciem klas. Mechanizmy te powodują, że w języku C++ poprawny kod wygląda inaczej niż ekwiwalent w C. Jednym z największych obszarów, w których różni się język C++ od C jest obszar zarządzania zasobami, czyli temat tego cyklu artykułów.

Bardzo popularne dziś podejście do zarządzania pamięcią, jakim jest obowiązkowe użycie odśmiecacza pamięci (ang. Garbage Collector, GC) działającego „w tle” w języku takim jak C++ nie ma miejsca, ponieważ wiąże się z narzutem wydajnościowym, co z kolei kłóci się z koncepcją „Zero Overhead Principle”. Dodatkowo mechanizm ten dotyczy tylko jednego rodzaju zasobu – pamięci. I choć jest to zasób wykorzystywany przez programy najczęściej, to istnieją jednak inne, które również wymagają starannego ich zwalniania i mechanizmów, które to ułatwiają.

Wątpliwa kompatybilność języków C i C++

Język C++ jest w dużej mierze składniowo kompatybilny z językiem C. Oczywiście jak najbardziej można napisać kod zgodny ze standardem C, ale nie będący poprawny w C++. Nie o tym jednak chcemy teraz rozmawiać. Nie trudno jest przytoczyć fragmenty kodu, które kompilują się w obu tych językach. Poprawność składni oraz fakt, że kod się kompiluje, nie oznacza jeszcze jego poprawności semantycznej. Można więc przytoczyć fragmenty kodu, które dla programisty C wydawać się będą jak najbardziej poprawne, a na które wprawiony programista C++ będzie patrzył co najmniej z podejrzliwością. Oto przykład takiego kodu:

int count_db_entries(const char* table)
{
    database* db;
    db_connect(&db);
    if (!db->secure_connection)
    {
        db_disconnect(db);
        return 0;
    }
    if (!db_table_exist(db, table))
    {
        db_disconnect(db);
        return 0;
    }
    int result = db_count(db, table);
    db_disconnect(db);
    return result;
}

Uruchom w Wandbox

Mamy tutaj funkcję, która łączy się do bazy danych, a następnie zwraca liczbę rekordów podanej tabeli pod warunkiem, że korzystamy z zaufanego połączenia i oczywiście, że tabela o podanej nazwie istnieje. Istotne jest tutaj to, że zarządzamy zasobem w postaci połączenia do bazy danych. Po opuszczeniu tej funkcji zasób ten musi zostać zwolniony poprzez funkcję db_disconnect. Czy w przykładzie jest to zrobione poprawnie? Zależy to właśnie od tego, czy jest on napisany w języku C, czy C++.

Język C pozwala na wyjście z funkcji na 2 sposoby:

  • wykonanie wszystkich instrukcji i dojście do końca bloku funkcji
  • instrukcja return

W języku C++ pojawił się natomiast trzeci sposób zakończenia wykonywania funkcji. Chodzi tutaj o rzucenie wyjątku. Wystarczy, że któraś z wywołanych funkcji (na przykład db_table_exist) rzuci wyjątek, który, jak widać w naszej funkcji, nie zostanie złapany, więc zostanie on przekazany dalej przez stos wywołań. Połączenie z bazą danych nie zostanie poprawnie zerwane.

Niestety, programiści, którzy najpierw nauczyli się języka C, często na początku o tym zapominają. Prowadzi to do wycieku zasobów.

RAII z pomocą

„Co robić, jak żyć?” - można by było zapytać. Przecież nie będziemy sprawdzać, czy któraś z funkcji, którą wołamy, nie rzuca wyjątku. Nie będziemy tym bardziej sprawdzać, czy funkcja, którą woła wywołana przez nas funkcja… nie, nie będziemy. Przecież i tak być może nie chcemy wcale na tym etapie tego wyjątku obsługiwać.

Na szczęście, wprowadzenie elementu języka, który mógłby okazać się problematyczny, spotyka się z wprowadzeniem innego elementu języka, który jest prostym rozwiązaniem tego problemu. W tym przypadku jest to mechanizm rozwijania stosu (ang. stack unwinding), który jest łączony z wzorcem projektowym RAII. Jeżeli któraś funkcja rzuci wyjątek, to przy powrocie do miejsca, w którym jest on łapany (catch), wywoływane są destruktory wszystkich obiektów „zdejmowanych” ze stosu. Poprawny kod w C++ mógłby więc wyglądać tak:

class db_wrapper
{
    database* db{nullptr};
public:
    db_wrapper()
    {
        db_connect(db);
    }
    ~db_wrapper()
    {
        db_disconnect(db);
    }
    database* operator*()
    {
        return db;
    }
    database* operator->()
    {
        return db;
    }
};

int count_db_entries(const char* table)
{
    db_wrapper db;
    if (!db->secure_connection)
    {
        return 0;
    }
    if (!db_table_exist(*db, table))
    {
        return 0;
    }
    return db_count(*db, table);
}

Uruchom w Wandbox

W przykładowym kodzie, dla ułatwienia, pominąłem kwestię domyślnie wygenerowanych funkcji kopiujących. Poruszę ją później.

Dzięki mechanizmowi rozwijania stosu jesteśmy pewni, że każdy z trzech wariantów zakończenia się funkcji spowoduje, że połączenie z bazą danych zostanie bezpiecznie zerwane. Ręczne zarządzanie zasobami (zwłaszcza pamięcią) jest w nowoczesnym języku C++ uważane za złą praktykę. Jeżeli pojęcie „nowoczesny C++” jest dla Ciebie obce, zapraszam do drugiej części artykułu.

Czym jest RAII?

Rola wzorca projektowego RAII jest często sprowadzana do automatycznego wywołania destruktora. Owszem, jest to ważna, ale nie jedyna funkcjonalność tego wzorca. Przyjrzyjmy się jego nazwie – Resource Acquisition Is Initialization, w dosłownym tłumaczeniu „nabywanie zasobu jest inicjalizacją”. Sam Bjarne Stroustrup, autor języka C++, przy każdej możliwej okazji podkreśla, że nazwa wzorca, której sam jest autorem, nie jest najlepsza. Pomijając jednak ten fakt, zwróćmy uwagę na to, że nie mamy tutaj żadnej wzmianki o destrukcji. Działanie wzorca RAII rozpoczyna się wraz z konstrukcją (technicznie uruchomieniem konstruktora) obiektu. To właśnie z tym procesem jest jakby „na sztywno” powiązywane zajmowanie zasobu. Jednakowoż jego zwalnianie jest powiązane z destrukcją obiektu. Ogólnie można byłoby to zapisać:

RAII jest wzorcem projektowym, którego celem jest powiązanie czasu zajmowania zasobu z czasem życia obiektu – od jego konstrukcji po destrukcję.

Czym jest zasób? Może być fragmentem pamięci na stosie, połączeniem do bazy danych, wątkiem, procesem, blokadą muteksa, urządzeniem czy plikiem na dysku. Generalizując:

Zasób to byt, który jest zajmowany w celu wykonania jakiejś operacji, ale jednocześnie wymaga zwolnienia zaraz po tym, jak przestanie być używany.

Czym byłoby RAII bez mechanizmu wyjątków?

Pokazaliśmy, jak wzorzec projektowy RAII pozwala w łatwy i lekki sposób wspomagać programistę przy prawidłowej obsłudze wyjątków. Spójrzmy jednak na korelację tych dwóch mechanizmów z drugiej strony. Załóżmy, że mamy klasę reprezentującą bufor pamięci zaalokowanej na stercie:

class buffer {
    std::byte* value_;

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

    ~buffer() { delete[] value_; }
    // w przykładzie pomińmy "Rule Of Five"

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

Uruchom w Wandbox

Klasa taka będzie działać i radzić sobie, dopóki na stercie będzie wolny ciągły obszar pamięci o żądanym rozmiarze. Co się natomiast stanie gdy jej zabraknie? Obiekt klasy buffer zostanie stworzony, jednak zasób nie zostanie zarezerwowany. Nie jest to do końca zgodne z ideą RAII, bowiem – gdyby ją interpretować dosłownie – to albo obiekt żyje i zasób jest zarezerwowany, albo obiektu nie ma i zasób nie jest zarezerwowany. Innej opcji być nie powinno – zasób bez obiektu lub obiekt bez zasobu nie powinny istnieć.

Aby osiągnąć powyższą zależność, należy zawsze w konstruktorze rzucić wyjątek, jeżeli tylko znajdziemy się w sytuacji, w której zasób nie może zostać zarezerwowany. Spowoduje to przerwanie konstrukcji obiektu. Zobaczmy uzupełniony przykład:

class buffer {
    std::byte* value_;

public:
    buffer(std::size_t size)
    {
        value_ = new(std::nothrow) std::byte[size];
        if (!value_)
            throw std::bad_alloc();
        // lub inaczej: value_ = new std::byte[size];
    }

    ~buffer() { delete[] value_; }

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

Uruchom w Wandbox

Zobaczmy również w jaki sposób rzucenie takiego wyjątku zapobiega konstrukcji obiektu:

void foo()
{
    try {
        buffer my_buff{ 100'000'000 };
        bar(my_buff.get());
    } catch (std::bad_alloc& ex) {
        // cannot use my_buff here
    }
    // cannot use my_buff here
}

Uruchom w Wandbox

Jeżeli konstrukcja bufora nie powiedzie się, to blok try zostanie przerwany, a w bloku catch nie można użyć żadnej zmiennej lokalnej z bloku try. Nie można jej też użyć za blokiem try. Nie bez powodu więc try jest blokiem (ang. scope), a nie na przykład jedną instrukcją. Poza blokiem dostęp do zmiennych automatycznych jest już niemożliwy (co nie ma miejsca w niektórych językach programowania).

Jeżeli nie chcemy używać wyjątków…

Wyjątki kosztują. Mało czy dużo – kwestia dyskusyjna, ale posiadają narzut w wykonywaniu programów. Niektórzy z tego powodu świadomie rezygnują z ich użycia. Kompilatory posiadają nawet specjalne przełączniki, które umożliwiają całkowite ich wyłączenie. Nie oznacza to, że RAII nie ma wtedy racji bytu. W takich sytuacjach również chroni przed wyciekami zasobów, które są spowodowane zwyczajnym roztargnieniem programisty. Każdy programista popełnia błędy i warto stosować techniki, które pozwalają zmniejszyć ich liczbę.

Co jednak zrobić, gdy – jak w poprzednim przykładzie – konstruktorowi obiektu nie uda się pozyskać zasobu? W C++ mamy kilka rodzajów obsługi błędów, natomiast wiele z nich nie ma zastosowania w konstruktorach. Przykładowo, konstruktor nie może zwrócić pustego obiektu typu std::optional<buffer>. Jeżeli nie chcemy używać wyjątków, a chcemy mieć obsługę typów RAII, to musimy obsłużyć stan „pusty” naszego obiektu. W literaturze angielskiej taki stan nie ma usystematyzowanej nazwy – jest kilka stosowanych wymiennie: empty, invalid, uninitialized lub valueless. I tak przykładowo naturalnym stanem pustym dla wskaźnika jest nullptr, dla kontenera jest kontener o rozmiarze zerowym itp. Czasami jednak taki stan trzeba obsłużyć na przykład dodatkową flagą lub też trzymaniem pola o typie std::optional<Foo> zamiast przykładowego Foo. Polecam to drugie rozwiązanie, gdyż (w mojej opinii) jest bardziej czytelne i zgodne z zasadą Single Responsibility Principle, czyli z SOLID.

Wróćmy do przykładów z buforem. Moglibyśmy osiągnąć coś takiego:

class buffer {
    std::byte* value_;

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

    ~buffer() { delete[] value_; }

    bool is_valid() const
    {
        return value_ != nullptr;
    }

    operator bool() const
    {
        return is_valid();
    }

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

Uruchom w Wandbox

Używanie takiej klasy powinno być bardziej asertywne:

void foo()
{
    buffer my_buff{ 100'000'000 };
    if (!my_buff) {
        handle_error();
    } else {
        bar(my_buff.get());
    }
}

Uruchom w Wandbox

Wsparcie biblioteki standardowej

Dziś w bibliotece standardowej jest wiele typów, które są implementacją wzorca RAII dla konkretnych zasobów. W pierwszych standardach języka (do C++03) były to jednak raczej skromne dwie klasy ogólnego przeznaczenia:

  • std::auto_ptr dla wskaźnika zarządzającego obiektem na stercie. Tego typu kategorycznie nie powinno się jednak używać w nowszych wersjach języka – został całkowicie zastąpiony przez std::unique_ptr.
  • std::vector dla wskaźnika zarządzającego tablicą na stercie.

Nie bez powodu pierwszy z nich został całkowicie usunięty z biblioteki standardowej. Jego implementacja, a konkretnie obsługa jego kopiowania, niosła ze sobą spore problemy – była po prostu zła…

Problemy z RAII

W powyższych rozważaniach przemilczeliśmy istotną kwestię. Chodzi o kopiowanie obiektów RAII oraz „przenoszenie” ich pomiędzy blokami kodu. Kwestie te były pewną bolączką przed standardem C++11, lecz były poprawione sukcesywnie wraz z nowymi standardami języka. Zostaną one omówione w kolejnych częściach tego cyklu artykułów.

Podsumowanie

Zarządzanie zasobami w języku C++ sprawia, że jest to język bardzo wyjątkowy. Jedynie młodsze i mniej popularne języki podobne podejście zaadoptowały. W społeczności programistów istnieją bardzo skrajne opinie o zarządzaniu pamięcią w C++. Jedni twierdzą, że zarządzanie pamięcią (ciekawe, że często pomija się inne rodzaje zasobów) jest w pełni manualne. Prawdopodobnie opinie te pochodzą od osób, które ściśle wiążą język C++ z językiem C. Pojawiają się też głosy zupełnie przeciwne – że język C++ jest wręcz stworzony do zarządzania zasobami. Prawdopodobnie, jak zwykle, prawda jest gdzieś pośrodku.

Czytaj dalej

Zarządzanie zasobami w C++ #2 – semantyka przenoszenia (std::move)
Zarządzanie zasobami w C++ #3 – RVO, NRVO i obowiązkowe RVO w C++17



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