C++ std::forward and Perfect Forwarding: How It Works and Where It Breaks

Key takeaways

std::forward<T>(arg) is a conditional cast: it yields an rvalue only if the caller passed an rvalue. This article shows how it is implemented, how it differs from std::move, and the cases where forwarding silently fails or misfires: forwarding twice, braced lists, 0/NULL, overloaded names, bit-fields and greedy forwarding constructors.

Perfect forwarding is the technique that lets std::make_unique, emplace_back, std::thread and every generic wrapper pass their arguments on to another function as if the caller had called that function directly: lvalues arrive as lvalues (and get copied), rvalues arrive as rvalues (and get moved). It takes two ingredients, a forwarding reference parameter T&& and std::forward<T>. The deduction rules behind T&& are covered in C++ universal (forwarding) references; this article is about std::forward itself: what it actually does, how it differs from std::move, and the cases where forwarding does the wrong thing or refuses to compile.

All snippets were compiled with g++ 10.3 (-std=c++17 unless noted), and the outputs shown are what they printed.


The problem: a named rvalue reference is an lvalue

#include <iostream>
#include <string>
#include <utility>

void process(std::string&)  { std::cout << "lvalue overload\n"; }
void process(std::string&&) { std::cout << "rvalue overload\n"; }

template<typename T> void bad(T&& arg)  { process(arg); }
template<typename T> void good(T&& arg) { process(std::forward<T>(arg)); }

int main() {
    std::string s = "text";
    bad(s);                  // lvalue overload
    bad(std::string("tmp")); // lvalue overload  <- the caller passed an rvalue
    bad("literal");          // rvalue overload  <- surprise, see below
    good(s);                 // lvalue overload
    good(std::string("tmp"));// rvalue overload
}

Output:

lvalue overload
lvalue overload
rvalue overload
lvalue overload
rvalue overload

Inside bad, arg has a name, and any expression that names a variable is an lvalue, even when the variable’s type is std::string&&. The information “the caller gave me a temporary” was not lost, though: it was recorded in T. For bad(s) the compiler deduced T = std::string&; for bad(std::string("tmp")) it deduced T = std::string. std::forward<T> reads that back.

The third line is a trap worth knowing about. bad("literal") deduces T = const char(&)[8], so arg is a reference to a char array. process(arg) then has to build a temporary std::string from it, and a temporary can only bind to std::string&&. You get the rvalue overload without any forwarding at all, which is exactly the kind of accidental “it works” that makes a missing std::forward survive code review.


What std::forward actually is

The whole of std::forward (the lvalue overload that matters here) is one cast:

template<typename T>
constexpr T&& forward(std::remove_reference_t<T>& x) noexcept {
    return static_cast<T&&>(x);
}

Plug in the two possible deductions and apply reference collapsing (& && becomes &, && && becomes &&):

Caller passedDeduced Tstatic_cast<T&&> becomesResult
lvalue std::stringstd::string&static_cast<std::string&>lvalue
rvalue std::stringstd::stringstatic_cast<std::string&&>xvalue (rvalue)
const lvalueconst std::string&static_cast<const std::string&>const lvalue

Two details explain the API:

  • The parameter type std::remove_reference_t<T>& is a non-deduced context. That is deliberate. If forward could deduce T from its argument, it would always see an lvalue and always deduce an lvalue reference, which is useless. Writing std::forward<T>(arg) is not optional ceremony; the explicit T is the only channel through which the caller’s value category reaches the cast.
  • It generates no code. Like std::move, it is a cast that changes which overload gets chosen. There is no runtime cost and nothing is moved by std::forward itself. The move (if any) happens in whatever constructor or function receives the rvalue.

std::forward vs std::move

std::move(x) is an unconditional cast to rvalue. std::forward<T>(x) is a conditional one. The difference only shows up when the caller passes an lvalue, and that is exactly when using the wrong one hurts:

void sink(std::string s) { std::cout << "sink got [" << s << "]\n"; }

template<typename T> void greedy(T&& arg) { sink(std::move(arg)); }  // wrong

int main() {
    std::string s = "caller still needs this";
    greedy(s);
    std::cout << "s after greedy: [" << s << "]\n";
}
sink got [caller still needs this]
s after greedy: []

The caller passed a named lvalue, did not write std::move, and still lost the contents of s. From the caller’s side this is indistinguishable from a bug in the callee, and it usually surfaces far away, as an empty string in a log line or a container that is mysteriously empty.

The rule of thumb:

  • Parameter is T&& with T deduced by this function template (or auto&&): use std::forward<T> / std::forward<decltype(x)>(x).
  • Parameter is Widget&&, std::vector<int>&&, or T&& where T is a class template parameter already fixed (e.g. void push(T&& v) inside template<class T> class Queue): it is a plain rvalue reference, use std::move.

The second bullet is the one people trip on. In std::vector<T>::push_back(T&&), T is not deduced at the call, so that parameter is not a forwarding reference, and std::forward there would simply behave like std::move while misleading the reader.


Forwarding packs: make_unique and emplace

Variadic forwarding is the same idea applied element by element: std::forward<Args>(args)... expands to one std::forward<Ai>(ai) per argument, each with its own deduced type. Here is a stripped-down make_unique next to a version that takes its arguments by value, instrumented to show what gets copied:

struct Tracer {
    Tracer() = default;
    Tracer(const Tracer&) { std::cout << "  copy\n"; }
    Tracer(Tracer&&) noexcept { std::cout << "  move\n"; }
};
struct Widget {
    Tracer t; int id;
    Widget(Tracer tr, int i) : t(std::move(tr)), id(i) {}
};

template<typename T, typename... Args>
std::unique_ptr<T> my_make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
template<typename T, typename... Args>
std::unique_ptr<T> copying_make_unique(Args... args) {   // no forwarding
    return std::unique_ptr<T>(new T(args...));
}

int main() {
    Tracer tr;
    std::cout << "my_make_unique(lvalue):\n";      auto a = my_make_unique<Widget>(tr, 1);
    std::cout << "my_make_unique(rvalue):\n";      auto b = my_make_unique<Widget>(Tracer{}, 2);
    std::cout << "copying_make_unique(rvalue):\n"; auto c = copying_make_unique<Widget>(Tracer{}, 3);
}
my_make_unique(lvalue):
  copy
  move
my_make_unique(rvalue):
  move
  move
copying_make_unique(rvalue):
  copy
  move

The second move in every case is Widget’s own constructor moving its by-value parameter into the member; that is Widget’s design, not the wrapper’s. The line that matters is the first one: with forwarding, an rvalue argument is moved into Widget’s parameter; without it, the wrapper holds the argument in a named variable, and passing a named variable on means a copy. For a Tracer that is a printed line; for a std::vector with a million elements or a type with no copy constructor at all (std::unique_ptr) it is the difference between cheap, expensive and “does not compile”.

emplace_back, std::make_shared, std::optional::emplace, std::thread’s constructor and std::invoke all follow this exact shape. If you write a wrapper around any of them, forward the pack; otherwise your wrapper quietly reintroduces the copies the library was designed to avoid.


Forwarding the same argument twice

void sink(std::string s) { std::cout << "sink got [" << s << "]\n"; }

template<typename T> void twice(T&& arg) {
    sink(std::forward<T>(arg));
    sink(std::forward<T>(arg));   // bug when T is not an lvalue reference
}

int main() {
    std::string s = "payload that is long enough to avoid SSO";
    twice(s);
    twice(std::move(s));
}
sink got [payload that is long enough to avoid SSO]
sink got [payload that is long enough to avoid SSO]
sink got [payload that is long enough to avoid SSO]
sink got []

With an lvalue argument both calls copy and everything looks fine, which is why this bug passes tests that only use named variables. With an rvalue, the first call moves the buffer into sink’s parameter and the second call sees a moved-from string. Nothing is undefined here (a moved-from std::string is valid but unspecified, in practice empty), so there is no crash, no sanitizer report, just wrong data.

The fix is to treat arg as an lvalue for every use but the last:

template<typename T> void twice(T&& arg) {
    sink(arg);                    // copy
    sink(std::forward<T>(arg));   // move only at the final use
}

I have run into this most often in “log it, then pass it on” wrappers and in retry helpers: a function that forwards a request object into an attempt, and on failure forwards it again into the next attempt. The first retry works in the unit test that uses a named request and sends an empty payload in production, where callers pass temporaries. Loops are the extreme case: std::forward inside a loop body is almost always wrong.


Arguments that cannot be perfectly forwarded

“Perfect” is an overstatement. Forwarding works by deducing a type from the argument, and some expressions have no type to deduce or cannot bind to a reference. For each of these, a direct call compiles but the forwarded call does not (or does something different).

Braced initializer lists

void f(const std::vector<int>&);
template<typename T> void fwd_f(T&& a) { f(std::forward<T>(a)); }

f({1, 2, 3});       // OK
fwd_f({1, 2, 3});   // error
error: no matching function for call to 'fwd_f(<brace-enclosed initializer list>)'
note:   couldn't deduce template parameter 'T'

A braced list is not an expression with a type, so template deduction gives up. emplace_back({1, 2}) fails for the same reason. Workarounds: name it (auto il = {1, 2, 3}; fwd_f(il); deduces std::initializer_list<int>), or construct the target type explicitly (fwd_f(std::vector<int>{1, 2, 3})).

0 or NULL as a null pointer

void g(int*);
template<typename T> void fwd_g(T&& a) { g(std::forward<T>(a)); }

g(NULL);          // OK: a null pointer constant converts to int*
fwd_g(NULL);      // error
error: invalid conversion from 'long long int' to 'int*' [-fpermissive]

The literal 0 (or NULL, which on this MinGW build is __null and deduces as long long) is only a null pointer constant at the point where it is written. Once it has been deduced into an integer-typed parameter, it is just an integer that happens to be zero, and integers do not convert to pointers. Use nullptr, which has its own type, std::nullptr_t, that survives forwarding.

Overloaded function names and function templates

int h(int);
int h(double);
void take(int (*)(int));
template<typename T> void fwd(T&& a) { take(std::forward<T>(a)); }

take(h);   // OK: the target type picks h(int)
fwd(h);    // error
error: no matching function for call to 'fwd(<unresolved overloaded function type>)'
note:   couldn't deduce template parameter 'T'

A direct call resolves the overload set against the parameter type. A forwarding template has no parameter type yet, so there is nothing to resolve against. Pick the overload explicitly: fwd(static_cast<int(*)(int)>(h)), or wrap it in a lambda, fwd([](int x) { return h(x); }), which is usually more readable and also works for function templates like std::max.

Bit-fields

struct Flags { unsigned ready : 1; };
Flags fl{1};
fwd(fl.ready);   // error
error: cannot bind bit-field 'fl.Flags::ready' to 'unsigned int&'

A non-const reference cannot point at a bit-field because bit-fields have no address. Copy it first: unsigned r = fl.ready; fwd(r); or fwd(static_cast<unsigned>(fl.ready)).


Greedy forwarding constructors hijack copying

A constructor template taking T&& competes with the copy constructor, and for non-const lvalues it wins:

struct Person {
    std::string name;
    template<typename T>
    explicit Person(T&& n) : name(std::forward<T>(n)) {}
    Person(const Person& o) : name(o.name) {}
};

Person a("Ann");
const Person ca("Cat");
Person b(ca);   // copy constructor: exact match for const lvalue
Person c(a);    // error
error: no matching function for call to 'std::__cxx11::basic_string<char>::basic_string(Person&)'

For Person c(a), a is a non-const Person lvalue. The template deduces T = Person& and takes Person&, an exact match. The copy constructor takes const Person&, which needs a qualification conversion. Overload resolution prefers the template, which then tries to initialize a std::string from a Person. With a type that can be constructed from the class (say, a template name member or a variant) this does not even fail to compile; it just silently does the wrong thing. Derived classes calling Base(other) in their copy constructor hit the same issue, since Derived& also prefers the template.

Constrain the template so it drops out when the argument is the class itself (or derived from it):

template<typename T,
         typename = std::enable_if_t<!std::is_base_of_v<Person, std::decay_t<T>>>>
explicit Person(T&& n) : name(std::forward<T>(n)) {}

// C++20
template<typename T>
    requires (!std::is_base_of_v<Person, std::remove_cvref_t<T>>)
explicit Person(T&& n) : name(std::forward<T>(n)) {}

With the constraint, Person c(a) goes to the copy constructor. If the class only ever wraps a std::string, the simpler design is to drop the template and take std::string by value and move it into the member; that costs at most one extra move and has none of these overload problems. Reach for a forwarding constructor when you really need to accept many unrelated types, as std::optional, std::variant and std::function do, and those all carry exactly this kind of constraint.


Forwarding return values: decltype(auto)

A wrapper that forwards arguments usually also needs to forward the result. auto as a return type decays, so a function returning int& comes back as a copy:

std::vector<int> data{1, 2, 3};
int& at(std::size_t i) { return data[i]; }

template<typename F, typename... Args>
auto call_auto(F&& f, Args&&... args) {
    return std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
}
template<typename F, typename... Args>
decltype(auto) call_exact(F&& f, Args&&... args) {
    return std::invoke(std::forward<F>(f), std::forward<Args>(args)...);
}

call_auto(at, 0) = 10;    // error: lvalue required as left operand of assignment
call_exact(at, 0) = 10;   // OK, data[0] is now 10

decltype(auto) keeps the exact type of the return expression, reference included. Be careful with it in the other direction: decltype(auto) on return local; is fine, but return (local); has type T& and returns a dangling reference.

In generic lambdas there is no named T, so you forward with decltype:

auto log = [](auto&&... xs) {
    ((std::cout << std::forward<decltype(xs)>(xs) << ' '), ...);
};

For an lvalue argument decltype(xs) is X& and for an rvalue it is X&&; std::forward<X&&> behaves the same as std::forward<X>, so this is correct even though it looks different from the template form. C++20 also lets you give generic lambdas an explicit template parameter list ([]<typename... Ts>(Ts&&... xs)) if you prefer std::forward<Ts>(xs)....