unique_ptr – inteligentne wskaźniki

Czas czytania: < 1 minutę

Wielokrotnie tworząc jakąś aplikację w C/C++ miałeś pewnie problem ze wskaźnikami. To jest jedna z największych zalet C++, a jednocześnie najgorsze przekleństwo. Wskaźniki pozwalają na bezpośrednią kontrolę nad przydziałem pamięci, a jednocześnie ich użycie może spowodować powstanie trudnych do wykrycia błędów. Osoby rozwijające standard C++ wiedziały o tym problemie. Po kilku latach prac nad nowymi standardami powstało rozwiązanie praktycznie wszystkich problemów raw-pointerów. Sposobem na pokonanie wszelkich problemów z „surowymi wskaźnikami” są „inteligentne wskaźniki”, którymi zajmiemy się w tym artykule.

Nowe możliwości w zakresie wskaźników dodał standard C++11. C++14 natomiast je rozszerzył. Powstało kilka rodzajów inteligentnych wskaźników. Wszystkie są zadeklarowane w pliku nagłówkowym memory. Powinieneś także dodać opcję kompilacji: „-std=c++14” w ustawieniach swojego IDE.  Jesteś gotowy? Zaczynamy 🙂 Dzisiaj poznamy pierwszy z nich – unique_ptr

Unique_ptr – wskaźnik unikalny

Unique_ptr jest najprostszym z „inteligentnych wskaźników”. Posiada on „na własność” stworzony dynamicznie obiekt. W momencie, kiedy wskaźnik jest niszczony, niszczony jest także wskazywany przez niego obiekt. Dzieje się tak np.: po zakończeniu funkcji/metody, w której zadeklarowany jest dany wskaźnik. Obiekt jest usuwany także wtedy, kiedy do wskaźnika przechowującego już jakiś obiekt przypiszemy inny obiekt.

Przełamujemy lody – make_unique

Kod 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
 
using namespace std;
class fooObject {
    public:
    fooObject() {
        cout<<"Konstruktor fooObject"<<endl;
    }
    ~fooObject() {
        cout<<"Destruktor fooObject"<<endl;
    }
};
int main()
{
    fooObject* object = new fooObject();
    delete object;
}

Kod II

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <memory>
using namespace std;
 
class fooObject {
    public:
    fooObject() {
        cout<<"Konstruktor fooObject"<<endl;
    }
    ~fooObject() {
        cout<<"Destruktor fooObject"<<endl;
    }
};
int main()
{
    unique_ptr<fooObject> object = make_unique<fooObject>();
    return 0;
}

Zaczniemy od bardzo prostego przykładu. Powyżej masz dwa przykłady kodu. Kod I jest napisany z użyciem „raw pointerów”. Drugi natomiast – unique_ptr. Wynik ich działania jest taki sam – stworzenie i skasowanie obiektu w funkcji main. Na pewno rzuca ci się w oczy brak operatorów new/delete w drugim przykładzie. Nie jest to potrzebne. Unique_ptr sam zarządza cyklem życia obiektu. Dzięki temu programista ma mniej roboty i mniej możliwości do popełnienia błędu 🙂

Co możesz jeszcze wywnioskować z powyższego fragmentu kodu? Otóż, nie możesz przypisać obiektu do unique_ptr za pomocą znaku =. Musisz użyć specjalnej funkcji: make_unique. Z czego to wynika? Unique_ptr, jak już wspominałem wcześniej, nie pozwala na kopiowanie obiektu. Używając operatora przypisania, moglibyśmy stworzyć więcej niż jedną kopię obiektu, co w wypadku unique_ptr jest sytuacją niedopuszczalną. Używając funkcji make_unique, tworzymy „unikalny” egzemplarz obiektu, który jest następnie przejmowany i zarządzany przez inteligentny wskaźnik.
Istnieje możliwość przypisania unique_ptr zwykłego wskaźnika, co pokazuje poniższy przykład:

1
unique_ptr<int> wsk(new int(6));

Nie jest to metoda polecana.

Kopiowanie!=przenoszenie – move

Wiemy już jak tworzyć unique_ptry. Dowiedzmy się teraz, jak je przenosić. Służy do tego move. Move zmienia „właściciela” obiektu. Unique_ptr, jak sama nazwa wskazuje, jest „unikalnym” wskaźnikiem. Nie możemy więc go skopiować (Wyrażenie wskaznik1=wskaznik2 nie skompiluje się). Możemy wykonać tylko operację przeniesienia. Spójrz na poniższy przykład:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <memory>
 
using namespace std;
struct seriousStructure {
    float number1;
    int number2;
};
int main()
{
    unique_ptr<seriousStructure> object = make_unique<seriousStructure>();
    object->number1 = 3.4;
    object->number2 = 5;
    unique_ptr<seriousStructure> object2;
    object2 = move(object);
    return 0;
}

Mamy strukturę seriousStructure, którą zapisujemy w unique_ptr object i wypełniamy. Następnie przenosimy object do object2. Wskaźnik object po tej operacji przyjmuje wartość nullptr.
Sprawdź sam! Zmodyfikuj powyższy program tak, aby po operacji przeniesienia odczytywał zawartość obydwu pól struktury ze wskaźnika object2. Spróbuj także na samym końcu odwołać się do wskaźnika object aby przekonać się na własnej skórze o tym, że przyjął on wartość nullptr.

Unique_ptr jako argument funkcji

Wskaźniki unique_ptr możemy przekazywać jako argumenty funkcji/metod. Możemy zrobić to na dwa sposoby. Pierwszy ze sposobów to przeniesienie obiektu do funkcji za pomocą metody move. Możemy także przekazać unique_ptr przez referencję. Pamiętajmy, że jak użyjemy funkcji move, to nasz oryginalny wskaźnik jest nullowany.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <memory>
#include <assert.h>
 
using namespace std;
void foo(unique_ptr<int>& arg) {
    cout<<"foo: "<<*arg<<endl;
    // zmieńmy wartość arg
    *arg = 7;
}
void foo2(unique_ptr<int> arg) {
    cout<<"foo2: "<<*arg<<endl;
}
int main()
{
    unique_ptr<int> number = make_unique<int>(5);
    foo(number);
    foo2(move(number));
    assert(number); // Asercja prawdziwa - number jest równe null
    return 0;
}

W powyższym kodzie mamy dwie funkcje. Pierwsza funkcja, foo, przyjmuje unique_ptr typu int. Argument przekazywany jest przez referencję. Możemy zmienić wartość wskazywaną przez ten wskaźnik tak jak byśmy używali zwykłych.

Foo2 jest innym przykładem. Z deklaracji funkcji wynikałoby, że unique_ptr jest przekazywany przez kopię. Jak już wiemy, unique_ptr jest wskaźnikiem unikalnym, więc nie możemy go skopiować. Jedynym sposobem na przekazanie argumentu w takiej sytuacji jest przeniesienie własności za pomocą operatora move. Wtedy oryginalny wskaźnik – number ma wartość nullptr. Wychwyci to asercja, która znajduje się na końcu powyższego przykładu.

Praktyczny przykład – stos oparty na unique_ptrach

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <memory>
using namespace std;
class stackNode {
    public:
    int actual;
    unique_ptr<stackNode> prev;
};
class stack {
    public:
    void push(int number) {
        if(firstStackNode==nullptr) {
            unique_ptr<stackNode> newStackNode = make_unique<stackNode>();
            newStackNode->actual = number;
            firstStackNode = move(newStackNode);
        }
        else {
            unique_ptr<stackNode> newStackNode = make_unique<stackNode>();
            newStackNode->actual = number;
            newStackNode->prev = move(firstStackNode);
            firstStackNode = move(newStackNode);
        }
    }
    int pop() {
        if(firstStackNode==nullptr) return -1;
        else {
            int number = firstStackNode->actual;
            firstStackNode = move(firstStackNode->prev);
            return number;
        }
    }
private:
    unique_ptr<stackNode> firstStackNode;
};
int main()
{
    stack st;
    st.push(5);
    st.push(4);
    st.push(6);
    cout<<st.pop()<<"\n";
    cout<<st.pop()<<"\n";
    return 0;
}

Powyżej znajduje się przykład stosu zbudowanego na bazie unique_ptrów. Prawda, że wygląda nieziemsko? Żadnych operacji new, żadnych delete’ów. Nie martwisz się o zwolnienie pamięci po zdjęciu obiektu ze stosu. Same zalety. Kod także wygląda o wiele bardziej przejrzysto.

Zrobiłem parę uproszczeń – stos przyjmuje tylko liczby całkowite typu int. Nie chcąc dodawać obsługi wyjątków ani błędów, w wypadku braku liczb na stosie metoda pop zwraca wartość -1. Stos może więc przechowywać tylko liczby całkowite. Nic nie stoi na przeszkodzie, abyś spróbował własnymi siłami go ulepszyć.

Podsumowując

Wraz ze standardem C++11/C++14 powinniśmy jak najrzadziej używać zwykłych wskaźników, gdyż wiąże się z nimi dużo problemów. Unique_ptr sam zarządza cyklem życia obiektu, co niesie ze sobą dużą ilość zalet. W tym artykule poruszyłem jedynie wierzchołek góry lodowej. Niemniej, powyższe wiadomości powinny pomóc zrozumieć ci zasadę działania wskaźnika unique_ptr i pozwolić na zastosowanie go w swoich własnych programach.
W ramach ćwiczeń możesz spróbować zaimplementować w podobny sposób kolejkę FIFO.
Pliki zawierające kod źródłowy programów zaprezentowanych w tym wpisie znajdują się tu

Opublikowany w C++

3 komentarze do “unique_ptr – inteligentne wskaźniki

  1. KrzaQ Odpowiedz

    Ta implementacja stosu ma jeden dość znaczący problem – destruktory wywoływane są rekursywnie, więc bardzo łatwo o stack overflow przy większej liczbie elementów. Zresztą akurat stos można zaimplementować za dowolnego kontenera sekwencyjnego (vector, list, deque), co wykorzystuje std::stack. No ale koncepcyjnie fajnie to pokazuje ułatwienie implementacji 🙂

  2. Poeta Kodu Odpowiedz

    Ogólnie artykuł fajny, ale można by trochę doprecyzować – to nie jest tak, że unique_ptr zabrania nam kopiować obiektu. On zabrania nam kopiować samego siebie, bo wtedy powstałby więcej niż jeden wskaźnik właściciel (owner) tego obiektu. Sam obiekt można skopiować tak samo jak każdy inny, jeśli klasa / typ ma copy constructor lub copy assignment operator.

    std::move nie nulluje wskaźnika, tylko castuje go na rvalue reference, co sprawia, że pisząc:

    ptr2 = std::move(ptr1);

    …zostanie wykorzystany move-assignment operator z szablonu klasy unique_ptr i dopiero w momencie przypisania ten wskaźnik jest nullowany. Sam zapis std::move(ptr1) nic nie robi, poza castowaniem.

    No i zabrakło bardzo ważnej rzeczy, chociaż wiem, że łatwo o tym można zapomnieć. To nie jest tak, że smart pointerów powinno się używać wszędzie zamiast raw pointerów. Raw pointerów, a przede wszystkim referencji powinno się używać wszędzie tam, gdzie nie chcemy po prostu jakoś wykorzystać obiekt czy zmienną. Smart pointery służą jedynie do zarządzania czasem życia obiektu (z wyjątkiem weak_ptr) a nie do tego, żeby pchać je wszędzie gdzie się da.
    Fajnie by było uzupełnić artykuł o te informacje. Pozdrawiam 🙂

    • Poeta Kodu Odpowiedz

      ^ mała poprawka,
      „Raw pointerów, a przede wszystkim referencji powinno się używać wszędzie tam, gdzie CHCEMY po prostu jakoś wykorzystać obiekt czy zmienną.”

Skomentuj Poeta Kodu Anuluj pisanie odpowiedzi

Twój adres e-mail nie zostanie opublikowany.