This looks harmless and is the most expensive four characters in the file:
class Digest:
def __init__(self, salt: int) -> None:
self.salt = salt
@lru_cache(maxsize=128)
def value(self, n: int) -> int:
return (self.salt + n) ** 2
lru_cache is applied to the function, at class-creation time, so there
is exactly one cache and it lives on the class. The method is called as
instance.value(n), which means the cache key is (instance, n) — self is
an ordinary first argument as far as the decorator is concerned.
Two consequences follow, and both are bugs.
It leaks
The cache holds a strong reference to every self it has ever seen. In a
long-lived service that caches something per request object, per session, per
connection, the instances never become garbage. Verified: after del h and
gc.collect(), a weakref to the instance is still live and the instance is
still visible in cache_info().
The fingerprint in a profile is distinctive and confusing: a growing
population of objects that nothing in your code references, held by a
functools internal, with no obvious owner. People spend hours looking for a
missing close().
maxsize is global across instances
There is one cache of 128 entries for the whole class, not 128 per instance. Ten live objects share it, so each effectively gets 12 slots and they evict each other. The hit rate collapses in production — where there are many instances — while looking perfect in a unit test, where there is one.
So it is a correctness-of-performance bug as well as a memory bug, and the two failure modes point in opposite directions: too much retention, too little caching.
💡self is part of the key, so the cache needs Digest to be
click to reveal
hashable. What happens to the whole scheme if Digest defines __eq__?
Defining __eq__ without __hash__ sets __hash__ = None, so the class
becomes unhashable and every cached call raises
TypeError: unhashable type: 'Digest'. Loud, immediate, easy to fix.
Defining both is much worse. Now two distinct but equal instances share
a cache entry. Digest(salt=1) and a second Digest(salt=1) are different
objects with the same state, so the second one silently receives values
computed by and for the first. If the class is genuinely immutable that is
merely surprising; if any mutable state feeds the computation, the cache
returns stale results with no way to notice.
This is the deeper reason the pattern is wrong: caching on self couples your
memoisation to your equality semantics, which are two completely unrelated
design decisions.
The fix
Give each instance its own cache, so it dies with the instance:
class Digest:
def __init__(self, salt: int) -> None:
self.salt = salt
self._memo: dict[int, int] = {}
def value(self, n: int) -> int:
if n not in self._memo:
self._memo[n] = (self.salt + n) ** 2
return self._memo[n]
A plain dict is usually enough and is trivially readable. If you want LRU
eviction per instance, build the cached callable in __init__
(self._value = lru_cache(maxsize=128)(self._compute)) — that closes over
self and creates a reference cycle, which the cyclic collector handles, so
the instance still dies at the next gc.collect().
For a class-wide cache that genuinely should be shared, make the method a
@staticmethod or a module-level function and cache that — then self is not
in the key and there is nothing to leak.
The type checker says nothing
Not a warning, on any of it. Combined with the signature erasure from the
previous item, a cached method is doubly invisible: its parameters are not
checked, and its retention behaviour is not expressible. This is a good place
to internalise the general point — --strict proves things about types, and
“this object outlives its scope” is not a type.
💡Where else does this pattern appear? @cached_property also
click to reveal
caches per method — is it affected?
No, and the contrast is instructive. cached_property stores the computed
value in the instance’s __dict__, under the property’s name. The cache
is therefore part of the instance, it dies with the instance, and there is no
key involving self at all.
The family resemblance is misleading in the other direction too:
cached_property has its own hazards (it needs a __dict__, so it crashes on
a __slots__ class, and the per-property lock was removed in 3.12 so it
can now compute twice under threading). Different decorator, different
failure modes.
The general lesson is to ask where does the cached value live? for every memoisation tool you adopt. Class-level storage keyed on the instance leaks; instance-level storage does not.