40.5 Érték, élettartam és kifejezések: auto(x), implicit move és range-for

A C++23 nyelvi változásainak egy része nem új szintaxist vezet be, hanem az élettartam- és értékkategória-szabályokat finomítja. Ez a rész négy ilyen változást tárgyal: az auto(x) explicit decay-copy-t, az egyszerűsített implicit move-ot, a range-for temporary-élettartam-kiterjesztését, valamint a [[assume]] és a std::unreachable optimalizálási szerződéseit. A vezérmotívum a dangling: mindegyik változás arról szól, hogy egy érték vagy egy rá mutató nézet meddig érvényes, és a fordító mikor feltételezheti, hogy a programozó ezt betartotta. A rész a Temporary, value category és dangling (20.4) részben bevezetett temporary- és value category-fogalmakra, a Borrowed range, dangling és iterator lifetime (30.5) részben tárgyalt borrowed range-ekre és az Optimalizáló, as-if és UB következményei (14.3) részben bevezetett UB- és as-if-modellre épül; ezeket itt nem ismételjük teljesen, csak a szükséges előfeltételeket idézzük fel.

40.5.1 A labor alapja: feature-test és compile probe

A C++23-as képességek fordító- és könyvtártámogatása nem azonos. A fejezetterv kiinduló URL-jei (a WG21 munkatervezet, a cppreference C++23-oldala, a GCC/Clang státuszoldalak) kutatási kiindulópontok, nem bizonyítékok; a konkrét támogatást a laborban, compile probe-pal kell ellenőrizni. A feature-test makrók neve és a hozzá tartozó képesség:

Képesség Feature-test makró Jelleg
auto(x) __cpp_auto_cast nyelvi
implicit move __cpp_implicit_move nyelvi
range-for élettartam __cpp_range_based_for nyelvi; C++20-ban is definiált, más értékkel
[[assume]] __cpp_assume nyelvi
std::unreachable() __cpp_lib_unreachable könyvtári

A következő probe önmagában fordítható C++20-ban és C++23-ban is; a hiányzó makrókat nem definiáltként jelzi. A szakirodalomban elterjedt értékek (például __cpp_auto_cast = 202110L, __cpp_implicit_move = 202207L, __cpp_range_based_for = 202211L, __cpp_assume = 202207L, __cpp_lib_unreachable = 202202L) irodalmi értékek, amelyeket a probe kimenetével és a hivatkozott WG21-paperrel kell összevetni; a fejezet írásakor ezek nem tekintendők ellenőrzöttnek.

// probe.cpp
#include <version>
#include <cstdio>

int main() {
#ifdef __cpp_auto_cast
    std::printf("__cpp_auto_cast = %ld\n", __cpp_auto_cast);
#else
    std::printf("__cpp_auto_cast nem definiált\n");
#endif
#ifdef __cpp_implicit_move
    std::printf("__cpp_implicit_move = %ld\n", __cpp_implicit_move);
#else
    std::printf("__cpp_implicit_move nem definiált\n");
#endif
#ifdef __cpp_range_based_for
    std::printf("__cpp_range_based_for = %ld\n", __cpp_range_based_for);
#else
    std::printf("__cpp_range_based_for nem definiált\n");
#endif
#ifdef __cpp_assume
    std::printf("__cpp_assume = %ld\n", __cpp_assume);
#else
    std::printf("__cpp_assume nem definiált\n");
#endif
#ifdef __cpp_lib_unreachable
    std::printf("__cpp_lib_unreachable = %ld\n", __cpp_lib_unreachable);
#else
    std::printf("__cpp_lib_unreachable nem definiált\n");
#endif
}
g++ -std=c++20 -O2 -Wall -Wextra -o probe20 probe.cpp && ./probe20
g++ -std=c++23 -O2 -Wall -Wextra -o probe23 probe.cpp && ./probe23
# Clang esetén: clang++ -std=c++20 / -std=c++23

A kimenet értelmezése: ami C++20 módban „nem definiált”, az C++23 módban vagy definiált, vagy a fordító még nem támogatja. Utóbbi esetben a képességhez fallback kell, nem a makróra épülő feltétel nélküli kód.

40.5.2 Az auto(x) és az auto{x} decay-copy

A C++20-ig a „decay-copy” a standard szövegében létező, exposition-only fogalom volt: a std::ranges egyes részei és a generikus kód nem tudta kifejezni, hogy „az argumentumból független, másolt értéket kérek”. A P0849 paperszámú javaslat ezt nyelvi eszközzé tette.

Definíció. Az auto(x) egy prvalue kifejezés, amelynek típusát az auto típuslevezetése adja meg az x operandusból. A típuslevezetés a by-value paraméterátadás szabályait követi: a referencia eldobása, a top-level const és volatile eldobása, a tömb- és függvénytípus pointerré alakítása. Az eredmény egy új objektum, amely független az operandustól; a másolat akkor készül el, amikor a prvalue materializálódik. Az auto{x} ugyanezt teszi, de lista-inicializálással, ezért tiltja a szűkítő konverziót.

Feltételek és következmények. Az auto(x) mindig prvalue, soha nem referencia és soha nem xvalue; nem azonos a decltype(auto)-val, amely referenciát is adhat. Nem őrzi meg a top-level const-ot: const int i = 5; auto j = auto(i); esetén j típusa int. A másolat költsége akkor jelenik meg, amikor az érték anyagiassá válik; visszatérési értékként a guarantee copy elision miatt gyakran nincs tényleges másolás, de ez nem általános garancia.

// auto_decay_copy.cpp
#include <cstdio>
#include <string>
#include <type_traits>
#include <utility>

template<class T>
auto decay_copy(T&& t) {
#if defined(__cpp_auto_cast) && __cpp_auto_cast >= 202110L
    return auto(std::forward<T>(t));          // C++23
#else
    return std::decay_t<T>(std::forward<T>(t)); // C++20 fallback
#endif
}

int main() {
    const std::string s = "hello";
    auto a = decay_copy(s);
    static_assert(std::is_same_v<decltype(a), std::string>);
    std::printf("%s\n", a.c_str());
}
g++ -std=c++23 -O2 -Wall -Wextra -o decay23 auto_decay_copy.cpp && ./decay23
g++ -std=c++20 -O2 -Wall -Wextra -o decay20 auto_decay_copy.cpp && ./decay20

A tipikus hiba itt az, hogy a programozó referenciát vagy const-ot vár vissza. Az auto(x) tudatosan értéket ad: ez a független, biztonságos másolat eszköze, nem a nézeté.

40.5.3 Egyszerűsített implicit move

A C++20 szabálya (P1825) szerint a return e; utasításban, ha e egy nem-volatile, automatikus tárolási idejű objektum vagy rvalue-referencia paraméter neve, és a visszatérési típus nem referencia, akkor a fordító először úgy végzi a túlterhelés-feloldást, mintha e rvalue lenne, és csak ennek kudarca esetén próbálja lvalue-ként. Ez a szabály már C++20-ban sem követeli meg, hogy a visszatérési típus megegyezzen e típusával: egy Derived helyi változó Base-ként visszaadva C++20-ban is a Base(Base&&) konstruktort választja. (A GCC ezt a javítást korábbi nyelvi módokra is alkalmazza.)

Definíció. A C++23 (P2266) egyszerűsíti a szabályt: a return e; utasításban a fenti feltételeknek megfelelő e név egyszerűen xvalue, a két lépcsős túlterhelés-feloldás helyett. Az új szabály a referencia visszatérési típusú függvényekre is vonatkozik, és éppen ott változtat megfigyelhetően. Tömören: C++23-ban a return e; utasításban az e xvalue, ha

  1. az e egy nem-volatile, automatikus tárolási idejű objektum vagy rvalue-referencia neve, és
  2. a return egy függvényben (vagy lambdában) áll.

Viselkedésváltozás. Érték szerinti visszatérésnél a C++20-hoz képest nincs eltérés; referencia visszatérési típusnál viszont van:

// implicit_move.cpp — C++20 és C++23: mi változott valójában?
#include <cstdio>
#include <utility>

struct Base {
    Base() = default;
    Base(const Base&) { std::puts("copy"); }
    Base(Base&&) noexcept { std::puts("move"); }
};
struct Derived : Base { };

Base f() {
    Derived d;
    return d;        // C++20 és C++23: move (P1825, C++20)
}

Base g() {
    const Derived d;
    return d;        // mindkét módban copy: const Derived&& nem köt Base&&-hez
}

#if __cplusplus > 202002L
int&& fwd(int&& x) { return x; }   // C++23: x xvalue, fordul; C++20: hiba
#endif
// int& keep(int&& x) { return x; } // C++20: fordul; C++23: hiba

int main() {
    (void)f();
    (void)g();
#if __cplusplus > 202002L
    int v = 7;
    int&& r = fwd(std::move(v));
    std::printf("fwd=%d\n", r);
#endif
}
g++ -std=c++20 -O0 -Wall -Wextra -o im20 implicit_move.cpp && ./im20
g++ -std=c++23 -O0 -Wall -Wextra -o im23 implicit_move.cpp && ./im23

Buildjegyzőkönyv (lab-gcc profil). A -std=c++20 kimenet: move, copy; a -std=c++23 kimenet: move, copy, fwd=7. A f() mindkét módban mozgat, a const változat (g()) mindkét módban másol. Az int&& fwd(int&& x) { return x; } C++20-ban cannot bind rvalue reference ... to lvalue hibát ad, C++23-ban fordul. A kikommentezett int& keep(int&& x) { return x; } fordítva viselkedik: C++20-ban fordul, C++23-ban cannot bind non-const lvalue reference ... to an rvalue hibát ad. Ez az utóbbi a migráció valódi forráskompatibilitási törése.

A return std::move(x); írásmód érték szerinti visszatérésnél felesleges, sőt hátrányos lehet, mert a std::move függvényhívása megakadályozza az NRVO-t; a -Wpessimizing-move figyelmeztet rá.

40.5.4 A range-for temporary-élettartama

A C++20-as range-for lényegében auto&& __range = range-init; deklarációvá alakult. A range-init azon temporary-jei, amelyek közvetlenül a __range referenciához kötődtek, a ciklus végéig éltek; a közvetett temporary-k azonban a teljes kifejezés végén megsemmisültek. Ez a különbség okozta a klasszikus danglinget.

Definíció. A C++23-ban (P2718) a range-initializerben létrejövő temporary objektumok élettartama a ciklus teljes élettartamára kiterjed, nem csak a range-initializer kiértékelésének végéig. Ez a temporary lifetime extension kiterjesztése a range-for-ra. A szabály csak a range-initializerre vonatkozik; a ciklustörzsben keletkező temporary-kre és az init-statement temporary-jeire nem.

// range_for_lifetime.cpp
#include <vector>
#include <cstdio>

struct Config {
    std::vector<int> values{1, 2, 3};
    const std::vector<int>& get() const { return values; }
};

Config make() { return Config{}; }

int main() {
    int sum = 0;
    for (int v : make().get()) {   // C++20: dangling; C++23: OK
        sum += v;
    }
    std::printf("sum = %d\n", sum);
}
g++ -std=c++20 -g -O1 -fsanitize=address -o r20 range_for_lifetime.cpp && ./r20
g++ -std=c++23 -g -O1 -fsanitize=address -o r23 range_for_lifetime.cpp && ./r23

C++20-ban a make() temporary nem közvetlenül a __range-hez kötődik, hanem a get() hívás argumentumaként jön létre, ezért a teljes kifejezés végén megsemmisül; a get() által visszaadott referencia dangling, az iterálás UB. C++23-ban a make() temporary a ciklus végéig él, a referencia érvényes marad. A várt laboreredmény: az r20 ASan heap-use-after-free hibát jelez, az r23 normálisan lefut; a tényleges kimenetet a laborban kell rögzíteni, ez a leírás nem mérési eredmény.

A P2718 nem old meg minden danglinget. Ha a get() saját temporary-t ad vissza referenciaként (const std::vector<int>& get() const { return std::vector<int>{1,2,3}; }), az már a get() visszatérésekor dangling. Ha a range egy std::string_view, amely egy lokális stringre mutat, az is dangling marad. A szabály csak a range-initializer temporary-jeire terjed ki.

40.5.5 Az [[assume]] és a std::unreachable

Mindkét eszköz a fordítónak tett ígéret, amelynek megsértése nem definiált viselkedés. A különbség az ígéret tartalmában van.

[[assume(expr)]]. A C++23-ban egy null utasításra alkalmazott attribútum. Azt jelenti: a programozó állítja, hogy expr a végrehajtás ezen pontján igaz. Ha nem igaz, a viselkedés nem definiált. Az expr nem értékelődik ki, tehát nem lehet mellékhatása, és nem hívódik meg; a fordító az igazságára támaszkodva optimalizálhat. Csak akkor használható, ha a feltétel tényleg garantált; nem helyettesíti a futásidejű ellenőrzést.

std::unreachable(). A C++23-as <utility> fejléc függvénye. Azt jelenti: a hívás helye nem érhető el. Ha mégis elérhető, a viselkedés nem definiált. Nem feltétel, hanem pont; a hívás megtörténik, de a fordító optimalizálhatja. Tipikus használata a minden enumértéket lefedő switch után, vagy olyan függvény végén, ahol a fordító nem látja, hogy nincs visszatérés.

Szempont [[assume(expr)]] std::unreachable()
Az ígéret tartalma a feltétel igaz a pont nem érhető el
Forma attribútum null utasításon függvényhívás
Kiértékelődik nem a hívás megtörténik; elérhetetlen esetben UB
Tipikus használat feltétel optimalizálása elérhetetlen ág jelölése
Fallback if (!(cond)) __builtin_unreachable(); __builtin_unreachable()
// assume_unreachable.cpp
#include <utility>
#include <cstdio>

enum class Color { Red, Green, Blue };

const char* name(Color c) {
    switch (c) {
    case Color::Red:   return "red";
    case Color::Green: return "green";
    case Color::Blue:  return "blue";
    }
    std::unreachable();   // C++23
}

int divide(int a, int b) {
    [[assume(b != 0)]];   // a hívónak garantálnia kell
    return a / b;
}

int main() {
    std::printf("%s %d\n", name(Color::Green), divide(6, 3));
}
g++ -std=c++23 -O2 -Wall -Wextra -o au23 assume_unreachable.cpp && ./au23
# C++20 fallback: std::unreachable() helyett __builtin_unreachable(),
# [[assume]] helyett if (!(b != 0)) __builtin_unreachable();

A switch után a std::unreachable() azért kell, mert a fordító a switch után is feltételezhet visszatérést; a hívás jelzi, hogy minden eset le van fedve. Ha később új enumérték kerül be, és a switch nem kezeli, a default nélküli switch -Wswitch figyelmeztetést ad, a std::unreachable() pedig UB-hez vezet, ha mégis elérhető. Ezért a std::unreachable() nem véd a jövőbeli bővítés ellen; a -Wswitch és a teljes lefedés a védelem.

40.5.6 Kisebb szintaktikai változások

A C++23 két apró, de hasznos szabályt is hozott. Az egyik: egy címke állhat az összetett utasítás végén, közvetlenül a } előtt; C++20-ban legalább egy utasítás kellett a címke után. Ez generált kódban és switch-eknél kényelmes:

switch (code) {
case 1:
    handle();
    break;
case 2:   // C++23: legális, C++20: fordítási hiba
}

A másik: az if/switch init-statementje lehet alias-deklaráció is. C++23-ban:

if (using T = int; sizeof(T) == 4) {
    // a T az if feltételében és ágában él
}

C++20-ban ez nem fordul; a fallback a deklarációt az if elé teszi, ami megváltoztatja a T hatókörét. A változás főleg generikus kódban hasznos, ahol a típust az init-statementben akarjuk bevezetni.

40.5.7 Sanitizer-labor: ugyanaz a kód két módban

A labor célja, hogy ugyanazt a forrást C++20 és C++23 módban fordítsuk, és megfigyeljük a különbséget. A range_for_lifetime.cpp esetében a C++20-as buildet AddressSanitizerrel futtatva a várt eredmény heap-use-after-free hiba; a C++23-as buildben nincs hiba. Az implicit_move.cpp esetében a különbség a fordításban látszik: a fwd csak C++23-ban fordul, a keep csak C++20-ban; a puts-kimenet (move, copy) a két módban azonos. Az assume_unreachable.cpp esetében a [[assume]] megsértése UB; a sanitizer nem garantált, hogy diagnosztizálja, ezért a helyes használatot kódolvasással és a hívói szerződés ellenőrzésével kell biztosítani. A std::unreachable() elérését egyes sanitizerek (például Clang -fsanitize=unreachable) jelezhetik, de ez nem hordozható elvárás.

A labor parancsai a fenti szekciókban szerepelnek. A „várt eredmény” itt a dokumentált viselkedésen alapuló illusztráció, nem mért eredmény; a tényleges kimenetet a tanuló laborja rögzíti, és összeveti a feature-test makrók kimenetével.

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

40.5.9 Összefoglaló

A C++23 négy élettartam- és értékkategória-változása egy közös irányba mutat: a fordító pontosabban tudja, meddig él egy érték, és a programozó explicitebb eszközökkel fejezheti ki a szándékát. Az auto(x) független értéket ad, az implicit move a return utasításban a származtatott és bázistípusok közötti mozgatást is lehetővé teszi, a range-for temporary-kiterjesztés a leggyakoribb közvetett danglinget szünteti meg, a [[assume]] és a std::unreachable() pedig a feltétel- és elérhetetlenségi ígéreteket választja szét. Egyik változás sem helyettesíti a szerződések tudatos tervezését; a feature-test makrók és a sanitizer-labor csak az eszközök helyes használatát segítik.

40.5.10 Feladatok

  1. Magyarázó feladat. Magyarázd el, miért dangling a range_for_lifetime.cpp C++20-as változatában a make().get() hívás, és mit változtat a C++23. Adj egy olyan módosítást, amelyet a P2718 nem javít.
  2. Kódolási feladat. Írj egy decay_copy függvénysablont, amely C++23-ban auto(x)-et, C++20-ban std::decay_t-t használ. Ellenőrizd feature-test makróval és static_assert-tel, hogy a visszatérési típus std::string egy const std::string& bemenetre.
  3. Diagnosztikai feladat. Adott a name(Color) függvény a std::unreachable() hívással. Vezess be egy új Color::Yellow értéket, és magyarázd el, mi történik, ha a switch nem kezeli. Javítsd a kódot úgy, hogy a hiba fordítási időben kiderüljön.
  4. Viselkedésváltozási feladat. A Base f() { Derived d; return d; } példában melyik konstruktor hívódik C++20-ban és C++23-ban, és mi a szerepe a const-nak? Mutass olyan függvényt, amelynek a fordíthatósága a két mód között megváltozik.

40.5.11 Megoldások

1. megoldás. A make() temporary nem közvetlenül a range-for __range referenciájához kötődik, hanem a get() tagfüggvény hívásának argumentumaként jön létre. C++20-ban az ilyen közvetett temporary a teljes kifejezés végén megsemmisül, így a get() által visszaadott const std::vector<int>& danglingre mutat; az iterálás UB. C++23-ban a range-initializer temporary-jei a ciklus végéig élnek, ezért a referencia érvényes. Nem javítja a P2718 azt az esetet, amikor a get() saját temporary-t ad vissza referenciaként: const std::vector<int>& get() const { return std::vector<int>{1,2,3}; } – itt a temporary a get() visszatérésekor megsemmisül, mert nem a range-initializerben jött létre. Ellenőrzési feltétel: a C++20-as ASan-build hibát jelez, a C++23-as nem; a get() módosított változata C++23-ban is hibát jelez.

2. megoldás. A decay_copy a fenti auto_decay_copy.cpp szerint épül fel. A lényeg a feltételes fordítás:

#if defined(__cpp_auto_cast) && __cpp_auto_cast >= 202110L
    return auto(std::forward<T>(t));
#else
    return std::decay_t<T>(std::forward<T>(t));
#endif

Az ellenőrzés static_assert(std::is_same_v<decltype(decay_copy(s)), std::string>);, ahol s const std::string&. Indoklás: az auto eldobja a referenciát és a top-level const-ot, a std::decay_t ugyanezt teszi, ezért a két ág típusa megegyezik. Ellenőrzési feltétel: a C++23-as buildben a __cpp_auto_cast ág fordul, a C++20-asban a fallback; mindkettőben a static_assert teljesül.

3. megoldás. Ha a Color::Yellow bekerül, de a switch nem kezeli, a default nélküli switch a -Wswitch figyelmeztetést adja, és a std::unreachable() hívás elérhetővé válik, ami UB. A javítás: hagyjuk el a default ágat, soroljuk fel az összes enumerátort, és a switch után std::unreachable()-t használjunk; így az új érték figyelmeztetést ad. Alternatíva: default ágban std::abort(), amely nem UB, hanem definiált leállás. Ellenőrzési feltétel: az új enumerátorral a build -Wswitch figyelmeztetést ad, a std::unreachable() nem érhető el.

4. megoldás. Mindkét módban a Base(Base&&) hívódik: a C++20 (P1825) már nem követeli meg a típusegyezést, a C++23 (P2266) pedig xvalue-ként kezeli a d-t. const Derived d; esetén a const Derived&& nem köthető Base&&-hez, ezért mindkét módban másolat készül; ez a const szerepe. A fordíthatóság referencia visszatérési típusnál változik: az int&& fwd(int&& x) { return x; } csak C++23-ban, az int& keep(int&& x) { return x; } csak C++20-ban fordul. Ellenőrzési feltétel: a puts-kimenet a két módban azonos (move, illetve a const változatban copy), a fwd/keep pár fordítási eredménye pedig a két módban ellentétes; a lab-gcc profilban mindezt lefuttattuk.