When writing C++, we like to do as much of the work as possible at compile time; with C++ being a statically-typed language, we know the type(s) involved when compilation is taking place. In this article we’ll look at how to pass and receive, variables and results, of differing types depending on the nature of the function call itself. The four methods we will examine are: function overloading, template specialization, tag dispatch, and SFINAE.
1. Function Overloading
In C++ the two function declarations below refer to completely different functions:
void f(int);
void f(double);
This works at the linker level due to name mangling, the actual names of the functions are modified by their parameter type(s). (This is assuming “C-linkage” is not enabled, and be aware that the return type does not affect the mangled name.)
Usual rules of type promotion for built-in types apply, so omitting the definition for an int parameter would cause the version for double to be called when given an int argument; “best match” is used in the case of there being a choice. Also an ellipsis can be used as a last-effort “catch-all”, which will match any number of parameters of any types:
void f(...);
Suppose we want to calculate the value of a number raised to an integral power, using only integer arithmetic if possible. The following program demonstrates this:
#include <iostream>
#include <cmath>
int index(int x, unsigned p) {
if (p == 0) {
std::cerr << "index(int, unsigned)\n";
return 1;
}
else {
return x * index(x, p - 1);
}
}
double index(double x, unsigned p) {
std::cerr << "index(double, unsigned)\n";
return pow(x, static_cast<double>(p));
}
int index(...) {
std::cerr << "index(...)\n";
return 0;
}
int main() {
auto a = index(-3, 4);
std::cout << "(-3) ^ 4 = " << a << '\n';
auto b = index(2.5, 2);
std::cout << "2.5 ^ 2 = " << b << '\n';
auto c = index("Hello", "There");
std::cout << c << '\n';
}
The output from running this program is:
index(int, unsigned)
(-3) ^ 4 = 81
index(double, unsigned)
2.5 ^ 2 = 6.25
index(...)
0
The fact that function overloading has been part of the language from its earliest days is no reason to disregard it, and its use still has a place in Modern C++.
2. Template Specialization
Rewriting the first (integral) version of index() as a generic function allows us to provide a specialization for (types convertible to) double. Failure to instantiate either the specialization(s) or generic version leads to a compile-time error; this may not necessarily provide the most user-friendly error messages but should (hopefully) never compile to broken code.
#include <iostream>
#include <cmath>
template<typename T>
T index(T x, unsigned p) {
if (p == 0) {
std::cerr << "index<T>()\n";
return 1;
}
else {
return x * index(x, p - 1);
}
}
template<>
double index(double x, unsigned p) {
std::cerr << "index<>(double, unsigned)\n";
return pow(x, static_cast<double>(p));
}
int main() {
auto a = index(-3, 4);
std::cout << "(-3) ^ 4 = " << a << '\n';
auto b = index(2.5, 2);
std::cout << "2.5 ^ 2 = " << b << '\n';
}
The output from running this program is:
index<T>()
(-3) ^ 4 = 81
index<>(double, unsigned)
2.5 ^ 2 = 6.25
This code has the advantage of being future-proofed to use of a multi-precision integer type, for example, and allows for other specializations (float or long double).
3. Tag Dispatch
The term “Tag Dispatch” refers to using a dummy (often, first) parameter of a user-defined type, and allowing function overloading to take place based on this placeholder type. The advantage over plain function overloading is that it is safe from type promotion issues, and is used within the Standard Library. (In fact, this method predates scoped enums, use of values from which would be a good alternative method.)
The code below is the same program modified to use tag dispatch:
#include <iostream>
#include <cmath>
class Integral{};
class Floating{};
int index(Integral, int x, unsigned p) {
if (p == 0) {
std::cerr << "index(Integral)\n";
return 1;
}
else {
return x * index(Integral{}, x, p - 1);
}
}
double index(Floating, double x, unsigned p) {
std::cerr << "index(Floating)\n";
return pow(x, static_cast<double>(p));
}
int main() {
auto a = index(Integral{}, -3, 4);
std::cout << "(-3) ^ 4 = " << a << '\n';
auto b = index(Floating{}, 2.5, 2);
std::cout << "2.5 ^ 2 = " << b << '\n';
}
Note that the first parameter to index() is not named, so no use of [[maybe_unused]] is necessary. Default-constructed objects are used at the call-sites in main(). The output from running this version is:
index(Integral)
(-3) ^ 4 = 81
index(Floating)
2.5 ^ 2 = 6.25
4. SFINAE (Substitution Failure Is Not An Error)
The most recent addition to this list is SFINAE, which refers to the process of filtering out obviously invalid template specializations, often using std::enable_if and/or decltype(). Use of SFINAE requires being fluent in template syntax, but the pain can be worth the gain.
The code below is the second (template specialization) version of the program with constraints provided by use of SFINAE. The first template only compiles successfully if std::is_integral_v<T> evaluates to true. The second template only compiles if the first did not, due to this conditional test being inverted, and also only if a variant of pow() taking type T as its first parameter is available.
#include <iostream>
#include <cmath>
#include <utility> // for std::declval<T>
#include <type_traits> // for std::enable_if
template<typename T>
typename std::enable_if_t<std::is_integral_v<T>, T>
index(T x, unsigned p) {
if (p == 0) {
std::cerr << "index<T>()\n";
return 1;
}
else {
return x * index(x, p - 1);
}
}
template<typename T>
typename std::enable_if_t<!std::is_integral_v<T>
&& !std::is_void_v<decltype(std::pow(std::declval<T>(), 0u))>, T>
index(T x, unsigned p) {
std::cerr << "index(pow)\n";
return pow(x, p);
}
int main() {
auto a = index(-3, 4);
std::cout << "(-3) ^ 4 = " << a << '\n';
auto b = index(2.5, 2);
std::cout << "2.5 ^ 2 = " << b << '\n';
}
The output from running this program is:
index<T>()
(-3) ^ 4 = 81
index(pow)
2.5 ^ 2 = 6.25
I suggest experimenting with this program, using a variety of built-in and valid/invalid user-defined types in order to gain a better understanding of how SFINAE works and the code-validation it provides.
Conclusion
This article has focused on function selection as a compile-time choice, so we have not mentioned virtual function calls (from polymorphic class hierarchies), or use of dynamic_cast. Another compile-time alternative to function selection is use of “constexpr-if”, which enables a particular code path, based on the evaluation of a boolean condition.
Hopefully you will now be equipped to view SFINAE as one method (of several) for choosing between functions based upon the parameter type(s) involved. Code for this article is available on GitHub.