Posted on

Ten artykuł pochodzi z mojego starego bloga na kacperkolodziej.pl.

Nowe standardy języka C++ dodają nie tylko całkiem nowe możliwości, ale także udostępniają nam udogodnienia, dzięki którym możemy wyeliminować na zawsze niektóre problemy, które były poważnym utrapieniem dla programistów. Jednym z takich problemów są wycieki pamięci przy jej dynamicznym alokowaniu. Wielu z nas często zapomina o zwalnianiu (przy użyciu operatorów delete i delete[]) pamięci zarezerwowanej przy pomocy new oraz new[]. Nowe udogodnienia w postaci wskaźników unikalnych i współdzielonych powodują, że z operatora delete będziemy korzystać coraz rzadziej. Czym właściwie są nowe rodzaje wskaźników?

Wspólne cechy wskaźników unikalnych i współdzielonych

Zacznijmy od tego, że nie są to nowe konstrukcje języka, a jedynie szablony klasy z biblioteki szablonów STL (Standard Template Library). Chodzi konkretnie o std::unique_ptr i std::shared_ptr. Szablony te wymagają co najmniej jednego typu - typu danych na jakie wskaźnik ma pokazywać. Drugim przekazywanym parametrem szablonu może być specjalna klasa obiektów funkcyjnych, która zwalnia pamięć po obiekcie. Standardowo jest używany default_delete z biblioteki standardowej - czyli obiekt, który wykorzystuje wbudowany w język operator delete. Aby wykorzystywać te typy wskaźników należy dołączyć do projektu plik nagłówkowy memory: #include <memory>.

Obiekty klas unique_ptr i shared_ptr w praktyce

Dzięki przeładowaniu odpowiednich operatorów (operator->, operator*, operator= i operator bool) możemy korzystać z nowych wskaźników tak samo jak z tych, które są konstrukcją języka. Dodatkowo możemy uzyskać dostęp do zwyczajnego wskaźnika dzięki funkcji składowej get() (obecnej zarówno w std::unique_ptr jak i std::shared_ptr). Dzięki temu wskaźnik możemy wykorzystywać również w przypadku gdy wymagane jest wykorzystywanie tradycyjnych wskaźników.

Wskaźniki unikalne - unique_ptr

Cechy charakterystyczne wskaźników unikalnych to:

  • może istnieć tylko jeden wskaźnik tego typu pokazujący na dany obiekt,
  • w momencie kiedy przestaje istnieć wskaźnik, usuwany jest także obiekt, na który ten wskaźnik pokazywał, a pamięć, którą zajmował jest zwalniana,
  • obiekt na który wskazuje wskaźnik jest także usuwany kiedy do wskaźnika przypisujemy inny obiekt,
  • obiekt wskazywany przez unique_ptr nie może być zarządzany przez inny obiekt tego typu ani też shared_ptr.

Przykład użycia wskaźników unikalnych:

#include <iostream>
#include <memory>

using namespace std;

struct MyClass
{
  static int nextId;
  int id;
  MyClass() : id(++nextId)
  {
    cout << "Tworze obiekt klasy MyClass. id = " << id << "\n";
  }
  ~MyClass()
  {
    cout << "Usuwam obiekt klasy MyClass. id = " << id << "\n";
  }
  void f()
  {
    cout << "Wywołanie funkcji f() dla obiektu id: " << id << "\n";
  }
};

struct MyClassDeleter
{
  void operator()(MyClass* ptr)
  {
    cout << "--- Uruchomienie usuwania obiektu id: "
         << ptr->id << " przy pomocy MyClassDeleter\n";
    delete ptr;
    cout << "--- Obiekt id: " << ptr->id << " usuniety\n";
  }
};

int MyClass::nextId = 0;

int main()
{
  unique_ptr<MyClass> ptr1(new MyClass);
  ptr1->f();
  (*ptr1).f();
  unique_ptr<MyClass, MyClassDeleter> ptr2(new MyClass);
  return 0;
}

W powyższym przykładzie zdefiniowaliśmy klasę MyClass, która posiada konstruktor i destruktor oraz funkcję składową void f(). Każda z tych funkcji wyświetla stosowny komunikat. Kolejna struktura to "usuwacz". Kiedy wskaźnik ma usunąć obiekt, wykorzystuje obiekt tej struktury jako funkcję (czyli uruchamia operator()). W funkcji main() tworzymy dwa unikalne wskaźniki i korzystamy z funkcji MyClass::f(). Po uruchomieniu programu widzimy, kiedy tworzone są obiekty, kiedy wywoływane ich funkcje składowe i kiedy są one usuwane z pamięci. Jak widać, do tego ostatniego nie przyłożyliśmy nawet ręki, a pamięć została zwolniona.

Wskaźniki współdzielone - shared_ptr

Cechy charakterystyczne wskaźników współdzielonych to:

  • może istnieć wiele wskaźników do tego samego obiektu
  • obiekt jest usuwany w momencie gdy przestaje istnieć ostatni wskaźnik typu shared_ptr

Przykład użycia wskaźników współdzielonych:

#include <iostream>
#include <memory>

using namespace std;

struct MyClass
{
  static int nextId;
  int id;
  MyClass() : id(++nextId)
  {
    cout << "Tworze obiekt klasy MyClass. id = " << id << "\n";
  }
  ~MyClass()
  {
    cout << "Usuwam obiekt klasy MyClass. id = " << id << "\n";
  }
  void f()
  {
    cout << "Wywołanie funkcji f() dla obiektu id: " << id << "\n";
  }
};

int MyClass::nextId = 0;

int main()
{
  shared_ptr<MyClass> ptr1(new MyClass);
  cout << "Tworze 2 kolejne wskazniki shared_ptr do obiektu o id: "
       << ptr1->id << "\n";
  shared_ptr<MyClass> ptr2(ptr1);
  shared_ptr<MyClass> ptr3(ptr1);

  cout << "Uzywam funkcji f we wszystkich wskaźnikach\n";

  ptr1->f();
  (*ptr2).f();
  ptr3->f();

  cout << "Program usuwa wskazniki\n";

  return 0;
}

Ponieważ std::shared_ptr nie udostępnia możliwości ustawienia klasy kasującej obiekt, wykorzystamy tutaj tylko MyClass. Na samym początku tworzymy jeden wskaźnik współdzielony do nowego obiektu zaalokowanego w pamięci. Następnie tworzymy dwa współdzielone wskaźniki. Musimy pamiętać, aby każdy następny tworzyć nie z tradycyjnego wskaźnika do pamięci, a z istniejącego już współdzielonego wskaźnika do pamięci. Następnie wykonujemy funkcję MyClass::f() wykorzystując operator*, służący do wyłuskania obiektu i operator-> służący do wyłuskania funkcji składowej tego obiektu.