C++20 operator<=>: Defaulted Comparisons, Ordering Categories and Migration

Key takeaways

C++20 operator<=>: how defaulted comparison works, what the rewrite rules generate, how to pick strong/weak/partial ordering, and the traps (missing ==, pointer members, double members, legacy types) when migrating.

What problem <=> solves

Before C++20, a type that needed full ordering required six hand-written operators: ==, !=, <, <=, >, >=. They had to agree with each other, and in practice they drifted. Someone adds a member, updates operator==, forgets operator<, and now std::set treats two objects as duplicates while == says they differ.

C++20 attacks this from two sides. First, the three-way comparison operator a <=> b returns a single value that says “less”, “equal/equivalent”, “greater” or (for some types) “unordered”. Second, the compiler now rewrites relational expressions: when it sees a < b and no usable operator<, it tries (a <=> b) < 0, and also the reversed form 0 < (b <=> a). Likewise a != b becomes !(a == b). So you only supply <=> and ==, and the other four come from the rewrite rules.

#include <compare>

struct Point {
    int x, y;
    auto operator<=>(const Point&) const = default;
};
// Point supports ==, !=, <, <=, >, >= — compared by x first, then y.

Note the #include <compare>: the ordering types live there, and you need it whenever you name std::strong_ordering and friends. Some standard headers pull it in, but don’t depend on that.

What = default actually does

A defaulted <=> compares base classes first, then non-static data members in declaration order, and stops at the first pair that isn’t equal. It is lexicographic, the same thing std::tie(a, b) < std::tie(o.a, o.b) used to do by hand.

Defaulting <=> also implicitly declares a defaulted operator==. That is a deliberate design choice, not a convenience. Equality can often be answered faster than ordering: two std::strings of different length are unequal without looking at any characters, but ordering them needs a character-by-character walk. Keeping == separate lets the compiler use each member’s own ==.

Declaration order matters. If you want Version to sort by major, then minor, then patch, declare them in that order. Reordering members for padding or readability silently changes the sort order of every container holding the type, which is a good reason to keep a unit test around the ordering of anything used as a map key.

Inheritance works as you’d hope, provided the base is comparable too:

struct Base { int id; auto operator<=>(const Base&) const = default; };
struct Derived : Base {
    std::string tag;
    auto operator<=>(const Derived&) const = default; // compares Base::id, then tag
};

Choosing the ordering category

The return type of <=> is one of three categories, and they convert only one way: strong converts to weak, weak converts to partial.

  • std::strong_ordering: exactly one of less/equal/greater holds, and equal values are substitutable, so any function of the value gives the same result. int <=> int and std::string <=> std::string yield this.
  • std::weak_ordering: every pair is ordered, but two values can be equivalent without being identical. A case-insensitive string is the classic example: "Hello" and "hello" sort in the same position, yet printing them gives different output.
  • std::partial_ordering: some pairs are unordered. double <=> double returns this because NaN is neither less than, equal to, nor greater than anything, including itself.

With auto as the return type, a defaulted <=> picks the weakest category among all members. This surprises people:

struct Product {
    std::string name;
    double price;
    int stock;
    auto operator<=>(const Product&) const = default;
};
// decltype(Product{} <=> Product{}) is std::partial_ordering, because of `double price`

That is correct, not a compiler quirk: a Product whose price is NaN genuinely can’t be ordered. If you know prices are never NaN and you want strong_ordering, write <=> by hand (for example with std::strong_order(price, o.price), which imposes a total order on floating-point values) rather than hiding the problem.

The NaN behaviour is easy to verify:

double n = NAN;
auto c = n <=> 1.0;
// c == std::partial_ordering::unordered  -> true
// n < 1.0, n > 1.0, n == n               -> all false

Writing a custom <=>

When the default member order isn’t the order you want, write <=> yourself. The idiom is to compare the most significant key, return early if it’s not equal, and fall through to the next key:

#include <algorithm>
#include <compare>
#include <string>
#include <vector>

struct Student {
    std::string name;
    int score;

    std::strong_ordering operator<=>(const Student& other) const {
        // Primary: score, descending (note the swapped operands)
        if (auto cmp = other.score <=> score; cmp != 0) return cmp;
        // Secondary: name, ascending
        return name <=> other.name;
    }
    bool operator==(const Student& other) const {
        return score == other.score && name == other.name;
    }
};

int main() {
    std::vector<Student> students = {{"Alice", 85}, {"Bob", 90}, {"Charlie", 85}};
    std::sort(students.begin(), students.end());
    // Bob(90) Alice(85) Charlie(85)
}

cmp != 0 looks odd but is the intended usage: the ordering types compare only against the literal 0, which is how you ask “was it less/equal/greater?” without naming the enumerators.

Keep == consistent with <=>. In Student, both look at score and name, so a == b exactly when (a <=> b) == 0. Containers like std::set use only the ordering to decide duplicates, while algorithms like std::find use ==; if the two disagree, you get the classic bug where set.count(x) finds an element that std::find doesn’t.

A weak ordering example, using only standard library calls (strcasecmp is POSIX and not available on MSVC):

#include <algorithm>
#include <cctype>
#include <compare>
#include <string>

struct CIString {
    std::string value;
    std::weak_ordering operator<=>(const CIString& o) const {
        auto lower = [](unsigned char c) { return std::tolower(c); };
        return std::lexicographical_compare_three_way(
            value.begin(), value.end(), o.value.begin(), o.value.end(),
            [&](char a, char b) { return lower(a) <=> lower(b); });
    }
    bool operator==(const CIString& o) const { return (*this <=> o) == 0; }
};
// CIString{"Hello"} == CIString{"hello"}  -> true (equivalent)
// but .value strings differ, which is exactly what weak_ordering means

The inner lambda returns strong_ordering (it compares ints), and the declared return type converts it to weak_ordering. The unsigned char parameter matters: passing a negative char to std::tolower is undefined behaviour.

Pitfalls

A custom <=> does not give you ==

This is the most common misunderstanding, and many tutorials get it wrong. Only a defaulted <=> implicitly declares operator==. If you hand-write <=>, equality is simply missing:

struct Data {
    int value;
    std::string name;
    std::strong_ordering operator<=>(const Data& o) const { return value <=> o.value; }
};

Data a{10, "A"}, b{10, "B"};
bool lt = a < b;   // OK: rewritten to (a <=> b) < 0, false
bool eq = a == b;  // error

GCC reports:

error: no match for 'operator==' (operand types are 'Data' and 'Data')

The fix is either bool operator==(const Data&) const = default; (memberwise, which here would disagree with the ordering since it also compares name) or a hand-written == that matches the keys used in <=>. Decide deliberately which one you mean.

The first time I moved a codebase to C++20, this was the error I saw most: types that had a hand-written operator< replaced with a hand-written <=>, and then every == call site broke. It’s a loud failure, which is good; the quiet version is when someone “fixes” it with = default on == and the equality now compares members the ordering ignores.

Pointer members compare addresses

Defaulted comparison on a raw pointer or std::unique_ptr member compares the addresses, not the pointed-to values. That’s correct for identity semantics and wrong for value semantics:

struct Node {
    int* data;
    auto operator<=>(const Node&) const = default; // compares addresses
};
int x = 10, y = 10;
Node n1{&x}, n2{&y};
// n1 != n2 is true even though *n1.data == *n2.data

If the type means “a value that happens to live on the heap”, write the comparison by hand and dereference (and decide what a null pointer should compare as).

Members without <=> delete the defaulted operator

If any member or base can’t be compared with <=>, the defaulted operator is not an error at the declaration; it is implicitly deleted, and you find out at the first use:

error: use of deleted function 'constexpr auto W1::operator<=>(const W1&) const'
note: 'constexpr auto W1::operator<=>(const W1&) const' is implicitly deleted because the default definition would be ill-formed:
error: no match for 'operator<=>' (operand types are 'Legacy' and 'Legacy')

This comes up with older third-party types that only define operator< and operator==. C++20 does describe a fallback that builds the comparison from < and == when you spell out the return type instead of auto, but compiler support for it has varied (GCC 10.3 still rejects it with the same “implicitly deleted” error), so the portable fixes are to add <=> to the member type or to write the enclosing <=> by hand.

Orderings must be consistent

std::sort, std::map and std::set require a strict weak ordering. A <=> that depends on anything other than the two operands, or that treats some members as significant only sometimes, breaks that contract. The result is undefined behaviour: in practice, missing or duplicated elements, and occasionally a sort that reads past the end of the range. Partial orderings are a special case of this: sorting a vector of double containing NaN with the default < already violates the requirement, and a partial_ordering type inherits the problem.

I’ve seen more than one “flaky” test that turned out to be a comparison mixing a floating-point field with NaN sentinels. The test failed only when the input happened to put a NaN in a position std::sort compared against, which made it look random.

Checking what the compiler synthesized

A static_assert with a requires-expression is a cheap way to lock in that a type supports the operators you rely on, so a later member change that deletes the defaulted <=> fails at the type rather than at a distant call site:

#include <compare>
#include <concepts>

struct Test {
    int x;
    auto operator<=>(const Test&) const = default;
};

static_assert(requires(Test a, Test b) {
    { a == b } -> std::same_as<bool>;
    { a != b } -> std::same_as<bool>;
    { a < b }  -> std::same_as<bool>;
    { a >= b } -> std::same_as<bool>;
});
static_assert(std::same_as<decltype(Test{} <=> Test{}), std::strong_ordering>);

The second assert pins the category, which catches the “someone added a double member” change.

Migration notes

  • Replace the six operators with auto operator<=>(const T&) const = default; only when the old operator< really was memberwise in declaration order. If it compared a subset of members or used a different order, the default changes behaviour.
  • If you keep a legacy operator< alongside the new <=>, the compiler prefers the existing operator< for a < b. Remove the old operators once <=> is in place so there is a single source of truth.
  • Code compiled with -std=c++17 can’t see <=>. Headers shared between C++17 and C++20 translation units need #if __cpp_impl_three_way_comparison guards or should stay on the old operators until everything moves.
  • The rewrite rules also consider reversed operands (b <=> a, b == a). An operator== that takes its parameters asymmetrically, or isn’t const, can become ambiguous with its own reversed form after switching to C++20. Making comparison operators const member functions with identical parameter types avoids this.