C++: Forwarding references, overload resolution, and taking back control
Consider merging overloads into one function with forwarding reference parameters
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 an is_like_v
trait for pattern matching template types. Beyond exact
matches with std::is_same_v as in this post,
these other traits allow matching patterns such as
A<_,_> for an instantiation of a class
template A with two template arguments, or
A<int,_> where the first template
argument must be int, or even nested patterns
such as B<_,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
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_constto remove theconstqualifier from a type,std::remove_referenceto remove the ref qualifier from a type,std::decay_tto 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 constexprstatements 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;
}