理解 Python 中的描述符

描述符是 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.yfoo.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 时会依次执行下列过程:

  1. 首先从 obj 所属的类 objtype 中查找 name 属性,如果对应的类变量 cls_var 存在,尝试获取 cls_var 所属的类的 __get__ 属性。
  2. 如果 __get__ 属性存在,即说明 cls_var (至少)是一个非数据描述符。接下来将判断该描述符是否为数据描述符(判断有无 __set____delete__ 属性),如果是,则调用在描述符中定义的 __get__ 方法,并传入当前对象 obj 和当前对象所属类 objtype 作为参数,最后返回调用结果,查找结束,数据描述符完全覆盖了对对象本身 __dict__ 的访问。
  3. 如果 cls_var 为非数据描述符(也可能并非描述符),此时将尝试在对象的字典 __dict__ 中查找 name 属性,若有则返回该属性对应的值。
  4. 如果在 obj 的 __dict__ 中未找到 name 属性,且 cls_var 为非数据描述符,则调用在描述符中定义的 __get__ 方法,和上文一样传入相应参数并返回调用结果。
  5. 如果 cls_var 不是描述符,则将其直接返回。
  6. 如果最后还没找到,唤起 AttributeError 异常。

在以上过程中,当我们从 obj 所属的类 objtype 中获取 name 属性时,若 objtype 中没找到将尝试从其所继承的父类中查找,具体的顺序取决于 cls.__mro__ 类方法的返回结果:

1
2
>>> print(Foo.__mro__)
(<class '__main__.Foo'>, <class 'object'>)

现在我们知道,描述符在 object.__getattribute__() 方法中根据不同条件被调用,这就是描述符控制属性访问的工作机制。如果我们重载 object.__getattribute__() 方法,甚至可以取消所有的描述符调用。

__getattr__ 方法

实际上,属性查找并不会直接调用 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.zbar.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 内部的描述符

除了一些自定义的场景,Python 本身的语言机制中就大量使用了描述符。

property

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.") 创建了一个描述符实例,并赋值给了 xproperty 类的实现与下面的 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

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

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)

参考链接

updatedupdated2022-08-032022-08-03