C++ Reference Collapsing: How T& && Becomes T&, and Why Forwarding References Depend on It
Key takeaways
When a reference type is formed on top of another reference through a template argument, alias, or decltype, C++ collapses the pair: any & wins, and only && + && stays &&. This single rule is what makes T&& a forwarding reference and std::forward work.
Reference collapsing is one of those rules that almost nobody writes code for directly, yet every generic C++ library leans on it. If you have ever wondered why template<class T> void f(T&& x) happily accepts an lvalue, or why std::forward needs you to spell out <T>, the answer is the same four-line table. This article walks through the rules, the exact places they apply, and the mistakes they cause in real code. Every snippet below was compiled with g++ 10.3 (-std=c++17 or -std=c++20), and the diagnostics quoted are what that compiler actually printed.
The rule
C++ has no “reference to reference” type. You cannot write one directly:
int x = 0;
int& & r = x;
error: cannot declare reference to 'int&', which is not a typedef or a template type argument
The error message already tells you the exception. When a reference type is formed through a typedef/alias or a template type argument (and also through decltype), the compiler does not reject it. It collapses the pair into a single reference:
| Inner type | Applied | Result |
|---|---|---|
T& | & | T& |
T& | && | T& |
T&& | & | T& |
T&& | && | T&& |
The short version is: if an lvalue reference appears anywhere, the result is an lvalue reference. You only get && when both are &&.
You can check this yourself with aliases and static_assert. No template machinery needed:
#include <type_traits>
using LRef = int&;
using RRef = int&&;
static_assert(std::is_same_v<LRef&, int&>);
static_assert(std::is_same_v<LRef&&, int&>);
static_assert(std::is_same_v<RRef&, int&>);
static_assert(std::is_same_v<RRef&&, int&&>);
Why the rule is shaped this way
The asymmetry is deliberate. An lvalue reference promises “this names an object that will still exist after this expression.” An rvalue reference promises “nobody else needs this object, feel free to steal from it.” If either layer says the object is a normal, persistent lvalue, treating it as disposable would be unsafe, so & wins. Only when both layers agree the object is expiring does the result stay &&.
A bit of history explains the error message. C++98 as written banned forming a reference to a reference even through a typedef or template argument, which made generic code fragile: instantiate a template that writes T& with T = int& and it simply failed. Core issue 106 proposed collapsing for that case, and C++11 standardized the full four-row table when it added rvalue references.
Where collapsing applies
There are four contexts you will actually meet.
1. Template type arguments. When T is deduced or specified as a reference type, any T& or T&& in the template collapses.
2. typedef / using aliases, as in the static_assert block above, including member aliases in class templates:
template<typename T>
struct Wrapper {
using RefType = T&&; // not always an rvalue reference
};
static_assert(std::is_same_v<Wrapper<int>::RefType, int&&>);
static_assert(std::is_same_v<Wrapper<int&>::RefType, int&>);
3. decltype. decltype(expr) can yield a reference type, and adding && to it collapses. This is exactly what makes the generic-lambda forwarding idiom work (see below).
4. auto&&. auto follows template deduction rules, so auto&& behaves like a forwarding reference:
int x = 10;
auto&& a = x; // auto = int&, a is int&
auto&& b = 10; // auto = int, b is int&&
static_assert(std::is_same_v<decltype(a), int&>);
static_assert(std::is_same_v<decltype(b), int&&>);
How it turns T&& into a forwarding reference
Collapsing alone would not be enough. It works together with a special deduction rule: when a function parameter has the form T&& with T deduced, and the argument is an lvalue of type U, T is deduced as U& (not U). Then collapsing does the rest:
#include <iostream>
#include <type_traits>
#include <utility>
template<typename T>
void show(T&& arg) {
std::cout << std::boolalpha
<< "T is lvalue ref: " << std::is_lvalue_reference_v<T>
<< ", arg is lvalue ref: " << std::is_lvalue_reference_v<decltype(arg)>
<< ", arg is rvalue ref: " << std::is_rvalue_reference_v<decltype(arg)> << '\n';
}
int main() {
int x = 10;
const int cx = 20;
show(x); // T = int&, arg: int& && -> int&
show(cx); // T = const int&, arg: const int&
show(10); // T = int, arg: int&&
show(std::move(x)); // T = int, arg: int&&
}
Output:
T is lvalue ref: true, arg is lvalue ref: true, arg is rvalue ref: false
T is lvalue ref: true, arg is lvalue ref: true, arg is rvalue ref: false
T is lvalue ref: false, arg is lvalue ref: false, arg is rvalue ref: true
T is lvalue ref: false, arg is lvalue ref: false, arg is rvalue ref: true
Notice what the deduced T encodes: T is a reference type exactly when the caller passed an lvalue. That is the whole trick. The parameter arg itself has a name, so as an expression it is always an lvalue inside the body, whatever its declared type. The caller’s value category survives only in T.
What is not a forwarding reference
Because the special deduction rule only fires for the exact form T&& with T deduced for that call, several look-alikes are ordinary rvalue references:
template<class T> void h(const T&&): theconstbreaks the pattern.template<class T> void h(std::vector<T>&&):Tis deduced, but the parameter is notT&&.template<class T> class vector { void push_back(T&&); };:Twas fixed when the class was instantiated, so nothing is deduced at the call.
Passing an lvalue to the const T&& version fails loudly:
error: cannot bind rvalue reference of type 'const W&&' to lvalue of type 'W'
note: initializing argument 1 of 'void h(const T&&) [with T = W]'
How std::forward uses collapsing
Here is the core of std::forward (the standard library also has a second overload for rvalue arguments, with a static_assert that rejects forwarding an rvalue as an lvalue):
template<typename T>
constexpr T&& forward(std::remove_reference_t<T>& arg) noexcept {
return static_cast<T&&>(arg);
}
Walk through both cases:
- Caller passed an lvalue, so
T = int&. The return type isint& &&, which collapses toint&. The cast isstatic_cast<int&>(arg): an lvalue goes out. - Caller passed an rvalue, so
T = int. The return type isint&&, and the cast produces an xvalue: an rvalue goes out.
The remove_reference_t<T>& parameter type is a non-deduced context, which is intentional. It forces you to write std::forward<T>(x); if T were deduced from x, it would always see an lvalue and the information would be lost.
Forwarding versus moving: an observable difference
#include <iostream>
#include <string>
#include <utility>
std::string stored;
void sink(const std::string& v) { stored = v; std::cout << "copy path\n"; }
void sink(std::string&& v) { stored = std::move(v); std::cout << "move path\n"; }
template<typename T> void viaMove(T&& s) { sink(std::move(s)); }
template<typename T> void viaForward(T&& s) { sink(std::forward<T>(s)); }
int main() {
std::string s = "hello";
viaMove(s);
std::cout << "s after viaMove: '" << s << "'\n";
s = "hello";
viaForward(s);
std::cout << "s after viaForward: '" << s << "'\n";
}
move path
s after viaMove: ''
copy path
s after viaForward: 'hello'
viaMove silently gutted the caller’s string. The empty result is what libstdc++ happens to leave behind; the standard only promises a “valid but unspecified” state, which is arguably worse because it may look fine on one platform and not another.
I have seen this exact bug in review more than once: someone writes a generic wrapper, uses std::move because “it’s an rvalue reference parameter,” all the tests pass temporaries, and it ships. The first caller that passes a named variable and uses it afterward gets an empty string or an empty vector with no warning at all. Since then, my rule is mechanical: a T&& parameter with deduced T gets std::forward<T>, a concrete Widget&& parameter gets std::move, and I never mix them.
The generic lambda version
Generic lambdas have no named T, so the idiom is std::forward<decltype(v)>(v). It works only because of collapsing:
auto lam = [](auto&& v) { sink(std::forward<decltype(v)>(v)); };
lam(s); // decltype(v) = std::string& -> forward returns string&
lam(std::string("tmp")); // decltype(v) = std::string&& -> forward<string&&> returns string&& && -> string&&
For the rvalue case, std::forward<std::string&&> is instantiated with T = std::string&&, and its return type T&& collapses to std::string&&. The result is identical to what std::forward<std::string> would give. With the lvalue it prints “copy path”, with the temporary “move path”. In C++20 you can write []<typename T>(T&& v) { sink(std::forward<T>(v)); } instead, which some people find clearer.
Variadic templates: each pack element collapses independently
#include <iostream>
#include <utility>
template<typename T> void printType() { std::cout << __PRETTY_FUNCTION__ << '\n'; }
template<typename... Args> void debug(Args&&...) { (printType<Args>(), ...); }
int main() {
int x = 1;
debug(x, 20, std::as_const(x));
}
void printType() [with T = int&]
void printType() [with T = int]
void printType() [with T = const int&]
So callee(std::forward<Args>(args)...) forwards each argument with its own category. This is how emplace_back, std::make_unique, and std::thread’s constructor pass arguments through without copies.
Pitfalls that come from collapsing
const T& is not always const
This one surprises experienced people. A const applied to a reference type is ignored, since a reference cannot be reseated anyway. So when T is itself a reference, const T& drops the const entirely:
template<typename T> struct Box { using CRef = const T&; };
static_assert(std::is_same_v<Box<int>::CRef, const int&>);
static_assert(std::is_same_v<Box<int&>::CRef, int&>); // const is gone
A class template that exposes a “read-only view” via const T& hands out a mutable reference when someone instantiates it with T = int&. If you mean “const reference to the underlying object”, write const std::remove_reference_t<T>&.
std::vector<T> inside a forwarding function
template<typename T>
void bad(T&& arg) {
std::vector<T> vec; // T = int& for lvalue callers
vec.push_back(std::move(arg));
}
int x = 1;
bad(x);
g++ produces a wall of errors from deep inside the allocator, starting with:
error: forming pointer to reference type 'int&'
The actual cause is std::vector<int&>. When T may be a reference, use std::remove_cvref_t<T> (C++20) or std::decay_t<T> for “the value type”, and std::forward<T> for the argument.
Greedy forwarding constructors
A constructor taking S&& is an exact match for a non-const lvalue of the class itself, which beats the copy constructor’s const Person&:
struct Person {
std::string name;
template<typename S>
explicit Person(S&& n) : name(std::forward<S>(n)) {}
Person(const Person&) = default;
};
Person p("a");
Person q(p); // picks Person(S&&) with S = Person&, not the copy constructor
error: no matching function for call to 'std::__cxx11::basic_string<char>::basic_string(Person&)'
Here it fails to compile, which is the lucky case. If name were a type constructible from Person, it would compile and do the wrong thing. The fix is to constrain the template:
template<typename S>
requires (!std::is_same_v<std::remove_cvref_t<S>, Person>)
explicit Person(S&& n) : name(std::forward<S>(n)) {}
(Before C++20, use std::enable_if_t with the same condition.) The first time I hit this, the error pointed at std::string’s constructor, and it took a while to realize the copy constructor was never even being considered as the winner. Now any forwarding constructor on a class I write gets that constraint by default.
auto&& and dangling references
auto&& extends the lifetime of a temporary it binds to directly, but not of a temporary that merely produced the referenced object:
std::vector<int> getVec();
auto&& vec = getVec(); // OK: the vector's lifetime is extended to vec's scope
auto&& first = vec[0]; // OK: binds to an element of a live vector
auto&& dangling = getVec()[0]; // the temporary vector dies at the semicolon
g++ 10.3 compiles the last line with -Wall -Wextra and no warning. This class of bug is usually found at runtime by AddressSanitizer (-fsanitize=address) as a heap-use-after-free, not by the compiler.
decltype(auto) and extra parentheses
decltype((x)) on a named variable yields T&, because a parenthesized name is an lvalue expression. Combined with decltype(auto), one pair of parentheses changes the return type:
decltype(auto) bad() { int x = 1; return (x); } // returns int&
warning: reference to local variable 'x' returned [-Wreturn-local-addr]
Here g++ does warn, even without -Wall, because -Wreturn-local-addr is on by default. It is worth promoting to an error (-Werror=return-local-addr), since the program is broken the moment the warning appears.
auto& versus auto&& in range-for
std::vector<bool> returns proxy objects by value, so for (auto& b : v) fails:
error: cannot bind non-const lvalue reference of type 'std::_Bit_reference&' to an rvalue of type 'std::_Bit_iterator::reference'
for (auto&& b : v) compiles, because auto&& binds to prvalues as well as lvalues. That is why generic code often uses auto&& in range-for loops: it works whether the range yields real references or proxies.
Seeing the deduced types
The most reliable trick I know uses no compiler flags. Declare a class template and never define it:
template<typename T> class TD; // "type displayer"
template<typename T>
void f(T&& arg) {
TD<T> t;
TD<decltype(arg)> u;
}
int x = 0;
f(x);
error: 'TD<int&> t' has incomplete type
error: 'TD<int&> u' has incomplete type
The compiler prints the exact collapsed types in the error. For runtime checks, static_assert(std::is_same_v<...>) documents your assumption and fails the build if a refactor changes it. __PRETTY_FUNCTION__ (GCC/Clang) or __FUNCSIG__ (MSVC), as in the variadic example, is handy for printing pack elements.
Runtime cost
None. Collapsing is resolved entirely during template instantiation; by the time code is generated, a function taking T&& with T = int& is simply a function taking int&. What forwarding references change at runtime is which overload gets called downstream (copy versus move), and that is where the real performance difference lives.