Podrozdziały


19.6 Inteligentne wskaźniki

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ą”.


19.6.1 Inteligentne wskaźniki typu unique_ptr

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.
Jeśli żadna z tych form nie jest właściwa, użytkownik może przekazać własny tak zwany deleter. Musi to być funkcja (obiekt funkcyjny) niczego nie zwracająca a pobierająca (zwykły) wskaźnik odpowiedniego typu, jak w przykładzie poniżej. Zauważmy, że typ własnego deletera musi być częścią typu samego u-wskaźnika — musimy go przekazać, aby właściwie skonkretyzować szablon unique_pointer! W programie poniżej jako deletera używamy obiektu funkcyjnego własnej klasy Del w pierwszym przypadku, a lambdy w drugim


P166: delunique.cpp     Własny deleter wskaźnika typu unique_ptr

      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


P167: ownunique.cpp     Przenoszenie posiadania

      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 ) 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.
Po przenoszącym przypisaniu (linia ), jak możemy się przekonać z wydruku, obiekt zawiadywany przez p1 jest niszczony i  p1 przejmuje własność obiektu zawiadywanego wcześniej przez p2 — ten z kolei jest „zerowany”.

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:


P168: uniquereset.cpp     Resetowanie wskaźników unique_ptr

      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:


P169: uniquederiv.cpp     U-pointers and polymorphism

      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.


19.6.2 Inteligentne wskaźniki typu shared_ptr

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:


P170: sharedcount.cpp     Liczniki związane z zasobami zawiadywanymi przez s-wskaźniki

      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.
Po utworzeniu każda ze zmiennych pq odnosi się do innego obiektu — oba liczniki są zatem 1. Kiedy posyłamy p przez wartość do funkcji f, musi zostać wykonana kopia obiektu p — ta kopia wewnątrz funkcji również odnosi się do obiektu 'A', tak więc licznik wynosi tu 2. Po wyjściu z funkcji, kopia jest usuwana ze stosu i licznik odwołań do 'A' znów ma wartość 1.
Teraz przypisujemy q do  p: zmienna p odnosi się do obiektu 'A', licznik jest obniżany i osiąga wartość zero, tak więc 'A' jest usuwane. Teraz zarówno  p jak i  q odnoszą się do obiektu 'B', więc licznik wynosi 2. Po wyjściu z funkcji main najpierw usuwane jest ze stosu  p — licznik spada do 1, a następnie  q — teraz licznik osiąga zero i obiekt 'B' jest usuwany.

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:


P171: shareddelete.cpp     Deletery dla s-wskaźników tablicowych

      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 *->.

T.R. Werner, 23 lutego 2022; 19:40