Here is a quick overview over lack of strong typing in higher order functions using template parameter as type of argument. This tip will present a possible solution to solve this problem.
Problem
Evolution of C++ higher order functions come a long way to form we know from modern C++. At first, function pointers were used, which allowed to define signature of function passed as an argument. While they were cumbersome to write (argument name in the middle of signature, weird syntax for member functions), they provided strong typed argument definition.
int add1(int x) { return x + 1; }
void print(int fn(int), int x) { std::cout << fn(x) << "\n"; }
struct Foo {
int foo(int x) { return x + 2; }
} foo;
void printFoo(int (Foo::*fn)(int), Foo& obj, int x) {
std::cout << (obj.*fn)(x) << "\n";
}
int main() {
print(add1, 1);
printFoo(&Foo::foo, foo, 1);
return 0;
}
The same code can be written using modern approach - template parameters:
int add1(int x) { return x + 1; }
template<class Fn>
void print(Fn fn, int x) { std::cout << fn(x) << "\n"; }
struct Foo {
int foo(int x) { return x + 2; }
} foo;
template<class Fn>
void printFoo(Fn fn, Foo& obj, int x) {
std::cout << (obj.*fn)(x) << "\n";
}
int main() {
print(add1, 1);
printFoo(&Foo::foo, foo, 1);
auto captureless_lambda = [](int x) { return x + 3; }
auto capturing_lambda = [y=4](int x) { return x + y; }
print(captureless_lambda, 1);
print(capturing_lambda, 1);
return 0;
}
This style is easy to read and allows to use functor objects like lambdas as passed function. But using this style has one flaw, that the previous one doesn't have - it is not strong typed. In the code snippet from above, we could inject any kind of function, that accepts int
as an argument, and the compiler will not complain (as long as returned type has ostream& operator<<
). This can potentially lead to hard to detect runtime errors.
C++17 Solution
The obvious solution is to use std::function
, right? Well, unless you are going to do some polymorphism with it, or you don't care about the cost, it is a viable solution. Another one is to use SFINAE
to limit the Fn
template parameter. But writing this every time can get old very quickly. So let's write little type trait to help us with this task.
Before we start, we must address the problem of the signature of passed function. We could use syntax used in standard library, where signature is divided into return type and argument list, but this could potentially lead to errors in order of parameters. Instead, we will use function pointer syntax. Yes, cumbersome function pointer syntax, but with a twist. Two twists exactly. The first one allows us to omit the name when passing function pointer as template parameter (there is no variable, only types). The second one is use of something that is not normally associated with function pointers - trailing return type. It will allow us to pass function signature in functional programming style (which isn't required for code to work, but is a nice touch for readability). For example, signature of function passed to print from above can have signature as follows:
auto (int) -> int
Which means that it is a function that takes int
and return an int
. Using member function in printFoo
is a bit more complicated:
auto (Foo::*)(int) -> int
Language is a bit inconsistent with usage of '*
' in function pointers. It is required to use member functions, but optional in use of free functions and static member functions.
Getting that out of the way, let's start with C++17 version of this code. Our little type trait will take two template parameters: type to test if is invocable Fn
and signature to check it against Fs
.
template<class Fn, class Fs>
struct has_signature;
Next, we will write two specializations of this template, one for member functions, and one for everything else. Let's start with the second case:
template<class Fn, class R, class... Args>
struct has_signature<Fn, auto (Args...) -> R>
: std::enable_if<std::is_invocable_r_v<R, Fn, Args...>> {};
The code above is specialization for Fs
that is a function pointer taking parameter pack Args
and returning type R
. Then it passes it to std::is_invocable_r_v
to check if function and signature match. We can use similar code for member function specialization:
template<class Fn, class C, class R, class... Args>
struct has_signature<Fn, auto (C::*)(Args...) -> R>
: std::enable_if<std::is_invocable_r_v<R, Fn, C&, Args...>> {};
This version takes function pointer to member function and deconstructs it to three parameters: class which function is member of C
, return type R
and arguments parameter pack Args
. Specialization adds reference to object of class C
at the beginning of arguments list, to account for hidden argument "this
".
Cherry on top is this little helper alias:
template<class Fn, class Fs>
using has_signature_t = typename has_signature<Fn, Fs>::type;
That way, we can use our trait as follows:
int add1(int x) { return x + 1; }
template<class Fn, class = has_signature_t<Fn, auto (int) -> int>>
void print(Fn fn, int x) { std::cout << fn(x) << "\n"; }
struct Foo {
int foo(int x) { return x + 2; }
} foo;
template<class Fn, class = has_signature_t<Fn, auto (Foo::*)(int) -> int>>
void printFoo(Fn fn, Foo& obj, int x) {
std::cout << (obj.*fn)(x) << "\n";
}
Live demo of C++17 version with additional examples is available at https://godbolt.org/z/esTcEYrvE.
C++20 Solution
Using the code in classic SFINAE
style may be still confusing. Connection between template parameter and metaprogramming part containing our trait is not obvious. Let's try to fix that using C++20 concepts. First, we must simplify our trait, because concept mechanism will do part of our job for us (it is basically a built in std::enable_if
). First, we must convert inheritance from std::enable_if
to inheritance from std::is_invocable_r
:
template<class Fn, class Fs>
struct has_signature;
template<class Fn, class R, class... Args>
struct has_signature<Fn, auto (Args...) -> R>
: public std::is_invocable_r<R, Fn, Args...> {};
template<class Fn, class C, class R, class... Args>
struct has_signature<Fn, auto (C::*)(Args...) -> R>
: public std::is_invocable_r<R, Fn, C&, Args...> {};
Next, we must change our helper to return value instead of type:
template<class Fn, class Fs>
inline constexpr bool has_signature_v = has_signature<Fn, Fs>::value;
And finally, write concept that will use our helper variable:
template<class Fn, class Fs>
concept signature = has_signature_v<Fn, Fs>;
Thanks to that, we can simplify our code:
int add1(int x) { return x + 1; }
void print(signature<auto (int) -> int> auto fn, int x) {
std::cout << fn(x) << "\n";
}
struct Foo {
int foo(int x) { return x + 2; }
} foo;
void printFoo(signature<auto (Foo::*)(int) -> int> auto fn, Foo& obj, int x) {
std::cout << (obj.*fn)(x) << "\n";
}
As in the previous paragraph, you can find additional examples in https://godbolt.org/z/dT4e1qj65.
Conclusion
As we can see, we managed to merge two worlds, gaining strong typed callable object, which can accept free functions, static
member functions, captureless and capturing lambdas, and non-static member functions. Presented solutions don't imply runtime overhead and adds little boilerplate in function definition ( signature<auto ...> auto ).
History
- 10th June, 2022: Initial version
- 11th June, 2022: Changed mistakely used "type safety" instead of "strong typing"