Omówiliśmy unique_ptr i shared_ptr. Jesteśmy bardzo blisko końca naszej fascynującej wędrówki przez świat inteligentnych wskaźników. Został nam do omówienia ostatni gatunek. Weak_ptr. W tłumaczeniu na polski byłby to „słaby wskaźnik”. Czym może się charakteryzować? Czy potrzeba nam w programowaniu słabeuszy?

Tytułowa „słabość” weak_ptr polega na tym, że nie posiada on własnego licznika referencji. Weak_ptr nie potrafi „na stałe” przejąć kontroli nad obiektem. Jeśli do wskaźnika weak_ptr przypiszemy wskaźnik shared_ptr, to wewnętrzny licznik shared_ptra nie ulegnie zmianie. Jeśli spełniony zostanie któryś z warunków unicestwienia shared_ptra, to obiekt z nim powiązany zostanie zniszczony. I nie ma tu znaczenia fakt, że wcześniej shared_ptr przechowujący ten obiekt został przypisany do weak_ptr’a.

Ale co w takim wypadku stanie się z weak_ptr, któremu przypisaliśmy shared_ptr? Intuicja podpowiada, że weak_ptr wskazuje w tym momencie na nieistniejący obiekt. Czy to prawda? Tak, oczywiście. Na co nam więc taki wskaźnik? Dowiesz się wkrótce. Najpierw przyjrzyjmy się, jak w praktyce wygląda jego funkcjonowanie.

Weak_ptr – jak działa? Przykład.

Zilustrujemy powyższą myśl prostym przykładem.

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
#include <iostream>
#include <memory>
#include <assert.h>
using namespace std;
 
int main()
{
    shared_ptr<int> shared_wsk1 = make_shared<int>(30);
    weak_ptr<int> weak1;
    weak_ptr<int> weak2;
    weak1 = shared_wsk1;
    {
        shared_ptr<int> shared_wsk2 = make_shared<int>(42);
        weak2 = shared_wsk2;
        cout<<"shared_wsk2 ref count: "<<shared_wsk2.use_count()<<endl;
        assert(!weak2.expired());
        shared_ptr<int> shared_wsk3 = weak2.lock();
        cout<<*weak2.lock()<<endl;
        cout<<"shared_wsk2 ref count: "<<shared_wsk2.use_count()<<endl;
    }
    assert(!weak1.expired());
    cout<<*weak1.lock()<<endl;
    assert(!weak2.expired());
    cout<<*weak2.lock()<<endl;
    return 0;
}

Ten krótki program przedstawia istotę działania wskaźnika typu weak_ptr. W linijce ósmej tworzymy sobie wskaźnik shared_ptr na obiekt typu int (żeby nie komplikować). Następnie tworzymy sobie dwa „słabe wskaźniki”.

W linijce 12 zaczyna się blok instrukcji. Dzięki niemu zaobserwujemy co się stanie z weak_ptr’em, kiedy skojarzony z nim shared_ptr zostanie zniszczony.

W linijce 13 tworzymy sobie kolejny wskaźnik shared_ptr wskazujący na obiekt typu int. Za nim, w linijce 14 idzie przypisanie: weak2 do shared_wsk2. Gdyby weak2 był wskaźnikiem typu shared_ptr, licznik referencji podskoczyłby do dwóch. Ale tak się nie stało. Pokazuje to linijka 15.

Weak_ptr – nowe metody

W linijce 16 znajduje się asercja. Warunek wewnątrz niej wykorzystuje jedną z metod, która jest charakterystyczna dla wskaźników słabych. Mowa o expired(). Metoda expired() informuje nas, czy wskaźnik weak_ptr wskazuje w chwili obecnej na coś sensownego. Jeśli tak, zwraca wartość true. W przeciwnym wypadku zwraca false.

To nie koniec nowości. Zwróć uwagę na linijkę 17. Zauważysz w niej kolejną nową metodę – lock. Do czego ona służy?

Jak dowiedziałeś się ze wstępu, weak_ptr nie potrafi przejąć obiektu na „własność”. Wynika z tego wiele jego własności. Nie możemy np.: bezpośrednio odwoływać się do obiektu, na który wskazuje weak_ptr. Chociaż weak_ptr nie może powiedzieć o obiekcie nic, posiada on możliwość poinformowania nas, czy obiekt jeszcze istnieje. Aby dostać się do obiektu, musimy użyć metody lock(). Pozwala ona przechwycić własność obiektu. Nazwa metody (lock) wskazywałaby, że coś jest blokowane. Tak jest w istocie. Lock „blokuje” możliwość zwolnienia pamięci i usunięcia obiektu, na który wskazuje weak_ptr. Lock wraca wskaźnik typu shared_ptr. Licznik referencji w tym momencie idzie w górę, gdyż dostaliśmy normalny, „pełnowartościowy” inteligentny wskaźnik.

Zobaczmy, co się dzieje w dalszej części programu. Linijka 21 – sprawdzamy, czy weak1 dalej wskazuje na poprawną wartość. Intuicja podpowiada że tak, gdyż skojarzony z nim wskaźnik shared_ptr (a zarazem obiekt, na który wskazuje) nie został zniszczony. Możemy bez problemu skorzystać z obiektu, na który wskazuje wskaźnik. Pokazuje to linijka 21. Oczywiście, musimy wcześniej „zablokować wskaźnik” za pomocą metody lock().

Zastanów się, co będzie działo się ze wskaźnikiem wsk2. Wskaźnik shared_ptr był zdefiniowany w „wewnętrznym bloku” programu, którego wykonywanie już się zakończyło. Wartość licznika referencji shared_wsk2 spadła do zera, a zatem obiekt razem ze wskaźnikiem został zniszczony. Intuicja podpowiada, że weak1 wskazuje na jakiś w tej chwili nieprawidłowy obiekt. I intuicja się nie myli. Dostaliśmy błąd: assertion failed. Metoda weak2.expired() zwróciła false.

Analiza metody lock() weak_ptr

W poprzednim rozdziale używaliśmy metody expired() do zweryfikowania, czy dany wskaźnik weak_ptr wskazuje na coś pożytecznego. Nie jest to jedyna metoda osiągnięcia celu.

Uruchom poniższy, prosty program:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>
#include <memory>
using namespace std;
 
int main()
{
    weak_ptr<int> w1;
    weak_ptr<int> w2;
    {
        shared_ptr<int> wsk1 = make_shared<int>(42);
        w1 = wsk1;
        cout<<"w1.lock() = "<<w1.lock()<<endl;
        cout<<"w2.lock() = "<<w2.lock()<<endl;
    }
    cout<<endl;
    cout<<"w1.lock() = "<<w1.lock()<<endl;
    cout<<"w2.lock() = "<<w2.lock()<<endl;
    return 0;
}

Na początku tworzymy dwa weak_ptry: w1 i w2. W wewnętrznym bloku tworzymy sobie dodatkowo shared_ptra wskazującego na obiekt typu int. Najpierw przypisujemy w1 do tego wskaźnika. Następnie sprawdzamy, na co wskazują w1 i w2 po zablokowaniu. Wskaźnik, który nigdy nie został użyty – w2 – wskazuje na 0, czyli nullptr. Natomiast wskaźnik w1 przechowuje poprawny adres.

Po opuszczeniu bloku obydwa wskaźniki wskazują na nullptr. Fajne, nie? Oznacza to, że nie zawsze musimy używać metody expired(). Wystarczy sprawdzić, czy zablokowanie obiektu wykonało się prawidłowo.

Zastosowanie weak_ptr – Praktyczne przykłady

weak_ptr i wiszące wskaźniki

Wiesz już, jak działa weak_ptr. Czytając poprzedni akapit poznałeś metody, które są unikalne dla tego rodzaju wskaźników. Niemniej, dalej możesz zastanawiać się, jakie jest praktyczne zastosowanie tego typu wynalazku. Cóż. Aby dobrze operować „inteligentnymi wskaźnikami” musimy mieć wprawę i „wyczuwać” kiedy należy użyć takiego, a kiedy innego.

Weak_ptr ma sporo zastosowań. Jednym z nich jest rozwiązanie problemu tzw. „wiszących wskaźników”. Na czym on polega?

„Wiszący wskaźnik” wskazuje na pamięć, która została już zwolniona za pomocą operatora delete lub delete[]. Lecz wskaźnik nie został wynullowany. Jeśli spróbujemy odczytać coś z takiego wskaźnika, stanie się coś niedobrego. Program albo zwróci nieprawidłowe dane, albo scrashuje się.

Spójrz na poniższy przykład:

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
#include <iostream>
#include <memory>
 
using namespace std;
 
int main()
{
    // normalne użycie
    int* wsk = nullptr;
    int* data = new int(25);
    wsk = data;
    cout<<"*wsk = "<<*wsk<<endl;
    delete data;
    data = nullptr;
   // cout<<"*wsk = "<<*wsk<<endl; //nieprawidłowe odwołanie - data zostało zwolnione, lecz wsk dalej wskazuje na zwolnioną przestrzeń w pamięci. Crash gwarantowany
 
   //Jak to rozwiązać za pomocą inteligentnych wskaźników?
    weak_ptr<int> weakwsk1;
    shared_ptr<int> shwsk1 = make_shared<int>(25);
    weakwsk1 = shwsk1;
    if(weakwsk1.lock()!=nullptr) {
        cout<<"*weakwsk1.lock() = "<<*weakwsk1.lock()<<endl;
    } else {
        cout<<"*weakwsk1 is expired"<<endl;
    }
    shwsk1.reset(); // zwalniamy pamięć przypisaną do shwsk1
    weakwsk1 = shwsk1;
    if(weakwsk1.lock()!=nullptr) {
        cout<<"*weakwsk1.lock() = "<<*weakwsk1.lock()<<endl;
    } else {
        cout<<"*weakwsk1 is expired"<<endl;
    }
    return 0;
}

Na samym początku programu, w linijkach 9-14 świadomie tworzymy wiszący wskaźnik (dangling pointer). Po wykonaniu 14 linijki wsk wskazuje na pamięć, która została już dawno zwolniona. Jeśli zapomnimy po zwolnieniu pamięci ustawić wskaźnika na nullptr, to nie ma żadnej możliwości na uniknięcie takiej sytuacji.

Od linijki 18 operujemy już na wskaźnikach inteligentnych. Próbujemy zasymulować taką samą sytuację jak wcześniej. Tworzymy sobie weak_ptr weakwsk1 (odpowiednik wsk z linijki 9) i shared_ptr shwsk1 (odpowiednik data z linijki 10). Najpierw przypisujemy weakwsk1 do shwsk1. Następnie sprawdzamy, czy wskaźniki są nadal aktualne. Pierwsza weryfikacja, która następuje w linijce 21 powiedzie się. Potem, w 26 linijce resetujemy shwsk1. W tym momencie pamięć jest zwalniana a shwsk1 wskazuje na nullptr. Co dzieje się z weakwsk1? Pokazuje to linijka 28. Metoda lock zwraca nullptr. co oznacza, że obiekt na który wskazywał wskaźnik już nie istnieje. Wyświetla się napis weakwsk1 is expired a my zostaliśmy uratowani przed wiszącym wskaźnikiem!

weak_ptr w algorytmice – drzewo

Weak_ptr może być przydatny przy realizacji różnego rodzaju algorytmów. Spójrz na poniższy przykład. Podobnie jak poprzednie, także i ten konsekwentnie omówimy linijka po linijce 🙂

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
#include <iostream>
#include <memory>
#include <vector>
using namespace std;
struct TreeNode {
    weak_ptr<TreeNode> parent;
    vector<shared_ptr<TreeNode>> children;
    int key;
};
void addChildren(shared_ptr<TreeNode> root,int value) {
    shared_ptr<TreeNode> child = make_shared<TreeNode>();
    child->key = value;
    root->children.push_back(child);
    child->parent = root;
}
void printValue(shared_ptr<TreeNode> root,int level = 1) {
    if(root==nullptr) {
        cout<<"Root is empty"<<endl;
        return;
    }
    int new_level = level+1;
    for(int i = 0;i<root->children.size();i++) {
        printValue(root->children[i],new_level);
    }
    cout<<"Level: "<<level<<endl;
    cout<<"Value: "<<root->key<<endl;
}
int main()
{
    shared_ptr<TreeNode> root = make_shared<TreeNode>();
    root->key = 10;
    addChildren(root,30);
    addChildren(root,15);
    addChildren(root->children[0],20);
    addChildren(root->children[0],17);
    printValue(root);
    cout<<"Clear tree"<<endl;
    root.reset();
    printValue(root);
    return 0;
}

W linijce 5 definiujemy strukturę TreeNode. „Dzieci” przechowujemy w wektorze wskaźników shared_ptr. Jest to logiczne. Lecz dlaczego wskaźnik na rodzica jest typu weak_ptr? Jest tak, gdyż dzięki temu usuwanie elementów drzewa jest mocno ułatwione. Weak_ptr pozwala nam na uzyskanie obiektu rodzica z poziomu dziecka, ale jednocześnie nie zwiększa licznika referencji dziecka. Dzięki temu zwykłe children[0].reset() potrafi usunąć całą gałąź drzewa bez żadnych problemów. Natomiast root.reset() usuwa całe drzewo. Efekt takiego postępowania widać w linijkach 38 i 39 powyższej aplikacji.

Podsumowując…

Weak_ptr, podobnie jak inne inteligentne wskaźniki, jest bardzo przydatny w niektórych scenariuszach. Pozwala na rozwiązanie wielu problemów, których nie pokonalibyśmy stosując zwykle staroświeckie wskaźniki udostępniane przez język C. Polecam poćwiczyć ich używanie. Jeśli nabierzesz wprawy, nie będziesz mógł patrzeć na relikt z czasów C – czyli „raw pointery”.

Wszystkie kody, które znajdują się w tym artykule znajdziesz także w moim repozytorium na Githubie, w folderze weak_ptr.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *