6. Python 预缓存的 Property 修饰器简单实现#
创建时间:2021-03-19
这份简短笔记,我们会讨论预缓存 Python Property (类属性) 的简单实现。
在运行程序时,特别是对于耗时但不耗内存、以后需要经常取用的计算,我们会希望找个内存或硬盘空间储存起来。为了方便起见,我们只讨论借用内存的方法。
假设现在的问题是,我们要计算 \(c = a+b, d = a^2\)。为了调用的便利,\(a, b\) 两个变量 (作为常数) 作为 property。
但麻烦之处在于,\(a, b\) 的值并不容易求,求完之后对内存的消耗却又不大。(嘛先不要追问为什么求个 1+1 要这么复杂)
同时,我们也不清楚末端用户是否需要求 \(c\) (需要同时计算 \(a\), \(b\)),还是需要求 \(d\) (只需要计算 \(a\) 即可)。
def get_a():
a = 0
for n in range(1, 1001):
a += 1 / 2**n
return a
def get_b():
b = 0
for n in range(5001):
b += 3 * n**2 / 5000**3
return b
这篇文档讨论四种做法。第一种做法简单但低效;第二、三种做法代码较复杂;第四种代码简单且不会产生多余的计算。作者倾向使用 第三种 与 第四种 做法。
6.1. 即时调用 property 定义方法#
最简单粗暴的方法是需要 \(a, b\) 时就现场计算。在第一次调用 \(a, b\) 时固然需要耗时的计算,但第二次调用仍然会相当费时。
class Dummy:
@property
def a(self):
return get_a()
@property
def b(self):
return get_b()
@property
def c(self):
return self.a + self.b
@property
def d(self):
return self.a**2
dum = Dummy()
dum.c
2.00030002
%%timeit -n 50
dum.c
2.41 ms ± 172 µs per loop (mean ± std. dev. of 7 runs, 50 loops each)
6.2. 一般的 property 做法#
为了避免多余的计算,一般的方法是,需要首先在 __init__
中声明两个隐含变量 _a
, _b
以保存结果。在使用 a
, b
两个 property 之前,先要使用 setter 函数作 \(a, b\) 的计算并分别保存到 _a
, _b
中;随后再用 getter 函数调用它们。
class General:
def __init__(self):
self._a = NotImplemented
self._b = NotImplemented
@property
def a(self):
return self._a
@a.setter
def a(self, val):
self._a = val
@property
def b(self):
return self._a
@b.setter
def b(self, val):
self._b = val
@property
def c(self):
return self.a + self.b
@property
def d(self):
return self.a**2
如果没有预先使用 setter 函数,就会碰到下面这种尴尬的情况:
gen = General()
gen.c
-------------------------------------------------
TypeError Traceback (most recent call last)
<ipython-input-7-ed9f42ab8466> in <module>
1 gen = General()
----> 2 gen.c
<ipython-input-6-7edbb0413f0d> in c(self)
23 @property
24 def c(self):
---> 25 return self.a + self.b
26
27 @property
TypeError: unsupported operand type(s) for +: 'NotImplementedType' and 'NotImplementedType'
因此,正确的调用方式是
gen = General()
gen.a, gen.b = get_a(), get_b()
gen.c, gen.d
(2.0, 1.0)
上面的步骤是耗时的,但随后当要调用 a
, b
变量时,就会快捷很多:
%%timeit -n 50
gen.c
265 ns ± 28 ns per loop (mean ± std. dev. of 7 runs, 50 loops each)
但这里有一个问题:\(a, b\) 的值实际上可以看作常数;如果末端用户真正希望得到的是 \(d = a^2\) 而非 \(c = a + b\),那么实际上用户不需要 \(b\),自然也就不需要对其花时间赋值了。决定是否要对 \(b\) 赋值的任务由此交给末端用户,这会造成一些困扰。
6.3. 偷懒的做法:将赋值函数嵌入 getter 函数#
如果这个任务交给程序编写者,那么一种最简单的实现方式是把赋值函数嵌入到 getter 函数中:
如果 \(a\) 的值已经被计算过,那么就从缓存空间
_a
取出该值;如果 \(a\) 被调用前没有被计算过,那么就计算该值并放入缓存
_a
。
class Improved:
def __init__(self):
self._a = NotImplemented
self._b = NotImplemented
@property
def a(self):
if self._a is NotImplemented:
self._a = get_a()
return self._a
@property
def b(self):
if self._b is NotImplemented:
self._b = get_b()
return self._b
@property
def c(self):
return self.a + self.b
@property
def d(self):
return self.a**2
如果末端用户只需要求 \(d = a^2\),那么耗费时间的关键步就只有 \(a\) 的计算;缓存空间 _b
就是空的。
imp = Improved()
print(imp.d)
print(imp._b)
1.0
NotImplemented
同时,以后再需要调用 \(d\) 时,\(a\) 的值也不会再被计算第二次。
当然,这种做法的弊端是,用户原则上无权限更改 \(a, b\) 的值 (通过更改隐含变量 _a
, _b
是可能的,但这违背了 PEP8 的程序规范)。
6.4. 改进的做法:缩减隐含变量的声明#
但上面的定义仍然有很多冗余。对于每个 property,我们总要声明隐含变量、调用时判断是否缓存空间存在。这两步可以通过改编在 property 修饰器内部增加一段代码方便地实现。这个修饰器我们命名为 cached_property
:
def cached_property(f):
def wrap(*args, **kwargs):
self = args[0] # self
_f = "_" + f.__name__ # _a
if not hasattr(self, _f) or getattr(self, _f) is NotImplemented: # if self._a is NotImplemented:
setattr(self, _f, f(*args)) # self._a = get_a()
return getattr(self, _f) # return self._a
return property(wrap) # make this wrap a property
这样之后,不仅代码量减少很多 (调用方式与最简单的 Dummy
完全一致),同时也避免多余重复的计算。
class Advanced:
@cached_property
def a(self):
return get_a()
@cached_property
def b(self):
return get_b()
@property
def c(self):
return self.a + self.b
@property
def d(self):
return self.a**2
adv = Advanced()
print(adv.d)
print(hasattr(adv, "_b"))
1.0
False