Skip to content

Commit 4d7c3c5

Browse files
committed
Introduce a functools.cached_method decorator
resolves #102618 This definition of `cached_method` is based on the discussion on DPO: https://discuss.python.org/t/107164 Some tradeoffs need to be made in any version of such a decorator. In this particular implementation, the choices made are as follows: - lru_cache will be used under the hood, and `cache_clear()` and `cache_info()` will be exposed - caches will be stored in a separate dict, indexed by `id(self)` -- meaning instances do not need to be hashable and will not share caches - weakrefs will be used to delete entries from the cache, so the instances must be weak-referencable - lru_cache itself is not threadsafe, but initialization of the caches is threadsafe -- this avoids confusing scenarios in which cache entries "disappear" New documentation is included, marked for 3.16, and a small number of new tests are added.
1 parent acefff9 commit 4d7c3c5

3 files changed

Lines changed: 180 additions & 1 deletion

File tree

Doc/library/functools.rst

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,49 @@ The :mod:`!functools` module defines the following functions:
122122
Python 3.12+ this locking is removed.
123123

124124

125+
.. decorator:: cached_method(func)
126+
127+
Decorator to wrap a method with a bounded or unbounded cache.
128+
129+
When :func:`cache` or :func:`lru_cache` are used on an instance method, the
130+
instance (``self``) will be stored in the cache. As a result, instances cannot be
131+
garbage collected until the relevant caches are cleared.
132+
This decorator uses :func:`lru_cache`, but it wraps the unbound method to accept
133+
a weakref and ensures that caches are cleared when instances are garbage collected.
134+
135+
This is useful for expensive computations which are consistent with respect to
136+
an instance, e.g., those which depend only on immutable attributes.
137+
138+
Example::
139+
140+
class DataSet:
141+
142+
def __init__(self, sequence_of_ints):
143+
self._data = tuple(sequence_of_ints)
144+
145+
@cached_method
146+
def shifted(self, shift):
147+
return DataSet([value + shift for value in self._data])
148+
149+
On instances, :func:`cached_method` behaves very similarly to :func:`cache`,
150+
providing :func:`cache_info()` and :func:`cache_clear()`.
151+
152+
The *cached_method* does not prevent all possible race conditions in
153+
multi-threaded usage. The function could run more than once on the
154+
same instance, with the same inputs, with the latest run setting the cached
155+
value. However, initialization of the cached method, which happens lazily on
156+
first access, is itself threadsafe.
157+
158+
This decorator requires that the each instance supports weak references.
159+
Some immutable types and slotted classes without ``__weakref__`` as one of
160+
the defined slots will encounter errors when the cached method is first used.
161+
162+
*maxsize* and *typed* are supported as keyword arguments to the decorator,
163+
and are passed to the underlying :func:`lru_cache()`.
164+
165+
.. versionadded:: 3.16
166+
167+
125168
.. function:: cmp_to_key(func)
126169

127170
Transform an old-style comparison function to a :term:`key function`. Used

Lib/functools.py

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
from abc import get_cache_token
1818
from collections import namedtuple
19-
# import weakref # Deferred to single_dispatch()
19+
# import weakref # Deferred to single_dispatch() and cached_method()
2020
from operator import itemgetter
2121
from reprlib import recursive_repr
2222
from types import FunctionType, GenericAlias, MethodType, MappingProxyType, UnionType
@@ -1183,3 +1183,89 @@ def __get__(self, instance, owner=None):
11831183
return val
11841184

11851185
__class_getitem__ = classmethod(GenericAlias)
1186+
1187+
################################################################################
1188+
### cached_method -- a version of lru_cache() which uses `id(self)`
1189+
################################################################################
1190+
1191+
1192+
def _cached_method_weakref_callback(cache_dict, id_key):
1193+
def callback(ref):
1194+
cache_dict.pop(id_key)
1195+
return callback
1196+
1197+
1198+
def _wrap_unbound_cached_method(ref, unbound_method, maxsize, typed):
1199+
@lru_cache(maxsize, typed)
1200+
def wrapped(*args, **kwargs):
1201+
return unbound_method(ref(), *args, **kwargs)
1202+
return wrapped
1203+
1204+
1205+
class cached_method:
1206+
"""
1207+
A caching decorator for use on instance methods.
1208+
1209+
Using cache or lru_cache on methods is problematic because the instance is put into
1210+
the cache and cannot be garbage collected until the cache is cleared. This decorator
1211+
uses a cache based on `id(self)` and a weakref to clear cache entries.
1212+
1213+
The instance must be weak-referencable.
1214+
1215+
By default, this provides an infinite sized cache similar to functools.cache. Use
1216+
*maxsize* and *typed* to set these attributes of the underlying LRU cache.
1217+
"""
1218+
def __init__(self, func=None, /, maxsize=None, typed=False):
1219+
self.func = None
1220+
self._maxsize = maxsize
1221+
self._typed = typed
1222+
self._function_table = {}
1223+
# we need a lock when initializing per-instance caches
1224+
self._cache_init_lock = RLock()
1225+
1226+
if func is not None:
1227+
self.func = func
1228+
update_wrapper(self, func)
1229+
1230+
def __call__(self, func):
1231+
if self.func is not None:
1232+
raise TypeError(
1233+
"Each cached_method decorator can only apply to one function."
1234+
)
1235+
self.func = func
1236+
update_wrapper(self, func)
1237+
return self
1238+
1239+
def __get__(self, instance, owner=None):
1240+
# similar to singledispatch(), we want to defer use of weakref until/unless it
1241+
# is needed
1242+
import weakref
1243+
1244+
if instance is None:
1245+
return self
1246+
1247+
instance_id = id(instance)
1248+
1249+
# first try to retrieve the cached func without locking (thus avoiding any
1250+
# unnecessary contention when there is a value), but then retry
1251+
# under a lock to actually provide safety such that two parallel threads won't
1252+
# construct distinct caches simultaneously
1253+
try:
1254+
ref, cached_func = self._function_table[instance_id]
1255+
except KeyError:
1256+
with self._cache_init_lock:
1257+
try:
1258+
ref, cached_func = self._function_table[instance_id]
1259+
except KeyError:
1260+
ref = weakref.ref(
1261+
instance,
1262+
_cached_method_weakref_callback(
1263+
self._function_table, instance_id
1264+
),
1265+
)
1266+
cached_func = _wrap_unbound_cached_method(
1267+
ref, self.func, self._maxsize, self._typed
1268+
)
1269+
self._function_table[instance_id] = ref, cached_func
1270+
1271+
return cached_func

Lib/test/test_functools.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3810,5 +3810,55 @@ def prop(self):
38103810
self.assertEqual(t.prop, 1)
38113811

38123812

3813+
class CachedValueAdder:
3814+
def __init__(self, value):
3815+
self.value = value
3816+
3817+
@py_functools.cached_method
3818+
def add(self, other):
3819+
return self.value + other
3820+
3821+
3822+
class TestCachedMethod(unittest.TestCase):
3823+
module = py_functools
3824+
3825+
def test_cached_usage(self):
3826+
one = CachedValueAdder(1)
3827+
self.assertEqual(one.add(2), 3)
3828+
one.value = 2
3829+
self.assertEqual(one.add(2), 3) # still 3, not 4
3830+
one.add.cache_clear()
3831+
self.assertEqual(one.add(2), 4) # now 4
3832+
3833+
def test_cache_info(self):
3834+
one = CachedValueAdder(1)
3835+
self.assertEqual(one.add.cache_info(),
3836+
self.module._CacheInfo(hits=0, misses=0, maxsize=None, currsize=0))
3837+
for _ in range(3):
3838+
for i in range(10):
3839+
one.add(i)
3840+
self.assertEqual(one.add.cache_info(),
3841+
self.module._CacheInfo(hits=20, misses=10, maxsize=None, currsize=10))
3842+
one.add.cache_clear()
3843+
self.assertEqual(one.add.cache_info(),
3844+
self.module._CacheInfo(hits=0, misses=0, maxsize=None, currsize=0))
3845+
3846+
def test_reapplication_causes_type_error(self):
3847+
with self.assertRaisesRegex(
3848+
TypeError,
3849+
r"Each cached_method decorator can only apply to one function\.",
3850+
):
3851+
decorator = py_functools.cached_method()
3852+
3853+
class MyObject:
3854+
@decorator
3855+
def a(self):
3856+
return None
3857+
3858+
@decorator
3859+
def b(self):
3860+
return None
3861+
3862+
38133863
if __name__ == '__main__':
38143864
unittest.main()

0 commit comments

Comments
 (0)