0%

Passing Functions to Functions in C++

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
2
3
4
5
const auto call_twice = [](auto f)
{
f();
f();
};
1
call_twice([]{ std::cout << "hello\n"; });

Output:

hello
hello

Returning Functions

1
2
3
4
5
6
7
const auto less_than = [](int x)
{
return [x](int y)
{
return y < x;
};
};
1
2
3
4
auto less_than_five = less_than(5);  // FunctionObject
assert(less_than_five(3));
assert(!less_than_five(10));
assert(less_than(5)(-20));

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
2
3
4
5
6
const auto benchmark = [](auto f)
{
start_timer();
f();
std::cout << end_timer();
};
1
2
3
4
benchmark([]
{
my_algorithm(100, 200);
});

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 template operator() 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
    7
    int add(int, int);
    int sub(int, int);

    void execute_operation(int(*op)(int, int), int a, int b)
    {
    op(a, b);
    }
    1
    2
    3
    execute_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

Template parameters

  • Can be used to pass any FunctionObject (or Callable if std::invoke is used)

    1
    2
    template <typename F>
    void call_with_zero(F&& f) { f(0); }
    1
    2
    3
    4
    void 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
    10
    struct 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
  • Constraining Signatures
    • With std::enable_if
      1
      2
      3
      4
      5
      template <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
      5
      template <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)

std::function

  • Can be used to pass any Callable
    1
    void call_with_zero(std::function<void(int)> f) { f(0); };
    1
    2
    3
    4
    void 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
    struct 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
  • Summary
    • Advantages:
      • Flexibility: Any Callable can be passed
      • Easy to constrain to a particular signature
    • Drawbacks:
      • Heavyweight general-purpose polymorphic function wrapper
      • Performance hit due to indirection and potential allocations
      • Unclear semantics: Can be owning, non-owning or empty
  • Semantics
    1
    2
    3
    4
    void foo(std::function<void()> f)
    {
    f();
    }
    1
    2
    3
    foo(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
      2
      foo("hello world!");
      foo(std::string{"goodbye"});
    • What if we apply the same idea to Callable objects?
  • 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
      4
      void foo(function_ref<void(int)> f)
      {
      f(10);
      }
      1
      2
      3
      4
      5
      6
      7
      8
      auto l = [i = 0](int x) mutable
      {
      i += x;
      return i;
      };

      assert(foo(l) == 10);
      assert(foo(l) == 20);
    • Benchmark

    • Implementation

      • function_ref is a class template that takes a signature as its template parameters
      • Its constructor takes any Callable object, whose operator() 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
      8
      template <typename Signature>
      class function_ref;

      template <typename Return, typename ... Args>
      class function_ref<Return(Args ...)>
      {
      // ...
      };
      • Specializing function_ref to extract Return and Args
      1
      2
      3
      4
      5
      6
      7
      8
      9
      template <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 to invoke_erased_fn
      1
      2
      3
      4
      5
      6
      7
      8
      template <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 its T::operator()
      1
      2
      3
      4
      5
      6
      7
      template <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 are Callable with Args
      • Prevent hijacking function_ref‘s copy/move constructors
      1
      2
      3
      4
      decltype(auto) operator()(TArgs ... xs) const
      {
      return _erased_fn(_ptr, std::forward<TArgs>(xs) ... );
      }
      • function_ref::operator() will invoke the _erased_fn passing the type-erased void* ptr as the first argument, and fowarding all xs
      • 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 generic Callable 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 to Callable 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 and std::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 and function_ref are more general but might introduce overhead