很多现代框架和库都使用 "描述符 "协议,以使为终端用户创建API的过程变得整洁而简单。让我们讨论一下如何使用描述符协议来模仿Python的内置属性、静态方法和类方法的行为。
考虑一下下面这个例子类:
class Person:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
def _full_name_getter(self):
return f'{self.first_name} {self.last_name}'.title()
def _full_name_setter(self, value):
first_name, *_, last_name = value.split()
self.first_name = first_name
self.last_name = last_name
full_name = property(fget=_full_name_getter, fset=_full_name_setter)
foo = Person('foo', 'bar')
每当我们访问foo 的任何一个属性,比如foo.first_name ,那么first_name 就会被检查,直到找到为止:
foo.__dict__,type(foo).__dict____dict__在MRO中, 的基类--元类除外。foo
>>> foo.__dict__
{'first_name': 'foo', 'last_name': 'bar'}
>>> foo.first_name
foo
>>> foo.last_name
bar
>>> foo.full_name
Foo Bar
>>>
注意到full_name 的属性在foo.__dict__ 中并不存在。咦,它是从哪里来的?
好吧,我们讨论的属性访问机制是不完整的。但是,在讨论这个问题之前,让我们绕道看看描述符协议是如何工作的--属性、classmethod、staticmethod都是在这个基础上工作的。
什么是描述符协议?
任何至少有一个__get__,__set__,__delete__ 方法被定义的对象,都被称为描述符。这些方法的签名是:
__get__(self, obj, type=None) -> value
__set__(self, obj, value) -> None
__delete__(self, obj) -> None
有两种类型的描述符:数据描述符,和非数据描述符。两者的区别在于,如果一个对象有__set__ 或__delete__ 的定义,那么它就被称为数据描述符。因此,一个非数据描述符,在这三种方法中只有__get__ 的定义。数据描述符和非数据描述符在属性查找链中具有不同的优先级(后面会详细介绍)。
在Person 类中,类属性full_name 是一个描述符。当foo.full_name 被访问时,Person.full_name.__get__(foo, Person) 被调用,而后者又调用我们在property 中作为fget 关键字参数传递的函数。
所以现在的属性访问机制是:
- 检查
type(foo).__dict__['first_name']是否是一个数据描述符。如果是,则返回Person.first_name.__get__(foo, Person)。 - 如果不是,则在
foo.__dict__、type(foo).__dict__和MRO中foo的基类的__dict__中检查first_name- 除非它是一个元类。 - 最后,检查
type(foo).__dict__['first_name']是否是一个非数据描述符,在这种情况下,返回Person.first_name.__get__(foo, Person)。
注意,第一和第三步几乎是相似的。但是,如果一个属性是数据描述符,那么它的优先级最高,而在非数据描述符的情况下,__dict__ 查询的优先级比非数据描述符高。我们将在后面的文章中看到这将在缓存属性中使用。
你可能想知道是什么安排了这种查找机制。那么是__getattribute__ (不要和__getattr__ 混淆)--当我们查找foo.full_name ,foo.__getattribute__('full_name') ,它根据我们刚刚定义的属性访问机制来处理。
了解属性设置机制也很重要。考虑一下这个语句:foo.age = 32:
- 如果
age属性是一个描述符,那么type(foo).__dict__['age'].__set__(32)被调用。如果age是一个非数据描述符,那么AttributeError被抛出。 - 否则,在
foo's__dict__,即foo.__dict__['age'] = 32,就会创建一个条目。
属性内置程序是如何工作的?
让我们先看看property 的签名:
property(fget=None, fset=None, fdel=None, doc=None)
虽然它看起来像一个函数,但它实际上是一个类,它也是一个描述符,因为它定义了__get__ 、__set__ 和__delete__ 。
我们知道,一个作为描述符的属性,当在一个对象上访问时,比如说foo ,就会调用它的__get__ 方法,并以对象和对象的类作为参数,即type(foo).__dict__['attr_name'].__get__(foo, type(foo)) 。同样地,当它被设置时,它的__set__ 方法会被调用,并带有要设置的对象和值,即type(foo).__dict__['attr_name'].__set__(foo, value) 。
继续开头的例子:
>>> foo.full_name
Foo Bar
>>> # Person.__dict__['full_name'].__get__(foo, Person)
>>> foo.full_name = 'keanu reeves'
>>> # Person.__dict__['full_name'].__set__(foo, 'keanu reeves')
>>> foo.first_name
keanu
>>> foo.last_name
reeves
>>> foo.full_name
Keanu Reeves
请注意,当我们设置foo.full_name = 'keanu reeves' ,然后full_name 属性的__set__ 被调用,反过来调用我们作为fset 参数传递给属性的_full_name_setter 。
我们可以通过下面的实现来模仿property 的行为:
class Property:
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
self.doc = doc
def __get__(self, instance, owner):
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(instance)
def __set__(self, obj, value):
if self.fset is None:
raise AttributeError("can't set attribute")
self.fset(obj, value)
def __delete__(self, obj):
if self.fdel is None:
raise AttributeError("can't delete attribute")
self.fdel(obj)
def getter(self, fget):
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset):
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel):
return type(self)(self.fget, self.fset, fdel, self.__doc__)
缓存属性是如何工作的?
缓存属性的预期行为是,如果它还没有被计算出来,那么它应该被计算出来,并且在计算之后,它应该被存储起来("缓存"),以便下次可以快速访问它:
class CachedProperty:
def __init__(self, function):
self.function = function
def __get__(self, instance, owner):
result = self.function(instance)
instance.__dict__[self.function.__name__] = result
return result
现在让我们来使用它
>>> class Foo:
>>> def score(self):
>>> print('doing some time-consuming calculations')
>>> return 19.5
>>>
>>> score = CachedProperty(score)
>>> # you can also use CachedProperty as decorator
>>>
>>> foo = Foo()
>>> vars(foo) # i.e foo.__dict__
>>> {}
>>> foo.score
doing some time-consuming calculations
19.5
>>> vars(foo)
{'score': 19.5}
>>> foo.score
19.5
请注意,当我们第一次访问foo 上的score 属性时,它打印了 "正在进行一些耗时的计算"。在访问了一次foo.score 之后,foo.__dict__ 被填充了一个新的条目,键为score 。如果我们现在第二次访问foo.score ,不会打印出任何东西--而是返回vars(foo)['score'] 。
为什么会发生这种情况呢?
为了回答这个问题,现在是时候回顾一下属性访问机制了。当score 第一次被访问时:
- 它被检查是否得分是一个数据描述符。它不是。
- 下一次检查是在
__dict__。同样,在foo和它的基础__dict__中都没有找到score的键。 - 接下来,检查
score是否是一个非数据描述符--是的,因此调用type(foo).__dict__['score'].__get__(foo, type(foo)),存储并返回结果。
当现在第二次访问score 以后:
- 检查
score是否是一个数据描述符 - 不是。 'score'然后在 中查找key,它是在第一次访问 的时候插入的。 被返回。foo.__dict__scorefoo.__dict__['score']
一个使用缓存属性变得特别有用的例子是,如果你在Django中拥有一个模型类,并且你定义了一个属性,这使得查询变得很耗时。Django的 "包含电池 "的理念没有落空,并为这种用例提供了django.utils.functional.cached_property 。
staticmethod和classmethod是如何工作的?
由staticmethod 装饰的方法不接受隐含的第一个参数。它将一个函数转换为一个静态方法。让我们使用描述性协议来实现它:
class StaticMethod:
def __init__(self, function):
self.function = function
def __get__(self, instance, owner):
return self.function
同样地,描述符API可以用来实现classmethod 装饰的方法--它接收类对象作为第一个参数--如下所示:
class ClassMethod:
def __init__(self, function):
self.function = function
def __get__(self, instance, owner):
def wrapper(*args, **kwargs):
return self.function(owner or type(instance), *args, **kwargs)
return wrapper
我们已经用描述符的魔力来理解像staticmethod、classmethod和property这样的内置方法是如何工作的,以及我们如何自己实现一个像CachedProperty这样的方法。注意,我们实现的 CachedProperty 并不是一个黑客--Python 3 提供了这些 API,使开发者能够在需要时定制东西。