SkillAgentSearch skills...

Absent

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

Install / Use

/learn @rvarago/Absent

README

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

<A name="transform"/>transform

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"}

<A name="and_then"/>and_then

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"}

<A name="eval"/>eval

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

Related Skills

View on GitHub
GitHub Stars45
CategoryDevelopment
Updated8mo ago
Forks8

Languages

C++

Security Score

87/100

Audited on Jul 19, 2025

No findings