Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C++

Strong Typed Higher Order Functions in C++

5.00/5 (5 votes)
11 Jun 2022CPOL4 min read 8.7K  
An overview of lack of strong typing in higher order functions in modern C++ and possible solution to this problem
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.

C++
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:

C++
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);

    //lambdas
    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:

C++
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:

C++
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.

C++
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:

C++
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:

C++
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:

C++
template<class Fn, class Fs>
using has_signature_t = typename has_signature<Fn, Fs>::type; 

That way, we can use our trait as follows:

C++
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:

C++
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:

C++
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:

C++
template<class Fn, class Fs>
concept signature = has_signature_v<Fn, Fs>;

Thanks to that, we can simplify our code:

C++
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"

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)