0%

Safer Interfaces with Higher-Order Functions in C++

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

Safer Interfaces with Higher-Order Functions

  • How higher-order functions can make interfaces safer
  • Using the Standard Library in conjunction with higher-order functions to create safer abstractions

Higher-Order Functions and Safety

  • Higher-order functions allow interface designers to restrict the operations that an user can access at given time
  • Classes that have some sort of “null state” and related preconditions can usually benefit from higher-order functions in their interface

Example: Implementing a Thread-Safe Class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class thread_safe_counter
{
public:
void add_one()
{
std::lock_guard l{_mtx};
_value += 1;
}

void add_two()
{
_value += 2; // whoops, forgot to lock the mutex
}

private:
std::mutex _mtx;
int _value{0};
};
  • The main problem with thread_safe_counter is that _value is accessible even when _mtx is not locaked
    • The compiler cannot prevent human mistakes - forgetting to lock _mtx is not detectable
  • What if we made _value only accessible when the mutex is held?

  • Let us create a locked<T> template class that stores a T instance and a mutex
  • The T instance is only exposed when the mutex is held
  • We can achieve this by providing an access higher-order function
1
2
3
4
5
6
7
8
9
10
11
template <typename T>
class locked
{
public:
template <typename F>
decltype(auto) access(F&& f);

private:
std::mutex _mtx;
T _data;
};
  • f is guaranteed to be invoked only when _mtx is held
1
2
3
4
5
6
template <typename F>
decltype(auto) locked<T>::access(F&& f)
{
std::lock_guard l{_mtx};
return std::forward<F>(f)(_data);
}
  • Users of locked<T> can only access the T instance through the access higher-order function
  • Using access guarantees exclusive access to the stored T instance
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class thread_safe_counter
{
public:
void add_one();
void add_two();

private:
locked<int> _value;
};

void thread_safe_counter::add_one()
{
_value.access([](int& x){ x += 1; });
}

void thread_safe_counter::add_two()
{
_value.access([](int& x){ x += 2; });
}
  • It is much harder to misuse _value. Users have to go out of their way to break this abstraction

Example: Preventing Undefined Behavior

  • std::unique_ptr<T>::operator* allows access to the heap-allocated object
  • If the pointer is null, the behavior is undefined
  • Could a better interface prevent human mistakes?
1
2
3
4
5
6
7
8
9
10
11
12
13
void foo(std::unique_ptr<int> p)
{
if(p != nullptr)
{
do_something(*p);
}
}

void bar(std::unique_ptr<int> p)
{
// whoops, forgot to check for `nullptr`
do_something_else(*p);
}
  • The problem is that *p is always a valid operation, even though the user might not have checked p != nullptr
  • Dereferencing p when p == nullptr is undefined behavior
  • Can we make this mistake harder to make using higher-order functions?

1
2
3
4
5
6
7
8
template <typename T, typename F>
decltype(auto) with_pointee(std::unique_ptr<T>& p, F&& f)
{
if(p != nullptr)
{
return std::forward<F>(f)(*p);
}
}
  • Using with_pointee guarantees that f will only be invoked if the pointer is not null, preventing undefined behavior
1
2
3
4
5
6
7
8
9
void foo(std::unique_ptr<int> p)
{
with_pointee(p, [](int x){ do_something(x); });
}

void bar(std::unique_ptr<int> p)
{
with_pointee(p, [](int x){ do_something_else(x); });
}
  • Using this interface, it is not possible to accidentally dereference a null pointer

  • This example also applies to std::optional<T>, introduced in C++17
  • std::optional<T> models as instance of T that might or might not be present
  • The T instance is stored in-place inside the optional - there is no dynamic allocation going on here
1
2
3
4
5
6
7
void foo(std::optional<int> o)
{
if(o != std::nullopt)
{
do_something(*o);
}
}
  • Same issue as std::unique_ptr: the user must remember to explicitly check for null values
1
2
3
4
5
6
7
8
template <typename T, typename F>
decltype(auto) with_value(std::optional<T>& o, F&& f)
{
if(o != nullptr)
{
return std::forward<F>(f)(*o);
}
}
  • f will be invoked only if o is set
1
2
3
4
void foo(std::optional<int> o)
{
with_value(o, [](int x){ do_something(x); });
}
  • Safer interface, prevents human mistakes
  • Can be expanded to support default values

Example: Avoiding Manual Initialization / Reset Steps

1
2
3
4
5
6
void draw_frame()
{
_window.clear();
for(const auto& s : shapes) { _window.draw(s); }
_window.render();
}
  • This API requires the window to be cleared first
  • The user can then draw objects on it
  • Finally, window.render() must be called to display the objects on the screen

1
2
3
4
5
6
7
template <typename F>
void window::with(F&& f)
{
this->clear();
std::forward<F>(f)(*this);
this->render();
}
  • clear() and render() could either be private or kept public for users who want fine-grained control
  • A proxy object could be used instead of *this to expose less/more operations
1
2
3
4
5
6
7
void draw_frame()
{
_window.with([&](auto& w)
{
for(const auto& s : shapes) { w.draw(s); }
});
}
  • The user cannot forget to invoke .clear() and .render()
  • In case window needs more initialization/reset steps in the future, user code won’t have to be changed

Summary

  • Learned that if you encounter any of the patterns shown in the example, consider writing an higher-order function to make them less error-prone
  • Studied that when designing an API, keep in mind that higher-order functions can be used to restrict the available operations to an user
  • Learned that before C++11, this kind of design was too cumbersome. Lambda expressions are the key feature that make it viable

Section Summary

  • Learned that lambdas work very well as predicates or actions for the Standard Library algorithms
  • Lambdas can be used to conveniently start tasks in separate threads
  • Lambdas can help performing operations on tuples and pair
  • Used lambdas as short local functions to avoid repetition
  • Learned that the IIFE idiom can help you achieve const-correctness
  • Considered writing higher-order functions interfaces to limit the operations that your API exposes, making it safer