EduC++ Three-Way Comparison (C++20)

Three-Way Comparison (C++20)

Before C++20, making a class fully comparable required writing up to 6 operator overloads (==, !=, <, >, <=, >=) — tedious and error-prone. The three-way comparison operator (<=>), informally "the spaceship", returns an ordering in one operation, and when defaulted, the compiler auto-generates all six relational operators for you.

Use defaulted <=> as your first choice for any class that needs comparison. Write a custom <=> only when member-wise comparison is wrong (e.g., case-insensitive strings, semantic versioning).

Prerequisites: See 02_oop/classes/ first.

1. Defaulted <=> — one line gives you all 6 operators

The compiler compares members in declaration order, just like
   it does for defaulted constructors.

   If you write a custom <=>, you must explicitly default or
   define == yourself — it is NOT synthesized from <=>.

Watch out: defaulted <=> also generates a defaulted ==.

struct Point {
    int x, y;

    // This one line gives us ==, !=, <, >, <=, >= !
    auto operator<=>(const Point&) const = default;
};

2. Comparison categories

The return type of <=> tells you about the ordering:

   strong_ordering:  exactly one of <, ==, > is true.
                     Equal values are indistinguishable (e.g., int).
   weak_ordering:    equivalent values may not be identical.
                     (e.g., case-insensitive string comparison).
   partial_ordering: some values may be unordered (e.g., double
                     with NaN — NaN is not less than, equal to,
                     or greater than anything, including itself).

   the defaulted <=> returns partial_ordering for the whole class.

Watch out: if any member has partial_ordering (like double),

3. Custom <=> implementation

Write your own when member-wise order is wrong.
   Return the appropriate ordering category.

   define or default operator== separately. The compiler will
   not synthesize == from your custom <=>.

Watch out: when you define a custom <=>, you MUST also

class Version {
    int major_, minor_, patch_;

public:
    Version(int major, int minor, int patch)
        : major_(major), minor_(minor), patch_(patch) {}

    // Custom three-way comparison: compare major first, then minor, then patch
    std::strong_ordering operator<=>(const Version& other) const {
        if (auto cmp = major_ <=> other.major_; cmp != 0) return cmp;
        if (auto cmp = minor_ <=> other.minor_; cmp != 0) return cmp;
        return patch_ <=> other.patch_;
    }

    // Reason: custom <=> does not generate ==; must be explicit
    bool operator==(const Version&) const = default;

    std::string to_string() const {
        return std::format("{}.{}.{}", major_, minor_, patch_);
    }
};

4. Partial ordering with floating point

double's <=> returns std::partial_ordering because NaN
   is unordered relative to every value, including itself.
void partial_ordering_demo() {
    std::cout << "\n--- Partial Ordering (double / NaN) ---\n";

    double a = 1.0, b = 2.0, nan = std::nan("");

    auto result = a <=> b;
    if (result < 0) {
        std::cout << std::format("{} < {}\n", a, b);
    }

    auto nan_cmp = nan <=> 1.0;
    if (nan_cmp == std::partial_ordering::unordered) {
        std::cout << "NaN is unordered with everything (including itself)\n";
    }

    // Reason: NaN != NaN is true per IEEE 754; this surprises many beginners
    std::cout << std::format("NaN == NaN? {}\n", nan == nan);
}

5. Using <=> with containers and algorithms

Classes with defaulted <=> work immediately in
   std::set, std::map, std::sort, and binary search.

Key Takeaways

  1. Default <=> whenever member-wise comparison is correct — one line, six operators.
  2. A custom <=> requires a separate == definition (default or manual).
  3. Choose the return type carefully: strong_, weak_, or partial_ordering.
  4. If any member is a double, the defaulted <=> returns partial_ordering.
  5. Types with <=> work automatically in std::set, std::sort, etc.
int main() {
    // ---- 1. Defaulted <=> ----
    std::cout << "--- Defaulted <=> ---\n";
    Point p1{1, 2}, p2{1, 3}, p3{1, 2};

    std::cout << std::format("({},{}) == ({},{}): {}\n",
                              p1.x, p1.y, p3.x, p3.y, p1 == p3);
    std::cout << std::format("({},{}) <  ({},{}): {}\n",
                              p1.x, p1.y, p2.x, p2.y, p1 < p2);
    std::cout << std::format("({},{}) >  ({},{}): {}\n",
                              p2.x, p2.y, p1.x, p1.y, p2 > p1);

    // ---- 5. Works in containers automatically ----
    std::cout << "\n--- In Containers ---\n";
    std::set<Point> points = {{3, 4}, {1, 2}, {1, 1}, {2, 0}};
    std::cout << "Points in sorted order: ";
    for (const auto& p : points) {
        std::cout << std::format("({},{}) ", p.x, p.y);
    }
    std::cout << '\n';

    // ---- 3. Custom <=> ----
    std::cout << "\n--- Custom <=> (Version) ---\n";
    Version v1{2, 0, 0}, v2{1, 9, 9}, v3{2, 0, 1};

    std::cout << std::format("{} >  {}: {}\n",
                              v1.to_string(), v2.to_string(), v1 > v2);
    std::cout << std::format("{} <  {}: {}\n",
                              v1.to_string(), v3.to_string(), v1 < v3);
    std::cout << std::format("{} == {}: {}\n",
                              v1.to_string(), v1.to_string(), v1 == v1);

    // Direct use of <=> result
    auto cmp = v1 <=> v2;
    if (cmp > 0) std::cout << std::format("{} is newer\n", v1.to_string());
    else if (cmp < 0) std::cout << std::format("{} is newer\n", v2.to_string());
    else std::cout << "Same version\n";

    // Sort a vector of Versions
    std::vector<Version> versions = {{1,0,0}, {3,2,1}, {2,0,0}, {1,5,3}};
    std::ranges::sort(versions);
    std::cout << "\nVersions sorted: ";
    for (const auto& v : versions) {
        std::cout << v.to_string() << "  ";
    }
    std::cout << '\n';

    // ---- 4. Partial ordering ----
    partial_ordering_demo();

    return 0;
}