Alokacja i dealokacja pamięci przez new/ delete wymaga uwagi i jest bardzo podatna na błędy. Nowe cechy C++, w szczególności r-wartości i semantyka przenoszenia, umożliwiły znacznie ulepszoną implementację tak zwanych inteligentnych wskaźników. Są to obiekty klas konkretyzowanych z szablonów shared_ptr oraz unique_ptr. Wewnętrznie zawierają one wskaźniki do obiektów (w zasadzie dowolnych typów). Dzięki odpowiednim przeciążeniom operatorów mogą one składniowo i semantycznie zachowywać się (do pewnego stopnia, dotyczy to w szczególności wskaźników typu unique_ptr) jak wskaźniki do obiektów którymi „zawiadują”.
Obiekty typu unique_ptr (nazywajmy je dalej u-wskaźnikami) są z założenia jedynymi „właścicielami” zawiadywanymi obiektami. Gdy są niszczone, zwalniane, albo zaczynają zawiadywać innym obiektem, obiekt przez nie zawiadywany też jest usuwany i są zwalniane związane z nim zasoby. Dlatego u-wskaźniki nie mogą być kopiowane ani podlegać kopiującym przypisaniom — doprowadziłoby to bowiem do sytuacji, gdy dwa obiekty zawiadują tym samym obiektem. Mogą jednak być przenoszone. Ten, z którego przenosimy traci „posiadanie” obiektu (jest „zerowany”), podczas gdy ten do którego przenosimy przejmuje posiadanie i odpowiedzialność za zarządzane zasoby (i ewentualnie zwalnia te, którymi zawiadywał wcześniej).
U-wskaźniki mogą być tworzone na kilka sposobów, na przykład:
std::unique_ptr<int> empty; // holds nullptr std::unique_ptr<int> pi(new int(1)); std::unique_ptr<Person> pp(new Person("Mary", 2001)); std::unique_ptr<int[]> pa(new int[4]{1,2,2}); // array *pi = 21; pp->setName("Kate"); pa[2] = 3;Zauważmy, że pa reprezentuje wskaźnik na tablicę, wobec tego jest dla niego przeciążony operator indeksowania (operator[]), za pomocą którego możemy mieć dostęp do poszczególnych elementów; za to operator dereferencji * i dostępu do składowych -> nie jest dla u-wskaźników tablicowych określony. Wskaźniki nietablicowe jednakże mogą być używane składniowo i semantycznie jak zwykłe wskaźniki, choć fakt, że nie mogą być kopiowane i przypisywane powoduje, że nie mają pełnej ani semantyki wartości ani wskaźnikowej. W każdym razie, gdy zawiadywany obiekt ma być zniszczony, odpowiednia wersja operatora delete będzie automatycznie użyta — delete[] dla tablic i delete dla obiektów nietablicowych.
1. #include <functional> 2. #include <iostream> 3. #include <memory> 4. #include <string> 5. 6. using std::unique_ptr; using std::string; 7. 8. template <typename T> 9. struct Del { 10. void operator()(T* p) { 11. std::cout << "Del deleting " << *p << '\n'; 12. delete p; 13. } 14. }; 15. 16. int main() { 17. { 18. unique_ptr<string, Del<string>> 19. us{new string{"Hello"}, Del<string>{}}; 20. } 21. std::cout << "us is now out of scope\n"; 22. 23. { 24. unique_ptr<double, std::function<void(double*)>> 25. ud{new double{7.5}, 26. [](double* p) { 27. std::cout << "Deleting " << *p << '\n'; 28. delete p; 29. } 30. }; 31. } 32. std::cout << "ud is now out of scope\n"; 33. }
Program drukuje
Del deleting Hello us is now out of scope Deleting 7.5 ud is now out of scope
Obiekty typu unique_ptr można też tworzyć za pomocą funkcji make_unique. Obiekt, którym wskaźnik ma zawiadywać jest wtedy tworzony przez tę funkcję, a przekazujemy do niej tylko inicjujące wartości albo argumenty dla konstruktora. Jeśli tworzymy tablicę, to przekazać można tylko jej wymiar — nie ma sposobu, aby tę tablicę od razu zainicjować przekazanymi wartościami. Wadą tej funkcji jest też to, że nie da się wtedy przekazać deletera — jest to jednak konieczne raczej rzadko.
std::unique_ptr<int> pi = std::make_unique<int>(19); std::unique_ptr<Person> pp = std::make_unique<Person>("Mary", 2001); std::unique_ptr<int[]> pa = std::make_unique<int[]>(3); for (int i = 0; i < 3; ++i) pa[i] = i;U-wskaźniki nie mogą być kopiowane ani podlegać kopiującym przypisaniom, gdyż pogwałciłoby to wyłączność posiadania. Mogą jednak być przenoszone. Poniższy program
1. #include <iostream> 2. #include <memory> // smart pointers 3. #include <string> 4. #include <utility> // move 5. 6. using std::unique_ptr; using std::string; 7. 8. template <typename T> 9. struct Del { 10. void operator()(T* p) { 11. std::cout << "Del deleting " << *p << '\n'; 12. delete p; 13. } 14. }; 15. 16. template <typename T> 17. void print(const T* p) { 18. if (p) std::cout << *p << " "; 19. else std::cout << "null "; 20. } 21. 22. int main() { 23. unique_ptr<string, Del<string>> 24. p1{new string{"abcde"}, Del<string>{}}, 25. p2{new string{"vwxyz"}, Del<string>{}}; 26. 27. print(p1.get()); print(p2.get()); std::cout << '\n'; ➊ 28. std::cout << "Now moving\n"; 29. p1 = std::move(p2); ➋ 30. std::cout << "After moving\n"; 31. print(p1.get()); print(p2.get()); std::cout << '\n'; ➌ 32. std::cout << "Exiting from main\n"; 33. }
drukuje
abcde vwxyz Now moving Del deleting abcde After moving vwxyz null Exiting from main Del deleting vwxyzZauważmy, że funkcja get (linie ➊ i ➌) zwraca „goły” wskaźnik zawiadywany przez u-wskaźnik: nie powinniśmy go przypisywać do żadnej zmiennej, gdyż łatwo wtedy by było naruszyć zasadę jednego właściciela.
Inne ważne metody u-wskaźników to między innymi (T oznacza tu typ obiektu zawiadywanego przez wskaźnik):
T* release() — zwalnia zasoby zawiadywane przez ten wskaźnik, który jest „zerowany”; funkcja zwraca goły wskaźnik, którym zawiadywała (lub nullptr jeśli wskaźnik był pusty) – teraz użytkownik przejmuje całkowitą odpowiedzialność za wskazywany obiekt, w szczególności za jego usunięcie w odpowiednim czasie;
T* reset(a_pointer = nullptr) — usuwa zawiadywany obiekt (jeśli był) i przejmuje "pod opiekę” przekazany wskaźnik (być może, lub domyślnie, nullptr).
Metoda
reset
zilustrowana jest w następującym programie:
1. #include <iostream> 2. #include <memory> 3. 4. using std::unique_ptr; using std::ostream; using std::cout; 5. 6. template <typename T> 7. struct Del { 8. void operator()(T* p) { 9. cout << "Del deleting " << *p << '\n'; 10. delete p; 11. } 12. }; 13. 14. struct Klazz { 15. Klazz() { cout << "Ctor Klazz\n"; } 16. ~Klazz() { cout << "Dtor Klazz\n"; } 17. friend ostream& operator<<(ostream& s, const Klazz& k) { 18. return s << "object of type Klazz"; 19. } 20. }; 21. 22. int main() { 23. std::cout << "Creating u-pointer\n"; 24. std::unique_ptr<Klazz, Del<Klazz>> 25. p(new Klazz{}, Del<Klazz>{}); 26. std::cout << "Resetting u-pointer\n"; 27. p.reset(new Klazz{}); 28. std::cout << "Releasing and deleting\n"; 29. p.reset(); // or reset(nullptr) 30. }
Jak widzimy, gdy u-wskaźnik jest resetowany, nowy obiekt jest tworzony najpierw a dopiero potem stary jest niszczony przez wywołanie deletera; dla typów obiektowych będzie jeszcze, oczywiście, wywołany destruktor.
U-wskaźniki są często stosowane jako elementy kolekcji.
Następujący program demonstruje jak wypełnić wektor
u-wskaźnikami. Zwróćmy uwagę, że inteligentne wskaźniki
typu klas bazowych mogą odnosić się do obiektów klas pochodnych.
Wywołania polimorficzne działają zgodnie z przewidywaniami,
jak dla zwykłych wskaźników. Tu umieszczamy w wektorze jeden
wskaźnik do obiektu klasy bazowej
B
i trzy do obiektów
klasy pochodnej
D:
1. #include <iostream> 2. #include <vector> 3. #include <memory> 4. 5. struct B { 6. virtual void f() { std::cout << "f from B\n"; } 7. virtual ~B() { } 8. }; 9. struct D : B { 10. D() { std::cout << "Ctor D\n"; } 11. void f() override { std::cout << "f from D\n"; } 12. ~D(){ std::cout << "Dtor D\n"; } 13. }; 14. 15. int main() { 16. { 17. std::vector<std::unique_ptr<B>> vec; 18. vec.push_back(std::make_unique<B>()); 19. vec.push_back(std::make_unique<D>()); 20. vec.emplace_back(std::make_unique<D>()); 21. std::unique_ptr<B> d{new D}; 22. vec.push_back(std::move(d)); 23. for (const auto& up : vec) up->f(); 24. } 25. std::cout << "now vec is out of scope\n"; 26. }
Zauważmy, że gdy wektor wychodzi z zakresu, wszystkie jego elementy (u-wskaźniki) zwalniają zawiadywane przez siebie zasoby, tak, że nie powstaje żaden wyciek pamięci; program drukuje
Ctor D Ctor D Ctor D f from B f from D f from D f from D Dtor D Dtor D Dtor D vec out of scope
Tak jak zwykłe wskaźniki, tak też u-wskaźniki mogą być używane w kontekście wymagającym wartości logicznej; wskaźnik pusty (czyli spełniający p.get() == nullptr) jest interpretowany jako false, w przeciwnym wypadku jako true.
Inteligentne wskaźniki typu shared_ptr (s-wskaźniki) dzielą własność zasobu reprezentowanego przez zwykły wskaźnik. Są wyposażone w mechanizm zliczania referencji — tworzone są specjalne struktury danych z licznikiem, pozwalającym na zliczanie s-wskaźników odnoszących się do tego samego zasobu. Kiedy taki wskaźnik wychodzi z zakresu licznik jest obniżany o jeden, a kiedy osiąga wartość zero zasób jest zwalniany. Podobnie, po przypisaniu takich wskaźników, p = q, licznik związany z zasobem zawiadywanym przez p jest zmniejszany (bo p już nie będzie się do niego odnosić), natomiast ten związany z zasobem zawiadywanym przez q jest zwiększany, bo teraz również p do niego się odnosi. Możemy poznać stan liczników wywołując metodę use_count.
Niektóre z tych własności s-wskaźników są zilustrowane
następującym programem:
1. #include <iostream> 2. #include <memory> 3. using std::shared_ptr; using std::cout; using std::ostream; 4. 5. class Klazz { 6. char c; 7. public: 8. Klazz(char c) 9. : c{c} { cout << "Ctor " << c << '\n'; } 10. ~Klazz() { cout << "Dtor " << c << '\n'; } 11. friend ostream& operator<<(ostream& s, const Klazz& k) { 12. return s << k.c; 13. } 14. }; 15. 16. void f(shared_ptr<Klazz> p) { 17. cout << "In f: p=" << *p << ", count=" 18. << p.use_count() << '\n'; 19. } 20. 21. int main() { 22. shared_ptr<Klazz> p = std::make_shared<Klazz>('A'); 23. shared_ptr<Klazz> q{new Klazz{'B'}}; 24. cout << "p=" << *p << ", count=" << p.use_count() << '\n'; 25. cout << "q=" << *q << ", count=" << q.use_count() << '\n'; 26. f(p); 27. cout << "p=" << *p << ", count=" << p.use_count() << '\n'; 28. cout << "Now assigning p = q\n"; 29. p = q; 30. cout << "After assignment\n"; 31. cout << "p=" << *p << ", count=" << p.use_count() << '\n'; 32. cout << "q=" << *q << ", count=" << q.use_count() << '\n'; 33. cout << "Exiting from main\n"; 34. }
który drukuje
Ctor A Ctor B p=A, count=1 q=B, count=1 In f: p=A, count=2 p=A, count=1 Now assigning p = q Dtor A After assignment p=B, count=2 q=B, count=2 Exiting from main Dtor BJak widzimy, można utworzyć s-wskaźnik posyłając zwykły wskaźnik do konstruktora. Konstruktor domyślny tworzy wskaźnik pusty, podobnie jak w przypadku u-wskaźników. Dla s-wskaźników istnieje też, analogiczna do make_unique, funkcja make_shared.
Dla s-wskaźników można, podobnie jak dla u-wskaźników, definiować
własne deletery. Inaczej niż dla u-wskaźników, typ deletera nie
jest częścią typu wskaźnika — po prostu przesyłamy deleter
jako dodatkowy argument do konstruktora. Przed wersją
C++17 nawet jeśli zarządzanym zasobem była tablica, domyślnie
stosowany był deleter nie wywołujący
delete[]
jak
powinien, tylko
delete
— trzeba zatem było definiować
i przesyłać własne deletery, jak w przykładzie poniżej:
1. #include <iostream>
2. #include <memory>
3. using std::shared_ptr;
4.
5. template< typename T >
6. struct arrdel {
7. void operator ()(T const *p) { delete[] p; }
8. };
9.
10. int main() {
11. shared_ptr<int> sp(new int(1));
12.
13. // pointer to int[] array - custom deleter
14. shared_ptr<int> p1(new int[10], arrdel<int>());
15. // ... or lambda
16. shared_ptr<int> p2(new int[10'000'000],
17. [](int *p) { delete[] p; });
18. // ... or the one from the library
19. shared_ptr<int> p3(new int[3]{1, 2, 3},
20. std::default_delete<int[]>());
21. std::cout << p3.get()[2] << " " << *p3 << std::endl; ➊
22.
23. // since c++17 this will work
24. shared_ptr<int[]> p4(new int[3]{4, 5, 6});
25. std::cout << p4[2] << std::endl;
26. }
Nie był również dla takich wskaźników określony operator indeksowania ([]): dlatego nie można go było użyć w linii➊ powyższego programu. Jednak od wersji C++17 standardu s-wskaźniki tablicowe można tworzyć tak jak u-wskaźniki i używają one właściwego deletera domyślnie, bez potrzeby definiowania ich przez użytkownika. Program skompilowany kompilatorem wspierającym C++17, drukuje
3 1 6
Jak u-wskaźniki, tak i s-wskaźniki mogą być używane w kontekście logicznym (false jeśli są puste, true w przeciwnym przypadku). Zdefiniowana jest też dla nich metoda get oraz przeciążone są operatory * i ->.
T.R. Werner, 23 lutego 2022; 19:40