解读Python的描述符协议

104 阅读6分钟

很多现代框架和库都使用 "描述符 "协议,以使为终端用户创建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_namefoo.__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__ score foo.__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,使开发者能够在需要时定制东西。