Type Deduction
How C++ deduces types for templates, auto, decltype, and decltype(auto) — the three deduction cases, reference collapsing, and pitfalls.
Type Deductionsince C++11The compiler-performed process of determining a type from context — applied to template parameters, auto variables, decltype expressions, and function return types — following a precise set of rules that differ by form.
Overview
Type deduction in C++ is not a single mechanism. It is four overlapping systems — template argument deduction (C++98/03, extended in C++11), auto deduction (C++11), decltype (C++11), and decltype(auto) (C++14) — each with its own rules. Understanding them as a unified model avoids the confusion that arises from treating them independently.
The master rule: auto follows template deduction rules; decltype does not. Everything else is a corollary.
Template Argument Deduction — Three Cases
All auto behavior derives from template deduction, so understanding it first is essential.
template<typename T> void by_value(T param); // Case 1
template<typename T> void by_lref(T& param); // Case 2
template<typename T> void by_fwd(T&& param); // Case 3Case 1: T — deduce by value
const, volatile, &, and && are stripped from the argument type. Arrays and function types decay to pointers.
template<typename T> void f(T x);
const int ci = 42;
f(ci); // T = int — const stripped
f(std::move(ci)); // T = int — const + && stripped
const int& ri = ci;
f(ri); // T = int — const& stripped
int arr[5];
f(arr); // T = int* — array decays
f("hello"); // T = const char* — string literal decays
void callback(int);
f(callback); // T = void(*)(int) — function decaysCase 2: T& — deduce by lvalue reference
const and volatile are preserved; the deduced T never includes a reference (it lives in the & of T&). Arrays are not decayed.
template<typename T> void g(T& x);
int i = 0;
const int ci = 0;
const int& ri = ci;
g(i); // T = int, param = int&
g(ci); // T = const int, param = const int&
g(ri); // T = const int, param = const int& (ref in arg ignored)
int arr[5];
g(arr); // T = int[5], param = int(&)[5] — no decayconst T& (Case 2 variant) is identical to T& for deduction purposes — const in the parameter type doesn't affect T; it's just part of the parameter.
Case 3: T&& — forwarding reference (universal reference)
Only applies when T&& appears directly as a function parameter with a deduced T. Reference collapsing determines the final type.
template<typename T> void h(T&& x);
int i = 0;
const int ci = 0;
h(i); // lvalue: T = int&, T&& collapses to int&
h(ci); // lvalue: T = const int&, T&& collapses to const int&
h(42); // rvalue: T = int, T&& stays int&&
h(std::move(i)); // rvalue: T = int, T&& stays int&&Reference collapsing rules (C++11): & & → &, & && → &, && & → &, && && → &&. Any & in the combination wins except && &&.
auto Deduction (C++11)
auto maps directly onto the three template cases. The auto keyword substitutes for T.
const int ci = 42;
int arr[5];
// Case 1 — by value
auto a1 = ci; // int (const stripped)
auto a2 = arr; // int* (decays)
// Case 2 — by lvalue ref
auto& a3 = ci; // const int& (const preserved)
auto& a4 = arr; // int(&)[5] (no decay)
// Case 3 — forwarding ref
auto&& a5 = ci; // const int& (lvalue → lvalue ref)
auto&& a6 = 42; // int&& (rvalue → rvalue ref)auto in range-for (C++11)
The deduction rules explain the three idiomatic forms:
std::map<std::string, int> m{{"a", 1}, {"b", 2}};
for (auto p : m) // pair<const string,int> — COPY each iteration
p.second++; // modifies copy, not map
for (auto& p : m) // pair<const string,int>& — modifies map
p.second++;
for (const auto& p : m) // read-only ref, no copy
process(p);Initializer list — special case (C++11/C++17)
auto with a braced-init-list has its own rule, partially revised in C++17:
auto a = {1, 2, 3}; // C++11/14/17: std::initializer_list<int>
auto b = {42}; // C++11/14/17: std::initializer_list<int>
auto c{42}; // C++11/14: std::initializer_list<int>
// C++17+: int (direct-init deduces scalar)
auto d{1, 2}; // C++11/14: std::initializer_list<int>
// C++17+: ill-formed (direct-init requires one element)decltype (C++11)
decltype does not follow template deduction rules. It preserves exact declared types and encodes value category.
Two distinct forms:
int x = 0;
const int cx = 0;
const int& rx = cx;
// Form 1: decltype(entity) — declared type of the named entity
decltype(x) d1; // int
decltype(cx) d2; // const int (not default-constructible, shown for type)
decltype(rx) d3 = cx; // const int&
// Form 2: decltype(expression) — type plus value category
// lvalue expr → T&
// xvalue expr → T&&
// prvalue expr → T
decltype((x)) d4 = x; // int& — (x) is an lvalue expression
decltype(x + 0) d5; // int — x+0 is a prvalue
decltype(std::move(x)) d6 = std::move(x); // int&&The parenthesised-name form decltype((name)) is a notorious footgun. decltype(x) and decltype((x)) yield int and int& respectively for the same x.
Trailing return types (C++11)
Before decltype(auto) existed, trailing returns were the way to forward return types precisely:
template<typename C>
auto front(C& c) -> decltype(c.front()) {
return c.front();
}
// If c.front() returns int&, front() returns int&
// Works in C++11; C++14 allows decltype(auto) insteaddecltype(auto) (C++14)
decltype(auto) applies decltype rules to the initializer expression rather than template rules. It is the correct tool for perfectly forwarding return types without writing them explicitly.
int x = 5;
const int& rx = x;
auto a = rx; // int — template rules strip const&
decltype(auto) d = rx; // const int& — decltype rules preserve itForwarding return types (C++14)
// auto strips — wrong when the return expression is a reference
auto bad(std::vector<int>& v) { return v[0]; } // int, copy
auto& fragile(std::vector<int>& v) { return v[0]; } // int&, but manual
// decltype(auto) propagates exactly what the expression is
decltype(auto) correct(std::vector<int>& v) { return v[0]; } // int&
// Works in generic forwarding wrappers
template<typename F, typename... Args>
decltype(auto) call(F&& f, Args&&... args) {
return std::forward<F>(f)(std::forward<Args>(args)...); // C++11
}Common Pitfalls
auto makes unexpected copies
std::vector<std::string> words = get_words();
auto w = words[0]; // std::string — copy
auto& r = words[0]; // std::string& — reference, no copyThis is especially costly in range-for over containers of heavy objects.
decltype((x)) returns a dangling reference
decltype(auto) dangling() {
int x = 42;
return (x); // decltype((x)) = int& — returns reference to local!
}
decltype(auto) safe() {
int x = 42;
return x; // decltype(x) = int — returns by value
}This is an easy mistake in decltype(auto) functions. Always audit the return expression.
Forwarding references require deduced context
T&& is a forwarding reference only when T is deduced at the call site. If T is fixed, T&& is a plain rvalue reference:
template<typename T>
void fwd(T&& x); // forwarding reference — T deduced
void rval(std::string&& x); // rvalue reference — not a forwarding ref
template<typename T>
struct S {
void mem(T&& x); // rvalue reference — T fixed at class instantiation
};auto in structured bindings (C++17)
In C++17 structured bindings, auto controls the binding specifier, not the individual element types:
auto [a, b] = std::pair{1, 2.0}; // copies the pair; a=int, b=double
auto& [c, d] = some_pair; // binds to pair by ref
const auto& [e, f] = some_pair; // const refThe element types are always determined by the type of the aggregate/tuple, not independently by auto.
Best Practices
- Use
auto&orconst auto&in range-for by default; only useautowhen you explicitly need a copy. - Use
decltype(auto)in generic forwarding wrappers and proxy return types; use explicitauto/auto&where the type is obvious and you want the stripping behaviour. - Never use
decltype(auto)on a parenthesised local variable in a return statement. - Prefer
auto&&in generic lambdas (C++14) and range-for when the element type is uncertain — it binds to anything without copying. - Tag forwarding reference parameters with a comment or a concept (C++20) to distinguish them from intentional rvalue references.
Template argument deduction rules
Template deduction succeeds only when the deduced type can be determined unambiguously from the argument. Ambiguity — such as two parameters deducing conflicting types for the same T — is a hard error.
// C++11
template<typename T>
void add(T a, T b);
add(1, 2); // OK: T = int
add(1, 2.0); // Error: T deduced as int from first arg, double from second
// Fix: explicit cast or explicit template argument
add(1, static_cast<int>(2.0)); // OK: T = int
add<double>(1, 2.0); // OK: T = doubleDeduction does not cross implicit conversions. A parameter of type std::vector<T> cannot deduce T from an std::initializer_list argument.
// C++11
template<typename T>
void fill(std::vector<T> v);
fill({1, 2, 3}); // Error: cannot deduce T from braced-init-list
fill(std::vector<int>{1, 2, 3}); // OK: T = intNon-deduced contexts also prevent deduction. Anything appearing on the left side of ::, inside a nested type, or inside a non-type template parameter is non-deduced.
// C++11
template<typename T>
void process(typename std::vector<T>::value_type x); // T is non-deduced here
process(42); // Error: cannot deduce T
process<int>(42); // OK: T = int, explicitauto in structured bindings
In structured bindings (C++17), auto qualifies the invisible initializer object, not each individual name. The element types come from the decomposed type, not from the auto specifier.
// C++17
std::pair<int, double> p{1, 2.5};
auto [a, b] = p; // copies p; a is int, b is double
auto& [c, d] = p; // c and d refer into p — modifying c changes p.first
const auto& [e, f] = p; // read-only access, no copy
// Works with arrays
int arr[3] = {10, 20, 30};
auto [x, y, z] = arr; // copies arr; x, y, z are int
auto& [r, s, t] = arr; // r, s, t refer into arrStructured bindings work with any type that satisfies the tuple protocol (std::get, std::tuple_size) or is an aggregate, or has accessible data members.
// C++17
struct Point { int x; int y; };
Point pt{3, 7};
auto& [px, py] = pt; // px binds to pt.x, py binds to pt.y
px = 10; // pt.x is now 10
// With std::map iteration — the standard pattern
std::map<std::string, int> scores{{"alice", 95}, {"bob", 87}};
for (const auto& [name, score] : scores) {
// name is const std::string&, score is const int&
}auto&& in a structured binding creates forwarding references into the decomposed object, useful in generic code.
// C++17
auto&& [key, val] = *some_map.find("key"); // binds without copyingauto function return types
auto as a return type (C++14) deduces from the return expression using template-deduction rules, stripping const, volatile, and reference qualifiers. Every return statement in the function must deduce the same type.
// C++14
auto square(int x) { return x * x; } // deduced: int
auto make_pair(int a, double b) {
return std::make_pair(a, b); // deduced: std::pair<int, double>
}
// Multiple return statements must agree
auto classify(int x) {
if (x > 0) return 1; // int
if (x < 0) return -1; // int — consistent
return 0; // int — consistent
}Recursive functions need at least one return statement that does not depend on the recursive call for deduction to succeed.
// C++14
auto factorial(int n) {
if (n <= 1) return 1; // base case deduced first → int
return n * factorial(n - 1); // consistent with int
}Lambdas also support auto return deduction from C++11 (single-expression) and C++14 (multi-statement body).
// C++11 — single-expression lambda, return type inferred
auto square = [](int x) { return x * x; };
// C++14 — multi-statement body
auto clamp = [](double v, double lo, double hi) {
if (v < lo) return lo;
if (v > hi) return hi;
return v;
};Trailing return types
Trailing return types (C++11) let the return type reference parameter names, which is impossible with a leading return type. They use the syntax auto name(params) -> ReturnType.
// C++11 — return type depends on parameter type
template<typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
return t + u;
}
// Without trailing syntax this would require repeating the type or using decltype
// with parameter copies — trailing return makes it cleanTrailing returns are still useful in C++14+ when the return type is complex and spelling it out improves readability, or when it depends on a type alias in the class.
// C++11 — useful with complex function pointer return types
auto get_callback() -> void(*)(int, int); // clearer than: void(*get_callback())(int,int)
// C++11 — member function returning type from the class's own scope
struct Widget {
using Handle = int;
auto id() const -> Handle { return id_; } // Handle is in scope after ->
private:
Handle id_;
};In C++20, abbreviated function templates use trailing returns naturally with auto parameters.
// C++20 — abbreviated template with trailing return type
auto transform(auto container, auto fn) -> decltype(container) {
for (auto& item : container) item = fn(item);
return container;
}decltype and expression types
decltype encodes both the type and the value category of an expression, making it the precise tool for type introspection. The three value-category outcomes follow a simple rule: lvalue → T&, xvalue → T&&, prvalue → T.
// C++11
int x = 0;
int* p = &x;
decltype(*p) r1 = x; // int& — *p is an lvalue
decltype(std::move(x)) r2 = std::move(x); // int&& — move() is an xvalue
decltype(x + 1) v1; // int — x+1 is a prvalue
// Member access
struct S { int m; };
S s;
decltype(s.m) m1; // int — named entity, Form 1
decltype((s.m)) m2 = s.m; // int& — expression, Form 2 (lvalue)decltype is the backbone of type-safe generic programming. It appears in SFINAE conditions, std::result_of replacements, and concept definitions.
// C++11 — SFINAE with decltype in trailing return
template<typename T>
auto size(const T& t) -> decltype(t.size(), std::size_t{}) {
return t.size(); // enabled only if t.size() is valid
}
// C++20 — concept using decltype
template<typename T>
concept Sizeable = requires(T t) {
{ t.size() } -> std::convertible_to<std::size_t>;
};std::declval<T>() extends decltype to work on types that cannot be constructed, enabling type queries purely at compile time.
// C++11
template<typename T, typename U>
using AddResult = decltype(std::declval<T>() + std::declval<U>());
// AddResult<int, double> is double — no T or U object ever constructedType deduction with forwarding references
Forwarding references (T&& with deduced T) preserve the value category of the argument, enabling perfect forwarding. They are the mechanism behind std::forward and generic factory functions.
// C++11
template<typename T>
void relay(T&& arg) {
// T deduced as lvalue-ref when arg is lvalue, plain type when rvalue
use(std::forward<T>(arg)); // forwards the original value category
}
int x = 10;
relay(x); // T = int&, arg = int&, forwards as lvalue
relay(42); // T = int, arg = int&&, forwards as rvalue
relay(std::move(x)); // T = int, arg = int&&, forwards as rvaluestd::forward<T> is a conditional cast: it casts to T&& if T is a non-reference type (rvalue case) and returns an lvalue reference otherwise — it is a no-op for lvalues.
// C++11 — perfect forwarding in a generic factory
template<typename T, typename... Args>
T* create(Args&&... args) {
return new T(std::forward<Args>(args)...); // each arg forwarded as original category
}
// C++20 — forwarding reference in a lambda (abbreviated template)
auto wrapper = []<typename T>(T&& val) {
return process(std::forward<T>(val));
};auto&& in range-for and generic lambdas acts as a forwarding reference. It avoids copies while remaining agnostic about value category.
// C++14 — generic lambda with forwarding reference parameter
auto log_and_call = [](auto&& fn, auto&&... args) {
log("calling");
return std::forward<decltype(fn)>(fn)(std::forward<decltype(args)>(args)...);
};
// C++17 — auto&& in range-for avoids copies for proxy iterators (e.g. vector<bool>)
for (auto&& elem : container) {
process(std::forward<decltype(elem)>(elem));
}