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; }
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) { 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
Reference Link