Why does a cached total on a `list` subclass go stale when only `append` is overridden?
answer
- the cache tracks only one path
- which mutators are C code?
- extend and += never call append
- UserList delegates to self.data, not self.append
- wrap it, expose two methods
basics
~20 sBuilt-in list mutators are C code that never calls your Python append: extend, +=, insert, slice assignment and sort change the contents behind the override, so the cache is never invalidated. Subclassing list gives you no single mutation hook.
solid answer
~50 sOverriding `append` on a `list` subclass intercepts exactly one mutation path. `list.extend`, `list.insert`, `list.__iadd__`, `list.__setitem__` with a slice, `list.__delitem__` and `list.sort` are all implemented in C and write to the internal array directly, without routing through any Python-level method you defined — so every call other than a literal `append` leaves the cached value describing data that no longer exists. `collections.UserList` improves the picture, because every method is Python and can be overridden individually, but its own `extend` calls `self.data.extend` rather than `self.append`, so overriding one method still does not cover the others: you would have to override roughly a dozen entry points. The design that actually holds is composition — keep a private list, expose only the mutations the caller needs, and invalidate or recompute there — or drop the cache and compute the value lazily, which cannot go stale by construction.
code
python · 18 linesclass PendingJobs(list):
def __init__(self, items=()):
super().__init__(items)
self._total = sum(items)
def append(self, item):
super().append(item)
self._total += item
@property
def total(self):
return self._total
jobs = PendingJobs([100, 200])
jobs.extend([300])
jobs += [400]
print(jobs.total, sum(jobs)) # 300 1000go deeper
Remember that built-in methods are C code and do not call your Python overrides. If you override append on a list subclass, extend and += still change the contents without telling you.
Name the bypassing paths concretely — extend, insert, __iadd__, slice assignment, sort — and explain what collections.UserList does and does not buy: overridable Python methods and subclass-preserving slices, but still no single choke point.
Diagnose it from the symptom: a derived value that drifts during a long run, with no exception. Then argue the design fix — a wrapper with a narrow mutation API, or a lazily recomputed value — and the test that covers the whole mutation surface.
Own the guidance: when a codebase may inherit from built-in containers at all, what the review rule is for cached derived state, and how much per-operation overhead a wrapper type is allowed to cost on the hot paths that matter.
## The scenario An image-thumbnail worker keeps its pending jobs in a `list` subclass that caches the total pixel count so the batch sizer does not recompute it on every tick: ```python class PendingJobs(list): def __init__(self, items=()): super().__init__(items) self._total = sum(items) def append(self, item): super().append(item) self._total += item @property def total(self): return self._total ``` It is correct in the unit test, which appends. In the six-hour nightly run the queue is filled by `jobs.extend(batch)` and `jobs += retries`, trimmed with `del jobs[:100]`, and reordered with `jobs.sort()`. `total` stays at whatever the appends left behind, the sizer allocates from a stale number, and the failure shows up hours in as thumbnails produced at the wrong batch size — with no exception anywhere to point at the cause. ## Why the override is bypassed `list` is a C type. Its mutating methods manipulate the underlying pointer array directly; they do not perform a Python-level attribute lookup for `append` and call it. That is a deliberate design choice — it is what makes list operations fast — but it means a Python subclass has no interception point: - `extend`, `insert`, `remove`, `pop`, `clear`, `sort`, `reverse` — all C, all direct. - `+=` goes through `list.__iadd__`, which extends in place. - `jobs[0] = x`, `jobs[:100] = xs` and `del jobs[0]` go through the C slot functions. The general rule: **inheriting from a built-in container inherits its methods, not its call graph.** You can override any single method, but you cannot assume the built-in's other methods will call the one you overrode. The same shape appears whenever people subclass a built-in container and override one accessor or one mutator, expecting the rest of the type to funnel through it. ## Does `collections.UserList` fix it? Partly, and it is worth being precise about how much. `UserList` is written in Python and keeps the real list in an ordinary attribute named `data`. Every method is therefore overridable, and slicing and `+` return `self.__class__` instead of a plain list. But its methods delegate to the wrapped list, not to each other: its `extend` is `self.data.extend(other)`, its `__iadd__` is `self.data += other`, its `insert` is `self.data.insert(...)`. Overriding `append` on a `UserList` subclass still does not catch `extend`. So the honest answer is that `UserList` gives you the *ability* to intercept everything, at the cost of doing so explicitly: `append`, `insert`, `extend`, `pop`, `remove`, `clear`, `sort`, `reverse`, `__setitem__`, `__delitem__`, `__iadd__`, `__imul__`. Twelve overrides, and a reviewer has to notice if a thirteenth appears. It is also several times slower per operation than a built-in list, and it is not a `list` — `isinstance(jobs, list)` is false, and code that special-cases lists, including `json.dumps`, will not accept it. ## The designs that hold **Composition with a narrow API.** Do not be a list; hold one. Expose `add`, `take_batch` and `__len__` — the three operations the worker actually performs — and maintain the total inside them. Nothing else can mutate the collection, so nothing else can invalidate the cache. Everything you did not expose is a mutation that can no longer surprise you. **No cache at all.** Compute `sum(self._jobs)` on demand, or keep it as a lazily-computed value cleared by the narrow API. A derived value that is recomputed cannot be stale, and for a queue of a few thousand integers the cost is invisible next to decoding one image. **Immutable snapshots.** If the batch sizer needs a consistent view, hand it a tuple built at the moment of the decision, with the total computed alongside it. Value and total travel together and cannot drift apart. ## Proving it in a test The test that would have caught this exercises every public mutation path and asserts the derived value against a fresh recomputation, rather than testing `append` alone. That is also the review heuristic: whenever a cached attribute is invalidated in an overridden method of a container, ask what else can change the container. On a built-in subclass, the answer is almost always "a dozen things, none of which call you".
- If you kept `collections.UserList`, which methods would you have to override to catch every mutation?`append`, `insert`, `extend`, `pop`, `remove`, `clear`, `sort`, `reverse`, `__setitem__`, `__delitem__`, `__iadd__` and `__imul__` — each reaches the wrapped list independently rather than calling one another. Enumerating twelve entry points, and relying on reviewers to spot a thirteenth, is itself the argument for exposing a narrow wrapper instead.
- How would you write a test that catches this class of bug?Parametrize over every public mutation the type offers — append, extend, in-place add, insert, slice assignment, delete, sort — and after each one assert the cached value equals a fresh recomputation from the current contents. The point is coverage of the mutation surface, not of one method; a test that only appends passes on broken code.
- Would making the container immutable solve it?Yes, by construction: if every change produces a new object, the cached total is computed once per version and can never describe stale contents. The cost is an allocation and a recomputation per mutation, which is fine for a queue mutated in batches and wrong for one mutated per item in a hot loop.
saying these in an interview costs you the question
- Assumes overriding append also intercepts extend and +=
- Thinks collections.UserList routes every mutation through append
- Blames threading before checking the mutation paths
- Proposes calling an invalidate() helper at every call site
- Says the cache is safe because the run is single-threaded
- Claims a list subclass can hook mutation via __setattr__