Hashability and the Capstone
In Lesson 4, James made NoteCollection iterable. Now he tries to put a Note in a set:
note = Note("Meeting", "Q3 roadmap", 4)
note_set = {note}
Output:
TypeError: unhashable type: 'Note'
"Unhashable?" James says. "In Chapter 48 I learned that sets only accept immutable types. But I thought that was just about strings and numbers."
"Not quite," Emma says. "Sets accept hashable types. Strings and numbers are hashable. Your Note became unhashable the moment you implemented __eq__. Python's safety rule: if you define custom equality, you must also define a custom hash, or Python disables hashing entirely."
Why eq Breaks Hashability
Here is the rule: objects that are equal must have the same hash.
Python uses this contract for sets and dictionaries. When you add an item to a set, Python computes hash(item) to find the right storage bucket, then uses == to check for duplicates within that bucket. If two equal objects have different hashes, they go into different buckets and the set treats them as different items.
Before you implemented __eq__, Python used the default hash (based on the object's id(), which is its memory address). That worked because the default __eq__ also used id(): same identity meant same hash. But once you defined __eq__ to compare by value (title and body), the identity-based hash no longer matched. Two notes with the same title and body would be "equal" by __eq__ but have different hashes (different memory addresses). Python prevents this inconsistency by setting __hash__ to None when you define __eq__.
| Scenario | eq | hash | Hashable? |
|---|---|---|---|
| No eq defined | Default (identity) | Default (id-based) | Yes |
| eq defined, no hash | Custom (value) | Set to None | No |
| eq and hash defined | Custom (value) | Custom (value) | Yes |
Implementing hash
The hash must use the same fields as __eq__. If __eq__ compares title and body, then __hash__ must use title and body:
class Note:
def __init__(self, title: str, body: str, word_count: int) -> None:
self.title = title
self.body = body
self.word_count = word_count
def __repr__(self) -> str:
return f"Note(title={self.title!r}, word_count={self.word_count})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Note):
return NotImplemented
return self.title == other.title and self.body == other.body
def __hash__(self) -> int:
return hash((self.title, self.body))
note1 = Note("Meeting", "Q3 roadmap", 4)
note2 = Note("Meeting", "Q3 roadmap", 4)
note3 = Note("Design", "Architecture plan", 12)
# Same hash for equal objects
print(f"hash(note1): {hash(note1)}")
print(f"hash(note2): {hash(note2)}")
print(f"Equal hashes: {hash(note1) == hash(note2)}")
# Sets work now
note_set = {note1, note2, note3}
print(f"Set size: {len(note_set)}") # 2, not 3
Output:
hash(note1): 5765333547288505889
hash(note2): 5765333547288505889
Equal hashes: True
Set size: 2
note1 and note2 are equal (same title and body), so they have the same hash. The set deduplicates them: three notes in, two unique notes stored.
James's analogy: warehouse barcodes. The hash is the barcode. Two items with the same barcode are the same product. The warehouse scanner (the set) uses the barcode to find items quickly and to prevent duplicate entries. If two identical products had different barcodes, the scanner would store both, creating phantom inventory.
The hash() Pattern
hash((self.title, self.body)) hashes a tuple of the equality fields. The built-in hash() function handles tuples efficiently. Always use a tuple of the exact fields from your __eq__ method.
bool: Truthiness
Python calls __bool__ when it needs a truth value for your object: in if, while, and, or, and not operations.
Without __bool__, all objects are truthy by default. For a collection, the natural convention is: an empty collection is falsy, a non-empty collection is truthy (matching how lists behave).
class NoteCollection:
def __init__(self, name: str) -> None:
self.name = name
self._notes: list[Note] = []
def add(self, note: Note) -> None:
self._notes.append(note)
def __len__(self) -> int:
return len(self._notes)
def __bool__(self) -> bool:
return len(self._notes) > 0
empty = NoteCollection("Empty")
full = NoteCollection("Full")
full.add(Note("Meeting", "Q3 roadmap", 4))
print(f"empty is truthy: {bool(empty)}")
print(f"full is truthy: {bool(full)}")
if full:
print("Collection has notes")
if not empty:
print("Collection is empty")
Output:
empty is truthy: False
full is truthy: True
Collection has notes
Collection is empty
Note: If you define __len__ but not __bool__, Python falls back to __len__ for truthiness (falsy if __len__() returns 0). So __bool__ is technically optional when __len__ exists. But explicit is better than implicit: defining __bool__ communicates your intent and avoids relying on fallback behavior.
The Capstone: All Special Methods Together
Here is the complete SmartNotes special methods suite. This is the integration of everything from Lessons 1-5:
Note (Display + Comparison + Hashability)
import functools
@functools.total_ordering
class Note:
def __init__(self, title: str, body: str, word_count: int,
author: str = "Anonymous", is_draft: bool = True,
tags: list[str] | None = None) -> None:
self.title = title
self.body = body
self.word_count = word_count
self.author = author
self.is_draft = is_draft
self.tags = tags if tags is not None else []
def __repr__(self) -> str:
return f"Note(title={self.title!r}, word_count={self.word_count})"
def __str__(self) -> str:
status = "draft" if self.is_draft else "published"
return f"{self.title} ({self.word_count} words, {status})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Note):
return NotImplemented
return self.title == other.title and self.body == other.body
def __lt__(self, other: object) -> bool:
if not isinstance(other, Note):
return NotImplemented
return self.word_count < other.word_count
def __hash__(self) -> int:
return hash((self.title, self.body))
NoteCollection (Container + Iteration + Truthiness)
class NoteCollection:
def __init__(self, name: str) -> None:
self.name = name
self._notes: list[Note] = []
def __repr__(self) -> str:
return f"NoteCollection(name={self.name!r}, count={len(self)})"
def add(self, note: Note) -> None:
self._notes.append(note)
def __len__(self) -> int:
return len(self._notes)
def __getitem__(self, index: int) -> Note:
return self._notes[index]
def __contains__(self, item: object) -> bool:
return item in self._notes
def __iter__(self):
yield from self._notes
def __bool__(self) -> bool:
return len(self._notes) > 0
Integration Test
# Create notes
meeting = Note("Meeting", "Q3 roadmap discussion", 4)
design = Note("Design Doc", "Full architecture plan", 12)
standup = Note("Standup", "Daily sync notes", 2)
duplicate = Note("Meeting", "Q3 roadmap discussion", 4)
# Display (Lesson 1)
print(repr(meeting)) # Note(title='Meeting', word_count=4)
print(meeting) # Meeting (4 words, draft)
# Comparison (Lesson 2)
print(meeting == duplicate) # True (same title and body)
print(meeting < design) # True (4 < 12 words)
# Container (Lesson 3)
collection = NoteCollection("My Notes")
collection.add(meeting)
collection.add(design)
collection.add(standup)
print(len(collection)) # 3
print(collection[0]) # Note(title='Meeting', word_count=4)
print(meeting in collection) # True
# Iteration (Lesson 4)
titles = [n.title for n in collection]
print(titles) # ['Meeting', 'Design Doc', 'Standup']
# Hashability (Lesson 5)
note_set = {meeting, design, duplicate}
print(len(note_set)) # 2 (meeting and duplicate are equal)
# Truthiness (Lesson 5)
print(bool(collection)) # True
print(bool(NoteCollection("Empty"))) # False
Output:
Note(title='Meeting', word_count=4)
Meeting (4 words, draft)
True
True
3
Note(title='Meeting', word_count=4)
True
['Meeting', 'Design Doc', 'Standup']
2
True
False
Every built-in operation works. Your objects are Pythonic.
PRIMM-AI+ Practice: Capstone
Predict [AI-FREE]
Press Shift+Tab to enter Plan Mode before predicting.
Given the complete Note and NoteCollection above, predict the result of each operation. Write your prediction and a confidence score from 1 to 5.
sorted(collection)[-1]{Note("A", "text", 1), Note("A", "text", 1), Note("B", "text", 2)}any(n.word_count > 10 for n in collection)bool(NoteCollection("Empty"))
Check your predictions
Note(title='Design Doc', word_count=12).sorted()uses__lt__(word count). The last item in sorted order is the longest note.- A set with 2 items. The two Note("A", "text", 1) objects are equal (same title and body) and have the same hash, so the set deduplicates them.
- True. The Design Doc has 12 words, which is greater than 10.
any()short-circuits on the first True. - False. An empty collection has no notes, so
__bool__returns False.
Run
Press Shift+Tab to exit Plan Mode.
Create capstone_demo.py with the complete Note and NoteCollection classes. Add all four test operations and run:
uv run python capstone_demo.py
Compare each output to your prediction.
Investigate
In Claude Code, type:
/investigate @capstone_demo.py
Ask: "What would happen if I implemented __hash__ using only title (not body)? Would two notes with the same title but different bodies end up in the same set bucket? Would the set treat them as equal?"
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: TDG for the Capstone
I need to implement all special methods for my SmartNotes
project using the TDG cycle. Here are the methods I need:
Note: __repr__, __str__, __eq__, __lt__, __hash__
NoteCollection: __repr__, __len__, __getitem__,
__contains__, __iter__, __bool__
Write the class stubs (method signatures with pass bodies)
and a test file with at least 15 tests covering all
special methods. Group tests by protocol: display,
comparison, container, iteration, hashability.
Do NOT write the implementation yet. Just stubs and tests.
Read the AI's stubs and tests. Verify that the test structure matches the TDG cycle: stubs first, tests second, implementation later.
What you're learning: You are using the AI to generate the specification (stubs and tests) for the capstone. This is the TDG cycle at scale: specify first, test second, implement third. You evaluate whether the AI's tests cover all the protocols you learned in Lessons 1-5.
Prompt 2: Hash Consistency Check
Review my __eq__ and __hash__ implementations. Are they
consistent? Do equal objects always produce equal hashes?
Show me a test case that would break if they were
inconsistent.
def __eq__(self, other: object) -> bool:
if not isinstance(other, Note):
return NotImplemented
return self.title == other.title and self.body == other.body
def __hash__(self) -> int:
return hash((self.title, self.body))
What you're learning: The AI reviews your implementation against the hashability contract. You learn to verify consistency between __eq__ and __hash__ by examining which fields each method uses.
Prompt 3: Full Integration Test
I have implemented all special methods on Note and
NoteCollection. Write an integration test that exercises
every special method in a single realistic scenario:
create notes, add to collection, deduplicate with a set,
sort by word count, iterate with a comprehension, and
verify display output. The test should fail if any
special method is missing or broken.
What you're learning: Integration testing verifies that special methods work together. The AI designs a test that chains display, comparison, container, iteration, and hashability operations in a single workflow, exposing any gaps in your implementation.
James runs the capstone test suite. All tests pass. He types print(note) one more time and sees Meeting (4 words, draft) instead of a memory address.
"Four lessons ago, my objects were invisible," he says. "Now they display, compare, contain, iterate, and hash. They work with everything Python has."
Emma nods. "Think about what changed. You did not modify print() or len() or for. You implemented contracts on your classes, and Python's infrastructure connected to them. That is the protocol pattern: your objects participate in the language by speaking its protocols."
James reaches for a warehouse analogy. "It is like getting your products certified for a new logistics network. The network already has the trucks, the scanners, and the routing system. You do not build those. You just put the right barcodes on your products, pack them in standard sizes, and label them correctly. Then the entire network handles them automatically."
"That is the best framing I have heard for Python protocols," Emma says. She pauses. "Your objects are Pythonic now. They display, compare, contain, iterate, and hash. Next chapter: decorators, properties, and Protocols. You will learn how to control attribute access, enforce interfaces, and build reusable patterns that attach behavior to classes without modifying them."