C++11 #11: Inteligentne wskaźniki wchodzą do gry: std::shared_ptr, std::weak_ptr.

|

std::shared_ptr.

W odróżnieniu od std::unique_ptr, std::shared_ptr opiera się na współdzieleniu właśności (ang. shared ownership) przechowywanego zasobu. Z tego powodu możemy posiadać wiele inteligentnych wskaźników typu std::shared_ptr, wskazujących na ten sam obiekt w pamięci.

Ten koncept ma bardzo dużo wspólnego z realizacją automatycznego odśmiecania pamięci w innych językach programowania, ponieważ klasa std::shared_ptr ma zaimplementowany mechanizm tzw. zliczania referencji.

Implementacja współdzielonego wskaźnika zawiera 2 główne elementy – wskaźnik na przechowywany obiekt oraz wskaźnik na dynamicznie alokowany licznik. Licznik mówi nam o tym, ile obiektów klasy std::shared_ptr pokazuje aktualnie na interesujący nas zasób. Jeśli wartość licznika spadnie do zera, przechowywany obiekt nie jest nam potrzebny – zostanie usunięty, a pamięć po nim zostanie poprawnie zwolniona.

Kiedy licznik jest aktualizowany ?

Oczywiście w momencie tworzenia i niszczenia obiektu klasy std::shared_ptr, a zatem w konstruktorach i destruktorach. Licznik jest także aktualizowany podczas przypisania jednego obiektu takiego inteligentnego wskaźnika do drugiego:

std::shared_pt<MyClass> sp1, sp2; /* Zakładamy, że sp1 oraz sp2 wskazują
                                    * na różne obiekty typu MyClass.
                                    */
...
sp1 = sp2; /* W tym momencie sp1 zaczyna pokazywać na to, na co pokazuje sp2.
            * Licznik referencji związany z obiektem pokazywanym wcześniej
            * przez sp1 jest dekrementowany, natomiast licznik związany z obiektem 
            * pokazywanym przez sp2 jest inkrementowany.
            */

Sytuacją wyjątkową są tutaj dwa elementy: konstruktor przenoszący oraz przenoszący operator przypisania – oba związane z tzw. semantyką przeniesienia. W przypadku wywołania którejś z tych konstrukcji, std::shared_ptr zachowa się podobnie do std::unique_ptr – wskaźnik źródłowy zostanie wyzerowany. W takim przypadku licznik dotyczący zasobu, przechowywanego w std::shared_ptr, nie zmieni się (nastąpi przeniesienie, a nie skopiowanie – w takim przypadku nie ma sensu ruszać licznika).

Taka operacja inkrementacji bądź dekrementacji licznika powinna być w środowisku wielowątkowym operacją atomową. Trzeba zawsze brać to pod uwagę podczas stosowania klasy std::shared_ptr.

Konsekwencje  współdzielonej własności.

std::shared_ptr jest faktycznie bardzo sensownym rozwiązaniem, ale wg mnie w dość wąskiej dziedzinie – w momencie, kiedy faktycznie potrzebujemy udostepnić jeden obiekt do użycia w wielu miejscach. To naprawdę nie jest aż tak częsta sytuacja, jak mogłoby się wydawać. W zdecydowanej większości przypadków o wiele korzystniej jest użyć klasy std::unique_ptr, zwłaszcza, że jest ona o wiele bardziej „bezobsługowa”.

Użycie wskaźnika współdzielonego wiąże się z pewnymi kosztami oraz konsekwencjami:

  • std::shared_ptr wypada gorzej pod względem wydajności, w porównaniu zarówno do surowego wskaźnika, jak i do std::unique_ptr – oczywiście w większościu zastosowań będzie to niezauważalne, ale różnicę w porównaniu do innych rozwiązań należy zauważyć
  • obiekt współdzielonego wskaźnika jest dwa razy większy od zwykłego wskaźnika, ze względu na wspomniane wczesniej zliczanie referencji
  • jak już wyżej wspomniałem, operacje na liczniku referencji muszą być operacjami atomowymi, jeśli chcemy korzystać z więcej niż jednego wątku

Z wewnętrzną implementacją klasy std::shared_ptr wiąże się jeszcze jedna pułapka, z którą na pewno spotkamy się podczas codziennego stosowania współdzielonego wskaźnika.

Pytanie brzmi: Skąd std::shared_ptr może wiedzieć, czy nie istnieje inny inteligentny wskaźnik, który przechowuje dokładnie ten sam dynamiczny obiekt, którym chcemy zainicjalizować nowo tworzony wskaźnik współdzielony ? W dwóch słowach: Nie może.

Jak najbardziej możliwa jest sytuacja, w której jeden dynamicznie zaalokowany obiekt został użyty do inicjalizacji dwóch obiektów typu std::shared_ptr i każdy z tych inteligentnych wskaźników posiada niezależne od siebie liczniki referencji. Prowadzi to oczywiście do próby wielokrotnego zwolnienia pamięci po przechowywanym obiekcie…

Jak w takim razie korzystać z klasy std::shared_ptr ?

Wnioski są takie, że należy korzystać ze wskaźnika współdzielonego zgodnie z pewnymi zasadami, które, jak to w C++ bywa bardzo często, nie są sztywnymi wymogami. Poniżej moja subiektywna lista najważniejszych uwag:

  • Unikaj tworzenia wielu obiektów typu std::shared_ptr z jednego surowego wskaźnika:
int* ptr = new int;
std::shared_ptr<int> sp1(ptr);
std::shared_ptr<int> sp2(ptr); // Baaardzo zły pomysł !
  • Preferuj użycie szablonu funkcji std::make_shared podczas tworzenia współdzielonego wskaźnika z nowego obiektu:
std::shared_ptr<int> sp = std::make_shared<int>(4);

/* Następuje dynamiczna alokacja pamięci dla obiektu 
 * typu int oraz tworzony jest obiekt klasy std::shared_ptr<int>,
 * który staje się pierwszym właścicielem dynamicznie zaalokowanego
 * int'a, a następnie cały inteligentny wskaźnik jest zwracany przez funkcję.
 */

Jak widać, ten pomocny szablon ukrywa wewnątrz wiele operacji (np. alokację pamięci) oraz jawnie wskazuje miejsce, w którym tworzymy współdzielony wskaźnik z nowego zasobu.

  • Jeśli chcesz utworzyć nowy wskaźnik współdzielony z już istniejącego zasobu, przechowywanego w obiekcie std::shared_ptr, użyj konstruktora kopiującego, jako argument podając istniejący obiekt std::shared_ptr:
std::shared_ptr<int> sp1 = std::make_shared<int>(10);

...

std::shared_ptr<int> sp2(sp1); /* Następuje współdzielenie własności obiektu 
                                * przechowywanego w sp1. Licznik referencji 
                                * zwiększa się o 1. sp1 oraz sp2 pokazują na 
                                * dokładnie tę samą, dynamicznie zaalokowaną 
                                * pamięć, która przechowuje int'a o wartości 10.
                                */

Jeśli chodzi o codzienne korzystanie z klasy std::shared_ptr, jej interfejs ma wiele wspólnego z interfejsem klasy std::unique_ptr (dokładny opis interfejsu można znaleźć TUTAJ). Operacje takie, jak dereferencja inteligentnego wskaźnika, pobranie surowego wskaźnika czy zresetowanie obiektu współdzielonego wskaźnika wyglądają identycznie, jak w przypadku wskaźnika unikalnego.

std::weak_ptr.

Tzw. wskaźnik słaby jest bardzo ciekawą klasą. Jest to implementacja wskaźnika, poprzez który w razie potrzeby możemy się dostać do interesującego nas zasobu, ale który nie współdzieli własności tego zasobu.

Dopóki nie potrzebujemy wspomnianego zasobu, obiektu klasy std::weak_ptr nie interesuje, czy ten zasób istnieje (być może został już zniszczony, być może nadal znajduje się w pamięci) – jest tylko, a może i aż, punktem zaczepienia.

Nieprzypadkowo piszę o tej klasie w tym miejscu – wskaźnik słaby ma sens tylko w połączeniu ze wskaźnikiem współdzielonym. Jest jego uzupełnieniem. Nie można wykonać na takim słabym wskaźniku ani dereferencji, ani sprawdzenia na obecność std::nullptr (bo de facto nie przechowuje on interesującego nas zasobu/wskaźnika).

W momencie poprawnego użycia klasy std::weak_ptr, w połączeniu ze wskaźnikiem współdzielonym, obiekt wskaźnika słabego umożliwia dostęp do zasobu przechowywanego w std::shared_ptr, ale nie zwiększa to wspomnianego wcześniej licznika referencji. Dlatego powiązane obiekty typu std::weak_ptr nie mają wpływu na działanie danego obiektu klasy std::shared_ptr.

Jak dostać się do zasobu poprzez std::weak_ptr ?

I dochodzimy do sedna – jak bezpośrednio skorzystać z takiego inteligentnego wskaźnika i jak go poprawnie tworzyć. Spójrzmy na kod:

std::shared_ptr<int> sp1 = std::make_shared<int>(4);
std::weak_ptr<int> wp(sp1);

...

std::shared_ptr<int> sp2 = wp.lock();

if(sp2)
{
    // Można skorzystać z sp2, ponieważ jest poprawny.
}

To pokazuje taki typowy przypadek użycia. Wskaźnik słaby jest konstruowany z interesującego nas wskaźnika współdzielonego. Gdy chcemy skorzystać z obiektu std::weak_ptr, musimy wywołać funkcję składową lock, która utworzy nowy std::shared_ptr, będący współwłaścicielem interesującego nas zasobu. Jeśli w międzyczasie zasób przechowywany w sp1 uległ zniszczeniu (bo sp1 a także inni współwłaściciele przestali istnieć), zwrócony obiekt typu std::shared_ptr będzie pusty (będzie zawierał std::nullptr) – stąd potrzeba sprawdzenia wyniku przed użyciem. Co niemniej ważne – wywołanie funkcji lock jest operacją atomową.

Jak widać, w momecie faktycznego użycia zasobu, nie korzystamy z std::weak_ptr, ale z utworzonego na jego podstawie std::shared_ptr. Wskaźnik słaby jest więc tak naprawdę uchwytem do interesującego nas zasobu, który to wskaźnik możemy łatwo i bezproblemowo kopiować oraz przechowywać.

Zastosowania klasy std::weak_ptr.

Pomiając oczywiste i podstawowe funkcje, bardzo podoba mi się przykład realizacji za pomocą std::weak_ptr pamięci typu cache. Wskaźnik słaby wpisuje się w ten pomysł niemal idealnie.

Mamy więc obiekty (typu std::weak_ptr), w których możemy przechować uchwyty do używanych ostatnio w różnych miejscach systemu zasobów (reprezentowanych przez obiekty typu std::shared_ptr) i w razie potrzeby spróbować się do nich dostać. Jeśli nie zostały usunięte z pamięci (istnieje choć jeden wskaźnik współdzielony, który na nie pokazuje), to super – możemy ich użyć bez kosztownych operacji wczytywania. Jeśli zostały już usunięte – trudno, trzeba wczytać je ponownie. Podstawą jest to, że możemy sprawdzić dostępność zasobu, do którego posiadamy uchwyt w postaci obiektu std::weak_ptr.

Podsumowując, std::weak_ptr nigdy nie ingeruje w system oparty na obiektach typu std::shared_ptr, ale daje możliwość dostępu do zasobów i sprawdzenia ich poprawności w każdym momencie.

Kontynuuj poznawanie C++11: C++11 #12: Jak działa semantyka przeniesienia ?

5 komentarzy do “C++11 #11: Inteligentne wskaźniki wchodzą do gry: std::shared_ptr, std::weak_ptr.”

Dodaj komentarz