Inteligentne wskaźniki - Smart Pointers

Mechanizm RAII

Mechanizm wyjątków a zasoby

Jawne i ręczne zarządzanie zasobami jest podatne na wycieki:

void send(Msg* msg, std::string_view destination)
{
    auto port = open_port(destination);
    my_mutex.lock();

    send(port, msg); // if throws - port, mutex & msg will leak

    my_mutex.unlock();
    close_port(port);
    delete msg;
}

Używanie konstrukcji try-catch w celu unikania wycieków jest trudne, mało czytelne i prowadzi do duplikacji kodu

// Nieudolna poprawa
void send(Msg* msg, std::string_view destination)
{
    auto port = open_port(destination);
    my_mutex.lock();

    try
    {
        send(port, msg); // if throws - port, mutex & msg leak
    }
    catch(...)
    {
        my_mutex.unlock();
        close_port(port);
        delete msg;

        throw;
    }

    my_mutex.unlock();
    close_port(port);
    delete msg;
}

Zarządzanie zasobami RAII

Resource Acquisition Is Initialization (RAII) - zdobywanie zasobów jest inicjowaniem. Technika łączy przejęcie i zwolnienie zasobu z inicjalizacją zmiennych lokalnych i ich automatyczną deinicjalizacją (destrukcją).

Pozyskanie zasobu jest połączone z konstrukcją, a zwolnienie zasobu z automatyczną destrukcją lokalnej zmiennej. Ponieważ zagwarantowane jest wywołanie destruktora zmiennej lokalnej w momencie wyjścia z zakresu, zasób zostanie zwolniony od razu gdy skończy się czas życia kontrolującego obiektu. Mechanizm ten działa również przy wystąpieniu wyjątku. RAII jest kluczową koncepcją przy pisaniu kodu odpornego na wycieki zasobów.

Poprawny kod stosujący RAII wygląda następująco:

void send(std::unique_ptr<Msg> msg, std::string_view destination) // msg owns the Msg
{
    Port port{open_port(destination)}; // port owns the PortHandle
    std::lock_guard lk{my_mutex}; // lk owns the lock

    send(port, msg.get());
} // automatically unlocks my_mutex, closes the port & deletes the msg

W powyższej implementacji klasy zarządzające zasobami to std::unique_ptr, std::lock_guard oraz Port:

Implementacja std::lock_guard:

class lock_guard
{
    std::mutex& mtx_;
public:
    lock_guard(std::mutex& mtx) : mtx_{mtx}
    {
        mtx_.lock();
    }

    ~lock_guard()
    {
        mtx_.unlock();
    }

    lock_guard(const lock_guard&) = delete;
    lock_guard& operator=(const lock_guard&) = delete;
}

Implementacja klasy Port:

class Port {
    PortHandle port_;
public:
    Port(std::string_view destination) : port_{open_port(destination)} { }
    ~Port() { close_port(port_); }
    operator PortHandle() { return port_; }

    Port(const Port&) = delete;
    Port& operator=(const Port&) = delete;
};

Obie klasy wykorzystują symetrię operacji konstruktor/destruktor i wykonują odpowiednio operacje:

  • lock()/unlock()
  • open_port()/close_port()

Kopiowanie obiektów RAII

Klasy implementujące RAII posiadają destruktor, dlatego należy określić sposób zachowania obiektów przy ich kopiowaniu. Możliwe są następujące strategie:

  • Całkowite blokowanie kopiowania oraz transferu prawa własności
  • Blokowanie kopiowania przy jednoczesnym umożliwieniu transferu prawa własności do zasobu
  • Zezwolenie na kopiowanie obiektów - obiekty współdzielą prawo własności z wykorzystaniem licznika referencji

Inteligentne wskaźniki

Inteligentne wskaźniki umożliwiają wygodne zarządzanie obiektami, które zostały zaalokowane na stercie za pomocą operatora new. Przejmują odpowiedzialność za wywołanie destruktora dla zarządzanego obiektu oraz zwolnienie pamięci - poprzez wywołanie operatora delete.

Inteligentne wskaźniki w C++11 umożliwiają reprezentację prawa własności do zaalokowanego zasobu. Przeciążając operatory operator* oraz operator-> umożliwiają korzystanie z nich w taki sam sposób, jak wskaźników natywnych.

Dostępne implementacje inteligentnych wskaźników w bibliotece standardowej C++ oraz w bibliotece Boost.

Klasa wskaźnika Kopiowanie Transfer prawa własności Licznik referencji
std::auto_ptr<T> o o  
std::unique_ptr<T>   o  
std::shared_ptr<T> o o wewnętrzny
boost::scoped_ptr<T>      
boost::shared_ptr<T> o o wewnętrzny
boost::intrusive_ptr<T> o   zewnętrzny

Klasa std::unique_ptr<T>

Plik nagłówkowy: <memory>.

Klasa szablonowa std::unique_ptr służy do zapewnienia właściwego usuwania przydzielanego dynamicznie obiektu. Implementuje RAII - destruktor inteligentnego wskaźnika usuwa wskazywany obiekt. Wskaźnik unique_ptr nie może być ani kopiowany ani przypisywany, może być jednakże przenoszony. Przeniesienie prawa własności odbywa się zgodnie z move semantics w C++11 - wymaga dla lvalue jawnego transferu przy pomocy funkcji std::move().

void f()
{
    std::unique_ptr<Gadget> my_gadget {new Gadget()};
    // kod, który może wyrzucać wyjątki
    my_gadget->use();


    std::unique_ptr<Gadget> your_gadget = std::move(my_gadget); // explicit move

} // Destruktor klasy unique_ptr wywoła operator delete dla wskaźnika
  //  do kontrolowanej instancji

Semantyka przenoszenia dla unique_ptr

Obiekt std::unique_ptr nie może być kopiowany. Ale dzięki semantyce przenoszenia, może być stosowany tam, gdzie zgodne ze standardem C++03 niekopiowalne obiekty nie mogły działać:

  • może być zwracany z funkcji
std::unique_ptr<Gadget> create_gadget(int type)
{
    return std::unique_ptr<Gadget> {new Gadget(arg)};
}

auto ptr_gadget = create_gadget(1); // implicit transfer of ownership
ptr_gadget->do_something();

std::unique_ptr<Gadget> another_ptr = std::move(ptr_gadget); // explicit transfer of ownership

Od C++14 funkcję create_gadget() można zastąpić biblioteczną funkcją std::make_unique(), która transferuje swoje argumenty wywołania do konstruktora alokowanego na stercie obiektu:

auto ptr_gadget = std::make_unique<Gadget>(arg); // C++14
  • może być przekazywany (przenoszony) do funkcji jako parametr sink
void sink(unique_ptr<Gadget> gadget)
{
    gadget->call_method();
    // sink takes ownership of gadget - deletes the object pointed by gadget
}

sink(std::move(ptr_gadget));  // explicitly moving into sink
sink(create_gadget(2));  // implicit move
sink(std::make_unique<Gadget>(42)); // implicit move
  • może być przechowywany w kontenerach STL
auto gadget = std::make_unique<Gadget>(67);

std::vector<std::unique_ptr<Gadget>> gadgets;

gadgets.push_back(std::move(gadget)); // explicit transfer of ownership
gadgets.push_back(std::make_unique<Gadget>(31)); // implicit move to container
gadgets.push_back(create_gadget(3)); // implicit move to the container

for(const auto& g : gadgets)
    g->do_something();

gadgets.clear(); // elements are automatically destroyed

Wskaźniki klas pochodnych

Wskaźnik do klasy pochodnej może zostać przypisany do wskaźnika do klasy bazowej (upcasting). Daje to możliwość stosowania polimorfizmu z wykorzystaniem funkcji wirtualnych.

Gadget* pb = new SuperGadget();
pb->do_something();

Odpowiednik z użyciem unique_ptr

std::unique_ptr<Gadget> pb = std::make_unique<SuperGadget>();

//explicit conversion - hard to miss it
auto pb = std::unique_ptr<Gadget>{ std::make_unique<SuperGadget>() };

Dealokatory

Używając wskaźnika std::unique_ptr można zdefiniować własny dealokator, który będzie odpowiedzialny za prawidłowe zwolnienie zasobu. Umożliwia to kontrolę nad zasobami innymi niż obiekty dynamicznie alokowane na stercie lub wymagającymi specjalnej obsługi w fazie destrukcji.

Aby użyć własnego dealokatora należy podać jego typ jako drugi parametr szablonu std::unique_ptr<T, Dealloc> oraz przekazać instancję dealokatora jako drugi parametr konstruktora:

{
    std::unique_ptr<FILE, int(*)(FILE*)> file{fopen("test.txt", "w"), &fclose};

    fprintf(file.get(), "Id %d", 42);
} // fclose() is called for an opened file

Ważne

Dealokator dla unique_ptr wywoływany jest tylko, jeśli wewnętrzny wskaźnik jest różny od nullptr!

Idiom PIMPL

Wskaźnik std::unique_ptr świetnie nadaje się do stosowania tam, gdzie wcześniej stosowane były wskaźniki zwykłe albo obiekty typu std::auto_ptr (obecnie mające status deprecated), np. do implementacji idiomu PIMPL

PIMPL - Private Implementation:

  • minimalizuje zależności na etapie kompilacji
  • separuje interfejs od implementacji
  • ukrywa implementację

Plik blob.hpp:

class Blob
{
public:
    // interfejs klasy
    /* … */
    ~Blob(); // must be only declared
private:
    class Impl; // deklaracja zapowiadająca

    // implementacja jest ukryta
    std::unique_ptr<Impl> pimpl_; // wskaźnik do implementacji
};

Plik blob.cpp:

class Blob::Impl
{
    // wszystkie składowe prywatne (pola i metody)
    // zmiany implementacji nie wymagają rekompilacji klas obiektów korzystających
    // z instancji Blob
};

Blob::Blob() : pimpl_ {new Impl()}
{
    // ustawienie stanu obiektu implementującego
}

Blob::~Blob() = default; // important! after defintion of Blob::Impl()

Specjalizacja dla tablic std::unique_ptr<T[]>

Szablon std::unique_ptr stanowi również lepszą alternatywę dla klasycznych tablic przydzielanych dynamicznie.

Przejmuje zarządzanie czasem życia takich tablic. Oferuje przeciążony operator indeksowania (operator[]) umożliwiający stosowanie naturalnej składni odwołań do elementów tablicy. Destruktor wykorzystuje operator delete [] aby automatycznie usunąć wskazywaną tablicę.

Specjalizacja std::unique_ptr<T[]> wykorzystywana jest głównie do bezpiecznej obsługi kodu legacy

namespace Legacy
{
    int* create_array(size_t size)
    {
        return new int[size]; // creates dynamic array on heap
    }
}

//...
{
    int size = 1024;
    std::unique_ptr<int[]> data{create_array(size)};

    for(int i = 0; i < size; ++i)
    {
        data[i] = i; // operator[]

        may_throw(data);
    } // automatic delete[] on allocated array
}

Wskaźniki ze zliczaniem odniesień

Inteligentne wskaźniki ze zliczaniem odniesień eliminują konieczność kodowania skomplikowanej logiki sterującej czasem życia obiektów współużytkowanych przez pewną liczbę innych obiektów.

Można podzielić je na:

  • ingerencyjne (intrusive) – wymagają od klas obiektów zarządzanych udostępnienia specjalnych metod lub składowych, za pomocą których realizowane jest zliczanie odwołań
  • nieingerencyjne (non-intrusive) – nie wymagają niczego od obiektu zarządzanego

Ważne

Wskaźniki ze zliczaniem odniesień mogą być przechowywane w kontenerach standardowych (np. vector, list, itp.)

Klasa std::shared_ptr<T>

Plik nagłówkowy: <memory>

std::shared_ptr jest szablonem nieingerencyjnego wskaźnika zliczającego odniesienia do wskazywanych obiektów.

Działanie:

  • konstruktor tworzy licznik odniesień i inicjuje go wartością 1
  • konstruktor kopiujący lub operator przypisania inkrementują licznik odniesień
  • destruktor zmniejsza licznik odniesień, jeżeli ma on wartość 0, to usuwa obiekt wywołując domyślnie operator delete

Przykład 1

#include <memory>
#include <map>
#include <string>

class Gadget { /* implementacja */ };

std::map<std::string, std::shared_ptr<Gadget>> gadgets;

void use()
{
    std::shared_ptr<Gadget> p1 = std::make_shared<Gadget>(1);  // reference counter = 1
    {
        std::shared_ptr<Gadget> p2 = p1; // copying of shared_ptr
        // reference counter == 2

        gadgets.insert(make_pair("mp3 player", p2)); // copying shared_ptr to a std container
        // reference counter == 3

        p2->use();
    }  // destruction of p2 decrements reference counter = 2
}  // destruction of p1 decrements reference counter = 1

// ...

gadgets.clear(); // reference counter = 0 - gadget is removed

Przydatne metody z interfejsu shared_ptr<T>

T* get() const

zwraca przechowywany wskaźnik.

void reset()

zwalnia prawo własności do zarządzanego obiektu.

template <typename Y*> void reset(Y* ptr)

zamienia obiekt zarządzany na obiekt wskazywany przez ptr.

bool unique() const

zwraca true, jeśli obiekt shared_ptr, na rzecz którego nastąpiło wywołanie, jest jedynym właścicielem przechowywanego wskaźnika. W pozostałych przypadkach zwraca false

long use_count() const

zwraca wartość licznika odwołań do wskaźnika przechowywanego w obiekcie shared_ptr. Przydatna w diagnostyce.

void swap(shared_ptr<T>& other)

wymiana wskaźników między dwoma obiektami shared_ptr. Wymienia wskaźniki oraz liczniki odwołań.

explicit operator bool() const

umożliwia konwersję do wartości logicznej, np. if (p && p->is_valid())

Fabryka std::make_shared<T>()

Używanie std::shared_ptr eliminuje konieczność stosowanie operatora delete, jednakże nie eliminuje użycia new. Można uniknąć używania operatora new stosując zamiast tego funkcję pomocniczą std::make_shared(), która pełni rolę fabryki wskaźników std::shared_ptr. Funkcja przekazuje swoje parametry do konstruktora obiektu kontrolowanego przez inteligentny wskaźnik (perfect forwarding).

auto ptr = std::make_shared<std::string>("hello, world!");
std::cout << *ptr << "\n";

Ważne

Stosowanie funkcji std::make_shared<T>(arg...) jest wydajniejsze niż konstrukcja shared_ptr<T>(new T(arg...)) ponieważ alokowany jest tylko jeden segment pamięci, w którym umieszczany jest wskazywany obiekt oraz blok kontrolny z licznikami odniesień.

Problem tymczasowych obiektów typu std::shared_ptr

Ważne

Zawsze należy używać nazwanych instancji std::shared_ptr!

Użycie tymczasowych zmiennych anonimowych typu std::shared_ptr może powodować wycieki pamięci.

Przykład:

void f(std::shared_ptr<int>, int);
int may_throw();

void ok()
{
    std::shared_ptr<int> ptr { new int(2) };
    f(ptr, may_throw());
    // or
    f(std::make_shared<int>(13), may_throw());
}

void bad()
{
    f(std::shared_ptr<int> {new int(2)}, may_throw() );
}

Ponieważ kolejność ewaluacji argumentów funkcji nie jest określona, jest możliwa sytuacja w której wykona się new, następnie funkcja may_throw rzuci wyjątkiem. Obiekt``std::shared_ptr`` nie został w ogóle skonstruowany, nie wykona się jego destruktor, a tym samym zasób nie zostanie uprzątnięty.

Powyższy problem może zostać również rozwiązany przy użyciu funkcji std::make_shared().

Dealokatory

Niekiedy pojawia się potrzeba zastosowania wskaźnika std::shared_ptr do kontroli zasobu takiego typu, że jego zwolnienie nie sprowadza się do prostego wywołania operatora delete. Takie przypadki std::shared_ptr obsługuje przy pomocy dealokatorów użytkownika.

Dealokator jest:

  • obiektem funkcyjnym odpowiedzialnym za zwolnienie zasobu, kiedy liczba referencji spadnie do zera.
  • przekazywany jako drugi argument konstruktora std::shared_ptr.
class SocketCloser
{
public:
    void operator()(Socket* s)
    {
        s->close();
    }
};

void use_device()
{
    Socket socket;
    std::shared_ptr<Socket> safe_socket(&socket, SocketCloser{});

    may_throw(); // może wylecieć wyjątek

    safe_socket->write("Data");
} // zasób Socket został bezpiecznie zwolniony za pomocą metody close()

Rzutowania między wskaźnikami std::shared_ptr

Problemy związane z rzutowaniami między wskaźnikami shared_ptr rozwiązują trzy funkcje szablonowe:

template<class T, class U>
std::shared_ptr<T> static_pointer_cast(std::shared_ptr<U> const &r)
  • Jeśli r jest pusty, zwraca pusty shared_ptr<T>
  • W innym przypadku shared_ptr<T> przechowuje kopię static_cast<T*>(r.get()) i współdzieli prawo własności z r
template<class T, class U>
std::shared_ptr<T> const_pointer_cast(std::shared_ptr<U> const &r)
  • Jeśli r jest pusty, zwraca pusty shared_ptr<T>
  • W innym przypadku shared_ptr<T> przechowuje kopię const_cast<T*>(r.get()) i współdzieli prawo własności z r
template<class T, class U>
std::shared_ptr<T> dynamic_pointer_cast(std::shared_ptr<U> const &r)
  • Jeśli dynamic_cast<T*>(r.get()) zwraca wartość różną od nullptr, zwracany jest obiekt shared_ptr<T>, który współdzieli prawo własności do r
  • W przeciwnym wypadku pusty shared_ptr<T>
class Gadget
{};

class SuperGadget : public Gadget
{
public:
    void super_only();
};

std::shared_ptr<Gadget> g = std::make_shared<SuperGadget>(42); // rc == 1
auto super_g = std::dynamic_pointer_cast<SuperGadget>(g); // rc == 2
super_g->super_only();

Zależności cykliczne między wskaźnikami shared_ptr

Problemem dla wskaźników std::shared_ptr są zależności cykliczne, które powodują wycieki zasobów (pamięci).

struct Cyclic
{
    std::shared_ptr<Cyclic> me;
    /* … */
};

void foo()
{
    auto cptr = std::make_shared<Cyclic>();
    cptr->me = cptr;
}  // wyciek pamięci

Ważne

Cykle muszą być przerywane za pomocą wskaźników std::weak_ptr

Tworzenie wskaźnika std::shared_ptr ze wskaźnika this

Niekiedy istnieje konieczność utworzenia inteligentnego wskaźnika std::shared_ptr ze wskaźnika this. Oznacza to, że obiekt danej klasy będzie zarządzany za pośrednictwem inteligentnego wskaźnika. W ogólności rozwiązaniem problemu konwersji this do std::shared_ptr jest użycie wskaźnika typu std::weak_ptr, który jest obserwatorem wskaźników std::shared_ptr (pozwala na podglądanie wskazywanego obiektu bez ingerowania w wartość licznika odwołań). Zdefiniowanie w klasie składowej typu std::weak_ptr i zainicjowanie jej wartością this umożliwia późniejsze pozyskiwanie wskaźników std::shared_ptr. Aby nie trzeba było za każdym razem pisać tego samego kodu, można wykorzystać dziedziczenie po pomocniczej klasie std::enable_shared_from_this.

Klasa std::enable_shared_from_this<T> umożliwia obiektowi typu T, który jest zarządzany przez instancję std::shared_ptr<T> bezpieczne tworzenie dodatkowych wskaźników typu std::shared_ptr<T>, które współdzielą prawo własności do zarządzanego obiektu.

Przykład nieprawidłowego generowania instancji std::shared_ptr ze wskaźnika this:

#include <memory>

void do_stuff(std::shared_ptr<A> p)
{
   // using p
}

// ...

class A
{
public:
   void call_do_stuff()
   {
      do_stuff(std::shared_ptr<A>(this));
   }
};

int main()
{
   auto p = std::make_shared<A>();
   p->call_do_stuff();
}

Prawdiłowe tworzenie instancji std::shared_ptr ze wskaźnika this:

#include <memory>

void do_stuff(std::shared_ptr<A> p)
{
   // using p
}

// ...

class A : public std::enable_shared_from_this<A>
{
public:
   void call_do_stuff()
   {
      do_stuff(shared_from_this());
   }
};

int main()
{
   auto p = std::make_shared<A>();
   p->call_do_stuff();
}

std::shared_ptr – podsumowanie

Wskaźniki std::shared_ptr można skutecznie stosować tam, gdzie:

  • jest wielu użytkowników obiektu, ale nie ma jednego jawnego właściciela
  • trzeba przechowywać wskaźniki w kontenerach biblioteki standardowej
  • trzeba przekazywać wskaźniki do i z bibliotek, a nie ma jawnego wyrażenia transferu własności

Klasa std::weak_ptr<T>

Plik nagłówkowy: <memory>

Najbardziej znanym problemem, związanym ze wskaźnikami opartymi na zliczaniu odniesień, są odniesienia cykliczne. Występują one w sytuacji, gdy kilka obiektów trzyma wskaźniki do siebie nawzajem, przez co licznik odniesień nie spada do zera i wskazywane obiekty nie są nigdy kasowane. Rozwiązaniem tego problemu jest zastosowanie słabych wskaźników – obiektów std::weak_ptr.

Wskaźnik typu std::weak_ptr obserwuje wskazywany obiekt, ale nie ma kontroli nad czasem jego życia i nie może zmieniać jego licznika odniesień.

Nie udostępnia operatorów dereferencji. Aby mieć dostęp do wskazywanego obiektu konieczne jest dokonanie konwersji do std::shared_ptr.

Gdy wskazywany obiekt został już skasowany konwersja na std::shared_ptr daje w wyniku:

  • wskaźnik pusty - w przypadku metody lock()
  • zgłasza wyjątek std::bad_weak_ptr - w przypadku konstruktora std::shared_ptr<T>(const std::weak_ptr<T>&)

Konwersja std::weak_ptr na std::shared_ptr

Przykład 1

std::weak_ptr<Gadget> observer;
auto ptr = std::make_shared<Gadget>(42);

observer = ptr; // ref counter == 1

ptr.reset();  // ref counter == 0 -> gadget is destroyed

if (std::shared_ptr<Gadget> valid_ptr = observer.lock(); valid_ptr)
{
    valid_ptr->do_stuff();
}

Przykład 2

std::weak_ptr<Gadget> observer;
auto ptr = make_shared<Gadget>(42);

observer = ptr; // ref counter == 1

ptr.reset();  // gadget is destroyed

try
{
    std::shared_ptr<Gadget> anotherPtr(observer);
}
catch(const std::bad_weak_ptr& e)
{
    std::cout << e.what() << "\n";
}

Przechowywanie std::weak_ptr w kontenerach asocjacyjnych

Aby przechować wskaźniki std::weak_ptr w kontenerach asocjacyjnych należy klasy std::owner_less jako parametru definiującego sposób porównania wskaźników.

Klasa std::owner_less dostarcza implementację umożliwiającą porównanie wskaźników std::shared_ptr oraz std::weak_ptr na podstawie prawa własności, a nie wartości przechowywanych wskaźników. Dwa wskaźniki są uznane za równoważne tylko wtedy, gdy oba są puste lub oba zarządzają tym samym obiektem (współdzielą blok kontrolny).

std::map<std::shared_ptr<Key>, Value, std::owner_less<std::shared_ptr<Key>>> my_map;

std::set<std::weak_ptr<Key>, std::owner_less<std::weak_ptr<Key>>> my_set;

Zastosowanie std::weak_ptr

Wskaźniki std::weak_ptr stosuje się do:

  • zrywania cyklicznych zależności dla std::shared_ptr
  • współużytkowania zasobu bez przejmowania odpowiedzialności za zarządzanie nim
  • eliminowania ryzykownych operacji na wiszących wskaźnikach