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.

    Zaloguj się aby dodać komentarz

      Artemis 10.03.2014 o 20:12

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