Czy wiesz, że jesteśmy również na Slacku? Dołącz do nas już teraz klikając tutaj!

Objects, their lifetime and pointers


2020-04-21, 00:00

Być może, wydaje Ci się, że temat tego postu jest bardzo podstawowy i nie warto poświęcać na niego czasu. Częściowo jest to prawda - treść dotyczy podstaw języka C++ - jestem jednak pewien, że znajdziesz tu czytelniku rzeczy, które będą dla Ciebie zupełnie nowe, a także dowiesz się o wielu niezdefiniowanych zachowaniach. Zapraszam do lektury!

Powodem, dla którego wybrałem ten temat, jest fakt, że jest on zawiły i większość z nas nie rozumie go wystarczająco dobrze, aby pisać poprawne programy zwłaszcza kiedy próbujemy manipulować obiektami na niskim poziomie.

Wierzę w to, że jest to spowodowane procesami edukacji, przez które większość z nas przeszła. Zazwyczaj mamy doświadczenie w języku C, w którym zasady dotyczące życia obiektów i wskaźników do nich są nieco inne niż w języku C++. Celem tego artykułu jest zmiana tego starego myślenia o obiektach na nowy.

Artykuł zaczniemy od teorii, która później pozwoli nam zrozumieć praktyczne przykłady.

Teoria obiektów w C++

Zacznijmy od tego, czym w ogóle są obiekty. Już w tym miejscu możemy mieć mały problem. Intuicyjnie wiemy, czym jest obiekt, ale ciężko sformułować nam poprawną definicję.

Na nic zda się nam wikipedia, która podaje następujący opis obiektu:

Obiekt – podstawowe pojęcie wchodzące w skład paradygmatu programowania obiektowego w analizie i projektowaniu oprogramowania oraz w programowaniu.

przede wszystkim, w C++ obiekt nie musi być związany z programowaniem zorientowanym obiektowo. Angielska wersja definicji z wikipedii, również jest niepoprawna z punkty widzenia języka C++:

In computer science, an object can be a variable, a data structure, a function, or a method, and as such, is a value in memory referenced by an identifier.

Powodem, dla którego definicje są niepoprawne jest fakt, że każdy język może definiować obiekt w inny sposób.

Definicja obiektów w języku C++

Do rzeczy. Czym są w takim razie obiekty w C++?

Do wprowadzenia C++17 obiekty w C++ były definiowane jako część magazynu (ang. storage) posiadającą pewne cechy. Wraz z nadejściem C++17 zrezygnowano z porównania obiektów do zajmowanej przez niego pamięci, na rzecz bytu z cechami.

Obiekt według języka C++ ma następujące cechy:

  • może (nie musi) mieć nazwę
  • ma magazyn (ang. storage) i jego czas trwania
  • typ
  • czas życia
  • wartość

Poznajmy bliżej te cechy. Zacznijmy od tego, w jaki sposób możemy tworzyć obiekty, ponieważ jest to jedna z tych rzeczy, które najczęściej powodują niezdefiniowane zachowania w naszych programach. Obiekt możemy stworzyć poprzez:

  • jego definicję (int x;),
  • wyrażenie new (new int{};)
  • utworzenie obiektu tymczasowego (int{};)
  • zmianę aktywnego membera unii:
union {
  int a;
  float b;
} u;
u.a = 5; // utworzenie obiektu u.a
u.b = 10.0f; // utworzenie obiektu u.b

równie ważne jest to, jak możemy dany obiekt zniszczyć, a możemy to zrobić poprzez:

  • wywołanie destruktora
  • poprzez ponowne użycie magazynu, lub jego uwolnienie (ang. release)

tak naprawdę, jest tutaj małe oszustwo. Nie każdy obiekt możemy zniszczyć poprzez wywołanie destruktora. W obecnej formie obiekty, których typ jest fundamentalny (std::is_fundamental), czyli jest typem wbudowanym języka, takim jak char, int, wskaźniki i tym podobne, nie mają destruktorów. Posiadają one za to coś, co nazywamy pseudo-destruktorami, jednak nie mają one żadnego wpływu na zachowanie naszego programu (w szczególności wywołanie destruktora nie niszczy takiego obiektu).

Kłamstwo to wzięło się stąd, że w kolejnych wydaniach standardu ma się to zmienić i wywołanie takiego destruktora na typach wbudowanych jak najbardziej będzie niszczyć obiekty, warto więc już teraz założyć, że tak się stanie.

Zastanawiać się również możecie co znaczy ponowne użycie magazynu i jego uwolnienie. Ponowne użycie magazynu oznacza to, że w miejscu magazynu, tworzymy nowy obiekt. Jego uwolnienie oznacza tyle, że czas trwania magazynu dobiega końca.

Powinniśmy już teraz bez problemu rozróżnić co jest, a co nie jest obiektem, ale żeby nie było wątpliwości: referencje oraz funkcje obiektami nie są. Funkcje nie mają czasu życia, a referencji nie możemy tworzyć tak samo jak obiektów (np. poprzez new), nie jest również określone, czy referencje posiadają swój magazyn.

Jest jeszcze jedna rzecz w C++ - zmienne. Czym jest zmienna? Zmienna jest bytem utworzonym poprzez deklarację obiektu lub referencji. Spójrzmy na przykłady:

int x; // x jest zmienną - deklaracja (i definicja) obiektu
int& y; // y jest zmienną - deklaracja (i definicja) referencji
struct A{
  int b;
}c;
// A oraz A::b nie są zmiennymi - nie deklarują one obiektów - A to nazwa klasy, a A::b to nazwa pola klasy.
// c jest zmienną - deklaruje ona obiekt typu A.

Czas życia obiektu

Skoro wiemy już, jak stworzyć obiekt, oraz jak go zniszczyć, przyjrzyjmy się czasowi życia obiektu.

Czas życia, tak samo jak obiekty pojmujemy dość intuicyjnie. Standard w tym wypadku nie mówi konkretnie czym jest czas życia. Mówi jedynie, kiedy się on zaczyna i kiedy kończy. Pojęcie czasu życia obiektu zostało wprowadzone po to, aby łatwo można było zdefiniować kiedy nie powinniśmy wykonywać pewnych czynności na obiektach.

Wszystkie fazy życia obiektu (nie mylić z czasem życia) to:

  • czas, kiedy magazyn został zaalokowany, ale konstruktor nie został jeszcze uruchomiony,
  • obiekt podczas konstrukcji (czas, w którym konstruktor jest właśnie wykonywany),
  • czas życia obiektu
  • obiekt podczas destrukcji (destruktor jest wykonywany),
  • destruktor zakończony, magazyn jeszcze nie zwolniony.

Nie wszystkie fazy życia istnieją dla każdego obiektu. Dla przykładu typy trywialnie konstruktowalne (std::is_trivially_constructible) nie posiadają drugiej fazy życia, ponieważ konstruktor takiego typu nie wykonuje żadnych czynności. Podobnie mamy typy trywialnie destruktowalne (std::is_trivially_destructible), dla których nie istnieją ostatnie dwa etapy życia. Co więcej, ponieważ wywoływanie destruktorów nie jest wymuszone poprzez standard, można pominąć dwa ostatnie etapy życia dla każdego typu obiektu.

Omówmy po krótce jakie są ograniczenia co do używania obiektów w poszczególnych fazach życia:

Przed rozpoczęciem konstruktora

Korzystanie z obiektu jest praktycznie niemożliwe. Jeżeli posiadamy referencję lub wskaźnik do takiego obiektu, to możemy go traktować wyłącznie tak, jakby był to wyłącznie magazyn. Zabronione jest między innymi:

  • podawanie wskaźnika do takiego obiektu jako argumentu do wyrażenia delete
  • nie możemy odnosić się do żadnych nie statycznych składowych obiektu,
  • nie możemy rzutować obiektu poprzez static_cast do typów innych niż void*, char*, unsigned char*, std::byte. Nie możemy również używać wskaźnika do obiektu, ani referencji do obiektu jako argument dynamic_cast.

Jeżeli odważymy się na jedną z powyższych sytuacji, zachowanie naszego programu stanie się niezdefiniowane.

Obiekt podczas konstrukcji oraz destrukcji

Kiedy obiekt jest konstruowany lub niszczony, nasze możliwości wykorzystania obiektu są nieco większe. Możemy np. odnieść się do składowych obiektu, które zostały już zainicjalizowane. Możemy to jednak uczynić tylko poprzez wskaźnik na this. Jeżeli na przykład spróbujemy wykonać następujący kawałek kodu (przykład ze szkicu standardu):

struct C;
void no_opt(C*);

struct C {
  int c;
  C() : c(0) { no_opt(this); }
};

const C cobj;

void no_opt(C* cptr) {
  int i = cobj.c * 100;    // wartość nieokreślona
  cptr->c = 1;
  cout << cobj.c * 100   // wartość nieokreślona
       << '\n';
}

okaże się, że wartość cobj.c będzie nieokreślona (choć nie jest wykluczone, że będzie prawidłowa).

Podczas konstrukcji oraz destrukcji obiektu inne jest zachowanie również narzędzi dynamic_cast oraz typeid, ale jest to opisane na moim poprzednim wpisie (dynamic_cast oraz type_id)

Jedną z różnic w zachowaniu obiektów podczas konstrukcji oraz destrukcji jest fakt, że wywołanie funkcji wirtualnych będzie działało tak, jakby obiektem najniżej w hierarchii dziedziczenia był obiekt, typu, którego konstruktor/destruktor jest właśnie wykonywany. Na przykład:

struct Base{
  Base(){foo();}
  virtual void foo(){std::cout << "Base" << std::endl;}
};

struct Derived : Base{
  void foo() override {std::cout << "Derived" << std::endl;}
};

D d;

Podczas tworzenia się obiektu d, wywołanie funkcji foo nie będzie skutkowało wywołaniem przeciążenia funkcji z klasy pochodnej lecz bazowej.

Jest to spowodowane tym, że tzw. wskaźnik na wirtualną tablicę, która to zawiera informacje o tym, która funkcja z hierarchii dziedziczenia jest najbardziej pochodna, jest aktualizowany dopiero po zakończeniu się konstruktora B::B.

Jest to dość znany fakt. Bardziej interesujące może być to, że dostęp do wskaźnika na tablicę wirtualną nie jest bezpieczny wielowątkowo. Z tego powodu zachowanie następującego programu jest niezdefiniowane:

struct Derived : Base{
  Derived(std::atomic_bool& b){b.store(true);}
  void foo() override {std::cout << "Derived" << std::endl;}
};

struct Derived2 : Derived{
  Derived2(std::atomic_bool& b) : Derived(b){}
  void foo() override {};
}

//...
auto d = (Derived2*)operator new(sizeof(Derived2));
std::atomic_bool d_ctor_running{false};
auto t1 = std::thread([d, &d_ctor_running](){new(d) Derived2(d_ctor_running);});
auto t2 = std::thread([d, &d_ctor_running](){while(d_ctor_running.load()); d->foo();});

t1.join();
t2.join();

Pomimo tego, że kod może niektórym się wydawać poprawny, to tzw. wyścig danych (ang. data race) powoduje, że nasz kod poprawny nie jest. Problem pojawia się w momencie, kiedy jeden wątek próbuje wywołać funkcję wirtualną, które to wywołanie powoduje odczyt wskaźnika na tablicę wirtualną, a drugi wątek kończy wywołanie konstruktora klasy Derived a ropoczyna wykonywanie konstruktora klasy Derived2, kiedy to owy wskaźnik jest zapisywany.

Z podobnym zachowaniem możemy się spotkać, kiedy będziemy próbowali synchronizować operacje podczas wykonywania się destruktora.

Kolejnym problemem jest rzutowanie obiektu, na inny obiekt bazowy, lub formułowanie wskaźnika/referencji na obiekt którego konstruktor jeszcze się nie rozpoczął lub ścieżka rzutowania zawiera obiekt, którego konstruktor jeszcze się nie rozpoczął. Już spieszę wyjaśnić o co chodzi w praktyce.

Najprostszy przykład programu, który łamie tę zasadę możemy zobaczyć tutaj:

struct A{
  A(A*){}
};

struct B : A{
  B() : A(this){}  
};

problem polega na tym, że nie wolno nam rzutować na obiekt, którego konstruktor jeszcze się nie zaczął. Tym obiektem w tym przypadku jest bazowy obiekt klasy A.

Kolejny przykład tym razem nieco bardziej skomplikowany:

struct C;

struct B{
  B(C*);
};

struct C{
  C(B*);
};

struct D{
  D() :
    c(&b), // niezdefiniowane zachowanie. konstruktor b, jeszcze się nie zaczął
    b(&c){}
  C c; // konstruowany jako pierwszy
  B b;
};

Zastanawiasz się pewnie, dlaczego to w ogóle jest niezdefiniowane zachowanie. Czy kompilator miałby jakąkolwiek trudność, żeby poprawnie zrzutować obiekt? Odpowiedź brzmi: czasem.

W przypadku, kiedy dziedziczenie jest wirtualne, rzutowanie na klasy bazowe odbywa się po wirtualnych tablicach. Jeżeli natomiast konstruktor obiektu się nie rozpoczął, to wskaźnik na wirtualną tablicę nie został jeszcze utworzony, a więc dostęp do niego jest niezdefiniowany.

Popularne błędy spowodowane czasami życia obiektów

Czy kiedykolwiek próbowałeś w C++ type-punningu? Dla jasności type-punning to taka technika, w której dostajemy się reprezentacji obiektu poprzez inny obiekt.

Jeżeli piszesz w C++ i jesteś programistą embedded, to prawdopodobnie odpowiedź brzmi tak. Type-punning prawie zawsze kończy się w C++ niezdefiniowanych zachowaniem.

“Ale dlaczego?” pewnie pomyślałeś. Otóż okazuje się, że istnieje pewna optymalizacja, na którą kompilatory sobie pozwalają (a standard C++ do nich zachęca), która nazywa się TBAA (ang. Type Based Alias Analysis). Na czym polega ta optymalizacja?

Kompilator może wygenerować mniej instrukcji odczytu z pamięci, jeżeli program odczytuje i zapisuje wartości do dwóch obiektów o różnych typach.

Zobaczmy to na prostym przykładzie. Dla następującej funkcji:


struct S{
  int a;
};

int test(S& val1, S& val2){
  val1.a = 10;
  val2.a = 2;

  return val1.a+val2.a;
}

Na początek musimy sobie zadać pytanie: “jakie są możliwe wyniki takiej funkcji?” Są dwie poprawne odpowiedzi: jeden poprawny wynik to 12, a drugi 4. Wynik 12 uzyskamy wtedy, kiedy podamy dwa różne obiekty jako argumenty funkcji, a 4, kiedy to będzie ten sam obiekt, ponieważ drugi zapis wartości 2 zmodyfikuje wartość zarówno zmiennej var1, jak i var2.

kompilator wygeneruje następujące instrukcje (przy włączonej optymalizacji, wygenerowany assembler dotyczy architektur ARM):

test(S&, S&):
  mov r3, #2
  mov r2, #10
  str r2, [r0]
  str r3, [r1]
  ldr r0, [r0]
  add r0, r0, r3
  bx lr

To, co się tutaj dzieje, jest następujące:

Pierwsze dwie instrukcje wpisują stałe wartości do tymczasowych rejestrów r3 oraz r2. Wartości te następnie są zapisywane do odpowiednich komórek pamięci, których adresy są przechowywane w rejestrach r0 oraz r1.

Po tej operacji nie potrzebujemy już dłużej wartości rejestru r0. Odczytujemy to, co jest pod adresem (odświeżamy wartość val1.a), wartości sumujemy i przechowujemy w rejestrze r0, który jest jednocześnie rejestrem trzymającym wynik wywołania funkcji.

Spójrzmy teraz na to, jaki kod zostanie wygenerowany, jeżeli do analogicznej funkcji podamy obiekty dwóch różnych typów:

struct S{
  int a;
};

struct T {
  int a;
};

int test(S& val1, T& val2){
  val1.a = 10;
  val2.a = 2;

  return val1.a+val2.a; 
}

dla tak zdefiniowanej funkcji, wygenerowany kod assemblerowy będzie:

test(S&, T&):
  mov r2, #10
  mov r3, #2
  str r2, [r0]
  str r3, [r1]
  mov r0, #12
  bx lr

Na pierwszy rzut oka widać, że wygenerowany kod jest krótszy, oraz że teraz kompilator sam wydedukował, że zwracaną wartością z funkcji będzie 12, ponieważ teraz, kiedy argumentami są dwa obiekty różnych typów, kompilator założy, że nie mogą one pochodzić z tego samego miejsca w pamięci.

Znaczy to również tyle, że wywołanie tej funkcji w następujący sposób, nie tylko będzie miało teoretyczne niezdefiniowane zachowanie, ale również to niezdefiniowane zachowanie będziemy mogli zaobserwować na własne oczy:

S s;
foo(s, reinterpret_cast<T&>(s);

To, co powoduje niezdefiniowane zachowanie jest fakt, że próbujemy dostać się do obiektu T, którego nigdy nie utworzyliśmy.

To oczywiście tylko jeden z przypadków, gdzie możemy spotkać niezdefiniowane zachowanie. Inne przykłady:

używanie unii jako narzędzie do type-punningu:

struct rgba{
  uint8_t red;
  uint8_t green;
  uint8_t blue;
  uint8_t alpha;
};

union color{
  rgba color;
  uint32_t as_int;
};

color c = {255, 120, 0, 50};
display(c.as_int);

Co jest tutaj niezdefiniowanym zachowaniem? Czytamy nieaktywną składową unii. Co znaczy, że składowa unii nie jest aktywna? Znaczy to tylko tyle, że nie jest to składowa, która została ostatnio utworzona w unii.

Powyższy przypadek ilustruje co robimy, aby w łatwy sposób dostać się do poszczególnych bajtów jakiegoś większego typu. Tutaj używamy do tego struktury rgba, która wie, w jaki sposób ułożone są bajty w obiekcie as_int.

Kolejny przykład: czytanie ze strumieni. Czytając kolejne bity i bajty ze strumieni, wiemy, że stanowią one reprezentację, którą możemy interpretować poprzez obiekt jakiegoś typu T. Koniec końców, kodem wynikowym naszej pracy jest:

struct T{
  // ...
};

T process_element(Stream& s){
  alignas(T) unsigned char buff[sizeof(T)];
  read_stream(s, buff);

  auto* element = reinterpret_cast<T*>(buff);
  return *element;
}

Czytamy więc dane ze strumienia i zapisujemy je do bufora. Następnie rzutujemy bufor na inny typ i voila… niezdefiniowane zachowanie.

Prawdopodobnie domyślasz się, że takie postępowanie skutkuje niezdefiniowanym zachowaniem, bo znów próbujemy dostać się do obiektu, który nigdy nie został utworzony. Próba poprawienia kodu może zakończyć się następująco:

struct T{ // POD
  // ...
};

T process_element(Stream& s){
  alignas(T) char buff[sizeof(T)];
  read_stream(s, buff);

  T* element = new(buff) T;
  return *element;
}

Tym razem tworzysz obiekt, do którego chcesz się dostać, ale to nadal na nic. Wciąż nasz program zawiera niezdefiniowane zachowanie.

Co tym razem poszło nie tak? Brak obiektu nie jest już dłużej problemem naszej aplikacji, ale przyjrzyjmy się jej wartości. Wartość, jak wspomnieliśmy na samym początku jest cechą obiektu. W tym wypadku czytaliśmy wartość do obiektu buff, który to jest tablicą. Wyrażenie placement new, powoduje ponowne użycie magazynu, które to niszczy tablicę buff. Tak zniszczona tablica traci swoją wartość. W jej miejsce powstaje nowy obiekt T. T jest typem POD (ang. Plain Old Data), a więc takie utworzenie obiektu pozostawia go niezainicjalizowanym. Następuje odczyt wartości obiektu, która jest nieustalona, co skutkuje niezdefiniowanym zachowaniem.

Przykłady rozwiązań popularnych błędów

W ostatniej sekcji widzieliśmy częste przypadki błędów, czas zastanowić się, jak je rozwiązać.

Type-punning i unia

Powodem, dla którego unie są w języku C++, to możliwość optymalizacji użytej pamięci, jeżeli wiemy, że w każdym momencie możliwe jest istnienie wyłącznie jednego typu obiektu z podanego zbioru. Użycie unii takie, jak oferuje to typ std::variant jest zgodne z jej przeznaczeniem. Type-punning natomiast zgodny z przeznaczeniem unii nie jest.

To, co chcieliśmy osiągnąć poprzez tworzenie unii z typu rgba oraz uint32_t, to możliwość łatwego interpretowania bajtów wrtości typu uint32_t. Możemy osiągnąc to samo, poprzez stworzenie odpowiedniej abstrakcji:

struct color{
  uint8_t red(){
    return as_int>>(8*3);
  }

  void red(const uint8_t value){
    const uint32_t red32 = static_cast<uint32_t>(red()) << (8*3);
    as_int ^= red32; // wyczyść czerwony bajt w as_int
    as_int |= (static_cast<uint32_t>(value) << (8*3)); // ustaw czerwony bajt do as_int
  }

  // pozostałe funkcje

  uint32_t as_int;
};

W tak utworzonej strukturze, mamy dwie przeładowane funkcje red, które to służą do zapisu oraz odczytu bajtu odpowiedzialnego za kolor czerwony w naszym kolorze reprezentowanym przez uint32_t.

funkcja odczytująca wartość koloru czerwonego przesuwa wartość as_int o odpowiednią ilość miejsc w prawo oraz rzutując wartość na typ uint8_t.

Druga funkcja natomiast ustawia wartość koloru czerwonego w zmiennej as_int. Takie podejście pomimo tego, że jest nieco trudniejsze do zrealizowania ma swoje zalety:

  • nie powoduje niezdefiniowanych zachowań w programie,
  • jest niezależne od architektury (endianness nie ma znaczenia)

Jeżeli chodzi o wydajność takiej implementacji, to jest ona (przy włączonych optymalizacjach kompilatora) taka sama jak w przypadku poprzedniej implementacji (zakładając, że kompilator nie używa niezdefiniowanego zachowania w żaden sposób).

Cała funkcja ustawiania koloru czerwonego jest przez kompilator zredukowana do jednej instrukcji:

  strb    r1, [r0, #3];

oczywiście reszta funkcji może być zaimplementowana analogicznie do funkcji red, a więc pominiemy resztę implementacji dla naszej wygody.

Odczyt ze strumienia

Problem z niezdefiniowanym zachowaniem mieliśmy również przy odczycie ze strumieni. Dla przypomnienia, następujący kod powodował niezdefiniowane zachowanie

struct T{
  // ...
};

T process_element(Stream& s){
  alignas(T) unsigned char buff[sizeof(T)];
  read_stream(s, buff);

  auto* element = reinterpret_cast<T*>(buff);
  return *element;
}

Również wersja programu z wyrażeniem placement new nie była poprawna.

Naszym ratunkiem będą tutaj typy trywialnie kopiowalne. Jeżeli tylko nasz typ T jest trywialnie kopiowalny, to jego wartość możemy ustawić poprzez zwykłe kopiowanie poszczególnych jego bajtów. Poprawna wersja programu wygląda następująco:

struct T{
  // ...
};
static_assert(std::is_trivially_copyable_v<T>);

T process_element(Stream& s){
  alignas(T) unsigned char buff[sizeof(T)];
  read_stream(s, buff);

  T read_element;
  std::memcpy(&read_element, buff, sizeof(T));
  return read_element;
}

skopiowanie wartości bajtów z bufora do naszego obiektu read_element, nada mu odpowiednią wartość, którą następnie możemy zwrócić.

Od C++20 implementacja takiej funkcji może być jeszcze prostsza, ponieważ dostajemy następne narzędzie - std::bit_cast.

Co ono robi? Dokładnie to samo, co my w powyższym przykładzie, ale ma kilka zalet:

  • funkcja ta jest constexpr,
  • wywołanie funkcji jest prvalue, co umożliwia optymalizację RVO (ang. Return Value Optimization)

Wraz z użyciem std::bit_cast nasza funkcja może wyglądać następująco:

T process_element(Stream& s){
  alignas(T) unsigned char buff[sizeof(T)];
  read_stream(s, buff);
  return std::bit_cast<T>(buff);
}

Możemy tę funkcję zaimplementować na jeszcze jeden sposób:

T process_element(Stream& s){
  T element;
  read_stream(s, reinterpret_cast<unsigned char*>(&element));
  return element;
}

Możemy to zrobić, ponieważ C++ ma specjalne reguły odnośnie odczytywania reprezentacji obiektów jako typ char, unsigned char oraz std::byte - możemy w ten sposób odczytywać wartość każdego obiektu - optymalizacja TBAA jest wyłączona, jeżeli używamy tych typów.

std::launder, wskaźniki oraz ich wartości

Jeżeli jesteś w miarę na bieżąco z nowinkami C++, zwłaszcza jeśli chodzi o C++17, to zapewne słyszałeś o std::launder. Według mnie ciężko jest zrozumieć, co dokładnie robi std::launder, bez uprzedniego zrozumienia wskaźników. Wyjaśnijmy sobie zatem jak w C++ działają wskaźniki i czym tak naprawdę one są. Umówmy się również, że będziemy mówić o modelu relaxed pointer safety (luźne bezpieczeństwo wskaźników), które to jest zaimplementowane w popularnych kompilatorach (gcc, clang, msvc). Istnieją również inne modele wskaźników (strict pointer safety), których implementacja pozwala implementacjom na używanie Garbage Collectora.

Spójrzmy na przykład, który motywuje do wprowadzenia std::launder:

alignas(T) char buff[sizeof(T)];
new(buff) T;
T* ptr = reinterpret_cast<T*>(buff);
use_object_under(ptr);

Niespodzianką zapewne jest, że ten przykład zawiera… a jakże - niezdefiniowane zachowanie. I nie jest to spowodowane żadnymi regułami omówionymi do tej pory. Powodem, dla którego przykład ma niezdefiniowane zachowanie, jest fakt, że ptr, który zawiera adres nowo utworzonego obiektu typu T, nie wskazuje na obiekt pod tym adresem.

Wiem, że brzmi to dziwnie, ale tak właśnie działają wskaźniki w C++. Przyjrzyjmy się im zatem:

Wartość wskaźnika zawsze można zakwalifikować do jednej z czterech kategorii:

  • wskaźnik na obiekt (który ma zbiór wartości i ich reprezentacji),
  • wskaźnik za obiekt (również ma zbiór wartości i ich reprezentacji),
  • null pointer (jedna wartość, jedna reprezentacja),
  • niepoprawny wskaźnik (jedna wartość wiele reprezentacji)

Wyjaśnijmy jeszcze sobie czym jest reprezentacja obiektu, reprezentacja wartości i wartość. Wartość jest logiczna i abstrakcyjna, np. 5 jest wartością, nie może ona być zmieniona (5 nie może nagle znaczyć 4. 5 zawsze będzie 5). Obiekty mają wartość, co znaczy, że możemy mieć obiekt typu int, który ma wartość 5. Wartość obiektów zazwyczaj nie jest stała i możemy ją zmieniać i tak zmienna int x; może mieć wartość 5 lub 4. Każdy obiekt ma jakąś reprezentację w pamięci ta reprezentacja nazywana jest reprezentacją obiektu. Ta część reprezentacji obiektu, która wpływa na wartość obiektu jest nazywana reprezentacją wartości. Aby pomóc odróżnić reprezentację obiektu od reprezentacji wartości wyobraźmy sobie strukturę z paddingiem. Padding jest częścią reprezentacji obiektu, ale nie ma znaczenia dla wartości obiektu. Dlatego padding nie należy do reprezentacji wartości.

Wskaźnik na obiekt jest zbiorem różnych wartości, ponieważ wskaźnik może wskazywać na różne obiekty. Reprezentacją takiego obiektu jest adres obiektu na który wskazuje, ponieważ wskaźnik nie ma paddingu, to reprezentacja obiektu jest równa reprezentacji wartości. Dokładnie tak samo jest w przypadku wskaźników za obiekt.

Null pointer jest pojedynczą wartością z pojedynczą reprezentacją. Każdy null pointer tego samego typu jest równy innemu null pointerowi tego samego typu.

Zostały nam jeszcze niepoprawne wskaźniki. Są to takie wskaźniki, które nie wskazują na obiekt. Jest to jedna wartość, która ma wiele reprezentacji. Różne niepoprawne wskaźniki mogą, ale nie muszą być sobie równe. Jeżeli wydaje się to dziwne, to bardzo podobny przypadek możemy dostrzec w wartościach zmiennoprzecinkowych - jest jedna wartość NaN, ale ma ona wiele reprezentacji.

Najciekawsze jednak jest to, że reprezentacje wartości wskaźników na obiekt i wskaźników niepoprawnych pokrywają się. Co znaczy, że w danej chwili możemy miec dwa wskaźniki różnej wartości, ale o tej samej reprezentacji owej wartości. Jest to wyjątek od ogólnej reguły, gdzie zmiana wartości implikuje zmianę reprezentacji wartości. Możemy więc zmienić wartość wskaźnika bez zmiany reprezentacji tej wartości. Jak to zrobić? Właśnie poprzez std::launder.

Znaczy to też tyle, że prawdziwą wartość wskaźnika zna wyłącznie kompilator. Jeżeli kompilator nie jest w stanie wydedukować z kontekstu jaka jest prawdziwa wartość wskaźnika, znaczy to, że kompilator musi założyć, że w programie nie popełniono żadnego niezdefiniowanego zachowania.

Wskaźniki są również typami trywialnymi, co znaczy, że ich wartość może być skopiowana tam i z powrotem a ich wartość będzie zachowana. Spójrzmy na przykład:

T t;
T* tptr = &t; // tptr wartość: wskaźnik na t
X* xptr = reinterpret_cast<X*>(tptr); // xptr wartość: niepoprawny wskaźnik
tptr = reinterpret_cast<T*>(xptr); // tptr wartość: wskaźnik na t

tptr na sam koniec nadal ma tę samą wartość, właśnie dzięki temu, że wskaźniki są typami trywialnymi. Xptr mimo, że miał wartość niepoprawny wskaźnik, to skopiowanie tej wartości z powrotem to obiektu typu T* sprawiło, że tptr nadal wskazywał na t.

std::launder

Deklaracja funkcji szablonowej wygląda następująco:

template <class T>
constexpr T* launder(T* p) noexcept;

i jej opis z cppreference:

Obtains a pointer to the object located at the address represented by p.

Bez poprzedniego wprowadzenia nie byłoby możliwym, żeby zrozumieć taki opis tej funkcji. Kluczem do zrozumienia jest

pointer to the object

który mówi, że jeżeli p jest niepoprawnym wskaźnikiem, ale jego reprezentacja wartości to adres do miejsca w pamięci, gdzie znajduje się obiekt typu T, to w wyniku dostaniemy wskaźnik na ten obiekt.

Możemy teraz naprawić nasz przykład:

alignas(T) char buff[sizeof(T)];
new(buff) T; // buff kończy życie
T* ptr = reinterpret_cast<T*>(buff); // niepoprawny wskaźnik zrzutowany na niepoprawny wskaźnik.
use_object_under(std::launder(ptr)); // OK! std::launder zwraca wskaźnik na obiekt - tutaj nowo utworzony obiekt typu T.

Przed C++17 nie mieliśmy możliwości, żeby uniknąć tego typu niezdefiniowanego zachowania, ale kompilatory były na tyle miłe, że nie robiły nam z tego powodu żadnych utrudnień (dawały w praktyce zdefiniowane zachowanie - takie jakiego byśmy się spodziewali).

Jest jeszcze jeden przypadek, gdzie std::launder może okazać się użyteczny. Jest to “przypisanie bez operatora przypisania”.

Wyobraźmy sobie następujący przypadek, zakładając, że typ T nie jest przypisywalny:

T a;
T b;
if(condition){
  // chcę przypisać b do a.
  new(a) auto(b);
}

Niestety ponieważ nie mamy operatora przypisania, decydujemy się na stworzenie nowego obiektu w miejscu poprzedniego.

Pytanie brzmi, czy po takim wyrażeniu new, możemy używać starej nazwy obiektu a, i wszystkich referencji oraz wskaźników wskazujących na ten obiekt, żeby odwoływać się do nowo utworzonego obiektu?

Odpowiedź brzmi to zależy. Istnieje zasada w C++ mówiąca o tym, że jeżeli nowo utworzony obiekt jest tego samego typu, co stary, to wszystkie referencje, wskaźniki oraz stara nazwa zmiennej zacznie odnosić się do nowo utworzonego obiektu, ale zasada ta ma pewne ograniczenie:

Przed C++17 zasada ta ma zastosowanie tylko wtedy, kiedy typ T nie zawiera żadnego const‘owego niestatycznego membera oraz nie zawiera żadnej referencji (w C++20 te ograniczenia zostały zniesione).

Nowa nadzieja - niejawne tworzenie obiektów

Do C++20 ma zostać dodana nowa funkcjonalność - niejawne tworzenie obiektów. W skrócie funkcjonalność ta ma polegać na tym, że pewne operacje w C++ będą dodatkowo tworzyć obiekty, jeżeli takie utworzenie spowodowałoby, że program zacznie mieć zdefiniowane zachowanie. Jak ma to działać?

Spójrzmy na wycinek kodu napisanego w języku C:

struct T{};
//...
struct T*ptr = (struct T*)malloc(sizeof(struct T);

użycie wskaźnika ptr w celu dostania się do obiektu typu T byłoby w C++ niezdefiniowanym zachowaniem (w przeciwieństwie do języka C). Po adopcji wspomnianej funkcjonalności powyższy kawałek kodu będzie zachowywać się dokładnie tak samo jak w języku C.

Ciekawostką może być fakt, że w obecnym C++ mamy już jeden przypadkek, w którym to następuje niejawne tworzenie obiektu. Jest to przypadek trywialnego, domyślnego operatora przypisania do pola unii. Spójrzmy na następujący przypadek:

struct T{
  int a;
  char b;
};

union U{
  T first;
  int second;
};

U u;
u.first = T{1,2};  // 1
u.second = 5;    // 2

Z poznanych dotychczas zasad, możnaby wywnioskować, że zarówno #1, jak i #2 są niezdefiniowanymi zachowaniami, ponieważ nigdy nie utworzyliśmy obiektów tego typu. Tak się jednak nie dzieje z powodu wcześniej wspomnianej funkcjonalności. Jak to działa? Za każdym razem, kiedy próbujemy coś przypisać do składowej unii, a operator przypisania jest trywialny, wtedy kompilator niejako za nas najpierw utworzy nam obiekt, do którego przypisujemy.

Zachowanie to będzie można zaobserwować na większej ilości operacji. Od C++20 będą to:

  • funkcje malloc oraz podobne
  • operator new
  • std::allocator<T>::allocate
  • std::memcpy oraz std::memmove
  • tworzenie tablic typu char, unsigned char oraz std::byte

W praktyce oznacza to, że przypadki, w których musimy używać std::launder będą bardzo rzadkie.

Podsumowanie

Jeżeli jest coś, co chciałbym żebyście na pewno zapamiętali po czytaniu tego posta, to są to:

  • Myśl zawsze o obiektach w kotekście ich typów oraz wartości, a nie zajmowanej przez nich pamięci
  • Nie próbuj type-punning’u w C++ - jest to bardzo prawdopodobne, że coś pójdzie nie tak
  • Jeżeli używasz obiektu poza jego czasem życia - bądź bardzo ostrożny

Bibliografia



Dawid Pilarski

Software Developer w TomTom. Absolwent Politechniki Łódzkiej kierunku Automatyka i Robotyka. Członek komitetu standaryzacyjnego C++, blogger, prywatny nauczyciel C++.

Blog Dawida
Profil na LinkedIn


Podobne wpisy


Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.
Polityka Prywatności