cpp

Wprowadzenie

Wskaźniki są nieodzowne. Wskaźniki:

Wsparcie wskaźników może być:

W C++ najlepiej unikać surowych wskaźników i korzystać z zaawansowanego wsparcia w postaci standardowych wskaźników inteligentnych.

Referencja w Javie czy C# jest inteligentnym wskaźnikiem o semantyce współdzielonej własności, gdzie dostęp do składowej uzyskujemy z użyciem operatora . (czyli object.member), a nie -> (czyli pointer->member). W C++ referencja jest aliasem, która w czasie kompilacji będzie wyoptymalizowana albo zmieniona na surowy wskaźnik.

Motywacja: problemy surowych wskaźników

Surowe wskaźniki łatwo używać, ale też łatwo popełniać błędy.

Problemy

Kiedy mamy wskaźnik typu T *, który wskazuje na dynamicznie stworzone dane, to mamy następujące problemy:

Problem typu

Operatory new i delete mają wiele wersji, ale ważnymi są:

Jeżeli tworzymy dane z użyciem wersji pojedynczej albo tablicowej operatora new, to powinniśmy zniszczyć dane tą samą wersją operatora delete. Pomieszanie dwóch wersji skutkuje niezdefiniowanym działaniem. Kompilator nie jest w stanie wychycić błędu, bo operatory new i delete posługują się tym samym typem danych: T *.

Problem własności

Problem własności może skutkować:

Problem obsługi wyjątków

Jeżeli zarządamy dynamicznie zaalokowanymi danymi z użyciem surowych wskaźników, to obsługa wyjątków staje się nudnym i podatnym na błędy programowaniem, szczególnie gdy dane są złożone. Da się, ale kto chce to robić?

Przykład

Przykład niżej pokazuje jak łatwo możemy się natknąć na problemy typu, własności i obsługi wyjątków. Kompilator nie zgłasza błędów ani ostrzeżeń przy kompilowaniu tego błędnego kodu.

// Who should destroy the allocated data?  Should the data be
// destroyed by the foo function?

void
foo(int *p)
{
  // By mistake the array delete is used.
  delete [] p;
}

int *
factory()
{
  int *p;

  try
    {
      p = new int;

      // Work on the new data.  An exception could be thrown here.
      throw true;

      return p;
    }
  catch(bool)
    {
      // It's easy to forget this delete:
      delete p;      
    }

  return p;
}

int
main()
{
  // The problem is brewing: we use a pointer to integer to point to
  // the begining of an array of integers.
  int *p = factory();

  // I'm thinking that foo will use, but not destroy the data.
  foo(p);

  // This is the second delete.
  delete p;
}

Rozwiązanie: inteligentny wskaźnik

Inteligentny wskaźnik zarządza dynamicznie zaalokowanymi danymi, więc objekt inteligentnego wskaźnika nazywamy obiektem zarządzającym, a dynamicznie zaalokowane dane nazywamy danymi zarządzanymi.

Inteligentny wskaźnik nie kopiuje czy przenosi zarządzanych danych, może je tylko zniszczyć.

Typ zarządzanych danych nie musi być przygotowany w jakiś specjalny sposób, żeby można było użyć inteligentnych wskaźników, np. typ nie musi dziedziczyć z jakiegoś typu bazowego (interfejsu) i implementować go.

Inteligentne wskaźniki rozwiązują:

Każdy wszechstronny język powinien wspierać surowe wskaźniki, ponieważ ta niskopoziomowa funkcjonalność jest wymagana do implementacji wysokopoziomowej funkcjonalności, takiej jak inteligentne wskaźniki.

Programista powinien mieć wybór pomiędzy surowymi wskaźnikami (na przykład do implementacji jakiejś wyrafinowanej funkcjonalności) i inteligentnymi wskaźnikami do codziennego użytku.

Za wyjątkiem specjalnych przypadków, obecnie programista C++ nie powinien używać surowych wskaźników, nie mówiąc już o void * – te czasy już dawno minęły.

Typy inteligentnych wskaźników

Są trzy typy inteligentnych wskaźników zdefiniowanych w pliku nagłówkowym memory:

Inteligentne wskaźniki używają surowych wskaźników w swojej implementacji, więc można powiedzieć, że obudowują je. Na przykład, semantyka przeniesienia jest elementem tej obudowy, która jest niezbęda do wygodnej i poprawnej implementacji, ale nie wprowadza żadnego narzutu czasowego w czasie uruchomienia. Inteligentne wskaźniki są tak szybkie i używają tak mało pamięci, jak to jest tylko możliwe, czyli tak, jakbyśmy ręcznie (ale ciągle poprawnie) wyrzeźbili ten kod.

Inteligentne wskaźniki są:

Jest jeszcze przestarzały inteligentny wskaźnik std::auto_ptr, ale nie należy już go stosować.

std::unique_ptr

Typ std::unique_ptr implementuje semantykę wyłącznej własności:

Wyłączność pociąga za sobą to, że std::unique_ptr jest typem tylko do przenoszenia, więc:

Własność pociąga za sobą to, że zarządzane dane są niszczone, kiedy obiekt zarządzający:

Kiedy nie chcemy już używać surowych wskaźników, to najczęściej powinniśmy stosować ten typ inteligentnego wskaźnika.

Przykład

Typ std::unique_ptr jest szablonowy: typ zarządzanych danych przekazujemy jako argument szablonu. Przekazujemy argumenty szablonu w nawiasach ostrokątnych <> w ten sposób:

std::unique_ptr<typ> p;

W przykładzie niżej, obiekt zarządzający p zarządza daną typu int, która będzie automatycznie zniszczona, kiedy p wyjdzie poza zakres.

#include <memory>

int
main()
{
  std::unique_ptr<int> p(new int);
}

Funkcja std::make_unique

Szablon funkcji std::make_unique został wprowadzony dla wygody (dalibyśmy radę bez niego): funkcja tworzy dane zarządzane i zarządzający obiekt.

Możemy sami stworzyć dane i przekazać ich surowy wskaźnik obiektowi zarządzającemu w ten sposób:

unique_ptr<A> up(new A("A1"));

Zamiast tego możemy napisać równoważny kod bez dwukrotnego pisania A:

auto up = make_unique<A>("A1");

Funkcja std::make_unique nie wprowadza narzutu wydajnościowego: konstruktor przenoszący będzie pominięty, więc obiekt zarządzający będzie stworzony bezpośrednio w miejscu zmiennej up.

Używający specyfikatora typu auto prosimy kompilator o wywnioskowanie typu dla zmiennej up na podstawie wyrażenia inicjalizującego, czyli wyrażenia wywołania funkcji make_unique<A>("A1"), której zwracana wartość jest typu std::unique_ptr<A>. Moglibyśmy równoważnie napisać:

unique_ptr<A> up = make_unique<A>("A1");

Szablonowi funkcji std::make_unique przekazujemy jako jego argument typ danych do stworzenia i zarządzania. Argumenty (w dowolnej liczbie, także zero) wywołania funkcji są przekazywane do konstruktora zarządzanych danych. W przykładzie wyżej, "A1" jest argumentem przekazywanym do konstruktora typu A.

Bez narzutu wydajnościowego

Poniższy przykład pokazuje, że inteligentne wskaźniki nie wprowadzają narzutu wydajnościowego. W bardziej skomplikowanych przykładach być może będzie drobny narzut, ale można oczekiwać, że wraz z kolejnymi standardami C++ i nowszymi kompilatorami, ten narzut będzie mniejszy.

Przykład używa std::unique_ptr i std::make_unique. Plik test1.cc:

#include <memory>

int
main()
{
  auto p = std::make_unique<int>();
}

Poniższy przykład implementuje tą samą funkcjonalność z surowymi wskaźnikami. Plik test2.cc:

#include <memory>

int
main()
{
  int *p = new int;
  delete p;
}

Kompilujemy do asemblera:

g++ -S -O3 test1.cc test2.cc

Otrzymaliśmy dwa pliki w asemblerze: test1.s i test2.s. Spójrzymy na jeden z nich:

c++filt < test1.s | less

Porównajmy te pliki, żeby się przekonać, że są takie same. Porównanie pokazuje, że std::unique_ptr i std::make_unique nie wprowadzają narzutu:

diff test1.s test2.s

Jak używać std::unique_ptr

Przykład niżej pokazuje, jak używać std::unique_ptr.

#include <cassert>
#include <iostream>
#include <memory>
#include <string>

using namespace std;

struct A
{
  string m_name;

  A()
  {
    cout << "default ctor\n";
  }

  A(string &&name): m_name(std::move(name))
  {
    cout << "ctor: " << m_name << endl;
  }

  ~A()
  {
    cout << "dtor: " << m_name << endl;
  }

  // Smart pointers never copy or move their managed data, so we can
  // delete these special member functions, and the code should
  // compile.
  A(const A &) = delete;
  A(A &&) = delete;
  A &operator=(const A &) = delete;
  A &operator=(A &&) = delete;
};

int
main()
{
  // That's an empty pointer.
  std::unique_ptr<A> p1;

  // That's how we test whether a pointer manages some data.
  assert(!p1);
  assert(p1 == nullptr);

  // This pointer manages an object.
  std::unique_ptr<A> p2(new A("A1"));
  assert(p2);
  assert(p2 != nullptr);

  // We can assign a new object to manage, but not this way.
  // p1 = new A("A1'");

  // That's the correct way.  The previously managed object is
  // destroyed.
  p2.reset(new A("A2"));

  // Or better yet:
  p2 = make_unique<A>("A3");

  // We cannot copy-initialize, because the ownership is exclusive.
  // std::unique_ptr<A> p3(p2);
  // auto p3(p2);

  // We cannot copy-assign, because the ownership is exclusive.
  // p2 = p1;

  // We can move-initialize to move the ownership.
  auto p3 = std::move(p2);

  // We can move-assign to move the ownership.
  p2 = std::move(p3);

  // That's how we can get access to the managed data.
  cout << p2->m_name << endl;
  cout << (*p2).m_name << endl;
  cout << p2.get()->m_name << endl;

  // The "release" function releases p1 from managing the data.  The
  // managed data is not destroyed.  Luckily, p1 doesn't manage
  // anything, so we don't get a memory leak.
  p1.release();
}

Rozwiązanie problemów

Problem typu

Problem typu, czyli niespasowania wersji pojedynczej i tablicowej operatorów new and delete, jest rozwiązany przez dwie wersje inteligentnych wskaźników:

Używając odpowiedniej wersji inteligentnego wskaźnika nie musimy pamiętać o niszczeniu zarządzanych danych z użyciem odpowiedniego operatora delete.

Czychające problemy i jak sobie z nimi radzić.

Ciągle jednak możemy popełnić błędy, jak w przykładzie niżej, gdzie:

Szablon funkcji std::make_unique pozwala nam bezpieczne osiągnąć poprawną implementację:

#include <memory>

using namespace std;

int
main()
{
  // Undefined behavior!
  unique_ptr<int> up1(new int[5]);
  unique_ptr<int[]> up2(new int);

  // The preferred way, because it's less error-prone.
  auto up3 = make_unique<int>();
  auto up4 = make_unique<int[]>(5);
}

Lepiej użyć std::array!

Jeżeli potrzebujemy tablicy statycznego rozmiaru (czyli rozmiaru, który nie zmienia się w czasie uruchomienia), to lepiej użyć std::array zamiast tablicy języka C. Możemy jej użyc z inteligentnymi wskaźnikami w ten sposób:

#include <array>
#include <memory>

using namespace std;

int
main()
{
  unique_ptr<array<int, 5>> up1(new array<int, 5>);
  auto up2 = make_unique<array<int, 5>>();
}

Problem własności

Problem własności jest rozwiązany: po prostu przenosimy własność przez przenoszenie wartości inteligentnego wskaźnika, np. do funkcji czy jakiejś struktury danych. Możemy także przenieść własność przekazując obiekt inteligentnego wskaźnika przez wartość, np. przekazując argument do funkcji, albo zwracając wynik z funkcji. Oto przykład:

#include <iostream>
#include <memory>

using namespace std;

struct A
{
  A()
  {
    cout << "ctor\n";
  }

  ~A()
  {
    cout << "dtor\n";
  }
};

auto // C++14
factory()
{
  auto p = make_unique<A>();

  return p; // Return value optimization.
}

void
stash(unique_ptr<A> p)
{
  static unique_ptr<A> stash;
  stash = std::move(p);
}

int
main()
{
  auto p = factory();
  stash(std::move(p));
}

Problem obsługi wyjątków

Kiedy rzucany jest wyjątek, to dane, które wcześniej były stworzone, a nie są już potrzebne, powinny być zniszczone. Programując z użyciem surowych wskaźników możemy zwolnić pamięć w bloku obsługi wyjątku (ang. a catch block), jak pokazano w przykładzie niżej. Musimy zadeklarować wskaźnik p przed blokiem przechwytywania wyjątku (ang. a try block), żeby był dostępny w bloku obsługi wyjątku, a to komplikuje kod.

#include <iostream>

struct A
{
  A()
  {
    std::cout << "ctor\n";
  }

  ~A()
  {
    std::cout << "dtor\n";
  }
};

void
foo()
{
  throw true;
}

int
main(void)
{
  A *p;

  try
    {
      p = new A();
      foo();
      delete p;
    }
  catch (bool)
    {
      // Have to delete.
      delete p;
    }
  
  return 0;
}

To samo, ale bezpieczniej, możemy osiągnąć z użyciem inteligentnych wskaźników:

#include <iostream>
#include <memory>

struct A
{
  A()
  {
    std::cout << "ctor\n";
  }

  ~A()
  {
    std::cout << "dtor\n";
  }
};

void
foo()
{
  throw true;
}

int
main(void)
{
  try
    {
      auto p = std::make_unique<A>();
      foo();
    }
  catch (bool)
    {
    }
  
  return 0;
}

Surowe wskaźniki nie takie łatwe

W poniższym przykładzie mamy wyciek pamięci, ponieważ standard nie gwarantuje, że argumenty wywołania funkcji będą opracowane w kolejności ich podania. Wyciek da się zaobserwować (brak wywołania destruktora) kompilując program z GCC. Jeżeli używamy innego kompilatora i nie widzimy wycieku, to powinien się on pojawić po zamianie miejscami parametrów funkcji foo.

Obiekt klasy A:

#include <iostream>

struct A
{
  A()
  {
    std::cout << "ctor\n";
  }

  ~A()
  {
    std::cout << "dtor\n";
  }
};

void
foo(int, A *p)
{
  delete p;
}

int
index()
{
  throw true;
  return 0;
}

int
main(void)
{
  try
    {
      foo(index(), new A());
    }
  catch (bool)
    {
    }
  
  return 0;
}

To samo, ale bezpieczniej, możemy osiągnąć z użyciem inteligentnych wskaźników. Ten kod działa poprawnie z wyjątkami.

#include <iostream>
#include <memory>

struct A
{
  A()
  {
    std::cout << "ctor\n";
  }

  ~A()
  {
    std::cout << "dtor\n";
  }
};

void
foo(int, std::unique_ptr<A> p)
{
}

int
index()
{
  throw true;
  return 0;
}

int
main(void)
{
  try
    {
      foo(index(), std::make_unique<A>());
    }
  catch (bool)
    {
    }
  
  return 0;
}

A na koniec pierwszy przykład

Niżej jest przykład z samego początku, ale naprawiony. Problemów brak.

#include <memory>

using namespace std;

void
foo(unique_ptr<int> p)
{
}

unique_ptr<int>
factory()
{
  try
    {
      auto p = make_unique<int>();

      // Work on the new data.  An exception could be thrown here.
      throw true;

      return p;
    }
  catch(bool)
    {
    }

  return nullptr;
}

int
main()
{
  auto p = factory();
  foo(std::move(p));
}

Podsumowanie

Quiz