Defining a comparable type in pre-C++20 code meant writing six operators: ==, !=, <, <=, >, >=. Each one took an argument of the same type, did its own member-by-member walk, and returned bool. Almost every line was redundant — the same comparisons computed three different ways — and the redundancy was a quiet source of bugs, because nothing in the language forced a < b and b > a to actually agree.
C++20’s three-way comparison operator (<=>, nicknamed the spaceship for obvious reasons) collapses the six operators into one and lets the compiler synthesise the rest from it. Done well, it turns “make this type comparable” from a multi-page chore into one defaulted line.
Three things to take away:
<=>returns a comparison category object, not abool. The category encodes the full relationship — less, equal, greater, or unordered — in a single value.auto operator<=>(const T&) const = default;plusoperator== = defaultis enough to get all six relational operators, member-wise, in declaration order.- A custom
<=>does not generate==. If you write the spaceship by hand, write equality by hand too.
What pre-C++20 comparison looked like
Six operators, all of which had to stay consistent with each other:
struct Version {
int major, minor, patch;
bool operator==(const Version& o) const {
return major == o.major && minor == o.minor && patch == o.patch;
}
bool operator!=(const Version& o) const { return !(*this == o); }
bool operator< (const Version& o) const {
if (major != o.major) return major < o.major;
if (minor != o.minor) return minor < o.minor;
return patch < o.patch;
}
bool operator> (const Version& o) const { return o < *this; }
bool operator<=(const Version& o) const { return !(o < *this); }
bool operator>=(const Version& o) const { return !(*this < o); }
};
This is correct. It is also six functions, three of which compute the same thing in different shapes, and a single typo in any member name silently breaks an arbitrary subset of the relationships. Refactoring is a hazard — if Version grows a fourth field, operator< needs updating and operator==, and forgetting one is a bug that may not surface for months.
What <=> does
The spaceship operator performs a single comparison and returns a value that says whether the left operand is less than, equal to, greater than, or (for some types) unordered relative to the right. The return type is one of three comparison category types from <compare>, depending on what the comparison guarantees about the values involved.
For types whose comparison is the obvious member-wise one, you ask the compiler to do the work:
#include <compare>
struct Version {
int major, minor, patch;
auto operator<=>(const Version&) const = default;
};
That single defaulted line makes Version fully comparable. The compiler synthesises every relational operator (<, <=, >, >=) from <=> automatically. It also synthesises == and != because the operator is defaulted, which is the case where the language permits the equality synthesis. Calling v1 < v2 doesn’t directly invoke operator< — the compiler rewrites it as (v1 <=> v2) < 0, comparing the category result against zero.
The order of comparison is the order of declaration: major first, then minor, then patch, with short-circuit on the first inequality. For most aggregate types this is exactly what you’d have written by hand — minus the bugs.
The three comparison categories
The return type of <=> is one of three category types, and choosing the right one is the only part of using the operator that requires real thought:
| Category | Meaning | Example |
|---|---|---|
std::strong_ordering | Total order; equal values are substitutable | int, enum, Version |
std::weak_ordering | Total order; equivalent values may differ | Case-insensitive strings |
std::partial_ordering | Some pairs of values are unordered | float, double (because of NaN) |
strong_ordering is the strictest — if a == b, then a and b are interchangeable in every context. weak_ordering allows equivalent-but-not-identical values: a case-insensitive string type might consider "hello" and "Hello" equivalent for ordering, even though they are different objects with different memory contents. partial_ordering admits the existence of unordered pairs — most importantly, NaN, which compares unordered against everything including itself.
The defaulted operator<=> deduces its category from the members: if every member returns strong_ordering, the type’s <=> returns strong_ordering; if any member returns partial_ordering (because it’s a double, say), the whole type drops to partial_ordering automatically.
Custom three-way comparison
When the default member-wise order is wrong, write the operator by hand:
#include <compare>
#include <string>
struct Person {
std::string name;
int age;
// Order by age first, then by name.
std::weak_ordering operator<=>(const Person& other) const {
if (auto cmp = age <=> other.age; cmp != 0) return cmp;
return name <=> other.name;
}
bool operator==(const Person& other) const {
return age == other.age && name == other.name;
}
};
Two things worth noting. First, the return type is named explicitly — weak_ordering rather than auto — because we want the contract visible at the signature. Second, operator== is written by hand. Unlike the defaulted case, a custom <=> does not synthesise equality. The standard separates the two intentionally: equality can sometimes be done faster than ordering (compare string lengths before contents, for instance), so the compiler refuses to assume that <=> is the right way to compute ==.
The if (auto cmp = ...) pattern is the idiomatic way to chain comparisons. Each <=> returns a category value that converts to bool for the cmp != 0 check; if the comparison was decisive, return immediately, otherwise fall through to the next member.
When to use each category
A practical decision rule, in order:
- Default to
strong_orderingfor value types whose members are themselves strongly ordered. This is by far the most common case. - Use
weak_orderingwhen the type has equivalence classes that aren’t equality — case-insensitive comparison, sorting that ignores some fields, anything where “the same for ordering” doesn’t mean “indistinguishable.” - Reach for
partial_orderingonly when unordered values genuinely exist. In practice, this is almost always because the type contains afloatordoubleand you cannot rule out NaN.
A subtle trap worth knowing: if your type contains a double and you = default the spaceship, the type silently becomes partial_ordering. That’s correct, but it means putting the type into std::map or std::sort may be unsafe in the presence of NaN, because both expect a strict weak order. If you can rule out NaN by construction, consider validating at the constructor and returning strong_ordering explicitly.
Takeaway
<=> replaces six hand-written operators with one. Default it when member-wise order is what you want; write it by hand when it isn’t, and remember to write operator== alongside it because custom spaceships do not synthesise equality. Choose the return category to match the actual semantics of your type: strong_ordering for ordinary value types, weak_ordering when equivalence isn’t equality, partial_ordering only when unordered pairs genuinely exist. The rule is simple: define the order once, in one place, with a category type that documents how strict it is — and let the compiler synthesise the rest.