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;
}