Dzisiejszy wpis dotyczyć będzie inteligentnych wskaźników obecnych w C++ od standardu C++ 11. Przedstawię wam różnice pomiędzy poszczególnymi klasami inteligentnych wskaźników oraz ich przewagę nad surowymi wskaźnikami.
Tworzenie inteligentnego wskaźnika
Inteligentne wskaźniki to obiekty klas wprowadzonych w standardzie C++ 11 udoskonalające działanie wskaźników. Sposób użycia wskaźników inteligentnych praktycznie nie różni się od metody korzystania ze znanych nam raw pointerów. Zasadnicza różnica występuje przy tworzeniu inteligentnego wskaźnika. Zobaczmy na czym to polega (nie zapomnij dodać dyrektywy #include <memory>):
1 2 3 |
int *raw_ptr = new int(5); // zwykly wskaznik std::unique_ptr<int> u_ptr(new int(5)); //jeden z inteligentnych wskaźników w C++ |
Jaki widzimy tworzenie instancji klasy inteligentnego wskaźnika na pierwszy rzut oka wydaje się nieco bardziej skomplikowane, ale co do zasady jest to bardzo proste. Posługujemy się wyrażeniem o takiej składni:
1 |
std::unique_ptr<type_name> ptr(new typename); |
Pamiętaj, aby tworząc nowy obiekt inteligentnego wskaźnika zawsze starać się przydzielać mu dynamicznie alokowaną pamięć. Taką pamięć alokuje się na stercie i w odpowiednim momencie zwalnia. Gdybyśmy chcieli przydzielić inteligentnemu wskaźnikowi adres pamięci spoza sterty może dojść do próby zwolnienia tej pamięci co może okazać się co najmniej niebezpieczne dla działania programu. Nie rób tak.
1 2 3 |
int var = 5; std::unique_ptr<int> u_ptr(& var); // fatalnie |
Ponadto wskaźnik na pamięć na stercie musi być przekonwertowany jawnie.
1 2 3 4 5 6 7 8 9 |
int *r_ptr = new int(5); shared_ptr<int> s_ptr; s_ptr = r_ptr; //nie przejdzie konwersja niejawna s_ptr = shared_ptr<int>(r_ptr); //wszystko git jawna konwersja s_ptr = new int(6);//nie przejdzie konwersja niejawna |
Inteligentne wskaźniki C++ – zwalnianie pamięci
Jedną z najczęściej wymienianych zalet korzystania z inteligentnych wskaźników jest fakt, że są one de facto obiektami pewnych klas. W takim razie można zdefiniować konstruktor i destruktor wewnątrz definicji klasy. Działanie konstruktora mogłeś zauważyć już wcześniej. Operator new type_name zwraca niezerowy wskaźnik na właśnie zaalokowaną pamięć i właśnie ten wskaźnik jest argumentem konstruktora.
Natomiast w definicji destruktora określono zwalnianie wskazywanej przez wskaźnik pamięci na stercie. Jest to o tyle przydatne, że nie musimy pamiętać o zwalnianiu pamięci, ponieważ destruktor zrobi to za nas. Spróbujmy to zrozumieć na pewnym przykładzie:
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 49 50 51 52 53 54 55 56 |
#include <iostream> #include <memory> #include <cstdlib> using namespace std; void foo(); void foo1(); void foo2(); int main() { srand( time( NULL ) ); cout << "Wywolanie foo():"; foo(); cout << "\nWywolanie foo1():"; foo1(); cout << "\nWywolanie foo2():"; foo2(); } void foo() { int * ptr = new int(rand()%10); //a wez cos z tym zrob return; } void foo1() { int * ptr = new int(rand()%10); if(*ptr == 5) { cout << "5 to ja nie chce :|"; return; } //a wez cos z tym zrob delete ptr; return; } void foo2() { unique_ptr<int> ptr(new int(rand()%10)); if(*ptr == 5) { cout << "5 to ja nie chce :|"; return; } //a wez cos z tym zrob return; } |
W pierwszej z funkcji foo() tworzymy zmienną lokalną istniejącą tylko w bloku tej funkcji. Po słowie kluczowym return automatycznie zwolni się pamięć przydzielona na samą zmienną wskaźnikową ptr. Natomiast nowo zaalokowana pamięć pozostanie na stercie, co jak pewnie się domyślasz będzie błędem.
W kolejnej funkcji również korzystamy z raw pointera, ale przed jednym z returnów umieściliśmy operator delete, który zaalokowaną pamięć zwolni. Niby cacy, ale jeżeli funkcja ma więcej niż jedną ścieżkę kończącą to musimy pamiętać o każdorazowym skorzystaniu z delete. W foo1() funkcja nie ma ochoty na liczbę 5 i kończy swoje działanie. Jednak zapomnieliśmy o delete, co oczywiście jest błędem -> kolejny wyciek pamięci.
W ostatniej funkcji poszliśmy po rozum do głowy i skorzystaliśmy z inteligentnego wskaźnika. Nieważne co się stanie w obrębie tej funkcji. Zawsze wraz z końcem żywota inteligentnego wskaźnika, wywołany zostanie jego destruktor zwalniający zaalokowaną pamięć. Żyć nie umierać 😊.
Pojęcie własności inteligentnych wskaźników
Pewnie zauważyłeś, że w jednym ze skrawków kodów wcześnie użyłem klasy unique_ptr, a później użyłem wskaźnika shared_ptr. Nie było takiej potrzeby, po prostu chciałem, abyś zobaczył, że C++ 11 dostarczył więcej niż jeden szablon klasy inteligentnych wskaźników. Teraz poznamy zasadniczą różnicę pomiędzy unique_ptr() a shared_ptr().
Inteligentne wskaźniki klasy unique_ptr są (Mr. Obvious) unikalne. Korzystając z unikalnego wskaźnika gwarantujemy, że on i tylko on będzie wskazywał na dany obszar w pamięci. Jest on właścicielem tego miejsca. Zatem skorzystamy z niego w momencie, gdy będziemy chcieli mieć pewność, że pamięć nie zostanie swobodnie przekazana do innego wskaźnika. Jeżeli już będziemy chcieli to zrobić to skorzystamy z jawnych metod (np. funkcja move() lub metoda swap()).
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 |
int *r_ptr = new int(5); unique_ptr<int> u_ptr1(r_ptr); //unique_ptr<int> u_ptr2(r_ptr);//błąd unique_ptr<int> u_ptr2; //u_ptr2 = u_ptr1;//błąd u_ptr2 = move(u_ptr1); // wszystko gra, ale teraz u_ptr1 będzie wskazywał na NULL cout << "u_ptr2=" << *u_ptr2 << endl; unique_ptr<int> u_ptr3(new int(3)); unique_ptr<int> u_ptr4(new int(2)); u_ptr3.swap(u_ptr4); cout << "u_ptr3=" << *u_ptr3 << "\tu_ptr4=" << *u_ptr4 << endl; |
Mechanizm unikalnego wskaźnika jest o tyle bezpieczny, że nigdy nie dojdzie do sytuacji, w której destruktory dwóch wskaźników będą próbowały zwolnić tą samą pamięć.
Shared_ptr czyli wspólnego wskaźnika użyjemy w przypadku, gdy będziemy chcieli umożliwić wskazywanie tego samego miejsca w pamięci przez kilka wskaźników. Inteligentne wskaźniki shared_ptr zostały obmyślone w taki sposób, aby w przypadku korzystania z nich również nie doszło do próby zwolnienia tego samego obszaru pamięci przez kilka destruktorów. Zaimplementowano w tym celu rodzaj licznika zliczającego liczbę wskaźników wskazujących na tą samą pamięć. Dzięki temu do zwolnienia pamięci dojdzie wówczas, gdy żywot zakończy ostatni z wskaźników.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
shared_ptr<int> s_ptr1(new int(5)); { shared_ptr<int> s_ptr2(new int(5)); shared_ptr<int> s_ptr3(new int(5)); cout << *s_ptr1 << endl; cout << *s_ptr2 << endl; cout << *s_ptr3 << endl; } cout << *s_ptr1 << endl; |
W powyższym przykładzie mamy trzy wskaźniki zarządzające tym samym skrawkiem pamięci z tym, że dwa z nich kończą swój żywot wcześniej. Gdyby wraz z ich zniszczeniem zwolniono też pamięć to nie bylibyśmy w stanie odwołać się do tej pamięci po tym fakcie.
Podsumowanie
Temat inteligentnych wskaźników w C++ jest o wiele szerszy niż w niniejszym wpisie. Ponadto budzi on wiele emocji wśród programistów tego języka. Słusznie, bo jest to pretekst do merytorycznych dyskusji na ten temat. Nie chciałem specjalnie wydłużać tego wpisu, ale jeszcze kiedyś wrócę do tego tematu. Do opisania jest kolejny inteligentny wskaźnik weak_ptr oraz sposób użycia wskaźników przy pracy z tablicami. Jak zawsze odsyłam do dokumentacji C++.
VIGOR_PICTURES Wiktor Bukała
says:Powitać,
W sekcji „zwalnianie pamięci” jest pomyłka. W zdaniu „W foo2() funkcja nie ma ochoty na liczbę 5 i kończy swoje działanie. ” powinno być raczej foo1() zamiast foo2().
admin
says:Dzięki za czujność!