Amend Note

Note that this post contains detailed explanations that are likely to end up incorrect as this feature evolves to be accepted as part of the standard. Specifically there is an ongoing matter of discussion whether defaulting operator<=> should generate equality comparisons, and it likely will not do this in the end. Whenever this situation clears up more, this post will be corrected.

This post still provides value as a way to get a broad understanding of this feature, just keep in mind that the details are subject to change as C++20 is not yet finalised.

Original Post

Three-way comparison is another great C++20 addition to the language. Personally I really like this feature as it lets us get rid of boilerplate code!

In particular we’re talking about the boilerplate code that arises when we want our custom types to be comparable using == != < <= > >=.

struct T
{
    int n;
    int m;
};

bool operator==(T lhs, T rhs)
{
    return lhs.n == rhs.n && lhs.m == rhs.m;
}
bool operator!=(T lhs, T rhs)
{
    return !(lhs == rhs);
}
bool operator<(T lhs, T rhs)
{
    if(lhs.n < rhs.n)
        return true;
    else if(rhs.n < lhs.n)
        return false;
    else
        return lhs.m < rhs.m; //if we had more than 2 member variables, this would nest here as well
}
bool operator<=(T lhs, T rhs)
{
    return !(rhs < lhs);
}
bool operator>(T lhs, T rhs)
{
    return rhs < lhs;
}
bool operator>=(T lhs, T rhs)
{
    return !(lhs < rhs);
}

As you can see, even for this very trivial type T, we have to do quite a bit of work to implement the expected baseline of comparison operators. It’s less painful than it could have been, since the only real complex work is done in operator< and the other operators are derived from that. But there is still quite some room for subtle bugs due to brain farts or copy-paste errors. Bugs in comparison operators tend to cause extra headache since they might not surface until later on when used in some complicated algorithm. Yuck.

So what about three-way comparison?

The three-way comparison operator is a brand new binary operator for comparing two values. It looks like a <=> b (it is visually kinda similar to a spaceship, which is why many people call it the spaceship operator). It returns a value that can be compared to 0 to establish the relation.

auto res = a <=> b;

bool lessThan = res < 0;    //if the result compares less than zero, then a is less than b
bool greaterThan = res > 0; //if it's greater than zero, then a is greater than b
bool equal = res == 0;      //in case the result is equal to zero, then a and b are equivalent
bool greaterOrEqual = greaterThan || equal;
bool lessOrEqual = lessThan || equal;

Here we have established the full set of relations between a and b using just one single operator call between them. That’s pretty cool. We can already see a potential benefit in performance if comparing the operands is expensive, as we now need to do it just once. This is the same idea as the C string compare function strcmp(a, b).

Return value

At this point we might be tempted to assume that the return value of operator<=> is int, but it is actually not. C++20 took the opportunity to make operator<=> able to convey even more information to both the users and the compiler by defining a few possible return types: std::strong_ordering, std::weak_ordering, std::partial_ordering, std::strong_equality and std::weak_equality.

These all provide different guarantees on how values can be compared. I won’t go into details about all of them in this post, but std::strong_ordering gives the strongest guarantees and implies that if a and b are equivalent in ordering (i.e. a < b == false && b < a == false) then they are also indistinguishable (i.e. a == b and f(a) == f(b) where f is a pure function).

A value of the type std::strong_ordering is returned when <=> is used on integers, for example. Floats, on the other hand, do not fulfil the criteria of std::strong_ordering since they might be equivalent but still distinguishable (-0.0f and 0.0f are equivalent in ordering, but are still distinguishable since for example std::signbit would return different results). There are also floating point values that are not orderable at all, like NaN. Because of this, <=> returns the type std::partial_ordering for floats.

All of these comparison category types define values. For example there is std::strong_ordering::less which is returned by 3 <=> 6 and std::partial_ordering::unordered which is returned by 0.5f <=> NAN. They also provide comparison operators that work against the literal 0 which is how you can use them like a <=> b < 0. Note that comparing them against anything else than the literal 0 is undefined behaviour.

Killing that boilerplating

So far so good, but how does this eliminate the boilerplating from before? Well, since the operator<=> can be used to describe all other comparison operators in one call as we did above, and it also provides semantic information on the ordering itself using the return type, the compiler is actually kind enough to generate all other operators if the operator<=> is defined properly!

Let’s look at the example type from above, but with an extra inlined friend function.

struct T
{
    int n;
    int m;
    int o;

    friend auto operator<=>(const T& lhs, const T& rhs)
    {
        if(auto res = lhs.n <=> rhs.n; res != 0) //if the first member pair isn't equivalent, return the result
            return res;

        if(auto res = lhs.m <=> rhs.m; res != 0) //do the same for the next member pair
            return res;

        return lhs.o <=> rhs.o; //we've reached the last member pair, so just return the comparison of those directly
    }
};

As you can see, implementing it is pretty straightforward, at least for this case. The return type will be std::strong_ordering as you might have guessed, since that is what the operator returns when comparing ints. If one of the members would have been say, a float, then our function would have to return std::partial_ordering since it is the weakest comparison category amongst all the members. There is a helper template called std::common_comparison_category for working out the appropriate return type.

But really. For the majority of cases, we can take an even simpler approach:

struct T
{
    int n;
    int m;
    int o;

    friend auto operator<=>(const T&, const T&) = default;
};

This will make the compiler auto generate a default implementation of the three-way comparison operator which will lexiographically compare all members (from top to bottom) in the same manner as our manually implemented version, and it will also work out the correct return type based on the common comparison category of the members involved. Phew! Not bad.

Yay for auto generation

At this point where our type T has an operator<=> defined that returns one of the comparison category types, we will get the other comparison operators automatically generated for free!

void f(T t1, T t2)
{
    //normal comparison operators are now available
    if(t1 < t2)
    {
        //things
    }
    
    if(t1 >= t2)
    {
        //more things
    }
}

The auto generated operators will be implemented to use the operator<=> function internally for their logic. If the return type is std::strong_ordering, std::weak_ordering or std::partial_ordering, then we get all of the operators defined (that is == != < <= > >=) and if the return type is std::strong_equality or std::weak_equality then we get == != but not < <= > >=.

In other words, in the past we needed to define six operator functions manually to get basic comparisons. With this new feature we can = default a single operator and get all the comparison operators for free. This is a huge win since we can get rid of cluttery and potentially buggy code.

In my opinion, C++20 is great since it has many of these things that let you write simpler and more correct code while typing fewer lines. This is why C++20 is so exciting.

More C++20

If you’re curious about other C++20 features, make sure to check out my other posts below.