We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Data Modelling and Invariants step 25 of 25
__slots__, part 2: the eight things it breaks
@dataclass(slots=True) cannot modify a class in place — __slots__ has to
exist before the class object is created. So the decorator builds a brand-new
class object from the old one’s namespace and returns that. Every breakage
below is a consequence of that one implementation fact, and every one of them is
a hard runtime failure produced by a change that looks cosmetic in review.
The eight things it breaks
-
Class-attribute defaults become slot descriptors.
getattr(cls, "x")no longer returns the default; it returns amember_descriptor. Anything doing “read the class to find the default” silently changes behaviour. -
Decorators applied below
@dataclass(slots=True)are stale. They ran on the old class object; the name now points at the new one. Same for any registry populated from the class body. -
Zero-argument
super()raisesTypeError— the implicit__class__cell still points at the original class, which is no longer in the MRO. This is gh-90562, fixed only in 3.14. A class that works on 3.14 raises on 3.13, with no static warning. -
functools.cached_propertyraises — it caches intoself.__dict__, and there isn’t one. -
No weak references unless you also pass
weakref_slot=True(3.11+).WeakSet,WeakValueDictionaryand most observer registries stop working. -
Two slotted classes cannot be multiply inherited —
TypeError: multiple bases have instance lay-out conflict. Note the timing: the diamond is legal today, and becomes illegal the moment the second base getsslots=True. -
__init_subclass__with parameters is aTypeError(gh-91126). -
__slots__has not been the field list since 3.11 — inherited names are excluded, so a subclass’s__slots__is a subset of its fields. Readfields(), never__slots__.
What it actually saves — and does not
Measured on 3.14.6, one million three-int instances, tracemalloc:
106.5 B/instance with __dict__ vs 74.2 B with slots — about 30%, not 10×.
The folk claim is a decade out of date: PEP 412 key-sharing plus 3.11’s lazily
created instance namespaces already recovered most of it. Attribute reads are
3.9 ns vs 3.8 ns — no difference at all.
Where slots still wins big is when key sharing breaks: touch obj.__dict__
directly and the dict form jumps to 170.6 B; give instances divergent attribute
sets and it goes to 373.8 B. Then slots is 5×. (And sys.getsizeof is the
wrong tool here — it reports the object without its dict. Use tracemalloc.)
The real reason to reach for it is static, not dynamic. Under mypy,
slots=True makes a typo’d self.totl = 1 an error. On a plain dataclass, mypy
accepts any method inventing any new attribute.
Your task
Implement:
def slots_safety_check(
cls: type[DataclassInstance],
target_python: tuple[int, int],
migrating: frozenset[str],
) -> list[str]:
returning the sorted, de-duplicated reasons that adding slots=True to cls
would break it. migrating names the classes your migration is about to slot —
you need it because breakage 6 depends on what the bases will become, not on what
they are today.
Detect exactly these, with exactly these strings:
| reason | detection |
|---|---|
"cached-property" |
a functools.cached_property in vars(cls) |
"zero-arg-super" |
a method whose __code__.co_freevars contains "__class__" — only when target_python < (3, 14) |
"instance-dict-access" |
a method whose __code__.co_names contains "__dict__" |
"set-name-descriptor" |
a value in vars(cls) whose type defines __set_name__ (excluding cached_property, already reported) |
"init-subclass-kwargs" |
an __init_subclass__ taking more than just cls |
"multiple-slotted-bases" |
more than one non-object base that is either in migrating or already has a non-empty __slots__ |
solve(target, target_python, migrating) adapts the JSON inputs (a two-element
list and a list of names) to that signature.
Why co_freevars and co_names? Because super() with no arguments is
compiled into a closure over an implicit __class__ cell — the freevar is the
only reliable trace of it, and it is also present for a bare __class__
reference. co_names holds the global and attribute names a code object touches,
which is how self.__dict__ shows up without parsing source.
Types. Reaching a function through classmethod/staticmethod wrappers
needs getattr(value, "__func__", value); narrowing the result with
isinstance(inner, FunctionType) is what keeps Any out of the helper’s return
type. And note what mypy models here: __slots__ membership, and none of the
eight breakages. This is the clearest demonstration in the whole course of
where static analysis ends and knowing the runtime begins.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.