Skip to content

← Data Modelling and Invariants step 12 of 25

Hard Framework

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" — would hash(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; [] whenever hashable is False or the hash is identity-based.

Decision procedure, in order:

  1. getattr(cls, "__hash__", None) is None → unhashable, "eq-without-frozen".
  2. "__hash__" not in cls.__dict__ → the class never got a generated hash, so it inherited object‘s → "eq-disabled-identity-hash".
  3. Otherwise collect the hashed fields: a field participates if f.hash is None and f.compare, or f.hash is True.
  4. Resolve the annotations with get_type_hints(cls) and, if any hashed field’s type is (or has origin) list, dict, set or bytearray, report "unhashable-field-type" with hashable: False.
  5. Otherwise "unsafe-hash" if the class was built with unsafe_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.