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 ?
Bardzo fajny artykuł 😀
std::weak_ptr wp(sp);
Tutaj powinno być sp1
Hej fevo,
Dzięki za komentarz, kod został poprawiony.
To naprawdę dobre, jasne i zwarte omówienie problemu tych dwóch inteligentnych wskaźników.
Bardzo dziękuję !