描述符是 Python 中的一个进阶概念,也是许多 Python 内部机制的实现基础,本文将对其做适当深入的介绍。
描述符的定义很简单,实现了下列任意一个方法的 Python 对象就是一个描述符(descriptor):
__get__(self, obj, type=None)
__set__(self, obj, value)
__delete__(self, obj)
这些方法的参数含义如下:
self
是当前定义的描述符对象实例。obj
是该描述符将作用的对象实例。type
是该描述符作用的对象的类型(即所属的类)。
上述方法也被称为描述符协议,Python 会在特定的时机按协议传入参数调用某一方法,如果我们未按协议约定的参数定义方法,调用可能会出错。
描述符可以用来控制对属性的访问行为,实现计算属性、懒加载属性、属性访问控制等功能,我们先来举个简单的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| class Descriptor:
def __get__(self, instance, owner):
if instance is None:
print('__get__(): Accessing x from the class', owner)
return self
print('__get__(): Accessing x from the object', instance)
return 'X from descriptor'
def __set__(self, instance, value):
print('__set__(): Setting x on the object', instance)
instance.__dict__['_x'] = value
class Foo:
x = Descriptor()
|
在示例中我们创建了一个描述符实例,并将其赋值给 Foo
类的 x
属性变量。现在访问 Foo.x
,会发现 Python 自动调用了该属性所绑定的描述符实例的 __get__()
方法:
1
2
3
| >>> print(Foo.x)
__get__(): Accessing x from the class <class '__main__.Foo'>
<__main__.Descriptor object at 0x106e138e0>
|
接下来实例化一个对象 foo
,并通过 foo
对象访问 x
属性:
1
2
3
4
| >>> foo = Foo()
>>> print(foo.x)
__get__(): Accessing x from the object <__main__.Foo object at 0x105dc9340>
X from descriptor
|
同样执行了描述符所定义的相应方法。
如果我们尝试对 foo
对象的 x
进行赋值,也会调用描述符的 __set__()
方法:
1
2
3
4
5
6
7
| >>> foo.x = 1
__set__(): Setting x on the object <__main__.Foo object at 0x105dc9340>
>>> print(foo.x)
__get__(): Accessing x from the object <__main__.Foo object at 0x105dc9340>
X from descriptor
>>> print(foo.__dict__)
{'_x': 1}
|
同理,如果我们在描述符中定义了 __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__()
方法实现一个非数据描述符:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| class NonDataDescriptor:
def __get__(self, instance, owner):
if instance is None:
print('__get__(): Accessing y from the class', owner)
return self
print('__get__(): Accessing y from the object', instance)
return 'Y from non-data descriptor'
class Bar:
y = NonDataDescriptor()
bar = Bar()
|
当 bar.__dict__
不存在键为 y
的属性时,访问 bar.y
和 foo.x
的行为是一致的:
1
2
| >>> print(bar.y)
Y from non-data descriptor
|
但如果我们直接修改 bar
对象的 __dict__
,向其中添加 y
属性,则该对象属性将覆盖在 Bar
类中定义的 y
描述符,访问 bar.y
将不再调用描述符的 __get__()
方法:
1
2
3
| >>> bar.__dict__['y'] = 2
>>> print(bar.y)
2
|
而在上文的数据描述符示例中,即使我们修改 foo.__dict__
,对 x
属性的访问始终都由描述符所控制:
1
2
3
| >>> foo.__dict__['x'] = 1
>>> print(foo.x)
__get__(): Accessing x from the object <__main__.Foo object at 0x102b40340>
|
在下文中我们会介绍这两者的差异是如何实现的。
描述符控制属性访问的关键,在于从执行 foo.x
到 __get()__
方法被调用这中间所发生的过程。
一般来说,对象的属性保存在 __dict__
属性中:
- 根据 Python 文档介绍,
object.__dict__
是一个字典或其他的映射类型对象,用于存储一个对象的(可写)属性。 - 除了一些 Python 的内置对象以外,大部分自定义的对象都会有一个
__dict__
属性。 - 这个属性包含了所有为该对象定义的属性,
__dict__
也被称为 mappingproxy
对象。
我们从之前的示例继续:
1
2
3
4
| >>> print(foo.__dict__)
{'_x': 1}
>>> foo.x
1
|
当我们访问 foo.x
,Python 是如何判断应该调用描述符方法还是从 __dict__
中获取对应值的呢?其中起关键作用的是 .
这个点操作符。
点操作符的查找逻辑位于 object.__getattribute__()
方法中,每一次向对象执行点操作符都会调用对象的该方法。CPython 中该方法由 C 实现,我们来看一下它的等价 Python 版本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| def object_getattribute(obj, name):
"Emulate PyObject_GenericGetAttr() in Objects/object.c"
null = object()
objtype = type(obj)
cls_var = getattr(objtype, name, null)
descr_get = getattr(type(cls_var), '__get__', null)
if descr_get is not null:
if (hasattr(type(cls_var), '__set__')
or hasattr(type(cls_var), '__delete__')):
return descr_get(cls_var, obj, objtype) # data descriptor
if hasattr(obj, '__dict__') and name in vars(obj):
return vars(obj)[name] # instance variable
if descr_get is not null:
return descr_get(cls_var, obj, objtype) # non-data descriptor
if cls_var is not null:
return cls_var # class variable
raise AttributeError(name)
|
理解以上代码可知,当我们访问 object.name
时会依次执行下列过程:
- 首先从
obj
所属的类 objtype
中查找 name
属性,如果对应的类变量 cls_var
存在,尝试获取 cls_var
所属的类的 __get__
属性。 - 如果
__get__
属性存在,即说明 cls_var
(至少)是一个非数据描述符。接下来将判断该描述符是否为数据描述符(判断有无 __set__
或 __delete__
属性),如果是,则调用在描述符中定义的 __get__
方法,并传入当前对象 obj
和当前对象所属类 objtype
作为参数,最后返回调用结果,查找结束,数据描述符完全覆盖了对对象本身 __dict__
的访问。 - 如果
cls_var
为非数据描述符(也可能并非描述符),此时将尝试在对象的字典 __dict__
中查找 name
属性,若有则返回该属性对应的值。 - 如果在 obj 的
__dict__
中未找到 name
属性,且 cls_var
为非数据描述符,则调用在描述符中定义的 __get__
方法,和上文一样传入相应参数并返回调用结果。 - 如果
cls_var
不是描述符,则将其直接返回。 - 如果最后还没找到,唤起
AttributeError
异常。
在以上过程中,当我们从 obj
所属的类 objtype
中获取 name
属性时,若 objtype
中没找到将尝试从其所继承的父类中查找,具体的顺序取决于 cls.__mro__
类方法的返回结果:
1
2
| >>> print(Foo.__mro__)
(<class '__main__.Foo'>, <class 'object'>)
|
现在我们知道,描述符在 object.__getattribute__()
方法中根据不同条件被调用,这就是描述符控制属性访问的工作机制。如果我们重载 object.__getattribute__()
方法,甚至可以取消所有的描述符调用。
实际上,属性查找并不会直接调用 object.__getattribute__()
,点操作符会通过一个辅助函数来执行属性查找:
1
2
3
4
5
6
7
8
| def getattr_hook(obj, name):
"Emulate slot_tp_getattr_hook() in Objects/typeobject.c"
try:
return obj.__getattribute__(name)
except AttributeError:
if not hasattr(type(obj), '__getattr__'):
raise
return type(obj).__getattr__(obj, name) # __getattr__
|
因此,如果 obj.__getattribute__()
的结果引发异常,且存在 obj.__getattr__()
方法,该方法将被执行。如果用户直接调用 obj.__getattribute__()
,__getattr__()
的补充查找机制就会被绕过。
假如为 Foo
类添加该方法:
1
2
3
4
5
6
7
| class Foo:
x = Descriptor()
def __getattr__(self, item):
print(f'{item} is indeed not found')
foo = Foo()
|
然后分别调用 foo.z
和 bar.z
:
1
2
3
4
| >>> foo.z
z is indeed not found
>>> bar.z
AttributeError: 'Bar' object has no attribute 'z'
|
该行为仅在对象所属的类定义了 __getattr__()
方法时才生效,在对象中定义 __getattr__
方法,即在 obj.__dict__
中添加该属性是无效的,这一点同样适用于 __getattribute__()
方法:
1
2
3
4
5
| >>> bar.__getattr__ = lambda item:print(f'{item} is indeed not found')
>>> print(bar.__dict__)
{'__getattr__': <function <lambda> at 0x1086e1430>}
>>> bar.z
AttributeError: 'Bar' object has no attribute 'z'
|
除了一些自定义的场景,Python 本身的语言机制中就大量使用了描述符。
property
的具体效果我们不再赘述,下面是其常见的语法糖用法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| class C:
def __init__(self):
self._x = None
@property
def x(self):
"""I'm the 'x' property."""
return self._x
@x.setter
def x(self, value):
self._x = value
@x.deleter
def x(self):
del self._x
|
property
本身是一个实现了描述符协议的类,它还可以通过以下等价方式使用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| class C:
def __init__(self):
self._x = None
def getx(self):
return self._x
def setx(self, value):
self._x = value
def delx(self):
del self._x
x = property(getx, setx, delx, "I'm the 'x' property.")
|
在上面例子中 property(getx, setx, delx, "I'm the 'x' property.")
创建了一个描述符实例,并赋值给了 x
。property
类的实现与下面的 Python 代码等价:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| class Property:
"Emulate PyProperty_Type() in Objects/descrobject.c"
def __init__(self, fget=None, fset=None, fdel=None, doc=None):
self.fget = fget
self.fset = fset
self.fdel = fdel
if doc is None and fget is not None:
doc = fget.__doc__
self.__doc__ = doc
def __get__(self, obj, objtype=None): # 描述符协议方法
if obj is None:
return self
if self.fget is None:
raise AttributeError("unreadable attribute")
return self.fget(obj)
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): # 实例化一个拥有 fget 属性的描述符对象
return type(self)(fget, self.fset, self.fdel, self.__doc__)
def setter(self, fset): # 实例化一个拥有 fset 属性的描述符对象
return type(self)(self.fget, fset, self.fdel, self.__doc__)
def deleter(self, fdel): # 实例化一个拥有 fdel 属性的描述符对象
return type(self)(self.fget, self.fset, fdel, self.__doc__)
|
property
在描述符实例的字典内保存读、写、删除函数,然后在协议方法被调用时判断是否存在相应函数,实现对属性的读、写与删除的控制。
没错,每一个我们定义的函数对象都是一个非数据描述符实例。
这里使用描述符的目的,是让在类定义中所定义的函数在通过对象调用时成为绑定方法(bound method)。
方法在调用时会自动传入对象实例作为第一个参数,这是方法和普通函数的唯一区别。通常我们会在定义方法时,将这个形参指定为 self
。方法对象的类定义与下面的代码等价:
1
2
3
4
5
6
7
8
9
10
11
| class MethodType:
"Emulate PyMethod_Type in Objects/classobject.c"
def __init__(self, func, obj):
self.__func__ = func
self.__self__ = obj
def __call__(self, *args, **kwargs):
func = self.__func__
obj = self.__self__
return func(obj, *args, **kwargs)
|
它在初始化方法中接收一个函数 func
和一个对象 obj
,并在调用时将 obj
传入 func
中。
我们举一个实际的例子:
1
2
3
4
5
6
7
8
9
10
| >>> class D:
... def f(self, x):
... return x
...
...
>>> d = D()
>>> D.f(None, 2)
2
>>> d.f(2)
2
|
可以看到,当通过类属性调用 f
时,其行为就是一个正常的函数,可以将任意对象作为 self
参数传入;当通过实例属性访问 f
时,其效果变成了绑定方法调用,因此在调用时会自动将绑定的对象作为第一个参数。
显然在通过实例访问属性时创建一个 MethodType
对象,这正是我们可以通过描述符实现的效果。
函数的具体实现如下:
1
2
3
4
5
6
7
8
| class Function:
...
def __get__(self, obj, objtype=None):
"Simulate func_descr_get() in Objects/funcobject.c"
if obj is None:
return self
return MethodType(self, obj)
|
通过 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
是在函数描述符基础上实现的变种,其用法如下:
1
2
3
4
5
6
7
8
9
| class F:
@classmethod
def f(cls, x):
return cls.__name__, x
>>> F.f(3)
('F', 3)
>>> F().f(3)
('F', 3)
|
其等价 Python 实现如下,有了上面的铺垫会很容易理解:
1
2
3
4
5
6
7
8
9
10
11
12
| class ClassMethod:
"Emulate PyClassMethod_Type() in Objects/funcobject.c"
def __init__(self, f):
self.f = f
def __get__(self, obj, cls=None):
if cls is None:
cls = type(obj)
if hasattr(obj, '__get__'):
return self.f.__get__(cls)
return MethodType(self.f, cls)
|
@classmethod
返回一个非数据描述符,实现了将通过实例调用 obj.f(*args)
转换为 f(type(obj), *args)
,通过类调用 cls.f(*args)
转换成 f(*args)
。
staticmethod
实现的效果是,不管我们通过实例调用还是通过类调用,最终都会调用原始的函数:
1
2
3
4
5
6
7
8
9
| class E:
@staticmethod
def f(x):
return x * 10
>>> E.f(3)
30
>>> E().f(3)
30
|
其等价 Python 实现如下:
1
2
3
4
5
6
7
8
| class StaticMethod:
"Emulate PyStaticMethod_Type() in Objects/funcobject.c"
def __init__(self, f):
self.f = f
def __get__(self, obj, objtype=None):
return self.f
|
调用 __get__()
方法时返回了保存在 __dict__
中的函数对象本身,因此不会进一步触发函数的描述符行为。
@staticmethod
返回一个非数据描述符,实现了将通过实例调用 obj.f(*args)
转换为 f(*args)
,通过类调用 cls.f(*args)
也转换成 f(*args)
。