Understanding Overload Resolution

Learn how the compiler decides which function to call based on the arguments we provide.
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Posted

In our previous course, we introduced the concept of overloading, which allows us to give functions the same name if they have a unique argument list.

This also applies to member functions, constructors, and operators, and it helps us create standardized APIs Developers using our code can invoke our functionality using consistent and intuitive names and operators, whilst we handle the complexity behind the scenes using overloading:

// SomeType.h
#pragma once

struct SomeType {
  int value;
};

// Overloading a function
int operator+(SomeType& x, SomeType& y) {
  return x.value + y.value;
}
// main.cpp
#include <iostream>
#include "SomeType.h"

// Invoking functions using a consistent syntax
int main() {
  int x{2};
  int y{2};
  std::cout << "Sum: " << x + y;
  
  SomeType a{2};
  SomeType b{2};
  std::cout << "\nSum: " << a + b;
}
Sum: 4
Sum: 4

We’ve learned a lot of new techniques since then, so it’s time to revisit the topic and learn how overload resolution interacts with templates, type traits, value categories, and more.

For those less familiar with the concept, our introductory lesson may be worth reviewing first:

Overloading vs Overriding

This lesson focuses on overloading. This is commonly confused with overriding, but they are not the same.

Overloading allows multiple functions to have the same name but different parameter lists. This is a form of compile-time polymorphism, where the compiler determines which function to call based on the argument types provided at the call site.

Overriding, on the other hand, is a form of runtime polymorphism that uses inheritance and virtual methods. With overriding, a derived class provides its own implementation of a method that is already defined in its base class.

Identifier Lookup and Candidates

When we write an expression to invoke a function in our code, we can imagine the compiler going through a multi-step process to determine what it needs to invoke.

In this lesson, our examples use free functions, but the same process applies to member functions, constructors, operators, and function templates too.

Step 1: Identify Candidates by Name

The first step of the process involves looking at the function name we used in our expression, and finding all the identifiers in scope that have that same name.

In the following example, our main function invokes SomeFunction(), and we have three things in scope that could be used.

We have a function that accepts an int, and a second function that accepts an int and a float. We also have a template that the compiler could instantiate using template argument deduction to create a third candidate:

void SomeFunction(int Input) {
  // ...
}

void SomeFunction(int x, float y) {
  // ...
}

template <typename T>
void SomeFunction(T x) {
  // ...
}

int main() {
  SomeFunction(42);
}

Step 2: Eliminate Unviable Candidates

The next step in the process involves looking through all the candidates in the list and eliminating those that can’t possibly satisfy our function call. Common reasons candidates are eliminated include:

  • They require too many or too few parameters. For example, if our function call provides two arguments, only functions that accept two arguments can ever be viable. All others are eliminated
  • They require conversions that cannot be done implicitly. For example, if we call SomeFunction() and provide a std::string argument, a SomeFunction() overload that requires an int argument will be eliminated, as a std::string cannot be converted to an int implicitly.

Explicit Conversions

Note that overload resolution only cares about conversions that can be done implicitly. That is, conversions that can be done without requiring additional code, such as an explicit cast.

Even when an implicit conversion isn’t available, an explicit conversion might be:

#include <iostream>
#include <string>

void SomeFunction(int Input) {
  std::cout << "Called SomeFunction with "
    << Input;
}

int main() {
  std::string Input{"42"};
  SomeFunction(std::stoi(Input));
}
Called SomeFunction with 42

From the purview of overload resolution, SomeFunction() is not being called with a string in the previous example. It is being called with the return type of std::stoi(), which is an int.

After eliminating all the unviable candidates, we have three possibilities:

  • There are no candidates left - that is, no function could serve our function call. In this case, we’ll get a compiler error.
  • There is one candidate left. This will be the case in most scenarios. The compiler now knows what function it needs to call, so the process stops here.
  • There are multiple candidates left. If there is more than one function that can serve our request, likely involving help from some argument conversions, we move on to analyzing the candidates to determine which one we prefer,

Step 3: Rank Candidates

Once we’ve reduced our candidate list to just those that are viable, those functions are ranked into categories. The rankings are based on how small the difference is between the types of the arguments, and the types of the parameters expected by that candidate.

Candidates whose parameter list is an exact match for the provided arguments will be ranked highly, whilst candidates whose arguments require more elaborate type conversions will be ranked lower.

Step 4: Apply Tiebreakers

If the ranking process results in a single candidate being on top, that function will be selected. If there are multiple candidates in the top group, we then compare them using tiebreakers.

These are a set of additional rules that compare functions within the same ranking, to determine if one should be preferred over the other. If that process yields a preference, we finally have our winner. If not, the compiler will determine the function call is too ambiguous, and throw an error.

Candidate Ranking

If our candidate list has multiple viable candidates, they’ll go through a ranking process to determine how favorable each candidate is. We can imagine this being a process of grouping, where candidates that require less elaborate argument conversions are ranked highly

Ranking 1: Arguments are an Exact Match

Candidates that require no argument conversion at all are at the top of the rankings. Below, we provide an int argument, and we have an overload that accepts exactly an int. Naturally, this is the preferred choice:

#include <iostream>

void Print(float x) {
  std::cout << "Float Function Called";
}

void Print(int x) {
  std::cout << "Int Function Called";
}

int main() {
  Print(42);
}
Int Function Called

Note that templates can often generate candidates that are an exact match, so are often also in this category. Below, we call Print() with an integer argument.

Using template argument deduction, the compiler sees it can instantiate the Print template to create a function that will be an exact match, by setting T to int. This means our template is selected over the function that requires a float:

#include <iostream>

void Print(float x) {
  std::cout << "Non-Template Function Called";
}

template <typename T>
void Print(T x) {
  std::cout << "Template Function Called";
}

int main() {
  Print(42);
}
Template Function Called

Ranking 2: Arguments Require Trivial Conversion

The next tier down is for candidates that require only trivial argument conversions. An example of this is providing a non-const argument to a const parameter.

Note that binding a value to an equivalent reference is not considered a conversion at all, so our first overload in the following example is an exact match for our argument: As such, the exact match will beat the trivial conversion:

#include <iostream>

void Print(int& x) {
  std::cout << "non-const int& Function Called";
}

void Print(const int& x) {
  std::cout << "const int& Function Called";
}

int main() {
  int x{42};
  Print(x);
}
non-const int& Function Called

However, a trivial conversion from an int to a const int& will outrank a conversion from an int to a float, for example:

#include <iostream>

void Print(float x) {
  std::cout << "non-const float Function Called";
}

void Print(const int& x) {
  std::cout << "const int& Function Called";
}

int main() {
  int x{42};
  Print(x);
}
const int& Function Called

There are a few more examples of trivial conversions which we’ll introduce later in the course, such as the conversion of C-style arrays to pointers, and the conversion of functions to function pointers.

Ranking 3: Arguments Require Numeric Promotion

The C++ specification has specifically carved out a set of conversions that it states should be given higher priority than most others. These are called numeric promotions, and the main two categories are:

  • Conversion of float to double
  • Conversion of small integer types such as short and unsigned short to int or unsigned int.

Below, we are providing an 8-bit integer as an argument. The compiler prefers promoting this to an int rather than converting it to a float:

#include <iostream>

void Print(float x) {
  std::cout << "float Function Called";
}

void Print(int x) {
  std::cout << "int Function Called";
}

int main() {
  short x{42};
  Print(x);
}
int Function Called

Integer promotion is a fairly complex topic. Those needing more information should consider checking the specification or a reference such as cppreference.com

Ranking 4: Arguments Require Numeric Conversion

The next ranking includes numeric conversions between built-in types, such as converting int to float and vice versa. Below, we call our function with an integer argument, with overloads accepting a float and user-defined Container type being available.

The conversion to float outranks the user-defined conversion, so the compiler prefers it:

#include <iostream>

struct Container {
  Container(int x) {}
};

void Print(float x) {
  std::cout << "float Function Called";
}

void Print(Container x) {
  std::cout << "Container Function Called";
}

int main() {
  Print(42);
}
float Function Called

Ranking 5: Arguments Require Class/Struct-based Conversion

Typically, the lowest ranking in our candidate list will be those requiring conversions we define within our classes and structs, such as constructors and typecast operators.

Below, we call our function with a const int. We have a candidate that accepts a non-const int reference, which isn’t viable as it violates the const qualifier. We have another candidate that accepts a Container, which is viable based on a conversion provided by a constructor:

#include <iostream>

struct Container {
  Container(int x){};
};

void Print(int& x) {
  std::cout << "non-const int& Function Called";
}

void Print(Container x) {
  std::cout << "Container Function Called";
}

int main() {
  const int x{42};
  Print(42);
}
Container Function Called

Broadly, these are referred to as user-defined conversions, but the term can be a bit confusing, as conversions performed by standard library types such as std::string are also considered user-defined. As such, they also fall within this ranking.

Preview: Variadic Overloads

There is a rank below user-defined conversions, which relates to variadic functions. Variadic functions are functions that can accept a variable number of arguments. In C++, this is denoted using the ellipsis syntax, ...

If no other overloads can serve a request, a variadic function will be used if it is available. Below, we’ve deleted our Container type’s int constructor, leaving the variadic function as the only viable candidate:

#include <iostream>

struct Container {
  Container(int x){}; 
};

void Print(Container x) {
  std::cout << "Container Function Called";
}

void Print(...) {
  std::cout << "Variadic Function Called";
}

int main() {
  Print(42);
}
Variadic Function Called

We cover variadics in more detail later in the course:

Tiebreakers

After ranking all of our candidates, the compiler may find itself in a situation where multiple candidates are still in the top spot. When this happens, we can imagine the compiler performing a series of tiebreakers, to determine which candidate is more appropriate. The main tiebreakers include:

Non-Template Functions are Preferred

When we have template and non-template functions within the same ranking, the non-template versions are preferred:

#include <iostream>

void Print(int x) {
  std::cout << "Non-Template Function Called";
}

template <typename T>
void Print(T x) {
  std::cout << "Template Function Called";
}

int main() {
  Print(42);
}
Non-Template Function Called

Conversions Requiring Fewer Steps are Preferred

When two overloads require the same type of conversion, the overload whose conversion requires fewer steps is preferred. Below, we provide an argument of type A, with candidates that accept either an int or a float.

Either is viable - an A can be converted to an int through its typecast operator, or it can be converted to a float through the intermediate step of being converted to a B, then using B's typecast operator

Because the conversion to int requires fewer steps, the compiler prefers it:

#include <iostream>

struct B; // Forward declaration

struct A {
  operator int() const { return 42; }
  operator B() const { return {}; }
};

struct B {
  operator float() const { return 3.14f; }
};

void Print(float x) {
  std::cout << "Called float overload";
}

void Print(int x) {
  std::cout << "Called int overload";
}

int main() {
 Print(A{});
}
Called int overload

Preview: Templates Constrained by Concepts are Preferred

Later in the chapter, we’ll introduce concepts, a feature added in C++20 that constrains our templates to only be instantiable with types that have specific characteristics.

Below, we have a template that can be instantiated with any type, and a template that can be instantiated with only integral types, using the std::integral concept. When we invoke Print() with an integer, either of these templates can create functions that will be an exact match.

However, the compiler will prefer the function that was instantiated from the template that was constrained by a concept:

#include <iostream>
#include <concepts>

template <typename T>
void Print(T x) {
  std::cout << "Called generic overload";
}

template <std::integral T>
void Print(T x) {
  std::cout << "Called constrained overload";
}

int main() {
  Print(42);
}
Called constrained overload

Ambiguous Calls and Incorrect Selections

After ranking all our candidates and considering tiebreakers, the compiler may find itself in a situation where it simply cannot determine which function to use. Therefore, it considers our invocation ambiguous:

#include <iostream>

void Print(float x) {
  std::cout << "Called float overload";
}

void Print(double x) {
  std::cout << "Called double overload";
}

int main() {
  Print(42);
}
error: 'Print': ambiguous call to overloaded function

To help out, we need to insert some explicit conversions to help the compiler disambiguate which function we want:

#include <iostream>

void Print(float x) {
  std::cout << "Called float overload";
}

void Print(double x) {
  std::cout << "Called double overload";
}

int main() {
  Print(float(42));
}
Called float overload

Where templates are involved, we can also explicitly provide the template arguments, rather than relying on automatic deduction:

#include <iostream>

void Print(float x) {
  std::cout << "Called free function overload";
}

template <typename T>
void Print(T x) {
  std::cout << "Called template overload";
}

int main() {
  Print<float>(42);
}
Called template overload

An ambiguous call may be problematic, but what’s worse is when the compiler unambiguously selects a function we didn’t expect. This can result in a much more confusing compiler error, or potentially no error at all and erroneous runtime behavior.

If we find our function calls invoking unexpected functions, that’s often indicative of a deeper problem. Functions with the same name, being visible in the same scope, but doing different things, generally indicate a problem with our design.

We can likely improve that by renaming functions such that if functions do different things, they have different names. It may also be helpful to introduce namespaces, so we can qualify exactly which function we’re expecting to use:

namespace Render {
  void Square(int SideLength) {
    // ...
  };
}

int Square(int x) {
  return x * x;
}

int main() {
  // Calculate a square
  Square(3);

  // Render a square
  Render::Square(3);
}

What doesn’t affect overload resolution?

We already covered how the value of arguments may apply to runtime polymorphism, but does not affect overload resolution. There are a few other things that aren’t considered for overload resolution, where people often assume they are:

Return type

How we’re using the return type/value of our function call is not considered within overload resolution. For example, the following two expressions will always call the same function:

int x{SomeFunction(42)};
float y{SomeFunction(42)};

Additionally, the compiler will pre-emptively attempt to stop us from making this mistake - if we declare an overload that is only distinct by its return type, we will get an error before we even try to use it:

int Print(int x) {
  std::cout << "int-returning function called";
}

bool Print(int x) {
  std::cout << "bool-returning function called";
}
error: 'bool Print(int)': overloaded function differs only by return type from 'int Print(int)'

Function Location / Declaration Order

The compiler doesn’t care about the order in which the overloads are declared, or whether they’re coming from #include directives. All candidates that are visible at the point we attempted to invoke one are treated equally.

Default Arguments

Default arguments also are not considered when ranking overloads. In the following example, our first and second overloads are both exact matches, resulting in a compilation error due to ambiguity:

#include <iostream>

void Print(int x) {
  std::cout << "(int) function called";
}

void Print(int x, int y = 2) {
  std::cout << "(int, int=2) function called";
}

int main() {
  Print(42);
}
error: 'Print': ambiguous call to overloaded function

Note however that the existence of default arguments can determine which candidates are even viable. The following works, because our second overload cannot be invoked with a single argument, so it is removed from the list entirely:

#include <iostream>

void Print(float x, int y = 2) {
  std::cout << "(float, int=2) function called";
}

void Print(int x, int y) {
  std::cout << "(int, int) function called";
}

int main() {
  Print(42);
}
(float, int=2) function called

Finally, whether or not the selected function will even compile is also not a consideration within overload selection. Below, overload resolution selects our template, as it can create a function that will be an exact match.

This results in a compilation error, even though there was another viable candidate that would have worked. However, that other candidate required a conversion, meaning it was ranked lower and not selected:

#include <iostream>

template <typename T>
void Print(T x) {
  x();
}

void Print(float x) {
  std::cout << "float Function Called";
}

int main() {
  Print(42);
}
error: x does not evaluate to a function taking 0 arguments

There is an exception to this rule, which relates to a C++ paradigm called substitution failure is not an error, abbreviated to SFINAE. We cover this in a dedicated lesson later in this chapter.

Summary

In this lesson, we learned about overload resolution in C++, which is the process by which the compiler determines which function to call when there are multiple functions with the same name but different parameters (overloads).

We covered the steps of overload resolution:

  1. Identifying candidate functions by name
  2. Eliminating functions with the wrong number of parameters or parameters that can't be converted
  3. Ranking the remaining functions based on the match between the arguments and parameters
  4. Applying tiebreaker rules if there are multiple equally good matches

We also learned about the different categories of conversion sequences (exact match, promotion, standard conversion, user-defined conversion) and how they affect the ranking of overloads.

Finally, we discussed some common pitfalls, such as ambiguous calls and unexpected overload resolution results, and how to debug them.

Key takeaways:

  • Overload resolution allows multiple functions to have the same name but different parameters
  • The compiler uses a specific set of rules to determine which overload to call
  • The argument types and the parameter types of the overloads are key factors in overload resolution
  • Implicit conversions are considered, but exact matches are preferred over conversions
  • Ambiguous calls occur when there are multiple equally good matches
  • You can use explicit casts or template arguments to guide overload resolution if needed

Was this lesson useful?

Next Lesson

Type Traits: Compile-Time Type Analysis

Learn how to use type traits to perform compile-time type analysis, enable conditional compilation, and enforce type requirements in templates.
Illustration representing computer hardware
Ryan McCombe
Ryan McCombe
Posted
A computer programmer
This lesson is part of the course:

Professional C++

Comprehensive course covering advanced concepts, and how to use them on large-scale projects.

Free, Unlimited Access
Type Traits and Concepts
Next Lesson

Type Traits: Compile-Time Type Analysis

Learn how to use type traits to perform compile-time type analysis, enable conditional compilation, and enforce type requirements in templates.
Illustration representing computer hardware
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved