很多现代框架和库都使用 "描述符 "协议,以使为终端用户创建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)。 __dict__如果不是,则在foo.__dict__、type(foo).__dict__和MRO中foo的基类中检查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 = '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
现在让我们来使用它
请注意,当我们第一次访问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,使开发者能够在需要时对事物进行定制。