Getting your head around auto’s type-deduction rules

Automatic type-deduction is perhaps one of the more divisive features of Modern C++.  At its core it’s a straightforward concept:  let the compiler deduce the type of an object from its initialiser.   Used in the right way this can improve the readability and maintainability of your code.

However, because auto is based on template type-deduction rules there are some subtleties that can catch the unwary programmer.

In this article we’ll have a look at auto in the context of the template type-deduction rules to see where all these subtleties come from.

Start simple

The basic form of auto is

auto <obj_name> = <initialiser>

For example:

auto a = 17;

auto b { 17 }; // Using brace initialisation

As the type of ‘17’ is int, the type of i is also int.  Of course, this is a trivial example and not likely one you’d use in any real program.  auto type-deduction become much more useful when the initialiser is a more complex expression; for example a function call:

std::vector<ADT> v;

auto it { v.begin() }; // it=> std::vector<ADT>::iterator

It’s not always so easy to deduce what the resultant type will be:

int const some_var { 178 };

auto  a { some_var };        // a => int

auto& b { some_var };        // b => int const&

Auto as a template parameter

A good way to think about auto’s type-deduction rules is to consider what happens with template type-deduction.  Consider some hypothetical template function

template <typename auto_ty>
void fn(auto_ty param)
{
}


int main()
{
  int arg { 178 };
  fn(arg);         // Deduce the template parameter
}

The compiler uses two pieces of information to deduce the template parameter’s type:

  • The type of the parameter (param) in the function declaration. This is directly analogous to the variable having its type deduced.  In the example above, its type is currently an unqualified auto_ty
  • The type of the argument to the function call (arg). This is analogous to the initialiser.

The compiler attempts to construct a function signature based on this information.  It is guided by two principles:

  • The resultant function signature should be syntactically valid (otherwise it may be silently ignored by the compiler – known as Substitution Failure Is Not An Error)
  • The function should act like the programmer expects (even it is not what the programmer wants)

It’s fairly obvious in this example that, given the type of arg is int the compiler should deduce the template parameter, arg_ty, as int also.

Notice also that the deduced-function’s parameter is pass-by-value.  So, when fn is called a new object (param) is made, copy-constructed from arg.

This example is analogous to what’s happening with auto type-deduction:

ADT adt {};

auto a { adt }; // Copy-construct a from adt

As the a object is being initialised we would expect copy-elision to occur.

Not always what you expect – or is it?

There are a couple of special cases occur that are worth observing.  Firstly, consider the example below:

template <typename auto_ty>
void fn(auto_ty param)
{
}


int main()
{
  const int arg { 178 };
  fn(arg);
}

At first glance it would appear that the type of param would be const int.  However, since the parameter is pass-by-value the compiler is free to ignore cv-qualifiers.  From the caller’s perspective the function argument can never be modified.  The new object (param) does not need to be cv-qualified (and generally that makes the code more flexible – not everyone gets const-correctness right!)

The same thing happens with auto: any cv-qualifiers on the initialiser are ignored for the auto-deduced object.

int const var { 176 };

auto a { var };        // a => int

For C-style arrays the compiler must juggle things to get what the programmer expects.  Take as an example this piece of C code:

void function(int arr[10])
{
}


int main(void)
{
  int my_array[10];
  function(my_array);
}

A decent C programmer will tell you that the array notation in the function signature is actually ignored; passing an array to a function is effectively passing a pointer:

void function(int *arr)
{
}


int main(void)
{
  int my_array[10];
  function(my_array);  // Pass &my_array[0]
}

The C++ template compiler must maintain this illusion when deducing template parameters:

template <typename auto_ty>
void fn(auto_ty param)
{
}


int main()
{
  int my_array[10];
  fn(my_array);
}

The compiler must deduce the template parameter as int* to match the programmer’s expectation.

Of course, when used with auto this may lead to unexpected results:

int my_array[10];
auto a { my_array };

++a;   // Yup.  No problem.

(l-value) reference template parameters

Let’s make a small modification to our template function so that the parameter is passed by reference.

template <typename auto_ty>
void fn(auto_ty& param)
{
}


int main()
{
  int arg { 178 };
  fn(arg);         // Pass a reference to arg
}

When passing by reference no new object is made, only the identity of the argument is passed; param becomes an alias for arg.

However, reference parameters have stricter requirements than pass-by-value parameters.  For example, it is not possible to have a non-const reference to a const-object.  Thus the compiler cannot ignore the cv-qualifiers of the argument when deducing the template type.

template <typename auto_ty>
void fn(auto_ty& param)
{
}


int main()
{
  const int arg { 178 };
  fn(arg);               // Can’t ignore cv-qualifiers
}

In the example above the template parameter must be const int; otherwise the function could modify arg!

Of course, you could be stricter in your template function definition:

template <typename auto_ty>
void fn(const auto_ty& param) // cv-qualify *after* type deduction
{
}


int main()
{
  int arg { 178 };
  fn(arg);
}

As previously, the cv-qualifiers of the argument will be included as part of the template type-deduction.  Only after the template parameter is deduced will the const qualifier be applied to param.

So why might you want to l-value reference qualify an auto-deduced type?  There are two basic applications.

Range-for loops

Using a reference-qualified auto in a range-for loop gives a reference to the current object in the container.  This is not only more efficient than making a copy but also allows the client to modify the contents of the container in the loop

void process(std::vector<int>& vec)
{
  for(auto& i : vec)
  {
    // Do something with i...
  }
}


int main()
{
  std::vector<int> v { };

  // Add some data to v...

  process(v);
}

Generic lambdas

C++14 introduced the concept of generic lambdas to allow lambda expressions to be used in template algorithms.  The use of auto& in this instance is slightly disingenuous, since it is syntactic sugar to cause the compiler to generate a template member function on the closure class.

template <typename Container_Ty>
void process(Container_Ty& container)
{
  std::for_each(container.begin(), 
                container.end(), 
                [](auto& elem) { /* ... */ });
}

There are other occasions where you might be able to use reference auto-deduced types (for example, auto-deduced return types) but you should always ask yourself whether you are, in fact, obfuscating rather than clarifying your code.

R-value reference qualifiers

The r-value reference was added to C++11 to support two features: move semantics and perfect forwarding.  Both of these features modify the core semantics of C++ to some extent; and so the rules concerning r-value reference template parameters are somewhat convoluted.  (You get the distinct impression the cart was put before the horse with these rules – they knew the end result they wanted to achieve, and invented as many (arbitrary) rules as necessary to achieve it.  Or am I being cynical?)

So what happens when we r-value reference qualify a deduced template parameter?

template <typename auto_ty>
void fn(auto_ty&& param)   // r-value reference
{
}


int main()
{
  int arg { 178 };
  fn(arg);
}

The type deduction occurs as follows.

Step 1:  The compiler applies a new set of rules for r-value reference template parameters:

  • If the argument is an l-value expression then the template parameter is deduced as a reference-to-expression-type
  • If the argument is an r-value expression then the template parameter is whatever the expression type was (minus any cv-qualifiers, as before)

Some examples might help demonstrate this.

template <typename auto_ty>
void fn(auto_ty&& param);


int main()
{
  int a {10};

  fn(a);     // l-value: auto_ty => int&
  fn(a + 1); // r-value: auto_ty => int
}

In the case of the r-value expression fn(a+1) the signature of fn becomes

void fn(int&& param);   // auto_ty => int

However, in the case of the l-value expression fn(a) the signature of fn becomes

void fn(int& && param); // auto_ty => int&

This is an invalid function signature; even though it seems a perfectly legitimate call.  In this case a second set of rules is applied.

Step 2:  apply the reference-collapsing rules.

The reference-collapsing rules state:  If either parameter-type or argument-type is an l-value reference, then the result is an l-value reference; otherwise the result is an r-value reference.  So that gives us:

param-type  arg-type   resultant-type

T&          &          T&
T&          &&         T&
T&&         &          T&
T&&         &&         T&&

Applying this to our function gives us:

void fn(int& && param); // As deduced

void fn(int& param);    // After reference collapsing

Practically, this means if a template parameter is auto_ty&& it will deduce l-value types as l-value references; and r-value objects as r-value references.  For this reason, Scott Meyers refers to these as Universal References – meaning “a reference that can bind to anything”.

What does this mean for auto type-deduction?  Perhaps even more so than l-value references, r-value reference-qualified auto-deduced types have very little practical application in everyday code.

Lastly – some guidance on auto type-deduction

The fact that auto is based on template type deduction leads to a number of subtleties that can catch-out the naïve (or just over-enthustiastic) programmer.  I’d offer the following guidelines for auto-deduced types:

  • Don’t use auto for scalar (built-in) types. It brings very little benefit; and could actually be confusing.
  • Reserve auto for functions returning user-defined types; better still if the function name declares the type of object created (for example std::make_shared)
  • Avoid reference-qualifying (either l-value or r-value) auto-deduced objects.
  • Remember, any added cv-qualifiers belong to the auto-deduced object.
Glennan Carnie
Dislike (0)
Website | + posts

Glennan is an embedded systems and software engineer with over 20 years experience, mostly in high-integrity systems for the defence and aerospace industry.

He specialises in C++, UML, software modelling, Systems Engineering and process development.

About Glennan Carnie

Glennan is an embedded systems and software engineer with over 20 years experience, mostly in high-integrity systems for the defence and aerospace industry. He specialises in C++, UML, software modelling, Systems Engineering and process development.
This entry was posted in C/C++ Programming and tagged , , , , , , , , , , . Bookmark the permalink.

3 Responses to Getting your head around auto’s type-deduction rules

  1. dendibakh says:

    void fn(auto_ty&& param) // r-value reference
    Here it is forwarding reference. I think better to fix it to avoid misunderstanding.

    I would argue that a 'forwarding reference' isn't a language construct; it's an idiom. It's a consequence of using r-value references in a template type-deduction context. The fact that we always use them in conjunction with std::forward has led to them being referred to using the shorthand 'forwarding references'. In Scott Meyers' seminal work on the subject he refers to the idiom as 'Universal references'.

    It's perhaps better to call it the 'forwarding reference idiom'

    I've avoided the term 'universal/forwarding reference' in this context as we're talking about automatic type-deduction, where forwarding doesn't really apply.

    Like (1)
    Dislike (0)
  2. MB says:

    Fabricating the term "forwarding reference" was a frightfully bad idea IMO as it created in a lot of good programmers the misapprehension that they could elect not to learn these horribly complicated and counter-intuitive argument deduction rules for &&. This led, among other things, to probably 85% of C++ programmers out there assuming that this holds:

    template
    void fn(T &&)
    {
    static_assert(std::is_same_v);
    }

    fn(1);

    with disastrous consequences.

    Like (0)
    Dislike (0)
  3. Sorry your code didn't render so not sure what you're trying to say - you're better off using a Compiler Explorer link

    Like (0)
    Dislike (0)

Leave a Reply