Skip to content
Merged
28 changes: 28 additions & 0 deletions Doc/library/functools.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,34 @@ function for the purposes of this module.

The :mod:`functools` module defines the following functions:

.. decorator:: cached_property(func)

Transform a method of a class into a property whose value is computed once
and then cached as a normal attribute for the life of the instance. Similar
to :func:`property`, with the addition of caching. Useful for expensive
computed properties of instances that are otherwise effectively immutable.

Example::

class DataSet:
def __init__(self, sequence_of_numbers):
self._data = sequence_of_numbers

@cached_property
def stdev(self):
return statistics.stdev(self._data)

@cached_property
def variance(self):
return statistics.variance(self._data)

.. versionadded:: 3.8

.. note::

This decorator does not support classes which define ``__slots__``.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd word this note slightly differently, as there are multiple ways to end up with instances that don't have a mutable mapping as their __dict__ attribute, and you can also include __dict__ in a __slots__ list if you want the fast C level access for selected fields, but aren't worried about the per-instance dict memory overhead.

Something like:

This decorator requires that the __dict__ attribute on each instance be a mutable mapping. This means it will not work with some types, such as metaclasses (since the __dict__ attributes on type instances are read-only proxies for the class namespace), and those that specify __slots__ without including __dict__ as one of the defined slots (as such classes don't provide a __dict__ attribute at all).



.. function:: cmp_to_key(func)

Transform an old-style comparison function to a :term:`key function`. Used
Expand Down
47 changes: 47 additions & 0 deletions Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,3 +868,50 @@ def _method(*args, **kwargs):
@property
def __isabstractmethod__(self):
return getattr(self.func, '__isabstractmethod__', False)


################################################################################
### cached_property() - computed once per instance, cached as attribute
################################################################################

_NOT_FOUND = object()


class cached_property:
def __init__(self, func):
self.func = func
self.attrname = None
self.__doc__ = func.__doc__
self.lock = RLock()

def __set_name__(self, owner, name):
if self.attrname is None:
self.attrname = name
elif name != self.attrname:
raise TypeError(
"Cannot assign the same cached_property to two different names "
f"({self.attrname!r} and {name!r})."
)

def __get__(self, instance, owner):
if instance is None:
return self
if self.attrname is None:
raise TypeError(
"Cannot use cached_property instance without calling __set_name__ on it.")
try:
cache = instance.__dict__
except AttributeError: # objects with __slots__ have no __dict__

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Tweak the comment here along similar lines to the note in the docs

msg = (
f"No '__dict__' attribute on {type(instance).__name__!r} "
f"instance to cache {self.attrname!r} property."
)
raise TypeError(msg) from None
val = cache.get(self.attrname, _NOT_FOUND)
if val is _NOT_FOUND:
with self.lock:
# check if another thread filled cache while we awaited lock
val = cache.get(self.attrname, _NOT_FOUND)
if val is _NOT_FOUND:
val = cache[self.attrname] = self.func(instance)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Similar to the AttributeError above, it's likely worth wrapping TypeError here to account for cases like:

>>> str.__dict__["x"] = 1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'mappingproxy' object does not support item assignment

return val
151 changes: 151 additions & 0 deletions Lib/test/test_functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2313,5 +2313,156 @@ def f(*args):
with self.assertRaisesRegex(TypeError, msg):
f()


class CachedCostItem:
_cost = 1

def __init__(self):
self.lock = py_functools.RLock()

@py_functools.cached_property
def cost(self):
"""The cost of the item."""
with self.lock:
self._cost += 1
return self._cost


class OptionallyCachedCostItem:
_cost = 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is this needed? It seems to be always shadowed in __init__().


def get_cost(self):
"""The cost of the item."""
self._cost += 1
return self._cost

cached_cost = py_functools.cached_property(get_cost)


class CachedCostItemWait:

def __init__(self, event):
self._cost = 1
self.lock = py_functools.RLock()
self.event = event

@py_functools.cached_property
def cost(self):
self.event.wait(1)
with self.lock:
self._cost += 1
return self._cost


class CachedCostItemWithSlots:
__slots__ = ('_cost')

def __init__(self):
self._cost = 1

@py_functools.cached_property
def cost(self):
raise RuntimeError('never called, slots not supported')


class TestCachedProperty(unittest.TestCase):
def test_cached(self):
item = CachedCostItem()
self.assertEqual(item.cost, 2)
self.assertEqual(item.cost, 2) # not 3

def test_cached_attribute_name_differs_from_func_name(self):
item = OptionallyCachedCostItem()
self.assertEqual(item.get_cost(), 2)
self.assertEqual(item.cached_cost, 3)
self.assertEqual(item.get_cost(), 4)
self.assertEqual(item.cached_cost, 3)

def test_threaded(self):
go = threading.Event()
item = CachedCostItemWait(go)

num_threads = 3

orig_si = sys.getswitchinterval()
sys.setswitchinterval(1e-6)
try:
threads = [
threading.Thread(target=lambda: item.cost)
for k in range(num_threads)
]
with support.start_threads(threads):
go.set()
finally:
sys.setswitchinterval(orig_si)

self.assertEqual(item.cost, 2)

def test_object_with_slots(self):
item = CachedCostItemWithSlots()
with self.assertRaisesRegex(
TypeError,
"No '__dict__' attribute on 'CachedCostItemWithSlots' instance to cache 'cost' property.",
):
item.cost

def test_reuse_different_names(self):
"""Disallow this case because decorated function a would not be cached."""
with self.assertRaises(RuntimeError) as ctx:
class ReusedCachedProperty:
@py_functools.cached_property
def a(self):
pass

b = a

self.assertEqual(
str(ctx.exception.__context__),
str(TypeError("Cannot assign the same cached_property to two different names ('a' and 'b')."))
)

def test_reuse_same_name(self):
"""Reusing a cached_property on different classes under the same name is OK."""
counter = 0

@py_functools.cached_property
def _cp(_self):
nonlocal counter
counter += 1
return counter

class A:
cp = _cp

class B:
cp = _cp

a = A()
b = B()

self.assertEqual(a.cp, 1)
self.assertEqual(b.cp, 2)
self.assertEqual(a.cp, 1)

def test_set_name_not_called(self):
cp = py_functools.cached_property(lambda s: None)
class Foo:
pass

Foo.cp = cp

with self.assertRaisesRegex(
TypeError,
"Cannot use cached_property instance without calling __set_name__ on it.",
):
Foo().cp

def test_access_from_class(self):
self.assertIsInstance(CachedCostItem.cost, py_functools.cached_property)

def test_doc(self):
self.assertEqual(CachedCostItem.cost.__doc__, "The cost of the item.")


if __name__ == '__main__':
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Add ``functools.cached_property`` decorator, for computed properties cached
for the life of the instance.