This is my learning notes from the course - Mastering C++ Standard Library Features.
Passing Functions to Functions
- Higher-order functions
- Functions as arguments
- Functions pointers
- Template parameters
std::function
function_ref
Higher-Order Functions
Higher-order functions are functions that can either take other functions as arguments or return them as results.
– from Wikipedia - Higher-order function
First-class functions: https://en.wikipedia.org/wiki/First-class_function
Taking Functions as Arguments
1 | const auto call_twice = [](auto f) |
1 | call_twice([]{ std::cout << "hello\n"; }); |
Output:
hello
hello
Returning Functions
1 | const auto less_than = [](int x) |
1 | auto less_than_five = less_than(5); // FunctionObject |
As you can see, we have a lambda function, which takes an int x
, then return another closure that capture x
by value, takes an argument y
, and returns whether or not that argument y
is less than what we captured before.
The code is basically binding the first argument to the less_than
operator, so that we can reuse it with multiple values.
This technique is called currying in functional programming. The idea is that, instead of providing all the arguments to a certain operation at once, you provide every argument and return another function that takes the rest of the arguments one by one. This allows you to partially bind some arguments of the function in order to create a more flexible interface.
Example Use Case
1 | const auto benchmark = [](auto f) |
1 | benchmark([] |
What Parameters Type
auto
has been used to pass/return functions in these examples- Is it always the right choice?
- What are its drawbacks/advantages?
- what other options are available?
- (Remember that
auto
parameters in generic lambdas expand to a templateoperator()
in the generated closure) - Parameter Types
- Functions pointers
- Template parameters (or
auto
) std::function
function_ref
Function Pointers
- Function pointers can be used to pass
- Non-member functions
- Stateless lambdas
- Member functions can be passed through member function pointers
- Example:
1
2
3
4
5
6
7int add(int, int);
int sub(int, int);
void execute_operation(int(*op)(int, int), int a, int b)
{
op(a, b);
}1
2
3execute_operation(add, 2, 5); // evaluates to `7`
execute_operation(sub, 10, 5); // evaluates to `5`
execute_operation([](int a, int b){ return a * b; }, 2, 4); // evaluates to `8` - Summary
- Advantages:
- Simplicity
- Compilers can aggressively optimize functions pointers
- Drawbacks:
- Very limited use cases
- Stateful lambdas cannot be passed
- Generic function objects cannot be passed
- Advantages:
Template parameters
Can be used to pass any
FunctionObject
(or Callable ifstd::invoke
is used)1
2template <typename F>
void call_with_zero(F&& f) { f(0); }1
2
3
4void foo(int) { }
call_with_zero(foo);
call_with_zero(some_function_object{});
call_with_zero([i = 0](int) mutable { });Accepting Callable Objects
1
2
3
4
5
6
7
8
9
10struct bar
{
void foo();
};
template <typename F>
void call_member_function(F&& f)
{
std::invoke(f, bar{});
}1
call_member_function(&bar::foo);
Summary
- Advantages:
- Zero-overhead: Compilers can aggressively inline and optimize
- Flexibility: Any
FunctionObject
can be passed
- Drawbacks:
- Possible compilation time degradation
- Possible code bloat
- Hard to constrain to a particular signature
- Advantages:
- Constraining Signatures
- With
std::enable_if
1
2
3
4
5template <typename F>
auto call_with_zero(F&& f) -> std::enable_if_t<std::is_invocable_v<F&&, int>>
{
f(0);
} - With expression SFINAE
1
2
3
4
5template <typename F>
auto call_with_zero(F&& f) -> decltype(f(0), void())
{
f(0);
}- (Unable to express desired signature as part of the
F
parameter)
- (Unable to express desired signature as part of the
- With
std::function
- Can be used to pass any
Callable
1
void call_with_zero(std::function<void(int)> f) { f(0); };
1
2
3
4void foo(int) { }
call_with_zero(foo);
call_with_zero(some_function_object{});
call_with_zero([i = 0](int) mutable { }); - Accepting Callable Objects
1
2
3
4
5
6
7
8
9struct bar
{
void foo();
};
void call_member_function(std::function<void(bar)> f)
{
f(bar{});
}1
call_member_function(&bar::foo);
- No need to use
std::invoke
,std::function
will internally do it for you
- No need to use
- Summary
- Advantages:
- Flexibility: Any
Callable
can be passed - Easy to constrain to a particular signature
- Flexibility: Any
- Drawbacks:
- Heavyweight general-purpose polymorphic function wrapper
- Performance hit due to indirection and potential allocations
- Unclear semantics: Can be owning, non-owning or empty
- Advantages:
- Semantics
1
2
3
4void foo(std::function<void()> f)
{
f();
}1
2
3foo(std::function<void()>{}); // <-- empty
foo([]{ /* ... */ }; // <-- owning, f becomes the owner of lambda
foo(std::ref(some_function)); // <-- non-owning- The parameter type does not convey the intended semantics
function_ref
Inspiration
- Since C++17, the Standard Library provides a lightweight type-erasing non-owning wrapper for strings:
std::string_view
1
void foo(std::string_view s) { /* ... */ } // no ownership, no copying, no moving
1
2foo("hello world!");
foo(std::string{"goodbye"}); - What if we apply the same idea to
Callable
objects?
- Since C++17, the Standard Library provides a lightweight type-erasing non-owning wrapper for strings:
function_ref
The Standard Library does not provide any lightweight type-erasing
Callable
reference (proposed in P0792)Let’s implement our own:
function_ref
Goals:
- Lightweight: Should be passed by value. No allocations. Easy for the compiler to inline/optimize
- User-friendly: Constrained through type, similar interface to
std::function
- Clear semantics: Non-owning reference semantics
- Flexible: Accepts any
Callable
object
Usage Example
1
2
3
4void foo(function_ref<void(int)> f)
{
f(10);
}1
2
3
4
5
6
7
8auto l = [i = 0](int x) mutable
{
i += x;
return i;
};
assert(foo(l) == 10);
assert(foo(l) == 20);Benchmark
function_ref
can be as fast as template parameters- Yet, more user-friendly
std::function
generates more instructions- Code and full measurements:
Implementation
function_ref
is a class template that takes a signature as its template parameters- Its constructor takes any
Callable
object, whoseoperator()
invocation will be type erased function_ref::operator()
will call the type erased pointee and return its result
1
2
3
4
5
6
7
8template <typename Signature>
class function_ref;
template <typename Return, typename ... Args>
class function_ref<Return(Args ...)>
{
// ...
};- Specializing
function_ref
to extractReturn
andArgs
1
2
3
4
5
6
7
8
9template <typename Return, typename ... Args>
class function_ref<Return(Args ...)>
{
private:
using signature_type = Return(void*, Args ...);
void* _ptr;
Return (*_erased_fn)(void*, Args ...);
};- Store the erased
operator()
as a function pointer - Store a
void*
to the object on which toinvoke_erased_fn
1
2
3
4
5
6
7
8template <typename T, typename = /* constrain `T` */>
function_ref(T&& x) noexcept : _ptr{(void*)&x}
{
_erased_fn = [](void* ptr, Args ... xs) -> Return
{
return (*reinterpret_cast<T*>(ptr))(std::forward<Args>(xs) ... );
};
}- Take any
Callable x
, store its address and type-erase itsT::operator()
1
2
3
4
5
6
7template <typename T, typename =
std::enable_if_t<std::is_invocable_v<T, Args ...>{}
&& !std::is_same<std::decay_t<T>, function_ref>{}>>
function_ref(T&& x) noexcept : _ptr{(void*)&x}
{
// ...
}- Only enable the constructor for
T
objects that areCallable
withArgs
- Prevent hijacking
function_ref
‘s copy/move constructors
1
2
3
4decltype(auto) operator()(TArgs ... xs) const
{
return _erased_fn(_ptr, std::forward<TArgs>(xs) ... );
}function_ref::operator()
will invoke the_erased_fn
passing the type-erasedvoid* ptr
as the first argument, and fowarding allxs
decltype(auto)
is returned to preserve references
Usage Example
Summary
- Learned that there are many options you can use when passing functions to other functions:
- Function pointers
std::function
- Template parameters
function_ref
- Studied that all of them have significant advantages and drawbacks
Guidelines
- Use
std::function
only if you need to model ownership of a genericCallable
object that can be rebound at run-time- Highly flexible
- Hard to inline
- Potentially significant run-time overhead
- Use a template parameter for both storing and referring to
Callable
objects when type erasure is not required- Can easily be inlined, no run-time overhead
- Requires code to be in headers
- Can increase compilation time
- Use
function_ref
to refer toCallable
object through lightweight type erasure- Can be usually inlined
- Small run-time overhead
- Not in the Standard Library
Section Summary
- Learned that lambdas are very versatile and can be used to solve multiple problems
- Discussed Standard Library facilities like
std::bind
andstd::mem_fn
should not be used - lambdas are a better option - Learned that the callable objects can be stored or passed to functions without any overhead thanks to templates and type inference
- Studied about type erasing wrappers such as
std::function
andfunction_ref
are more general but might introduce overhead