Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions Doc/library/functools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,51 @@ The :mod:`!functools` module defines the following functions:
.. versionadded:: 3.9


.. decorator:: cached_method(func)
cached_method(maxsize=None, typed=False)

Decorator to wrap a method with a bounded or unbounded cache.

When :func:`cache` or :func:`lru_cache` are used on an instance method, the
instance (``self``) will be stored in the cache. As a result, instances cannot be
garbage collected until the relevant caches are cleared.
This decorator uses :func:`lru_cache`, but it wraps the unbound method to accept
a weakref and ensures that caches are cleared when instances are garbage collected.

This is useful for expensive computations which are consistent with respect to
an instance, for example, those which depend only on immutable attributes.

Example::

class DataSet:

def __init__(self, sequence_of_ints):
self._data = tuple(sequence_of_ints)

@cached_method
def shifted(self, shift):
return DataSet([value + shift for value in self._data])

On instances, :func:`cached_method` behaves very similarly to :func:`cache`,
providing :func:`!cache_info` and :func:`!cache_clear`.

:func:`cached_method` does not prevent all possible race conditions in
multi-threaded usage. The function could run more than once on the
same instance, with the same inputs, with the latest run setting the cached
value. The per-instance cache is lazily initialized on first access (via the
descriptor protocol), so parallel access on a single instance can race to
initialize.

This decorator requires that the instance supports weak references.
Some immutable types and slotted classes without ``__weakref__`` as one of
the defined slots will encounter errors when the cached method is first used.

*maxsize* and *typed* are supported as keyword arguments to the decorator,
and are passed to the underlying :func:`lru_cache`.

.. versionadded:: next


.. decorator:: cached_property(func)

Transform a method of a class into a property whose value is computed once
Expand Down
8 changes: 8 additions & 0 deletions Doc/whatsnew/3.16.rst
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,14 @@ encodings
(Contributed by Serhiy Storchaka in :gh:`66788`.)


functools
---------

* Added :func:`functools.cached_method` decorator for applying a cache to
instance methods.
(Contributed by Stephen Rosen in :gh:`150002`.)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
(Contributed by Stephen Rosen in :gh:`150002`.)
(Contributed by Stephen Rosen in :gh:`102618`.)

Issue is preferred here IIRC, right Hugo?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is that right? That seems odd. "Contributed by X in Y" suggests to me that "Y" is the implementation (PR), not specification (issue).



gzip
----

Expand Down
89 changes: 82 additions & 7 deletions Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
__all__ = ['update_wrapper', 'wraps', 'WRAPPER_ASSIGNMENTS', 'WRAPPER_UPDATES',
'total_ordering', 'cache', 'cmp_to_key', 'lru_cache', 'reduce',
'partial', 'partialmethod', 'singledispatch', 'singledispatchmethod',
'cached_property', 'Placeholder']
'cached_method', 'cached_property', 'Placeholder']

from abc import get_cache_token
from collections import namedtuple
# import weakref # Deferred to single_dispatch()
from operator import itemgetter
from reprlib import recursive_repr
from types import FunctionType, GenericAlias, MethodType, MappingProxyType, UnionType
from _thread import RLock
lazy import weakref

################################################################################
### update_wrapper() and wraps() decorator
Expand Down Expand Up @@ -907,11 +907,6 @@ def singledispatch(func):
implementations can be registered using the register() attribute of the
generic function.
"""
# There are many programs that use functools without singledispatch, so we
# trade-off making singledispatch marginally slower for the benefit of
# making start-up of such applications slightly faster.
import weakref

registry = {}
dispatch_cache = weakref.WeakKeyDictionary()
cache_token = None
Expand Down Expand Up @@ -1132,6 +1127,86 @@ def __wrapped__(self):
def register(self):
return self._unbound.register

################################################################################
### cached_method -- a version of lru_cache() which uses id(self)
################################################################################


def _cached_method_weakref_callback(cache_dict, id_key):
def callback(ref):
cache_dict.pop(id_key, None)
return callback


def _wrap_unbound_cached_method(ref, unbound_method, maxsize, typed):
@lru_cache(maxsize, typed)
def wrapped(*args, **kwargs):
return unbound_method(ref(), *args, **kwargs)
return wrapped


class _cached_method:
"""
A caching decorator for use on instance methods.

Using cache or lru_cache on methods is problematic because the instance is put into
the cache and cannot be garbage collected until the cache is cleared. This decorator
uses a cache based on `id(self)` and a weakref to clear cache entries.

The instance must be weak-referencable.

By default, this provides an infinite sized cache similar to functools.cache. Use
*maxsize* and *typed* to set these attributes of the underlying LRU cache.
"""
def __init__(self, func, /, maxsize=None, typed=False):
self._function_table = {}

self._maxsize = maxsize
self._typed = typed

self.func = func
update_wrapper(self, func)

def __call__(self, instance, *args, **kwargs):
cached_func = self._get_or_create_cached_func(instance)
return cached_func(*args, **kwargs)

def __get__(self, instance, owner=None):
if instance is None:
return self
return self._get_or_create_cached_func(instance)

def _get_or_create_cached_func(self, instance):
instance_id = id(instance)

try:
ref, cached_func = self._function_table[instance_id]
except KeyError:
ref = weakref.ref(
instance,
_cached_method_weakref_callback(
self._function_table, instance_id
),
)
cached_func = _wrap_unbound_cached_method(
ref, self.func, self._maxsize, self._typed
)
self._function_table[instance_id] = ref, cached_func

return cached_func


def cached_method(func=None, /, maxsize=None, typed=False):
Comment thread
sirosen marked this conversation as resolved.
if maxsize is not None and not isinstance(maxsize, int) and not callable(maxsize):
raise TypeError(
'Expected maxsize to be an integer, a callable, or None')
if func is None:
def decorator(func):
return _cached_method(func, maxsize=maxsize, typed=typed)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lru_cache does a little dance to ensure maxsize is not junk:

cpython/Lib/functools.py

Lines 586 to 600 in 1604043

if isinstance(maxsize, int):
# Negative maxsize is treated as 0
if maxsize < 0:
maxsize = 0
elif callable(maxsize) and isinstance(typed, bool):
# The user_function was passed in directly via the maxsize argument
user_function, maxsize = maxsize, 128
wrapper = _lru_cache_wrapper(user_function, maxsize, typed, _CacheInfo)
wrapper.cache_parameters = lambda : {'maxsize': maxsize, 'typed': typed}
return update_wrapper(wrapper, user_function)
elif maxsize is not None:
raise TypeError(
'Expected first argument to be an integer, a callable, or None')

I think we should do it here too to avoid confusing errors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It came out slightly differently in 8086aa5 because the signatures are a bit different, but the idea is the same.

cached_method is able to have a slightly better signature because it uses a positional-only param, not having the burdens of history.

return decorator
else:
return _cached_method(func, maxsize=maxsize, typed=typed)


################################################################################
### cached_property() - property result cached as instance attribute
Expand Down
60 changes: 60 additions & 0 deletions Lib/test/test_functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -3810,5 +3810,65 @@ def prop(self):
self.assertEqual(t.prop, 1)


class CachedValueAdder:
def __init__(self, value):
self.value = value

@py_functools.cached_method
def add(self, other):
return self.value + other


class TestCachedMethod(unittest.TestCase):
module = py_functools

def test_cached_usage(self):
one = CachedValueAdder(1)
self.assertEqual(one.add(2), 3)
one.value = 2
self.assertEqual(one.add(2), 3) # still 3, not 4
one.add.cache_clear()
self.assertEqual(one.add(2), 4) # now 4

def test_cache_info(self):
one = CachedValueAdder(1)
self.assertEqual(one.add.cache_info(),
self.module._CacheInfo(hits=0, misses=0, maxsize=None, currsize=0))
for _ in range(3):
for i in range(10):
one.add(i)
self.assertEqual(one.add.cache_info(),
self.module._CacheInfo(hits=20, misses=10, maxsize=None, currsize=10))
one.add.cache_clear()
self.assertEqual(one.add.cache_info(),
self.module._CacheInfo(hits=0, misses=0, maxsize=None, currsize=0))

def test_reapplication_is_allowed(self):
decorator = py_functools.cached_method(maxsize=10)

class MyObject:
@decorator
def a(self):
return 1

@decorator
def b(self):
return 2

x = MyObject()
self.assertEqual(x.a(), 1)
self.assertEqual(x.b(), 2)

def test_cached_method_under_property(self):
class MyObject:
@property
@py_functools.cached_method
def foo(self):
return 1

x = MyObject()
self.assertEqual(x.foo, 1)


if __name__ == '__main__':
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Added a new ``functools.cached_method`` decorator, which uses weakrefs and
avoids putting ``self`` into the cache.
Loading