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