Jonathan Boccara's blog

Strong Optionals

Published January 16, 2018 - 5 Comments

Strong optionals

Both strong types and optionals are useful tools to make our interfaces more expressive. Could they be used in synergy to make one benefit from each other?

The contents of this post are at an experimental stage. They are laid out here to expose a problem and a possible solution, and as a basis for discussion. So your feedback will be welcome on this article (as it is welcome on any post, really).

All optionals are grey in the dark

strong optionalOptional can be useful to execute partial queries.

For instance, let’s consider this interface that retrieves a collection of Employees that have a given first name and last name:

std::vector<Employees> findEmployees(std::string const& firstName, std::string const& lastName);

The following call:

findEmployees("John", "Doe")

returns the collection of the employees that are called John Doe.

Now say that we want to add a new functionality: searching all the employees that have a given first name, like “John”. Or a given last name, like “Doe”.

To achieve this, we can make this interface accept optionals instead of hard strings:

std::vector<Employees> findEmployees(std::optional<std::string> const& firstName, std::optional<std::string> const& lastName);

optional is available in the standard library in C++17, and has been in Boost for a long time before that.

To retrieve all the employees that have the first name “John”, we can pass it as a first parameter and pass an empty optional as a second parameter:

findEmployees("John", std::nullopt)

And similarly, to get all the employees that belong to the Doe family:

findEmployees(std::nullopt, "Doe")

This interface gets the job done, but has at least two problems, which are related:

Problem #1: the parameter std::nullopt express that we pass “no” parameter. But at call site, it hides what role this parameter should have had in the function. It’s no parameter, but no what? No first name? No last name? No something else?

Problem #2: with the meaning of this parameter hidden, it becomes arguably even easier to mix up the order of parameters: findEmployees(std::nullopt, "Doe") looks very much like findEmployees("Doe", std::nullopt), since both have only one “real” parameter.
And it gets more confusing if there are more parameters: findEmployees(std::nullopt, "Doe", std::nullopt), with the third parameter representing, say, the department of the employee. It then becomes harder to see if “Doe” really is at the correct position between the std::nullopts.

Strong optionals

Clarifying the role of each parameter of an interface sounds like a job for strong types. Would it be possible to have a “strong optional”, that doesn’t use std::nullopt as a default parameter, but something more specific to its meaning instead?

Let’s design a class around that constraint.

This class would be essentially like an optional, but with an additional type NoValue that represents an empty value. It would have a is-implemented-in-terms-of relationship with optional, so we model this by containing an optional inside the class (see Effective C++ items 32 and 38 for more about how to express the various relationship between entities in C++):

template<typename T, typename NoValue>
class NamedOptional
{
private:
    std::optional<T> o_;
};

Its interface would resemble the one of std::optional except that it could be constructible from its NoValue type:

    NamedOptional(NoValue) noexcept : o_(){}

Now here is all the code put together. The interface of std::optional is richer than meets the eye so if you don’t like to look at tedious code, don’t look at this thorough forwarding to the interface of std::optional:

template<typename T, typename NoValue>
class NamedOptional
{
public:
    NamedOptional() noexcept : o_() {}
    NamedOptional(NoValue) noexcept : o_(){}
    constexpr NamedOptional(const NamedOptional& other) : o_(other.o_) {}
    constexpr NamedOptional( NamedOptional&& other ) noexcept : o_(std::move(other.o_)){}
    template < class U >
    NamedOptional( const NamedOptional<U, NoValue>& other ) : o_(other.o_) {}
    template < class U >
    NamedOptional( NamedOptional<U, NoValue>&& other ) : o_(std::move(other.o_)){}
    template< class... Args > 
    constexpr explicit NamedOptional( std::in_place_t, Args&&... args ) : o_(std::in_place, std::forward<Args...>(args...)){}
    template< class U, class... Args >
    constexpr explicit NamedOptional( std::in_place_t,
                                 std::initializer_list<U> ilist, 
                                 Args&&... args ) : o_(std::in_place, ilist, std::forward<Args...>(args...)){}
    template<typename U = T>
    NamedOptional(U&& x) : o_(std::forward<U>(x)){}
    NamedOptional& operator=( NoValue ) noexcept { o_ = std::nullopt; }
    NamedOptional& operator=( const NamedOptional& other ) { o_ = other.o_; }
    NamedOptional& operator=( NamedOptional&& other ) noexcept(std::is_nothrow_move_assignable<T>::value && std::is_nothrow_move_constructible<T>::value) { o_ = std::move(other.o_); }
    template< class U = T > 
    NamedOptional& operator=( U&& value ) { o_ = std::forward<U>(value); }
    template< class U >
    NamedOptional& operator=( const NamedOptional<U, NoValue>& other ) { o_ = other.o_; }
    template< class U >
    NamedOptional& operator=( NamedOptional<U, NoValue>&& other ) { o_ = std::forward<U>(value); }
    constexpr std::optional<T> const& operator->() const { return o_; }
    constexpr std::optional<T>& operator->() { return o_; }
    constexpr const T& operator*() const& { return *o_; }
    constexpr T& operator*() & { return *o_; }
    constexpr const T&& operator*() const&& { return *std::move(o_); }
    constexpr T&& operator*() && { return *std::move(o_); }
    explicit operator bool () const { return static_cast<bool>(o_); }
    constexpr bool has_value() const noexcept { return o_.has_value(); }
    constexpr T& value() & { return o_.value(); }
    constexpr const T & value() const &  { return o_.value(); }
    constexpr T&& value() &&  { return std::move(o_).value(); }
    constexpr const T&& value() const && { return std::move(o_).value(); }
    template< class U > 
    constexpr T value_or( U&& default_value ) const& { return o_.value_or(std::forward<U>(default_value)); }
    template< class U > 
    constexpr T value_or( U&& default_value ) && { return std::move(o_).value_or(std::forward<U>(default_value)); }
    void swap( NamedOptional& other ) noexcept { return o_.swap(other.o_); }
    void reset() noexcept { o_.reset(); }
    template< class... Args > 
    T& emplace( Args&&... args ) { return o_.emplace(std::forward<Args...>(args...)); }
    template< class U, class... Args > 
    T& emplace( std::initializer_list<U> ilist, Args&&... args ) { return o_.emplace(ilist, std::forward<Args...>(args...)); }
    template< class U > friend constexpr bool operator==( const NamedOptional& lhs, const NamedOptional<U, NoValue>& rhs ) { return lhs.o_ == rhs.o_; }
    template< class U > friend constexpr bool operator!=( const NamedOptional& lhs, const NamedOptional<U, NoValue>& rhs ) { return lhs.o_ != rhs.o_; }
    template< class U > friend constexpr bool operator<( const NamedOptional& lhs, const NamedOptional<U, NoValue>& rhs ) { return lhs.o_ < rhs.o_; }
    template< class U > friend constexpr bool operator<=( const NamedOptional& lhs, const NamedOptional<U, NoValue>& rhs ) { return lhs.o_ <= rhs.o_; }
    template< class U > friend constexpr bool operator>( const NamedOptional& lhs, const NamedOptional<U, NoValue>& rhs ) { return lhs.o_ > rhs.o_; }
    template< class U > friend constexpr bool operator>=( const NamedOptional& lhs, const NamedOptional<U, NoValue>& rhs ) { return lhs.o_ >= rhs.o_; }
    friend constexpr bool operator==( const NamedOptional& lhs, NoValue) { return lhs.o_ == std::nullopt; }
    friend constexpr bool operator!=( const NamedOptional& lhs, NoValue) { return lhs.o_ != std::nullopt; }
    friend constexpr bool operator< ( const NamedOptional& lhs, NoValue) { return lhs.o_ <  std::nullopt; }
    friend constexpr bool operator<=( const NamedOptional& lhs, NoValue) { return lhs.o_ <= std::nullopt; }
    friend constexpr bool operator> ( const NamedOptional& lhs, NoValue) { return lhs.o_ >  std::nullopt; }
    friend constexpr bool operator>=( const NamedOptional& lhs, NoValue) { return lhs.o_ >= std::nullopt; }
    friend constexpr bool operator==( NoValue, const NamedOptional& rhs) { return std::nullopt == rhs.o_; }
    friend constexpr bool operator!=( NoValue, const NamedOptional& rhs) { return std::nullopt != rhs.o_; }
    friend constexpr bool operator< ( NoValue, const NamedOptional& rhs) { return std::nullopt <  rhs.o_; }
    friend constexpr bool operator<=( NoValue, const NamedOptional& rhs) { return std::nullopt <= rhs.o_; }
    friend constexpr bool operator> ( NoValue, const NamedOptional& rhs) { return std::nullopt >  rhs.o_; }
    friend constexpr bool operator>=( NoValue, const NamedOptional& rhs) { return std::nullopt >= rhs.o_; }
    template< class U > friend constexpr bool operator==( const NamedOptional& lhs, const U& value) { return lhs.o_ == value; }
    template< class U > friend constexpr bool operator!=( const NamedOptional& lhs, const U& value) { return lhs.o_ != value; }
    template< class U > friend constexpr bool operator< ( const NamedOptional& lhs, const U& value) { return lhs.o_ <  value; }
    template< class U > friend constexpr bool operator<=( const NamedOptional& lhs, const U& value) { return lhs.o_ <= value; }
    template< class U > friend constexpr bool operator> ( const NamedOptional& lhs, const U& value) { return lhs.o_ >  value; }
    template< class U > friend constexpr bool operator>=( const NamedOptional& lhs, const U& value) { return lhs.o_ >= value; }
    template< class U > friend constexpr bool operator==( const U& value, const NamedOptional& rhs) { return value == rhs.o_; }
    template< class U > friend constexpr bool operator!=( const U& value, const NamedOptional& rhs) { return value != rhs.o_; }
    template< class U > friend constexpr bool operator< ( const U& value, const NamedOptional& rhs) { return value <  rhs.o_; }
    template< class U > friend constexpr bool operator<=( const U& value, const NamedOptional& rhs) { return value <= rhs.o_; }
    template< class U > friend constexpr bool operator> ( const U& value, const NamedOptional& rhs) { return value >  rhs.o_; }
    template< class U > friend constexpr bool operator>=( const U& value, const NamedOptional& rhs) { return value >= rhs.o_; }
    friend size_t std::hash<NamedOptional<T, NoValue>>::operator()(NamedOptional<T, NoValue> const& x) const;
private:
    std::optional<T> o_;
};
namespace std
{
template< typename T, typename NoValue >
void swap( NamedOptional<T, NoValue>& lhs, NamedOptional<T, NoValue>& rhs ) noexcept(noexcept(lhs.swap(rhs))) { return lhs.swap(rhs); }
template<typename T, typename NoValue>
struct hash<NamedOptional<T, NoValue>>
{
    size_t operator()(NamedOptional<T, NoValue> const& x) const
    {
        return std::hash<T>()(x.o_);
    }
};
}

Isn’t it like Boost Outcome / std::expected?

This NamedOptional component represents a value that could be there or not, and has an additional template parameter. From afar, this can look a bit like Outcome that is in Boost, or to its yet-to-be standard counterpart std::expected.

But when we get closer, we can see NamedOptional doesn’t represent the same thing as those two. Indeed, Outcome and expected represent a piece of data that could be empty, but accompanied with a piece of information that gives details about why it is empty. This is more powerful that optional or NamedOptional in this regard, as they only contain the binary information that the value is empty or not.

In our case we don’t need to know why it isn’t there. It is a partial query, so it is expected that some parameters are not specified. So optional and expected can serve different purposes, and NamedOptional is closer to optional and adds a more explicit names to the empty values.

Strong types + strong optionals

Let’s now use this strong optional to express that an empty parameter can mean “no first name” or “no last name”, and that those two mean a different thing:

struct NoFirstName{};
using OptionalFirstName = NamedOptional<std::string, NoFirstName>;
struct NoLastName{};
using OptionalLastName = NamedOptional<std::string, NoLastName>;

EDIT: after discussing this with Ivan Čukić, we realized that “AnyFirstName” was better expressing the intention of “we don’t specify a first name because it could be any first name” than “NoFirstName”:

struct AnyFirstName{};
using OptionalFirstName = NamedOptional<std::string, AnyFirstName>;
struct AnyLastName{};
using OptionalLastName = NamedOptional<std::string, AnyLastName>;

Note that, contrary to the usual definitions of NamedTypes, we can’t declare AnyFirstName inside the using declaration, because since we are going to instantiate it, we need a definition and not just a declaration.

To get all the employees of the Doe family we now have to write:

findEmployees(AnyFirstName(), "Doe");

which provides a solution to problems #1 and #2 above: we know what the empty argument stand for, and mixing up the arguments would not compile:

findEmployees("Doe", AnyFirstName()); // compilation error

because the second parameter, an OptionalLastName, cannot be constructed from a AnyFirstName.

To go further in clarifying the meaning of those function parameters, we can combine strong optionals with strong types:

using FirstName = NamedType<std::string, struct FirstNameTag>;
struct AnyFirstName{};
using OptionalFirstName = NamedOptional<FirstName, AnyFirstName>;
using LastName = NamedType<std::string, struct LastNameTag>;
struct AnyLastName{};
using OptionalLastName = NamedOptional<LastName, AnyLastName>;

which leads to this type of call site:

findEmployees(AnyFirstName(), LastName("Doe"));

The purpose of this development was to clarify the role of each of the (possibly empty) parameters of the function.

Now that you’ve seen the problem and a possible solution, it’s your turn to express your opinion on this!

Do you think there is a need for strong optionals? Do you see another way to go about this issue?

You may also like:

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

Comments are closed