absent is a C++17 small header-only library meant to simplify the functional composition of operations on nullable (i.e. optional-like) types used to represent computations that may fail.
Handling nullable types has always been forcing us to write a significant amount of boilerplate and sometimes it even obfuscates the business logic that we are trying to express in our code.
Consider the following API that uses std::optional<A>
as a nullable type to represent computations that may fail:
std::optional<person> find_person() const;
std::optional<address> find_address(person const&) const;
zip_code get_zip_code(address const&) const;
A fairly common pattern in C++ would then be:
std::optional<person> person_opt = find_person();
if (!person_opt) return;
std::optional<address> address_opt = find_address(person_opt.value());
if (!address_opt) return;
zip_code code = get_zip_code(address_opt.value());
We have mixed business logic with error-handling, and it'd be nice to have these two concerns more clearly separated from each other.
Furthermore, we had to make several calls to std::optional<T>
accessor value()
. And for each call,
we had to make sure we’d checked that the std::optional<T>
at hand was not empty before accessing its value.
Otherwise, it would've triggered a bad_optional_access
.
Thus, it’d be better to minimize the number of direct calls to value()
by
wrapping intermediary calls inside a function that checks for emptiness and then accesses the value.
Hence, we would only make a direct call to value()
from our application at the very end of the chain of operations.
Now, compare that against the code that does not make use of nullable types at all:
zip_code code = get_zip_code(find_address(find_person()));
That is possibly simpler to read and therefore to understand.
Furthermore, we can leverage function composition to reduce the pipeline of function applications:
(void -> person) compose (person -> address) compose (address -> zip_code)
Where compose means the usual function composition, which applies the first function and then feeds its result into the second function:
f: A -> B, g: B -> C => (f compose g): A -> C = g(f(x)), forall x in A
Since the types compose (source and target types match), we can reduce the pipeline of functions into a function composition:
(void -> zip_code)
However, for nullable types we can't do the same:
(void -> optional<person>) compose (person -> optional<address>) compose (address -> zip_code)
This chain of expression can't be composed or reduced, because the types don't match anymore, so compose isn't powerful enough to be used here. We can't
simply feed an std::optional<person>
into a function that expects a person
.
So, in essence, the problem lies in the observation that nullable types break our ability to compose functions using the usual function composition operator.
We want to have a way to combine both:
- Type-safety brought by nullable types.
- Expressiveness achieved by composing simple functions as we can do for non-nullable types.
Inspired by Haskell, absent provides building-blocks based on functional programming to help us to compose computations that may fail.
It abstracts away some details of an "error-as-value" API by encapsulating common patterns into a small set of higher-order functions that encapsulates repetitive pieces of logic. Therefore, it aims to reduce the syntactic noise that arises from the composition of nullable types and increase safety.
It worth mentioning that absent does NOT provide any implementation of nullable types. It rather tries to be generic and leverage existing implementations:
Up to some extent, absent is agnostic regarding the concrete implementation of a nullable type that one may use, as long as it adheres to the concept of a nullable type expected by the library.
The main example of a nullable type that models this concept is: std::optional<T>
, which may get a monadic interface in the future.
Meanwhile, absent may be used to fill the gap. And even after, since it brings different utilities and it's also generic regarding the concrete nullable type implementation,
also working for optional-like types other than std::optional<T>
.
For instance, a function may fail due to several reasons and you might want to provide more information to explain why a
particular function call has failed. Perhaps by returning not an std::optional<A>
, but rather a types::either<A, E>
. Where types::either<A, E>
is an alias for std::variant<A, E>
,
and, by convention, E
represents an error. types::either<A, E>
is provided by absent and it supports a whole set
of combinators.
absent is packaged as a header-only library and, once installed, to get started with it you simply have to include the relevant headers.
Using a prefix notation, we can rewrite the zip_code example using absent as:
std::optional<zip_code> code_opt = transform(and_then(find_person(), find_address), get_zip_code);
And that solves the initial problem of lack of compositionality for nullable types.
Now we express the pipeline as:
(void -> optional<person>) and_then (person -> optional<address>) transform (address -> zip_code)
And that's functionally equivalent to:
(void -> optional<zip_code>)
For convenience, an alternative infix notation based on operator overloading is also available:
std::optional<zip_code> code_opt = find_person() >> find_address | get_zip_code;
Which is closer to the notation used to express the pipeline:
(void -> optional<person>) >> (person -> optional<address>) | (address -> zip_code)
Hopefully, it's almost as easy to read as the version without using nullable types and with the expressiveness and type-safety that we wanted to achieve.
transform
is used when we want to apply a function to a value that is wrapped in a nullable type if such nullable
isn't empty.
Given a nullable N<A> and a function f: A -> B,
transform
uses f to map over N<A>, yielding another nullable N<B>. If the input nullable is empty,transform
does nothing, and simply returns a brand new empty nullable N<B>.
Example:
auto int2str = [](auto x){ return std::to_string(x); };
std::optional<int> one{1};
std::optional<std::string> one_str = transform(one, int2str); // std::optional{"1"}
std::optional<int> none = std::nullopt;
std::optional<std::string> none_str = transform(none, int2str); // std::nullopt
To simplify the act of chaining multiple operations, an infix notation of transform
is provided by operator|
:
auto int2str = [](auto x){ return std::to_string(x); };
std::optional<int> one{1};
std::optional<std::string> one_str = one | int2str; // std::optional{"1"}
and_then
allows the application of functions that themselves return nullable types.
Given a nullable N<A> and a function f: A -> N<B>,
and_then
uses f to map over N<A>, yielding another nullable N<B>.
The main difference if compared to transform
is that if you apply f using transform
you end up with N<N<B>>
that would need to be flattened.
Whereas and_then
knows how to flatten N<N<B>> into N<B> after the function f has been applied.
Suppose a scenario where you invoke a function that may fail and you use an empty nullable type to represent such failure.
And then you use the value inside the obtained nullable as the input of another function that itself may fail with an empty nullable.
That's where and_then
comes in handy.
Example:
auto int2str_opt = [](auto x){ return std::optional{std::to_string(x)}; };
std::optional<int> one{1};
std::optional<std::string> one_str = and_then(one, int2str_opt); // std::optional{"1"}
std::optional<int> none = std::nullopt;
std::optional<std::string> none_str = and_then(none, int2str_opt); // std::nullopt
To simplify the act of chaining multiple operations, an infix notation of and_then
is provided by operator>>
:
auto int2str_opt = [](auto x){ return std::optional{std::to_string(x)}; };
std::optional<int> one{1};
std::optional<std::string> one_str = one >> int2str_opt; // std::optional{"1"}
eval
returns the wrapped value inside a nullable if present or evaluates the
fallback function and returns its result in case the nullable is empty. Thus, it provides a "lazy variant" of std::optional<T>::value_or
.
Given a nullable N<A> and a function f: void -> A,
eval
returns the un-wrapped A inside N<A> if it's not empty, or evaluates f that returns a fallback, or default, instance for A.
Here, lazy roughly means that the evaluation of the fallback is deferred to point when it must happen,
which is: inside eval
when the nullable is, in fact, empty.
Therefore, it avoids wasting computations as it happens with std::optional<T>::value_or
, where, the function
argument is evaluated before reaching std::optional<T>::value_or
, even if the nullable is not empty, in which case
the value is simply discarded.
Maybe even more seriously case is when the fallback triggers side-effects that would only make sense when the nullable is indeed empty.
Example:
role get_default_role();
std::optional<role> role_opt = find_role();
role my_role = eval(role_opt, get_default_role);
Sometimes we have to interface nullable types with code that throws exceptions, for instance, by wrapping exceptions into empty nullable
types. This can be done with attempt
.
Example:
int may_throw_an_exception();
std::optional<int> result = attempt<std::optional, std::logic_error>(may_throw_an_exception);
may_throw_an_exception
returns either a value of type int
, and then result
will be an std::optional<int>
that wraps the returned value, or it throws an exception derived from std::logic_error
, and then result
will be an empty std::optional<int>
.
for_each
allows running a function that does not return any value, but only executes an
action when supplied with a value, where such value is wrapped in a nullable type.
Since the action does not return anything meaningful, it's only executed because of its side-effect, e.g. logging a message to the console, saving an entity in the database, etc.
Given a nullable N<A> and a function f: A -> void,
for_each
executes f providing A from N<A> as the argument to f. If N<A> is empty, thenfor_each
does nothing.
Example:
void log(event const&) const;
std::optional<event> event_opt = get_last_event();
for_each(event_opt, log);
from_variant
allows us to go from an std::variant<As...>
to a "simpler" nullable type, such as std::optional<A>
, holding a value
of type A
if the variant holds a value of such type, or empty otherwise.
std::variant<int, std::string> int_or_str = 1;
std::optional<int> int_opt = from_variant<int>(int_or_str); // std::optional{1}
int_or_str = std::string{"42"}
std::optional<int> int_opt = from_variant<int>(int_or_str); // std::nullopt
One way to do multiple error-handling is by threading a sequence of
computations that return std::optional<T>
to represent success or failure,
and the chain of computations should stop as soon as the first one returns an empty
std::optional
, meaning that it failed. For instance:
std::optional<blank> first();
std::optional<blank> second();
auto const ok = first() >> sink(second);
if (ok) {
// handle success
}
else {
// handle failure
}
Where:
support::blank
is a type that conveys the idea of a unit, i.e. it can have only one possible value.support::sink
wraps a callable that should receive parameters in another callable, but discards the whatever arguments it receives.
It's also possible to raise the level of abstraction by using the alias support::execution_status
for
std::optional<blank>
, as well as the compile-time constants of type execution_status
:
success
for anexecution_status
filled with aunit
.failure
for anexecution_status
filled with astd::nullopt
.
For example:
execution_status first() {
// ...
return success;
}
execution_status second() {
// ...
return failure;
}
auto const ok = first() >> sink(second);
if (ok) {
// handle success
}
else {
// handle failure
}
- Abuse of operator-overloading: We give different meanings to some operators, e.g.
operator>>
meansand_then
, instead of extracting from an input stream. - Lack of interface coherence: We may overload operators (e.g.
operator>>
) for types that we don't own (e.g.std::optional<T>
), and therefore code may break if the true owner of a given type happens to define the operator in the future.
- C++17
- CMake
- Make
- Conan
- Docker
The Makefile conveniently wraps the commands to fetch the dependencies need to compile the tests using Conan, invoke CMake to build, execute the tests, etc.
- Compile:
make # BUILD_TESTS=OFF to skip tests
- To run the tests:
make test
Optionally, it's also possible to build and run the tests inside a Docker container by executing:
make env-test
To install absent:
make install
This will install absent into ${CMAKE_INSTALL_PREFIX}/include/absent and make it available into your CMake local package repository.
Then, it's possible to import absent into external CMake projects, say in a target myExample, by adding the following commands into the CMakeLists.txt:
find_package(absent REQUIRED)
target_link_libraries(myExample rvarago::absent)
absent is also integrated into the following package managers: