A nyers tároló és a benne élő objektum a C++ objektummodell két külön
fogalma. A gyakorlatban mégis gyakran mosódik össze a kettő: puffert
foglalunk, reinterpret_cast-tal „objektumot csinálunk”,
majd a tárolót újra felhasználjuk. A C++20 szabályai szerint azonban a
byte-ok önmagukban nem alkotnak objektumot; az objektum élettartamának
el kell kezdődnie, és a tároló újrafelhasználásakor a régi pointer,
referencia vagy név csak meghatározott feltételek mellett érvényes
tovább. Ez a rész ezeket a feltételeket tárgyalja. Fontos határ: a C
effective type szabályait nem emeljük át a C++-ba; a C++
saját objektum-élettartam-szabályai érvényesek.
Tárolón (storage) itt egy byte-okból álló, címezhető területet
értünk. Objektum a C++ memóriamodell értelmében egy típussal rendelkező
entitás, amelynek élettartama van. A tároló lehet egy
std::byte tömb, egy operator new által adott
terület, vagy egy malloc eredménye. Az objektum viszont
akkor jön létre, amikor a megfelelő típusú konstrukció lezajlik, vagy
amikor egy implicit-lifetime típus esetében a szabvány által megnevezett
művelet implicit módon elindítja az élettartamát.
A két fogalom szétválasztása azért kritikus, mert a tároló mérete és
igazítása még nem garantálja, hogy ott használható objektum van. Ha
például egy std::string méretű byte-pufferre
reinterpret_cast<std::string*>-tal mutatunk, és
meghívjuk a size() tagfüggvényt, az nem „kicsit hibás”,
hanem undefined behavior (UB): nincs ott std::string
objektum, amelynek állapota értelmezhető lenne. Ugyanígy a
malloc(sizeof(T)) eredménye önmagában még nem
T objektum, ha a T nem implicit-lifetime
típus.
Az implicit-lifetime típusok köre a szabványban pontosan definiált:
ide tartoznak a skalár típusok, a megfelelő aggregátumok, valamint azok
az osztálytípusok, amelyek legalább egy triviális „eligible”
konstruktorral és triviális, nem törölt destruktorral rendelkeznek. A
std::string nem ilyen, a
struct Point { int x; int y; }; viszont tipikusan igen.
C++23-ban a std::is_implicit_lifetime trait segíthet, de a
library-támogatást a konkrét fordítónál ellenőrizni kell.
Egy T típusú objektum élettartama akkor kezdődik, amikor
a megfelelő méretű és igazítású tároló rendelkezésre áll, és az
inicializálás — az üres, úgynevezett vacuous initialization is —
befejeződött. Triviális alapértelmezett konstruktor esetén ez
gyakorlatilag azt jelenti, hogy a tároló megszerzése után az élettartam
elkezdődik; nem triviális konstruktor esetén a konstruktor lefutása
után.
Az élettartam vége C++20 szerint ([C++20] [basic.life]/1) a típus fajtájától függ, nem a destruktor trivialitásától:
int) az
élettartam véget ér, amikor az objektumot megsemmisítik (C++20-ban a
pszeudo-destruktorhívás, így a std::destroy_at(int*) is
ilyen), illetve a tároló felszabadításakor vagy
újrafelhasználásakor.A triviális destruktor tehát költség szempontjából semmi
(nem generál kódot), szemantikailag viszont ugyanúgy lezárja az
élettartamot. A std::destroy_at(p) után két dolog marad
külön: (1) a tároló és a pointer korlátozottan tovább
használható — a p értéke void*-ként átadható,
a tároló újra felhasználható egy új objektum számára, és ki lehet rá
szabadítani; (2) a régi objektum tagjai viszont már
nem: a p->x olvasása, nem statikus tagfüggvény hívása
vagy a p leszármazott→bázis konverziója nem definiált
viselkedés ([basic.life]/6). A tároló újrafelhasználása destruktorhívás
nélkül triviális destruktornál szabályos; nem triviális destruktorú
típusnál a destruktor elmaradása önmagában nem UB, de a C++20 szövege
szerint az a program, amely a destruktor mellékhatásaira (felszabadítás,
flush) épít, nem definiált viselkedésű.
Ha az új objektum pontosan ugyanabba a tárolóba kerül, mint a régi,
felmerül a „transparently replaceable” (átlátszóan helyettesíthető)
kérdése. A C++20 szabvány ([C++20] [basic.life]/8) szerint a régi
pointer, referencia vagy név automatikusan az új objektumra utal, ha az
eredeti o1 objektumot az új o2
átlátszóan helyettesíti, azaz:
o2 tárolója pontosan lefedi az o1
tárolóját;o1 és az o2 típusa a legfelső szintű
cv-minősítéstől eltekintve azonos;o1 nem teljes const objektum;o1, sem az o2 nem potenciálisan
átfedő részobjektum (bázisosztály-részobjektum vagy
[[no_unique_address]] tag);p1 és
p2 objektumok közvetlen részobjektumai, és a
p1-et a p2 átlátszóan helyettesíti.A const vagy referencia típusú nem statikus adattag
nem zárja ki a helyettesítést. A C++17 szövege még
kizárta, de ezt a C++20 előtt elfogadott javítás megszüntette, és a
C++20 szövegben már nem szerepel. Ha a feltételek nem teljesülnek, a
régi pointerrel való hozzáférés UB; ilyenkor jön szóba a
std::launder.
A placement new egy létező tárolóban hívja meg a konstruktort:
T* p = ::new (static_cast<void*>(storage)) T(args...);Ehhez a <new> fejléc kell. A placement new nem
foglal memóriát; a tárolóért és annak élettartamáért a hívó felel. Nem
triviális destruktor esetén a destruktort explicit módon kell meghívni,
például p->~T(); vagy C++17-től
std::destroy_at(p); formában.
C++20-tól a std::construct_at és a
std::destroy_at kényelmesebb, constexpr-képesebb
alternatívát ad. A std::construct_at a megadott
T* helyen konstruál, és visszaadja az új objektum
pointerét; a std::destroy_at meghívja a destruktort. A
következő példa egy std::string objektumot kétszer hoz
létre ugyanabban a puffban:
// storage_reuse.cpp
#include <cstddef>
#include <iostream>
#include <memory>
#include <string>
int main() {
alignas(std::string) std::byte storage[sizeof(std::string)];
std::string* s = std::construct_at(
reinterpret_cast<std::string*>(storage), "első");
std::cout << *s << '\n';
std::destroy_at(s);
s = std::construct_at(
reinterpret_cast<std::string*>(storage), "második");
std::cout << *s << '\n';
std::destroy_at(s);
}Fordítási példa:
g++ -std=c++20 -Wall -Wextra -pedantic \
-fsanitize=address,undefined storage_reuse.cpp -o storage_reuseBuildjegyzőkönyv (lab-gcc profil). A
fenti ASan/UBSan build kimenete első, majd
második, diagnosztika nélkül, 0 kilépési kóddal.
A példa a második konstrukció után a construct_at
visszatérési értékét menti el az s-be. Ez
egyértelmű és általánosan használható gyakorlat, nem
ebben a példában fennálló kényszer: a construct_at
eredménye mindig az új objektumra mutat, ezért std::launder
nélkül is biztonságos, bármilyen típust és tárolót használunk. Ebben a
konkrét esetben azonban a régi s sem lenne hibás: az új
std::string ugyanazt a tárolót pontosan lefedi, azonos
típusú, nem const teljes objektum, tehát a régit
átlátszóan helyettesíti (lásd fent, [basic.life]/8), és
a régi pointer automatikusan az új objektumra utal. A visszaadott
pointer elmentése azért jobb szokás, mert akkor is helyes marad, ha a
feltételek valamelyike nem teljesül — például más típust építünk a
tárolóba, vagy a régi objektum const volt.
C++17-es fallbackként a placement new és az explicit destruktorhívás használható:
std::string* s = ::new (static_cast<void*>(storage)) std::string("első");
// ...
s->~std::string();Egy T objektum elhelyezéséhez a tárolónak legalább
sizeof(T) byte méretűnek és legalább
alignof(T) igazításúnak kell lennie. Ha ezek bármelyike nem
teljesül, a placement new és az azt követő használat UB. A gyakori
hiba:
char buf[sizeof(T)]; // lehet, hogy nincs T-hez igazítva
T* p = ::new (buf) T{}; // UB lehetA helyes alak például:
alignas(T) std::byte storage[sizeof(T)];A std::aligned_storage C++23-ban deprecated; helyette az
alignas(T) és a std::byte tömb a javasolt
egyszerű minta. Több objektum esetén a méret N * sizeof(T),
az igazítás továbbra is alignof(T); a tömb elemeit azonban
külön kell konstruálni és destruálni.
Fontos az over-aligned típusok kezelése. A hagyományos
operator new csak a fundamentális igazításra garantál
megfelelő tárolót; ha alignof(T) ennél nagyobb, C++17-től
az aligned operator new, vagy platformon
std::aligned_alloc használható. A malloc
eredménye szintén nem garantált over-aligned típusokhoz.
Egy objektum objektumreprezentációja sizeof(T) byte-ból
áll, amely tartalmazhat paddinget is. Ha T trivially
copyable, a byte-ok másolása megőrzi az értéket, feltéve, hogy a
célobjektum létezik, és a másolás a megfelelő módon történik. Erre való
a std::memcpy vagy C++20-tól a std::bit_cast;
utóbbi azonos méretű, trivially copyable típusok között használható.
#include <cstdint>
#include <cstring>
#include <type_traits>
struct Header {
std::uint32_t magic;
std::uint16_t version;
};
static_assert(std::is_trivially_copyable_v<Header>);
Header h{0x4d5a, 1};
std::byte bytes[sizeof(Header)];
std::memcpy(bytes, &h, sizeof h);
Header h2;
std::memcpy(&h2, bytes, sizeof h2);Ez a minta akkor helyes, ha a Header valóban trivially
copyable, a h2 objektum már létezik, és a byte-sorrendet a
protokoll külön kezeli. A std::string nem trivially
copyable; a memcpy vele UB, mert a belső pointer és
allokáció kétszeresen lenne tulajdonolva.
C-oldali kontrasztként a malloc és memcpy
utáni effective type szabály C-ben más:
/* c_effective_type.c */
#include <stdlib.h>
#include <string.h>
int main(void) {
void *p = malloc(sizeof(int));
if (!p) return 1;
int x = 42;
memcpy(p, &x, sizeof x); /* C: itt az effective type int lesz */
int y = *(int *)p;
free(p);
return y == 42 ? 0 : 1;
}gcc -std=c17 -Wall -Wextra -pedantic c_effective_type.c -o c_effective_typeC++-ban ez a magyarázat önmagában nem elég. C++20-ban (P0593, a
korábbi szabványokra is visszamenőleges hibajavításként) bizonyos
műveletek implicit módon létrehozhatnak
implicit-lifetime típusú objektumokat a tárolóban, ha ez a programnak
definiált viselkedést ad: a
malloc/calloc/realloc, az
operator new/operator new[], a
std::allocator<T>::allocate, egy
unsigned char vagy std::byte tömb
élettartamának kezdete, valamint a memcpy és a
memmove a céltárolóban ([C++20] [intro.object]/10–13). A
fenti C-s minta (malloc, majd memcpy egy
int-ből) ezért C++20-ban is jól definiált, ha az
int olvasása előtt a tároló egy int objektumot
tartalmaz — ezt az implicit létrehozás biztosítja. A nem
implicit-lifetime típusokhoz (például std::string) viszont
továbbra is explicit konstrukció kell. A C effective type
szabályait tehát nem szabad a C++ objektummodell helyettesítőjeként
használni.
A std::launder egy szűk, speciális eszköz.
Preconditionja nagyjából a következő: a pointer egy olyan címre mutat,
ahol egy T típusú objektum él, az objektum élettartama
tart, és az eredményen keresztül elérhető minden byte a bemeneti
pointeren keresztül is elérhető. A std::launder nem hoz
létre objektumot, nem igazít tárolót, és nem aktivál union tagot; csak
egy létező objektumra ad pointert olyan esetben, amikor a régi pointer
nem feltétlenül arra mutat.
Először egy ellenpélda: a const tagot tartalmazó típus
C++20-ban átlátszóan helyettesíthető, ezért itt nincs
szükség std::launder-re.
// transparent_const_member.cpp — C++20: a const tag nem akadálya
#include <iostream>
#include <new>
struct X {
const int n;
};
int main() {
alignas(X) unsigned char storage[sizeof(X)];
X* p = ::new (static_cast<void*>(storage)) X{1};
std::cout << p->n << '\n';
::new (static_cast<void*>(storage)) X{2}; // a régi X élettartama véget ér
std::cout << p->n << '\n'; // szabályos: p az új X-re utal
}Mind az öt feltétel teljesül: az új X pontosan ugyanazt
a tárolót foglalja el; a típusok azonosak; a régi X nem
teljes const objektum (csak a tagja const);
egyik sem részobjektum, mindkettő teljes objektum. A második
p->n ezért szabályosan 2.
Valóban std::launder-t igényel viszont, ha a régi
objektum teljes const objektum volt.
Dinamikus tárolásnál a helyére szabad új objektumot létrehozni;
statikus, szál vagy automatikus tárolású const objektumnál
ez maga is UB lenne ([basic.life]/10).
// launder_demo.cpp — C++20: teljes const objektum helyére épített új objektum
#include <iostream>
#include <new>
struct X {
int n;
};
int main() {
const X* p = new const X{1}; // o1: teljes const objektum, dinamikus tárolás
std::cout << p->n << '\n';
::new (const_cast<X*>(p)) X{2}; // o2 ugyanott; o1 élettartama véget ér
// int bad = p->n; // UB: o1 teljes const objektum volt
std::cout << std::launder(p)->n << '\n'; // az o2-re mutat
delete std::launder(p);
}A levezetés a [basic.life]/8 feltételeivel: (1) az új X
pontosan lefedi a régit; (2) a típusok a legfelső szintű
const-tól eltekintve azonosak; (3) nem
teljesül: az o1 teljes const objektum
volt, ezért a régi p nem utal automatikusan az új
objektumra, és a p->n UB lenne; (4) és (5) teljesül. A
std::launder(p) előfeltételei ([ptr.launder]) teljesülnek:
a p címén egy élettartamában lévő X objektum
van (az o2), és az eredményen keresztül csak olyan bájtok
érhetők el, amelyek a p-n keresztül is. A
launder tehát az új objektumra ad pointert; a
delete is ezen keresztül történik. Ugyanígy
launder kellene akkor is, ha egy bázisosztály-részobjektum
helyére hoznánk létre új objektumot (4. feltétel).
Buildjegyzőkönyv (lab-gcc profil).
Mindkét program g++ -std=c++20 -Wall -Wextra -Wpedantic
beállítással figyelmeztetés nélkül fordult, és 1,
2 kimenettel futott, ASan/UBSan builddel is.
A unionban egyszerre legfeljebb egy tag aktív. Az aktív tag élettartama akkor kezdődik, amikor azzá válik, és akkor ér véget, amikor egy másik tag lesz aktív, vagy a union objektum élettartama véget ér. Nem triviális tag esetén a tagot konstruálni és destruálni kell; a puszta byte-csere nem elég. Az inaktív tag olvasása UB, kivéve a standard-layout unionok közös kezdősorozatára (common initial sequence) vonatkozó szűk kivételt.
union U {
int i;
float f;
};
void f() {
U u;
u.i = 42; // i aktív
// float x = u.f; // UB: f nem aktív
u.f = 3.14f; // most f aktív; i élettartama véget ért
}A std::launder nem teszi aktívvá az inaktív union tagot;
erre a std::variant vagy egy explicit aktív-tag állapotgép
a helyes megoldás.
A típuselérhetőség (type accessibility) ettől külön kérdés. C++-ban
egy objektum tárolt értékét csak a szabvány által engedett típusú
glvalue-n keresztül lehet elérni: az objektum dinamikus típusa, annak
cv-változata, hasonló típus, a megfelelő signed/unsigned pár, az
objektumot tartalmazó aggregate/union, bázisosztály, illetve
char, unsigned char vagy
std::byte. A char/std::byte nézet
mindig megengedett byte-szintű vizsgálatra, de ettől még nem lesz a
pufferben T objektum.
reinterpret_cast<T*>(buf) utáni azonnali
dereferálás: még nincs T objektum.char buf[sizeof(T)] használata alignas(T)
nélkül: az igazítás nem garantált.destroy_at
elmaradása a tároló újrafelhasználása előtt.const
objektum vagy bázisosztály-részobjektum helyén),
std::launder nélkül.std::launder univerzális javításként való alkalmazása
olyan helyen, ahol nincs életben lévő objektum.memcpy használata std::string vagy más nem
trivially copyable típus esetén.std::launder
használata az aktív tag „kijelölésére”.effective type szabályainak átemelése a C++
objektum-élettartam helyére.#include <cstddef>
#include <string>
int main() {
std::byte buf[sizeof(std::string)];
auto* p = reinterpret_cast<std::string*>(buf);
p->assign("hello");
}Írj egy rövid programot, amely egy
alignas(std::string) std::byte puffert kétszer használ
std::string tárolására! A második konstrukcióhoz a
std::construct_at visszatérési értékét használd. Magyarázd
el, miért nem kell std::launder a visszakapott
pointeren!
Adott a transparent_const_member.cpp és a
launder_demo.cpp. Magyarázd el a [basic.life]/8
feltételeivel, miért szabályos az elsőben a második
p->n, és miért UB a másodikban a p->n,
miközben a std::launder(p)->n definiált! Sorolj fel két
olyan esetet, amikor a std::launder nem segít!
Az alábbi unionos kódban hol lehet UB? Javítsd
std::variant-tal, vagy explicit aktív-tag
követéssel!
union U {
int i;
float f;
};
int main() {
U u;
u.i = 42;
float x = u.f;
}buf nincs std::string-hez igazítva, és
nincs benne std::string objektum; a
p->assign ezért UB. Helyes minimal fix:#include <cstddef>
#include <memory>
#include <string>
int main() {
alignas(std::string) std::byte buf[sizeof(std::string)];
std::string* p = std::construct_at(
reinterpret_cast<std::string*>(buf), "hello");
std::destroy_at(p);
}Fordítás:
g++ -std=c++20 -Wall -Wextra -pedantic -fsanitize=address,undefined storage_fix.cpp -o storage_fix.
Ellenőrzési feltétel: a construct_at visszatérési értékét
használjuk, és a destruktor lefut.
#include <cstddef>
#include <iostream>
#include <memory>
#include <string>
int main() {
alignas(std::string) std::byte storage[sizeof(std::string)];
std::string* first = std::construct_at(
reinterpret_cast<std::string*>(storage), "első");
std::cout << *first << '\n';
std::destroy_at(first);
std::string* second = std::construct_at(
reinterpret_cast<std::string*>(storage), "második");
std::cout << *second << '\n';
std::destroy_at(second);
}A construct_at visszatérési értéke már az új objektumra
mutat, ezért nincs szükség std::launder-re. A
launder akkor kellhet, ha egy korábbi pointert szeretnénk
az új objektumra irányítani, és a transparently replaceable feltétel nem
teljesül. Ellenőrzési feltétel: a második konstrukcióhoz nem a
first pointert használjuk, és a destruktorok lefutnak.
A transparent_const_member.cpp-ben mind az öt
feltétel teljesül: a const tag nem akadály, csak a teljes
const objektum az; a régi p ezért az új
X-re utal, és a p->n értéke 2. A
launder_demo.cpp-ben a régi objektum teljes
const objektum volt, így a 3. feltétel sérül; a régi
p nem utal az új objektumra, a p->n UB. A
std::launder(p) az új objektumra ad pointert, mert a címen
élő X van, és a launder előfeltételei teljesülnek. A
std::launder nem segít, ha nincs életben lévő
T objektum a címen (például elmaradt a placement new), és
nem segít az inaktív union tag olvasásán sem; ilyenkor nem lehet
„launderrel” objektumot vagy aktív tagot varázsolni. Ellenőrzési
feltétel: a válasz megkülönbözteti az élettartam létrehozását a pointer
újrairányításától.
Az u.i = 42 után az i aktív, az
f olvasása UB. Egy lehetséges javítás
std::variant-tal:
#include <variant>
int main() {
std::variant<int, float> u;
u = 42;
float x = std::get<float>(u); // dob, mert int az aktív alternatíva
}A std::get<float> itt kivételt dob, mert nem az
float az aktív tag; ez már definiált viselkedés. Explicit
aktív-tag követéssel egy enum class Active { Int, Float };
és a megfelelő konstrukció/destrukció lenne a másik helyes irány.
Ellenőrzési feltétel: a megoldás nem olvassa az inaktív union tagot, és
kezeli a tagváltást.
A C++-ban a tároló és az objektum nem ugyanaz. Az objektum
élettartama a megfelelő méretű és igazítású tároló megszerzésével és az
inicializálás befejezésével kezdődik; nem triviális destruktor esetén a
destruktorhívás zárja le. A placement new, a
std::construct_at és a std::destroy_at teszi
lehetővé a tároló újrafelhasználását, de csak a méret, az igazítás és az
élettartam-szabályok betartásával. A std::launder szűk
eszköz: létező objektumra ad pointert, nem hoz létre objektumot. A
trivially copyable típusok byte-szinten másolhatók, a nem trivially
copyable típusoknál konstrukció kell. A union aktív tagjának szabályai
és a strict aliasing külön rétegek; a C effective type
szabályai nem helyettesítik a C++ objektummodellt.