Skip to content
Merged
Next Next commit
bpo-21145: Add cached_property decorator in functools
  • Loading branch information
carljm committed Jul 28, 2018
commit 1cda951b28dfc8a0b1350be7e64926602ee9e0e5
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

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.

I prefer _data to imply dataset.data = newdata is not supported.


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

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.

self._data


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

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.

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
29 changes: 29 additions & 0 deletions Lib/functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -868,3 +868,32 @@ def _method(*args, **kwargs):
@property
def __isabstractmethod__(self):
return getattr(self.func, '__isabstractmethod__', False)


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

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

def __get__(self, instance, cls=None):

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.

When cls is None? When __get__ is called without cls parameter?
Btw in docs for descriptors, the third argument is called owner IIRC.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AFAIK every call to __get__ generated by attribute lookup machinery will include the second (not including self) argument, but both the descriptor documentation (https://docs.python.org/3/reference/datamodel.html#descriptors) and the descriptor howto guide (https://docs.python.org/3/howto/descriptor.html) explicitly include an example of manually calling a descriptor's __get__ method providing only one argument. Also, all of the code examples in the HOWTO show the second argument as optional. Given that the method can be called directly and cls is unused in this implementation, it seems reasonable to follow that lead and make it optional.

Regarding the argument name, considering both the Data Model documentation and the HOWTO, we see examples in Python's own documentation of descriptors whose __get__ second argument is named owner, type, or objtype. All three of these are found in the standard library, along with cls, klass, ownerclass, and _type. There doesn't seem to be a standard usage. Personally I find cls or klass or objtype clearer, as owner doesn't clarify the key difference from the other argument, which is that it's a type. Perhaps naming the two arguments obj and objtype does the best job of clearly indicating their relationship. That said, I don't feel strongly and will rename to owner if you prefer that.

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 doubt if howto is 100% correct.

datamodel doesn't allow None for owner, AFAIK Python aways pass a class type.

Using default for always specified argument confuses me.

datamodel uses owner, please do it as well.

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.

If we need to change recommended argument name -- let's discuss it in another issue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Changed signature of __get__ to (self, instance, owner) to match Data Model documentation.

if instance is None:
return self
attrname = self.func.__name__
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 {attrname!r} property."
)
raise TypeError(msg) from None
with self.lock:

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.

__getitem__ / setdefault are atomic operations.
The code could be modified in the following way:

  1. Get attribute value without locking.
  2. If cached attr is not found -- acquire the lock, get the value again under the lock and calculate it if not present.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Sure, will update. Thanks for the review!

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.

looks good

# check if another thread filled cache while we awaited lock
if attrname not in cache:
cache[attrname] = self.func(instance)
return cache[attrname]
84 changes: 84 additions & 0 deletions Lib/test/test_functools.py
Original file line number Diff line number Diff line change
Expand Up @@ -2313,5 +2313,89 @@ 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 CachedCostItemWait:
_cost = 1

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):
"""The cost of the item."""
return self._cost

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.

Looks like the method is never called

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, that’s expected. The point of this class is just to test the error message; cached property on classes with slots is not supported. Would you find it clearer if I used pass as the body? I just wrote it the way someone realistically might, to make it clear that the error is not due to a mistake in the implementation of the method.

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.

raise RuntimeError('never executed') is even more clear.



class TestCachedProperty(unittest.TestCase):
def test_cached(self):
item = CachedCostItem()
self.assertEqual(item.cost, 2)
self.assertEqual(item.cost, 2) # not 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)


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.

Redundant line?

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_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.