Python 描述符(descriptor) 杂记

时间:2021-04-13 04:32:11

转自:https://blog.tonyseek.com/post/notes-about-python-descriptor/

Python 引入的“描述符”(descriptor)语法特性真的很黄很暴力,我觉得这算是 Python 对象模型的核心成员之一。Python 语言设计的紧凑很大程度上得益于它。所以写一篇笔记文记录关于描述符我知道的一切。

低层 - 纯纯的描述符

纯纯的描述符很纯,基于类中定义的 __get__ 、 __set__ 、 __delete__ 三个特殊的方法。实现了这三个中方法的任意一个,这个类的实例就拥有了一些特殊特性。

假设现在有这么一个类 MyDescriptor,它拥有描述符的实现。把 MyDescriptor 实例化(my_descriptor),然后将实例作为另一个类 Spam 的类属性。

class MyDescriptor(object):
def __get__(self, subject_instance, subject_class):
return (self, subject_instance, subject_class) def __set__(self, subject_instance, value):
print "%r %r %r" (self, subject_instance, value) my_descriptor = MyDescriptor() class Spam(object):
my = my_descriptor spam = Spam()

吧 MyDescriptor 的实例作为 Spam 一个名为 my 的类属性之后,Spam 类中属性 my 的访问就被重载了。 Spam.my , spam.my 和 spam.my = 123 都不再直接在 spam.__dict__ 上获取和添加值,而是调用 my_descriptor.__get__(None, Spam) , my_descriptor.__get__(spam, Spam) 和 my_descriptor.__set__(spam, 123) 。如果 MyDescriptor 实现了 __delete__ ,那么 del spam.my 也会被重载为 my_descriptor.__delete__(spam) 。

所以我理解为对应“元类”(meta-class),描述符事实上是实现了“元属性”(meta-attribute)。通过这种重载方式,可以定义许多具有特殊行为的对象属性规格,而 Python 许多内置的对象模型成员也是通过这种方式实现的。

高层#0 - 从“函数”到“方法”

描述符在 Python 对象模型中的一个重要作用是实现“对象方法”(method)。我们都知道 Python 中方法有这些特性:

  • 方法本身是一个至少要有一个参数的普通函数,第 0 个参数位 self 指向实例,类似于 C++ 的 const T* this 指针。
  • 作为方法的函数在类的命名空间还是以普通函数的形式存在的,比如 Spam 类中的 egg 方法的原始形态是 Spam.__dict__['egg'] 这个函数。
  • 通过实例访问这个函数时,也就是类似 Spam().egg 的方式,得到的不再是 Spam.__dict__['egg'] 中保存的那个原始函数,而是一个包装过的 "bound method"。这个对象仍然有 __call__ 可以调用,但相比它所包装的原函数,这个对象接受调用时参数列表已经少了一个参数。这是一种类似 functools.partial(raw_function, self) 的效果,原函数已经被做了偏函数化处理,默认绑定了实例对象到第 0 个参数位(通常 self 所在的位置);
  • 奇怪的是,如果自己定义一个实现了 __call__ 的函数对象,把它放到类属性中,它并不会表现出“绑定 self”的特性。

我此前一直对此很疑惑,以为这是一个语法级别的行为。直到看了 Flask、Jinja 2 的作者 Armin Ronacher 的 一个 Presentation 我才知道,原来 Method 根本不是语法级别特性,而是通过描述符来实现的。

我此前一直忽略了的是:一个 def 定义的 Python 函数或一个 lambda 表达式,除了拥有 __call__ 实现外,还拥有 __get__ 实现。也就是说,所有 Python 函数都默认是一个描述符。这个描述符的特性在“类”这一对象上下文之外没有任何意义,但只要到了类中,就会和其他描述符所表现的一样,将 my_instance.my_method 重载为 MyClass.__dict__['my_method'].__get__(my_instance, MyClass) 。“绑定 self 参数” 这个过程正是 __get__ 的行为,导致了 spam.egg(1, 2, 3) 和 Spam.egg(spam, 1, 2, 3) 等价。

明白了这一点,我的思路就清晰多了。“方法”不就是一种特殊的类属性吗,而定义特殊类属性的行为在 Python 对象模型中的实现正是描述符。而对这点的理解也非常有用,Armin Ronacher 的演示稿中提到了可以利用这一点实现只对对象方法有效的装饰器。

高层#1 - Property 属性

不明白为啥属性这个词有 Property 和 Attribute 两种翻译,但是在 Python 中二者是有区别的。后者指的是真正的对象属性,就是保存在 obj.__dict__ 中的成员(我一般更喜欢用 vars(obj) 来取这个字典);而前者指的是类似 C# 中“方法属性”或 Java 中 Getter、Setter 方法的重载属性。

class Spam(object):

    @property
def egg(self):
return "some-value" @egg.setter
def egg(self, value):
self._egg = "value = %s" % value spam = Spam()

说起来也很清晰,就是 spam.egg 返回的是第一个 egg 方法调用的返回值, spam.egg = "abc" 调用的是对于第二个 egg 方法的 egg(self=spam, value="abc") 。此外还可以继续定义删除属性操作的重载。这个比对象方法要容易看出来得多, property 正是一个描述符。

高层#n - 不受限制的用户自定义描述符

除去语言内置的这些描述符用法,还有许多用户自定义的描述符用法。这些用法让开发者可以更加灵活地扩展对象行为,是非常有用的元编程工具。相比起元类对整个类行为的重载,描述符的重载粒度非常细,它只关注一个属性。所以使用上我们也可以比使用元类减少更多的顾忌,因为我们能非常容易看到它能量释放的边界。

用户定义描述符的例子,我觉得最经典的要属 SQLAlchemy (当然,Django Model 定义也是同理)。SQLAlchemy 的 Column 类就是描述符的实现者。定义一个模型对象的时候,以声明风格写出这个模型对应的数据表所拥有的属性:

class User(Jsonizable, LowerIdMixin, db.Model):
"""The account of the user.""" email = db.Column(db.String(64), unique=True, nullable=False)
nickname = db.Column(db.Unicode(20))

而 email 、 nickname 描述符将托管今后所有 User 的实例中,对于这两个同名属性的访问。SQLAlchemy 在映射对象关系的时候,可以用这个特性实现许多特性,比如延迟加载——直到访问user.roles 的时候才执行 SQL 语句查询 role 表并构造 Role 对象集返回。