How C++ auto Deduces Types: References, const, Braced Init and decltype(auto)

Key takeaways

auto uses template argument deduction: plain auto copies and drops top-level const and references, auto& keeps them, auto&& binds to anything. Braces, vector<bool> and decltype(auto) are where it surprises people.

auto (C++11) tells the compiler to deduce a variable’s type from its initializer. It saves typing on iterators and lambdas, but the more important thing to learn is which type it deduces, because plain auto quietly drops const and references. The rules are the same as template argument deduction, with one exception for braces.

#include <map>
#include <string>
#include <vector>

int main() {
    std::vector<int> v = {1, 2, 3};
    auto it = v.begin();            // std::vector<int>::iterator
    const auto n = v.size();        // const std::size_t
    for (auto& x : v) x *= 2;       // int&: modifies v

    std::map<int, std::string> m{{1, "one"}};
    for (const auto& [key, value] : m) { /* C++17 structured binding */ }
}

The deduction rules

Think of auto x = expr; as calling template<typename T> void f(T x); f(expr);. Whatever T would be, that is the type of x. With auto& it is f(T& x), and with auto&& it is f(T&& x).

DeclarationInitializerDeduced typeWhy
auto a = cr;const int& crintby value: reference and top-level const dropped
auto& b = cr;const int& crconst int&by reference: const kept
auto&& c = x;int x (lvalue)int&forwarding reference collapses to lvalue ref
auto&& d = 5;rvalueint&&binds the temporary and extends its lifetime
auto p = arr;int arr[3]int*arrays decay by value
auto& r = arr;int arr[3]int(&)[3]no decay through a reference
auto s = "hi";string literalconst char*decay, not std::string
const auto* q = &x;int*const int*auto* requires a pointer initializer

Every row was checked with static_assert(std::is_same_v<decltype(...), ...>) on GCC 10.3.

Only top-level const is dropped. For const int* p, auto q = p; is still const int*: the pointer is copied, and what it points to stays const.

Choosing between auto, const auto& and auto&&

  • auto: you want your own copy (cheap types, or when you will modify it independently).
  • const auto&: read-only access to something expensive to copy; also binds temporaries.
  • auto&: modify the original. Cannot bind to a temporary.
  • auto&&: generic code that must accept anything, such as for (auto&& x : range) over a range whose elements are proxies (std::vector<bool>, zip views).

A copy you did not ask for

std::vector<std::string> names = {"Alice", "Bob", "Charlie"};

for (auto name : names) { /* copies each string */ }
for (const auto& name : names) { /* no copies */ }

The reverse mistake is subtler and is where auto actually beats an explicit type:

std::map<std::string, int> m{{"a", 1}};

for (const std::pair<std::string, int>& kv : m) { }   // creates a temporary pair every iteration
for (const auto& kv : m) { }                          // binds to the element itself

A map’s value_type is std::pair<const std::string, int>. The first loop’s type does not match, so each element is converted into a temporary pair<std::string, int> (copying the string) and the reference binds to that. I tested this by comparing &kv.first against the address inside the map: it differs with the explicit type and matches with const auto&. This is the classic argument for auto in range-for loops: the explicit type looked more precise and was wrong.

Returning by value is not a copy problem. auto v = make_big_vector(); does not copy the vector: since C++17 the result object is initialized directly (guaranteed copy elision), and before that compilers elided or moved it.

Braced initialization

#include <initializer_list>

auto a = 10;        // int
auto b = {10};      // std::initializer_list<int>
auto c{10};         // int
auto d = {1, 2};    // std::initializer_list<int>
// auto e{1, 2};    // error: direct-list-initialization of 'auto' requires exactly one element
// auto f = {1, 2.0}; // error: unable to deduce 'std::initializer_list<auto>' from '{1, 2.0e+0}'

This is the one place auto differs from template deduction: a template parameter T cannot be deduced from {1, 2} at all, but auto x = {1, 2} deduces std::initializer_list<int>.

auto c{10} being int comes from N3922, adopted for C++17. Compilers applied it retroactively as a defect fix: GCC 10 gives int even with -std=c++11 and -std=c++14 (I checked both). Articles that say “auto x{1} is an initializer_list in C++14” describe the original C++11 wording, not what current compilers do.

In practice: use auto x = value; for single values. Reserve = {...} for when you really want an initializer_list, which is rare outside of passing one to a function.

Proxy types: std::vector and friends

std::vector<bool> flags = {true, false};

auto flag = flags[0];   // std::vector<bool>::reference, not bool
flag = false;           // writes into the vector!
// flags[0] is now false

bool copy = flags[1];   // a real bool

std::vector<bool> packs bits, so operator[] returns a proxy object that refers into the vector’s storage. auto deduces the proxy type, so assigning to what looks like a local copy changes the container. It can also dangle: auto b = make_flags()[0]; holds a proxy into a vector that was destroyed at the end of the statement. auto&& does not fix either problem; it just binds to the proxy by reference.

The fix is to state the type (bool b = flags[0];) or force the conversion (auto b = static_cast<bool>(flags[0]);). The same issue appears with expression-template libraries such as Eigen, where auto sum = a + b; stores an unevaluated expression that references a and b. Eigen’s documentation explicitly warns against auto with its expressions for this reason. If a type exists to be converted, auto will skip the conversion.

decltype(auto)

decltype(auto) deduces with decltype rules instead of template rules, so references and const are kept exactly:

int x = 10;
int& ref = x;
auto a = ref;            // int
decltype(auto) b = ref;  // int&

Its main use is forwarding a return value without deciding value-or-reference yourself:

#include <utility>

template<typename Container>
decltype(auto) first(Container&& c) {
    return std::forward<Container>(c)[0];   // int& for std::vector<int>
}

Two ways it produces a dangling reference:

decltype(auto) oops() {
    int x = 1;
    return (x);   // (x) is an lvalue expression -> int&
}
// GCC: warning: reference to local variable 'x' returned [-Wreturn-local-addr]

decltype(x) is int, but decltype((x)) is int&, so a pair of “harmless” parentheses changes the return type. And first(std::vector<int>{1, 2}) returns int& into a temporary vector that is destroyed at the end of the full expression; std::vector has no rvalue overload of operator[], so forwarding does not help. I have seen decltype(auto) added to “preserve the type” in a getter and turn a safe copy into a dangling reference exactly this way. Use it only when forwarding a reference is the goal, and keep the returned expression unparenthesized.

Function return types and parameters

auto add(int a, int b) { return a + b; }        // C++14: int

auto pick(bool b) {
    if (b) return 1;
    return 2.0;   // error: inconsistent deduction for auto return type: 'int' and then 'double'
}

auto print = [](const auto& x) { std::cout << x << '\n'; };   // C++14 generic lambda
void log_all(const auto& x);                                  // C++20 abbreviated template

All return statements must deduce the same type. A function with a deduced return type must be defined before it is called, so it cannot be declared in a header and defined in a .cpp file. A C++20 parameter declared with auto makes the function a template, which means its definition must be visible in the header too.

Common compiler errors

Reproduced with GCC 10.3, -std=c++20:

CodeGCC diagnostic
auto x;declaration of 'auto x' has no initializer
auto y{1, 2};direct-list-initialization of 'auto' requires exactly one element
auto z = {1, 2.0};unable to deduce 'std::initializer_list<auto>' from '{1, 2.0e+0}'
mixed return 1; / return 2.0;inconsistent deduction for auto return type: 'int' and then 'double'
for (auto i = 0; i < v.size(); ++i)warning: comparison of integer expressions of different signedness (with -Wall)

The last one is a logic trap rather than an error: auto i = 0 is int because 0 is an int literal. Use std::size_t i = 0, a range-for, or C++20 std::ssize(v).

To see what auto deduced, make the compiler tell you:

template<typename> struct TD;   // declared, never defined
TD<decltype(x)> td;             // error message contains the exact type of x

Almost Always Auto, and where it stops

Herb Sutter’s “almost always auto” style writes every local as auto x = expr;, putting the type on the right when it matters: auto s = std::string("hello");, auto n = std::size_t{0};. The arguments for it are real: a variable declared with auto cannot be left uninitialized, and it never silently converts (int n = v.size(); narrows; auto n = v.size(); cannot). The arguments against are about reading code: auto result = compute(); tells a reviewer nothing, and when compute() changes its return type, every caller silently follows.

A common middle ground is auto when the type is obvious from the right-hand side or irrelevant (iterators, lambdas, std::make_unique<T>()), and an explicit type when the exact type is the point of the line, such as integer widths in arithmetic, a proxy that must be converted, or a std::string you intend to own rather than a std::string_view you do not.

C++23 adds auto(x) and auto{x}, which make a decayed prvalue copy of x (useful for passing a copy of a container element to a function that might modify the container). GCC supports it from version 12, so I could not test it with GCC 10.

FAQ

Why does auto drop const and references?

Plain auto deduces like a template parameter taken by value: auto x = expr makes a new object, so top-level const, volatile and references on expr are discarded. Write const auto&, auto& or auto&& when you want to keep them.

What is the difference between auto, auto& and auto&&?

auto makes a copy. auto& binds an lvalue reference and cannot bind to a temporary. auto&& is a forwarding reference: it becomes T& for lvalues and T&& for rvalues, so it binds to anything, which is why generic range-for loops use it.

Is auto x{1} an int or std::initializer_list?

An int. auto x{1} deduces int, auto x = {1} deduces std::initializer_list<int>, and auto x{1, 2} is an error. GCC 5+ and Clang 3.8+ apply this rule even in C++11 and C++14 mode, because the change (N3922) was treated as a defect fix.

When does decltype(auto) return a dangling reference?

When the returned expression is a parenthesized local, as in return (x);, which deduces int& to a local variable (GCC warns reference to local variable 'x' returned). Also when forwarding operator[] on a temporary container, which returns a reference into an object that dies at the end of the call.

How can I see what type auto deduced?

Declare an undefined template, template<typename> struct TD;, and write TD<decltype(x)> td;. The compiler error names the exact type. IDE hovers are handy but sometimes show a typedef instead of the real type.