Zdobywanie Zasobów Jest Inicjalizacją

… Czyli dlaczego w C++ instrukcja finally nie jest konieczna.

Co jakiś czas słyszy się studentów, którzy narzekają na brak w języku C++ instrukcji finally, którą znają z Javy lub C#. Konstrukcja ta w ww. językach umożliwia przeprowadzenie pewnych akcji bez względu na to, gdzie i jak kończy się wykonanie danej operacji, wg. poniższego bloku:

{
    //Inicjalizacja zasobów
    try 
    {
        //Jakieś operacje
    } 
    catch(/*jakiś wyjątek*/)
    {
        //Obsługa wyjątku
    }
    //... dalsze instrukcje catch dla innych wyjątków
    finally
    {
        //Zwolnienie zasobów bez względu na to, jak kończy się wykonanie tej metody
    }
}

Nie wszyscy jednak zdają sobie sprawę, że obecność bloku finally została dodana do Javy (i przeniesiona do C#) ze względu na różnice modelu zarządzania pamięcią alokowaną dynamicznie występującego w tych językach w stosunku do C++. Otóż warto przypomnieć, że Java i C# (i wiele innych języków) korzystają z niedeterministycznego modelu zarządzanego, tzn. jest jakiś odśmiecacz (Garbage Colector), który "jeździ" po pamięci zajmowanej przez aplikację i sprawdza obiekty pod względem liczby odniesień. Jeśli do obiektu nie istnieje żadne odniesienie, pamięć jest zwalniana. C++ natomiast posługuje się modelem manualnym, tzn. każdy zasób zaalokowany na stercie należy zwolnić własnoręcznie, albo, inaczej ujmując: kod, który będzie zwalniał ten zasób musi być gdzieś napisany. W innym wypadku powstaną wycieki pamięci. Każda z tych technik ma swoje wady i zalety, ale w kontekście tego artykułu jest to nieistotne. Ważna jest tylko prosta konsekwencja: o ile w językach zarządzalnych nie możemy dokładnie przewidzieć, kiedy obiekt jest niszczony, w językach z modelem manualnym można to dokładnie określić.

W momencie niszczenia obiektu w C++ wywoływany jest destruktor obiektu. Skoro potrafimy dokładnie sprecyzować, kiedy obiekt jest tworzony i niszczony, a od zasobu też wymagamy by miał ściśle sprecyzowany czas życia, to automatycznie nasuwa się pomysł, by zasób opakować obiektem, którego konstruktor inicjalizuje ten zasób (np. otwiera plik), a destruktor ten zasób zwalnia (np. zamyka plik). Taka technika nosi nazwę "Zdobywanie zasobów jest inicjalizacją".

Przykład - sekcja krytyczna.

Niektóre niskopoziomowe API (w tym API Windowsów) rozwiązuje sekcje krytyczne poprzez zmienne globalne, które poszczególne bloki funkcjonalności przejmują na własność manualnie i manualnie zwalniają do niej dostęp. Wyobraźmy sobie, że mamy typ SekcjaKrytyczna i (dla uproszczenia) tylko jeden jej egzemplarz. Mamy także dwie funkcje, które korzystają z tej zmiennej. Funkcja służąca do wchodzenia w sekcję krytyczną nosi nazwę wejdźWSekcjęKrytyczną(), a do zwalniania - opuśćSekcjęKrytyczną(). Twórca tych funkcji bardzo się napracował, żeby w każdym przypadku nieprawidłowego zadziałania programu zasób był zwalniany.

SekcjaKrytyczna sekcja; //nasza sekcja krytyczna
const unsigned int MAX_LICZBA_POŁĄCZEŃ = 100000000;
 
int funkcja1()
{
    try
    {
        Połączenie* połączeniePtr = 0; //jakieś połączenie z czymśtam... nie ważne.
        wejdźWSekcjęKrytyczną(sekcja);
 
        połączeniePtr = otwórzPołączenie(); //otwieramy jakieś połączenie
        if(0 == połączeniePtr)
        {
            opuśćSekcjęKrytyczną(sekcja); //1
            return -2;
        }
 
        ... //Jakieś działania, które mogą zaowocować rzuceniem wyjątku typu WyjątekSiakiśTam na zasobie dzielonym
 
        opuśćSekcjęKrytyczną(sekcja); //2
        return 0;
    }
    catch(const WyjątekSiakiśTam& excRef)
    {
        std::cerr << excRef.opis() << std::endl;
        opuśćSekcjęKrytyczną(sekcja); //3
        return -1;
    }
    catch(...)
    {
        opuśćSekcjęKrytyczną(sekcja); //4
        assert(false); //nic innego nie powinno być rzucone
    }
}
 
std::vector<int>& funkcja2()
{
    //Rozszerzanie wektora może troszkę potrwać więc nie ma sensu robić tego w sekcji krytycznej.
    static std::vector<int> intVector(MAX_LICZBA_POŁĄCZEŃ); 
    wejdźWSekcjęKrytyczną(sekcja);
 
    ... //Jakieś działania związane z zasobem dzielonym i dopiero co zaalokowanym wektorem.
 
    opuśćSekcjęKrytyczną(sekcja);
 
    ... //jakieś działania, które nie dotyczą zasobu dzielonego, więc nie muszą być w sekcji krytycznej.
    return intVector;// zwraca referencję do intVector    
}

Powyższy kod może się wydawać bezsensowny i źle zaprojektowany (bo taki jest:>), ale w tym momencie to nie jest istotne - to tylko zabawka, która ma na celu pokazanie czegoś konkretnego, a mianowicie tego, że twórca tego kodu w funkcji funkcja1() użył funkcji opuśćSekcjęKrytyczną() cztery razy. Co by się stało, gdyby oprócz tej jednej sekcji krytycznej było więcej zasobów koniecznych do zwalniania automatycznego? Spowodowałoby to konieczność kopiowania po kilka razy całych bloków kodu, a także skutecznie utrudniłoby modyfikację tej funkcji i zmniejszyło jej odporność na błędy.

Teraz spróbujemy podejść do problemu z pomocą techniki "Zdobywanie Zasobów Jest Inicjalizacją". Opakujemy typ sekcji krytycznej klasą, która za nas będzie się zajmować zwalnianiem dostępu do sekcji. W tym przykładzie zdecydowałem się nie oddzielać interfejsu klasy od jej implementacji, gdyż upraszcza to troszkę kwestie, które nie są istotne z punktu widzenia omawianego tematu.

class Zatrzask
{
    public:
        Zatrzask(SekcjaKrytyczna & initSekcja) : sekcja(initSekcja)
        {
            wejdźWSekcjęKrytyczną(sekcja); //zdobywamy zasób podczas inicjalizacji
        }
 
        ~Zatrzask()
        {
            opuśćSekcjęKrytyczną(sekcja); //zwalniamy zasób
        }
    protected:
    private:
        SekcjaKrytyczna& sekcja; //będziemy przechowywać odwołanie do już istniejącej sekcji krytycznej
};
 
SekcjaKrytyczna sekcja; //nasza sekcja krytyczna
const unsigned int MAX_LICZBA_POŁĄCZEŃ = 100000000;
 
int funkcja1()
{
    try
    {
        Połączenie* połączeniePtr = 0; //jakieś połączenie z czymśtam... nie ważne.
        Zatrzask zatrzask(sekcja); //tylko raz!
 
        połączeniePtr = otwórzPołączenie(); //otwieramy jakieś połączenie
        if(0 == połączeniePtr)
        {
            return -2; //wykonuje się destruktor zmiennej zatrzask
        }
 
        ... //Jakieś działania, które mogą zaowocować rzuceniem wyjątku typu WyjątekJakiśTam na zasobie dzielonym
 
        return 0; //wykonuje się destruktor zmiennej zatrzask
    }
    catch(const WyjątekSiakiśTam& excRef) //wykonuje się destruktor zmiennej zatrzask
    {
        std::cerr << excRef.opis() << std::endl;
        return -1;
    }
    catch(...) //wykonuje się destruktor zmiennej zatrzask
    {
        assert(false); //nic innego nie powinno być rzucone
    }
}
 
std::vector<int>& funkcja2()
{
    //Rozszerzanie wektora może troszkę potrwać więc nie ma sensu robić tego w sekcji krytycznej.
    static std::vector<int> intVector(MAX_LICZBA_POŁĄCZEŃ); 
 
    { //sztuczny blok mający na celu skrócenie czasu życia zmiennej zatrzask
        Zatrzask zatrzask(sekcja);
        ... //Jakieś działania związane z zasobem dzielonym i dopiero co zaalokowanym wektorem.
    } //wykonuje się destruktor zmiennej zatrzask
 
    ... //jakieś działania, które nie dotyczą zasobu dzielonego, więc nie muszą być w sekcji krytycznej.
    return intVector;// zwraca referencję do intVector    
}

Jak widać, ten kod jest dużo bezpieczniejszy (bez względu na to, jak zakończy się funkcja, zasób jest zwalniany), bardziej przejrzysty(w funkcja1() nie ma ani jednej instrukcji zwalniającej zasób) i przez to odporniejszy na błędy programisty (programista nie musi się martwić za każdym razem gdy zmienia ciało funkcji, czy każdy przypadek zakończenia jest "obstawiony", bo wie, że jest).

Technika ta, jak już wspomniałem, jest nie do zastosowania w językach z pamięcią zarządzaną (a tak naprawdę z tzw. odśmiecaczasmi niedeterministycznymi, gdyż odśmiecacze z zliczaniem referencji dają gwarancję przewidywalnego zwalniania zasobów), dlatego wprowadzono w nich blok finally. Mimo to niektórzy praktycy programowania w C++ twierdzą, że technika "Zdobywanie zasobów jest inicjalizacją" nie kompensuje całkowicie braku finally, ale nigdy nie spotkałem się z sytuacją, która potwierdziłaby ten pogląd.

astralastral

Bibliography
1. Stroustrup B. Język C++ Warszawa, WNT 2004.
O ile nie zaznaczono inaczej, treść tej strony objęta jest licencją Creative Commons Attribution-Share Alike 2.5 License.