Skip to content

← Modelling Data with Dataclasses step 11 of 28

Hard Framework

Predict Which Dataclasses Are Hashable

You are about to use seven record classes as dictionary keys, and you want to know which ones raise TypeError: unhashable type before a user finds out. The article Why Some Objects Can Go in a Set explained why a plain dataclass has no hash and a frozen one does. Here you write a function that predicts the answer from the class alone, without building an object.

solve("Partial")
# {"hashable": True, "reason": "eq-and-frozen", "hashed_fields": ["a"]}

What the decorator does to __hash__

For a class that does not define __hash__ itself, three options decide what happens:

eq frozen unsafe_hash What the decorator does
True True False Writes a __hash__ from the fields
True False False Sets __hash__ to None
False either False Leaves __hash__ alone, so the class keeps the one it inherits
either either True Writes a __hash__ from the fields

eq=True and unsafe_hash=False are the defaults. Identity has eq=False and inherits from object, so it keeps the identity hash.

A generated __hash__ can still fail. It hashes the fields together, so a field that holds a list raises TypeError. FrozenList has such a field.

What to write

Fill in hashability_report(cls). Do not create instances, and do not change the classes. Apply these steps in order, and stop at the first one that returns:

  1. If getattr(cls, "__hash__", None) is None, return False with the reason "eq-without-frozen".
  2. If "__hash__" is not a key of cls.__dict__, the class inherited its hash. Return True with the reason "eq-disabled-identity-hash".
  3. Collect the fields that take part in the hash, in dataclasses.fields order. A field takes part when its hash option is True, or when hash is None and compare is True. A field with hash=False never takes part.
  4. Get the annotations with get_type_hints(cls). For each field from step 3, take get_origin(annotation) or annotation. If that is list, dict, set or bytearray, return False with the reason "unhashable-field-type".
  5. Otherwise, return True with the field names from step 3. The reason is "unsafe-hash" when _params(cls).unsafe_hash is True, and "eq-and-frozen" otherwise.

Return a dictionary with exactly the keys "hashable", "reason" and "hashed_fields". hashed_fields is an empty list in steps 1, 2 and 4. Keep the field names in declaration order, not sorted. Keep solve as it is. It takes a name from REGISTRY and calls your function.

Expected reports

Target hashable reason hashed_fields
Mutable False eq-without-frozen []
Frozen True eq-and-frozen ["a", "b"]
Identity True eq-disabled-identity-hash []
Unsafe True unsafe-hash ["a"]
FrozenList False unhashable-field-type []
Partial True eq-and-frozen ["a"]
Override True eq-and-frozen ["a", "b"]

Partial.cached has compare=False and the default hash=None, so it takes part in neither == nor the hash. Override.b also has compare=False, but hash=True puts it in the hash.

Two classes not to copy

hashable: True means hash() succeeds. It does not mean the class is safe as a key.

  • Override breaks the rule that equal objects have equal hashes. Override(1, "x") == Override(1, "y") is True, but their hashes differ, because b is in the hash and not in ==.
  • Unsafe lets you change a field after you store the object in a set, and then the set cannot find it.

Type checking

Your code must pass mypy --strict. Read the hash through getattr, as in step 1. _params(cls) returns the options the decorator received, so _params(Unsafe).unsafe_hash is True. Its return type, DataclassParams, only tells mypy which attributes the object has.

DataclassInstance is a type that exists only for the type checker and means "any dataclass instance". That is why the starter imports it under if TYPE_CHECKING: and writes it in quotes.