Implementing operator<=> for optional<T>
Asked Answered
B

1

20

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.

Bani answered 15/11, 2017 at 19:15 Comment(5)
BTW the feature is incompatible with a>>operator<=>>c; that currently can be valid.Atop
@JohannesSchaub-litb Yes, it is. That’s also mentioned in the proposal: “Tokenization follows max munch as usual. [...] This is the only known backward source incompatibility, and it is intentional. (We could adopt a special parsing rule to keep such code working without a space, but I would discourage that as having too little benefit for the trouble.)”. Seeing as you would usually have a space and/or parentheses there anyway, I think I agree with the proposal here.Suffumigate
@Bani it isn't mentioned in the proposal, as far as I can see (it only gives a following comparison, but not a following switch, as backward incompatiblity. and it says "this is the only known backward source incompatibility")Atop
@JohannesSchaub-litb 1. Please check who you’re replying to; Barry didn’t say the proposal mentioned that, I did, and I almost missed your comment. 2. I assume you mean “a following shift”, since I don’t see how a following switch can cause an issue. 3. I read it as saying maximum munch is the only known backward source incompatibility, although I don’t know why Herb didn’t list shift as one of the two places he’s aware of maximum munch occurring.Suffumigate
(For reference, here is an example where maximum munch changes the tokenization in a shift expression)Suffumigate
B
12

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.

Bani answered 15/11, 2017 at 19:17 Comment(14)
Here you’re calling *lhs and *rhs when you know one of them is empty. Also, why are you implementing this as a non-member when operator<=> is intended to be implemented as a member?Suffumigate
@DanielH I fixed his typo in the 2nd comparisonCochabamba
(I also feel like calling has_value is clearer than the explicit cast to bool, but that’s a matter of style)Suffumigate
@DanielH Why in the world do you think it is "intended to be implemented as a member"? My complaint would rather be "why are you a template on both, and not a koenig operator".Cochabamba
@Yakk Thanks! And it states as such in the paper. I just wrote it as a non-member for clarity and not having to write **thisBani
@Yakk Because of the note on page 3 of the proposal which says “Normally, operator<=> should be just a member function; you will still get conversions on each parameter because of the symmetric generation rules in §2.3.”, right above the start of section 1.4.1.Suffumigate
@Yakk I missed your edit. Presumably you template on both so that if you have optionals of two different but comparable types, like string and string_view, you can still compare them.Suffumigate
@DanielH I get why you have to have that, but my problem is that by templating on both you cannot support types that can implicitly convert-to optional on either side of the <=>. As an example, try streaming struct hello_world { operator std::string() const { return "hello world"; } } to std::ostream and watch it break because << in question is "over-templated".Cochabamba
@Yakk Would that be solvable by adding a non-template overload for optional<T> specifically?Suffumigate
@DanielH You could constrain the second template to types which are not convertible to optional<T> and keep the first one as is.Croak
Why not implement the optional<T> <=> optional<U> case using the optional<T> <=> U case? E.g. if(has_value()) return **this <=> rhs; else return has_value() <=> rhs.has_value();Funderburk
@Funderburk You could, but I don't see the benefit? Doesn't save you any code.Bani
I'm a bit aggressive against code duplication lately, and this compare_3way is repeated four times; we could drop two of them. I guess you could even implement all comparison functions using a single implementation, if optional<T&> was supported (wrap U const& -> optional<U const&>).Funderburk
I was musing about sum/product types, and it makes me think your code should have a if (auto kind = (has_value() <=> rhs.has_value()) directly in it. (or does it need a ;kind==0?)Cochabamba

© 2022 - 2024 — McMap. All rights reserved.