[C++] Szablony klas inteligentnych wskaźników

opublikował: Razi91, 2014-03-09

Jednym z największych wyzwań podczas tworzenia dużych aplikacji jest efektywne zarządzanie pamięcią. W językach poziomu C i C++ nie ma żadnych mechanizmów które zabezpieczają przed tzw. wyciekiem pamięci – zjawisku w którym tworzony jest obiekt, ale gubiony jest do niego wskaźnik w skutek czego pozostaje on w pamięci nieużywany i niemożliwy do usunięcia aż do zakończenia programu.

Stos i sterta

Jak wiele języków (środowisk) w C++ pamięć dzieli się na stos oraz stertę.

Stos

Stos to „pamięć podręczna”, miejsce gdzie trzymane są lokalne zmienne (w tym obiekty, ale lokalne!). Pamięć ta jest rezerwowana w momencie wywołania funkcji (przesunięcie wskaźnika stosu do przodu) oraz zwalniana gdy się z tej funkcji wychodzi (wskaźnik jest cofany), kompilator sam na podstawie symboli znajdujących się w tej funkcji/metodzie wywołuje odpowiednie destruktory. Pamięć ta ma jedną wadę — jest ograniczona i nie powinno się w niej trzymać dużych danych.

class Bar{
public:
  ~Bar(){std::cout<<\"destruct Bar\\n\";}
};
class Foo{
public:
  Bar bar;
  ~Foo(){std::cout<<\"destruct Foo\\n\";}
  int getVal(){ return 1; }
};

int foo(){
  Foo obj;
  return obj.getVal();
}

Zarówno obiekty `obj\' jak i jego pole `bar\' zostaną usunięte po zakończeniu funkcji `foo()\'.

Sterta

Sterta to „wszystko poza stosem”. Jest to pamięć rezerwowana w C++ poprzez operator new, który zwraca wskaźnik do tej pamięci (i przy okazji wywołuje odpowiedni konstruktor). Pamięć tak zarezerwowana musi zostać ręcznie zwolniona operatorem delete, w przeciwnym razie zostanie zwolniona dopiero w momencie wyłączenia programu. Jeśli utracimy wskaźnik do takiego obiektu nie będziemy w stanie go ani wykorzystać, ani usunąć. Po zakończeniu funkcji lub destrukcji obiektu który taką pamięć zadeklarował usuwany jest właśnie jedynie wksaźnik – zarezerwowana pamięć pozostaje.

class Bar{
public:
  ~Bar(){std::cout<<\"destruct Bar\\n\";}
};
class Foo{
public:
  Bar *bar;
  Foo(){ bar = new Bar();}
  ~Foo(){std::cout<<\"destruct Foo\\n\";}
  int getVal(){ return 1; }
};

int foo(){
  Foo *obj = new Foo();
  return obj->getVal();
}

W tym przypadku żaden destruktor nie będzie wywołany, żadna pamięć nie zostanie zwolniona, po każdym wywołaniu `foo()\' w pamięci pozostaną oba obiekty bez żadnej referencji do nich.

Zarządzanie pamięcią

Najprostszą metodą jest oczywiście trzymanie wszystkich wskaźników w polach klasy i usuwanie pamięci na którą wskazują w destruktorach. Działa to jednak tylko gdy dane organizują się w drzewa: każdy obiekt ma swój własny zestaw „dzieci” i tylko jednego rodzica.

W grach na planszy może być jednocześnie kilkaset elementów używających tych samych zasobów tj. grafiki, dźwięki, nie ma sensu by każdy z tych obiektów ładował własny taki zestaw, wtedy najlepiej stworzyć menedżer zasobów, który będzie je ładował na początku rozgrywki (ktoś paimęta ekran ładowania z Quake 3 Arena?) i później ewentualnie dodawał kolejne potrzebne (do gry dołącza ktoś używający innego modelu postaci), a same obiekty posiadają wtedy tylko wskaźniki do tych zasobów, ale same ich nie zwalniają.

Czasem jednak dane są zbyt ciężkie żeby pozwolić sobie na ciągłe trzymanie ich w pamięci, powinny być załadowane wtedy i tylko wtedy gdy faktycznie są wykorzystywane. Trzeba wtedy zadbać by przy destrukcji użytkowników takiego zasobu zadbać o to by zasób nie był usunięty przedwcześnie (gdy są jeszcze inni użytkownicy) lub w ogóle (ostatni użytkownik zniszczony, a zasób pozostaje). Tutaj z pomocą przychodzą inteligentne wskaźniki.

Inteligentne wskaźniki

C++11 włącza w standard szablonowe klasy inteligentnych wskaźników (wcześniej można było samemu je zaimplementować lub skorzystać z biblioteki Boost). Są to klasy zachowujące się jak wskaźniki które pozwalają wygodniej zarządzać pamięcią. Mamy do dyspozycji 3 klasy:

  • unikalne (unique_ptr) — unikalny wskaźnik na obiekt, obiekt pod tym wskaźnikiem zostanie usunięty gdy ten wskaźnik zostanie zniszczony lub zresetowany
  • współdzielony (shared_ptr) — współdzielony wskaźnik przez wiele wskaźników tego typu, obiekt wskazywany przez ten wskaźnik zostanie zniszczony gdy ostatni współdzielony wskaźnik zostanie zniszczony
  • słaby (weak_ptr) — wskazuje na obiekt wskazywany przez wskaźniki współdzielone, ale nie będzie utrzymywał życia tego obiektu, gdy wskaźniki współdzielone zostaną zniszczone, obiekt również, nawet jeśli pozostaną jeszcze wskaźniki słabe (zaczną wskazywać na nullptr)
  • Unikalny

    Unikalne wskaźniki przydają sie gdy mamy tylko jeden wskaźnik do danego obiektu i nie więcej.

    class Foo{
    public:
      std::unique_ptr bar;
      Foo() : bar (std::unique_ptr(new Bar())) {} //poprzez inicjalizację
      //Foo() { bar = std::move(std::unique_ptr(new Bar()));} //poprzez przypisanie
      ~Foo() {std::cout<<\"destruct Foo\\n\";}
      int getVal(){ return 1; }
    };
    

    Obiekt `bar\' zostanie usunięty, zajmie się tym destruktor klasy wskaźnikowej std::unique_ptr. Obiekt na takim wskaźniku tworzy się od razu przy zadeklarowaniu wskaźnika:

    std::unique_ptr foo(new Foo());
    albo poprzez przeniesienie innego (unikalnego) wskaźnika:
    foo = std::move(std::unique_ptr(new Foo()));
    Wtedy wskaźnik podany za argument std::move zostanie zwolniony (wskaże nullptr), a jedynym właścicielem będzie ten nowy wskaźnik (`foo\').

    Współdzielony

    Wiele wskaźników współdzielonych może wskazywać na ten sam obiekt.

    class Bar{
    public:
      ~Bar(){std::cout<<\"destruct Bar\\n\";}
    };
    
    class Foo{
      int n;
    public:
      std::shared_ptr bar;
      Foo(int n) : n(n){}
      ~Foo(){std::cout<<\"destruct Foo \"< bar(new Bar());
      obj1->bar = bar;
      obj2->bar = bar;
      std::cout<<\"release local pointer\\n\";
      bar.reset();
      std::cout<<\"destruct objects\\n\";
      delete obj1;
      delete obj2;
      std::cout<<\"end\\n\";
    }
    

    Wynikiem wywołania funkcji `foo()\' będzie wypisanie kolejno:

    release local pointer
      destruct objects
      destruct Foo 1
      destruct Foo 2
      destruct Bar
      end
    Co świadczy o tym, że obiekt `bar\' został zniszczony dopiero po zniszczeniu ostatniej referencji, czyli ostatniego wskaźnika inteligentnego, a jednocześnie przed końcem funkcji w której był stworzony. Metoda reset() tych wskaźników jest równoznaczna z utratą referencji (zniszczenia wskaźnika, ale do tego wskaźnika można przypisać kolejny).

    Słaby

    Słabe wskaźniki stosuje się gdy używamy wskaźników współdzielonych, ale chcemy użyć referencji do takiego obiektu gdzie będzie on przydatny, ale nie wymagany do działania (dobrze jak jest, ale jak go nie ma to nie zakłóci to działania), przykładowo niektóre przypadki słuchaczy: co z tego że nasłuchują, skoro nic z tym nie robią, nie są nigdzie indziej wykorzystywani? Bazując na poprzednich definicjach klas:

    void foo(){
      Foo *obj1 = new Foo(1);
      Foo *obj2 = new Foo(2);
      std::shared_ptr bar(new Bar());
      std::weak_ptr weak(bar);
      obj1->bar = bar;
      obj2->bar = bar;
      std::cout<<(!weak.expired()?\"weak not null\\n\":\"weak null\\n\");
      std::cout<<\"release local\\n\";
      bar.reset();
      std::cout<<(!weak.expired()?\"weak not null\\n\":\"weak null\\n\");
      std::cout<<\"destruct objects\\n\";
      delete obj1;
      std::cout<<(!weak.expired()?\"weak not null\\n\":\"weak null\\n\");
      delete obj2;
      std::cout<<(!weak.expired()?\"weak not null\\n\":\"weak null\\n\");
      std::cout<<\"end\\n\";
    }
    

    Da wynik:

      weak not null
      release local
      weak not null
      destruct objects
      destruct Foo 1
      weak not null
      destruct Foo 2
      destruct Bar
      weak null
      end
    Oznacza to że słaby wskaźnik utrzymuje referencję do obiektu, ale gdy ten zostanie zniszczony, będzie o tym wiedział.

    Słabe wskaźniki są niebezpieczne podczas pracy wielowątkowej, kiedy chce się na nich działać, bezpiecznie jest utworzyć z nich tymczasowo wskaźnik współdzielony, aby nie został usunięty w tym samym czasie:

      if(auto barPtr = bar.lock()){
          //operacje na barPtr
      }
      
    W ten sposób jednocześnie dowiemy się czy on już wygasł (funkcja lock zwróci nullptr, a wtedy warunek nie jest spełniony) oraz uzyskamy tymczasowy (w zasięgu tego warunku) wskaźnik współdzielony.

    Jak ich używać?

    Przede wszystkim są to klasy szablonowe, stąd też konieczność użycia przy deklaracji nawiasów trójkątnych określających typ jaki ma przechowywać. Pomoże to nie tylko kompilatorowi, ale również też środowiskom programistycznym w podpowiadaniu kodu. Same w sobie są obiektami, których nie powinno się tworzyć na stercie(!), jedynie na stosie wewnątrz metod/funkcji lub jako pola klas. Wtedy mamy gwarancję że kompilator zadba o ich usunięcie i wywołanie destruktorów.

    Największą zaletą inteligentnych wskaźników jest ich proste używanie niczym nieróżniące się od normalnych. Mamy takie metody i operatory jak:

    • * —dereferencja obiektu (zwraca wartość, to samo co *ptr)
    • -> — dereferencja pól obiektu (foo->metoda())
    • = — przypisanie, w przypadku shared i weak przypisać można dowolny wskaźnik shared, w przypadku unique tylko to, co zwróci std::move() (funkcja przenosi prawa własności)
    • T* get() — zwraca normalny wskaźnik
    • long use_count() — (tylko shared i weak) zwraca ilość użytkowników tego wskaźnika
    • bool unique() — (tylko shared) zwraca czy jest to ostatni egzemplarz wskaźnika na dany obiekt
    • bool expired() – (tylko weak) zwraca czy dany obiekt został zniszczony
    • shared_ptr<T> lock() – (tylko weak) zwraca wskaźnik współdzelony lub nullptr w celu zablokowania tego obiektu przed usunięciem
    Więcej o wskaźnikach można poczytać tutaj oraz tutaj.

    Pułapki

    Używając wskaźników shared należy pamiętać, że taki może się „porozumiewać” z pozostałymi shared tylko wtedy, gdy TYLKO PIERWSZY otrzymał surowy normalny wskaźnik, a reszta została utworzona lub przypisana na podstawie jakiegoś inteligentnego wskaźnika (i nie należy przy tym używać get()). W przeciwnym wypadku powstaną dwa lub więcej liczniki i obiekt zostanie usunięty zanim zniszczeni zostaną wszyscy użytkownicy.

    Również jak wspomniałem wcześniej, inteligentne wskaźniki nie powinny wylądować bezpośrednio na stercie, nie należy ich tworzyć operatorem new gdyż wtedy tracą one sens.


    Zarejestruj się albo zaloguj aby dodać komentarz

    Artemis 10.03.2014 o 20:12

    Niezwykle gruntowne podejście do tematu i świetny artykuł. Gratulacje :)

wszystkie oferty

 

 

 

 
 
 

Kalendarz wydarzeń

WSZYSTKIE WYDARZENIA