Template type deduction in C++ — Behind the magic.
Template programming is a powerful built-in feature of C++ that most developers neglect to take the time to truly understand — mostly because it “just works”.
However, it is important to be cognizant of the template type deduction process, since picking the wrong deduction behaviour could cause unintended side effects — such as calling incorrect function overloads or casting to erroneous types. This may lead to performance being left on the table, or worse, undefined behaviour.
Although there are a few SO threads that do a decent job at explaining, most online tutorials on templates are brief and vague. Hence, I am writing this digestible guide to everything you know about template type deduction.
Getting familiar
template<class T>
void Func(T& t)
{
// do Func() things
}
int a = 0;
Func(a); // what is T deduced as here?
Taking a look at an example of a template function above, Func()
takes a reference to a T
. If we pass it a variable a
of type int
, T
is deduced as int
, with the function parameter type becoming int&
.
Simple, right? At this point, most devs would turn around and move with their day — and understandably so. It may seem like T
is simply the type of argument being passed in. However, this is not always the case.
template<class T>
void Func(T&& t)
{
// do Func() things
}
int a = 0;
Func(a); // now what is T?
Notice above, the parameter type for Func()
has changed to T&&
. In this instance, both T
and the parameter type will be int&
. Yes, deduced type is a indeed a reference!
Template function parameter types
The keen eye among us may have already observed that alongside T
, the template function’s parameter type plays a major role in what type of deduction behaviour will be used.
Writing templates functions, we do not have control over the types of arguments that will be passed into our templates (if we are not expecting certain types, practice SFINAE!). Therefore, in order to get our compiler to deduce the right types, we are responsible for choosing the correct function parameter type.
Let me introduce a snippet of pseudocode to help get my point across.
template<class Type>
void Func(ParamType param);
Func(arg);
As illustrated above, Type
, ParamType
and arg
represent the deduced type, template function parameter type and argument respectively.
We can split ParamType
into three categories: ParamType
of a pointer or reference, ParamType
of neither a pointer nor reference, and ParamType
of a universal or forwarding reference.
template <class T>
void Foo(T& t); // ParamType of a pointer or a reference
template <class T>
void Foo(T t); // ParamType of neither a pointer nor reference (copy)
template <class T>
void Foo(T&& t); // ParamType of a universal or forwarding reference
ParamType — a pointer or reference
template<class T>
void Func(T& param); // ParamType is a reference
As observed in our first example, we know that this is the most straightforward case. The type deduced for Type
is essentially the type of arg
.
For instance, if we call Func()
with arg
of type int
. Type
will be deduced as an int
, while ParamType
becomes an int&
.
int a = 0;
Func(a); // Type is int, ParamType is int&
If we replace arg
with a variable of type const int
, Type
will be deduced as a const int
, and ParamType
becomes a const int&
.
const int ca = a;
Func(ca); // Type is const int, ParamType is const int&
And if we pass arg
of an reference type, we get the same behaviour as previous examples — just with its reference-ness ignored. Type
is deduced as a const int
and ParamType
becomes const int&
.
const int& cra = ca;
Func(cra); // Type is const int, ParamType is const int&
Therefore, for template functions where ParamType
is a pointer or reference, the following type deduction occurs.
The pattern-matching is more noticeable when ParamType
contains additional qualifiers. For instance, if we add a const
qualifier to ParamType
— making it a reference to a const
, theconst
-ness of the arg
no longer needs to be a part of Type
.
template<class T>
void Func(const T& param); // ParamType is a reference-to-const
int a = 0
Func(a); // Type is int, ParamType is const int&
const int ca = a;
Func(ca); // Type is int, ParamType is const int&
This process is similar if ParamType
was a pointer, as seen below.
template<class T>
void Func(T* param);
int a = 0;
Func(&a) // Type is int, ParamType is int*
const int* pa = &a;
Func(pa) // Type is const int, ParamType is const int*
ParamType — not a pointer or a reference
Just like any function with a parameter that is neither a pointer nor reference, we are passing the argument by value. This means the argument passed into our function is copied, which reflects by the type of deduction that takes place.
template<class T>
void Func(T param); // ParamType is neither a pointer nor reference
int a = 0; // int
Func(a); // Type is int, ParamType is int
const int ca = a; // const int
Func(ca); // Type is int, ParamType is int
const int& cra = ca; // reference to const int
Func(cra); // Type is int, ParamType is int
As observed, any qualifiers and reference-ness of the argument are ignored. This is as expected because we are dealing with a new, independent copy of the original object that was passed as the argument.
For instance, passing in an argument of const int
corresponds to Type
and ParamType
deduced as an int
. Same goes for passing in an argument of const int&
.
Thus, we can draw the behaviour for template function parameter type that is neither a pointer nor reference as the following.
For complete-ness, if a const
pointer to a const
type is passed in as an argument. The only thing being copied-by-value here is the pointer itself. This means that the const
-ness of the object it is pointing to is preserved.
template<class T>
void Func(T param); // ParamType is neither a pointer nor reference
int a = 0;
const int* const cpa = &a;
Func(cpa); // Type is const int*, ParamType is const int*
ParamType — a universal or forwarding reference
This type is probably the most confusing, since the syntax for a universal (or officially known as a forwarding) reference template parameter is similar to a function that takes an rvalue reference (T&&
). Because of this, the type of deduction that occurs might not be what most people expect.
template<class T>
void Func(T&& param); // ParamType is a universal reference
int a = 0;
Func(a); // Type is int&, ParamType is int&
Yes, you are reading that right, the deduced type is indeed a reference.
In order to understand this behaviour, I recommend focusing on the name itself — a “universal” reference. This should suggest its ability to receive an lvalue or rvalue and distinguish them. Let’s take a look at a few examples.
const int ca = a;
Func(ca); // Type is const int&, ParamType is const int&
const int& cra = ca;
Func(cra); // Type is const int&, ParamType is const int&
If we pass an lvalue const int
, T
and ParamType
are deduced as const int&
. This behaviour is similar when we pass it a reference.
So, if the argument is an lvalue, ParamType
and T
are deduced to be an lvalue reference.
However, if we pass it an rvalue int
, ParamType
is deduced as an int&&
— an rvalue reference, and T
is deduced as an int
.
// Passing in an r-value
Func(42); // Type is int, ParamType is int&&
Hence, the process for template type deduction of a universal or forwarding reference parameter type could be drawn as such.
It is important to note though, that this template function parameter type is the only one able to deduce to a reference
type.
A common use of this is using templates to achieve perfect forwarding — which in short is preserving the rvalue-ness of an argument using std::forward
so the correct function overload will be called (if one that takes an r-value reference exists!).
template <class T>
class MyClass
{
public:
// c-tor with perfect forwarding
template<class U>
MyClass(U&& v): mVar(std::forward<U>(v)) {}
private:
T mVar;
};
int main()
{
int a = 7;
MyClass<int> myClass1(a); // Forwards lvalue to T's ctor
MyClass<int> myClass2(42) // Forwards rvalue to T's ctor
}
Template programming is powerful tool, but just like any powerful tool, the wielder has to know how to use it. For a C++ developer, that is going beyond understanding features as “just magic” :)
Feel free to leave a comment if you have any questions, happy coding!
Useful Resources: