在Python中缓存类属性

135

我正在编写一个Python类,其中有一个属性需要相对较长的时间来计算,因此我只想计算一次。此外,并非每个类实例都需要该属性,因此在 __init__ 不想默认计算

我是Python新手,但并非编程新手。我可以轻松地想出一种方法来解决这个问题,但我发现一次又一次的,以“Pythonic”方式处理事情通常比我用其他语言的经验设计的方式要简单得多。

在Python中有一种“正确”的做法吗?


29
这些答案中没有一个是正确的。OP希望得到一个缓存的_class_属性,例如Foo.something_expensive。所有这些答案都是关于缓存的_instance_属性,这意味着something_expensive将为每个新实例重新计算,这在大多数情况下都不是最佳选择。 - steve
2
从Python 3.9开始,所有以下内容现在都可以作为@classmethod进行包装,这应该会为您提供一个缓存的类属性。 - Jake Stevens-Haas
3
@JakeStevens-Haas 我不相信那是正确的。我尝试在Python 3.10.2中使用@classmethod@functools.cached_property两个装饰器来创建缓存的类属性,无论顺序如何都无法实现。相比之下,Dickens库中的@cachedclassproperty装饰器对我起作用了。 - nimble_ninja
11个回答

190

Python 3.8以上版本已将@property@functools.lru_cache 合并为@cached_property

import functools
class MyClass:
    @functools.cached_property
    def foo(self):
        print("long calculation here")
        return 21 * 2

3.2 ≤ Python < 3.8

你应该同时使用 @property@functools.lru_cache 装饰器:

import functools
class MyClass:
    @property
    @functools.lru_cache()
    def foo(self):
        print("long calculation here")
        return 21 * 2

这个答案包含了更详细的例子,并且提到了先前Python版本的回退。

Python < 3.2

Python维基有一个缓存属性装饰器(MIT许可),可以像这样使用:

import random
# the class containing the property must be a new-style class
class MyClass(object):
   # create property whose value is cached for ten minutes
   @cached_property(ttl=600)
   def randint(self):
       # will only be evaluated every 10 min. at maximum.
       return random.randint(0, 100)

或者使用其他答案中提到的适合您需求的实现方式。
或者使用上述提到的后移版。


3
“lru_cache”也已被移植到Python 2中:https://pypi.python.org/pypi/functools32/3.2.3 - Buttons840
1
-1 lru_cache 的默认大小为 128,这会导致属性函数可能被调用两次。如果您使用 lru_cache(None),所有实例将永久保持活动状态。 - orlp
4
@orlp 的 lru_cache 默认大小为 128,用于处理 128 种不同的参数配置。只有当你生成的对象超过缓存大小时才会出现问题,因为这里唯一会发生变化的参数是 self。如果你正在生成如此多的对象,那么你真的不应该使用无限制的缓存,因为它将强制你无限期地保留所有调用该属性的对象,这可能会导致可怕的内存泄漏。无论如何,你可能最好使用一种在对象本身中存储缓存的缓存方法,这样缓存就会随对象一起清除。 - Taywee
3
@property @functools.lru_cache() 方法给我一个 TypeError: unhashable type 错误,可能是因为 self 不可哈希。 - Daniel Himmelstein
8
注意!在我看来functools.lru_cache似乎会导致类的实例在缓存中时避免垃圾回收。更好的解决方案是Python 3.8中的functools.cached_property - user1338062
1
说"@property@functools.lru_cache合并成了@cached_property"有点误导人。[这个答案演示了两者如何合并] (https://dev59.com/IWQo5IYBdhLWcg3wXuin#16099881)但是如果你尝试设置新值而没有编写setter,它不会像`@cachedproperty`那样运行。在您的两个示例代码片段中,尝试`x=MyClass()`,`print(x.foo)`,`x.foo=6*9`以查看区别。从文档中可以看到:“ cached_property()的机制与property()略有不同。常规属性阻止属性写入,除非定义了setter。相反,cached_property允许写入。” - Silverfish

59

我曾经按照gnibbler的建议这样做,但最终我厌倦了这些小的清理步骤。

因此,我构建了自己的描述符:

class cached_property(object):
    """
    Descriptor (non-data) for building an attribute on-demand on first use.
    """
    def __init__(self, factory):
        """
        <factory> is called such: factory(instance) to build the attribute.
        """
        self._attr_name = factory.__name__
        self._factory = factory

    def __get__(self, instance, owner):
        # Build the attribute.
        attr = self._factory(instance)

        # Cache the value; hide ourselves.
        setattr(instance, self._attr_name, attr)

        return attr

以下是使用它的方法:

class Spam(object):

    @cached_property
    def eggs(self):
        print 'long calculation here'
        return 6*2

s = Spam()
s.eggs      # Calculates the value.
s.eggs      # Uses cached value.

15
太好了!这是它的工作原理: 实例变量优先于非数据描述符。在第一次访问属性时,没有实例属性,只有描述符类属性,因此执行了描述符。然而,在其执行期间,描述符创建了一个带有缓存值的实例属性。这意味着,当第二次访问属性时,将返回先前创建的实例属性,而不是执行描述符。 - Florian Brucker
4
在 PyPI 上有一个名为 cached_property 的包,其中包括线程安全和按时间过期的版本。(同时感谢 @Florian 提供的解释。) - leewz
7
恭喜奇怪的边角情况:在使用 __slots__ 时无法使用 cached_property 描述符。__slots__ 使用数据描述符实现,而使用 cached_property 描述符会简单地覆盖生成的 slot 描述符,因此 setattr() 调用将无效,因为没有 __dict__ 可以设置属性,而此属性名称可用的唯一描述符是 cached_property。只是为了帮助其他人避免这种陷阱,将其放在这里。 - Martijn Pieters
这个解决方案在嵌套的DRF序列化器中无法工作,但是@leewz建议使用PyPI上的cached_property包来完成任务。 - Ruben Alves

44

通常的方法是将属性设为属性,并在第一次计算时存储其值。

import time

class Foo(object):
    def __init__(self):
        self._bar = None

    @property
    def bar(self):
        if self._bar is None:
            print "starting long calculation"
            time.sleep(5)
            self._bar = 2*2
            print "finished long caclulation"
        return self._bar

foo=Foo()
print "Accessing foo.bar"
print foo.bar
print "Accessing foo.bar"
print foo.bar

3
在Python3.2+中,相较于使用@property + @functools.lru_cache()的方法,是否有使用这种方式的动机?准私有属性的方法似乎让人想起Java/setters/getters;在我看来,仅使用lru_cache进行装饰更符合Pythonic风格。 - Brad Solomon
(如@Maxime的答案) - Brad Solomon
5
@functools.lru_cache()会将结果以self参数为键进行缓存,这也会防止该实例在缓存中存在时被垃圾回收。 - rectalogic
通过这种方法,我的IDE可以轻松自动完成。 - Ben Law

23

Python 3.8包含 functools.cached_property 装饰器。

将类的方法转换为属性,其值被计算一次,然后缓存为实例的普通属性的生命周期内。类似于 property(),但增加了缓存功能。对于实例的昂贵计算属性非常有用,这些实例在其他方面是有效不变的。

此示例直接来自文档:

from functools import cached_property

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)

限制在于带有要缓存的属性的对象必须具有可变映射的 __dict__ 属性,这排除了具有 __slots__ 的类,除非在 __slots__ 中定义了 __dict__


14

如前所述,functools.cached_property可用于缓存实例属性。对于缓存的属性:

from functools import cache

class MyClass:
    @classmethod
    @property
    @cache
    def foo(cls):
        print('expensive calculation')
        return 42
>>> MyClass.foo
expensive calculation
42
>>> MyClass.foo
42

如果您想要一个可重复使用的装饰器:

def cached_class_attr(f):
    return classmethod(property(cache(f)))

class MyClass:
    @cached_class_attr
    def foo(cls):
        ...

Python版本必须在3.9及以上且小于3.11


6
终于有人在12年后和其他10个(得票率很高的)答案之后给出了正确的答案。谢谢! - smac89
为什么选择Python < 3.11?在3.11中有任何变化吗? - Davy
@Davy 堆叠描述符(classmethod,property)不再允许使用 :( - ajanss
我没有看到任何地方说在3.11中不再允许叠加装饰器。 此外,上面的代码在3.11.1中对我有效。 - Thering
请查看此问题: https://dev59.com/iXVC5IYBdhLWcg3w-mVO - ajanss

5

这里要介绍的是dickens包(不是我的),它提供了cachedproperty, classpropertycachedclassproperty修饰器。

如果要缓存类属性,可以使用以下方式:

from descriptors import cachedclassproperty

class MyClass:
    @cachedclassproperty
    def approx_pi(cls):
        return 22 / 7

2
class MemoizeTest:

      _cache = {}
      def __init__(self, a):
          if a in MemoizeTest._cache:
              self.a = MemoizeTest._cache[a]
          else:
              self.a = a**5000
              MemoizeTest._cache.update({a:self.a})

1
大多数,如果不是全部的现有答案都是关于缓存“实例”属性的。要缓存“类”属性,您可以简单地使用字典。这确保了属性每个类只计算一次,而不是每个实例计算一次。
mapping = {}

class A:
    def __init__(self):
        if self.__class__.__name__ not in mapping:
            print('Expansive calculation')
            mapping[self.__class__.__name__] = self.__class__.__name__
        self.cached = mapping[self.__class__.__name__]

为了说明:

foo = A()
bar = A()
print(foo.cached, bar.cached)

提供

Expansive calculation
A A

1

你可以尝试使用记忆化技术。它的工作原理是,如果你传入相同的参数给一个函数,它将返回缓存的结果。你可以在这里找到更多关于如何在Python中实现它的信息。

此外,根据你的代码设置方式(你说并非所有实例都需要它),你可以尝试使用某种轻量级模式,或者延迟加载。


-3
这样做最简单的方法可能就是编写一个方法(而非使用属性),该方法包装属性(getter 方法)。在第一次调用时,此方法计算、保存并返回值;以后它只返回保存的值。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接