auto as a return type is one of the most quietly useful additions in modern C++ — until the moment you realise it has been silently stripping references and const from your return values. A function that looks like it returns a reference into a container can return a copy instead, and the only signal is a surprise at the call site when the modification doesn’t stick. decltype(auto) exists for exactly this case: it deduces the return type using the same rules as decltype, which preserves references and cv-qualifiers that plain auto throws away.
Three things to take away:
autoreturn type deduction follows template argument rules and drops references and top-levelconst.decltype(auto)followsdecltyperules and preserves them exactly, including the value category of the returned expression.- Reach for
decltype(auto)in forwarding wrappers and accessors; reach forautoeverywhere else.
What auto actually deduces
When you write auto f() { return expr; }, the compiler deduces the return type the same way it would deduce T in template <class T> void g(T). That means references collapse to values and top-level const is dropped. The deduced type is always a plain object type:
#include <vector>
std::vector<int> v{ 1, 2, 3 };
auto first(){
return v[0]; // v[0] is int&, but auto deduces int
}
int main(){
first() = 99; // ERROR: assigning to a temporary int
// v[0] is still 1
}
v[0] returns int& — std::vector::operator[] is specifically designed to return a reference so callers can modify the element. But auto deduces int, not int&. The function returns a copy of the element, the assignment binds to a prvalue, and the compiler rejects it. Even if first() were used on the right-hand side of an expression, every call would silently copy.
This is not a bug in auto; it is the deliberate rule. auto matches the deduction behaviour of templates, where stripping references is usually what you want — T x = expr; should give you a fresh object, not bind to whatever expr happened to refer to.
What decltype(auto) deduces
decltype(auto) was introduced in C++14 specifically to give return-type deduction a way to not strip. It uses the rules of decltype applied to the return expression, which preserves the exact type and value category:
decltype(auto) first(){
return v[0]; // deduces int& because v[0] is an lvalue of type int
}
int main(){
first() = 99; // OK: returns a reference into v
// v[0] is now 99
}
The deduction rule is decltype(<the-return-expression>). For v[0], that is int&. For a returned local variable, it is the plain object type. For a returned rvalue, it is an rvalue reference. The point is that decltype(auto) mirrors the expression being returned, not a sanitised version of it.
A quick rule for which one to pick
A useful mental model: auto is for “give me a value of whatever type this expression produces”; decltype(auto) is for “give me back exactly what the expression is, references and all.” Concretely:
| Returning… | Use |
|---|---|
| A local variable or computation | auto |
| A literal or temporary | auto |
| A reference into a container | decltype(auto) |
The result of *ptr | decltype(auto) |
| A forwarded call to another func | decltype(auto) |
If the function would idiomatically be written with an explicit reference return type — T&, const T& — that is the case where decltype(auto) is the right deduction tool. If it would be written with a plain return type, auto is fine.
The forwarding-wrapper case
The most common production use of decltype(auto) is in generic wrappers that need to return exactly what the wrapped function returned, without forcing a copy or losing const-ness:
template <typename F, typename... Args>
decltype(auto) invoke_logged(F&& f, Args&&... args){
log("calling function");
return std::forward<F>(f)(std::forward<Args>(args)...);
}
If f returns int&, the wrapper returns int&. If f returns const std::string&, the wrapper returns const std::string&. If f returns void, the wrapper returns void — decltype(auto) handles that case too. Replacing decltype(auto) with auto silently turns every reference return into a copy, and replacing it with auto& breaks for functions returning by value.
This is the case where decltype(auto) earns its keep most clearly: it lets a wrapper be transparent to its caller.
A trap worth knowing: parentheses change the type
decltype distinguishes between a name and an expression in parentheses. For a name, it gives the declared type; for any other expression, it gives the type plus a reference qualifier based on value category. This rule applies to decltype(auto) deduction too:
decltype(auto) f(){
int x{ 42 };
return x; // deduces int — x is a name
}
decltype(auto) g(){
int x{ 42 };
return (x); // deduces int& — (x) is an lvalue expression
}
g returns a reference to a local variable, which is dangling the moment the function returns. The parentheses change the deduction rule, and the compiler does not warn. This is the one footgun in decltype(auto) — extra parentheses around a return expression are not cosmetic, they change the type. Write return x;, not return (x);, unless you genuinely want the lvalue-expression deduction.
Takeaway
Use auto for return types when you want a value, and decltype(auto) when you want to preserve the exact type and value category of the returned expression. The two cases where decltype(auto) is genuinely necessary are accessor functions that return references into a data structure, and forwarding wrappers that must be transparent to whatever they wrap. Watch the parentheses on the return statement — they change the deduction. The rule is simple: if a hand-written signature would have included an &, decltype(auto) is the deduction tool that preserves it.