20.4 Temporary, value category és dangling

A temporary objektumok (ideiglenesek) és az értékkategóriák (value category) adják a modern C++ egyik legtöbb buktatót rejtő területét. A témát érdemes a nyelvi memóriamodell felől közelíteni: az értékkategória az kifejezés tulajdonsága, nem a típusé, és az ideiglenesek élettartamát pontosan meghatározott szabályok szerint hosszabbítja meg a fordító — de csak néhány, jól körülhatárolt helyzetben. Ez az alfejezet ezeket a szabályokat rögzíti; a string_view/span konkrét dangling-tünetei a későbbi, std::string, std::string_view és szövegownership (28.2) és span, byte és bounds-szerződések (31.3) részekben kerülnek elő, itt csak előkészítjük a mechanizmust.

20.4.1 Kifejezéstípus és értékkategória

A C++-ban minden kifejezésnek van egy statikus típusa és egy értékkategóriája. A típus a deklarációból és a környezetből következik; az értékkategória azt írja le, hogy a kifejezés milyen módon használható fel (azonosítható-e egy objektum, mozgatható-e, köthető-e rá referencia). A két fogalom független: std::string s; esetén s típusa std::string, kategóriája lvalue; std::move(s) típusa std::string&&, kategóriája xvalue; std::string{"x"} típusa std::string, kategóriája prvalue. Ugyanaz a típus tehát három kategóriában is megjelenhet, attól függően, hogyan jött létre a kifejezés.

A definíciók (C++20 alapján):

A gyakorlatban ezt a térképet érdemes fejben tartani:

Kifejezés Kategória Miért
x (változó) lvalue van azonosítója
*p, a[i] lvalue tagra mutató/helyettesítő
std::move(x) xvalue T&& cast
f() ahol T f() prvalue értéket inicializál
f() ahol T&& f() xvalue T&& visszatérés
x.m ahol x lvalue lvalue tag-hozzáférés megőrzi
T{}.m ahol T{}.m tag xvalue tag-hozzáférés rvalue-n
42, 1+2 prvalue számított érték

Fontos: egy változó neve mindig lvalue, még akkor is, ha a változó típusa T&&. Ez az alapja annak, hogy std::move egyáltalán létrejöhet: azért kell, hogy a nevesített változóból xvalue-t csináljunk.

20.4.2 Temporary materialization

C++17-től a modell „temporary materialization” néven ismert: ha egy prvalue-t olyan helyen használunk, ahol glvalue-t vár a nyelv (referencia-kötés, tag-hozzáférés, sizeof, feltételes operátor egyik ága), a fordító materializál egy ideiglenes objektumot, és a továbbiakban arra hivatkozunk. Ez a modell váltotta fel a korábbi, „mikor keletkezik temporary” kérdésre adott homályosabb választ. Gyakorlati következménye: a prvalue önmagában nem rendelkezik címmel, csak a materializált objektum. Ez teszi lehetővé a garantált copy elisiont (C++17): T x = T{}; esetén még egy mozgatás sem történik, mert a prvalue közvetlenül x-et inicializálja.

A materializálást a nyelv automatikusan végzi, nem kell rá kifejezést írni. A fontos pont az, hogy a materializált ideiglenes élettartama a full expression végéig tart, kivéve, ha a lifetime extension szabályai közbe lépnek.

20.4.3 Full expression és lifetime extension

Full expression (teljes kifejezés) az a legnagyobb kifejezés, amely nem része egy másik kifejezésnek; a ;-ig, if (...) zárójeléig, for (...) fejéig, return operandusáig tart. A materializált ideiglenesek alapértelmezés szerint a full expression végén pusztulnak el, fordított sorrendben a létrehozásukhoz képest.

A lifetime extension (élettartam-hosszabbítás) kivétel: ha egy referencia közvetlenül egy prvalue-ra kötődik, az ideiglenes élettartama a referencia élettartamáig hosszabbodik. A pontos szabály (C++20 [class.temporary]) szerint a hosszabbítás akkor érvényes, ha a referencia:

A hosszabbítás nem érvényes, ha a referencia egy függvény visszatérési értéke (const T& f() { return T{}; } — a temporary a return full expression végén megszűnik, a hívó egy dangling referenciát kap), ha függvényparaméterre kötődik (void g(const T&); g(T{}); — a temporary a hívás teljes kifejezésének végén megszűnik), ha egy függvényhíváson keresztül érjük el (S{}.getRef() — a hívás eredménye nem „örökli” a temporary élettartamát), vagy ha std::initializer_list-t adunk vissza függvényből.

A std::initializer_list külön eset: a {...} mögötti háttértömb egy temporary, amelynek élettartama pontosan úgy hosszabbodik, mintha referenciát kötnénk rá. Ezért auto il = {1, 2, 3}; biztonságos (a háttértömb il élettartamáig él), de return {1, 2, 3}; egy std::initializer_list-t visszaadó függvényből dangling: a háttértömb a return után megszűnik, a visszaadott lista egy halott tömbre mutat.

20.4.4 Visszaadott referencia, initializer_list és range-for csapdák

A leggyakoribb hibaforrások:

  1. Visszaadott referencia lokálisra vagy temporary-ra.

    const std::string& bad() { return std::string{"x"}; }   // dangling
    const std::string& bad2() { std::string s; return s; }   // dangling

    Mindkettő UB, amint a hívó használja a visszaadott referenciát. A -Wall -Wextra ezt gyakran jelzi; a -Wdangling-reference (GCC 13+) célzottabban szűr.

  2. Tag-hozzáférés függvényhíváson keresztül.

    struct Holder { std::string name; const std::string& ref() const { return name; } };
    const std::string& r = Holder{"x"}.ref();   // dangling: a Holder temporary a full expression végén megszűnik

    A Holder{"x"} temporary élettartama csak a full expression végéig tart, mert a ref() visszatérési értéke nem „örökli” a temporary élettartamát. Ez a string_view/span későbbi dangling-mintáinak előképe.

  3. Range-for és a range-initializer.

    struct H { std::vector<int> v; const std::vector<int>& items() const { return v; } };
    for (int x : H{}.items()) { /* ... */ }   // C++20: dangling; C++23: biztonságos

    C++20-ban a range-for desugaringja csak a __range referenciára kötött közvetlen temporary-t hosszabbítja meg; a H{} temporary a full expression végén megszűnik, és a ciklus egy halott vector-on iterál. A P2718R0 („Fix for Range-based for Loop”, P2644R1 nyomán) ezt Defect Report-ként javítja: a range-initializerben keletkező minden temporary élettartama a ciklus végéig tart. Ez a változás C++23-ban jelent meg, és DR-ként visszamenőleg a C++20-ra is alkalmazandó — de a tényleges támogatás fordítónként és standard library-verziónként eltér, ezért a felhasználónak ellenőriznie kell (feature-test, illetve teszteset). A korábbi C++20-as szöveg szerint ez a kód nem hordozható.

  4. Tag referencia-taggal (reference member) rendelkező osztályban.

    struct S { const std::string& r; };
    S s{ std::string{"x"} };   // r dangling: a temporary csak a full expression végéig él

    A reference tagot aggregátumban vagy konstruktorban kötni nem hosszabbítja meg a temporary-t; itt a string a S s{...} deklaráció full expressionjének végén megszűnik.

Ezek a minták mind azt illusztrálják, hogy a lifetime extension nem „ragadós”: nem öröklődik függvényhíváson, visszatérésen, reference tagon keresztül.

20.4.5 Tipikus hibák és javításuk

Az alábbi példa önállóan fordítható, és szemlélteti a hosszabbítás működését és elmaradását. A program a Probe destruktorában ír a kimenetre, így a létrehozás és megszűnés sorrendje megfigyelhető.

// lifetime_demo.cpp
// Fordítás: g++ -std=c++20 -Wall -Wextra -O0 -g lifetime_demo.cpp -o lifetime_demo
#include <cstdio>
#include <string>
#include <utility>

struct Probe {
    std::string name;
    explicit Probe(std::string n) : name(std::move(n)) {
        std::printf("ctor %s\n", name.c_str());
    }
    ~Probe() { std::printf("dtor %s\n", name.c_str()); }
    const std::string& ref() const { return name; }
};

Probe makeProbe() { return Probe{"made"}; }
const std::string& dangling() { return std::string{"temp"}; }

int main() {
    // 1) lifetime extension: az ideiglenes a referencia élettartamáig él
    const std::string& a = std::string{"extended"};
    std::printf("a=%s\n", a.c_str());

    // 2) extension elmarad: a temporary a full expression végén megszűnik
    const std::string& b = makeProbe().ref();   // dangling; itt már nem lenne szabad használni
    (void)b;

    // 3) közvetlen temporary-kötés a return után: dangling
    const std::string& c = dangling();          // dangling; használat UB
    (void)c;

    return 0;
}

A kimenet sorrendje (a Probe példányok esetében) a következő elvet követi: ctor és dtor párokban jelenik meg, kivéve, ahol a hosszabbítás megtörténik. A b és c esetén a temporary (made, illetve temp) destruktora a full expression végén fut le, mielőtt a program a következő utasításra lépne — ezt a kimenet sorrendje mutatja. A példa futtatása nem helyettesíti a -fsanitize=address alatti ellenőrzést: a dangling referencia használata UB, és a megfigyelt viselkedés nem garantált.

20.4.6 Feladatok

1. feladat (magyarázó). Az alábbi kifejezések közül melyik lvalue, xvalue, prvalue? Indokolj. x, std::move(x), x + 1, *p, f() ahol int f(), f() ahol int&& f(), x.m ahol x lvalue, std::move(x).m.

2. feladat (kódolási). Az alábbi kódrészlet danglinget tartalmaz. Írd át úgy, hogy ne legyen dangling, és indokold a választott javítást. Törekedj arra, hogy a szemantika (a hívó által látott érték) ne változzon.

const std::string& name_of(const std::string& prefix) {
    return prefix + "-suffix";
}

3. feladat (diagnosztikai). Tervezz olyan tesztkörnyezetet, amely képes megkülönböztetni a „C++20-as, P2718R0 nélküli” és a „C++23-as, P2718R0-val javított” range-for viselkedést. Milyen megfigyelést rögzítesz, és hogyan támasztod alá, hogy a két verzió között valóban eltér a viselkedés?

20.4.7 Megoldások

1. megoldás. x: lvalue (nevesített változó). std::move(x): xvalue (kategóriakonverzió T&&-ra). x + 1: prvalue (számított érték). *p: lvalue (a pointer által jelölt objektum). f() int f() esetén: prvalue. f() int&& f() esetén: xvalue (a visszatérési típus T&&, ez a kategória-konvenció szerint xvalue). x.m lvalue x esetén: lvalue (tag-hozzáférés megőrzi a kategóriát). std::move(x).m: xvalue (tag-hozzáférés rvalue-n). A döntő szabály: a tag-hozzáférés és a [] megőrzi az operandus kategóriáját; a std::move T&&-ra castol, ami xvalue-t eredményez; a literál és az aritmetikai művelet prvalue.

2. megoldás. A prefix + "-suffix" egy std::string prvalue; a visszaadott referencia a full expression végén dangling. Helyes javítások: (a) adjuk vissza értékként: std::string name_of(const std::string& prefix) { return prefix + "-suffix"; }; (b) ha a hívó csak olvassa, adjunk vissza std::string-et, és a hívó kösse const std::string&-hez — ez a hívó oldalán hosszabbít; (c) ha mindenképp referencia kell, a háttértárolónak a hívó élettartamáig kell élnie (pl. static cache, ami viszont szálbiztonsági és újrahasználati kérdéseket vet fel). Az (a) a legrobusztusabb: értékszemantika, nincs rejtett élettartam-függés. Ellenőrzés: -Wall -Wextra -Wdangling-reference (GCC) vagy -Wdangling (Clang) és -fsanitize=address alatt futtatva a javított kód nem jelez.

3. megoldás. Készíts két kis programot: az egyikben H{}.items()-et iterálunk for (int x : H{}.items()) formában, ahol H::items() const std::vector<int>&-t ad vissza. A Probe-hoz hasonló nyomkövetéssel (vagy a vector méretének és tartalmának kiírásával) rögzítjük, hogy a ciklus teste érvényes adatot lát-e, illetve az ASan jelez-e. C++20 + régi libstdc++ esetén a H{} temporary a full expression végén megszűnik, a ciklus dangling, az ASan heap-use-after-free-t jelez. C++23 + P2718R0-t támogató libstdc++/libc++ esetén a temporary a ciklus végéig él, az ASan nem jelez. A megfigyelést a -std=c++20 és -std=c++23 két buildjén, azonos forráson kell elvégezni, és a két eredményt rögzíteni — a különbség a verzióhelyes viselkedés bizonyítéka. A megoldás korlátja: a viselkedés függ a fordítótól és a libstdc++/libc++ verziójától, ezért a konkrét kiadást a laborprofilban rögzíteni kell.

20.4.8 Összefoglaló

A temporary objektumok élettartamát a full expression, a materializálás és a lifetime extension hármasa szabályozza. Az lvalue/xvalue/prvalue kategóriák a kifejezésre vonatkoznak, nem a típusra; a glvalue az azonosítható, az rvalue a mozgatható kifejezések halmaza. A hosszabbítás csak közvetlen referencia-kötésnél, tagra kötésnél és tömb-elemre kötésnél érvényes; függvény visszatérésén, függvényhíváson, reference tagon és std::initializer_list visszaadásán keresztül nem. A range-for esetében a C++20 és a C++23 (P2718R0 DR) között van eltérés, amelyet a fordító és a standard library tényleges verziója dönt el. A string_view/span dangling-minták ezekre a szabályokra épülnek; a következő részekben ezeket a konkrét típusok szerződésein keresztül vizsgáljuk tovább.