Learning Goals
3 minBy the end of this lesson you can:
- Define
__eq__(self, other)soa == bcompares values. - Define
__lt__(self, other)sosorted()andmin/maxwork. - Use
functools.total_orderingto get<=,>,>=for free. - Recognise when to define
__hash__— needed if objects go in a set or as dict keys.
Warm-Up · Two Identical-Looking Heroes
5 minclass Hero: def __init__(self, name, hp): self.name = name; self.hp = hp a = Hero("Aisyah", 100) b = Hero("Aisyah", 100) # same data! print(a == b) # → False -- surprising? print(a is b) # → False -- different objects in memory
By default == tests identity, same as is. Two heroes with identical data are still considered different.
Today: teach Python that two Heroes are equal if their names match. Same shape for sortability — "Hero A is less than Hero B" means "A's HP is smaller than B's" or whatever we decide.
The operators == and < are dunders in disguise. Implement __eq__ and __lt__ on your class and the operators do exactly what you say.
New Concept · Three Dunders + One Decorator
14 min__eq__ — value equality
class Hero: def __init__(self, name, hp): self.name = name; self.hp = hp def __eq__(self, other): if not isinstance(other, Hero): return NotImplemented # let Python try the other way round return self.name == other.name a = Hero("Aisyah", 100) b = Hero("Aisyah", 80) # different HP — still equal because we compare on name print(a == b) # → True
Two new things: (1) isinstance check — refuse to compare with unrelated types; return NotImplemented (Python falls back to the other object's __eq__, then to False). (2) Decide which fields define equality — sometimes name, sometimes id, sometimes all fields.
__lt__ — "less than"
Python's sorted, min, max all rely on <. Implement __lt__ and they spring to life:
class Hero: # ... __init__ ... def __lt__(self, other): return self.hp < other.hp # order by HP party = [Hero("Aisyah", 80), Hero("Wei Jie", 50), Hero("Priya", 95)] party.sort() for h in party: print(h.name, h.hp) # Wei Jie 50 # Aisyah 80 # Priya 95
One method, and the entire sorted infrastructure starts working on your class.
The other comparisons
Operator Dunder Inverse a == b __eq__ __ne__ (default: not __eq__) a < b __lt__ __gt__ (use __lt__ with args swapped) a <= b __le__ __ge__ a > b __gt__ a >= b __ge__
You don't need all of them. Define __eq__ and __lt__ and Python wires the rest sensibly — but only with a helper:
functools.total_ordering — the shortcut
from functools import total_ordering @total_ordering class Hero: def __init__(self, name, hp): self.name = name; self.hp = hp def __eq__(self, other): return isinstance(other, Hero) and self.hp == other.hp def __lt__(self, other): return self.hp < other.hp # Now Hero supports ==, !=, <, <=, >, >= — all for free
@total_ordering is a class decorator that fills in the missing comparison dunders from __eq__ and __lt__. Saves four method definitions.
The hash gotcha
Defining __eq__ on a class makes it unhashable by default — you can't put it in a set or use it as a dict key. To fix that, also define __hash__:
class Hero: def __init__(self, name, hp): self.name = name; self.hp = hp def __eq__(self, other): return isinstance(other, Hero) and self.name == other.name def __hash__(self): return hash(self.name) # MUST match what __eq__ compares set([Hero("Aisyah", 80), Hero("Aisyah", 100)]) # → set of one (same name)
Rule: if a == b, then hash(a) == hash(b). Otherwise sets and dicts misbehave. Compute the hash from the same fields you compare in __eq__.
Worked Example · Sortable Heroes
12 minSave as sortable.py:
# sortable.py — equality, ordering, hashing from functools import total_ordering @total_ordering class Hero: def __init__(self, name, hp, level): self.name = name; self.hp = hp; self.level = level def __repr__(self): return f"Hero({self.name!r}, hp={self.hp}, level={self.level})" def __eq__(self, other): if not isinstance(other, Hero): return NotImplemented return self.name == other.name def __lt__(self, other): # Sort by level descending, then HP descending — strongest first return (self.level, self.hp) > (other.level, other.hp) def __hash__(self): return hash(self.name) party = [ Hero("Aisyah", 85, 3), Hero("Wei Jie", 70, 5), Hero("Priya", 95, 4), Hero("Iman", 60, 5), Hero("Aizat", 80, 2), ] # 1 — sort uses __lt__ print("=== Sorted ===") for h in sorted(party): print(f" {h.name:<10} Lv {h.level} HP {h.hp}") # 2 — min / max print(f"\nStrongest: {min(party)}") # min by our __lt__ = strongest first print(f"Weakest : {max(party)}") # 3 — equality by name clone = Hero("Aisyah", 999, 99) print(f"\nAisyah duplicate? {clone in party}") # uses __eq__ # 4 — set membership uses __eq__ + __hash__ unique = set(party + [Hero("Wei Jie", 1, 1)]) print(f"\nUnique heroes: {len(unique)} (out of {len(party) + 1})") for h in unique: print(f" {h}")
Output
=== Sorted ===
Wei Jie Lv 5 HP 70
Iman Lv 5 HP 60
Priya Lv 4 HP 95
Aisyah Lv 3 HP 85
Aizat Lv 2 HP 80
Strongest: Hero('Wei Jie', hp=70, level=5)
Weakest : Hero('Aizat', hp=80, level=2)
Aisyah duplicate? True
Unique heroes: 5 (out of 6)
Hero('Wei Jie', hp=70, level=5)
Hero('Aisyah', hp=85, level=3)
...Read the diff
Three operators wired up. The tuple trick (self.level, self.hp) > (other.level, other.hp) sorts by level first, then HP as a tie-breaker. Equality uses name only — "same name = same hero". The set dedupes by name; the second Wei Jie gets dropped. Hash matches eq, exactly as required.
Try It Yourself
13 minAdd __eq__ to Book — two books are equal if both title and author match. Test with two books and confirm == returns the right answer.
Hint
class Book: def __init__(self, title, author, year): self.title = title; self.author = author; self.year = year def __eq__(self, other): if not isinstance(other, Book): return NotImplemented return (self.title, self.author) == (other.title, other.author) b1 = Book("Hobbit", "Tolkien", 1937) b2 = Book("Hobbit", "Tolkien", 1937) b3 = Book("Hobbit", "Tolkien", 2012) # different year b4 = Book("Hobbit", "Pichon", 1937) # different author print(b1 == b2) # True print(b1 == b3) # True — year doesn't count print(b1 == b4) # False — author differs
Add __lt__ by year. Sort a list of three books. Print them in year order.
Hint
class Book: # ... existing __init__ and __eq__ ... def __lt__(self, other): return self.year < other.year shelf = [ Book("Wings of Fire", "Sutherland", 2012), Book("The Hobbit", "Tolkien", 1937), Book("Tom Gates", "Pichon", 2011), ] for b in sorted(shelf): print(b.year, b.title)
Make Book usable in a set. Define __hash__ matching what __eq__ compares. Test by deduplicating a list with three Books that have the same title/author but different years.
Hint
class Book: # ... existing ... def __hash__(self): return hash((self.title, self.author)) # same fields as __eq__ shelf = [ Book("Hobbit", "Tolkien", 1937), Book("Hobbit", "Tolkien", 2012), Book("Hobbit", "Pichon", 2020), # different author = different book ] unique = set(shelf) print(len(unique)) # → 2
If you forget __hash__ and try set(shelf), you get TypeError: unhashable type. The rule "eq implies hash" is Python being strict about correctness.
Mini-Challenge · Card Game Hand
8 minBuild cards.py. A Card class with suit (♠♥♦♣) and rank (2-14, with 11=J, 12=Q, 13=K, 14=A). Define:
__eq__— equal if same suit AND same rank.__lt__— order by rank only.__hash__— same fields as eq.__str__— like"A♠".__repr__—Card(suit='♠', rank=14).
Build a 5-card hand, sort it, dedupe a list of 7 cards with duplicates, find max/min.
Show one possible solution
# cards.py — Card with five dunders from functools import total_ordering @total_ordering class Card: LABELS = {11: "J", 12: "Q", 13: "K", 14: "A"} def __init__(self, suit, rank): self.suit = suit self.rank = rank def __eq__(self, other): if not isinstance(other, Card): return NotImplemented return (self.suit, self.rank) == (other.suit, other.rank) def __lt__(self, other): return self.rank < other.rank def __hash__(self): return hash((self.suit, self.rank)) def __str__(self): label = self.LABELS.get(self.rank, str(self.rank)) return f"{label}{self.suit}" def __repr__(self): return f"Card(suit={self.suit!r}, rank={self.rank})" hand = [Card("♠", 14), Card("♥", 7), Card("♦", 14), Card("♣", 11), Card("♠", 5)] print("Hand (sorted by rank):") for c in sorted(hand): print(" ", c) print(f"\nHigh card: {max(hand)}") print(f"Low card : {min(hand)}") bag = hand + [Card("♥", 7), Card("♣", 11)] # 2 duplicates unique = set(bag) print(f"\nUnique cards: {len(unique)} (out of {len(bag)})")
Non-negotiables: five dunders, @total_ordering to fill in the rest, hash matching eq. sorted() + max + set() all work because of the dunders — the user code looks like it's working with built-in types.
Recap
3 minThree dunders, one decorator. __eq__ defines value equality — what makes two instances "the same". __lt__ defines ordering — enough for sorted, min, max, and (with @total_ordering) all six comparison operators. __hash__ is mandatory when objects go in sets or dict keys — and must match __eq__. The tuple trick (a, b, c) < (x, y, z) gives you multi-key sorting for free.
Vocabulary Card
- __eq__
- Value equality. Returns True/False/NotImplemented.
- __lt__
- Less-than. Used by sorted, min, max.
- __hash__
- Make objects usable as set members / dict keys. Must match
__eq__. - @total_ordering
- Decorator that fills in the missing comparison dunders from __eq__ and __lt__.
- NotImplemented
- The sentinel value
__eq__returns when it can't compare. Python tries the other side's eq.
Homework
4 minBuild student_records.py. A Student class with name and average. Define:
__eq__by name.__lt__by average (higher is "less" — top students sort first).__hash__by name.__repr__.
Then build a class roster of 6 students, sort, find top + bottom, dedupe.
Sample · student_records.py
from functools import total_ordering @total_ordering class Student: def __init__(self, name, average): self.name = name; self.average = average def __eq__(self, other): if not isinstance(other, Student): return NotImplemented return self.name == other.name def __lt__(self, other): return self.average > other.average # higher = sorts first def __hash__(self): return hash(self.name) def __repr__(self): return f"Student({self.name!r}, {self.average})" class_data = [ Student("Aisyah", 88.2), Student("Wei Jie", 76.5), Student("Priya", 95.1), Student("Iman", 60.0), Student("Aizat", 80.7), Student("Hafiz", 70.3), ] print("=== Leaderboard ===") for s in sorted(class_data): print(f" {s.name:<10} {s.average}") print(f"\nTop : {min(class_data)}") print(f"Last : {max(class_data)}") # Dedupe — pretend two records for Aisyah snuck in with_dupe = class_data + [Student("Aisyah", 90)] print(f"\nUnique: {len(set(with_dupe))} from {len(with_dupe)}")
Non-negotiables: four dunders, the @total_ordering decorator, leaderboard sorted top-first because of the "higher = less" trick. Note min returns the top student in this scheme.