8.10 使用延迟计算属性

问题

你想将一个只读属性定义成一个property,并且只在访问的时候才会计算结果。但是一旦被访问后,你希望结果值被缓存起来,不用每次都去计算。

解决方案

定义一个延迟属性的一种高效方法是通过使用一个描述器类,如下所示:

  1. class lazyproperty:
  2. def __init__(self, func):
  3. self.func = func
  4.  
  5. def __get__(self, instance, cls):
  6. if instance is None:
  7. return self
  8. else:
  9. value = self.func(instance)
  10. setattr(instance, self.func.__name__, value)
  11. return value

你需要像下面这样在一个类中使用它:

  1. import math
  2.  
  3. class Circle:
  4. def __init__(self, radius):
  5. self.radius = radius
  6.  
  7. @lazyproperty
  8. def area(self):
  9. print('Computing area')
  10. return math.pi * self.radius ** 2
  11.  
  12. @lazyproperty
  13. def perimeter(self):
  14. print('Computing perimeter')
  15. return 2 * math.pi * self.radius

下面在一个交互环境中演示它的使用:

  1. >>> c = Circle(4.0)
  2. >>> c.radius
  3. 4.0
  4. >>> c.area
  5. Computing area
  6. 50.26548245743669
  7. >>> c.area
  8. 50.26548245743669
  9. >>> c.perimeter
  10. Computing perimeter
  11. 25.132741228718345
  12. >>> c.perimeter
  13. 25.132741228718345
  14. >>>

仔细观察你会发现消息 Computing areaComputing perimeter 仅仅出现一次。

讨论

很多时候,构造一个延迟计算属性的主要目的是为了提升性能。例如,你可以避免计算这些属性值,除非你真的需要它们。这里演示的方案就是用来实现这样的效果的,只不过它是通过以非常高效的方式使用描述器的一个精妙特性来达到这种效果的。

正如在其他小节(如8.9小节)所讲的那样,当一个描述器被放入一个类的定义时,每次访问属性时它的 get()set()delete() 方法就会被触发。不过,如果一个描述器仅仅只定义了一个 get() 方法的话,它比通常的具有更弱的绑定。特别地,只有当被访问属性不在实例底层的字典中时 get() 方法才会被触发。

lazyproperty 类利用这一点,使用 get() 方法在实例中存储计算出来的值,这个实例使用相同的名字作为它的property。这样一来,结果值被存储在实例字典中并且以后就不需要再去计算这个property了。你可以尝试更深入的例子来观察结果:

  1. >>> c = Circle(4.0)
  2. >>> # Get instance variables
  3. >>> vars(c)
  4. {'radius': 4.0}
  5.  
  6. >>> # Compute area and observe variables afterward
  7. >>> c.area
  8. Computing area
  9. 50.26548245743669
  10. >>> vars(c)
  11. {'area': 50.26548245743669, 'radius': 4.0}
  12.  
  13. >>> # Notice access doesn't invoke property anymore
  14. >>> c.area
  15. 50.26548245743669
  16.  
  17. >>> # Delete the variable and see property trigger again
  18. >>> del c.area
  19. >>> vars(c)
  20. {'radius': 4.0}
  21. >>> c.area
  22. Computing area
  23. 50.26548245743669
  24. >>>

这种方案有一个小缺陷就是计算出的值被创建后是可以被修改的。例如:

  1. >>> c.area
  2. Computing area
  3. 50.26548245743669
  4. >>> c.area = 25
  5. >>> c.area
  6. 25
  7. >>>

如果你担心这个问题,那么可以使用一种稍微没那么高效的实现,就像下面这样:

  1. def lazyproperty(func):
  2. name = '_lazy_' + func.__name__
  3. @property
  4. def lazy(self):
  5. if hasattr(self, name):
  6. return getattr(self, name)
  7. else:
  8. value = func(self)
  9. setattr(self, name, value)
  10. return value
  11. return lazy

如果你使用这个版本,就会发现现在修改操作已经不被允许了:

  1. >>> c = Circle(4.0)
  2. >>> c.area
  3. Computing area
  4. 50.26548245743669
  5. >>> c.area
  6. 50.26548245743669
  7. >>> c.area = 25
  8. Traceback (most recent call last):
  9. File "<stdin>", line 1, in <module>
  10. AttributeError: can't set attribute
  11. >>>

然而,这种方案有一个缺点就是所有get操作都必须被定向到属性的 getter 函数上去。这个跟之前简单的在实例字典中查找值的方案相比效率要低一点。如果想获取更多关于property和可管理属性的信息,可以参考8.6小节。而描述器的相关内容可以在8.9小节找到。

原文:

http://python3-cookbook.readthedocs.io/zh_CN/latest/c08/p10_using_lazily_computed_properties.html