C++ programming language logo, white text 'C++' in shaded blue hexagon
blog

C++: Forwarding references, overload resolution, and taking back control

Consider merging overloads into one function with forwarding reference parameters
5 min read /

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.

  1. Motivation
  2. Design pattern: forwarding reference function only
  3. Understanding std::is_same_v and std::decay_t
  4. Return to the design pattern
  5. Advantages and disadvantages
  6. 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:

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:

  1. Reducing code size by eliminating repetitive implementations.
  2. Reducing code documentation, such as when using Doxygen or Doxide, as there is only one function to document.
  3. 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;
}