6. Python 预缓存的 Property 修饰器简单实现#

创建时间:2021-03-19

这份简短笔记,我们会讨论预缓存 Python Property (类属性) 的简单实现。

在运行程序时,特别是对于耗时但不耗内存、以后需要经常取用的计算,我们会希望找个内存或硬盘空间储存起来。为了方便起见,我们只讨论借用内存的方法。

假设现在的问题是,我们要计算 \(c = a+b, d = a^2\)。为了调用的便利,\(a, b\) 两个变量 (作为常数) 作为 property。

但麻烦之处在于,\(a, b\) 的值并不容易求,求完之后对内存的消耗却又不大。(嘛先不要追问为什么求个 1+1 要这么复杂)

\[\begin{split} \begin{align} a &= \sum_{n = 1}^{\infty} \frac{1}{2^n} \simeq \sum_{n = 1}^{1000} \frac{1}{2^n} \\ b &= \int_0^1 3 x^2 \, \mathrm{d} x \simeq \sum_{n = 0}^{5000} \frac{3 n^2}{5000^3} \end{align} \end{split}\]

同时,我们也不清楚末端用户是否需要求 \(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