/absent

A small C++17 library meant to simplify the composition of nullable types in a generic, type-safe, and declarative way.

Primary LanguageC++MIT LicenseMIT

absent

Build Status

Build Status

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.

Description

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.

Composition with absent

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.

Getting started

absent is packaged as a header-only library and, once installed, to get started with it you simply have to include the relevant headers.

Rewriting the person/address/zip_code example using absent

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.

Combinators

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, then for_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

Multiple error-handling

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 an execution_status filled with a unit.
  • failure for an execution_status filled with a std::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
}

Obvious drawbacks

  1. Abuse of operator-overloading: We give different meanings to some operators, e.g. operator>> means and_then, instead of extracting from an input stream.
  2. 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.

Requirements

Mandatory

  • C++17

Optional

  • CMake
  • Make
  • Conan
  • Docker

Build

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

Build inside a Docker container

Optionally, it's also possible to build and run the tests inside a Docker container by executing:

make env-test

Installing on the system

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)

Package managers

absent is also integrated into the following package managers:

  1. Conan
  2. Vcpkg