C++20 Course

The C++20 Spaceship Operator: One Defaulted <=> Replaces Six Comparisons

Before C++20, a comparable value type needed six operators written by hand, and the six had to agree with each other. C++20 adds one operator, <=>, and a rule: default it, and the compiler generates the other five, plus ==. This guide performs that migration on one small type, then runs the three traps that catch people who default it blindly — a signed/unsigned comparison where <=> lies exactly like <, a NaN that collapses a std::map to a single key, and a missing header that only fails in a bare translation unit. Every program below was compiled and run on the compilers listed here, and every failure is quoted verbatim.

What you will build

One value type, Point, taken through the migration in full: first with all six comparison operators written by hand, the way C++17 code had to do it, then with a single defaulted operator<=>. The two versions must produce identical results, including use as a std::set key. Then three traps, each executed rather than asserted:

| Trap | What happens | | Signed vs unsigned | < lies, and <=> lies the same way for runtime values — only a constant operand makes the compiler refuse | | Float members and NaN | -0 and +0 compare equivalent, NaN compares unordered, and a std::map keyed on the type collapses to one entry | | The missing <compare> include | Compiles fine when <iostream> is included, fails in a bare translation unit |

The guide closes with the case where defaulting is wrong: a type whose comparison order is not its member-declaration order.

The toolchain reality first

The cppreference compiler support page for C++20 lists the three-way comparison operator (P0515R3) at GCC 10, Clang 8 (partial) / 10, and MSVC 19.20* — the asterisk marks a pop-up note on that page, and its text is not reproduced here. Library support for operator<=> and <compare> (P0768R1) is listed at GCC 10, Clang 7 (partial) / 12 (partial) / 17, and MSVC 19.20* (partial) / 19.28 (16.9)*. The safe integer comparison functions used in Trap 1 (std::cmp_less and friends, P0586R2) are listed at GCC 10, Clang 13, and MSVC 19.27.

| Setup | Result | | GCC 13.3.0, 14.2.0, 16.2.0 | Every example below compiles and runs; outputs identical across all three | | Clang 18.1.3 | Every example below compiles and runs, except the signed/unsigned trap, where Clang refuses code GCC accepts — see Trap 1 | | MSVC | Not tested here | | GCC 15, libc++ | Not tested here |

The feature-test macros are __cpp_impl_three_way_comparison (201907L, compiler support) and __cpp_lib_three_way_comparison (201907L, library support), per the cppreference comparison operators page.

Before: six operators, by hand

This is the pre-C++20 shape. Six operators, each written out, and the definitions must stay consistent with each other: <= is defined as "not greater", > as "the other way round". A mistake in any one of them is a bug that compiles cleanly.

#include <iostream>
#include <set>

struct Point {
    int x;
    int y;
    bool operator==(const Point& o) const { return x == o.x && y == o.y; }
    bool operator!=(const Point& o) const { return !(*this == o); }
    bool operator<(const Point& o) const {
        return x < o.x || (x == o.x && y < o.y);
    }
    bool operator<=(const Point& o) const { return !(o < *this); }
    bool operator>(const Point& o) const { return o < *this; }
    bool operator>=(const Point& o) const { return !(*this < o); }
};

int main() {
    Point a{1, 2}, b{1, 3}, c{1, 2};
    std::cout << "a==c " << (a == c) << "\n";
    std::cout << "a!=b " << (a != b) << "\n";
    std::cout << "a<b " << (a < b) << "\n";
    std::cout << "a<=b " << (a <= b) << "\n";
    std::cout << "a>b " << (a > b) << "\n";
    std::cout << "a>=b " << (a >= b) << "\n";
    std::set<Point> s{a, b, c};
    std::cout << "set size (a,b,c dedup) " << s.size() << "\n";
    return 0;
}

Output, identical on GCC 13, 14, 16 and Clang 18:

a==c 1
a!=b 1
a<b 1
a<=b 1
a>b 0
a>=b 0
set size (a,b,c dedup) 2

The old shortcut for this boilerplate was using namespace std::rel_ops;, which generated !=, >, <=, and >= from == and <. That namespace was deprecated in C++20 by P0768R1, because the operator rewrite rules do the same job in the language itself — see P3047R0, "Remove Namespace relops From C++26". Deprecated is not removed: std::rel_ops is still present, and the 2024 paper proposing its removal from C++26 is a proposal, not the standard. New code should not use it.

After: one defaulted <=>

The same type, the same program, one line instead of six operators:

#include <compare>
#include <iostream>
#include <set>

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

int main() {
    Point a{1, 2}, b{1, 3}, c{1, 2};
    std::cout << "a==c " << (a == c) << "\n";
    std::cout << "a!=b " << (a != b) << "\n";
    std::cout << "a<b " << (a < b) << "\n";
    std::cout << "a<=b " << (a <= b) << "\n";
    std::cout << "a>b " << (a > b) << "\n";
    std::cout << "a>=b " << (a >= b) << "\n";
    std::cout << "a<=>c is eq " << ((a <=> c) == 0) << "\n";
    std::set<Point> s{a, b, c};
    std::cout << "set size (a,b,c dedup) " << s.size() << "\n";
    return 0;
}

Output, identical on GCC 13, 14, 16 and Clang 18:

a==c 1
a!=b 1
a<b 1
a<=b 1
a>b 0
a>=b 0
a<=>c is eq 1
set size (a,b,c dedup) 2

Every line matches the hand-written version. No operator== is declared anywhere in the new type, and a == c still works: when operator<=> is defaulted and operator== is not declared at all, operator== is implicitly defaulted with the same access, per the cppreference default comparisons page. The four relational operators are rewritten candidates generated from <=>, and != is rewritten from ==.

What the default actually does

A defaulted operator<=> compares base subobjects, then non-static members in declaration order, stopping at the first non-equal result — a lexicographical comparison, with array members expanded in increasing subscript order. The return type, when declared auto, is deduced as the common comparison category of the member comparisons (std::common_comparison_category_t). The three category types, per the default comparisons page:

| Category | Equivalent values are | Incomparable values are | | std::strong_ordering | indistinguishable | not allowed | | std::weak_ordering | distinguishable | not allowed | | std::partial_ordering | distinguishable | allowed |

For Point, both members are int, and integral operands yield std::strong_ordering, so the defaulted operator returns std::strong_ordering. A type with a double member deduces std::partial_ordering instead, because floating-point operands yield std::partial_ordering. That difference is what Trap 2 is about.

The built-in rules behind all of this are normative in the standard draft, [expr.spaceship] (n4861): (a <=> b) < 0 if a < b, > 0 if a > b, == 0 if equal or equivalent. If one operand is bool and the other is not, the program is ill-formed. Comparing two arrays with <=> is ill-formed.

Trap 1: signed vs unsigned — <=> lies too

The comparison operators page shows the classic pitfall pair:

unsigned int i = 1;
auto r = -1 < i;    // existing pitfall: returns 'false'
auto r2 = -1 <=> i; // Error: narrowing conversion required

The first line is real: -1 < 1u evaluates to false, because the usual arithmetic conversions turn -1 into a huge unsigned value. The probe program confirms it on all four compilers, with GCC emitting -Wsign-compare on the line. The second line, the comment claims, is an error. Whether it actually errors depends on the operand form and the compiler, and that is the trap inside the trap.

With a constant -1, all four compilers refuse it. GCC 13, 14, and 16 report:

error: narrowing conversion of '-1' from 'int' to 'unsigned int' [-Wnarrowing]

Clang 18 reports:

error: argument to 'operator<=>' evaluates to -1, which cannot be narrowed to type 'unsigned int'

But with runtime variables, the compilers split. This compiles on GCC 13, 14, and 16, with warnings only:

#include <compare>
#include <iostream>
int main() {
    int neg = -1;
    unsigned one = 1u;
    auto r = (neg <=> one);
    std::cout << ((r < 0) ? -1 : (r > 0) ? 1 : 0) << "\n";
    return 0;
}
neg-signmix.cpp:7:15: warning: narrowing conversion of 'neg' from 'int' to 'unsigned int' [-Wnarrowing]
neg-signmix.cpp:7:19: warning: comparison of integer expressions of different signedness: 'int' and 'unsigned int' [-Wsign-compare]

And it runs, printing 1 — greater. The unsigned conversion makes -1 huge, so <=> gives exactly the same wrong-looking answer as <. Clang 18 refuses the same program:

error: argument to 'operator<=>' cannot be narrowed from type 'int' to 'unsigned int'

The standard says a narrowing conversion other than integral-to-floating makes the program ill-formed, per [expr.spaceship]. GCC enforces that as an error for constant operands and downgrades it to a warning for runtime values; Clang enforces it in both cases. So "< lies, <=> errors" is only true for constants and only on Clang for variables. The honest statement: <=> does not rescue a mixed-sign comparison. The fix is to stop comparing across signs — convert explicitly to a common signed type, or use the C++20 safe comparison functions in <utility>, which compare integers as values rather than after conversion:

#include <iostream>
#include <utility>
int main() {
    int neg = -1;
    unsigned one = 1u;
    std::cout << "cmp_less(-1, 1u): " << std::cmp_less(neg, one) << "\n";
    std::cout << "cmp_less(1u, -1): " << std::cmp_less(one, neg) << "\n";
    return 0;
}

Output on GCC 13 and Clang 18:

cmp_less(-1, 1u): 1
cmp_less(1u, -1): 0

Negative signed integers always compare less than unsigned integers under std::cmp_less, per the cppreference integer comparison functions page. Turn on -Wsign-compare and -Wnarrowing and treat their output as a defect, not noise: the warnings above are the compiler telling you the comparison is meaningless.

Trap 2: floats, NaN, and the map that ate its keys

Floating-point operands yield std::partial_ordering, and the built-in rules give two special results: -0.0 <=> +0.0 is equivalent, and NaN <=> anything is unordered. Both are confirmed by the probe program on all four compilers:

-0==+0 1 <=>eq 1
NaN<=>1 unordered 1
NaN==NaN 0

So a type with a double member gets a defaulted <=> returning std::partial_ordering, and std::map will accept it as a key — it compiles. What it does at runtime is the trap:

#include <compare>
#include <iostream>
#include <limits>
#include <map>
#include <string>

struct Reading {
    double value;
    auto operator<=>(const Reading&) const = default;
};

int main() {
    double nan = std::numeric_limits<double>::quiet_NaN();
    std::map<Reading, std::string> m;
    m[{nan}] = "first NaN reading";
    m[{1.0}] = "one";
    m[{nan}] = "second NaN reading";
    std::cout << "map size after 3 inserts (2 NaN, 1.0): " << m.size() << "\n";
    std::cout << "m[{NaN}] = " << m[{nan}] << "\n";
    std::cout << "m[{1.0}] = " << m[{1.0}] << "\n";
    auto it = m.find(Reading{nan});
    std::cout << "find(NaN) found: " << (it != m.end()) << "\n";
    return 0;
}

Output, identical on GCC 13, 14, 16 and Clang 18:

map size after 3 inserts (2 NaN, 1.0): 1
m[{NaN}] = second NaN reading
m[{1.0}] = second NaN reading
find(NaN) found: 1

Three inserts, one entry. Even 1.0 is gone: it was stored under the same key as the NaN readings, then overwritten by the second NaN insert. The mechanism is the ordering requirement. std::map orders keys with <, and < here is rewritten from <=>. NaN <=> 1.0 is unordered, so neither NaN < 1.0 nor 1.0 < NaN is true, and the map treats the two as equivalent — the same key. std::map needs a strict weak ordering, and a partial_ordering with unordered values is not one. The compiler does not warn: the type satisfies every syntactic requirement, and the data loss is silent.

The fix is to never key an ordered container on a floating-point member that can hold NaN without handling it first. Reject NaN at insertion, or supply a comparator that total-orders NaN (for example, treat NaN as greater than every other value). Either way, the handling belongs in code you can see, not in a defaulted operator that cannot express it. The same warning applies to std::set and to std::sort on a range of such values.

Trap 3: the missing <compare> include

The three comparison category types are not predefined. The standard draft says so directly: if <compare> is not imported or included before a use of such a class type — even an implicit use, such as auto on a defaulted <=> — the program is ill-formed. See [expr.spaceship].

In practice the trap is narrower than the rule sounds, because most headers transitively include <compare>. This program compiles cleanly on all four compilers:

#include <iostream>
struct P { int x; auto operator<=>(const P&) const = default; };
int main() { P a{1}, b{2}; std::cout << (a < b) << "\n"; return 0; }

With <iostream> included but <compare> missing, the compile succeeds — the standard library header provides <compare> transitively. Remove every include and it fails on all four compilers. GCC 13 reports:

error: 'strong_ordering' is not a member of 'std'
note: 'std::strong_ordering' is defined in header '<compare>'; did you forget to '#include <compare>'?

Clang 18 reports:

error: cannot default 'operator<=>' because type 'std::strong_ordering' was not found; include <compare>

The rule to carry away: include <compare> in every header that declares or defaults <=>. Do not rely on a transitive include from another header — the day that header changes, or the program is compiled with a different standard library, the defaulted operator stops compiling with an error that points at the operator, not at the missing include.

When NOT to default

Defaulting compares members in declaration order. When the type's comparison order is not its declaration order, or the comparison is not the members' natural comparison, write operator<=> by hand and let the compiler generate the relational operators from it.

#include <compare>
#include <iostream>
#include <string>

struct EmployeeDefaulted {
    std::string name;
    int id;
    auto operator<=>(const EmployeeDefaulted&) const = default;
};

struct EmployeeById {
    std::string name;
    int id;
    auto operator<=>(const EmployeeById& o) const {
        if (auto c = id <=> o.id; c != 0) return c;
        return name <=> o.name;
    }
};

int main() {
    EmployeeDefaulted d1{"Zoe", 3}, d2{"Amy", 9};
    EmployeeById h1{"Zoe", 3}, h2{"Amy", 9};
    std::cout << "defaulted: Zoe(id 3) < Amy(id 9): " << (d1 < d2) << "\n";
    std::cout << "hand-written: Zoe(id 3) < Amy(id 9): " << (h1 < h2) << "\n";
    std::cout << "defaulted: Zoe(id 3) > Amy(id 9): " << (d1 > d2) << "\n";
    std::cout << "hand-written: Zoe(id 3) > Amy(id 9): " << (h1 > h2) << "\n";
    return 0;
}

Output, identical on GCC 13, 14, 16 and Clang 18:

defaulted: Zoe(id 3) < Amy(id 9): 0
hand-written: Zoe(id 3) < Amy(id 9): 1
defaulted: Zoe(id 3) > Amy(id 9): 1
hand-written: Zoe(id 3) > Amy(id 9): 0

The defaulted version sorts by name first, because name is declared first — Zoe sorts after Amy. The hand-written version sorts by id first, so employee 3 sorts before employee 9. Both are valid orderings; only one is the ordering the program wants. The same applies to a non-natural comparison, such as case-insensitive ordering on a string member: the default compares the member with its natural <=>, so a hand-written operator is the only way to get the other comparison. In both cases the hand-written operator<=> is the single point of truth, and the five other operators are still generated from it — the migration still pays, just without = default.

What breaks, and the fix

| Error or symptom | Cause | Fix | | error: narrowing conversion of '-1' from 'int' to 'unsigned int' | <=> on mixed-sign operands with a constant | Compare in a common signed type, or use std::cmp_less from <utility> | | -Wnarrowing warning, program runs, comparison wrong | <=> on mixed-sign runtime values; GCC warns, Clang errors | Same as above; treat the warning as a defect | | std::map collapses distinct keys into one entry | partial_ordering member (NaN) breaks strict weak ordering | Reject NaN at insertion, or supply a comparator that total-orders NaN | | error: 'strong_ordering' is not a member of 'std' | <compare> not included and no header provides it transitively | #include <compare> in every header that declares <=> | | error: cannot default 'operator<=>' because type 'std::strong_ordering' was not found | Same, on Clang | Same | | Members compare in the wrong order | Defaulted <=> follows declaration order | Write operator<=> by hand, comparing in the order the type needs |

Frequently asked questions

Do I still need to write operator==?

No, if you default <=> and do not declare == at all — the compiler implicitly defaults == for you. If you do declare ==, write it by hand or default it yourself, and keep it consistent with <=>: two values that compare equal under <=> must compare equal under ==, or containers will disagree with your comparisons.

Can I default <=> and also default the other operators individually?

Yes. Any of the six two-way operators can be explicitly defaulted with a bool return type. In practice there is no reason to: a defaulted <=> already generates all five others, and defaulting one by hand only adds a place for the definitions to disagree.

Does <=> work for enums and pointers?

The built-in operator handles both. Two operands of the same enumeration type compare through the underlying type, and pointer operands yield std::strong_ordering through the composite pointer type. The full built-in rules, including the array case (ill-formed), are on the comparison operators page.

Is std::set safe with a defaulted <=>?

Yes, when the deduced category is std::strong_ordering or std::weak_ordering — both forbid incomparable values, which is what std::set needs. It is not safe with std::partial_ordering and a member that can be unordered, as Trap 2 shows. Check the deduced category before using the type as an ordered-container key: if any member is floating-point, the category is partial.

What comes next in this course?

The next guide in the C++20 course migrates a small template library from SFINAE to C++20 concepts, keeping the runtime output identical and comparing the real compiler errors before and after. See Migrating a Template Library from SFINAE to C++20 Concepts.

Sources

Facts verified 2026-11-05. Every code listing on this page was compiled and run with GCC 13.3.0, GCC 14.2.0, GCC 16.2.0, and Clang 18.1.3 on Ubuntu 24.04, all with -std=c++20 -Wall -Wextra, except the combinations labeled as untested. MSVC, GCC 15, and libc++ were not tested here. Failures are quoted verbatim from the actual compiler output.

Keep Exploring

Keep exploring the docs, samples, and free tools to continue your evaluation.