20.6 Tároló újrafelhasználása és C++ objektumszabályok

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.

20.6.1 A tároló és az objektum szétválasztása

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.

20.6.2 Az élettartam kezdete és vége

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:

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:

  1. az o2 tárolója pontosan lefedi az o1 tárolóját;
  2. az o1 és az o2 típusa a legfelső szintű cv-minősítéstől eltekintve azonos;
  3. az o1 nem teljes const objektum;
  4. sem az o1, sem az o2 nem potenciálisan átfedő részobjektum (bázisosztály-részobjektum vagy [[no_unique_address]] tag);
  5. vagy mindkettő teljes objektum, vagy rendre a 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.

20.6.3 Placement new, construct_at és destroy_at

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_reuse

Buildjegyző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();

20.6.4 Alignment és storage size előfeltételek

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 lehet

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

20.6.5 Trivially copyable, byte copy és objektumreprezentáció

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_type

C++-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.

20.6.6 A std::launder szerepe

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.

20.6.7 Union aktív tag és típuselérhetőség

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.

20.6.8 Tipikus hibák

20.6.9 Feladatok

  1. Az alábbi kódban mi a hiba? Javítsd minimálisan, és add meg a fordítási parancsot!
#include <cstddef>
#include <string>

int main() {
    std::byte buf[sizeof(std::string)];
    auto* p = reinterpret_cast<std::string*>(buf);
    p->assign("hello");
}
  1. Í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!

  2. 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!

  3. 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;
}

20.6.9.1 Megoldások

  1. A 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.

  1. Példa:
#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.

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

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

20.6.10 Összefoglaló

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.