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.
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):
std::move(...) eredménye,
tag-hozzáférés egy rvalue-n, vagy T&&
visszatérés.T{}, f() ha T-t ad vissza
értékként, aritmetikai kifejezés.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.
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.
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:
const T& r = T{};,
T&& r = T{};),const T& r = S{}.member;), vagyA 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.
A leggyakoribb hibaforrások:
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; } // danglingMindkettő 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.
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űnikA 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.
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ágosC++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ó.
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 élA 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.
auto&& mindenre: az
auto&& nem varázsszer. Ha egy függvény referenciát
ad vissza, az auto&& sem hosszabbítja meg az
eredeti temporary-t.std::move túlhasználata: a
std::move nem hosszabbít semmit, csak kategóriakonverzió.
Visszaadott referenciát „megmenteni” vele nem lehet.for (auto x : v) másol, for (auto& x : v)
módosít; a temporary range-re mindkettő ugyanúgy dangling, ha a
range-initializer temporary-it nem hosszabbítja meg a szabály.initializer_list visszaadása: a
return {1,2,3}; egy std::initializer_list-t
adó függvényből tipikusan dangling; a helyes minta
std::array vagy std::vector visszaadása.-fsanitize=address
(ASan) a legtöbb ilyen hibát elkapja a használat pillanatában, de a
heapen túl nem mindig; a -fsanitize=undefined a
referencia-élettartamot nem feltétlenül ellenőrzi. A fordító warningjai
(GCC -Wdangling-reference, Clang -Wdangling)
és a kód review a fő eszköz.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.
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?
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.
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.