Unique_ptr, czyli monopol na zasoby.

W poprzednim wpisie powiedzieliśmy sobie kilka słów o wskaźnikach surowych (raw pointers), czyli zwykłych wskaźnikach znanych nam z języka C.

Wskazaliśmy niektóre z ich słabych stron pozostawiając pytania, na które nie udzielają nam one odpowiedzi. Dzisiaj robiąc krok dalej w temacie wskaźników inteligetnych, chciałbym poruszyć temat pierwszego z nich- unique_ptr, pozostawiając wam do oceny, w jaki sposób odpowiada on na postawione wcześniej pytania. 

unique_ptr omówimy w kolejnych krokach:

  1. Wstęp teoretyczny
  2. Tworzenie, użycie, waga
  3. Kopiowanie
  4. Przenoszenie
  5. Użycie w kontenerze
  6. Custom deleter (niestandardowe niszczenie obiektu)
  7. Unique_ptr dla tablic
  8. Tworzenie shared_ptr z unique_ptr
  9. Tworzenie unique_ptr za pomoca make_unique
  10. Podsumowanie

   

Wstęp teoretyczny

Artykuł warto rozpocząc od odpowiedzi na podstawowe pytanie: Czym jest unique_ptr?

Jest on wskaźnikiem inteligentnym, który:

  1. Posiada na wyłączność prawa własności do wskazywanego przez siebie obiektu.
  2. Niszczy posiadany obiekt gdy sam ulega zniszczeniu.
  3. Jest unikalny- nie ma dwóch wskaźniów unique_ptr, które zarządzają tym samym obiektem. 
  4. (Jest domyślnym wskaźnikiem inteligentnym).

Myślę, że tyle informacji na początek nam wystarczy. Zacznijmy od pierwszej z nich. 

unique_ptr posiada wskazywany obiekt na własność. Oznacza to, że zarządza on czasem życia wskazywanego obiektu. Jest jego władcą i to on decyduje, kiedy wskazywany obiekt ma przestać istnieć. Co za tym idzie, otrzymując ten typ wskaźnika, z definicji wiemy, że od tej pory to my kontrolujemy czas życia wskazywanego obiektu. Nikt inny nie ma prawa tego robić. Konsekwencją tego założenia jest kontrola, aby wskazywany obiekt nigdy nie pozotał bez właściciela. Mówi o tym punkt drugi.

Jeżeli unique_ptr ulegnie zniszczeniu, automatycznie zniszczy on także obiekt, na który wskazuje. To dość wygodne podejście. Od tej pory nie spotka nas już sytuacja, w której nasz wskaźnik został zniszczony a my zapomnieliśmy zwolnić obiekt, na który on wskazywał. Nasz unique_ptr zadba o to by zrobić to za nas, w momencie gdy sam będzie niszczony. Takie podejście nazywamy RAII (ang. Resource Acquisition Is Initialization)- czyli obiekt podczas konstrukcji bierze na właność dane zasoby, a podczas destrukcji je zwalnia. To bardzo dobra praktyka, jeżeli spotykasz się z nią pierwszy raz, polecam wygooglować na ten temat nieco więcej. 

Posiadanie zasobu na właność niesie za sobą jednak pewne ograniczenia. Mówi o nich punkt trzeci. Dwa unique_ptr nie mogą wskazywać na ten sam obiekt, bo z definicji nie byłyby wtedy już unikalne. Skutkuje to tym, że nie możemy takich wskaźników kopiować. W dalszej części artykułu dowiemy się, że można je natomiast przenosić, jednak teraz warto zapamiętać, że unique_ptr nie może być kopiowany.

Punkt ostatni jest w nawiasie, ponieważ stanowi jedynie dobra radę. Domyślny, oznacza tu, że jeżeli nie wiemy jakiego wskaźnika powinniśmy użyć podczas alokowania zasobu, to wybieramy właśnie unique_ptr. Dopiero gdy okaże się, że unique_ptr nie jest wystarczającym rozwiązaniem, powinniśmy rozważyć inne możliwosci. To ważne by o tym pamiętać, ponieważ wiele osób po zapoznaniu się ze wskaźnikami inteligentnymi domyślnie używa shared_ptr, który (jak się niebawem przekonamy) jest kosztowniejszy w użyciu, więc nie warto sięgać po niego bez potrzeby.

To tyle z niezbędnej teorii. Zobaczmy kilka linii kodu z użyciem unique_ptr i przyjżyjmy się mu nieco bliżej. W tym celu stworzymy sobie małą klasę Widget, na której będziemy zwykle operować za pomocą unique_ptr. Jej głównym celem jest informowanie nas o tym kiedy obiekt został stworzony oraz kiedy zniszczony. Dodatkowo stworzymy także klasę pochodną, która podwaja wartość zwracaną przez get, oraz faktorkę, która buduje odpowiedni Widget- zależnie od drugiego argumentu zwraca wskaźnik na klasę bazową lub pochodną. Kod został zamieszczony na samym końcu artykułu.

 

Tworzenie, użycie, waga

Od razu przejdźmy do kodu z naszym wskaźnikiem inteligentnym. 


{
Widget * raw_ptr = new Widget{10};
unique_ptr<Widget> ptr{raw_ptr};   			// antipattern!
// unique_ptr<Widget> ptr{new Widget{10}};	// that's fine


cout << "unique_ptr value:\t" << *ptr << endl;		// the same as in raw ptr
cout << "raw_ptr value:\t\t" << *raw_ptr << endl; 

cout << "smart size:\t"  << sizeof(ptr) << endl;		// the same as in raw ptr
cout << "raw size:\t" << sizeof(raw_ptr) << endl;
cout << endl ;

cout << "smart address:\t" << ptr.get() << endl;  
cout << "raw address:\t" << raw_ptr << endl ;
cout << endl ;
}

Output:


Widget ctor, number=    10
unique_ptr value:       10
raw_ptr value:          10

smart size:     8
raw size:       8

smart address:  0x11cdc20
raw address:    0x11cdc20

~Widget dtor

Pierwszą rzeczą, która rzuca nam się w oczy jest komentarz "antipattern!" i już tłumaczę o co z nim chodzi. Unique_ptr tworzymy na podstawie wskaźnika surowego. Jeżeli najpierw zalokujemy obiekt, a następnie na podstawie otrzymanego wskaźnika utworzymy unique_ptr to istnieje ryzyko, że nasz surowy wskaźnik zostanie w dalszej częsci kodu ponownie użyty do stworzenia stworzenia drugiego unique_ptr... który będzie wskazywał na ten sam zasób. Jest to niedopuszczalne, dlatego w momencie rozpoczęcia używania wskaźników inteligentnych, nie powinniśmy w kodzie używać operatora new, którego wynik zapiszemy do surowego wskaźnika. Linię niżej pokazuję w jaki sposób można to zrobić poprawnie i takiej praktyki należy się trzymać.

Jak widać na zamieszczonym kodzie, unique_ptr używamy dokładnie tak samo jak zwykłego wskaźnika. Jeżeli chcemy użyć obiektu, na który wskazuje, dereferujemy go za pomocą *. Jeżeli będziemy potrzebować surowego wskaźnika, możemy posłużyć się metodą get w celu jego uzyskania- nic się tutaj nie zmienia, wskaźnik pozostał taki sam. Co ważne, rozmiar unique_ptr jest taki sam jak rozmiar wskaźnika surowego. Co za tym idzie, zwykle jeżeli stać nas w kodzie na używanie zwykłego wskaźnika, nie powinno być problemów z użyciem unique_ptr. Co istotne, na samym końcu widzimy, że nasz Widget został poprawnie zniszczony. Zadbał o to nasz unique_ptr. Zasoby nie wyciekły, wszystko zostało utrzymane pod kontrolą.

 

Sprawa kopiowania


unique_ptr<Widget> ptr {new Widget{10}};
unique_ptr<Widget> ptr2 = ptr;              // error

Jak wspomnieliśmy we wstępie teoretycznym, unique_ptr nie może by kopiowany- ponieważ przestałby wtedy być unikalny. Z tego powodu powyższy kod nie może zostać skompilowany. Podczas próby kompilacji, zostaniemy poinformowani o próbie wywołania usuniętego konstruktora kopiującego- unique_ptr nie może być kopiowany, więc konstruktor kopiujący został z niego usunięty.

 

Sprawa przenoszenia

Natomiast nic nie stoi na przeszkodzie, by unique_ptr przekazał prawo własności innemu unique_ptr. Wtedy wciąż bądziemy dysponować tylko jednym unique_ptr, który wskazuje na dany zasób.


unique_ptr<Widget> ptr {new Widget{10}};
cout << "ptr address:\t" << ptr.get() << endl;

cout << "move ptr to ptr2" << endl << endl;
unique_ptr<Widget> ptr2{move(ptr)};
cout << "ptr address:\t" << ptr.get() << endl;
cout << "ptr2 address:\t" << ptr2.get() << endl;

output:


Widget ctor, number= 10
ptr address:    0x1106c20                                                                                                                             
move ptr to ptr2

ptr address:    0 
ptr2 address:   0x1106c20                                                                                                                             
~Widget dtor

Możliwość przeprowadzenia takiej operacji zawdzięczamy semantyce przenoszenia. Mówiąc prostymi słowami, pierwszy wskaźnik pozbywa się zasobu przekazując go drugiemu wskaźnikowi. Nie dochodzi tutaj do kopiowania. Po wykonaniu takiej operacji pierwszy wskaźnik wskazuje na nullptr. Tę właściwość najczęściej będziemy wkorzystywać zwracając unique_ptr, np z faktorki.


// get from factory
auto un_ptr = WidgetFactory(7, false);  // who is owner of the object? Me or Factory? 

cout << endl;
cout << *un_ptr << endl;
cout << endl;

output:


Widget ctor, number= 7    
7                                          
~Widget dtor

Jak widzimy, nie ma problemu z przekazaniem wskaźnika z faktorki do naszej części programu. Przy okazji zwracania wskaźnika nie mamy problemu co do ustalenia właściciela obiektu. Pytanie zaznaczone w komentarzu staje się jedynie pytaniem retorycznym. Co wiecej, nasz wskaźnik jest w pełni polimorficzny. Możemy używać wskaźnika typu klasy bazowej, by operować na obiekcie klasy pochodnej. Jeżeli zamienimy parametry dla naszej faktorki tak by zwróciła obiekt klasy pochodnej (drugi argument z false, należy zamienić na true) otrzymamy poprawny wynik działania aplikacji:


Widget ctor, number= 7 
Double_val_widget ctor, number= 7      
14 
~Double_val_widget dtor 
~Widget dtor

 

unique_ptr w kontenerach

Wsparcie dla semantyki przenoszenia umożliwia pracę z unique_ptr także w kontenerach, niżej przykład podstawowych operacji na standrdowym pojemniku:


unique_ptr<Widget> ptr1{new Widget(1)};
vector<unique_ptr<Widget>> w_vector{};

// w_vector.push_back(ptr1);    // copy not allowed!
w_vector.push_back(move(ptr1));
w_vector.push_back(unique_ptr<Widget> {new Widget{2}});
w_vector.emplace_back(unique_ptr<Widget> {new Double_val_widget{3}});

for(auto & elem : w_vector)
{
	cout << *elem << endl;
}

output:


Widget ctor, number= 1                                     
Widget ctor, number= 2 
Widget ctor, number= 3 
Double_val_widget ctor, number= 3  

1                            
2    
6  

~Widget dtor         
~Widget dtor          
~Double_val_widget dtor                                                                                                                               
~Widget dtor

Operacja kopiowania jest oczywiście niedozwolona. Natomiast podczas użycia funkcji move(), decydujemy o tym, aby nasz wskaźnik ptr1 przeniósł prawa do obiektu, na nowy wskaźnik inteligentny, który powstanie w kontenerze. W kolejnych dwóch liniach tworzymy unique_ptr w kontenerze. Jeżeli pierwszy raz widzisz użycie emplace_back to oznacza ono, iż obiekt nie będzie najpierw stworzony a następnie skopiowany do kontenera, a od razu zostanie stworzony na miejscu, jakie zostanie dla niego zarezerwowane w kontenerze. Podczas drukowania zawartości pojemnika widzimy, że polimorfizm działa bez szwanku.

 

Custom deleter

Unique_ptr (podobnie jak i shared_ptr) daje nam możliwość stworzenia niestandardowego deletera dla obiektu, na który wskazuje. Dla przykładu możemy stworzyć wskaźnik na obiekt klasy Widget, który będzie logował usunięcie tego konkretnego obiektu. Taki niestandardowy deleter musi mieć postać wywoływalną, czyli może to być np funkcja, która przyjmuje jako argument wskaźnik na obiekt, który ma zniszczyć. W ciele funkcji możemy dokonać różnych operacji, jednak należy pamiętać aby pod koniec usunąc wskazywany zasób. Poniżej przedstawiłem kilka customowych deleterów.


void custom_deleter_function_p(Widget * pWidget)
{
		cout << "Hi, I'm custom deleter!\n";
		delete pWidget;
}

// ... //

auto custom_deleter_lambda = [](Widget * pWidget)
{
	cout << "Hi, I em custom deleter!\n";
	delete pWidget;
	
	// int do_not_increse_size = 8;
};

struct custom_deleter_object
{
	void operator()(Widget * pWidget) const { delete pWidget;}
	// int increse_size;
};

std::function<void(Widget * pWidget)> custom_deleter_function = custom_deleter_lambda;


unique_ptr<Widget> up_del {nullptr};
unique_ptr<Widget, decltype(custom_deleter_lambda)>     up_del1(nullptr, custom_deleter_lambda);
unique_ptr<Widget, custom_deleter_object>               up_del2(nullptr, custom_deleter_object());
unique_ptr<Widget, decltype(custom_deleter_function)>   up_del3(nullptr, custom_deleter_function);


cout << "smart without deleter size:\t\t" << sizeof(up_del) << endl;
cout << "smart with lambda deleter size:\t\t"  << sizeof(up_del1) << endl;
cout << "smart with func obj deleter size:\t"  << sizeof(up_del2) << endl;
cout << "smart with function deleter size:\t"  << sizeof(up_del3) << endl;


up_del1.reset(new Widget); 

output:


smart without deleter size:             8
smart with lambda deleter size:         8
smart with func obj deleter size:       8
smart with pfunction deleter size:      16
smart with function deleter size:       40

Widget ctor
Hi, I em custom deleter!
~Widget dtor

3 ostatnie linie z output pokazują działanie naszego deletera. W kodzie programu metoda .reset() służy do nadania nowego obiektu dla unique_ptr. Wcześniej jako argument do stworzenia wskaźnika użyliśmy nullptr co oznaczało, iż nie wskazywał on na żaden obiekt- zmieniliśmy to używając metody reset().

Podczas usuwania obiektu, wywołany jest niestandardowy deleter, który loguje usunięcie obiektu. Nastęnie zgodnie z naszym deleterm obiekt zostaje usunięty. Natomiast ciekawą rzeczą, na którą należy zwrócić uwagę jest waga unique_ptr po dodaniu deletera. W przypadku braku deletera rozmiar unique_ptr wynosi 8 bajtów, co jest zrozumiałe- stanowi to wagę wskaźnika do obiektu. Podczas dodania deletera w postaci wskaźnika do funkcji (pfunction) rozmiar wzrasta o kolejne 8 bajtów, czyli zapisanie wskaźnika właśnie do deletera. Wielki skok możemy zanotować w przypadku std::function, którego waga sama w sobie wynosi 32 bajty. To stosunkowo duży narzut i warto o nim pamiętać w przypadku użycia tego rozwiązania. Ciekawy jest natomiast brak narzutu pamięciowego w przypadku użycia lambdy oraz funktora. Jak widzimy rozmiar pozostał bez zmian. Wiąże się to z użyciem Empty Class Optmalization.

Ważnym elementem wartm zapamiętania jest zmiana typu wskaźnika. Customowy deleter parametryzuje nasz pointer. Od tej pory wskaźnik jest już innego typu. Niesie to za sobą szereg konsekwencji, jedną z nich jest niemożność wsadzenia takiego wskaźnika do kontenera ze wskaźnikami bez custom deletera. (kontener parametryzujemy typem przechowywanego obiektu, tworząc customowy deleter zmieniamy typ wskaźnika na taki, który nie pasuje już do kontenera).

 

Użycie unique_ptr z tablicami

Unique_ptr możemy użyć także do zarządzania tablicami. Współcześnie nie jest to jednak zalecane. Zwykle gdy mamy do czynienia z tablicą obiektów o wiele rozsądniej będzie użyć któregoś ze standardowych kontenerów. W zastanym kodzie możemy jednak trafić na funkcję z C, która zwraca nam wskaźnik do tablicy obiektów i wtedy warto to odpowiednio owrapować właśnie we wskaźnik na tablicę. Aby uniknąć sytuacji jak w zwyklym wskaźniku, inaczej wygląda unique_ptr dla tablic, jak i posiada on inny interface. Poniżej przykład stworzenia i użycia unique_ptr dla jednego obiektu i tablicy obiektów.


// unique ptr for tables:
unique_ptr<Widget[]> up_table{ new Widget[10]};
unique_ptr<Widget> up_element{ new Widget};

cout << *up_table << endl;      // error!
cout << *up_element << endl;

cout << up_table[0] << endl;
cout << up_element[0] << endl;  // error!

Komentarze "error!" wskazują na miejsca w których interface dla obu typów jest różny. Dzięki tym róznicom nie jest możliwe używanie zamiennie wskaźnika do obiektu i do tablicy obiektów.

 

Tworzenie shared_ptr na podstawie unique_ptr

Unique_ptr ma duża zaletę- w każdej chwili możemy przekształcić go na shared_ptr. Jeżeli w danym momencie stwierdzimy, że obiekt powinien być dzielony między kilku współwłaścicieli, tworzy shared_ptr i na nim kontynuujemy pracę:


unique_ptr<Widget> u_p (new Widget);
shared_ptr<Widget> sh_p {move(u_p)};

auto lamb = [](Widget * p){ cout <<"I lost the game!\n"; delete p ;};
unique_ptr<Widget, decltype(lamb)> up_del1(new Widget, lamb);
shared_ptr<Widget> sh_p_cust_del {move(up_del1)};

output:

Widget ctor
Widget ctor
I lost the game!
~Widget dtor  
~Widget dtor  

Pierwsza linia, w ktorej tworzymy unique_ptr zapewne jest zrozumiała. Następnie w drugiem linii tworzymy shared_ptr analogicznie do unique_ptr. Jako argument każemy przesunąć do niego nasz unique_ptr. Dzięki temu tworzymy shared_ptr na zasób wcześniej wskazywany przez unique_ptr. I już. Tylko tyle i aż tyle.

Dociekliwe osoby mogły zapytać: OK, więc przekształcam unique_ptr w shared_ptr, jednak nasz shared_ptr nie jest parametryzowany customowym deleterm, czy więc będzie to działać? Tak! Bez obaw. Shared_ptr radzi sobie nieco inaczej z customowym deleterem, o czym powiemy sobie przy okazji jego omawiania. Stworzenie drugiego unique_ptr pokazuje, że pomimo przekształcenia go w shared_ptr, customowy deleter nadal jest poprawie wykonywany.

 

Tworzenie unique_ptr za pomoca make_unique

Czytając pierwszy rozdział zapewne wielu z was zapytało: OK, fajnie, ale dlaczego nie tworzysz unieuq_ptr za pomocą make_unique? I jest to bardzo dobre pytanie, ponieważ jeżeli tylko jest taka możliwość, warto używać make_unique. Jednak nie zawsze jest to możliwe. W pierwszej kolejności make_unique weszło do standrdu dopiero w C++14, więc osoby z C++11 nie będą mogły z niego skorzystać (abstrachując od tego, jak łatwo jest samemu stworzyć własne make_unique). Druga sprawa to brak wsparcia dla custom deletera przy make unique. Natomiast jeżeli pierwszy raz czytasz o make_unique, to jest to funkcja dzięki której uzyskujemy unieuq_ptr. Jej plusem jest niewątpliwe wyeliminowanie potrzeby używania operatora new. Poniżej przedstawiam przykład użycia:


auto up = make_unique<Widget>();
auto up2 = make_unique<Widget>(10);

output:


Widget ctor
Widget ctor, number= 10
~Widget dtor
~Widget dtor

W obu przypadkach otrzymamy unieuq_ptr do obiektu klasy Widet. W drugiej linii obiekt zostanie zainicjalizowany wartością 10. W C++11 nie mamy funkcji make_unique, jednak mamy dostępną funkcje make_shared dla shred_ptr. Stąd jeżeli możliwe jest używanie make_unique zaleca się to także dla zachowania pewnej symetryczności kodu.

 

Podsumowanie

Po zapoznaniu się z wieloma właściwościami unique_ptr pora na małe podsumowanie. Unique_ptr jest pierwszym wskaźnikiem inteligentnym z jakim się zapoznaliśmy. Dzięki niemu nie musimy martwić się o niszczenie obiektu, na który wskazuje- we wszystkich przytoczonych przykładach destruktory obiektów na stercie wywoływały się samoistnie wraz z destrukcją unieuq_ptr. Pierwszy wniosek jest więc oczywisty, dzięki unique_ptr o wiele trudniej doprowadzić do wycieku pamięci. Niestety jak każde narzędzie niesie ono za sobą także pewne zagrożenia:

  1. Używanie RAW ptr może doprowadzić do dwóch wskaźników, wskazujących na ten sam zasób
  2. Użycie custom deleterów może powodować istotny wzrost rozmiaru wskaźnika
  3. Należy wyrobić nawyk nieużywania jawnie operatora delete, jeżeli zasobami posługuje się smart pointer

Te kilka wad nie może jednak przyćmić zalet jakie otrzymujemy w zamian. Unique_ptr nie jest w c++ tyle użytecznym narzedziem co niezbędnym- jako dobrzy programiści powinniśmy jednak wiedziec jakie są tego konsekwencje.

Podsumowując temat, warto zapamiętać najważniejsze cechy nowopoznanego inteligentnego wskaźnika:

  1. Jest mały i szybki
  2. Nie ma możliwości skopiowania, jedynie przesunięcia
  3. Posiada na własność wskazywany obiekt
  4. Ustawienie custom deletera zmienia typ wskaźnika
  5. Umożliwia łatwą konwersję do shared_ptr

 

 

 


Klasa Widget


#include <iostream>
using namespace std;

class Widget
{
public:
    Widget()
    { 
        cout << "Widget ctor\n";
    }
    
    Widget(int a_num)
       : number(a_num)
    {
        cout << "Widget ctor, number= "<< a_num << endl;
    }
    
    virtual ~Widget()
    {
        cout << "~Widget dtor\n";
    }
    
    friend std::ostream& operator<< (std::ostream& stream, const Widget& widget) {
        stream << widget.get_number();
    }
    
    virtual int get_number() const
    {
        return number;
    }
    
    virtual void set_val(int val)
    {
        number = val;
    }
private:
    int number{1};    
};




class Double_val_widget
: public Widget
{
public:
    Double_val_widget()
    { 
        cout << "Double_val_widget ctor\n";
    }
    
    Double_val_widget(int a_num)
       : Widget(a_num)
    {
        cout << "Double_val_widget ctor, number= "<< a_num << endl << endl;
    }
    
    ~Double_val_widget()
    {
        cout << "~Double_val_widget dtor\n";
    }
    
    int get_number() const override 
    {
        return Widget::get_number() * 2;
    }
    
    friend std::ostream& operator<< (std::ostream& stream, const Double_val_widget& double_val_widget) {
        stream << double_val_widget.get_number();
    }
};



unique_ptr<Widget> WidgetFactory(int val, bool inherited_class = false)
{
    if(inherited_class == true)
    {
        return unique_ptr<Widget> {new Double_val_widget{val}};
    }
    
    
    // create base class
    if(val < 0 )
         return unique_ptr<Widget> {new Widget{}};
    
    return unique_ptr<Widget> {new Widget{val}};
};


źrodła: "Skuteczny nowoczesny C++" - Scott Meyers http://www.bourez.be/