Skip to content
VLSI Mentor

C++ Part I · Module 15 · Operator Overloading

Comparison Operators

Arithmetic needed two operators to agree. Comparison needs six, and a reader arrives already believing a complete story about how they relate: that != is the negation of ==, that <= means < or ==, that > is < with the operands swapped, and that for any two values exactly one of less-than, equal-to and greater-than holds. Six hand-written functions can break that story in more ways than there are functions, and the breakages divide sharply. One kind is a wrong answer — this chapter measures an equality operator that quietly ignored a member added to the struct after it was written, reported two different objects as equal, and drew no warning from any flag the compiler has, including the one that warns about everything. The other kind is memory corruption, and it comes from the single most common mistake in this area: writing a less-than comparator with <= instead of <. That is one character, it compiles clean, it reads as a comparison, and it drove std::sort off the end of its array — SIGBUS in the plain build, and a heap-buffer-overflow that AddressSanitizer traced to the comparator's own line. Then C++20's answer, which replaces most of the six with a single defaulted line, measured precisely — because what it generates depends on whether you default it or write it, and the difference is one operator that almost everyone assumes comes for free.

Intermediate19 min readC++Part IFundamentalsOperators

Part I · Module 15 · Chapter 15.4 · Comparison Operators

1. Six Operators, One Story

Chapter 4.4 §1 listed them: ==, !=, <, <=, >, >=. For built-in types they are mutually consistent by definition. For your type they are six independent functions, and a reader expects all of this to hold:

  • a != b is exactly !(a == b)
  • a <= b is exactly a < b || a == b
  • a > b is exactly b < a
  • a >= b is exactly !(a < b)
  • and for any two values, exactly one of a < b, a == b, b < a is true

That last one has a name — a strict weak ordering — and §5 is about why it is not merely a tidiness requirement.

Chapter 15.3 §2 measured two operators drifting apart. Six have more room.

2. C++17 Gives You Nothing

A natural hope is that writing == and < is enough, since the other four are derivable. Measured, on a struct with exactly those two:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct V {
        int a;
        bool operator==(const V& o) const { return a == o.a; }
        bool operator< (const V& o) const { return a <  o.a; }
    };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
-std=c++17:  x != y  -> error: invalid operands to binary expression
             x >  y  -> error: invalid operands to binary expression
             x <= y  -> error: invalid operands to binary expression

All three fail. In C++17 there is no synthesis at all: six operators means six functions. Which is the whole reason <=> was added, and the reason this chapter has two halves.

3. The Hand-Written Six

In C++17 the discipline is the same as Chapter 15.3's — one implementation, everything else derived:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct Version { int major, minor, patch; };

    // the two primitives
    bool operator==(const Version& a, const Version& b)
    { return a.major == b.major && a.minor == b.minor && a.patch == b.patch; }

    bool operator<(const Version& a, const Version& b)
    {
        if (a.major != b.major) return a.major < b.major;
        if (a.minor != b.minor) return a.minor < b.minor;
        return a.patch < b.patch;
    }

    // the four derived, each one line, each defined in terms of the primitives
    bool operator!=(const Version& a, const Version& b) { return !(a == b); }
    bool operator> (const Version& a, const Version& b) { return b < a; }
    bool operator<=(const Version& a, const Version& b) { return !(b < a); }
    bool operator>=(const Version& a, const Version& b) { return !(a < b); }

All six are non-members, per Chapter 15.2 §4 — a reader will compare against a literal, and a member version rejects it on the left.

Note the derivations use only <, never <= or >: a > b is b < a, and a <= b is !(b < a). Deriving from one primitive is what makes the six mutually consistent by construction, and §5 shows why reaching for <= directly is the dangerous shortcut.

The < here is a lexicographic comparison — compare the first field, and only look at the next when the previous ones tie. That is the pattern for nearly every ordering over several members, and getting it wrong by comparing fields independently is how orderings stop being transitive.

4. The Member You Forgot

The first failure mode, and it needs no exotic input. A struct that grew a member after its == was written:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct Tx {
        unsigned addr;
        unsigned data;
        unsigned tag;              // <-- added later
    };
    bool operator==(const Tx& a, const Tx& b) { return a.addr == b.addr && a.data == b.data; }

Two objects differing only in tag:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
hand-written == , objects differing only in `tag`: a == b -> 1
defaulted    == , same two objects              : c == d -> 0

The hand-written version says they are equal. They are not. Every place that comparison is used — a lookup, a dedup, a scoreboard check — now treats two distinct transactions as one.

And nothing warns:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
-Wall -Wextra -Wpedantic : 0 warnings mentioning operator==
-Weverything             : 4 matches -- and none of them is about this

I read those four. They are -Wmissing-prototypes, two -Wc++98-compat notes about defaulted definitions, and -Wpre-c++20-compat. Not one concerns the incomplete comparison. So the honest conclusion is that no warning level in this compiler flags an == that ignores a member — which makes this a defect that survives indefinitely, because adding a field to a struct does not draw attention to a function in another file.

The C++20 defaulted form has the property that fixes it structurally: it compares every member, and it keeps doing so as the struct changes. §8 is that form.

5. Strict Weak Ordering

The requirement the standard library imposes on <, stated as the three properties that matter:

Irreflexive: a < a must be false. Transitive: a < b and b < c imply a < c. And incomparability must be transitive: if neither a < b nor b < a, and neither b < c nor c < b, then neither a < c nor c < a.

The practical consequence of the first is the one that bites:

A comparator must be false for equal values. a <= b is irreflexive's exact violation: a <= a is true.

This is why §3 derived everything from < alone. <= is a perfectly good operator for your users and a catastrophic comparator for an algorithm, and the two uses look identical in source.

6. What a Broken Ordering Does

Not a wrong answer — memory corruption. One comparator, one character different:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct BadLess  { bool operator()(const Rec& a, const Rec& b) const { return a.key <= b.key; } };
    struct GoodLess { bool operator()(const Rec& a, const Rec& b) const { return a.key <  b.key; } };

Sorting 64 records whose keys are all equal — the worst case for this bug, because every comparison hits the equal path:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
GOOD (<)  : sorting 64 records, all keys equal, with the GOOD (<) comparator
            survived; first=7 last=7
            exit 0

BAD  (<=) : sorting 64 records, all keys equal, with the BAD (<=) comparator
            <no further output>
            exit 138

Exit 138 is 128 + 10 — SIGBUS. The process died inside std::sort. Under AddressSanitizer:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
ERROR: AddressSanitizer: heap-buffer-overflow on address 0x611000000140
    #0 ... in BadLess::operator()(Rec const&, Rec const&) const  badorder.cpp:7
SUMMARY: AddressSanitizer: heap-buffer-overflow badorder.cpp:7 in BadLess::operator()
exit 134

heap-buffer-overflow, named at the comparator's own line. The mechanism is worth understanding because it explains why the failure is so severe: std::sort's partitioning advances one index while the comparator keeps returning true, and it relies on the comparator eventually returning false to stop. With <= and equal elements it never does, so the index walks past the end of the array — reading, and then writing, outside the buffer.

Zero compiler warnings. <= is a valid expression returning bool; nothing about the call site says this bool is required to be irreflexive.

(std::sort is Module 20's subject and is used here purely as an instrument — any ordered container or algorithm has the same requirement, so a std::map with this comparator is equally broken.)

7. C++20, Part One: == Synthesises !=

From here the chapter is C++20, compiled with -std=c++20. The curriculum's baseline is C++17 (Chapter 1.3), so treat this as the direction the language has gone rather than something you can assume is available.

The same struct from §2, same two operators, one flag different:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
-std=c++20:  x != y  -> COMPILES
             x >  y  -> error: invalid operands to binary expression
             x <= y  -> error: invalid operands to binary expression

!= now works and the relational operators still do not. C++20 rewrites a != b as !(a == b) automatically, so writing == is enough for equality — but < does not generate > or <=. For those you need <=>.

A related C++20 rule worth knowing: the compiler will also try reversed operands, so a single operator==(const V&, const Something&) covers Something == V too. That removes most of the reason to write the symmetric pairs by hand.

8. C++20, Part Two: The Three-Way Comparison Operator

<=> returns not a bool but an object saying which of three relationships holds:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct V {
        int a, b;
        auto operator<=>(const V&) const = default;
        bool operator==(const V&) const = default;
    };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
x <  y : 1
x >  y : 0
x <= y : 1
x == y : 0
x != y : 1
(x<=>y) < 0 : 1
__cplusplus = 202002

Two defaulted lines replaced all six operators, and the defaulted versions compare every member lexicographically in declaration order — which is exactly §3's hand-written < and §4's complete ==, generated rather than typed, and automatically updated when a member is added.

In C++17 the same source does not merely fail, it fails strangely, because the token does not exist:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
warning: '<=>' is a single token in C++20; add a space to avoid a change in behavior [-Wc++20-compat]
error: 'operator<=' cannot be the name of a variable or data member

The compiler lexed <=> as <= followed by >. If you see that error pair, you are compiling C++20 source as C++17.

9. What <=> Does Not Generate

Here is the part where the obvious assumption is wrong, and it is worth being precise because the two cases differ.

A defaulted <=> also gives you ==:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct V { int a; auto operator<=>(const V&) const = default; };   // <=> ONLY
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
relational ops : 1 0 1 0
x == y         : COMPILES

That is because defaulting <=> causes the compiler to implicitly declare a defaulted == as well.

An explicitly written <=> does not:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    struct V {
        int a;
        std::strong_ordering operator<=>(const V& o) const { return a <=> o.a; }   // WRITTEN
    };
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
relational ops : 1 0 1 0
x == y         : error: invalid operands to binary expression ('V' and 'V')

Four relational operators, and no equality. <, >, <=, >= are all synthesised from the written <=>, and == is not — so x == y is a hard error.

The rule to remember:

<=> never generates ==. A defaulted <=> gets == because defaulting it declares a defaulted == too; a written <=> gets nothing.

The design reason is performance. For a type like std::string, equality can check the length first and bail out, while ordering must compare character by character — so the standard deliberately keeps the two separate rather than defining == as (a <=> b) == 0.

The practical consequence: whenever you write <=> by hand, write == too. If you default <=>, you may rely on the implicit ==, but writing both defaulted lines as in §8 is clearer and costs nothing.

10. The Comparison Categories

<=> returns one of three types, and which one you get is not a free choice. Measured for defaulted <=> over different member types:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
int,int    members -> std::strong_ordering
double     members -> std::partial_ordering
string     members -> std::strong_ordering
int+double members -> std::partial_ordering
CategoryMeans
std::strong_orderingexactly one of <, ==, >; and equal values are indistinguishable
std::weak_orderingexactly one of the three, but equal values may differ in ways you ignore (case-insensitive text, say)
std::partial_orderingsome pairs are unordered — none of the three holds

Two things to take from the table.

The weakest member wins. int + double came out partial_ordering, not strong_ordering, because one double member is enough to make some pairs incomparable.

And floating point is the reason, measured:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
a <  b : 0
a >  b : 0
a == b : 0
(a<=>b) == std::partial_ordering::unordered : 1

With a holding NaN, all three comparisons are false simultaneously — which breaks §1's "exactly one holds" and is exactly what partial_ordering exists to express. Chapter 4.4 §6 established this for built-in double; here it propagates into any type with a floating-point member, and it means such a type must not be used as a sort key or map key without thought.

11. What to Actually Do

In C++20, default <=> and == — two lines, every member compared, and they stay correct as the struct changes.

Whenever you write <=> by hand, write == too — measured, a written <=> gives you four operators and no equality.

In C++17, write == and < and derive the other four — from < alone, never from <= or >.

Make them all non-members so a literal works on either side.

Compare members lexicographically, not independently.

A comparator must be false for equal values. Never <=.

Treat a floating-point member as a warning sign for any type used as a sort or map key.

And read warning-sweep matches rather than counting them.

12. Mistakes Worth Making Once

An == that misses a member. Measured: reported two different objects as equal, with no warning at any level, including -Weverything.

<= in a comparator. Measured: exit 138 (SIGBUS), and heap-buffer-overflow at the comparator's line under ASan.

Expecting < to give you > and <=. It does not, in either standard.

Expecting == to give you != in C++17. It does not; C++20 does.

Expecting a written <=> to give you ==. Measured: invalid operands to binary expression.

Deriving > from a hand-written > rather than from <. A second place for the truth to live.

Comparing members independently instead of lexicographically — the result is not transitive.

Using a type with a double member as a map key without considering NaN. Measured: all three comparisons false at once.

Seeing 'operator<=' cannot be the name of a variable and not realising you compiled C++20 source as C++17.

Counting grep matches in a warning sweep. Four matches, none relevant.

13. Interview Relevance

"How many comparison operators do you need to write?" In C++17, == and < plus four one-line derivations. In C++20, two defaulted lines. The strong answer names the derivation discipline, not just the count.

"What is a strict weak ordering, and why does it matter?" Irreflexive, transitive, with transitive incomparability. It matters because the standard library's algorithms require it, and violating it is undefined behaviour rather than a wrong answer — measured as a heap-buffer-overflow from <= instead of <.

"What does a <= b as a comparator do?" Breaks irreflexivity, so std::sort can run off the array. Exit 138 measured. This is a very common discriminator.

"What does <=> give you?" The four relational operators. Not == — unless you defaulted it, in which case a defaulted == is implicitly declared too. Getting this distinction right is rare and is worth stating carefully.

"Why doesn't <=> generate ==?" Performance: equality can short-circuit on a cheap check like length where ordering cannot.

"What are the three comparison categories?" strong_ordering, weak_ordering, partial_ordering — and a double member forces the last one because NaN is unordered.

"Will the compiler warn about an == that forgets a member?" No. Measured at every level.

14. What You Should Be Able to Say Now

  1. Six comparison operators must tell one consistent story, and nothing enforces it for your type.
  2. Measured, C++17: == and < alone leave !=, > and <= as hard errors.
  3. Measured, C++20: != is synthesised from ==; > and <= are still not synthesised from <.
  4. C++20 also tries reversed operands, so one == covers both orders.
  5. Measured: a hand-written == ignoring a later-added member reported two different objects equal.
  6. Nothing warns about that — 0 at -Wall -Wextra -Wpedantic, and all four -Weverything matches were unrelated.
  7. A strict weak ordering is irreflexive, transitive, with transitive incomparability.
  8. A comparator must be false for equal values — so <= violates irreflexivity.
  9. Measured: <= as a comparator gave exit 138 (SIGBUS), and ASan reported heap-buffer-overflow at the comparator's own line.
  10. Zero compiler warnings for it — <= is a valid bool expression.
  11. Measured: two defaulted C++20 lines replaced all six operators and compare every member lexicographically.
  12. Measured: a defaulted <=> also yields ==; an explicitly written one yields four relational operators and no ==.
  13. <=> never generates == itself — the defaulted case works because defaulting it declares a defaulted == as well.
  14. The reason is performance — equality may short-circuit where ordering cannot.
  15. Measured categories: int → strong_ordering, double → partial_ordering, int+double → partial_ordering — the weakest member wins.
  16. Measured: with NaN, a < b, a > b and a == b were all false, and a <=> b was unordered.
  17. In C++17, <=> lexes as <= then >, giving 'operator<=' cannot be the name of a variable.
  18. Severity and detectability are unrelated — the memory-corrupting bug was diagnosed precisely and the wrong-answer bug was not diagnosed at all.

15. Exercises

Exercise 1 — the six. For a Version{major, minor, patch}, write == and < and derive the other four, using only < in the derivations.

Exercise 2 — C++20 version. Rewrite it with defaulted operators and say what you gain beyond brevity.

Exercise 3 — the forgotten member. Tx{addr, data} gains a tag. Say what the existing == now reports for two objects differing only in tag, and what warns about it.

Exercise 4 — the three properties. State the strict-weak-ordering requirements and show that a <= b violates one of them.

Exercise 5 — predict the failure. Sorting 64 equal-keyed records with a <= comparator. Give the plain exit status and the ASan diagnosis.

Exercise 6 — the mechanism. Explain why a non-irreflexive comparator makes std::sort read past the end of the array.

Exercise 7 — what does <=> give you? For a defaulted <=> and for a written one, list exactly which of the six operators become available.

Exercise 8 — categories. For members int, std::string, double, and int + double, give the defaulted <=> return type and say why.

Exercise 9 — NaN. For a struct with one double member holding NaN compared against 1.0, give the values of <, >, == and <=>, and say what that means for using the type as a map key.

Exercise 10 — diagnose the build. A colleague gets 'operator<=' cannot be the name of a variable or data member. Say what is wrong.

16. Summary

  • Six operators, one story — and six independent functions give it many ways to break.
  • Measured, C++17: == and < synthesise nothing. C++20 synthesises != from == but not >/<= from <.
  • Measured: an == that ignores a later-added member called two different objects equal, with no warning at any level including -Weverything.
  • A comparator must be false for equal values. Measured: <= gave exit 138 (SIGBUS) and a heap-buffer-overflow that ASan traced to the comparator's own line, with zero compiler warnings.
  • Measured: two defaulted C++20 lines replace all six and compare every member lexicographically, staying correct as the struct grows.
  • Measured: a written <=> gives four relational operators and no ==; a defaulted one gets == because defaulting declares a defaulted == too. So always write == alongside a hand-written <=>.
  • Measured: double forces partial_ordering, and with NaN all three comparisons are false at once.
  • Severity and detectability are unrelated — plan QA around what tools cannot see.

Equality and ordering are the comparisons a type needs in order to be used. The next operator is the one it needs in order to be looked at, and it is the overload that pays for itself faster than any other in this module, because the first thing anyone does with a new type is print one to find out what it contains. It is also the overload with the strangest justification in C++: nothing about a left-shift suggests output, so it fails Chapter 15.1's guessing test outright and survives only on a convention learned once. The next chapter is that convention, the exact signature the chaining depends on, and two measured failures — a formatting change that escaped the function and silently altered how an unrelated number printed later, and an extraction that failed partway through and left its target object holding a mixture of new data, a zero, and whatever it had before.

Standards & specifications

Governing standard
ISO/IEC 14882 (the C++ language standard)(opens ISO/IEC (JTC 1/SC 22/WG 21) in a new tab)

Defines the C++ language and its standard library — the type system and templates, the object model and object lifetime, initialisation and overload resolution, the memory model and the containers, algorithms and utilities the library provides. Like C's standard it is a FAMILY of editions (C++98/03, C++11, C++14, C++17, C++20, C++23) and the editions are NOT interchangeable: a feature this curriculum relies on may be unavailable, differently specified or merely proposed in another edition, so each lesson names the edition its claim depends on. It does not define any foreign-function interface to a hardware description language, nor what a compiler actually emits, nor platform behaviour on real silicon — those are the implementation's and the device's contracts. Working drafts published by WG21 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C++ Programming curriculum.