Monadic Operations in Modern
C++: A Practical Approach
2
About me
● Vitaly Fanaskov
● Senior software engineer at reMarkable
● 10+ years of C++ experience
● GIS, VFX, frameworks, and libraries
● Ph.D (CS)
3
Agenda
● Briefly about expected and optional
● Common use cases of expected
● Monadic operations in software development
● Tips and tricks
4
In this talk
● Less theory
● C++ only
● Practical examples
5
Where do examples come from?
6
Our internal framework
Library 1 Module 1 Bundle 1
Library 2 Module 2 Bundle 2
Library 3 Module 3 Bundle 3
… … …
Library N Module N Bundle N
7
User interface subsystem
● Collect windows and widgets from modules
● Use bindings to 3rd party libraries (e.g. Qt) to display them
● Navigation
8
Technologies we use
● C++ 20
● vcpkg
● Many 3rd-party libraries (e.g. ranges-v3, tl-expected, catch2 etc)
● Qt for UI on devices
9
Briefly about Qt
C++ code: QML code:
● Business logic ● UI-related things
● Integration
● System-level
10
Briefly about classes
11
std::optional
#include <optional> ● Contains a value
● …or doesn’t contain a value
● Close to std::pair<T, bool>
std::optional<int> optionalBox;
optionalBox = 42;
fmt::println(
"The value is: {}",
optionalBox.value());
12
std::optional as a return value
When an operation can fail, but it doesn't matter why:
template<class T>
[[nodiscard]]
std::optional<std::size_t> Vector<T>::indexOf(const T &element) const {
./ Do lookup...
return std::nullopt; ./ If not found
}
13
std::optional as a parameter
When you need to pass some auxiliary arguments:
Url resolveUrl(
std::string_view input,
std::optional<Configuration> configuration = std::nullopt)
{
./ Read configuration if any
./ Resolve
}
14
It’ll be iterable
C++ 26
The optional object is a view that contains either one element if it contains a value, or otherwise
zero elements if it does not contain a value. The lifetime of the contained element is bound to the
object.
15
std::expected
#include <expected> ● Either a value
● …or an error
● Close to std::variant<T, E>
std::expected<int, Error> expectedBox;
expectedBox = 42;
fmt::println(
"The value is: {}",
expectedBox.value());
16
std::expected as a return value
When an operation can fail and we need to know why:
std::expected<Widget, WidgetError> loadWidget()
{
./ If error
return std::unexpected(WidgetError{ .* ... ./ });
./ Actual result
return Widget{ .* ... ./ };
}
17
Where can I get it?
● std::*
● tl::* (via vcpkg or Conan)
18
Use cases
19
What do we use?
● std::expected (approximately 90% of all cases)
● Error handling
● To unify interface
20
Process std::expected
void loadWidget()
{
if (const auto widgetBox = getNewWidget(); widgetBox.has_value()) {
const auto widget = widgetBox.value();
./ Do something with the widget ...
} else {
const auto error = widgetBox.error();
./ Handle the error ...
}
}
21
That was not entirely bad example…
void loadWidgetV2()
{
const auto widgetBox = getNewWidget();
if (widgetBox.has_value()) {
./ Do something with the widget ...
} else {
const auto error = widgetBox.error();
log("Cannot get a new widget {}: {}.", widgetBox.value(), error);
}
}
22
How do we handle this?
23
Monadic operations: and_then
if (const auto widgetBox = getNewWidget(); widgetBox.has_value()) {
const auto widget = widgetBox.value();
./ Do something with the widget ...
}
getWidget().and_then(
[](const auto &widget) :> std::expected<Widget, WidgetError> {
./ Do something with the widget ...
return widget;
});
24
Monadic operations: transform
if (const auto widgetBox = getWidget(); widgetBox.has_value()) {
const auto widget = widgetBox.value();
return widget.id();
}
getWidget().transform([](const auto &widget) :> ID { return widget.id(); });
25
Monadic operations: or_else
if (const auto widgetBox = getWidget(); widgetBox.has_value()) {
./ ...
} else {
log(widgetBox.error());
}
getWidget().and_then(.* ... ./).or_else([](const auto& error){ log(error); });
26
Monadic operations: transfrom_error
if (const auto widgetBox = getWidget(); widgetBox.has_value()) {
./ ...
} else {
return fmt::to_string(widgetBox.error());
}
getWidget().and_then(.* ... ./).transform_error(&fmt::to_string<WidgetError>);
27
On the way to software development
28
Key parts
● People
● Design
● Start with small pieces
29
People
30
An example: widget ID – left or right?
std::expected<ID, WidgetError> loadWidget() std::expected<ID, WidgetError> loadWidget()
{ {
if (const auto widgetBox = getWidget()) { return getWidget()
const auto widget = widgetBox.value(); .* .and_then ... ./
.transform(&Widget::id);
./ Do something with the widget ... }
return widget.id();
} else {
return {};
}
}
31
Observations
● Functional programming is difficult for many
● Combining functions using “monadic operations” is not straightforward
● std::optional and std::expected is terra incognita
32
What should I do about this?
● Show practical benefits (e.g. less boilerplate code)
● Do more workshops
● Do design review
33
Design
34
You will not be doing purely functional programming
● There will be side effects
● There will be object-oriented-style parts of the existing code base
● There will be integration with 3rd-party libraries
● …
35
Try splitting functional-style code from the rest
● A part where we use “monadics” and apply the best practices of functional
programming
● A part where it can be difficult to do (or it doesn't make much sense)
○ Final logging output
○ Integration with UI libraries
○ Glue-code
○ ...
36
Where is the boundary?
● Class interface
● Library interface
37
Example: window layout class diagram
IWindowLayout UI::WindowLayout
WindowLayoutStack
38
Example: window layout
class IWindowLayout
{
public:
virtual core::Expected:> add(const window::Uri& windowUri) = 0;
};
./ ...
core::Expected:> WindowLayoutStack::add(const window::Uri& windowUri)
{
return loadWindow(windowUri, m_windowLoader)
.and_then(&addToLayout)
.and_then(&updateActiveWindow)
.or_else(&addWindowLayoutPrefix);
}
39
Example: window layout wrapper
./ Inside another namespace
class WindowLayout : public QObject
Q_OBJECT
Q_PROPERTY(QString activeWindow READ activeWindow NOTIFY activeWindowChanged)
./ ...
private:
std::shared_ptr<IWindowLayout> m_windowLayout;
};
40
Example: window layout wrapper
void WindowLayout::add(const window::Uri& windowUri)
{
m_windowLayout:>add(windowUri).or_else(&printError);
}
41
What did we achieve?
● Integration with the existing codebase
● Monadic interface
● Separation of functional and non-functional code
42
Small steps
43
General approach (assume you have OO-style code-base)
● Start with the implementation of the methods
● Partially change class interface
● Fully change class interface
● (Optionally) Drop the entire class
44
Example: add a window – initial state
std::shared_ptr<Window> loadWindow(
const window::Uri &windowUri, const window::Loader &loader);
./ ...
bool WindowLayoutStack::add(const window::Uri& windowUri)
{
if (auto window = loadWindow(windowUri, m_loader))
{
./ ...
}
return false;
}
45
Example: add a window – change implementation
core::Expected<std::shared_ptr<Window:> loadWindow(
const window::Uri &windowUri, const window::Loader &loader);
./ ...
bool WindowLayoutStack::add(const window::Uri &windowUri)
{
auto result = loadWindow(windowUri, m_loader)
.and_then(&addToLayout)
.* .and_then... ./;
./ ...
return result.has_value();
}
46
Example: add a window – change interface
core::Expected:> WindowLayoutStack::add(const window::Uri& windowUri)
{
return loadWindow(windowUri, m_loader)
.and_then(&addToLayout)
.* .and_then... ./;
}
47
Tips and Tricks
48
Assuming
● You’re at the very beginning of your journey
● There are not many well-defined practices for using monadic operations
● There are not too many functional programming libraries used in the project
49
Lambda functions
● Lambda functions are flexible and powerful tool
● With noisy syntax
● And this is yet another footgun
Use less:
● Nested lambda functions
● Long lambda functions
● Lambda functions assigned to local variables
50
Use less lambda functions
core::Expected:> WindowLayoutStack::add( core::Expected:> WindowLayoutStack::add(
const window::Uri& windowUri) const window::Uri& windowUri)
{ {
return loadWindow( const auto addToLayout =
windowUri, m_windowLoader) []( /**/ ) { /**/ };
.and_then(&addToLayout) const auto updateActiveWindow =
.and_then(&updateActiveWindow) []( /**/ ) { /**/ };
.or_else(&addWindowLayoutPrefix); const auto addWindowLayoutPrefix =
} []( /**/ ) { /**/ };
return loadWindow(
windowUri, m_windowLoader)
.and_then(addToLayout)
.and_then(updateActiveWindow)
.or_else(addWindowLayoutPrefix);
}
51
…and more free functions in general
● Small steps with names
● Easy to reuse
● Easy to test
52
Use bind_back/front
● There is already a function
● …used in many places
● …and you cannot easily change a signature
● A solution with lambda functions looks too cumbersome
● std::bind_front – C++20
● std::bind_back – C++23
53
bind_back/front
auto add = [](int a, int b) { return a + b; };
auto addOne = std::bind_back(add, 1);
fmt::println("{}", addOne(2)); ./ prints 3
auto inc = [](int &a, int v) { a += v; };
int a{};
auto incA = std::bind_front(inc, std::ref(a));
incA(2);
fmt::println("{}", a);
54
Possible implementation of bind_back
template<class F, class ::.BoundArgs>
auto bind_back(F :&f, BoundArgs :&::.boundArgs)
{
return [::.boundArgs = std::forward<BoundArgs>(boundArgs), f = f]
<class ::.RemainArgs>(RemainArgs :&::.remainArgs) {
return std::invoke(f, std::forward<RemainArgs>(remainArgs)::., boundArgs::.);
};
}
55
Example of using xostd::bind_back
core::Expected:> addWindowLayoutPrefix(
const std::string &errorString, std::string_view customPrefix)
return core::make_unexpected(fmt::format("{} {}", errorString, customPrefix));
};
//...
.or_else(std::bind_back(&addWindowLayoutPrefix, "[StackLayout]"));
56
Make all functions return expected/optional
● Easy to compose
● Doesn’t require additional libraries or helpers
core::Expected:> removeFromLayout(MapIt windowIterator, Map& windows, Stack& windowsStack)
{
windowsStack.erase(windowIterator:>second);
windows.erase(windowIterator);
return {};
}
57
Tuples your best friends
● No need to create extra structures
● Easy to pass several objects
core::Expected<std::tuple<Context, Component, QString:>
createComponent(QQmlEngine& engine, const QString& fileName, const QString& viewName)
{
./...
return std::make_tuple(std::move(context), std::move(component), viewName);
}
58
Don’t forget about transform_error
● 3rd-party libraries can have different error types
● Need to pass additional context
● Need to amend existing error (e.g. add a message prefix)
59
Explore 3rd-party libraries
● Get inspired
● Learn
● Use well-tested solutions
60
Thank you!
61