We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 12 of 25
eq, hash, and the eq/frozen/unsafe_hash truth table
Broken __hash__/__eq__ is the hardest bug class in this course to reproduce.
Nothing raises. Lookups miss, lru_cache serves the wrong entry, set() dedup
silently keeps duplicates, a dict grows two entries that compare equal — and
every test passes, because tests build a key, use it, and throw it away without
ever mutating it.
The truth table
@dataclass decides __hash__ from eq, frozen and unsafe_hash:
eq |
frozen |
unsafe_hash |
result |
|---|---|---|---|
True |
True |
— |
__hash__ generated from the comparing fields |
True |
False |
False |
__hash__ set to None — the class is unhashable |
False |
any |
False |
__hash__ not touched — inherited identity hash from object |
| any | any |
True |
__hash__ generated, whatever else is true |
Row 2 is the default. It is also the correct default: a mutable object whose
equality depends on its fields cannot have a stable hash, so Python removes the
hash rather than let you corrupt a container with it. Setting __hash__ = None
is how a class opts out — the same mechanism list and dict use.
Row 3 catches people out. eq=False does not make the class unhashable; it makes
it hash and compare by identity, like a plain class. That is right for
entities (two User rows with the same data are still two different objects) and
wrong for values.
unsafe_hash=True — the name is the documentation
It forces row 1 regardless of mutability. Watch it corrupt a set:
@dataclass(unsafe_hash=True)
class Key:
a: int
k = Key(1)
s = {k}
k.a = 2 # hash changes; the set's bucket does not
k in s # False
len(s) # 1 — the object is still in there, unreachable
s == {Key(2)} # False, and {Key(2)} != s either
The entry is not lost, it is stranded. Every future lookup misses, and the
memory is never reclaimed by dedup. If you find yourself reaching for
unsafe_hash=True, what you actually want is frozen=True.
Per-field: hash follows compare
field(compare=False) removes a field from __eq__ and from __hash__,
because hash defaults to None, which means “do whatever compare does”.
field(hash=True, compare=False) overrides that — and produces a class where two
equal instances can have different hashes, which violates the language’s hash
invariant. It exists; you should essentially never use it.
Hashable in principle, unhashable in fact
@dataclass(frozen=True)
class Tagged:
a: int
items: list[str]
Tagged.__hash__ exists. hash(Tagged(1, [])) raises TypeError: unhashable type: 'list', because the generated hash is hash((self.a, self.items)). You
find out at the call site, usually inside a decorator you did not write.
What the type checker does about it
Almost nothing. {my_mutable_dataclass} type-checks and raises at runtime; mypy
does not model __hash__ = None. The static substitute is a convention:
annotate the containers (set[Hashable], dict[CacheKey, V]) so that at least
the intent is checkable, and get the hash rules right by construction —
frozen=True everywhere a value is used as a key.
Your task
Implement:
def hashability_report(cls: type[DataclassInstance]) -> dict[str, object]:
returning exactly three keys:
-
"hashable"— wouldhash(instance)actually succeed? -
"reason"— one of"eq-without-frozen","eq-disabled-identity-hash","eq-and-frozen","unsafe-hash","unhashable-field-type"; -
"hashed_fields"— the field names that feed the generated hash, in field order;[]wheneverhashableisFalseor the hash is identity-based.
Decision procedure, in order:
-
getattr(cls, "__hash__", None) is None→ unhashable,"eq-without-frozen". -
"__hash__" not in cls.__dict__→ the class never got a generated hash, so it inheritedobject‘s →"eq-disabled-identity-hash". -
Otherwise collect the hashed fields: a field participates if
f.hash is None and f.compare, orf.hash is True. -
Resolve the annotations with
get_type_hints(cls)and, if any hashed field’s type is (or has origin)list,dict,setorbytearray, report"unhashable-field-type"withhashable: False. -
Otherwise
"unsafe-hash"if the class was built withunsafe_hash=True, else"eq-and-frozen".
Two typing notes. Use getattr(cls, "__hash__", None) rather than
cls.__hash__ — typeshed types __hash__ as non-optional, so mypy proves the
is None branch unreachable and --warn-unreachable fails your submission. And
__dataclass_params__ is not in the stubs at all; the DataclassParams Protocol
plus a single cast is how you read it without an Any escaping or a
# type: ignore.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.