Skip to content
C++
Language
since C++20
Basic

Spaceship Operator <=> (C++20)

C++20 three-way comparison operator — ordering categories, defaulted synthesis, heterogeneous comparison, and migrating legacy relational operators.

Spaceship Operator (operator<=>)since C++20

operator<=> performs a single three-way comparison and returns an ordering category object from <compare> that encodes whether the left operand is less than, equal to, or greater than the right; defaulting it causes the compiler to synthesise all six relational operators member-wise.

Overview

Before C++20, providing a complete, consistent set of relational operators required writing six separate functions — tediously symmetric, prone to inconsistency, and impossible to enforce at the type level. The three-way comparison operator (<=>) collapses that into a single declaration. When defaulted, it compares non-static data members in declaration order, lexicographically, using each member's own <=>.

Unlike the int-returning convention of C's qsort comparator, <=> encodes the kind of ordering in its return type through distinct category types from <compare>. This makes it a type error to use a partial_ordering where a strong_ordering is expected, catching semantic mistakes at compile time.

Syntax

cpp
#include <compare>

// Defaulted — member-wise comparison, return type deduced
auto operator<=>(const T& rhs) const = default;

// Explicit return type — override or restrict deduced ordering
std::strong_ordering operator<=>(const T& rhs) const noexcept;

// Heterogeneous — different right-hand-side type
std::strong_ordering operator<=>(std::string_view rhs) const noexcept;

Ordering Categories

<=> returns one of three category types from <compare>. Each supports comparison against the integer literal 0 only — comparing against -1, 1, or any other non-zero value always yields false.

std::strong_ordering (C++20)

Equivalent elements are identical — substitutability holds in all contexts. Use for integers, pointers into the same array, and types where all data members contribute to identity.

cpp
static_assert((3 <=> 5) == std::strong_ordering::less);
static_assert((5 <=> 5) == std::strong_ordering::equal);
static_assert((7 <=> 5) == std::strong_ordering::greater);

std::weak_ordering (C++20)

Equivalent elements may not be identical — equivalence classes exist but substitutability is not guaranteed. The canonical case is case-insensitive string comparison: "ABC" and "abc" are equivalent but not the same string.

cpp
struct CIString {
    std::string s;

    std::weak_ordering operator<=>(const CIString& o) const {
        auto lower = [](std::string t) {
            for (char& c : t)
                c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
            return t;
        };
        return lower(s) <=> lower(o.s);
    }
    bool operator==(const CIString& o) const { return (*this <=> o) == 0; }
};

CIString a{"Hello"}, b{"hello"};
assert((a <=> b) == std::weak_ordering::equivalent);  // same class, different strings

std::partial_ordering (C++20)

Some pairs are unordered — neither less than, equal to, nor greater than each other. IEEE 754 NaN is the canonical example; a subset relation on sets is another.

cpp
double nan = std::numeric_limits<double>::quiet_NaN();
auto r = (1.0 <=> nan);

assert(r == std::partial_ordering::unordered);
assert(!(r < 0) && !(r == 0) && !(r > 0));   // none of the three hold

For a defaulted <=>, the compiler deduces the weakest ordering across all member types. One double member pulls the entire struct to partial_ordering.

Defaulted operator<=> and operator== Synthesis

The synthesis rule hinges on whether the body is defaulted or user-provided — not on the ordering category returned.

A defaulted operator<=> causes the compiler to also declare a defaulted operator== (member-wise equality, not routed through <=>), unless operator== is already user-declared. This holds for all ordering categories, including partial_ordering.

A user-defined (non-defaulted) operator<=> synthesises nothing — you must write operator== yourself.

cpp
// Defaulted: both <=> and == are generated                              // C++20
struct A {
    int x, y;
    auto operator<=>(const A&) const = default;
};
A a{1, 2}, b{1, 2};
assert(a == b);   // OK — synthesised member-wise equality

// User-defined body: == must be explicit
struct B {
    int x, y;
    std::strong_ordering operator<=>(const B& o) const noexcept {
        if (auto c = x <=> o.x; c != 0) return c;
        return y <=> o.y;
    }
    bool operator==(const B& o) const noexcept { return x == o.x && y == o.y; }
};

To override the deduced ordering when a float member is irrelevant for ordering:

cpp
struct Metric {
    int    id;
    double value;   // irrelevant for ordering purposes

    std::strong_ordering operator<=>(const Metric& o) const noexcept {
        return id <=> o.id;
    }
    bool operator==(const Metric& o) const noexcept { return id == o.id; }
};

Examples

Semantic versioning

cpp
#include <compare>

struct Version {
    int major, minor, patch;

    constexpr std::strong_ordering operator<=>(const Version& o) const noexcept {
        if (auto c = major <=> o.major; c != 0) return c;
        if (auto c = minor <=> o.minor; c != 0) return c;
        return patch <=> o.patch;
    }
    constexpr bool operator==(const Version&) const noexcept = default;
};

static_assert(Version{1, 2, 3} <  Version{1, 3, 0});
static_assert(Version{2, 0, 0} >  Version{1, 9, 9});
static_assert(Version{1, 0, 0} == Version{1, 0, 0});
static_assert(Version{1, 2, 3} != Version{1, 2, 4});

Heterogeneous comparison

Define <=> with a different RHS type; the compiler generates reversed and symmetric rewritten candidates automatically.

cpp
#include <compare>
#include <string>
#include <string_view>

struct InternedString {
    std::string data;

    std::strong_ordering operator<=>(std::string_view sv) const noexcept {
        return data <=> sv;
    }
    bool operator==(std::string_view sv) const noexcept { return data == sv; }
    // Compiler generates reversed candidates via rewriting:
    //   sv <=> InternedString  →  0 @ (InternedString <=> sv)
    //   sv == InternedString   →  InternedString == sv
};

InternedString s{"hello"};
assert(s < "world");
assert("hello" == s);                   // rewritten: s == "hello"
assert(std::string_view{"abc"} < s);    // reversed rewrite

Named predicates and std::compare_three_way (C++20)

cpp
#include <compare>
#include <algorithm>
#include <vector>

// As an algorithm comparator
std::vector<int> v{3, 1, 4, 1, 5};
std::sort(v.begin(), v.end(), std::compare_three_way{});

// Named predicates — clearer in generic code than comparing against 0
auto r = (v[0] <=> v[1]);
std::is_lt(r);    // r < 0
std::is_eq(r);    // r == 0
std::is_gt(r);    // r > 0
std::is_lteq(r);  // r <= 0
std::is_gteq(r);  // r >= 0
std::is_neq(r);   // r != 0

// Query result type statically without calling the operator
using Ord = std::compare_three_way_result_t<int, double>;  // partial_ordering

lexicographical_compare_three_way (C++20)

cpp
#include <algorithm>
#include <compare>
#include <vector>

std::vector<int> a{1, 2, 3};
std::vector<int> b{1, 2, 4};

auto r = std::lexicographical_compare_three_way(
    a.begin(), a.end(), b.begin(), b.end());
// r == std::strong_ordering::less — uses element type's <=>
assert(r < 0);

Migration from 6-operator boilerplate

C++17 and earlier

cpp
struct Record {
    int id;
    std::string name;

    bool operator==(const Record& o) const { return id == o.id && name == o.name; }
    bool operator!=(const Record& o) const { return !(*this == o); }
    bool operator< (const Record& o) const {
        return id != o.id ? id < o.id : name < o.name;
    }
    bool operator<=(const Record& o) const { return !(o < *this); }
    bool operator> (const Record& o) const { return o < *this; }
    bool operator>=(const Record& o) const { return !(*this < o); }
};

C++20

cpp
struct Record {
    int         id;
    std::string name;

    auto operator<=>(const Record&) const = default;
    // All 6 operators available; operator== also synthesised
};

Migration checklist:

  1. Replace all six relational operators with auto operator<=>(const T&) const = default;.
  2. Remove the explicit operator== — it is synthesised automatically when <=> is defaulted.
  3. If float/double members exist but you only care about strong ordering for specific fields, write a custom <=> with explicit std::strong_ordering return type and add operator== manually.
  4. Remove coexisting legacy relational operators — they create two code paths; <=> rewritten candidates take precedence for relational expressions but legacy overloads still participate in direct member-function calls.

Best Practices

  • Start with = default: prefer the defaulted form; write a custom body only when member-wise ordering is semantically wrong.
  • Annotate constexpr noexcept on custom bodies: the compiler cannot infer these for non-defaulted implementations.
  • Use named predicates in generic code: std::is_lt(r) reads better than r < 0 and works uniformly across all three ordering categories.
  • Don't model non-total-order relations with <=>: subset containment, dominance hierarchies, and other partial relations should use named methods like is_subset_of() rather than <=>, to avoid confusing callers who expect total ordering semantics from standard comparison operators.

Common Pitfalls

cpp
// 1. Comparing result against non-zero — always false
auto r = (1 <=> 2);
bool wrong = (r == -1);   // always false — only 0 is the valid sentinel
bool right = (r <  0);    // correct: strong_ordering::less < 0

// 2. User-defined <=> does not synthesise == — must be explicit
struct Fraction {
    int num, den;
    std::strong_ordering operator<=>(const Fraction& o) const noexcept {
        return static_cast<long long>(num) * o.den
           <=> static_cast<long long>(o.num) * den;
    }
    // Without this, (a == b) fails to compile:
    bool operator==(const Fraction& o) const noexcept { return (*this <=> o) == 0; }
};

// 3. Defaulted <=> with any float member deduces partial_ordering
struct S {
    int    id;
    double weight;
    auto operator<=>(const S&) const = default;  // deduces partial_ordering
};
// operator== IS synthesised (defaulted <=> always synthesises ==),
// but callers expecting strong_ordering for sort keys will be surprised.

// 4. Coexisting legacy operator< and operator<=> creates two code paths
struct Legacy {
    int v;
    bool operator<(const Legacy& o) const { return v < o.v; }          // old
    std::strong_ordering operator<=>(const Legacy& o) const noexcept { return v <=> o.v; }
};
Legacy a{1}, b{2};
bool x = a < b;             // uses <=> rewrite — ignores legacy operator<
bool y = a.operator<(b);    // explicit call: still uses legacy overload
// Solution: remove operator< entirely.

// 5. Domain relations that are not total orders do not belong in operator<=>
// Use is_subset_of(), dominates(), etc. instead of <=> for partial relations.

Choosing an Ordering Category

The ordering category you return is a semantic contract, not a performance hint. Choosing the wrong category misleads callers who rely on substitutability guarantees or total-order semantics.

Use std::strong_ordering when equal values are genuinely identical — all data members contribute to identity and there is no observable distinction between two equivalent objects. Integers, pointers into the same array, and plain-data structs whose every field is part of the key all qualify.

Use std::weak_ordering when you define equivalence classes: equal-under-comparison values may differ in observable ways that are irrelevant to ordering. Case-insensitive strings, locale-collated text, and objects compared by a single key field (while retaining unrelated payload fields) belong here.

Use std::partial_ordering only when genuinely unordered pairs exist — values for which none of less, equal, or greater holds. This is rare outside floating-point and explicitly partial mathematical structures such as subset containment. Prefer strong_ordering or weak_ordering wherever the domain allows it, because standard containers and algorithms work best with total orders.

cpp
// C++20
#include <compare>

// strong_ordering — every bit contributes to identity
struct Point2D {
    int x, y;
    auto operator<=>(const Point2D&) const noexcept = default;
    // deduces strong_ordering: two Point2D objects are equal iff x==x && y==y
};

// weak_ordering — compared by key only; payload is irrelevant to order
struct Employee {
    int         id;      // ordering key
    std::string name;    // payload — not part of the order

    std::weak_ordering operator<=>(const Employee& o) const noexcept {
        return id <=> o.id;
    }
    bool operator==(const Employee& o) const noexcept { return id == o.id; }
};

// partial_ordering — explicitly permits unordered pairs (e.g. interval overlap check)
struct Interval {
    double lo, hi;

    std::partial_ordering operator<=>(const Interval& o) const noexcept {
        if (hi < o.lo) return std::partial_ordering::less;
        if (lo > o.hi) return std::partial_ordering::greater;
        if (lo == o.lo && hi == o.hi) return std::partial_ordering::equivalent;
        return std::partial_ordering::unordered;  // overlapping but not equal
    }
    bool operator==(const Interval& o) const noexcept {
        return lo == o.lo && hi == o.hi;
    }
};

The implicit conversion hierarchy mirrors mathematical strength: strong_ordering converts to weak_ordering, and weak_ordering converts to partial_ordering. A function accepting partial_ordering accepts all three; the reverse is not true and will not compile.

Heterogeneous Comparison

Defining <=> with a different RHS type enables transparent mixed-type comparison without explicit casts. The compiler's rewrite rules generate reversed and symmetric candidates automatically, so lhs < rhs and rhs < lhs both resolve correctly from a single operator definition.

When writing a heterogeneous <=>, always pair it with a matching heterogeneous operator==. The two are independent overloads — the compiler does not synthesise one from the other unless both are defaulted.

cpp
// C++20
#include <compare>
#include <string>
#include <string_view>

struct PathKey {
    std::string path;

    // Compare against string_view directly — no temporary std::string needed
    std::strong_ordering operator<=>(std::string_view sv) const noexcept {
        return std::string_view{path} <=> sv;
    }
    bool operator==(std::string_view sv) const noexcept {
        return std::string_view{path} == sv;
    }
};

PathKey k{"/usr/lib"};
assert(k < "/var");                         // PathKey <=> string_view
assert("/etc" < k);                         // rewritten: 0 <  (k <=> "/etc")
assert(k == "/usr/lib");                    // PathKey == string_view
assert("/usr/lib" == k);                    // rewritten: k == "/usr/lib"

// Numeric heterogeneous comparison — int vs long
struct Index {
    int value;

    std::strong_ordering operator<=>(long rhs) const noexcept {
        return static_cast<long>(value) <=> rhs;
    }
    bool operator==(long rhs) const noexcept {
        return static_cast<long>(value) == rhs;
    }
};

Index idx{42};
assert(idx < 100L);   // Index <=> long
assert(10L < idx);    // rewritten candidate

The rewritten-candidate rule (0 @ (a <=> b)) applies only to <=> and ==. For <, >, <=, >= the compiler rewrites the expression to a <=> call; for == and != it uses operator== directly (with reversal). Keeping <=> and == consistent is therefore essential.

Three-Way Comparison with Floating-Point

Floating-point types return std::partial_ordering because NaN is unordered with every value including itself. This is not an implementation quirk — it is required by IEEE 754, and <=> faithfully models it.

When a struct has a double or float member and uses a defaulted <=>, the entire struct's ordering degrades to partial_ordering. If the float field is irrelevant to ordering, write a custom <=> with an explicit return type and ignore the float member.

cpp
// C++20
#include <compare>
#include <limits>
#include <cassert>

// NaN is unordered — none of <, ==, > hold
double nan = std::numeric_limits<double>::quiet_NaN();
double val = 3.14;

auto r1 = (val <=> nan);
auto r2 = (nan <=> nan);

assert(r1 == std::partial_ordering::unordered);
assert(r2 == std::partial_ordering::unordered);
assert(!(r1 < 0) && !(r1 == 0) && !(r1 > 0));  // all three are false

// Defaulted <=> with a float member — whole struct becomes partial_ordering
struct Measurement {
    int    sensor_id;
    double reading;
    auto operator<=>(const Measurement&) const = default;  // partial_ordering
};

// Custom <=> — order by sensor_id only, ignore the float
struct MeasurementSorted {
    int    sensor_id;
    double reading;

    std::strong_ordering operator<=>(const MeasurementSorted& o) const noexcept {
        return sensor_id <=> o.sensor_id;
    }
    bool operator==(const MeasurementSorted& o) const noexcept {
        return sensor_id == o.sensor_id;
    }
};

// std::partial_order() and std::strong_order() customisation points
// handle floating-point in generic code:
auto po = std::partial_order(1.0, nan);   // partial_ordering::unordered
// std::strong_order() uses IEEE 754 totalOrder, treating NaN as ordered:
auto so = std::strong_order(1.0, nan);    // strong_ordering::less (NaN > finite)

std::strong_order is useful in containers that require total order (such as std::map) when the element type is double — it imposes a consistent total order by treating negative zero as less than positive zero and arranging NaN values at the top.

The <compare> Header and Standard Utilities

Everything in the three-way comparison ecosystem lives in <compare> — including the category types, named predicates, comparison function objects, and customisation-point objects. Including it explicitly documents intent and avoids relying on transitive inclusion from <string> or <vector>.

std::compare_three_way is a transparent function object that calls <=> on its arguments. It satisfies the Compare named requirement and is accepted anywhere a comparator is expected. std::compare_partial_order_fallback extends this to types that do not provide <=> by synthesising partial ordering from < and ==.

cpp
// C++20
#include <compare>
#include <algorithm>
#include <functional>
#include <vector>

// compare_three_way as a sort comparator
std::vector<int> v{5, 2, 8, 1, 9, 3};
std::sort(v.begin(), v.end(), std::compare_three_way{});

// compare_three_way_result_t — query the result type without calling the operator
using R1 = std::compare_three_way_result_t<int, int>;       // strong_ordering
using R2 = std::compare_three_way_result_t<double, double>; // partial_ordering
using R3 = std::compare_three_way_result_t<int, double>;    // partial_ordering

// compare_partial_order_fallback — works even on types with only < and ==
// (synthesises partial_ordering from those two operators)
struct OldType {
    int x;
    bool operator<(const OldType& o) const { return x < o.x; }
    bool operator==(const OldType& o) const { return x == o.x; }
};
OldType a{1}, b{2};
auto r = std::compare_partial_order_fallback(a, b);  // partial_ordering::less

// Named predicate functions — work on all three category types
auto cmp = (3 <=> 5);
static_assert(std::is_lt(cmp));     // true
static_assert(std::is_lteq(cmp));   // true
static_assert(!std::is_gt(cmp));    // false
static_assert(!std::is_eq(cmp));    // false

The customisation-point objects std::strong_order, std::weak_order, and std::partial_order go further than <=> alone: they consult ADL-found strong_order/weak_order/partial_order overloads first, then fall back to <=>, and finally apply type-specific rules for floating-point. Use them in generic code when you need a guaranteed ordering category regardless of what the type directly exposes.

Interaction with the Standard Library

Once a type provides operator<=>, every standard library algorithm and container that uses ordering works automatically — no extra overloads needed. The standard library uses <=> rewritten candidates for all relational comparisons internally, so std::sort, std::lower_bound, std::set, and std::map all benefit without any change to call sites.

std::sort and its variants require a strict weak order. Any type whose <=> returns std::strong_ordering or std::weak_ordering satisfies this automatically. A partial_ordering type does not satisfy strict weak order and must supply a custom comparator that establishes one.

cpp
// C++20
#include <compare>
#include <algorithm>
#include <set>
#include <map>
#include <vector>

struct Priority {
    int level;
    auto operator<=>(const Priority&) const noexcept = default;
};

// std::sort — uses <=> rewritten candidates
std::vector<Priority> tasks{{3}, {1}, {4}, {1}, {5}};
std::sort(tasks.begin(), tasks.end());      // ascending, no comparator needed
std::sort(tasks.begin(), tasks.end(), std::greater<>{});  // descending

// std::set — uses <=> for the internal red-black tree ordering
std::set<Priority> pset{{2}, {5}, {1}, {3}};
auto it = pset.find({3});   // O(log n) — <=> drives the tree traversal

// std::map — key type only needs <=>; no separate less<> specialisation
std::map<Priority, std::string> labels;
labels[{1}] = "low";
labels[{5}] = "critical";

// std::lower_bound and std::upper_bound — binary search via <=>
std::vector<Priority> sorted{{1}, {2}, {3}, {4}, {5}};
auto lb = std::lower_bound(sorted.begin(), sorted.end(), Priority{3});
// *lb == Priority{3}

// Ranges algorithms — std::ranges::sort uses <=> directly
std::ranges::sort(tasks);  // C++20 ranges, no comparator argument needed

Legacy code that defines only operator< continues to work with std::sort and std::set, because those use std::less<> which calls operator<. Migrating to <=> is additive: existing containers do not need to be changed, and the new operator is picked up automatically for any relational expression that triggers the rewrite rules.

See Also