描述符是 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)。