C++ decltype: Why decltype((x)) Is a Reference and decltype(auto) Can Dangle
Key takeaways
decltype has two rules: for a plain name it returns the declared type, for any other expression it encodes the value category (lvalue -> T&, xvalue -> T&&, prvalue -> T). Almost every decltype surprise, including decltype(auto) returning a dangling reference, comes from that second rule.
What decltype actually does
decltype(e) asks the compiler “what is the type of e?” without evaluating e. It is the tool you reach for when auto loses information you need: auto strips references and top-level const and decays arrays, while decltype keeps them.
int x = 10;
const int& ref = x;
auto a = ref; // int (reference and const dropped)
decltype(ref) b = x; // const int& (declared type kept)
int arr[5];
auto p = arr; // int* (array decays)
decltype(arr) arr2; // int[5]
The part most tutorials skip is that decltype does not have one rule. It has two, and the one that applies depends on how you spell the operand.
The two rules
- Unparenthesized name or member access (
x,obj.member,ptr->member): the result is the declared type of that entity. Nothing more. - Any other expression: the result depends on the expression’s value category.
- lvalue of type
T->T& - xvalue of type
T->T&& - prvalue of type
T->T
- lvalue of type
Every line below is a static_assert that passes on GCC 10 with -std=c++17:
#include <type_traits>
#include <utility>
int x = 10;
struct S { int m; };
const S cs{1};
static_assert(std::is_same_v<decltype(x), int>); // rule 1: declared type
static_assert(std::is_same_v<decltype((x)), int&>); // rule 2: lvalue
static_assert(std::is_same_v<decltype(std::move(x)), int&&>); // xvalue
static_assert(std::is_same_v<decltype(42), int>); // prvalue
static_assert(std::is_same_v<decltype(x + 1), int>); // prvalue
static_assert(std::is_same_v<decltype(++x), int&>); // pre-increment yields lvalue
static_assert(std::is_same_v<decltype(x++), int>); // post-increment yields prvalue
static_assert(std::is_same_v<decltype(cs.m), int>); // declared type of the member
static_assert(std::is_same_v<decltype((cs.m)), const int&>); // expression: const lvalue
The cs.m pair is worth staring at. The member is declared int, so rule 1 gives int even though the object is const. Wrap it in parentheses and you get the type of the expression cs.m, which is a const int lvalue, so const int&. The same thing happens inside a const member function when you write decltype((this->m)).
Neither x++ nor ++x actually runs here. decltype is an unevaluated context, just like sizeof, so the value of x is unchanged after these lines.
Trailing return types: why they exist
In C++11 a function template’s return type sometimes depends on its parameters, but parameters are not in scope before the function name. The trailing return syntax moves the return type after the parameter list, where a and b are visible:
template<typename T, typename U>
auto add(T a, U b) -> decltype(a + b) {
return a + b;
}
auto r = add(1, 2.5); // double
C++14 made the common case easier: auto add(T a, U b) { return a + b; } deduces the return type from the body. So why keep the trailing form? Two reasons:
- The return type becomes part of the declaration. If
a + bis ill-formed for someT, the trailing version is removed from overload resolution (SFINAE). The body-deduced version is selected first and then fails with a hard error inside the body. - Declarations without a body. A header that declares but does not define the function cannot use body deduction, because callers need the type before the definition is seen.
decltype(auto): exact forwarding of a return type
decltype(auto) means “deduce the type, but use the decltype rules instead of the auto rules”. Its intended use is wrappers that should return exactly what the wrapped expression returns, reference or not:
template<typename C>
decltype(auto) first(C& c) { return c[0]; } // int& for std::vector<int>
template<typename C>
auto firstCopy(C& c) { return c[0]; } // int, a copy
std::vector<int> vec{1, 2, 3};
first(vec) = 99; // writes into vec
// firstCopy(vec) = 99; // error: assigning to a temporary
The same idea generalizes to call-forwarding helpers:
template<typename F, typename... Args>
decltype(auto) invoke_logged(F&& f, Args&&... args) {
// log something...
return std::forward<F>(f)(std::forward<Args>(args)...);
}
If f returns int&, so does invoke_logged. If it returns std::string by value, so does the wrapper. With plain auto the reference would be silently turned into a copy, which breaks callers that expect to modify through it.
One detail that trips people up: for a function parameter declared T& v, return v; inside a decltype(auto) function returns T&, because the declared type of v is already a reference. Removing parentheses only helps when the variable itself is declared as a non-reference.
The dangling-reference trap
Because rule 2 turns a parenthesized name into an lvalue reference, one pair of parentheses changes the return type:
decltype(auto) getLocal() {
int x = 10;
return (x); // return type is int&, x dies at the closing brace
}
int main() { return getLocal(); }
GCC 10 warns about it even without -Wall, because -Wreturn-local-addr is on by default:
b.cpp: In function 'decltype(auto) getLocal()':
b.cpp:3:13: warning: reference to local variable 'x' returned [-Wreturn-local-addr]
3 | return (x);
| ~^~
Built with GCC 10 at both -O0 and -O2, this program crashed with a segmentation fault when I ran it: when GCC detects this case it makes the function return a null address rather than a pointer into the dead stack frame. Do not count on that. With other compilers, or in less obvious cases the compiler cannot see through, the dangling reference can read back a plausible-looking value, which is worse because the bug passes testing. The fix is to write return x; (which returns int) or to not use decltype(auto) for functions that return locals at all.
I have been bitten by this in a less obvious form: a style checker (or a colleague) wraps a return expression in parentheses “for readability”, something like return (result);, in a function that had been switched from auto to decltype(auto) for an unrelated forwarding reason. The diff looks purely cosmetic in review, and the warning is easy to lose in a noisy build log. Since then I treat -Werror=return-local-addr (GCC) or -Werror=return-stack-address (Clang) as non-negotiable in any codebase that uses decltype(auto), and I keep decltype(auto) for thin forwarding functions whose body is a single return of a call.
Other warning signs that a decltype(auto) function returns a reference you did not intend:
return (member_);in a member function returnsT&to the member, which is fine if the object outlives the reference, but surprising if callers expected a copy.return cond ? a : b;where both are lvalues of the same type yields an lvalue, soT&. If either branch is a local, you dangle.return std::move(local);yieldsT&&to a local, which dangles just the same.
decltype(auto) vs auto&& for variables
These are easy to mix up because both can produce references:
int x = 10;
auto&& a = x; // int& (forwarding reference binds to lvalue)
decltype(auto) b = x; // int (decltype(x) is int -> copy)
decltype(auto) c = (x); // int& (decltype((x)) is int&)
auto&& asks “bind to whatever this is”; it is always a reference. decltype(auto) asks “use exactly the type decltype reports”; it is a reference only when that type is. For local variables auto&& or const auto& is almost always the clearer choice; decltype(auto) mainly belongs in return types.
SFINAE with decltype (and why it is easy to get wrong)
Before C++20 concepts, the standard way to say “this overload only exists if v.size() compiles” was to put the expression in the return type:
#include <string>
#include <vector>
template<typename T>
auto describe(const T& v, int) -> decltype(v.size(), std::string()) {
return "has size() = " + std::to_string(v.size());
}
template<typename T>
std::string describe(const T&, long) {
return "no size()";
}
// describe(std::vector<int>{1,2,3}, 0) -> "has size() = 3"
// describe(42, 0) -> "no size()"
The comma operator evaluates to the last operand’s type, so the return type is std::string if v.size() is valid, and substitution fails (silently removing the overload) otherwise. The int/long second parameter ranks the overloads: passing 0 prefers the int version when both are viable.
A version of this pattern that circulates widely is subtly broken:
template<typename T> auto process(T v) -> decltype(v.size(), void()) {}
template<typename T> void process(...) {} // T cannot be deduced from ...
process(42);
GCC rejects the call:
error: no matching function for call to 'process(int)'
note: template argument deduction/substitution failed:
error: request for member 'size' in 'v', which is of non-class type 'int'
The fallback is a template whose T appears nowhere in the parameter list, so it can never be deduced and is never a candidate. The fallback must either be a non-template or take const T& so T is deducible.
std::declval<T>() lets you form expressions on types without constructing objects, which is how you test a type rather than a value:
#include <type_traits>
#include <utility>
template<typename T, typename U>
auto canAdd(int) -> decltype(std::declval<T>() + std::declval<U>(), std::true_type{});
template<typename T, typename U>
std::false_type canAdd(...);
static_assert(decltype(canAdd<int, int>(0))::value);
static_assert(!decltype(canAdd<int, std::string>(0))::value);
Neither function needs a body: they only ever appear inside decltype, which never calls them. std::declval itself is ill-formed if it is ever evaluated.
In C++20, a requires clause expresses the same constraint far more readably, and error messages list the unsatisfied constraint instead of a substitution failure:
template<typename T>
requires requires(const T& v) { v.size(); }
std::string describe(const T& v);
Watch out for proxy types
decltype reports what the expression really is, which is sometimes not what you expect:
std::vector<int> vi{1};
std::vector<bool> vb{true};
static_assert(std::is_same_v<decltype(vi[0]), int&>);
static_assert(!std::is_same_v<decltype(vb[0]), bool&>); // std::vector<bool>::reference, a proxy
A decltype(auto) wrapper around vb[0] returns a proxy object that refers into the vector. That is correct, but if the vector is a temporary the proxy dangles in exactly the same way as a reference would. Expression-template libraries (Eigen, for instance) have the same property with auto in general.
Inspecting deduced types
The most reliable way to see a type is to make the compiler print it in an error message:
template<typename T> struct TD; // declared, never defined
int x = 10;
TD<decltype((x))> t;
error: aggregate 'TD<int&> t' has incomplete type and cannot be defined
typeid(...).name() is not a substitute: typeid strips references and top-level const, so typeid(decltype((x))) prints the same as typeid(int). When checking your understanding, static_assert(std::is_same_v<...>) is the most precise tool, and it doubles as documentation in the code.
Where decltype earns its keep
- Naming a type you cannot spell easily:
decltype(lambda)for a lambda’s closure type, ordecltype(m)::value_typein a template where the container type is a parameter. - Forwarding return types in thin wrappers, with
decltype(auto). - Constraints before C++20, in trailing return types.
- Getting a member’s declared type in generic code:
decltype(T::member)ordecltype(std::declval<T>().member).
It costs nothing at runtime; everything happens during compilation. The cost is readability: decltype(x) y = 20; where int y = 20; would do only makes the reader work harder, so reserve it for cases where the type is genuinely dependent or unwieldy.