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 vwxyz
Zauważ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 B
Jak 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