Czy wiesz, że jesteśmy również na Slacku? Dołącz do nas już teraz klikając tutaj!

Kompendium wiedzy o smart pointerach


2018-11-07, 00:00

Niestety, bardzo często język C++ jest obwiniany przez programistów o to, że pisząc w nim, łatwo o wycieki pamięci. Wiele osób nie zdaje sobie sprawy, że mówią o C++ sprzed… ponad 7 lat! Dzisiaj dowiemy się, dlaczego nie jest tak łatwo walczyć z wyciekami pamięci używając zwykłych wskaźników oraz co nowego w tym temacie oferuje nam standard C++11.

Wskaźnikowy problem

Największą bolączką wskaźników jest to, że często zapominamy zwolnić zaalokowane wcześniej w pamięci miejsce, kiedy to nie jest nam już potrzebne. Niestety, bardzo często dochodzi do sytuacji, kiedy nie zauważamy miejsca, w którym należy o to zadbać. Niejednokrotnie taka sytuacja pojawia się podczas wyrzucania wyjątków. Prześledźmy poniższy kod (bez analizy jego sensu).

void doSomething(int howMany) {
    int *value = new int[howMany];
    if (howMany > 100) {
        throw std::string("Za duża wartość");
    }
    delete[] value;
}

int main()
{
    try {
        doSomething(1000);
    } catch (std::string e) {}
}

W powyższym kodzie wyrzucamy wyjątek, zapominając o dealokacji wcześniej zarezerwowanej pamięci. Ten problem na szczęście można szybko rozwiązać. Wystarczy dorzucić tylko jedną linijkę, a cały problem znika:

void doSomething(int howMany) {
    int *value = new int[howMany];
    if (howMany > 100) {
        delete[] value;     // <---- dodana linijka
        throw std::string("Za duża wartość");
    }
    delete[] value;
}

int main()
{
    try {
        doSomething(1000);
    } catch (std::string e) {}
}

W tym przypadku o problemie zadecydowało zupełne gapiostwo ze strony programisty, które można w bardzo szybki sposób poprawić. Przeanalizujmy jednak nieco bardziej skomplikowany przykład:

int fibo(int number) {
    if (number <= 2) {
        return 1;
    }
    return fibo(number-1)+fibo(number-2);
}

int calculateFibo(int howMany) {
    if (howMany > 100) {
        throw std::string("Za duża wartość");
    }
    return fibo(howMany);
}

void doSomething(int howMany) {
    int *value = new int[howMany];
    for (int i=0; i<howMany; i++) {
        value[i] = calculateFibo(howMany);
    }
    delete[] value;
}

int main()
{
    try {
        doSomething(1000);
    } catch (std::string e) {}
}

Pomijając jego sens, przeanalizujmy to, co tutaj się dzieje. Alokujemy w pamięci miejsce na tablicę, po czym uzupełniamy ją o kolejne wartości ciągu Fibonacciego. W sytuacji, kiedy potrzebujemy obliczyć zbyt wielką wartość ciągu wyrzucany jest wyjątek. Na naszą niekorzyść, nie jest on łapany wewnątrz funkcji, która alokuje pamięć. Jest on łapany jeden poziom wyżej, przez co moment dealokacji zostaje pominięty w wykonaniu. W tym miejscu wycieka nam pamięć, ponieważ po złapaniu wyjątku nie mamy już dostępu do wskaźnika wskazującego ten obszar pamięci.

Jest to poglądowy, bardzo prosty przykład pokazujący jak można “zgubić” uchwyt do pamięci, którą należy dealokować. W tym przypadku możemy dorzucić dodatkowy try-catch, wyczyścić pamięć a następnie rzucić wyjątek dalej. Sytuacja komplikuje się, kiedy pracujemy w projekcie podzielonym na wiele plików, gdzie jest dużo więcej warstw wywołań funkcji oraz alokacji pamięci. Wtedy już nie do końca widać pełny przepływ informacji i dużo łatwiej o wycieki pamięci. Nie mówiąc o tym, że sporą częścią programu będą konstrukcje try-catch przerzucające wyjątki wyżej.

Twórcy języka C++ będącego językiem ciągle rozwijającym się zauważyli ten problem i rozwiązali go, dodając do standardu C++11 wskaźniki inteligentne, którym poświęcona zostanie dalsza część wpisu.

Inteligentne wskaźniki - jak to działa?

Standard C++11 wprowadza pojęcie wskaźników “inteligentnych”. Ich inteligencja polega na automatycznym niszczeniu zawartości (wraz z dealokacją pamięci) w chwili, kiedy przestaje być użyteczny. Jedyne co do nas należy, to wybrać odpowiedni typ wskaźnika, dzięki któremu mamy kontrolę nad czasem życia przechowywanej wartości.

Jedno dowiązanie - std::unique_ptr

Podstawowym typem inteligentnego wskaźnika jest std::unique_ptr. Jak sama nazwa wskazuje, ma on coś wspólnego z unikalnością. Dokładniej, po utworzeniu takiego wskaźnika, może istnieć tylko jeden obiekt, który posiada prawo własności do przechowywanej wartości. Prawo własności możemy przekazywać (ale nie współdzielić!). Kiedy dowiązanie właściciela straci swoją ważność, zawartość na którą on wskazywał jest niszczona.

Utworzyć std::unique_ptr możemy w następujący sposób:

std::unique_ptr<int> p1(new int(10));
std::unique_ptr<int> p2 = std::make_unique<int>(42);  // <---- Ten sposób działa dopiero od C++14!

Wspomnieć należy, że inteligentne wskaźniki są typami szablonowymi - przechowywać wewnątrz możemy wartość dowolnego typu (wartości skalarne, struktury, obiekty). Zainicjalizować inteligentny wskaźnik możemy na dwa różne sposoby: poprzez jawne użycie operatora new lub wykorzystanie funkcji std::make_unique, która robi to za nas.

Powyższy przykład pokazuje nam również, czym fizycznie jest wskaźnik inteligentny. Jest on obiektem dekorującym dla wskaźnika, który znamy sprzed standardu C++11. Dostępny tutaj jest konstruktor, który rezerwuje pamięć oraz destruktor, który tą pamięć zwalnia w odpowiednim czasie. Mamy również przeładowane operatory: wyłuskania (*) oraz dostępu do pól i metod (->). Spójrzmy na ten przykład:

#include <iostream>
#include <memory>

class A {

public:

    A() {
        std::cout << "Konstruktor" << std::endl;
    }

    void doSomething() {
        std::cout << "Do something" << std::endl;
    }

    ~A() {
        std::cout << "Destruktor" << std::endl;
    }

};

int main()
{
    std::unique_ptr<A> p = std::make_unique<A>();
    p->doSomething();

    std::unique_ptr<int> p2 = std::make_unique<int>(20);
    std::cout << *p2 << std::endl;
}

Na wyjście ten program zwróci nam:

Konstruktor
Do something
20
Destruktor

Teraz prześledźmy, co się stało:

  1. Utworzyliśmy wskaźnik na obiekt typu A (w tym miejscu wywołany zostaje konstruktor klasy A).
  2. Uruchomiliśmy metodę doSometing() używając przeładowanego operatora ->.
  3. Utworzyliśmy nowy wskaźnik na typ int
  4. Uzyskujemy dostęp do przechowywanej przez wskaźnik wartości, korzystając z operatora wyłuskania *.
  5. Aktualny zasięg się skończył, więc następuje automatyczne uruchomienie destruktora, a następnie zwolnienie pamięci.

Na ogół możemy zauważyć, że inteligentny wskaźnik zachowuje się w taki sam sposób, co wskaźnik zwykły. Jedyną różnicą w zachowaniu jest to, że w momencie kiedy obiekt przestaje być używany - zostaje usunięty z pamięci. Jest to zachowanie nowe, ale bardzo pożądane.

W razie, kiedy potrzebujemy dostać się do wskaźnika zwykłego przechowywanego przez ten inteligentny, możemy zrobić tak:

std::unique_ptr<int> p = std::make_unique<int>(10);
int *pp = p.get();

Operacja ta może być dla nas przydatna, kiedy np. chcemy otrzymać informację na temat miejsca w pamięci, w którym przechowywana jest wartość.

Konwersja do typu bool

Sporą zaletą inteligentnych wskaźników jest automatyczna konwersja do typu bool. Nie musimy szukać takich wartości jak NULL, nullptr czy 0. Wewnątrz warunku możemy potrakować wskaźnik tak, jakby był zmienną typu bool:

#include <iostream>
#include <memory>

int main()
{
    std::unique_ptr<int> p;
    std::cout << (bool)p << std::endl;
    if (p) {
        std::cout << "Ten warunek nie wskoczy" << std::endl;
    }

    p = std::make_unique<int>();
    std::cout << (bool)p << std::endl;
    if (p) {
        std::cout << "Ten warunek wskoczy" << std::endl;
    }
}

Powyższy program na wyjściu da nam:

0
1
Ten warunek wskoczy

Automatyczna destrukcja

Tak jak pisałem wcześniej, inteligentne wskaźniki mają to do siebie, że są automatycznie usuwane, kiedy nie są już potrzebne. Kiedy w takim razie nastąpi destrukcja wskaźnika std::unique_ptr? Odpowiedź brzmi:

  • Kiedy kończy się zasięg, w którym został on utworzony
  • Kiedy usunięty zostaje obiekt przechowujący go jako swoją właściwość
  • Podczas manualnego wywołania metody reset na obiekcie tego wskaźnika
  • Podczas każdego kolejnego użycia funkcji std::make_unique

Podczas operacji niszczenia obiektu uruchamiany jest destruktor, dzięki czemu zachowane zostaje zachowanie zwykłego wskaźnika. W przypadku, kiedy kilkukrotnie używamy funkcji std::make_unique w kontekście tego samego obiektu, również wywoływany jest destruktor. Spójrzmy na przykład poniżej, który obrazuje to zachowanie:

#include <iostream>
#include <memory>

class A {

public:

    static int counter;
    int number;

    A() {
        number = A::counter+1;
        A::counter++;
        std::cout << "Konstruktor " << number << std::endl;
    }

    ~A() {
        std::cout << "Destruktor " << number << std::endl;
    }
};

int A::counter = 0;

int main()
{
    std::unique_ptr<A> p = std::make_unique<A>();
    p = std::make_unique<A>();
}

Kod ten na wyjściu zwróci nam:

Konstruktor 1
Konstruktor 2
Destruktor 1
Destruktor 2

Jak widać, wszystko ładnie gra :)

Kopiowanie - zabronione!

Tym, co czyni std::unique_ptr wyjątkowym, jest wyłącznym właścicielem wskazywanego obiektu. Oznacza to, że kiedy on wskazuje na ten fragment pamięci, to żaden inny wskaźnik nie może tego zrobić. Nie możemy w takim razie zrobić tak:

#include <memory>

int main()
{
    std::unique_ptr<int> p = std::make_unique<int>();
    std::unique_ptr<int> pp = p;   // <---- Tutaj pojawi się błąd
}

Nie możemy również zrobić tak:

#include <memory>

void function(std::unique_ptr<int> p) {

}

int main()
{
    std::unique_ptr<int> p = std::make_unique<int>();
    function(p);   // <---- Tutaj pojawi się błąd
}

Jest tak dlatego, ponieważ konstruktor kopiujący klasy std::unique_ptr został usunięty. Zatem wszędzie, gdzie będzie występowało kopiowanie obiektu wskaźnika, dostaniemy błąd:

call to implicitly-deleted copy constructor of 'std::unique_ptr<int>'

Jeżeli z jakiegoś powodu chcemy zmienić właściela przechowywanej wartości, musimy użyć funkcji std::move:

#include <iostream>
#include <memory>

int main()
{
    std::unique_ptr<int> p = std::make_unique<int>(10);
    std::cout << p.get() << std::endl;
    std::unique_ptr<int> pp = std::move(p);
    std::cout << p.get() << std::endl;
    std::cout << pp.get() << std::endl;
}

Wykonując ten kod dostałem na wyjście coś takiego:

0x7f9b41c0f110
0x0
0x7f9b41c0f110

Zauważmy, że wskaźnik, z którego przenosimy własność jest zerowany po całej operacji (nie wskazuje na nic). Możemy go od nowa zainicjalizować, używając funkcji std::make_unique.

Wracając do kopiowania…

Tak jak wcześniej wspomniałem, nie jest możliwe skopiowanie wskaźnika typu std::unique_ptr. Jak w takim razie przesłać nasz obiekt dalej? Na szczęście, jest na to rada. Dozwolone jest przesyłanie obiektu przez referencję (pamiętajmy, że wtedy pracujemy na tym samym obiekcie!), zatem poprawnym jest:

void function(std::unique_ptr<int> &p) {

}

int main()
{
    std::unique_ptr<int> p = std::make_unique<int>();
    function(p);
}

W razie potrzeby, możemy również przesłać nasz wskaźnik jako właściwość klasy (przez konstruktor):

#include <iostream>
#include <memory>

class B {

public:

    B(std::unique_ptr<int> &p): pInTheClass(p) {

    }

    void doSomething() {
        std::cout << *pInTheClass << std::endl;
    }

private:

    std::unique_ptr<int> &pInTheClass;
};

int main()
{
    std::unique_ptr<int> p = std::make_unique<int>(10);
    B b(p);
    b.doSomething();
}

Niestety, ale to rozwiązanie działa wyłącznie wewnątrz konstruktora, który potrafi natychmiastowo zainicjalizować właściwości, jeszcze przed wejściem w jego kod wykonujący. Dzieje się tak, ponieważ wszystkie właściwości będące referencjami muszą być zainicjalizowane w momencie konstruowania obiektu (nie może istnieć referencja wskazująca na nic).

Ostatni gasi światło - std::shared_ptr

To, że std::unique_ptr nie jest kopiowalny, nie oznacza że na tym kończy się bajka o wskaźnikach inteligentnych. Do tego celu stworzony został typ std::shared_ptr, który inicjalizujemy w bardzo podobny sposób co std::unique_ptr:

#include <iostream>
#include <memory>

int main()
{
    std::shared_ptr<int> p = std::make_shared<int>(10);
    std::shared_ptr<int> pp(new int(10));
}

Na ogół std::shared_ptr zachowuje się tak samo, jak std::unique_ptr - mamy przeciążony operator wyłuskania * oraz dostępu ->. Mamy też funkcję zwracającą przechowywany wskaźnik starego typu get(). Tym, co różni go od std::unique_ptr jest jego odmienna definicja prawa własności do przechowywanej wartości. W przeciwieństwie do niego, std::shared_ptr może współdzielić własność do wartości z innymi wskaźnikami typu std::shared_ptr. Możemy w takim razie zrobić tak:

#include <iostream>
#include <memory>

int main()
{
    std::shared_ptr<int> p = std::make_shared<int>(10);
    std::shared_ptr<int> pp = p;
    std::cout << p.use_count() << std::endl;
    std::cout << pp.use_count() << std::endl;
}

Metoda use_count() zwraca nam liczbę wskaźników aktualnie wskazujących na daną wartość. Liczenie referencji na wartości niesie za sobą niemały narzut, dlatego na pierwszym miejscu powinniśmy używać std::unique_ptr, a w sytuacjach, kiedy jest to niemożliwe, dopiero pomyśleć o std::shared_ptr.

Czas życia wskazywanej wartości

Wartość, na którą wskazują wskaźniki współdzielone żyje tak długo, dopóki wskazuje na nią conajmniej jeden wskaźnik. Dopiero, kiedy kończy się czas życia ostatniego wskazującego na wartość wskaźnika, uruchamiany jest destruktor na przechowywanej wartości (jeżeli jest ona obiektem), a następnie jest ona niszczona. Prześledźmy sobie taki kod:

#include <iostream>
#include <memory>

class A {

public:

    A() {
        std::cout << "Konstruktor" << std::endl;
    }

    ~A() {
        std::cout << "Destruktor" << std::endl;
    }

};

int main()
{
    {
        std::shared_ptr<A> p;
        std::cout << p.use_count() << std::endl;
        {
            std::shared_ptr<A> pp = std::make_shared<A>();
            p = pp;

            std::cout << pp.use_count() << std::endl;
            std::cout << p.use_count() << std::endl;
        }
        std::cout << "Przed ostatnią klamrą" << std::endl;
        std::cout << p.use_count() << std::endl;
    }
    std::cout << "Koniec programu" << std::endl;
}

Na wyjściu pojawi nam się:

0
Konstruktor
2
2
Przed ostatnią klamrą
1
Destruktor
Koniec programu

Jak widać, obiekt klasy A jest trzymany w pamięci dopóki istnieje conajmniej jeden wskaźnik na niego wskazujący. To znaczy, że zasada “Ostatni gasi światło” działa. Dodatkowo, skoro wskaźnik typu std::shared_pointer można kopiować, to znaczy że można go również używać w funkcjach (bez stosowania referencji), a także przypisywać do właściwości klasy za pomocą setterów.

Uwaga na zakleszczenie!

Powszechnym problemem związanym z nadużywaniem std::shared_ptr jest tzw. zakleszczenie (zależności cykliczne). Polega ono na tym, że mogą istnieć dwa wskaźniki na klasy, które wewnątrz zawierają wskaźniki wskazujące na siebie wzajemnie, tak jak na poniższym przykładzie:

#include <memory>
#include <iostream>

class B;
class A {

public:

    std::shared_ptr<B> b;

    ~A() {
        std::cout << "Destruktor A" << std::endl;
    }
};

class B {

public:

    std::shared_ptr<A> a;

    ~B() {
        std::cout << "Destruktor B" << std::endl;
    }
};

void doSomething() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b = b;
    b->a = a;
}

int main() {
    doSomething();
    std::cout << "Tutaj A i B powinny być usunięte" << std::endl;
}

Niestety, mimo że obiekty wskaźników zostaną usunięte, to pamięć żadnego z nich nie zostanie zwolniona. Jest tak dlatego, ponieważ w chwili usunięcia każdego z nich licznik referencji wynosi 2. Jest to jeden z kluczowych powodów, dla których nie powinniśmy nadużywać tego typu wskaźników. Na szczęście istnieje sposób na rozwiązanie tego problemu: stosowanie trzeciego typu wskaźnika inteligentnego, std::weak_ptr.

Wskaźnik tymczasowy - std::weak_ptr

Wskaźnik std::weak_ptr jest zawsze stosowany w parze z std::shared_ptr. Stosuje się go wtedy, kiedy chcemy utworzyć wskaźnik na miejsce w pamięci, ale nie chcemy w tej chwili podbijać licznika referencji. Dopiero w momencie faktycznego wykorzystania wartości wskaźnik std::weak_ptr konwertujemy do typu std::shared_ptr, który podnosi licznik referencji.

Kiedy zatem przydatny staje się wskaźnik typu std::weak_ptr? Najsłuszniejszym miejscem będą na pewno właściwości klasy, dzięki którym zabezpieczamy się przed cyklicznymi zależnościami. Przywołując w tym miejscu poprzedni przykład, możemy pozbyć się zależności cyklicznejj przez zamianę conajmniej jednego pola typu std::shared_ptr na typ std::weak_ptr:

#include <memory>
#include <iostream>

class B;
class A {

public:

    std::shared_ptr<B> b;

    ~A() {
        std::cout << "Destruktor A" << std::endl;
    }
};

class B {

public:

    std::weak_ptr<A> a;

    ~B() {
        std::cout << "Destruktor B" << std::endl;
    }
};

void doSomething() {
    auto a = std::make_shared<A>();
    auto b = std::make_shared<B>();
    a->b = b;
    b->a = a;
}

int main() {
    doSomething();
    std::cout << "Tutaj A i B powinny być usunięte" << std::endl;
}

Z powyższego przykładu wynika, że do std::weak_ptr możemy przypisać obiekt typu std::shared_ptr. I tak faktycznie jest. Niestety, ale do samej wartości nie możemy dobrać się przez zwykły wskaźnik miękki. Aby mieć dostęp do przechowywanej wartości, potrzebujemy skonwertować std::weak_ptr do std::shared_ptr. Na przykład tak:

#include <memory>
#include <iostream>

int main() {
    std::shared_ptr<int> p = std::make_shared<int>(10);
    std::weak_ptr<int> wp = p;

    std::shared_ptr<int> pp(wp);
    std::cout << *pp << std::endl;
}

Wygasanie ważności wskaźnika

To, że wskaźnik std::weak_ptr nie podbija licznika referencji już wiemy. Niestety, ma to swoje konsekwencje: wartość, na którą wskazuje może po pewnym czasie zostać zniszczona. Przed użyciem wskaźnika miękkiego zawsze trzeba upewnić się, że wartość na którą on wskazuje w dalszym ciągu istnieje:

#include <memory>
#include <iostream>

int main()
{
    std::weak_ptr<int> wp;
    if (wp.expired()) {
        std::cout << "Ten wskaźnik nie wskazuje na nic" << std::endl;
    }

    {
        std::shared_ptr<int> p = std::make_shared<int>(10);
        wp = p;
        if (!wp.expired()) {
            std::cout << "Ten wskaźnik wskazuje na wartość" << std::endl;
        }
    }
    if (wp.expired()) {
        std::cout << "Ten wskaźnik nie wskazuje na nic" << std::endl;
    }
}

Na wyjściu otrzymamy tekst:

Ten wskaźnik nie wskazuje na nic
Ten wskaźnik wskazuje na wartość
Ten wskaźnik nie wskazuje na nic

Podsumowanie

Dowiedzieliśmy dzisiaj, dlaczego wskaźniki starego typu nie są zbyt dobrym wyborem. Przeanalizowaliśmy dwa najczęściej spotykane “sposoby” na wyciek pamięci w programie. Dodatkowo dowiedzieliśmy się, czym są wskaźniki inteligentne i dlaczego zapobiegają one wyciekaniu pamięci. Przyjrzeliśmy się również bliżej każdemu typowi inteligentnych wskaźników, dzięki czemu jesteśmy teraz w stanie pisać programy odporne na puchnięcie w pamięci. Myślę, że ten wpis powinien być obowiązkowym materiałem dla każdego programisty tworzącego oprogramowanie w języku C++.



Marcin Kukliński

Zawodowo backend developer, hobbystycznie pasjonat języka C++. Po godzinach poszerza swoją wiedzę na takie tematy jak teorii kompilacji oraz budowa formatów plików. Jego marzeniem jest stworzyć swój własny język programowania.

Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.
Polityka Prywatności