22.4 Copy elision, RVO/NRVO és értékvisszaadás

A C-ben megszokott érték-visszaadás mindig másolás: a return s; egy struct esetén bájtonként átmásolja a struktúra tartalmát a hívó által biztosított helyre, és ha a struktúra pointert tartalmaz, a mutatott erőforrásból csak egy második, nem tulajdonló hivatkozás keletkezik. A C++ értékszemantikája ezt a másolást megőrzi szemantikai szinten — a T értékű visszatérés azt jelenti, hogy a hívó egy önálló, teljes értékű T-t kap —, de a fordítónak megengedi, hogy a köztes másolásokat elhagyja. Ez a fejezet arról szól, melyik elhagyás garantált, melyik csak megengedett, és hogyan lehet ezt a különbséget helyesen kihasználni, illetve hogyan lehet elrontani egyetlen felesleges std::move-val. Az előfeltétel a move konstruktor és a moved-from állapot ismerete: Move constructor, moved-from állapot és std::move (22.3).

A gyakorlati tétel nem kicsi. Egy konfigurációs objektumot, egy std::vector<uint8_t> puffert vagy egy std::string-et visszaadó függvény a program meleg útján lehet; a visszatérési érték költsége a naiv modellben három konstruktor/destruktor-hívás, a garantált elision mellett egy. A fejezet ezért két síkot választ szét végig: a szabvány szerinti garanciát (mi történhet meg egyáltalán) és a megfigyelést (mit látunk egy adott fordítónál, adott kapcsolókkal). A kettő nem ugyanaz, és a különbségtétel elmaradása a leggyakoribb félreértés forrása.

22.4.1 A visszatérési érték szemantikája: prvalue, elision, NRVO

22.4.1.1 Alapfogalmak és a C++17-es cezúra

A C++17 előtt a visszatérési érték előállítása három fogalmon keresztül írható le. A függvény előállít egy lokális objektumot, ez a lokális a return utasításnál egy temporális (névtelen ideiglenes) objektummá másolódik, majd a temporális a hívó által biztosított célobjektummá másolódik (vagy C++11 óta mozog). A szabvány már C++17 előtt is megengedte, hogy a fordító ezeket a másolásokat elhagyja — ezt nevezi a szakirodalom copy elisionnek (másolás-elhagyás) —, de csak megengedte, nem írta elő. Ráadásul a megengedés feltétele az volt, hogy a copy vagy move konstruktor elérhető legyen, még akkor is, ha a fordító végül nem hívta meg.

A prvalue (pure rvalue) C++17-től nem egy „névtelen objektum”, hanem olyan kifejezés, amelynek értékelése egy objektumot inicializál. Amikor egy prvalue-t egy objektum inicializálására használunk, a prvalue közvetlenül inicializálja a célobjektumot; nincs köztes temporális, amit el kellene hagyni. Ez a garantált prvalue-konstrukció (mandatory vagy guaranteed copy elision), és nem optimalizáció: a program szemantikájának része. A C++17 előtti és utáni viselkedés különbsége éppen ezért nem teljesítménykérdés, hanem nyelvi kérdés — és bizonyos programok csak C++17-től fordulnak le.

22.4.1.2 A garantált prvalue-konstrukció feltételei

A garancia a return utasításban akkor áll fenn, ha a visszaadott kifejezés prvalue, és típusa megegyezik a függvény visszatérési típusával, a legfelső szintű (top-level) const/volatile minősítők eltérését leszámítva. Ilyenkor a prvalue közvetlenül a visszatérési objektumot inicializálja: nincs temporális, nincs copy, nincs move, és a copy/move konstruktornak nem is kell léteznie. Ez utóbbi a legfontosabb következmény, mert olyan típust is vissza lehet adni érték szerint, amely nem másolható és nem mozgatható.

A tipikus alakok, amelyekre a garancia vonatkozik:

T make_direct()   { return T{1, 2}; }     // prvalue: garantált
T make_braced()   { return {1, 2}; }      // a visszatérési típusra célzott inicializálás: garantált
T make_factory()  { return make_direct(); } // a belső hívás prvalue-ja: garantált

Az utolsó sor fontos: a make_factory visszatérési értéke nem egy lokális változó, hanem egy függvényhívás, amelynek eredménye prvalue, ezért a láncolt visszatérés is garantáltan költségmentes. Ez a gyárak és a make_unique-szerű segédfüggvények alapja.

22.4.1.3 Az NRVO feltételei

Az NRVO (Named Return Value Optimization) ennél gyengébb: a visszaadott kifejezés egy automatikus tárolási idejű, nem volatile, nem függvényparaméter nevű lokális változó neve, és a változó típusa megegyezik a visszatérési típussal (a top-level cv eltérését itt is leszámítva). Ha a fordító él a lehetőséggel, a lokális objektumot közvetlenül a hívó által biztosított tárhelyen építi fel, így a visszatéréskor nincs sem másolás, sem mozgatás. Ez azonban megengedett, nem kötelező: a szabvány megengedi, hogy a fordító elhagyja a copy/move konstruktor hívását, de nem írja elő.

Két következményt érdemes rögzíteni. Egyrészt az NRVO csak akkor alkalmazható, ha a return utasításban szereplő név minden végrehajtási útvonalon ugyanazt az objektumot nevezi meg; két különböző lokális változóból feltételesen visszaadott értéknél a fordítók a gyakorlatban nem élik vele a lehetőséggel, és ilyenkor move (vagy ha az nem elérhető, copy) történik. Másrészt mivel az NRVO nem garantált, a copy vagy move konstruktornak elérhetőnek kell lennie akkor is, ha a fordító végül nem hívja meg. Ez a látszólagos ellentmondás a C++17 utáni szabály: a prvalue-esetben nem kell konstruktor, a névvel történő visszatérésnél kell.

22.4.2 Az implicit move a return utasításban

22.4.2.1 A szabály és verziójelölése

Amikor egy függvény érték szerint ad vissza, és a return kifejezés egy automatikus tárolási idejű objektumot megnevező azonosító-kifejezés — lokális változót vagy érték szerinti függvényparamétert —, a kifejezést az overload resolution során úgy kell kezelni, mintha rvalue lenne, mintha a programozó std::move-ot írt volna. Ez az implicit move szabály. C++11 óta létezik, és a paraméterekre kezdettől vonatkozik; a C++14 (CWG1579) a konverziós esetekre, a C++20 (P1825) az rvalue-referencia változókra, a throw-ra és a co_return-ra terjesztette ki; a C++23 (P2266) egyszerűsítette: az ilyen név a return-ben eleve xvalue (lásd Érték, élettartam és kifejezések: auto(x), implicit move és range-for (40.5)).

Két jogosultságot kell szétválasztani:

Visszaadott név NRVO lehetséges? Implicit move?
lokális, nem volatile automatikus objektum, a visszatérési típussal azonos típus igen (nem garantált) igen
érték szerinti függvényparaméter nem — a paramétert a hívó építi, nem a célhelyen igen
T&& lokális rvalue-referencia (C++20 óta) nem igen
lvalue-referencia paraméter vagy tag (T&, member_) nem nem — itt std::move kell, ha mozgatni akarunk

Az érték szerinti paraméternél tehát a return p; mozgat, és a std::move felesleges; a referencia-paraméternél a return r; másolna (move-only típusnál nem is fordul):

// param_move.cpp — C++20: érték szerinti és referencia-paraméter visszaadása
#include <memory>
#include <utility>

std::unique_ptr<int> by_value(std::unique_ptr<int> p) {
    return p;                 // implicit move: fordul, nincs NRVO (paraméter)
}

std::unique_ptr<int> by_ref(std::unique_ptr<int>& r) {
    // return r;              // hiba: r lvalue-referencia, nincs implicit move
    return std::move(r);      // itt a std::move szükséges és helyes
}

int main() {
    auto a = by_value(std::make_unique<int>(1));
    auto src = std::make_unique<int>(2);
    auto b = by_ref(src);     // src kiürül: ez a hívó számára látható szerződés
    return (*a + *b == 3 && !src) ? 0 : 1;
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic param_move.cpp figyelmeztetés nélkül fordul, és 0 kóddal fut. A kikommentezett return r; visszakapcsolva C++20 és C++23 módban is use of deleted function 'std::unique_ptr<...>::unique_ptr(const std::unique_ptr<...>&)' hibát ad. A by_value törzsét return std::move(p);-re írva a GCC -Wredundant-move figyelmeztetést ad.

A gyakorlati haszon: ha az NRVO nem történik meg, a lokális objektum nem másolódik, hanem mozog. Ez akkor is így van, ha a programozó nem írt semmit. A return local; tehát „ingyen” megkapja a move-ot, és közben megőrzi az NRVO esélyét.

22.4.2.2 Miért káros a return std::move(local)

A std::move(local) egy függvényhívás, amelynek eredménye xvalue — nem azonosító-kifejezés. Ezzel két dolgot rontunk el egyszerre. Egyrészt az NRVO feltétele megszűnik, mert a visszaadott kifejezés már nem a lokális változó neve; a fordító nem építheti a lokálist a célhelyre, hiszen a std::move egy külön objektumra mutató referenciát ad. Másrészt az implicit move szabályára sincs szükség, mert a kifejezés már eleve xvalue: a move konstruktor meg fog hívódni. Az eredmény egy olyan move művelet, amely NRVO esetén nulla lett volna.

Fontos árnyalás, hogy ne essünk a másik végletbe: a std::move nem mindig káros, csak akkor, ha az NRVO egyébként megtörtént volna. Ha a visszaadott kifejezés nem implicit módon mozgatható — például egy tag (return std::move(member_);) vagy egy lvalue-referencia paraméter (return std::move(ref);) —, akkor az implicit move szabály rá nem terjed ki, és a std::move hiányában copy történne. Ezekben az esetekben a std::move helyes és szükséges, ha a forrás kiürítése a szerződés része. Az érték szerinti paraméter viszont nem ilyen: ott a return param; már mozgat, a std::move(param) csak felesleges. A megkülönböztetés kulcsa tehát nem a szintaxis, hanem az, hogy a kifejezés milyen tárolási idővel és milyen névvel rendelkezik.

22.4.2.3 Levezetés: az NRVO költségmodellje

Jelölje CcopyC_{\text{copy}} és CmoveC_{\text{move}} a copy, illetve move konstruktor költségét, CdtorC_{\text{dtor}} a destruktorét, CinitC_{\text{init}} pedig a lokális objektum felépítésének költségét. Egy return local; naiv, elision nélküli költsége:

Cnaiv=Cinit+Cmove+Cdtor,local+Cdtor,resultC_{\text{naiv}} = C_{\text{init}} + C_{\text{move}} + C_{\text{dtor,local}} + C_{\text{dtor,result}}

NRVO esetén a lokális a célhelyen épül fel, ezért a move és a lokális destruktora elmarad:

CNRVO=Cinit+Cdtor,resultC_{\text{NRVO}} = C_{\text{init}} + C_{\text{dtor,result}}

A return std::move(local); viszont kikényszeríti a move-ot, még akkor is, ha az NRVO egyébként lehetséges lett volna:

Cforced=Cinit+Cmove+Cdtor,local+Cdtor,resultC_{\text{forced}} = C_{\text{init}} + C_{\text{move}} + C_{\text{dtor,local}} + C_{\text{dtor,result}}

A különbség pontosan Cmove+Cdtor,localC_{\text{move}} + C_{\text{dtor,local}}. Ez kis objektumnál elhanyagolható, nagy puffer vagy drága destruktor esetén nem az. A std::vector és std::string esetében a move általában olcsó (pointer-átadás), de a destruktor és a move konstruktor hívásának elhagyása továbbra is mérhető különbség lehet szoros ciklusban — és ami fontosabb, a szemantika szempontjából az NRVO megőrzi az objektum identitását, amit a move nem.

22.4.2.4 Elision és destruktor-élettartam

A copy elision nem csak konstruktorokat hagy el, hanem destruktorokat is, és ez megfigyelhető viselkedést jelent. Garantált prvalue-konstrukciónál a temporális soha nem jön létre, ezért a destruktora sem fut le. NRVO esetén a lokális objektum a hívó által biztosított helyen épül fel, így egyetlen destruktor fut le — a visszatérési érték élettartamának végén, a hívónál. Ha az elision nem történik meg, két destruktor fut le: a lokálisé a return után, a visszatérési értéké a hívónál.

Ebből az következik, hogy a konstruktorokba és destruktorokba írt oldalhatások (számlálók, logolás, erőforrás-felszabadítás, lockolás) nem determinisztikusak az opcionális elision esetén. A szabvány ezt kifejezetten megengedi: a copy elision az as-if szabály egyik dokumentált kivétele. A program helyességét ezért soha nem szabad arra építeni, hogy egy copy vagy move konstruktor pontosan hányszor hívódik meg — legfeljebb diagnosztikai célra szabad ezt megfigyelni, és akkor is jelezni kell, hogy a megfigyelés fordító-, verzió- és kapcsolófüggő. Ez a fejezet instrumentált példájának is a fő tanulsága.

22.4.3 Kidolgozott példa: instrumentált copy/move számláló

22.4.3.1 A program

Az alábbi program három visszatérési módot hasonlít össze: garantált prvalue-t, NRVO-jelöltet és kikényszerített move-ot. A számlálók statikusak, hogy a main a végén kiírhassa őket; a konstruktorokban szándékosan nincs oldalhatás, hogy a kimenet ne függjön attól, melyik konstruktor hívódik meg.

// counter.cpp
#include <cstdio>
#include <utility>

struct Tracked {
    static int constructed;
    static int copied;
    static int moved;
    static int destroyed;

    int id;

    explicit Tracked(int i) : id{i} { ++constructed; }
    Tracked(const Tracked& other) : id{other.id} { ++copied; }
    Tracked(Tracked&& other) noexcept : id{other.id} {
        ++moved;
        other.id = -1;              // moved-from: érvényes, de nem specifikált érték
    }
    ~Tracked() { ++destroyed; }
};

int Tracked::constructed = 0;
int Tracked::copied      = 0;
int Tracked::moved       = 0;
int Tracked::destroyed   = 0;

Tracked make_prvalue() {
    return Tracked{1};              // garantált prvalue-konstrukció
}

Tracked make_named() {
    Tracked local{2};
    return local;                   // NRVO-jelölt: megengedett, nem kötelező
}

Tracked make_named_forced_move() {
    Tracked local{3};
    return std::move(local);        // NRVO blokkolva; move kényszerítve
}

int main() {
    Tracked a = make_prvalue();
    std::printf("a: copy=%d move=%d\n", Tracked::copied, Tracked::moved);

    Tracked b = make_named();
    std::printf("b: copy=%d move=%d\n", Tracked::copied, Tracked::moved);

    Tracked c = make_named_forced_move();
    std::printf("c: copy=%d move=%d\n", Tracked::copied, Tracked::moved);

    std::printf("total: ctor=%d copy=%d move=%d dtor=%d\n",
                Tracked::constructed, Tracked::copied,
                Tracked::moved, Tracked::destroyed);
    return 0;                       // a, b, c destruktorai itt futnak le
}

22.4.3.2 Build és várható megfigyelés

A példa önállóan fordítható; a fordítót és a verziót a laborprofilban kell rögzíteni.

g++     -std=c++17 -O0 -Wall -Wextra -pedantic -o counter counter.cpp
clang++ -std=c++17 -O0 -Wall -Wextra -pedantic -o counter counter.cpp

# diagnosztikai változat: az opcionális elision kikapcsolása (GCC/Clang-specifikus)
g++     -std=c++17 -O0 -fno-elide-constructors -o counter_noelide counter.cpp

A -fno-elide-constructors nem hordozható kapcsoló, és a garantált prvalue-esetet nem érinti; kizárólag az NRVO viselkedésének szemléltetésére való. A várt megfigyelés — nem mért eredmény, mert a kód futtatása a tanuló laborjának feladata:

Konfiguráció a után copy/move b után copy/move c után copy/move
C++17, alapértelmezett 0 / 0 0 / 0 (NRVO) 0 / 1
C++17, -fno-elide-constructors 0 / 0 0 / 1 0 / 1
C++14, alapértelmezett 0 / 1 vagy 1 / 0 0 / 1 vagy 1 / 0 0 / 1

A táblázat értelmezése a lényeg. Az a sor minden C++17-es konfigurációban garantáltan 0/0, mert a make_prvalue visszatérése prvalue. A b sor azért változik, mert az NRVO opcionális: -fno-elide-constructors mellett a fordító move-ot kényszerít, alapértelmezésben a legtöbb fordító elvégzi az NRVO-t. A c sor mindenhol move-ot mutat, mert a std::move(local) kizárja az NRVO-t. C++14-ben az a sor sem garantált: a fordító döntése, hogy copy-t vagy move-ot hív-e, vagy elhagyja-e — innen a „vagy” a táblázatban.

22.4.3.3 A megfigyelés korlátai

Három dolgot érdemes tudatosítani. Egyrészt a számlálók értéke nem a szabvány által előírt viselkedés, hanem az adott fordító döntésének lenyomata; más compiler, más verzió vagy más optimalizálási szint eltérő számot adhat. Másrészt az -O0 nem jelenti azt, hogy az NRVO elmarad: a legtöbb fordító az NRVO-t szemantikai szinten, az optimalizálástól függetlenül végzi el, ezért a „debug buildben nincs elision” feltevés hamis. Harmadrészt a táblázat „várt” értékeit senki nem mérte meg a fejezet írásakor; a mérés a labor része, és az eredményt a fordító és a kapcsolók rögzítésével együtt kell dokumentálni. A printf beszúrása a konstruktorokba egyébként megváltoztathatja az optimalizáló döntéseit, ezért a valódi méréshez érdemes külön, oldalhatás nélküli változatot használni, és csak a számlálókat kiírni.

22.4.3.4 Fordítási hibát bemutató példa

A garancia és az NRVO közti különbség legélesebben nem mozgatható típusnál látszik:

struct Pinned {
    Pinned() = default;
    Pinned(const Pinned&) = delete;
    Pinned(Pinned&&) = delete;
};

Pinned make_pinned() {
    return Pinned{};            // C++17: OK, a copy/move ctor nem is kell
}

Pinned make_pinned_named() {
    Pinned p;
    return p;                   // C++17: ill-formed, mert a move ctor törölt,
                                // és az NRVO nem garantált
}

A make_pinned_named azért nem fordul le, mert az implicit move szabály szerint a visszatéréshez move konstruktorra lenne szükség, és a fordító nem köteles az NRVO-t elvégezni. C++14-ben mindkét függvény hibás, mert ott a garantált esetben is elérhetőnek kellett lennie a konstruktornak. Ez a példa nem javasolt mintaként szolgál, hanem annak illusztrálására, hogy a két elision-fajta feltételei és következményei eltérnek.

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

A leggyakoribb hiba a return std::move(local); használata lokális változóra. A javítás mechanikus: a std::move elhagyása, és a return local; írása. Ez megőrzi az NRVO esélyét, és ha az NRVO nem történik meg, az implicit move akkor is mozgatni fog. A második hiba ennek a fordítottja: a return local; mintától elvárni, hogy garantáltan nulla másolással járjon. A garancia csak a prvalue-esetre vonatkozik; NRVO-nál a helyes állítás az, hogy „a fordító valószínűleg elvégzi, de nem köteles rá”.

A harmadik hiba a konstruktorokba és destruktorokba írt oldalhatásokra építés: számláló, logolás, lockolás, erőforrás-felszabadítás a copy konstruktorban. Mivel az elision opcionális, ezek száma nem determinisztikus, és a program viselkedése fordítófüggő lesz. A javítás az, hogy az ilyen oldalhatásokat külön, explicit életciklus-kezelő hívásokba (pl. close(), release()) helyezzük, és a destruktort idempotenssé tesszük.

A negyedik hiba a std::move és a szeletelés összekeverése: ha a lokális típusa származtatott, a visszatérési típus pedig bázis, a std::move bázisra konvertál, és a származtatott rész elveszik. Ez a slicing problémája, és nem az elision kérdése; a részletes tárgyalás az öröklésről szóló részre tartozik. Végül az ötödik hiba a referenciát visszaadó függvény és az értéket visszaadó függvény összetévesztése: a T& visszatérésnél nincs elision, mert nincs mit inicializálni — ott a dangling kockázata a fő kérdés, ami a Move constructor, moved-from állapot és std::move (22.3) alfejezethez kapcsolódik.

22.4.5 Összefoglaló

A C++17 garantált prvalue-konstrukciója nyelvi szabály: ha a return kifejezés prvalue és típusa megegyezik a visszatérési típussal, a prvalue közvetlenül a visszatérési objektumot inicializálja, copy/move konstruktor nélkül, sőt azok hiányában is. Az NRVO ezzel szemben megengedett, de nem kötelező elhagyás, amelynek feltétele, hogy a visszaadott kifejezés egy automatikus tárolási idejű lokális változó neve legyen; ilyenkor a copy/move konstruktornak elérhetőnek kell lennie. Az implicit move szabály a return local; alakot rvalue-ként kezeli az overload resolution során, így NRVO hiányában is move történik — ezért a return std::move(local); felesleges és káros, mert kizárja az NRVO-t. A tagra vagy paraméterre alkalmazott std::move viszont indokolt lehet, mert ezekre az implicit move nem terjed ki. Az elision destruktorokat is elhagy, ezért a konstruktorok és destruktorok oldalhatásaira építeni hibás; a megfigyelés pedig mindig fordító-, verzió- és kapcsolófüggő, amit a mérés dokumentálásakor rögzíteni kell.

22.4.6 Feladatok

1. feladat (magyarázó). Hasonlítsd össze a példaprogram make_prvalue és make_named függvényét! Melyik esetben garantált, hogy nem hívódik meg copy vagy move konstruktor, és melyik esetben csak valószínű? Fogalmazd meg, mi a pontos feltétel különbsége, és hogyan viselkedik a make_prvalue C++14-ben.

2. feladat (kódolási). Az alábbi függvény szándékosan rossz mintát mutat. Javítsd, indokold a javítást, és add meg, milyen esetben lenne mégis helyes a std::move használata.

Tracked build(int n) {
    Tracked local{n};
    return std::move(local);
}

3. feladat (diagnosztikai). Írj egy Tracked pick(bool first) függvényt, amely két különböző lokális változó közül ad vissza egyet a feltétel alapján. Egészítsd ki a számlálókat, add meg a build parancsot, és magyarázd el, miért nem alkalmazható itt NRVO, valamint hogy milyen copy/move számot vársz. Milyen ellenőrzési feltétellel tudod eldönteni, hogy a megfigyelésed megfelel-e az elvárásnak?

4. feladat (elemző). A Pinned példában melyik függvény fordul le C++17-ben, és melyik nem? Indokold a választ az implicit move szabályával és az NRVO feltételével, majd mondd meg, mi a helyzet C++14-ben. Milyen minimális módosítással tehető mindkét függvény C++17-kompatibilissé?

22.4.7 Megoldások

1. megoldás. A make_prvalue visszatérési kifejezése Tracked{1}, azaz prvalue, amelynek típusa megegyezik a visszatérési típussal. C++17-ben ezért a prvalue közvetlenül inicializálja a visszatérési objektumot: nincs temporális, nincs copy/move, és a konstruktoroknak nem is kell létezniük. Ez garantált. A make_named visszatérési kifejezése a local név, azaz automatikus tárolási idejű lokális változó, ezért NRVO-jelölt: a fordító elhagyhatja a copy/move hívást, de nem köteles rá; ha nem éli vele a lehetőséget, az implicit move szabály miatt move történik. C++14-ben a make_prvalue esetében sincs garancia: a szabvány csak megengedi az elhagyást, és a copy/move konstruktornak elérhetőnek kell lennie. Ellenőrzési feltétel: a válasz akkor helyes, ha kimondja, hogy a garancia a prvalue-esethez kötött, és hogy C++14-ben ez a garancia nem létezik.

2. megoldás. A javítás: return local; — a std::move elhagyása. Ezzel az NRVO lehetősége megmarad, és ha a fordító nem éli vele, az implicit move akkor is mozgatni fogja a lokálist, mert a return kifejezés egy automatikus tárolási idejű objektumot megnevező azonosító-kifejezés. A std::move akkor lenne indokolt, ha a visszaadott kifejezés nem ilyen lokális változó: például egy tag (return std::move(member_);) vagy egy függvényparaméter (return std::move(param);) esetén, mert ezekre az implicit move szabály nem terjed ki, és std::move nélkül copy történne. Ellenőrzési feltétel: a javított függvény fordul, a Tracked számlálói a pick-től függetlenül azt mutatják, hogy a build visszatérésekor legfeljebb egy move történik, és az NRVO engedélyezett konfigurációban nulla.

3. megoldás. A függvény például:

Tracked pick(bool first) {
    Tracked a{10};
    Tracked b{20};
    if (first) return a;
    return b;
}

Itt mindkét return egy-egy lokális változó nevét adja vissza, de különböző objektumokét. Az NRVO feltétele az, hogy a visszaadott kifejezés egy konkrét lokális objektumot nevezzen meg; két különböző lokális esetén a fordító nem tudja mindkettőt ugyanabba a célhelybe építeni, ezért a gyakorlatban move történik (a nem kiválasztott lokális pedig a saját destruktorával lezárul). A várt megfigyelés: a kiválasztott ágon egy move, a másik ágon a lokális destruktora; a számlálók értéke tehát move >= 1. Ellenőrzési feltétel: a program lefut, a kiválasztott objektum id értéke megmarad (10 vagy 20), és a számlálók konzisztensek a konstruktorok és destruktorok számával; a megfigyelést a fordító és a kapcsolók rögzítésével együtt kell dokumentálni, mert az NRVO hiánya itt nem garantált, csak tipikus.

4. megoldás. C++17-ben a make_pinned lefordul, mert a return Pinned{}; prvalue, és a garantált prvalue-konstrukcióhoz nem kell copy vagy move konstruktor. A make_pinned_named nem fordul le, mert a return p; NRVO-jelölt, és ha az NRVO nem történik meg, az implicit move szabály miatt a törölt move konstruktort kellene hívni — a program ill-formed. C++14-ben mindkét függvény hibás, mert a copy/move konstruktornak elérhetőnek kell lennie akkor is, ha a fordító elhagyja a hívást. A minimális javítás: a Pinned copy és move konstruktorának törlése helyett definiálása (vagy legalább a move konstruktor engedélyezése), illetve a névvel történő visszatérés elkerülése és a return Pinned{}; alak használata. Ellenőrzési feltétel: a javított változat mindkét függvénye lefordul C++17-ben, és a make_pinned esetében a számlálók nulla copy/move-ot mutatnak.

22.4.8 Források és ellenőrzési korlátok

A fejezet állításai a C++17 ISO szabvány copy elisionre és implicit move-ra vonatkozó szabályain alapulnak, valamint a C++20-as kiterjesztés ismertetett körére. A generálás során nem történt forrásellenőrzés: a konkrét szakaszhelyeket, a C++20-as kiterjesztés pontos hatókörét és a fordítók viselkedését a választott szabványban, illetve a fordító dokumentációjában kell ellenőrizni. Kiindulópontként a WG21 nyilvános oldala (https://www.open-std.org/jtc1/sc22/wg21/), a C++23 munkatervezet (https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/n4950.pdf) és a C++ Core Guidelines (https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines) szolgálhat; ezek a keresés kiindulópontjai, nem a fejezet állításainak bizonyítékai. A Core Guidelines ajánlás, nem normatív ISO-szöveg. A példák nincsenek lefuttatva; a táblázat várható értékeket tartalmaz, nem mért eredményeket.