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 + PartialOrdis a requirement list, and the payoff is that one bound gives you every operator. -
EqandCopyare 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/Eqagreement andOrd/PartialOrdagreement 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.