Expressiveness, Nullable Types, and Composition (Part 2)
This is Part 2 of guest author Rafael Varago‘s series on composing nullable types. In this episode, Rafael presents us absent, a generic library to compose nullable types in C++.
In the first part of this series, we saw how C++20’s monadic composition will help us to compose std::optional<T>
in a very expressive way. Now let’s see what we could do in the meantime and also how to tackle the same problem for other nullable types.
Enters absent
In the meantime, absent may help us to fill the gap by lifting nullable types into monads and working for std::optional<T>
and offering adapters for other types that model nullable types as well.
However, it’s important to mention that there’s no need to know what a monad is in order to benefit from the concrete advantages of absent
.
absent
is an open-source project shipped as a tiny header-only library. Its ideas were inspired by functional programming, especially from Haskell and Scala via their expressive Maybe and Option types.
absent
does not provide any implementation of a nullable type, because we already have plenty of excellent implementation available, like std::optional<T>
. Instead, it delegates to the concrete one that you happen to be using.
Furthermore, it’s also possible to adapt custom nullable types that don’t provide the API expected by absent
to work with it by providing template specializations. To make this work, the type has to adhere to a set of minimum requirements as described in the documentation. Here’s a list of nullable types currently supported via provided adapters:
- Boost.Optional
- either<A, E> which is a left-biased alias std::variant<A, E> provided by
absent
. Here, left-biased means that it maps overeither<A, E>
toeither<B, E>
- std::unique_ptr<T>
And more are planned to be added.
NOTE: Although std::unique_ptr
is a supported nullable type by absent
, I would advise against using it to express nullability. Because a pointer usually has more than this sole meaning, e.g. it can be used in order to enable subtyping polymorphism, allocation in the free store, etc. Therefore, using it may cause confusion and yield a less expressive code than using a better suitable type such as std::optional<T>
.
Combinators
Barely speaking, in a similar way of C++20 monadic std::optional<T>
, absent
provides some simple combinators implemented as small free functions that forward to the underlying nullable type.
Among the provided combinators implemented so far, two are of particular interest here:
– fmap
: Given a nullable N<A> and a function f: A -> B, fmap
uses f to map over N<A>, yielding another nullable N<B>.
– bind
: Given a nullable N<A> and a function f: A -> N<B>, bind
uses f to map over N<A>, yielding another nullable N<B>.
Both combinators are fail-fast, which means that when the first function in a pipeline of functions to be composed yields and empty nullable type, then the proceeding functions won’t even be executed. Therefore, the pipeline will yield an empty nullable type.
Two give you an example of how bind could be implemented for std::optional<T>
, we may have:
template <typename A, typename Mapper> auto bind(std::optional<A> input, Mapper fn) -> decltype(fn(std::declval<A>())) { if (!input.has_value()) { // If it’s empty, then simply returns an empty optional return std::nullopt; } // Otherwise, returns a new optional with the wrapped value mapped over return fn(std::move(input.value())); }
NOTE: The current implementation in absent
is slightly more complex, since it aims to be more generally applicable.
An interesting fact worth mentioning is that fmap
could be implemented in terms by bind
, by wrapping the mapping function inside a lambda that forwards the function application and then wraps the result inside a nullable type. And that’s precisely the current implementation used for absent
.
fmap
is the ideal one to handle getZipCode()
, since returns a zip_code
directly, i.e. it doesn’t wrap inside a nullable.
Likewise bind
fits nicely with findAddress()
, since it returns an std::optional<address>
. If we had tried to use fmap
for it, we’d end up with a rather funny type: std::optional<std::optional<address>>
, which would then need to be flattened into an std::optional<address>
. However, bind
does it altogether underneath for us.
Right now, each combinator is available under its own header file with the same name. For instance, fmap
is declared in absent/combinators/fmap.h
. And, as a convenience, all combinators can be imported at once by including absent/absent.h.
The combinators are all contained in the namespace rvarago::absent
, which you may want to alias in your project to reduce verbosity.
Let’s see how we could rewrite the example using absent
and then check whether or not it may help us by simplifying notation.
Rewriting using absent
to compose std::optional<T>
By using absent
we can solve the problem of composition using the introduced combinators as::
(query ->optional<person>) bind (person ->optional<address>) fmap (address -> zipcode)
That becomes:
(query ->optional<zipcode>)
And the intermediary function applications happens under the hood, as we wanted to :).
That translates to C++ code as:
#include <absent/absent.h> using namespace rvarago::absent; auto const zipCode = fmap(bind(findPerson(custom_query), findAddress), getZipCode); if (!zipCode) return; use(zipCode.value());
It’s getting better!
Now:
- The error handling only happens once.
- If any check fails, then
absent
will yield an empty std::optional as the result for the whole chain that is then checked to return from the function. - The error handling only happens at the end.
Furthermore, we don’t need to keep track of intermediary variables that may add syntactic noise to the code and cognitive load on the reader. Most of the boiler-plate is handled internally by absent
.
One thing that might not be so good is the reasonably dense prefix notation, that causes a nested set of function calls. This can be improved, absent
also provides overloaded operators for some combinators. Therefore providing an infix notation that eliminates the nesting and might read even nicer:
- “
|
” meansfmap
. - “
>>
” meansbind
.
So we could rewrite the line the retrieves the ZIP code as:
auto const zipCode = findPerson(custom_query) >> findAddress | getZipCode;
Thus, the syntactic noise was reduced even more and it reads from “left-right”, rather than “outside-inside”.
If findPerson()
returns an empty std:optional<person>
, then neither findAddress()
nor getZipCode()
will be executed. So the whole pipeline will yield an empty std:optional<zip_code>
. And the same logic follows for findAddress()
.
How about member functions?
What happens if instead of free functions, we had member functions?
A first and more general approach would be to wrap them inside lambdas that capture the objects and then use absent
the same way we’ve done so far. This works, it’s a general approach and it’s perfectly fine.
However, sometimes, it may be another source of syntactic noise to the caller code that we might not want to pay.
So, as a convenience, absent
also provides overloads for fmap
and bind
that accept “getter” member functions that have to be const and parameter-less.
Thus, if we had:
struct zip_code {}; struct address { zip_code getZipCode() const; }; struct person { std::optional<address> findAddress() const; };
We could rewrite the line that retrieves the ZIP code as:
auto const zipCode = findPerson(custom_query) >> &person::findAddress | &address::getZipCode;
Composing other nullable types
Another problem that we faced on part 1 was to apply composition to std::variant<A, E>
. As a recap, we had:
struct error {}; // represents a possible error that happened struct zip_code {}; struct address {}; struct person {}; std::variant<person, error> findPerson(Query const&) std::variant<address, error> findAddress(person const&); zip_code getZipCode(address const&);
Luckily, absent
provides an alias for std::variant<A, E>
named either<A, E>
that maps over A to B to produce a new either<B, E>
. Hiding the checking against the right alternative under the covers.
For the non member functions (the same applies for member functions), we could then modify the signatures to return either<T, E>
:
either<person, error> findPerson(Query const&) either<address, error> findAddress(person const&); zip_code getZipCode(address const&);
And compose exactly the same way as we did for std::optional<T>.
auto const zipCode = findPerson(custom_query) >> findAddress | getZipCode;
And we have the same vocabulary of combinators working for different kinds of nullable types, yielding the same advantages of expressiveness and type-safety that we’ve seen so far.
foreach
for when you just care about side-effects
Besides the described combinators, absent
offers more features, such as foreach
that runs a given side-effect only if a non-empty std::optional<T>
was provided.
One use-case for foreach
is where you would like to log the wrapped value if any. Otherwise, in case of an empty nullable, you don’t want to do anything:
void log(person const&) const;
And then we could call it via foreach
as:
foreach(findPerson(custom_query), log);
eval as a call-by-need version of value_or
Sometimes when using std::optional<T>
, we have a sensible default for the case its empty, for these cases we usually use value_or
that receives a default value that is returned when the optional is empty.
However, it has the inconvenience of being eagerly evaluated, i.e. its evaluation always happens regardless of the optional being empty or not, and it happens at the caller code.
Such inconvenience can be prohibitive sometimes, for instance when the instantiation of the default value is too expensive or it has side-effects that only makes sense to be run when the optional is in fact empty.
To fill this gap, absent
provides a general purpose eval
as a very similar version of value_or
, but works for all nullable types supported by absent
.
Moreover, it simulates call-by-need, in which, instead of receiving the default value itself, it receives a nullary (zero-argument) function that returns
the default value and this function only gets called when the nullable happens to be empty. Therefore any computation to build the default value or relevant side-effects is deferred, and only happens when the nullable is empty.
We may use it like this:
eval(make_nullable(), make_fallback_person);
Where make_fallback_person
may be:
person make_fallback_person();
Even if make_fallback_person
happens to throw, the exception won’t be triggered unless make_nullable
returns an empty nullable.
Conclusion
The ability to compose behaviors is one of the key aspects to write expressive code and we should always strive to bring expressiveness and safety together.
C++ has a powerful type-system from which we should extract the most we can to help us to catch bugs early, ideally at compile-time. And absent
may help your project as well.
The project tries to adhere to Modern CMake practices, so it should be easy to install on the system and get started, if that’s not the case, please let know. And hopefully as a Conan package soon.
It’s important to emphasize that there’s no such thing as a silver bullet, so absent
does NOT solve all the problems, actually, it’s far away from it. It’s simply offers an alternative way to handle a very specific problem of enabling some kinds of compositions for some kinds of nullable types.It has the advantage of enabling composition for different nullable types, favouring immutable operations that don’t mutate the argument, instead they create new brand instances and return it.
This is a pro, but may also be a con depending on your specific criterion, since this means that few instances might be created and destroyed as the flow of composition happens, which may or may not cause performance-related concerns. Hopefully, some copies may be optimized away by the compiler in some circumstances, but as usual, when we think about performance, it’s important to obtain objective measurements that prove it’s a real problem.
Furthermore, there are multiple ways to achieve pretty much the same goal as absent
attempts to achieve. Sometimes some ways may be better than the others, but it vastly depends on the specific scenario and requirements that you happen to have. As a pragmatic advice, we should be ready to assess pros and cons, and then choose the right tool for the right job. Expectantly, absent
may be this tool for some jobs, or at least give us some ideas about how we could use another tool as well :).
Being a fairly new project, absent
lacks many features, improvements, and optimizations. But the ideas behind it may be helpful to write composable code using nullable types. And more features are planned to be added in the future.
Needless to say, as an open-source project, your ideas, suggestions, fixes, improvements, etc are always more than welcome :). I’m looking forward to your feedback.
You will also like
- Expressiveness, Nullable Types, and Composition (Part 1)
- An Alternative Design to Iterators and Ranges, Using std::optional
- Clearer interfaces with optional<T>
- Partial queries with optional<T>
- Why Optional References Didn’t Make It In C++17
Share this post!