diff --git a/Doc/library/functools.rst b/Doc/library/functools.rst --- a/Doc/library/functools.rst +++ b/Doc/library/functools.rst @@ -20,6 +20,33 @@ 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.7 + + .. note:: + + This decorator does not support classes which define ``__slots__``. + .. function:: cmp_to_key(func) Transform an old-style comparison function to a :term:`key function`. Used diff --git a/Lib/functools.py b/Lib/functools.py --- a/Lib/functools.py +++ b/Lib/functools.py @@ -807,3 +807,25 @@ wrapper._clear_cache = dispatch_cache.clear update_wrapper(wrapper, func) return wrapper + + +################################################################################ +### cached_property() - computed once per instance, cached as attribute +################################################################################ + +class cached_property: + def __init__(self, func): + self.func = func + self.__doc__ = func.__doc__ + + def __get__(self, instance, cls=None): + if instance is None: + return self + attrname = self.func.__name__ + try: + cache = instance.__dict__ + except AttributeError: # objects with __slots__ have no __dict__ + msg = f"No '__dict__' attribute on {type(instance).__name__!r} instance to cache {attrname!r} property." + raise TypeError(msg) from None + val = cache[attrname] = self.func(instance) + return val diff --git a/Lib/test/test_functools.py b/Lib/test/test_functools.py --- a/Lib/test/test_functools.py +++ b/Lib/test/test_functools.py @@ -2006,5 +2006,49 @@ functools.WeakKeyDictionary = _orig_wkd +class CachedCostItem: + _cost = 1 + + @py_functools.cached_property + def cost(self): + """The cost of the item.""" + 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.""" + self._cost += 1 + return self._cost + + +class TestCachedProperty(unittest.TestCase): + def test_cached(self): + item = CachedCostItem() + self.assertEqual(item.cost, 2) + self.assertEqual(item.cost, 2) # not 3 + + 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()