描述符是 Python 中的一个进阶概念,也是许多 Python 内部机制的实现基础,本文将对其做适当深入的介绍。
描述符的定义很简单,实现了下列任意一个方法的 Python 对象就是一个描述符(descriptor):
__get__(self, obj, type=None)__set__(self, obj, value)__delete__(self, obj)这些方法的参数含义如下:
self 是当前定义的描述符对象实例。obj 是该描述符将作用的对象实例。type 是该描述符作用的对象的类型(即所属的类)。上述方法也被称为描述符协议,Python 会在特定的时机按协议传入参数调用某一方法,如果我们未按协议约定的参数定义方法,调用可能会出错。
描述符可以用来控制对属性的访问行为,实现计算属性、懒加载属性、属性访问控制等功能,我们先来举个简单的例子:
|
|
在示例中我们创建了一个描述符实例,并将其赋值给 Foo 类的 x 属性变量。现在访问 Foo.x ,会发现 Python 自动调用了该属性所绑定的描述符实例的 __get__() 方法:
|
|
接下来实例化一个对象 foo,并通过 foo 对象访问 x 属性:
|
|
同样执行了描述符所定义的相应方法。
如果我们尝试对 foo 对象的 x 进行赋值,也会调用描述符的 __set__() 方法:
|
|
同理,如果我们在描述符中定义了 __delete__() 方法,该方法将在执行 del foo.x 时被调用。
描述符在属性查找过程中会被 . 点操作符调用,且只有在作为类变量使用时才有效。
如果直接赋值给实例属性,描述符不会生效。
>>> foo.__dict__['y'] = Descriptor()
>>> print(foo.y)
<__main__.Descriptor object at 0x100f0d130>
如果用 some_class.__dict__[descriptor_name] 的方式间接访问描述符,也不会调用描述符的协议方法,而是返回描述符实例本身。
print(Foo.__dict__['x'])
<__main__.Descriptor object at 0x10b66d8e0>
根据所实现的协议方法不同,描述符又可分为两类:
__set__() 或 __delete__() 任一方法,该描述符是一个数据描述符(data descriptor)。__get__() 方法,该描述符是一个非数据描述符(non-data descriptor)。两者的在表现行为上存在差异:
__dict__ 中的属性。__dict__ 中定义的属性所覆盖。在上面的示例中我们已经展示数据描述符的效果,接下来去掉 __set__() 方法实现一个非数据描述符:
|
|
当 bar.__dict__ 不存在键为 y 的属性时,访问 bar.y 和 foo.x 的行为是一致的:
|
|
但如果我们直接修改 bar 对象的 __dict__,向其中添加 y 属性,则该对象属性将覆盖在 Bar 类中定义的 y 描述符,访问 bar.y 将不再调用描述符的 __get__() 方法:
|
|
而在上文的数据描述符示例中,即使我们修改 foo.__dict__,对 x 属性的访问始终都由描述符所控制:
|
|
在下文中我们会介绍这两者的差异是如何实现的。
描述符控制属性访问的关键,在于从执行 foo.x 到 __get()__ 方法被调用这中间所发生的过程。
一般来说,对象的属性保存在 __dict__ 属性中:
object.__dict__ 是一个字典或其他的映射类型对象,用于存储一个对象的(可写)属性。__dict__ 属性。__dict__ 也被称为 mappingproxy 对象。我们从之前的示例继续:
|
|
当我们访问 foo.x ,Python 是如何判断应该调用描述符方法还是从 __dict__ 中获取对应值的呢?其中起关键作用的是 . 这个点操作符。
点操作符的查找逻辑位于 object.__getattribute__() 方法中,每一次向对象执行点操作符都会调用对象的该方法。CPython 中该方法由 C 实现,我们来看一下它的等价 Python 版本:
|
|
理解以上代码可知,当我们访问 object.name 时会依次执行下列过程:
obj 所属的类 objtype 中查找 name 属性,如果对应的类变量 cls_var 存在,尝试获取 cls_var 所属的类的 __get__ 属性。__get__ 属性存在,即说明 cls_var (至少)是一个非数据描述符。接下来将判断该描述符是否为数据描述符(判断有无 __set__ 或 __delete__ 属性),如果是,则调用在描述符中定义的 __get__ 方法,并传入当前对象 obj 和当前对象所属类 objtype 作为参数,最后返回调用结果,查找结束,数据描述符完全覆盖了对对象本身 __dict__ 的访问。cls_var 为非数据描述符(也可能并非描述符),此时将尝试在对象的字典 __dict__ 中查找 name 属性,若有则返回该属性对应的值。__dict__ 中未找到 name 属性,且 cls_var 为非数据描述符,则调用在描述符中定义的 __get__ 方法,和上文一样传入相应参数并返回调用结果。cls_var 不是描述符,则将其直接返回。AttributeError 异常。在以上过程中,当我们从 obj 所属的类 objtype 中获取 name 属性时,若 objtype 中没找到将尝试从其所继承的父类中查找,具体的顺序取决于 cls.__mro__ 类方法的返回结果:
|
|
现在我们知道,描述符在 object.__getattribute__() 方法中根据不同条件被调用,这就是描述符控制属性访问的工作机制。如果我们重载 object.__getattribute__() 方法,甚至可以取消所有的描述符调用。
__getattr__ 方法实际上,属性查找并不会直接调用 object.__getattribute__() ,点操作符会通过一个辅助函数来执行属性查找:
|
|
因此,如果 obj.__getattribute__() 的结果引发异常,且存在 obj.__getattr__()方法,该方法将被执行。如果用户直接调用 obj.__getattribute__(),__getattr__() 的补充查找机制就会被绕过。
假如为 Foo 类添加该方法:
|
|
然后分别调用 foo.z 和 bar.z:
|
|
该行为仅在对象所属的类定义了 __getattr__()方法时才生效,在对象中定义 __getattr__ 方法,即在 obj.__dict__ 中添加该属性是无效的,这一点同样适用于 __getattribute__() 方法:
|
|
除了一些自定义的场景,Python 本身的语言机制中就大量使用了描述符。
property 的具体效果我们不再赘述,下面是其常见的语法糖用法:
|
|
property 本身是一个实现了描述符协议的类,它还可以通过以下等价方式使用:
|
|
在上面例子中 property(getx, setx, delx, "I'm the 'x' property.") 创建了一个描述符实例,并赋值给了 x。property 类的实现与下面的 Python 代码等价:
|
|
property 在描述符实例的字典内保存读、写、删除函数,然后在协议方法被调用时判断是否存在相应函数,实现对属性的读、写与删除的控制。
没错,每一个我们定义的函数对象都是一个非数据描述符实例。
这里使用描述符的目的,是让在类定义中所定义的函数在通过对象调用时成为绑定方法(bound method)。
方法在调用时会自动传入对象实例作为第一个参数,这是方法和普通函数的唯一区别。通常我们会在定义方法时,将这个形参指定为 self。方法对象的类定义与下面的代码等价:
|
|
它在初始化方法中接收一个函数 func 和一个对象 obj,并在调用时将 obj 传入 func 中。
我们举一个实际的例子:
|
|
可以看到,当通过类属性调用 f 时,其行为就是一个正常的函数,可以将任意对象作为 self 参数传入;当通过实例属性访问 f 时,其效果变成了绑定方法调用,因此在调用时会自动将绑定的对象作为第一个参数。
显然在通过实例访问属性时创建一个 MethodType 对象,这正是我们可以通过描述符实现的效果。
函数的具体实现如下:
|
|
通过 def f() 定义函数时,等价于 f = Function() ,即创建一个非数据描述符实例并赋值给 f 变量。
当我们通过类方法访问该属性时,调用 __get__() 方法返回了函数对象本身:
>>> D.f
<function D.f at 0x10f1903a0>
当我们通过对象实例访问该属性时, 调用 __get__() 方法创建一个使用以上函数和对象所初始化的 MethodType 对象:
>>> d.f
<bound method D.f of <__main__.D object at 0x10eb6fb50>>
概括地说,函数作为对象有一个 __get__() 方法,使其成为一个非数据描述符实例,这样当它们作为属性访问时就可以转换为绑定方法。非数据描述符将通过实例调用 obj.f(*args) 转换为 f(obj, *args),通过类调用 cls.f(*args) 转换成 f(*args)。
classmethod 是在函数描述符基础上实现的变种,其用法如下:
|
|
其等价 Python 实现如下,有了上面的铺垫会很容易理解:
|
|
@classmethod 返回一个非数据描述符,实现了将通过实例调用 obj.f(*args) 转换为 f(type(obj), *args),通过类调用 cls.f(*args) 转换成 f(*args)。
staticmethod 实现的效果是,不管我们通过实例调用还是通过类调用,最终都会调用原始的函数:
|
|
其等价 Python 实现如下:
|
|
调用 __get__() 方法时返回了保存在 __dict__ 中的函数对象本身,因此不会进一步触发函数的描述符行为。
@staticmethod 返回一个非数据描述符,实现了将通过实例调用 obj.f(*args) 转换为 f(*args),通过类调用 cls.f(*args) 也转换成 f(*args)。
描述符是 Python 中的一个进阶概念,也是许多 Python 内部机制的实现基础,本文将对其做适当深入的介绍。
描述符的定义很简单,实现了下列任意一个方法的 Python 对象就是一个描述符(descriptor):
__get__(self, obj, type=None)__set__(self, obj, value)__delete__(self, obj)这些方法的参数含义如下:
self 是当前定义的描述符对象实例。obj 是该描述符将作用的对象实例。type 是该描述符作用的对象的类型(即所属的类)。上述方法也被称为描述符协议,Python 会在特定的时机按协议传入参数调用某一方法,如果我们未按协议约定的参数定义方法,调用可能会出错。
描述符可以用来控制对属性的访问行为,实现计算属性、懒加载属性、属性访问控制等功能,我们先来举个简单的例子:
|
|
在示例中我们创建了一个描述符实例,并将其赋值给 Foo 类的 x 属性变量。现在访问 Foo.x ,会发现 Python 自动调用了该属性所绑定的描述符实例的 __get__() 方法:
|
|
接下来实例化一个对象 foo,并通过 foo 对象访问 x 属性:
|
|
同样执行了描述符所定义的相应方法。
如果我们尝试对 foo 对象的 x 进行赋值,也会调用描述符的 __set__() 方法:
|
|
同理,如果我们在描述符中定义了 __delete__() 方法,该方法将在执行 del foo.x 时被调用。
描述符在属性查找过程中会被 . 点操作符调用,且只有在作为类变量使用时才有效。
如果直接赋值给实例属性,描述符不会生效。
>>> foo.__dict__['y'] = Descriptor()
>>> print(foo.y)
<__main__.Descriptor object at 0x100f0d130>
如果用 some_class.__dict__[descriptor_name] 的方式间接访问描述符,也不会调用描述符的协议方法,而是返回描述符实例本身。
print(Foo.__dict__['x'])
<__main__.Descriptor object at 0x10b66d8e0>
根据所实现的协议方法不同,描述符又可分为两类:
__set__() 或 __delete__() 任一方法,该描述符是一个数据描述符(data descriptor)。__get__() 方法,该描述符是一个非数据描述符(non-data descriptor)。两者的在表现行为上存在差异:
__dict__ 中的属性。__dict__ 中定义的属性所覆盖。在上面的示例中我们已经展示数据描述符的效果,接下来去掉 __set__() 方法实现一个非数据描述符:
|
|
当 bar.__dict__ 不存在键为 y 的属性时,访问 bar.y 和 foo.x 的行为是一致的:
|
|
但如果我们直接修改 bar 对象的 __dict__,向其中添加 y 属性,则该对象属性将覆盖在 Bar 类中定义的 y 描述符,访问 bar.y 将不再调用描述符的 __get__() 方法:
|
|
而在上文的数据描述符示例中,即使我们修改 foo.__dict__,对 x 属性的访问始终都由描述符所控制:
|
|
在下文中我们会介绍这两者的差异是如何实现的。
描述符控制属性访问的关键,在于从执行 foo.x 到 __get()__ 方法被调用这中间所发生的过程。
一般来说,对象的属性保存在 __dict__ 属性中:
object.__dict__ 是一个字典或其他的映射类型对象,用于存储一个对象的(可写)属性。__dict__ 属性。__dict__ 也被称为 mappingproxy 对象。我们从之前的示例继续:
|
|
当我们访问 foo.x ,Python 是如何判断应该调用描述符方法还是从 __dict__ 中获取对应值的呢?其中起关键作用的是 . 这个点操作符。
点操作符的查找逻辑位于 object.__getattribute__() 方法中,每一次向对象执行点操作符都会调用对象的该方法。CPython 中该方法由 C 实现,我们来看一下它的等价 Python 版本:
|
|
理解以上代码可知,当我们访问 object.name 时会依次执行下列过程:
obj 所属的类 objtype 中查找 name 属性,如果对应的类变量 cls_var 存在,尝试获取 cls_var 所属的类的 __get__ 属性。__get__ 属性存在,即说明 cls_var (至少)是一个非数据描述符。接下来将判断该描述符是否为数据描述符(判断有无 __set__ 或 __delete__ 属性),如果是,则调用在描述符中定义的 __get__ 方法,并传入当前对象 obj 和当前对象所属类 objtype 作为参数,最后返回调用结果,查找结束,数据描述符完全覆盖了对对象本身 __dict__ 的访问。cls_var 为非数据描述符(也可能并非描述符),此时将尝试在对象的字典 __dict__ 中查找 name 属性,若有则返回该属性对应的值。__dict__ 中未找到 name 属性,且 cls_var 为非数据描述符,则调用在描述符中定义的 __get__ 方法,和上文一样传入相应参数并返回调用结果。cls_var 不是描述符,则将其直接返回。AttributeError 异常。在以上过程中,当我们从 obj 所属的类 objtype 中获取 name 属性时,若 objtype 中没找到将尝试从其所继承的父类中查找,具体的顺序取决于 cls.__mro__ 类方法的返回结果:
|
|
现在我们知道,描述符在 object.__getattribute__() 方法中根据不同条件被调用,这就是描述符控制属性访问的工作机制。如果我们重载 object.__getattribute__() 方法,甚至可以取消所有的描述符调用。
__getattr__ 方法实际上,属性查找并不会直接调用 object.__getattribute__() ,点操作符会通过一个辅助函数来执行属性查找:
|
|
因此,如果 obj.__getattribute__() 的结果引发异常,且存在 obj.__getattr__()方法,该方法将被执行。如果用户直接调用 obj.__getattribute__(),__getattr__() 的补充查找机制就会被绕过。
假如为 Foo 类添加该方法:
|
|
然后分别调用 foo.z 和 bar.z:
|
|
该行为仅在对象所属的类定义了 __getattr__()方法时才生效,在对象中定义 __getattr__ 方法,即在 obj.__dict__ 中添加该属性是无效的,这一点同样适用于 __getattribute__() 方法:
|
|
除了一些自定义的场景,Python 本身的语言机制中就大量使用了描述符。
property 的具体效果我们不再赘述,下面是其常见的语法糖用法:
|
|
property 本身是一个实现了描述符协议的类,它还可以通过以下等价方式使用:
|
|
在上面例子中 property(getx, setx, delx, "I'm the 'x' property.") 创建了一个描述符实例,并赋值给了 x。property 类的实现与下面的 Python 代码等价:
|
|
property 在描述符实例的字典内保存读、写、删除函数,然后在协议方法被调用时判断是否存在相应函数,实现对属性的读、写与删除的控制。
没错,每一个我们定义的函数对象都是一个非数据描述符实例。
这里使用描述符的目的,是让在类定义中所定义的函数在通过对象调用时成为绑定方法(bound method)。
方法在调用时会自动传入对象实例作为第一个参数,这是方法和普通函数的唯一区别。通常我们会在定义方法时,将这个形参指定为 self。方法对象的类定义与下面的代码等价:
|
|
它在初始化方法中接收一个函数 func 和一个对象 obj,并在调用时将 obj 传入 func 中。
我们举一个实际的例子:
|
|
可以看到,当通过类属性调用 f 时,其行为就是一个正常的函数,可以将任意对象作为 self 参数传入;当通过实例属性访问 f 时,其效果变成了绑定方法调用,因此在调用时会自动将绑定的对象作为第一个参数。
显然在通过实例访问属性时创建一个 MethodType 对象,这正是我们可以通过描述符实现的效果。
函数的具体实现如下:
|
|
通过 def f() 定义函数时,等价于 f = Function() ,即创建一个非数据描述符实例并赋值给 f 变量。
当我们通过类方法访问该属性时,调用 __get__() 方法返回了函数对象本身:
>>> D.f
<function D.f at 0x10f1903a0>
当我们通过对象实例访问该属性时, 调用 __get__() 方法创建一个使用以上函数和对象所初始化的 MethodType 对象:
>>> d.f
<bound method D.f of <__main__.D object at 0x10eb6fb50>>
概括地说,函数作为对象有一个 __get__() 方法,使其成为一个非数据描述符实例,这样当它们作为属性访问时就可以转换为绑定方法。非数据描述符将通过实例调用 obj.f(*args) 转换为 f(obj, *args),通过类调用 cls.f(*args) 转换成 f(*args)。
classmethod 是在函数描述符基础上实现的变种,其用法如下:
|
|
其等价 Python 实现如下,有了上面的铺垫会很容易理解:
|
|
@classmethod 返回一个非数据描述符,实现了将通过实例调用 obj.f(*args) 转换为 f(type(obj), *args),通过类调用 cls.f(*args) 转换成 f(*args)。
staticmethod 实现的效果是,不管我们通过实例调用还是通过类调用,最终都会调用原始的函数:
|
|
其等价 Python 实现如下:
|
|
调用 __get__() 方法时返回了保存在 __dict__ 中的函数对象本身,因此不会进一步触发函数的描述符行为。
@staticmethod 返回一个非数据描述符,实现了将通过实例调用 obj.f(*args) 转换为 f(*args),通过类调用 cls.f(*args) 也转换成 f(*args)。