W poprzednim artykule zajmowaliśmy się pierwszym z „inteligentnych” wskaźników – wskaźnikiem unikalnym (unique_ptr). Unique_ptr jest bardzo fajnym wskaźnikiem, ale nie w każdej sytuacji możemy skorzystać z jego dobrodziejstw. Czasami potrzebujemy „współdzielić” obiekt i jego własności między dwoma lub więcej miejscami w programie. Załóżmy, że tworzymy aplikację obsługującą przychodnię. Mamy trzy rodzaje danych: pacjenci, lekarze, wizyty. Opisując wizytę, musimy wiedzieć, który pacjent ją umówił. Musimy znać także lekarza, do którego dany pacjent chce się udać. To jest doskonałe miejsce na użycie shared_ptr. Użycie wskaźnika współdzielonego pozwoli nam np.: usunąć pacjenta (gdyż np.: przepisał się do innej przychodni) ale jednocześnie nie usunie to wizyt, które dany pacjent już odbył, pozwalając na przeniesienie ich do archiwum.

Charakterystyka shared_ptr

Wewnętrzna architektura shared_ptr jest nieco bardziej skomplikowana niż unique_ptr. Każdy wskaźnik shared_ptr posiada licznik referencji. Zlicza on, ile razy danym momencie wskaźnik jest używany w programie. Gdy tworzymy shared_ptr i go inicjujemy, licznik referencji wskazuje wartość 1. Podczas gdy skopiujemy go, np.: innej funkcji/metody, wartość licznika referencji podskoczy w górę. Jeśli dana metoda/funkcja skończy korzystanie ze wskaźnika, zostaje on zniszczony. Wartość licznika referencji maleje. Gdy osiągnie wartość zero, pamięć używana przez wskaźnik jest zwalniana. Zarazem obiekt, na który wskazuje wskaźnik jest niszczony.

Shared_ptr w swoim działaniu jest dosyć podobny do Garbage Collectora z Javy. Podobnie jak GB, shared_ptr zwalnia pamięć gdy liczba referencji osiągnie zero. Mimo podobieństw, shared_ptr nie jest tym samym co Garbage Collector i warto o tym pamiętać. Nie jest to tematem tego artykułu. Jeśli chcesz dowiedzieć się więcej, kliknij tu.

Poznałeś już charakterystykę shared_pointera. Zobaczmy więc, jak w praktyce się nim posługiwać.

Tworzenie i zarządzanie obiektem za pomocą shared_ptr

Uwaga! Podobnie jak w przypadku unique_ptr, aby poniższe programy się skompilowały, musisz w ustawieniach swojego IDE/kompilatora włączyć flagę ‑std=c++14.

Najprostszy 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
#include <iostream>
#include <memory>
 
using namespace std;
class patient
{
    public:
    patient() {
        cout<<"Patient constructor"<<endl;
    }
    ~patient() {
        cout<<"Patient destructor"<<endl;
    }
    string name;
    string surname;
};
int main()
{
    shared_ptr<patient> fpat = make_shared<patient>();
    fpat->name = "Jan";
    fpat->surname = "Kowalski";
    cout<<"Read data from fpat shared_ptr"<<endl;
    cout<<fpat->name<<endl;
    cout<<fpat->surname<<endl;
    cout<<"Reference counter: "<<fpat.use_count()<<endl<<endl;
    return 0;
}

Przeanalizujmy przykład zamieszczonego powyżej kodu. Utworzyliśmy sobie bardzo prostą klasę patient, która zawiera tylko dwa pola: name i surname typu string. Konstruktor i destruktor zostały zamieszczone po to, abyś wiedział, w którym momencie obiekt został utworzony, a kiedy został skasowany.

Wydaje się, że funkcja main nie robi nic szczególnego. Tworzymy wskaźnik shared_ptr, który przechowuje obiekt typu patient. W przypadku unique_ptr używaliśmy funkcji make_unique. Natomiast w przypadku shared_ptr używamy analogicznej – make_shared. Następnie uzupełniamy pola klasy i odczytujemy je za pomocą utworzonego wcześniej wskaźnika. W 25 linijce wykorzystujemy wbudowaną w shared_ptr metodę use_count(), która pozwala nam poznać ile razy obiekt jest współdzielony.

Shared_ptr jako argument funkcji

Spójrzmy teraz, jak zachowuje się shared_ptr gdy przekażemy go jako argument funkcji. Podobnie jak w wypadku unique_ptr, użyjemy dwóch sposobów. Przekażemy shared_ptr najpierw za pomocą kopii, a później za pomocą referencji.

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
45
46
#include <iostream>
#include <memory>
 
using namespace std;
class patient
{
    public:
    patient() {
        cout<<"Patient constructor"<<endl;
    }
    ~patient() {
        cout<<"Patient destructor"<<endl;
    }
    string name;
    string surname;
};
void foo(shared_ptr<patient> pat) {
    cout<<"Foo function"<<endl;
    cout<<pat->name<<endl;
    cout<<pat->surname<<endl;
    cout<<"Reference counter: "<<pat.use_count()<<endl<<endl;
}
void foo_reference(shared_ptr<patient>& pat) {
    cout<<"foo_reference function"<<endl;
    cout<<pat->name<<endl;
    cout<<pat->surname<<endl;
    cout<<"Reference counter: "<<pat.use_count()<<endl<<endl;
}
int main()
{
    shared_ptr<patient> fpat = make_shared<patient>();
    fpat->name = "Jan";
    fpat->surname = "Kowalski";
    cout<<"Read data from fpat shared_ptr"<<endl;
    cout<<fpat->name<<endl;
    cout<<fpat->surname<<endl;
    cout<<"Reference counter: "<<fpat.use_count()<<endl<<endl;
 
    foo(fpat);
    cout<<"After foo main function"<<endl;
    cout<<fpat->name<<endl;
    cout<<fpat->surname<<endl;
    cout<<"Reference counter: "<<fpat.use_count()<<endl<<endl;
    foo_reference(fpat);
    return 0;
}

Bazujemy na kodzie z pierwszego przykładu. Jak pewnie zauważyłeś, zostały dodane dwie funkcje: foo i foo_reference. Obydwie nie robią nic szczególnego – ot – wypisują zawartość pól name i surname. Każda z nich wyświetla także wartość licznika referencji za pomocą metody use_count. Spójrz na wynik działania powyższego programu:

przykład 1 shared_ptr

Metoda main wyświetla to samo co w poprzednim przykładzie. Nie stało się tutaj nic szczególnego. Ciekawy jest natomiast wynik działania funkcji foo. Jeśli spojrzysz na deklarację, zauważysz, że przekazywaliśmy tam shared_ptr przez kopię. I rezultat takiego postępowania doskonale widać w wynikach działania funkcji. Wskaźnik shared_ptr został „skopiowany”, ale sam obiekt nie. Co na to wskazuje? Wzrost o 1 liczby istniejących referencji. W chwili działania funkcji foo istnieją dwie kopie wskaźnika shared_ptr wskazującego na obiekt „Jan Kowalski”. Jedna kopia znajduje się w funkcji main, a druga w funkcji foo.

W momencie kiedy funkcja foo zakończy swoje działanie, wskaźnik wskazujący na obiekt zostanie zniszczony. Sam obiekt nie został unicestwiony, gdyż wartość licznika referencji jest dalej większa od zera (istnieje jeszcze jeden wskaźnik na obiekt w funkcji main).

Gdy przekazujemy shared_ptr przez referencję, wartość licznika referencji nie jest zwiększana. Dzieje się tak, gdyż, przekazując obiekt przez referencję, przekazujemy niejako ten sam wskaźnik, który istnieje w funkcji main. Nie jest wykonywana jego kopia, a co za tym idzie, wartość licznika referencji również nie jest zwiększana.

Destruktor obiektu pacjenta jest wywoływany wraz z zakończeniem pracy funkcji main, a więc wtedy, gdy wszystkie istniejące wskaźniki shared_ptr wskazujące na nasz obiekt zostaną zniszczone.

Shared_ptr z kontenerami

Zarówno shared_ptr jak i unique_ptr można używać w połączeniu z kontenerami (np.: vector). 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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <memory>
#include <vector>
using namespace std;
class patient
{
    public:
    patient(string name,string surname) {
        this->name = name;
        this->surname = surname;
        cout<<"Patient "<<this->name<<" "<<this->surname<<" constructor"<<endl;
    }
    ~patient() {
        cout<<"Patient "<<this->name<<" "<<this->surname<<" destructor"<<endl;
    }
    void printNameSurname() {cout<<name<< " "<<surname;}
    string name;
    string surname;
};
void printPatientData(shared_ptr<patient> patient) {
    cout<<"printPatientData function"<<endl;
    patient->printNameSurname();
    cout<<endl<<"Reference counter: "<<patient.use_count()<<endl<<endl;
}
void modifyPatientData(shared_ptr<patient> patient,string name,string surname) {
    cout<<"modifyPatientData function"<<endl;
    patient->name = name;
    patient->surname = surname;
    patient->printNameSurname();
    cout<<endl<<"Reference counter: "<<patient.use_count()<<endl<<endl;
}
int main()
{
    vector<shared_ptr<patient>> vector_of_patients;
    vector_of_patients.push_back(make_shared<patient>("Adam","Nowak"));
    vector_of_patients.push_back(make_shared<patient>("Jan","Kowalski"));
    vector_of_patients.push_back(make_shared<patient>("Amadeusz","Mozart"));
    shared_ptr<patient> add_patient = vector_of_patients[2];
    printPatientData(vector_of_patients[0]);
    modifyPatientData(vector_of_patients[0],"Juliusz","Cezar");
    cout<<"Return to main"<<endl;
    vector_of_patients[0]->printNameSurname();
    cout<<endl<<"Reference counter: "<<vector_of_patients[0].use_count()<<endl;
    cout<<"Vector clear"<<endl;
    vector_of_patients.clear();
    cout<<"After vector clear"<<endl;
    return 0;
}

Wynik działania powyższego programu przedstawia screen:

przykład 2 shared_ptr

Tworzymy sobie vector, do którego dodajemy trzech pacjentów. W 38 linijce tworzymy kopię shared_ptr’a za pomocą operatora przypisania. W tej chwili licznik referencji zmiennych add_patient i vector_of_patients[2] wynosi 2. Linijka 40 i funkcja modifyPatientData pokazuje, że wskaźnik działa tak jak powinien – zmieniamy obiekt i odczyt tego obiektu za pomocą dowolnego shared_ptra ujawni nam nowe uaktualnione właściwości.

W linijce 45 czyścimy vector.  Destruktory Juliusza Cezara i Jana Kowalskiego zostały uruchomione. A dlaczego nie Amadeusza Mozarta? Bo jak pamiętasz, istnieje jeszcze jeden wskaźnik na ten obiekt, który tworzyliśmy w linijce 38. Wyjście z funkcji main spowoduje zniszczenie wskaźnika add_patient. Licznik referencji spadnie wtedy do zera. Obiekt Amadeusza Mozarta będzie skasowany.

Przenoszenie własności wskaźnika współdzielonego

Gdy poznawaliśmy unique_ptra, nauczyliśmy się obsługi funkcji move. Jak pewnie pamiętasz, przenosi ona „własność” obiektu z jednego wskaźnika na drugi. Move współpracuje także ze wskaźnikami współdzielonymi. 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
#include <iostream>
#include <memory>
#include <assert.h>
using namespace std;
 
int main()
{
    shared_ptr<int> wsk1 = make_shared<int>(20);
    assert(wsk1);
    cout<<"wsk1 value: "<<*wsk1<<endl;
    cout<<"wsk1 reference number: "<<wsk1.use_count();
    shared_ptr<int> wsk2 = move(wsk1);
    assert(wsk2);
    cout<<endl<<endl<<"After move wsk1 to wsk2"<<endl;
    cout<<"wsk2 value: "<<*wsk2<<endl;
    cout<<"wsk2 reference number: "<<wsk2.use_count()<<endl;
    assert(wsk1);
    cout<<"wsk1 value: "<<*wsk1<<endl;
    cout<<"wsk1 reference number: "<<wsk1.use_count();
    return 0;
}

Tworzymy wskaźnik shared_ptr wsk1, który wskazuje na liczbę typu int. Wyświetlamy sobie te informacje w kolejnych linijkach. Następnie przenosimy „własność” tej zmiennej do drugiego wskaźnika wsk2. Wyświetlenie danych z wsk2 uda nam się ale asercja umieszczona w linijce 17 udowodni, że wsk1 w tej chwili jest równy null.

C++17 i alokowanie tablic za pomocą shared_ptr

C++14

Czasami może zaistnieć potrzeba utworzenia wskaźnika na tablicę. W standardzie C++14 rzucono nam pod nogi trochę kłód. Spójrz na poniższy kod.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <iostream>
#include <memory>
using namespace std;
 
int main()
{
    shared_ptr<int> int_table(new int[10],default_delete<int[]>());
    for(size_t i = 0;i<10;i++) {
        int_table.get()[i] = i;
    }
    for(size_t i = 0;i<10;i++) {
        cout<<"int_table["<<i<<"] = "<<int_table.get()[i]<<endl;
    }
    return 0;
}

Linijka nr 7. Tworzymy wskaźnik dosyć niestandardowo. Używamy staromodnego new. Dodatkowo pojawia się dziwne „default_delete”. O co chodzi?

Shared_ptry nie posiadają domyślnie wsparcia dla tablic. Aby utworzyć taki wskaźnik, oprócz jawnego użycia operatora new musimy zadeklarować własną „politykę usuwania”. Jeśli nie użylibyśmy default_delete<int[]>, to shared_ptr przy usuwaniu obiektu użyłby delete zamiast delete[]. Spowodowałoby to, jak pewnie się domyślasz wyciek pamięci. A tego staramy się za wszelką cenę uniknąć.

Dostęp do elementów takiej tablicy także nie jest zbyt intuicyjny. Musimy jawnie używać metody get.

C++17

Standard C++17 dodaje obsługę tablic do wskaźników współdzielonych. Program realizujący takie same zadanie jak wyżej wyglądałby wtedy następująco:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <memory>
#include <iostream>
#include <cstdlib>
using namespace std;
 
int main()
{
	shared_ptr<int[]> int_table(new int[10]); 
	for (size_t i = 0; i < 10; i++) {
		int_table[i] = i;
	}
	for (size_t i = 0; i < 10; i++) {
		cout << "int_table[" << i << "] = " << int_table[i] << endl;
	}
	system("pause");
    return 0;
}

(Program powyżej kompiluje się pod Visual Studio 2017. Nie kompiluje się pod GCC z C::B 17.12 mimo włączenia obsługi C++17)

Prawda, że jest o wiele prościej? Normalnie tworzymy tablicę (musimy użyć operatora new, ale nie jest to nic złego) i normalnie się do niej odwołujemy, za pomocą nawiasów kwadratowych. Wskaźnik współdzielony zarządza tą tablicą jak każdym innym obiektem i nie powoduje wycieków pamięci.

Shared_ptr – rozwiązanie wszelkich problemów?

Shared_ptr jest wspaniałym wskaźnikiem „inteligentnym” który pozwala rozwiązać wiele problemów. Pamiętaj tylko, aby go nie „nadużywać”. Shared_ptr jest nieco wolniejszy od unique_ptr i zajmuje nieco więcej miejsca. W projektach małej skali nie gra to roli, ale gdyby twoja aplikacja się rozrosła, ta pozornie drobna różnica w wydajności może spowodować problemy.

Nie pokazałem praktycznego przykładu użycia shared_ptr. Zobaczysz taki w następnym artykule z serii, w którym omówię wskaźnik weak_ptr.

Kod wszystkich przykładów umieszczonych w tym artykule znajduje się w tym miejscu na platformie GitHub.

Dodaj komentarz

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