0%

Storing Callable Objects in C++

This is my learning notes from the course - Mastering C++ Standard Library Features.

Storing Callable Objects

  • Storing function objects and closures in variables
  • Interactions between lambdas and function pointers
  • The FunctionObject and Callable concepts
  • std::function: Type erasure for Callable objects

Storing Functions in Variables

1
void foo() { std::cout << "hello!\n"; }
  • Functions can be stored via function pointers
    1
    2
    3
    4
    5
    auto p0 = &foo;
    void(*p1)() = &foo;

    p0(); // prints "hello!"
    p1(); // prints "hello!"
    1
    std::vector<void(*)()> vec{&foo};

Storing Hand-written Function Objects in Variables

  • The type of hand-written function objects is known
    1
    struct foo { void operator(){ std::cout << "hello!\n"; } };
    1
    2
    3
    4
    5
    auto f0 = foo{};
    foo f1;

    f0(); // prints "hello!"
    f1(); // prints "hello!"
    1
    std::vector<foo> vec{foo{}, foo{}, foo{}};

Storing Closures in Variables

  • Lambda expressions produce closures of anonymous types
  • auto (or templates) are required to store closures
    1
    auto p0 = []{ std::cout << "hello!\n"; };
    1
    2
    3
    4
    template <typename F>
    struct wrapper { F _f; };

    wrapper<decltype(p0)> w{std::move(p0)};
  • Even though multiple lambda expressions might have the same signature (same parameters, captures, and return type), it is not possible to store them homogeneously without type erasure
    1
    2
    auto l0 = []{};
    auto l1 = []{};
    • decltype(l0) is different from decltype(l1)

Lambda Expressions and Function Pointers

  • Lambda expressions are implicitly convertible to function pointers as long as they do not capture anything and have the same signature

    1
    2
    3
    4
    5
    auto l0 = []{};
    auto l1 = []{};

    void(*p0)() = l0;
    p0 = l1;
    • This is a lightweight form of type erasure
  • Example of what happens if we try to assign a lambda captured in something to a function pointer

    1
    2
    3
    4
    int i;
    auto l0 = [&i]{ };

    void(*p0)() = l0;

    ERROR: cannot convert main()::<lambda()> to void(*)() in initialization

  • Stateless lambda expressions can be explicitly converted into function pointers by using the unary + operator

    1
    2
    3
    4
    5
    auto l0 = []{ };
    static_assert(!std::is_same_v<decltype(l0), void(*)()>);

    auto p0 = +[]{ };
    static_assert(std::is_same_v<decltype(p0), void(*)()>);
    • This makes usage of auto possible and prevents spelling out complicated function pointer type

The Function Object and Callable Concepts

  • Standard Library classes “satisfy“ one or more concepts
  • A “concept“ is a set of requirements that a type must follow
  • The requirements are both syntactic and semantic

Type Erasure

  • Type erasure is the idea of “erasing“ the concrete type of an object into some common type that can store anything satisfying a particular concept
  • Usually requires indirection and/or dynamic allocations
    1
    2
    3
    4
    5
    struct A { void foo(); };
    struct B { void bar(); };

    std::any x = A{};
    x = B{};

std::function: Type Erasure for Callable Objects

  • The Standard Library provides a type erasing wrapper for Callable objects: std::function

  • It is a template class that takes a function signature as a parameter

  • An instance of std::function can then store any Callable matching that particular signature

  • Example:

    1
    2
    3
    4
    5
    6
    7
    int add(int a, int b) { return a + b; }
    const auto sub = [](int a, int b) { return a + b; };

    struct mult
    {
    int operator()(int a, int b) const { return a * b; }
    };
    1
    2
    3
    4
    std::function<int(int, int)> f = add;
    f = sub;
    f = mult{};
    f = [](int a, int b){ return a / b; };
  • std::function: https://en.cppreference.com/w/cpp/utility/functional/function

  • Some examples of what you can store in std::function

    1
    2
    3
    // non-member functions
    std::function<void(int)> f = print_num;
    f(-9);
    1
    2
    3
    // lambda expressions
    std::function<void()> f = []{ std::cout << 42; };
    f();
    1
    2
    3
    4
    // member functions
    std::function<void(const Foo&, int)> f = &Foo::print;
    Foo foo(1234);
    f(foo, 1);
    1
    2
    3
    // data members (uncommon)
    std::function<int(const Foo&)> f = &Foo::_number;
    std::cout << f(foo) << "\n";
    1
    2
    3
    // handwritten function objects
    std::function<void(int)> f = printer{};
    f(1234);
  • std::function can be “empty“ and can be arbitrary rebound

    1
    2
    int add(int a, int b);
    int sub(int a, int b);
    1
    2
    3
    4
    std::function<int(int, int)> f;  // <-- empty
    f = add; // <-- now stores `add`
    f = sub; // <-- now stores `sub`
    f = nullptr; // <-- now empty again
  • std::function can be easily stored in container

    1
    2
    3
    4
    5
    struct button
    {
    std::vector<std::function<void()>> on_click;
    // ...
    };
    button b;
    b += []{ open_modal(1) };
    b += []{ std::cout << "opening modal ... \n"; };
  • std::function is not a zero-cost abstraction

    • Invoking the stored object requires indirection
    • Can dynamically allocate if the stored object is big
    • Hard to inline/optimize
  • https://godbolt.org/g/i9vyyP

Summary

  • Understood that functions can be stored in variables using function pointers
  • Learned that the hand-written function objects can be easily stored as their type is known
  • Learned that closures must be stored with auto as their type is “anonymous
  • Studied that the unary operator + can be used to explicitly produce function pointers from stateless lambda expressions
  • Learned about the Standard Library provides two concepts:
    FunctionObject and Callable
  • Understood that Callable is more general as it includes member function pointers and member data pointers
  • std::invoke can be used to invoke any Callable object
  • Looked at many Standard Library utilities that accept Callable objects for genericity
  • std::function is a general-purpose polymorphic Callable wrapper
    • It supports copyable Callable objects
    • It uses type erasure and can potentially allocate memory at run-time
    • It is copyable, can be stored in containers, can be “empty”, and can be arbitrary rebound to other Callable objects at run-time
    • Guidelines:
      • Never use std::function unless you need type erasure
      • Use auto and templates instead - they can refer to arbitrary Callable objects without type erasure
      • Use std::function when you need flexibility at run-time or need to store Callable objects with the same signature but different types homogeneously