Skip to content
← All articles

The comparison traits, read as a design

PartialEq, Eq, PartialOrd, Ord and Hash are not five unrelated derives — they are a layered contract, half of which the compiler enforces and half of which it merely trusts you about.

You have already derived PartialEq, Ord and Hash and watched them work. This article is about the shape of that family: why there are five traits and not two, which relationships the compiler enforces, and which ones it simply believes you about — the ones that cause bugs that are not compile errors.

Supertraits: a trait that requires another trait

Write this and you have declared a supertrait relationship:

pub trait Eq: PartialEq<Self> {}

It reads like inheritance and it is not. There is no shared data, no overriding, no “is-a”. It is a bound: any type implementing Eq must also implement PartialEq. In exchange, code that has an Eq bound may freely call PartialEq methods.

The real declarations in std form a small lattice:

Clone ──────► Copy          (Copy: Clone)
PartialEq ──► Eq            (Eq: PartialEq<Self>)
PartialOrd ─┐
Eq ─────────┴► Ord          (Ord: Eq + PartialOrd<Self>)

Read those arrows as “requires”. Ord requires both Eq and PartialOrd, so a function taking T: Ord can use ==, <, .cmp() and .partial_cmp() without writing four bounds. That compression is most of what supertraits buy you in practice.

💡Copy: Clone means every Copy type must also implement Clone. If Copy is a bit-for-bit duplicate that needs no code, why does the language insist on the Clone impl as well? click to reveal

Two reasons, one practical and one about generic code.

The practical one: Clone is the general interface for duplication and Copy is a promise about how cheap it is. If Copy did not imply Clone, then fn dup<T: Clone>(x: &T) -> T — the most ordinary generic function imaginable — would reject u8. Every generic API would have to be written twice, once for each trait. Making Copy a subtrait of Clone means the cheap types are automatically usable everywhere the general ones are.

The one about generic code: Copy has no methods. It is a marker trait — its entire content is “the compiler may duplicate this by memcpy instead of moving it”. A bound of T: Copy changes the rules at the use site rather than giving you anything to call. If Copy did not carry Clone with it, a T: Copy bound would give you no callable way to duplicate a value at all, only an implicit one.

There is a matching rule in the other direction, worth memorising now: a type with a Drop impl can never be Copy. Copy means “duplicating this is a memcpy and both copies are equally real”; Drop means “destroying this runs code”. Together they would run the destructor twice on one resource. The compiler rejects the combination outright.

The Partial/total split

PartialEq and PartialOrd are the weaker halves, and the weakness is precise.

PartialEq supplies == and requires only symmetry and transitivity. It does not require reflexivity — x == x may be false. Eq adds exactly that missing axiom and nothing else:

pub trait Eq: PartialEq<Self> {}

An empty body. Eq is a marker trait: it adds no method, only a promise. The single reason it exists is f64. NaN == NaN is false, so floats are symmetric and transitive but not reflexive; they implement PartialEq and cannot implement Eq.

The same split, one level up: PartialOrd::partial_cmp returns Option<Ordering>, where None means “these two are not comparable”. Ord::cmp returns a plain Ordering — a total order, every pair comparable, no escape. Again f64 implements only the partial one, because there is no consistent place to file NaN.

This is why [3.0, 1.0].sort() does not compile but [3, 1].sort() does: slice::sort requires T: Ord. The idiomatic fixes are sort_by(f64::total_cmp) (a genuine total order that files NaN at the ends) or sort_by(|a, b| a.partial_cmp(b).unwrap()) (which panics on NaN, so only use it when you know there is none).

💡PartialEq is generic — PartialEq<Rhs = Self> — but Eq is not. Why can you compare a String to a &str but not "Eq" them against each other? click to reveal

Because the two traits answer different questions.

PartialEq<Rhs> is a cross-type comparison: “can I compare a Self with an Rhs?” std implements PartialEq<str> for String, PartialEq<&str> for String, and several more, which is why my_string == "hello" just works. The Rhs = Self default means you usually never see the parameter.

Eq asks a question about one type: “is this type’s equality reflexive?” There is no second type in the question, so there is no parameter. That is also why Eq cannot be checked by the compiler: reflexivity is a property of your eq implementation’s behaviour, not of its signature.

This is the first place you meet the associated-type-versus-generic-parameter distinction that item 7.16 develops properly. PartialEq‘s Rhs is an input chosen by the caller, so it is a generic parameter and a type may implement it many times. Compare Iterator::Item, which is an output decided by the impl, and is therefore an associated type implementable only once.

The contracts the compiler cannot check

Here is the part that matters in real code. Deriving these traits is safe. Hand-writing them is where the bugs live, because several of the requirements are semantic promises with no compile-time enforcement.

1. Hash must agree with Eq. The rule is:

a == b  implies  hash(a) == hash(b)

Break it and HashMap misbehaves in the most unhelpful way possible: you insert a key, you look it up with an equal key, and you get None. No panic, no error, just a value that has become unreachable. Nothing in the type system can catch this — it is a statement about two implementations agreeing.

Clippy does catch the most common way of breaking it, and it is deny-by-default:

error: you are deriving `Hash` but have implemented `PartialEq` explicitly
       [clippy::derived_hash_with_manual_eq]

If your eq ignores a field (say, a cache or a timestamp) but the derived hash still mixes it in, two equal values hash differently. Either derive both or write both.

2. Ord must agree with PartialOrd. partial_cmp must return Some(self.cmp(other)). Clippy denies the classic mistake here too:

error: you are deriving `Ord` but have implemented `PartialOrd` explicitly
       [clippy::derive_ord_xor_partial_ord]

and flags the reverse shape with clippy::non_canonical_partial_ord_impl. The canonical hand-written pair is:

impl Ord for Version {
    fn cmp(&self, other: &Self) -> Ordering {
        self.major.cmp(&other.major).then(self.minor.cmp(&other.minor))
    }
}

impl PartialOrd for Version {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

Write cmp once; make partial_cmp a one-line delegate. Any other shape is a chance for the two to drift.

3. Ord must be a strict weak ordering. If your comparator is inconsistent — a < b and b < a both true, or a comparison that depends on something mutable — the sort is entitled to misbehave.

Be careful about what “misbehave” means, because the folklore overstates it. The documentation says the sort may panic, and explicitly permits it to return normally with the elements in an unspecified order. Measured on 1.95: a comparator that always returns Less never panicked even at 10 000 elements, while a pseudo-random comparator started panicking from about 40. It is an ordinary unwinding panic when it happens — catch_unwind catches it — not an abort. So: never write code that depends on the panic, and never assume a broken comparator will announce itself.

💡You have a struct Account { id: u64, last_seen: SystemTime }. You want two accounts with the same id to be equal regardless of last_seen, and you want to use Account as a HashMap key. What must you write, and what will bite you if you get it wrong? click to reveal

You must hand-write both PartialEq and Hash, and both must look at id only:

impl PartialEq for Account {
    fn eq(&self, other: &Self) -> bool { self.id == other.id }
}
impl Eq for Account {}
impl Hash for Account {
    fn hash<H: Hasher>(&self, state: &mut H) { self.id.hash(state); }
}

impl Eq for Account {} with an empty body is correct and is not a formality — you are asserting reflexivity, which holds here because u64 equality is reflexive.

The bite: writing only the PartialEq impl and leaving #[derive(Hash)] in place. Then two accounts with the same id and different last_seen are equal but hash differently, and map.get(&probe) returns None for a key that is definitely in the map. On this toolchain clippy stops you — derived_hash_with_manual_eq is deny-by-default — but only for that exact derive-plus-manual shape. Hand-write both impls inconsistently and nothing warns you at all.

A second, subtler bite: if you later add a field and update eq but forget hash, you reintroduce the bug with no lint, because both are now manual. That is the argument for the newtype-key pattern — store HashMap<AccountId, Account> and never make the whole struct a key.

What to derive, and in what order

A practical default for a plain data type:

#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
struct Version { major: u32, minor: u32 }

Derived PartialOrd/Ord compare fields in declaration order, so reordering the fields of that struct silently changes the sort order of every collection of it. That is a real and easily-missed refactor hazard; if the ordering is meaningful, consider writing cmp by hand so the intent is in the source rather than in the field layout.

One lint you will read about and should not rely on: clippy::derive_partial_eq_without_eq, which nudges you to add Eq whenever you derive PartialEq on a type that could support it. It is a nursery lint — allow-by-default — so it will not fire under this course’s gate. The advice is still decent: if your type has no floats in it, Eq costs one word and unlocks HashMap keys, BTreeMap keys and Ord.

The summary worth keeping

  • Supertraits are bounds, not inheritance. Ord: Eq + PartialOrd is a requirement list, and the payoff is that one bound gives you every operator.
  • Eq and Copy are marker traits: no methods, only promises. Their bodies are empty on purpose.
  • The Partial/total split exists because of NaN. Every “why can’t I sort floats” question traces back to it.
  • Hash/Eq agreement and Ord/PartialOrd agreement are contracts the compiler cannot verify. Clippy catches the two most common shapes at deny level; beyond those you are on your own, and the failure mode is silent.