26.3 Forwarding reference és perfect forwarding

A perfect forwarding célja, hogy egy argumentumot úgy adjunk tovább egy hívási láncon, hogy a végső célfüggvény pontosan ugyanazt a value category-t (lvalue/rvalue) és ugyanazokat a cv-minősítőket (const/volatile) lássa, mintha a hívó közvetlenül őt hívta volna. A téma látszólag deklarációs trükk, valójában értékszemantikai szerződés: könyvtári factory-k, emplace és wrapper API-k mind erre épülnek, és a félreértése néma másolást, teljesítményvesztést vagy épp nem szándékolt átvételt okoz.

Ez az alfejezet a Függvénysablon, instantiation és deduction (26.1) template-dedukciójára és a Move constructor, moved-from állapot és std::move (22.3) értékkategória-kezelésére épül. A std::move ott az “erőforrás átadásának jelzése” volt; itt élesen elválasztjuk a std::forward “változatlan továbbadás” szándékától. A C++20 alapot használjuk, a requires-szel megfogalmazott konstraint csak említés szintjén jelenik meg, mert részletes kezelése a Concepts, requires és constrained overload (27.2) témája.

26.3.1 A probléma: miért nem elég egy sima T&&

Tegyük fel, hogy van két sink overloadunk, egy lvalue-re és egy rvalue-re, és szeretnénk egy-egy köztes függvényt, amely csak továbbad. Naiv megoldás:

void sink(int&);
void sink(int&&);

void relay_lvalue(int&  x) { sink(x); }        // OK: lvalue marad
void relay_rvalue(int&& x) { sink(x); }        // HIBA: x maga lvalue!

A második függvényben x deklarált típusa int&&, de mivel nevesített változó, value category-je lvalue, ezért a sink(int&) hívódik. Javíthatjuk sink(std::move(x))-szel, de akkor a két függvényre ketté kell választani — és N paraméter, illetve minden cv-kombináció esetén ez nem skálázódik. Kell egy sablon, amely megőrzi az argumentum értékkategóriáját.

26.3.2 T&& dedukció és reference collapsing

Tekintsük a kanonikus alakot:

template<class T>
void f(T&& p);

Ez forwarding reference (köznyelvi, Scott Meyers-féle “universal reference”; a szabvány hivatalosan nem használja ezt a nevet, csak a T&& dedukciós alakot írja le). A dedukció két esetre bomlik, és ehhez kell a reference collapsing szabály:

U&&→U&U&&&→U&U&&&→U&U&&&&→U&& \begin{aligned} U\& \;\&\; &\to U\& \\ U\& \;\&\&\; &\to U\& \\ U\&\& \;\&\; &\to U\& \\ U\&\& \;\&\&\; &\to U\&\& \end{aligned}

Mnemotechnika: ha bármelyik oldalon & szerepel, az eredmény &; csak a && && marad &&.

26.3.2.1 A két dedukciós eset

Lvalue argumentum U típusú (esetleg const U, volatile U): a dedukált T U& lesz (a cv megmarad, tehát const U&), azaz a T&& paraméter típusa U& && → collapse → U&.

Rvalue argumentum U típusú: a dedukált T U, a paraméter típusa U&&.

Numerikusan, int i; const int ci = 0; mellett:

Hívás Argumentum kategóriája Dedukált T p típusa a p névkifejezés std::forward<T>(p)
f(i) lvalue int& int& lvalue lvalue (int&)
f(ci) lvalue (const) const int& const int& lvalue lvalue (const int&)
f(42) prvalue int int&& lvalue xvalue (int&&)
f(std::move(i)) xvalue int int&& lvalue xvalue (int&&)

Ez a táblázat a forwarding reference egész lényege. A p név mint kifejezés minden sorban lvalue — egy névvel rendelkező változó mindig lvalue, akkor is, ha a típusa int&&. Az eredeti argumentum értékkategóriáját a dedukált T őrzi meg, és a std::forward<T>(p) állítja vissza: lvalue argumentumra lvalue-t, rvalue argumentumra xvalue-t ad.

// fwd_category.cpp — C++20: a p név mindig lvalue, a forward visszaadja az eredetit
#include <type_traits>
#include <utility>

template<class T>
constexpr int f(T&& p) {
    static_assert(std::is_lvalue_reference_v<decltype((p))>);    // p: mindig lvalue
    using Fwd = decltype(std::forward<T>(p));
    if constexpr (std::is_lvalue_reference_v<T>) {
        static_assert(std::is_lvalue_reference_v<Fwd>);          // forward: lvalue
        return 1;
    } else {
        static_assert(std::is_rvalue_reference_v<Fwd>);          // forward: xvalue
        return 2;
    }
}

int main() {
    int i = 0; const int ci = 0;
    static_assert(f(i) == 1 && f(ci) == 1 && f(42) == 2 && f(std::move(i)) == 2);
}

Buildjegyzőkönyv (lab-gcc profil). g++ -std=c++20 -Wall -Wextra -Wpedantic fwd_category.cpp figyelmeztetés nélkül fordul: mind a négy hívásnál teljesül a decltype((p)) lvalue-referencia volta, és a std::forward<T>(p) lvalue-, illetve rvalue-referencia típusa a táblázat szerinti.

26.3.3 Forwarding reference versus konkretizált T&&

A “forwarding reference” nem minden T&&. Négy feltételnek egyszerre kell teljesülnie:

  1. A T (vagy más néven a paramétertípus) template paramétere egy függvénysablonnak — nem egy osztálysablon tagjának fix paramétere —, vagy auto dedukciós kontextusban szerepel.
  2. A paraméter alakja szó szerint T&&, azaz cv nélküli T és && — const T&&, volatile T&& nem forwarding.
  3. A híváskor tényleges dedukció történik, nem explicit template argumentum (f<int>(x) esetén T = int, p típusa int&&, nem forwarding).
  4. Ha osztálysablonon belül van, a tag maga is függvénysablon (template<class U> void g(U&&);), nem az osztály T paramétere.

Ellenpéldák, hogy a különbség éles legyen:

void h1(int&&);                    // konkrét típus, csak rvalue-t fogad, nincs dedukció

template<class T>
void h2(const T&&);                // NEM forwarding: lvalue nem köthető rá

template<class T>
struct Box {
    void m1(T&&);                   // NEM forwarding: T az osztály paramétere
    template<class U>
    void m2(U&&);                   // forwarding: U a tag template paramétere
};

template<class T>
void h3(T&& t) { /* forwarding */ }

auto&& x = produce();              // forwarding reference (auto kontextusban)

h2 esetén h2(i) (lvalue int) fordítási hiba: a T&& alakú forwarding csak úgy tud lvalue-t fogadni, hogy a T maga &-t tartalmaz. Ha a const már a paraméter szintjén ott van, a T&& nem tud &-t “nyelni”.

26.3.4 std::forward<T> és std::move — két különböző szándék

A std::forward<T>(t) feltételes kaszt:

// egyszerűsített vázlat, C++14 utáni alak
template<class T>
constexpr T&& forward(std::remove_reference_t<T>& t) noexcept {
    return static_cast<T&&>(t);
}

Ha T = U&, akkor T&& = U&, a visszaadott referenciatípus U& → lvalue marad. Ha T = U, akkor T&& = U&& → rvalue lesz. A std::forward tehát nem “mozgat”, hanem visszaadja azt a value category-t, amit a T argumentum kódol.

Ezzel szemben a std::move(t) mindig static_cast<remove_reference_t<decltype(t)>&&>(t) — feltétel nélkül rvalue-t csinál. A két függvény közötti választás nem stílus, hanem szándék:

Helyes használat:

#include <iostream>
#include <utility>

struct Tracer {
    Tracer() = default;
    Tracer(const Tracer&) { std::cout << "copy\n"; }
    Tracer(Tracer&&) noexcept { std::cout << "move\n"; }
};

void consume(const Tracer&) { std::cout << "consume(const&)\n"; }
void consume(Tracer&&)      { std::cout << "consume(&&)\n"; }

template<class T>
void relay(T&& t) {
    std::cout << "relay: ";
    consume(std::forward<T>(t));    // értékkategória megőrzése
}

template<class T>
void relay_bad(T&& t) {
    std::cout << "relay_bad: ";
    consume(std::move(t));          // mindig rvalue: ellopná az lvalue-t is
}

int main() {
    Tracer t;
    const Tracer ct;
    relay(t);             // T = Tracer&         -> consume(const&)
    relay(ct);            // T = const Tracer&   -> consume(const&)
    relay(Tracer{});      // T = Tracer          -> consume(&&)
    relay_bad(t);         // T = Tracer&, de move -> consume(&&): hibás szándék
}

A fenti program a szerző szándéka szerint a jelzett három sort írja ki a relay_bad hibás sorával együtt (consume(const&), consume(const&), consume(&&), majd consume(&&)); a kód nem futtatott és nem tesztelt, a kimenet csak elvárt illusztráció. A fordítás tipikus parancsa:

g++     -std=c++20 -Wall -Wextra -pedantic-errors -o relay relay.cpp
# vagy
clang++ -std=c++20 -Wall -Wextra -pedantic-errors -o relay relay.cpp

Kritikus pont: a std::forward<T> argumentumának pontosan a dedukált T-nek kell lennie. Ha std::forward<U>-t írunk olyan U-val, amely nem a p dedukált típusa, a kaszt rossz értékkategóriát állít elő — ez a leggyakoribb “majdnem helyes” hiba.

26.3.5 Forwarding factory és emplace

A std::make_unique, std::make_shared és emplace_back mind forwarding reference-re épül. Az emplace_back variadikus forwarding: az argumentumok Args&&... args alakban érkeznek, és a tároló a célobjektum helyén hívja meg a konstruktort.

#include <vector>
#include <string>
#include <iostream>

struct Widget {
    std::string name;
    int value;
    Widget(std::string n, int v) : name(std::move(n)), value(v) {
        std::cout << "ctor " << name << '=' << value << '\n';
    }
    Widget(const Widget& o) : name(o.name), value(o.value) {
        std::cout << "copy " << name << '\n';
    }
    Widget(Widget&& o) noexcept : name(std::move(o.name)), value(o.value) {
        std::cout << "move " << name << '\n';
    }
};

int main() {
    std::vector<Widget> v;
    v.reserve(8);
    std::string s = "alpha";
    v.emplace_back(s, 1);              // s lvalue  -> string copy
    v.emplace_back(std::move(s), 2);   // s rvalue  -> string move
    v.emplace_back("gamma", 3);        // const char* -> string(ptr)
}

Itt az emplace_back az Args vektorát ugyanúgy dedukálja, mint bármelyik függvénysablon: s lvalue -> Args = {std::string&, int}, a forward az std::string&-et továbbítja, a Widget konstruktora másol. std::move(s) esetén Args = {std::string, int}, a forward rvalue-t ad, és a Widget std::string tagja move-ol. A viselkedés Tracer-szerű nyomkövetéssel ellenőrizhető; a kimenet itt is elvárt, nem mért.

26.3.6 A greedy forwarding konstruktor

A legravaszabb csapda: egy template<class T> X(T&&) konstruktor versenybe száll a copy/move konstruktorral, és lvalue esetben nyerhet ellene. Ez azért van, mert a const X& és az X& között a kötés minősége eltér: X&-re X& identity, const X&-re const X& qualification conversion, és az identity jobb. Nem-template vs template esetén pedig a nem-template nyer, de csak azonos minőségű kötéseknél.

#include <iostream>
#include <utility>

class Node {
public:
    int tag = 0;
    Node() = default;
    template<class T>
    Node(T&&) : tag(1) { std::cout << "template ctor\n"; }
    Node(const Node&) : tag(2) { std::cout << "copy ctor\n"; }
    Node(Node&&) noexcept : tag(3) { std::cout << "move ctor\n"; }
};

int main() {
    Node a;
    Node b(a);                  // template ctor nyer: T = Node&, Node& pontosabb, mint const Node&
    const Node c;
    Node d(c);                  // copy ctor nyer: nem-template döntetlenben
    Node e(std::move(a));       // move ctor nyer: nem-template döntetlenben
    std::cout << b.tag << ' ' << d.tag << ' ' << e.tag << '\n';   // elvárt: 1 2 3
}

A hiba lényege: Node b(a) esetén a template<class T> Node(T&&) T = Node&-del identity kötést ad, és a copy constructort megelőzi. Ezért a C++20 előtti kódok tipikusan std::enable_if-fel kizárták az azonos típust (!std::is_same_v<std::decay_t<T>, Node>), C++20-ban a requires a természetes eszköz:

template<class T>
    requires (!std::same_as<std::remove_cvref_t<T>, Node>)
Node(T&&) : tag(1) { /* ... */ }

A requires-szel kapcsolatos részletek a Concepts, requires és constrained overload (27.2) témához tartoznak; itt csak a diagnosztikai minta a fontos: ha egy forwarding konstruktor unexpected módon nyer, az első kérdés mindig az, hogy kizártuk-e a saját típust.

26.3.7 Korlátok: braced-init-list, overload set, bit-field

A forwarding reference nem csodaszer; három tipikus esetben a dedukció egyszerűen nem tud végbemenni, mert nincs dedukálható típus:

#include <initializer_list>

struct S { int b : 4; };
void g(int);
void g(double);

template<class T> void f(T&&);

S s;
f(s.b);                                       // HIBA: bit-field nem köthető referenciához
f(int(s.b));                                  // OK: ideiglenes int

f({1, 2, 3});                                 // HIBA: braced-init-list nem típus
f<std::initializer_list<int>>({1, 2, 3});     // OK: explicit template argumentum

f(g);                                         // HIBA: overload set nem egy típus
f(static_cast<void(*)(int)>(g));              // OK: konkrét függvénypointer

A braced-init-list {1,2,3} csak akkor vihető át, ha a cél típusa ismert — emiatt emplace_back({1,2,3}) sem működik, még akkor sem, ha a cél Widget{ std::initializer_list<int> }-et várna. Az overload set neve nem típus; a void g(int) és void g(double) között a dedukció nem tud választani, ezért kell cast vagy +g jellegű kényszerítés (bár a +g overload set esetén is csak akkor működik, ha pontosan egy g fordul elő, mert az unáris + is túlterhelés-feloldást végez). A bit-field nem rendelkezik címmel, így referencia nem köthető hozzá — ilyenkor kényszerített másolás (int(s.b)) a helyes válasz.

26.3.8 Gyakori hibák

  1. std::forward<T> helyett std::move. Egy relay wrapperben a std::move néma rvalue-konverziót okoz, és lvalue argumentumot is “ellop” a hívó elől.
  2. std::forward<T> és std::move felcserélése az owner oldalon. A std::forward<T>(member) ott, ahol a hívó szándéka az átadás, csak látszólag helyes: az T szerint viselkedik, miközben a member sose volt forward referenciánk.
  3. const T&& vagy konkrét T&& összetévesztése a forwarding reference-szel. A const T&& nem fogad el lvalue-t, a konkrét int&& pedig egyáltalán nem dedukál.
  4. A T kétszeri dedukálása. template<class T> void f(T&& a, T&& b) esetén a két argumentumnak ugyanazt a T-t kell dedukálnia, így lvalue + rvalue pár nem megy át.
  5. Greedy konstruktor. Forwarding konstruktor saját típus kizárása nélkül elnyeli a copy/move hívásokat.
  6. std::forward nem a T-vel. std::forward<SomeOtherType>(p) csendben rossz értékkategóriát ad.

26.3.9 Feladatok

1. feladat (dedukciós tábla). Egészítse ki a táblázatot a template<class T> void f(T&& p); sablonhoz az alábbi hívásokra: int i; const int ci; volatile int vi; int&& ri = std::move(i); esetén f(i), f(ci), f(vi), f(ri), f(std::move(ci)). Adja meg a dedukált T-t, a p típusát és value category-jét, indokolja a reference collapsing lépést.

2. feladat (relay implementálása). Írjon template<class T> void relay(T&& x) wrapper-t, amely a consume(const Tracer&) / consume(Tracer&&) overload párra továbbítja az argumentumot. Készítsen tesztet lvalue, const lvalue és rvalue esetre, és ellenőrizze a kiválasztott overloadot. Mutassa meg a std::move-os hibaváltozatot, és magyarázza el, miért rossz.

3. feladat (greedy diagnózis). Adott a Node osztály, amelynek forwarding konstruktora van. Írjon egy hívási sorozatot, amelyben a forwarding konstruktor elnyeli a copy constructort, majd javítsa a kódot C++20 requires-szel. Adjon egy ellenőrzési feltételt: milyen hívásra melyik konstruktor fut, és miért.

26.3.10 Megoldások

26.3.10.1 1. feladat megoldása

Hívás T p típusa p value category
f(i) int& int& lvalue
f(ci) const int& const int& const lvalue
f(vi) volatile int& volatile int& volatile lvalue
f(ri) int& int& lvalue (nevesített!)
f(std::move(ci)) const int const int&& const rvalue

Indoklás: f(ri) esetén ri deklarált típusa int&&, de nevesített, ezért value category-je lvalue, így T = int&, és a collapse után a p típusa int& — nem int&&. Az utolsó esetben a std::move(ci) const int&&-t ad, a dedukció T = const int, a paraméter const int&&.

26.3.10.2 2. feladat megoldása

#include <iostream>
#include <utility>

struct Tracer {
    Tracer() = default;
    Tracer(const Tracer&) { std::cout << "copy\n"; }
    Tracer(Tracer&&) noexcept { std::cout << "move\n"; }
};

void consume(const Tracer&) { std::cout << "consume(const&)\n"; }
void consume(Tracer&&)      { std::cout << "consume(&&)\n"; }

template<class T>
void relay(T&& x) {
    consume(std::forward<T>(x));
}

template<class T>
void relay_bad(T&& x) {
    consume(std::move(x));   // mindig rvalue
}

int main() {
    Tracer t;
    const Tracer ct;
    relay(t);     // consume(const&) – T = Tracer&
    relay(ct);    // consume(const&) – T = const Tracer&
    relay(Tracer{});        // consume(&&) – T = Tracer
    relay_bad(t); // consume(&&) – de t-t elloptuk volna
}

Ellenőrzési feltétel: relay(t) és relay(ct) sora consume(const&), relay(Tracer{}) sora consume(&&). Ha bármelyik korábbi sor consume(&&)-t ad, a T nem a dedukált típus vagy hibás a forward. relay_bad(t) bizonyítja, hogy a std::move minden lvalue-t rvalue-ként továbbít — ez a wrapperben néma másolás-eltörlés (esetleg adatvesztés) felé vezet.

26.3.10.3 3. feladat megoldása

#include <iostream>
#include <utility>

class Node {
public:
    int tag = 0;
    Node() = default;
    template<class T>
        requires (!std::same_as<std::remove_cvref_t<T>, Node>)
    Node(T&&) : tag(1) { std::cout << "template\n"; }
    Node(const Node&) : tag(2) { std::cout << "copy\n"; }
    Node(Node&&) noexcept : tag(3) { std::cout << "move\n"; }
};

int main() {
    Node a;
    Node b(a);                  // copy ctor (constraint kizárja a template-et) -> tag 2
    Node d(std::move(a));       // move ctor -> tag 3
    Node e(42);                 // template ctor -> tag 1
    std::cout << b.tag << ' ' << d.tag << ' ' << e.tag << '\n';  // elvárt: 2 3 1
}

A requires (!std::same_as<std::remove_cvref_t<T>, Node>) konstraint kizárja, hogy a forwarding konstruktor bármely cv/ref kombinációjú Node-ra versenyezzen. A remove_cvref_t C++20-ban elérhető, az std::same_as szintén. A Node e(42) hívás mutatja, hogy a template továbbra is fogad idegen típust — a konstraint nem tiltja le a forwardingot, csak a saját típusra zárja ki.

26.3.11 Összefoglaló

A forwarding reference a T&& azon alakja, ahol T függvénysablon-paraméter, cv nélküli, és tényleges dedukció történik. Lvalue argumentumra T = U&, rvalue-ra T = U; a reference collapsing biztosítja, hogy a paraméter pontosan az eredeti értékkategóriát és cv-jét tükrözze. A std::forward<T> ennek a value category-nek a továbbadása, a std::move feltétel nélküli rvalue-konverzió — a kettő nem ekvivalens, és a wrapper/factory mintákban a forward a helyes eszköz, a move az owner oldal eszköze. A const T&&, int&& konkrét alakok és az osztálysablon fix T-je nem forwarding reference-ek. A braced-init-list, overload set és bit-field nem dedukálható, ezekhez célzott kényszerítés vagy explicit template argumentum kell. A greedy forwarding konstruktor kizárja magát a saját típusra, C++20-ban tipikusan requires-szel; a részletek a concepts fejezetben következnek.