从 cached_property 理解描述符

cached_property

首先,运行取自Daniel Roy Greenfeld的文章 cached-property: Don’t copy/paste code 的一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import time


class cached_property(object):
def __init__(self, func):
self.__doc__ = getattr(func, '__doc__')
self.func = func

def __get__(self, instance, owner):
if instance is None:
return self
value = instance.__dict__[self.func.__name__] = self.func(instance)
return value


class SlowClass(object):
@cached_property
def very_slow(self):
time.sleep(1)
return 'I am slooooooow'


if __name__ == '__main__':
slow_class = SlowClass()
start_time = time.time()
print slow_class.very_slow
print time.time() - start_time
print slow_class.__dict__
print slow_class.very_slow
print time.time() - start_time

运行结果如下:

1
2
3
4
5
I am slooooooow
1.00401997566
{'very_slow': 'I am slooooooow'}
I am slooooooow
1.00412487984

可以看到第一次调用very_slow运行耗时了1秒,而第二次调用就变得很快了,就好像有了第一次运行结果的缓存一样。
稍微修改代码,给cached_property加上一个__set__方法:

1
2
def __set__(self, instance, value):
print 'set'

再运行一次,结果和刚才不太一样,看起来缓存效果不存在了:

1
2
3
4
5
I am slooooooow
1.00347995758
{'very_slow': 'I am slooooooow'}
I am slooooooow
2.00672006607

至此,我的脑海里有几个问题:

  1. 为什么very_slow明明被定义为方法,却不能用very_slow()的方式来调用?
  2. 为什么cached_property可以实现缓存效果?
  3. 为什么实现__set__方法后,cached_property就失去了缓存效果?

经过类装饰器cached_property的装饰,very_slow成为了一个描述符(Descriptor),而以上问题就和描述符有关。接下来,带着以上几个问题来学习和理解 Python 中的描述符。

描述符的定义

描述符是具有绑定行为对象属性,通过实现__get____set____delete__方法来控制属性的访问。上述三个方法被称为描述符协议,只要实现其了中任意一个协议,就可以称为描述符。

这里提到描述符是对象属性,这也就解释了第一个问题为什么不能写very_slow()。当然,如果修改very_slow让他返回一个可执行的函数,运行slow_class.very_slow()就不会报错了,但实际上这里的运行逻辑是先执行了slow_class.very_slow访问了对象属性,得到一个可执行的函数,然后再调用这个函数,所以这和描述符本身已经没有关系了。

属性的读写

关于属性读写,需要先提到__dict__

__dict__是一个用于存储属性的字典,以属性名和值作为键值对,正如上面运行结果中的{'very_slow': 'I am slooooooow'}

在 Python 中对属性的读写是不对等的,假设读取a.x,默认情况下进行读操作时的顺序:

  1. a.__dict__['x'],即实例属性
  2. type(a).__dict__['x'],即类属性
  3. 父类属性(不包含元类)
  4. __getattr__()方法

然而对一个实例的属性进行写操作,只会修改实例本身,而不会影响到类及其父类。

注意上述提到的顺序是在默认情况下,假设此时x是一个描述符,那么读取的顺序就要取决于描述符的具体实现了。

首先,实现了__set__方法的描述符称为覆盖型描述符,也叫数据描述符;只实现了__get__方法的描述符称为非覆盖型描述符,也叫非数据描述符。正如前边提到的属性读取顺序取决于描述符的具体实现,假设__dict__中有个与描述符同名的属性,那么读取的顺序为:

  1. 覆盖型描述符
  2. 实例的__dict__
  3. 非覆盖型描述符

结合上述cached_property例子来看,当描述符是非覆盖型时,第一次读取very_slow,解释器先从实例slow_class的字典中搜索属性值,在没有找到的情况下再依次从类与父类中搜索,最后调用了描述符的__get__方法,触发value = instance.__dict__[self.func.__name__] = self.func(instance),将运行结果存储到了实例的字典中,从而在第二次读取时起到了缓存的作用。

当例子中的描述符实现了__set__方法,成为覆盖型描述符,那么读取顺序就变了,优先调用描述符的__get__方法,所以丧失了缓存效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Description(object):
def __set__(self, instance, value):
print('set', instance, value)

def __get__(self, instance, owner):
print('get', instance, owner)


class Test(object):
foo = Description()


if __name__ == '__main__':
test = Test()
test.foo
test.__getattribute__('foo')
Test.__dict__['foo'].__get__(test, Test)

运行上面的代码,可以发现三种写法运行结果是一样的。

1
2
3
('get', <__main__.Test object at 0x10b89a090>, <class '__main__.Test'>)
('get', <__main__.Test object at 0x10b89a090>, <class '__main__.Test'>)
('get', <__main__.Test object at 0x10b89a090>, <class '__main__.Test'>)

事实上test.foo就是执行了test.__getattribute__('foo')__getattribute__进一步执行了Test.__dict__['foo'].__get__(test, Test)

所以需要注意只要重写了Tset__getattribute__,就会让描述符失去对读操作的控制。

同样的,与写操作对应的则是test.foo = 1 –> test.__setattr__('foo', 1) –> Test.__dict__['foo'].__set__(test, 1)

方法也是描述符

1
2
3
4
5
6
7
8
9
10
class Test(object):
def foo(self):
print 1


if __name__ == '__main__':
test = Test()
print(test.foo)
print(test.__getattribute__('foo'))
print(Test.__dict__['foo'].__get__(test, Test))

运行结果:

1
2
3
<bound method Test.foo of <__main__.Test object at 0x10d3c4050>>
<bound method Test.foo of <__main__.Test object at 0x10d3c4050>>
<bound method Test.foo of <__main__.Test object at 0x10d3c4050>>

可见类中实现的方法也是描述符,并且是只实现了__get__的非覆盖型描述符,以上调用结果都是返回了被绑定到实例上的函数。

因为其只实现了__get__,所以并不会对同名属性的赋值操作产生影响,对实例属性的赋值操作会覆盖掉描述符(回顾下属性读取顺序,__dict__优先于非覆盖型描述符的__get__方法)。

描述符的应用

  1. cached_property,非覆盖型描述符可以实现缓存的效果;
  2. 如 Python 内置的property就是实现了__get____set__的覆盖型描述符,并且调用__set__会抛出AttributeError: can't set attribute,这就实现了只读的效果;
  3. 可以实现一个覆盖型描述符,在__set__方法中进行校验操作,这就使写操作具有了校验功能。

参考阅读

Descriptor HowTo Guide
《流畅的 Python》第20章