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:
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.
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.
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);
}
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:
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.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:
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.
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.
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
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
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.
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:
float
to double
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
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
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.
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:
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:
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
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
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
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);
}
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:
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)'
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 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.
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:
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:
Learn how the compiler decides which function to call based on the arguments we provide.
Comprehensive course covering advanced concepts, and how to use them on large-scale projects.