cached_property
首先,运行取自Daniel Roy Greenfeld
的文章 cached-property: Don’t copy/paste code 的一段代码:
1 | import time |
运行结果如下:1
2
3
4
5I am slooooooow
1.00401997566
{'very_slow': 'I am slooooooow'}
I am slooooooow
1.00412487984
可以看到第一次调用very_slow
运行耗时了1秒,而第二次调用就变得很快了,就好像有了第一次运行结果的缓存一样。
稍微修改代码,给cached_property
加上一个__set__
方法:1
2def __set__(self, instance, value):
print 'set'
再运行一次,结果和刚才不太一样,看起来缓存效果不存在了:1
2
3
4
5I am slooooooow
1.00347995758
{'very_slow': 'I am slooooooow'}
I am slooooooow
2.00672006607
至此,我的脑海里有几个问题:
- 为什么
very_slow
明明被定义为方法,却不能用very_slow()
的方式来调用? - 为什么
cached_property
可以实现缓存效果? - 为什么实现
__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
,默认情况下进行读操作时的顺序:
a.__dict__['x']
,即实例属性type(a).__dict__['x']
,即类属性- 父类属性(不包含元类)
__getattr__()
方法
然而对一个实例的属性进行写操作,只会修改实例本身,而不会影响到类及其父类。
注意上述提到的顺序是在默认情况下,假设此时x
是一个描述符,那么读取的顺序就要取决于描述符的具体实现了。
首先,实现了__set__
方法的描述符称为覆盖型描述符,也叫数据描述符;只实现了__get__
方法的描述符称为非覆盖型描述符,也叫非数据描述符。正如前边提到的属性读取顺序取决于描述符的具体实现,假设__dict__
中有个与描述符同名的属性,那么读取的顺序为:
- 覆盖型描述符
- 实例的
__dict__
- 非覆盖型描述符
结合上述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
17class 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 | class Test(object): |
运行结果: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__
方法)。
描述符的应用
- 如
cached_property
,非覆盖型描述符可以实现缓存的效果; - 如 Python 内置的
property
就是实现了__get__
和__set__
的覆盖型描述符,并且调用__set__
会抛出AttributeError: can't set attribute
,这就实现了只读的效果; - 可以实现一个覆盖型描述符,在
__set__
方法中进行校验操作,这就使写操作具有了校验功能。
参考阅读
Descriptor HowTo Guide
《流畅的 Python》第20章