Jonathan Boccara's blog

Expressiveness, Nullable Types, and Composition (Part 2)

Published July 19, 2019 - 0 Comments

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:

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:

  • |” means fmap.
  • >>” means bind.

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

Don't want to miss out ? Follow:   twitterlinkedinrss
Share this post!Facebooktwitterlinkedin