C++: Forwarding references, overload resolution, and taking back control
This is the first of three posts on the nuisance of overload resolution when functions with forwarding reference parameters are involved (forwarding reference parameters are those of type T&&
, where T
is a template parameter). It suggests a design pattern of merging all overloads into one function definition that has forwarding reference parameters, then internally, in the function body, using if constexpr
in combination with std::is_same_v
to implement the behavior required for each combination of argument types.
Subsequent posts go further, introducing an
is_instance_of_v
trait for checking if a given type is an instantiation of a given template, and anis_like_v
trait for pattern matching template types. Beyond exact matches withstd::is_same_v
as in this post, these other traits allow matching patterns such asA<_,_>
for an instantiation of a class templateA
with two template arguments, orA<int,_>
where the first template argument must beint
, or even nested patterns such asB<_,A<int,_>>
. Using type traits such as these, we can reduce repetitive code and compactly implement our own logic with respect to overload resolution and type-specific behavior.
- Motivation
- Design pattern: forwarding reference function only
- Understanding
std::is_same_v
andstd::decay_t
- Return to the design pattern
- Advantages and disadvantages
- Summary
Motivation
Consider a function that uses a forwarding reference (sometimes called a universal reference) parameter, introduced in C++11:
template<class T>
void f(T&& x) {
std::cerr << "f(T&& x)" << std::endl;
}
The forwarding reference is the argument x
, which is of type T&&
, where T
is a template parameter. We now wish to overload this function for a particular type A
:
void f(const A& x) {
std::cerr << "f(const A& x)" << std::endl;
}
and call the function:
int main() {
A a;
f(a);
return 0;
}
When running the program, the output is:
f(T&& x)
This may come as a surprise! While we (perhaps thought we) overloaded the function for A
, the forwarding reference overload is called instead.
This by design. The variable a
in the main
program is an lvalue, and the forwarding reference overload is preferred over the const lvalue reference overload in this case. We could add a new overload for lvalue references:
void f(A& x) {
std::cerr << "f(A& x)" << std::endl;
}
This will be preferred to both the const lvalue and forwarding reference overloads in overload resolution. The output is now:
f(A& x)
If we move a
into the call:
int main() {
A a;
f(std::move(a));
return 0;
}
we’re back to the forwarding reference overload again:
f(T&& x)
until we add an rvalue reference overload:
void f(A&& x) {
std::cerr << "f(A&& x)" << std::endl;
}
giving:
f(A&& x)
Again, this is by design—presumably for good reason, too, but it does create a nuisance in situations where we desire a forwarding reference overload, but also specific type overloads. In this case we end up with multiple overloads for each specific type: const lvalue reference f(const A&)
, lvalue reference f(A&)
, and rvalue reference f(A&&)
. In many situations the implementation of these overloads looks exactly the same, or at least could be made generic, with respect to the const and ref qualifiers.
Design pattern: forwarding reference function only
Following on from the above example, the aim is to write the function f
just once, but structure the body to specialize the implementation for different argument types:
template<class T>
void f(T&& x) {
if constexpr (/* T is A, or some const or ref variant of it */) {
// implementation for A
std::cerr << "f(A x)" << std::endl;
} else {
// implementation for everything else
std::cerr << "f(T&& x)" << std::endl;
}
}
The first language feature to understand here is if constexpr
. This is an if
statement evaluated at compile time. It requires that the condition is a constexpr
, which it will be in this case: some check on the type at compile time. Importantly, if the condition is true, the else
branch is not evaluated, and if the condition is false, the if
branch is not evaluated. This allows the code in the branches to be type-specific and not trigger compile errors for incompatible types.
Understanding std::is_same_v
and std::decay_t
To fill in the if constexpr
condition, we can use type traits. The type trait std::is_same_v
is provided by the C++ standard library for comparing types:
#include <type_traits>
#include <iostream>
struct A;
int main() {
std::cerr << std::is_same_v<A,A> << ' ';
std::cerr << std::is_same_v<A,A&> << ' ';
std::cerr << std::is_same_v<A,A&&> << ' ';
std::cerr << std::is_same_v<A,const A&> << std::endl;
return 0;
}
When run, this outputs:
1 0 0 0
Only the exact match of A
to A
gives true, whereas const
and ref (i.e. &
and &&
) qualified versions compare false. But this is a good starting point.
The standard library also provides a number of type transformations:
std::remove_const
to remove theconst
qualifier from a type,std::remove_reference
to remove the ref qualifier from a type,std::decay_t
to combine the two with a little extra.
We will use std::decay_t
here for its brevity. Combining it with std::is_same_v
above we get:
#include <type_traits>
#include <iostream>
struct A;
int main() {
std::cerr << std::is_same_v<A,std::decay_t<A>> << ' ';
std::cerr << std::is_same_v<A,std::decay_t<A&>> << ' ';
std::cerr << std::is_same_v<A,std::decay_t<A&&>> << ' ';
std::cerr << std::is_same_v<A,std::decay_t<const A&>> << std::endl;
return 0;
}
which outputs:
1 1 1 1
This is what we want. The comparisons are now true regardless of the const and ref qualifiers.
Return to the design pattern
We can now return to the suggested design pattern, plugging in std::is_same_v
and std::decay_t
to the condition of the if constexpr
:
template<class T>
void f(T&& x) {
if constexpr (std::is_same_v<std::decay_t<T>,A>) {
// implementation for A
std::cerr << "f(A x)" << std::endl;
} else {
// implementation for everything else
std::cerr << "f(T&& x)" << std::endl;
}
}
That is, we apply std::decay_t
to the template argument T
to remove any const
and ref qualifiers, then compare the result to A
.
The above is a simple example with a special implementation for just one type A
. The approach is readily extended to additional types. It is also possible to selectively use std::remove_const
and std::remove_reference
if, indeed, behavior should be different according to const and ref qualifiers.
Advantages and disadvantages
There are three main advantages to this design pattern:
- Reducing code size by eliminating repetitive implementations.
- Reducing code documentation, such as when using Doxygen or Doxide, as there is only one function to document.
- Giving the programmer control over overload resolution, in that the single function definition can encode any logic into
if constexpr
statements to prioritize certain overloads over others, and even resolve what-would-be ambiguous overloads.
The disadvantage is that it’s a little ugly, sure.
Summary
This post suggested a design pattern of merging function overloads into the one function in the presence of forwarding references, which may get in the way of desired overload resolution. The next post will look at more advanced conditions within if constexpr
, using pattern matching on template types.
Here is a complete code snippet for further play:
#include <type_traits>
#include <iostream>
struct A {};
template<class T>
void f(T&& x) {
if constexpr (std::is_same_v<std::decay_t<T>,A>) {
// overload for A
std::cerr << "f(A x)" << std::endl;
} else {
// overload for anything else
std::cerr << "f(T&& x)" << std::endl;
}
}
int main() {
int i;
A a;
f(std::move(a));
f(i);
return 0;
}