Implementing operator<=> for optional<T>

Viewed 1139

With operator<=> being added into C++20, I wanted to try to reason about how to implement this operator for those cases where it's not a simple member-wise comparison.

How would you implement the spaceship operator for comparing an optional<T> to an optional<U>or a U, which is a case where we either have to compare the T to the U or compare underlying states, getting the correct return type? There isn't such an example in the latest paper.

1 Answers

With <=> we have to make a decision: what do we want to do if the underlying comparison we need does not yet implement <=>?

One option is: don't care. Just use <=> and if the relevant types don't provide it, we're not comparable. That makes for a very concise implementation (note that you need to do the same thing for == for a total of six functions):

template <typename T>
class optional {
public:
    // ...

    template <typename U>
    constexpr std::compare_three_way_result_t<T, U>
    operator<=>(optional<U> const& rhs) const
    {
        if (has_value() && rhs) {
            return **this <=> *rhs;
        } else {
            return has_value() <=> rhs.has_value();
        }
    }

    template <typename U>
    constexpr std::compare_three_way_result_t<T, U>
    operator<=>(U const& rhs) const
    {
        if (has_value()) {
            return **this <=> *rhs;
        } else {
            return strong_ordering::less;
        }
    }

    constexpr strong_ordering operator<=>(nullopt_t ) const {
        return has_value() ? strong_ordering::greater
                           : strong_ordering::equal;
    }
};

The three-way comparison between bools yields std::strong_ordering, which is implicitly convertible to the other comparison categories. Likewise, strong_ordering::less being implicitly convertible to weak_ordering::less, partial_ordering::less, strong_equality::unequal, or weak_equality::nonequivalent, as appropriate.

The above is the super nice, happy answer. And hopefully as time progresses, people will adopt <=> and more and more code will be able to rely on the happy answer.


Another option is: we do care, and want to fallback to synthesizing an ordering. That is, if we need to compare a T and a U and they don't provide a <=>, we can use one of the new customization point objects in the standard library. Which one though? The most conservative option is to synthesize a partial_ordering with compare_partial_order_fallback. This guarantees that we will always get the right answer.

For the optional<T> vs optional<U> comparison, that looks like:

template <typename T>
class optional {
public:
    // ...

    template <typename U>
    constexpr auto operator<=>(optional<U> const& rhs) const
        -> decltype(std::compare_partial_order_fallback(**this, *rhs))
    {
        if (has_value() && rhs) {
            return std::compare_partial_order_fallback(**this, *rhs);
        } else {
            return has_value() <=> rhs.has_value();
        }
    }

    // ...
};

Unfortunately, as implemented above, our comparison now always returns partial_ordering -- even between two optional<int>s. So a better alternative might be to use whatever <=> returns and use the conservative fallback otherwise. I have no idea how to name this concept yet, so I'll just go with:

template <typename T, std::three_way_comparable_with<T> U>
constexpr auto spaceship_or_fallback(T const& t, U const& u) {
    return t <=> u;
}

template <typename T, typename U>
constexpr auto spaceship_or_fallback(T const& t, U const& u)
    -> decltype(std::compare_partial_order_fallback(t, u))
{
    return std::compare_partial_order_fallback(t, u);
}

and use that:

template <typename T>
class optional {
public:
    // ...

    template <typename U>
    constexpr auto operator<=>(optional<U> const& rhs) const
        -> decltype(spaceship_or_fallback(**this, *rhs))
    {
        if (has_value() && rhs) {
            return spaceship_or_fallback(**this, *rhs);
        } else {
            return has_value() <=> rhs.has_value();
        }
    }

    // ...
};

A third option, the most conservative option, is what the Standard Library will take. Provide both the relational operators and the three-way comparison:

template <typename T> class optional { /* ... */ };

template <typename T, typename U>
constexpr bool operator<(optional<T> const&, optional<U> const&);
template <typename T, typename U>
constexpr bool operator>(optional<T> const&, optional<U> const&);
template <typename T, typename U>
constexpr bool operator<=(optional<T> const&, optional<U> const&);
template <typename T, typename U>
constexpr bool operator>=(optional<T> const&, optional<U> const&);

template <typename T, std::three_way_comparable_with<T> U>
std::compare_three_way_result_t<T, U>
operator<=>(optional<T> const& x, optional<U> const& y) {
    if (x && y) {
        return *x <=> *y;
    } else {
        return x.has_value() <=> y.has_value();
    }
}

This is the most conservative option as it effectively takes both the C++17-and-earlier comparison implementation strategy and the C++20 comparison implementation strategy. As in: the "Why not both?" strategy. The use of the concept on operator<=> is what ensures that a < b invokes <=> where possible, instead of <.

It's by far the most tedious and verbose approach, with lots of boilerplate, but it ensures that for existing, single-object comparison types, existing comparisons continue to work and do the same thing. The standard library has to be conservative like this.

But new code doesn't.

Related