Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Autorz: pekfos
Rozszerzenie materiału: Grzegorz 'baziorek' Bazior
Kurs C++

Wskaźniki inteligentne (C++11)

[lekcja] Rozdział 46. Omówienie funkcjonalności standardowych wskaźników inteligentnych.

Wstęp

Wszystkie rzeczy omówione poniżej wymagają C++11 i dołączenia nagłówka <memory>.
Zacznijmy od przykładu (błędnego):
C/C++
int * funkcja( int rozmiar )
{
   
int * tab = new int[ rozmiar ];
   
   
if( !f1( tab, rozmiar ) )
       
 return nullptr;
   
   
if( !f2( tab, rozmiar ) )
       
 return nullptr;
   
   
return tab;
}
Funkcje f1() i f2() w jakiś sposób przetwarzają dane, zwracają bool dla określenia powodzenia operacji. Mogą zwrócić błąd i wtedy cała funkcja() kończy się z błędem - zwraca pusty wskaźnik, zamiast takiego wskazującego na tablicę z wygenerowanymi danymi. Ta funkcja jest błędna, bo zwrócenie przez nią błędu oznacza wyciek pamięci. Przed każdym zwróceniem nullptr powinno tu być dopisane zwalnianie tab. Wersja poprawniejsza mogłaby wyglądać tak:
C/C++
int * funkcja( int rozmiar )
{
   
int * tab = new int[ rozmiar ];
   
   
if( f1( tab, rozmiar ) )
   
if( f2( tab, rozmiar ) )
       
 return tab;
   
   
delete[ ] tab;
   
return nullptr;
}
Odwróciłem logikę tych warunków, by obsługa błędów była w tylko jednym miejscu, jednak nie zawsze da się tak zrobić. Czasem po prostu trzeba obstawić wszystkie wyjścia. Zwykłe wskaźniki to po prostu liczby opisujące jakiś adres - są za mało inteligentne żeby po sobie posprzątać.

Wskaźnik std::unique_ptr<>

Zacznijmy od odszyfrowania nazwy: unique pointer - unikalny wskaźnik. Wskaźnika nie muszę tłumaczyć. Unikalny znaczy, że jest to jedyne odniesienie do tej pamięci, więc pamięć ma zostać zwolniona w momencie zniszczenia zmiennej std::unique_ptr<>. Aby utworzyć taki wskaźnik dla jakiegoś typu T, typ trzeba wpisać w ostre nawiasy:
C/C++
#include <memory>
std::unique_ptr < int > uptr;
Zwróć uwagę, że nie piszemy gwiazdki nigdzie. Samo użycie std::unique_ptr<> oznacza, że to będzie wskaźnik. Ten jest utworzony jako pusty, zróbmy taki, który zarządza jakąś pamięcią:
C/C++
//std::unique_ptr<int> uptr = new int; // źle!
std::unique_ptr < int > uptr( new int ); // OK
Wskaźniki inteligentne trzeba tworzyć jawnie. Gdyby dało się ot tak skonwertować int* na std::unique_ptr<int>, można by sobie przypadkiem zwolnić pamięć, co mogłoby być katastrofalne w skutkach.
C/C++
std::unique_ptr < int > uptr( new int );
//std::unique_ptr<int> uptr2 = uptr; // błąd
Takich wskaźników nie da się skopiować, bo naruszałoby to założenie, że dana instancja std::unique_ptr<> jest jedynym podmiotem zarządzającym jakimś skrawkiem pamięci. Da się utworzyć 2 wskaźniki inteligentne na podstawie tego samego adresu, ale to czysta głupota - te wskaźniki są inteligentne, ale nie mogą wiedzieć o sobie nawzajem. Zwolnią pamięć spod tego samego adresu w swoim czasie i spowodują tym samym błąd aplikacji.
C/C++
std::unique_ptr < int > uptr( new int );
std::unique_ptr < int > uptr2;

std::cout << "uptr: " <<( uptr != nullptr ) << " uptr2: " <<( uptr2 != nullptr ) << '\n';
uptr2 = std::move( uptr );
std::cout << "uptr: " <<( uptr != nullptr ) << " uptr2: " <<( uptr2 != nullptr ) << '\n';
uptr: 1 uptr2: 0
uptr: 0 uptr2: 1
Nie da się takiego wskaźnika skopiować, ale można go przenieść. Dzięki temu można taki wskaźnik zwrócić z funkcji.
Naprawmy teraz przykład ze wstępu, bez zmieniania niczego w sposobie użycia tej funkcji, ani w sposobie użycia funkcji pomocniczych f1() i f2():
C/C++
int * funkcja( int rozmiar )
{
   
std::unique_ptr < int[ ] > tab( new int[ rozmiar ] );
   
   
if( !f1( tab.get(), rozmiar ) )
       
 return nullptr;
   
   
if( !f2( tab.get(), rozmiar ) )
       
 return nullptr;
   
   
return tab.release();
}
Pierwsza rzecz: std::unique_ptr<int[]>. W zwykłych wskaźnikach nie ma rozróżnienia na takie wskazujące na jeden element, czy na tablicę elementów. W pewnym momencie musimy po prostu wiedzieć, którego delete użyć. std::unique_ptr<> też musi to wiedzieć, bo odpowiada za zwolnienie pamięci, więc informujemy go o tym, że wskazuje na tablicę, przez podanie T[] jako typu.
f1() i f2() operowały we wstępie na zwykłych wskaźnikach i tak miało zostać, do wyciągnięcia adresu z std::unique_ptr<> służy metoda get(). Całość miała zwracać zwykły wskaźnik, ale tutaj nie można już użyć get() - wyjście z funkcji spowodowałoby zwolnienie tej tablicy. Odpowiednią metodą w tym wypadku jest release(), która zwraca wskaźnik i ustawia pusty wskaźnik w zmiennej std::unique_ptr<>. Od tego momentu sami odpowiadamy za zwolnienie pamięci.

Inteligentny wskaźnik std::unique_ptr - podział między danymi na stosie (w obiekcie) a na stercie zarządzanej przez obiekt inteligentnego  wskaźnika
Inteligentny wskaźnik std::unique_ptr - podział między danymi na stosie (w obiekcie) a na stercie zarządzanej przez obiekt inteligentnego  wskaźnika

Wskaźnik std::shared_ptr<>

Wskaźnik współdzielony. Różnica względem wskaźnika std::unique_ptr<> polega na tym, że można go kopiować. Kopie wskaźników std::shared_ptr<> utrzymują między sobą licznik referencji, zwiększany w momencie utworzenia kopii, zmniejszany w momencie zniszczenia kopii. Pamięć zostaje zwolniona według reguły "kto ostatni, ten sprząta".
C/C++
std::shared_ptr < int > sptr( new int );
std::shared_ptr < int > sptr2;

std::cout << "sptr: " << sptr.use_count() << " sptr2: " << sptr2.use_count() << '\n';
sptr2 = sptr;
std::cout << "sptr: " << sptr.use_count() << " sptr2: " << sptr2.use_count() << '\n';

{
   
std::shared_ptr < int > sptr3 = sptr2;
   
std::cout << "sptr: " << sptr.use_count() << " sptr2: " << sptr2.use_count() << '\n';
}

std::cout << "sptr: " << sptr.use_count() << " sptr2: " << sptr2.use_count() << '\n';
sptr: 1 sptr2: 0
sptr: 2 sptr2: 2
sptr: 3 sptr2: 3
sptr: 2 sptr2: 2

Uproszczony schemat pamięci przy używaniu shared_ptr z już zaalokowaną pamięcią przekazywaną bezpośrednio do obiektu inteligentnego  wskaźnika
Uproszczony schemat pamięci przy używaniu shared_ptr z już zaalokowaną pamięcią przekazywaną bezpośrednio do obiektu inteligentnego  wskaźnika

Wskaźnik std::weak_ptr<>

std::shared_ptr<> pilnuje zaalokowanej pamięci nawet gdy kopie wskaźnika pojawiają się i znikają, pamięć zostanie zwolniona gdy nic już się do niej nie odnosi (a przynajmniej nie std::shared_ptr<>). Jednak wciąż, pamięć zarządzana przez std::shared_ptr<> może wyciec, jeśli się nie uważa na to, co się robi. Aby zarządzana pamięć została zwolniona, ostatni wskaźnik std::shared_ptr<> musi zostać zniszczony, co jest proste do zagwarantowania w przypadku zmiennych automatycznych, ale w przypadku dynamicznej alokacji już nie bardzo. Oczywiście to byłoby głupie mieszać bezpieczne z niebezpiecznym i oczekiwać, że całość będzie bezpieczna, więc rozważmy błędnie działający przypadek, w którym wszystkie wskaźniki to "bezpieczne" std::shared_ptr<> - nie ma żadnej alokacji zostawionej samej sobie, bez wskaźnika inteligentnego. Weźmy taką listę dwukierunkową:
C/C++
struct Lista
{
   
int liczba;
   
std::shared_ptr < Lista > nastepny, poprzedni;
};
Węzeł wskazuje na następny i poprzedni, a więc podtrzymuje je przy życiu. Sam również jest podtrzymywany przy życiu przez te węzły. Jeśli w liście jest więcej niż jeden element, powstaje cykl referencji. Gdyby to była lista jednokierunkowa, można by po prostu zapomnieć o niej i wtedy wyzeruje się licznik referencji wskaźnika na początek listy, początek listy zostanie usunięty i rekurencyjnie cała reszta też. Tutaj, zapomnienie o pierwszym elemencie nic nie da, bo drugi element o nim pamięta, przez wskaźnik na poprzednika.
C/C++
struct Lista
{
   
int liczba;
   
std::shared_ptr < Lista > nastepny;
   
Lista * poprzedni; // No to może zwykły wskaźnik..?
};
Takie rozwiązanie naprawi wyciek pamięci, ale nie jest idealne. Zwykły wskaźnik, o ile realizuje słabą referencję (nie podtrzymuje obiektu przy życiu), nie zapewnia żadnej informacji o tym, czy obiekt został już usunięty, czy jeszcze nie. Nie zapominajmy o tym, że usuwanie dzieje się automatycznie. W takiej liście dałoby się jeszcze nad tym zapanować. Do tego, ze zwykłego wskaźnika nie odzyskamy potem wskaźnika std::shared_ptr<> który zarządza daną pamięcią, więc przydałoby się coś lepszego: std::weak_ptr<>.
C/C++
struct Lista
{
   
int liczba;
   
std::shared_ptr < Lista > nastepny;
   
std::weak_ptr < Lista > poprzedni;
};
Wskaźnik std::weak_ptr<> jest związany z pamięcią zarządzaną przez std::shared_ptr<>, ale nie będzie przeszkadzać w usuwaniu obiektu. Można na nim sprawdzić, czy zarządzana pamięć została już zwolniona oraz uzyskać std::shared_ptr<> na tą pamięć, do czego służy metoda lock().
C/C++
#include <iostream>
#include <memory>


int main()
{
   
std::weak_ptr < int > weak;
   
std::cout << "expired: " << weak.expired() << "; address: " << weak.lock() << '\n';
   
   
{
       
std::shared_ptr < int > strong( new int );
       
weak = strong;
       
std::cout << "expired: " << weak.expired() << "; address: " << weak.lock() << '\n';
   
}
   
   
std::cout << "expired: " << weak.expired() << "; address: " << weak.lock() << '\n';
}
expired: 1; address: 0
expired: 0; address: 0x77ad90
expired: 1; address: 0

Uproszczony schemat działania bloku kontrolnego demonstrujący gdy wiele shared_ptr oraz weak_ptr wskazują na tą samą pamięć (mają wspólny blok kontrolny).
Uproszczony schemat działania bloku kontrolnego demonstrujący gdy wiele shared_ptr oraz weak_ptr wskazują na tą samą pamięć (mają wspólny blok kontrolny).

Rozszerzenie materiału

Poniżej znajdziesz rozszerzenie materiału opracowane przez Grzegorza Bazior.

std::make_shared jako lepsza alternatywa tworzenia inteligentnych wskaźników

Tworzenie inteligentnych wskaźników typu shared_ptr przy użyciu operatora new powoduje dwie alokacje pamięci - osobno dla obiektu oraz dla bloku kontrolnego. W wielu przypadkach można to zoptymalizować do jednej alokacji przy użyciu std::make_shared.

Funkcja std::make_shared tworzy zarówno obiekt, jak i blok kontrolny w jednej operacji:
C/C++
std::shared_ptr < int > sptr = std::make_shared < int >();
std::shared_ptr < int > sptr2 = std::make_shared < int >( 44 ); // ustawiamy wartość 44
auto sptr3 = std::make_shared < int >( 45 ); // jednokrotne podanie typu

Zalety std::make_shared:
  • jedna alokacja pamięci (lepsza lokalność danych i wydajność)
  • brak konieczności użycia operatora new
  • krótszy i czytelniejszy zapis

C/C++
std::shared_ptr < VeryLongTypeName > sptr { new VeryLongTypeName };
auto sptr = std::make_shared < VeryLongTypeName >();

a także dzięki jednokrotnemu podawaniu typu unikamy przypadkowych błędów przy jego zmianie.

Porównanie alokacji pamięci: osobno (new + shared_ptr) oraz pojedyncza alokacja (make_shared)
Porównanie alokacji pamięci: osobno (new + shared_ptr) oraz pojedyncza alokacja (make_shared)

std::make_unique jako lepsza alternatywa tworzenia inteligentnych wskaźników

std::make_unique jest zalecanym sposobem tworzenia obiektów typu std::unique_ptr.

W przeciwieństwie do std::shared_ptr, nie zmienia on modelu alokacji pamięci, ale zapewnia inne korzyści:

  • spójność ze std::make_shared
  • brak konieczności użycia operatora new
  • unikamy duplikacji kodu - typ podajemy jednokrotnie
  • czytelniejszy, a często też i krótszy zapis

C/C++
std::unique_ptr < VeryLongTypeName > sptr { new VeryLongTypeName };
auto sptr = std::make_unique < VeryLongTypeName >();

Unikamy także błędów związanych z niezgodnością typów:
C/C++
std::shared_ptr < BaseClass > sptr { new DerivedClass }; // działa, ale może być niezamierzone

W przypadku tablic należy używać odpowiedniej wersji:
auto sptr = std::make_unique < int[ ] >( 10 );


Niepoprawne użycie prowadzi do niezdefiniowanego zachowania:
C/C++
std::unique_ptr < int > sptr { new int[ 10 ] { } }; // błąd
std::unique_ptr < int[ ] > sptr { new int { 44 } }; // błąd

Bonus - zwalnianie innych zasobów przy pomocy inteligentnych wskaźników

Inteligentne wskaźniki mogą zarządzać nie tylko pamięcią, ale także innymi zasobami (np. plikami), dzięki możliwości podania własnego deletera:

C/C++
#include <iostream>
#include <memory>
#include <cstdio>

int main()
{
   
auto fileCloser =[ ]( FILE * file )
   
{
       
fclose( file );
       
std::cout << "File closed!\n";
   
};
   
   
std::unique_ptr < FILE, decltype( fileCloser ) > file(
   
fopen( "test.txt", "w" ),
   
fileCloser
    );
   
if( file )
   
{
       
fprintf( file.get(), "Hello World!\n" );
   
}
   
else
   
{
       
std::cerr << "Error opening file!\n";
   
}
}

Typ deletera jest częścią typu std::unique_ptr.

W przypadku std::shared_ptr deleter nie jest częścią typu, lecz przechowywany jest w bloku kontrolnym:

C/C++
#include <iostream>
#include <memory>
#include <cstdio>

int main()
{
   
std::shared_ptr < FILE > file(
   
fopen( "test.txt", "w" ),
   
::fclose
    );
   
if( file )
   
{
       
fprintf( file.get(), "Hello World!\n" );
   
}
   
else
   
{
       
std::cerr << "Error opening file!\n";
   
}
}
Poprzedni dokument Następny dokument
Struktury Rekurencja