总览

  • 描述器让对象能够自定义属性查找、存储和删除的操作。

  • descriptor就是任何一个定义了__get__()__set__()__delete__() 的对象。

  • 描述器仅在用作类变量时起作用。放入实例时,它们将失效。

  • 描述器的主要目的是提供一个钩子,允许存储在类变量中的对象控制在属性查找期间发生的情况。

  • 传统上,调用类控制查找过程中发生的事情。描述器反转了这种关系,并允许正在被查询的数据对此进行干涉

  • 描述器的使用贯穿了整个语言。就是它让函数变成绑定方法。常见工具诸如classmethod()staticmethod()property()functools.cached_property() 都作为描述器实现。

描述符例子一:入门

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class Ten:
    def __get__(self, instance, owner):
        return 10


class A:
    x = 5
    y = Ten()


a = A()
print(a.x)
print(a.y)
  • 类Ten是一个描述器,它的__get__()方法总是返回常量10
  • a.ya.x不同,它是通过Ten.__get__获取的,对y的访问属于描述器的访问,而对x的访问,是对普通类attribute的访问

描述符例子二:动态计算

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12

class DirectorySize:

    def __get__(self, obj, objtype=None):
        return len(os.listdir(obj.dirname))

class Directory:

    size = DirectorySize()              # Descriptor instance

    def __init__(self, dirname):
        self.dirname = dirname          # Regular instance attribute

输出:

1
2
3
4
5
6
7
8
9
>>> s = Directory('songs')
>>> g = Directory('games')
>>> s.size                              # The songs directory has twenty files
20
>>> g.size                              # The games directory has three files
3
>>> open('games/newfile').close()       # Add a fourth file to the directory
>>> g.size                              # File count is automatically updated
4

这个例子想让我们体会以下几点:

  1. 描述符与直接访问相比,显然是多了一层。多层意味着什么?参考数据库三层架构这一设计,我们可以改变底层而上层不变,可以改变上层而底层不变。在这个例子中,我们改变了目录名称,底层的逻辑不变,仍旧获得了对应的结果
  2. 这个例子突出的是,结果的动态计算这一特点
  3. 描述符中__get__(self, obj, objtype=None)中,self是实例size, objsg, objtype<class 'Directory'>

托管属性

描述器的一种流行用法是托管对实例数据的访问。描述器被分配给类字典中的公开属性,而实际数据作为私有属性存储在实例字典中。当读或写公开属性时,会分别触发描述器的 get() 和 set() 方法。

例子: 只允许设置一次的name

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
class Descriptor:
    def __set__(self, instance, value):
        if hasattr(instance, '_name'):
            raise
        else:
            instance._name = value

    def __get__(self, instance, owner):
        return getattr(instance, '_name')

class MyClass:

    name = Descriptor()

    def __init__(self, name):
        self.name = name


mc = MyClass('Cat Tom')
print(mc.name)
mc.name = 'Tom Cat'      # 将触发异常

这个例子也体现出了这么做的一个缺点,那就是硬编码了变量_name, 这十分不好。可以使用__set_name__()解决这个问题。

使用__set_name__()定制名称

在描述器中实现__set_name__()方法, 则用到描述器的类在定义属性时,会回调描述器中的__set_name__()方法,给类属性定制名称:

 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
class Descriptor:
    def __set_name__(self, owner, name):
        self.public_name = name
        self.private_name = '_' + name

    def __set__(self, instance, value):
        if hasattr(instance, self.public_name):
            raise
        else:
            setattr(instance, self.private_name, value)

    def __get__(self, instance, owner):
        print(f'access {self.public_name}')
        return getattr(instance, self.private_name)

class Person:

    name = Descriptor()
    age = Descriptor()

    def __init__(self, name, age):
        self.name = name
        self.age = age


p = Person('Tom', 10)
print(p.name, p.age)
print(vars(p))

输出:

1
2
3
4
5
6
access name
access age
access name
access age
Tom 10
{'_name': 'Tom', '_age': 10}

在属性查找期间,描述器由点运算符调用。如果使用vars(some_class)[descriptor_name]间接访问描述器,则返回描述器实例而不调用它。

描述符的一大用处————数据验证器

初始化一个类时总要对初始化变量进行各种验证,如数值的范围,字符串长度,枚举类型。现在使用描述符实现一个验证器,以避免使用大量的if else,避免__init__()变得臃肿

 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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
from abc import ABC, abstractmethod

class Validator(ABC):
    def __set_name__(self, owner, name):
        self.private_name = '_' + name

    def __get__(self, instance, owner):
        return getattr(instance, self.private_name)

    def __set__(self, instance, value):
        self.validate(value)
        setattr(instance, self.private_name, value)

    @abstractmethod
    def validate(self, value):
        pass

class Integer(Validator):
    def __init__(self, minvalue, maxvalue):
        self.minvalue = minvalue
        self.maxvalue = maxvalue

    def validate(self, value):
        if isinstance(value, int) and self.minvalue <= value <= self.maxvalue:
            pass
        else:
            raise Exception('Integer validation fail')

class String(Validator):
    def __init__(self, max_length=256):
        self.max_length = max_length

    def validate(self, value):
        if isinstance(value, str) and len(value) < self.max_length:
            pass
        else:
            raise Exception('String validation fail')

class Enum(Validator):
    def __init__(self, option):
        self.option = option

    def validate(self, value):
        if value in self.option:
            pass
        else:
            raise Exception('Enum validation fail')

class Person:
    name = String(max_length=64)
    age = Integer(minvalue=0, maxvalue=200)
    sex = Enum(('male', 'female'))

    def __init__(self, name, age, sex):
        self.name = name
        self.age = age
        self.sex = sex

    def __repr__(self):
        return f'Person{self.name, self.age, self.sex}'

p = Person(name='Tom', age=13, sex='male')
print(p)
p.age = -1    # 产生异常

描述器进阶

描述器是一个强大而通用的协议。 它们是属性、方法、静态方法、类方法和 super() 背后的实现机制。 它们在 Python 内部被广泛使用。 描述器简化了底层的 C 代码并为 Python 的日常程序提供了一组灵活的新工具。

描述器协议

descr.__get__(self, obj, type=None) -> value

descr.__set__(self, obj, value) -> None

descr.__delete__(self, obj) -> None

描述器的方法就这些。一个对象只要定义了以上方法中的任何一个,就被视为描述器,并在被作为属性时覆盖其默认行为。

如果一个对象定义了__set__()__delete__(),则它会被视为数据描述器。 仅定义了__get__()的描述器称为非数据描述器(它们经常被用于方法,但也可以有其他用途)。

为了使数据描述器成为只读的,应该同时定义__get__()__set__() ,并在__set__()中引发 AttributeError。用引发异常的占位符定义__set__()方法使其成为数据描述器。

数据和非数据描述器的不同之处在于,如何计算实例字典中条目的替代值。如果实例的字典具有与数据描述器同名的条目,则数据描述器优先。如果实例的字典具有与非数据描述器同名的条目,则该字典条目优先。举例如下:

 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
class DataDescriptor:
    def __set_name__(self, owner, name):
        self.public_name = name

    def __set__(self, instance, value):
        instance.__dict__[self.public_name] = value

    def __get__(self, instance, owner):
        print('call DataDescriptor __get__()')
        return 'data descriptor'

class NonDataDescriptor:
    def __get__(self, instance, owner):
        print('call NonDataDescriptor __get__()')
        return 'non-data descriptor'

class MyClass:
    data = DataDescriptor()
    non_data = NonDataDescriptor()

    def __init__(self, data, non_data):
        self.data = data
        self.non_data = non_data

mc = MyClass(-1, 1)
print(mc.data)
# >>> call DataDescriptor __get__()
# >>> data descriptor

print(mc.non_data)
# >>> 1

注意到,mc.non_data的输出为1,没有输出语句call NonDataDescriptor __get__()

属性访问的顺序

属性访问的默认行为是从一个对象的字典中获取、设置或删除属性。对于实例来说,访问顺序为:

  1. a.x的查找顺序会从a.__dict__['x']开始,若找到,则返回其值,否则执行2
  2. type(a).__dict__['x']中进行查找,即在实例a所属的class中进行查找,找不到执行3
  3. 依次查找type(a)的方法解析顺序(MRO)中的class的__dict__。 如果找到的值是定义了某个描述器方法的对象,则 Python 可能会重写默认行为并转而发起调用描述器方法。这具体发生在优先级链的哪个环节则要根据所定义的描述器方法及其被调用的方式来决定。

描述器调用概述

描述器可以通过d.__get__(obj)desc.__get__(None, cls)直接调用。函数的绑定和非绑定就是通过这个实现的。举例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class A:

    def func(self):
        pass

a = A()
print(A.func)
print(A.func.__get__(None, A))
# >>> <function A.func at 0x7f71b4a53dc0>
# >>> <function A.func at 0x7f71b4a53dc0>

print(a.func)
print(A.func.__get__(a, A))
# >>> <bound method A.func of <__main__.A object at 0x7f71b4aecc40>>
# >>> <bound method A.func of <__main__.A object at 0x7f71b4aecc40>>

函数也是描述器,实现了__get__(),通过不同参数,表现出bound和unboud

日常使用中,一般都是通过属性访问自动调用描述器。

表达式obj.x在命名空间的链中查找obj 的属性x。如果搜索在实例__dict__之外找到描述器,则根据下面列出的优先级规则调用其__get__()方法。

调用的细节取决于obj是对象、类还是超类的实例。这三类分别讨论如下

通过实例调用

实例查找通过命名空间链进行扫描,数据描述器的优先级最高,其次是实例变量、非数据描述器、类变量,最后是__getattr__()(如果存在的话)。

如果 a.x 找到了一个描述器,那么将通过 desc.get(a, type(a)) 调用它。

点运算符的查找逻辑在object.__getattribute__()中,下面是一个等价的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.getattribute() ,点运算符getattr()函数均通过辅助函数执行属性查找:

1
2
3
4
5
6
7
    "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__

因此,如果__getattr__()存在,则只要__getattribute__()引发AttributeError(直接引发异常或在描述符调用中引发都一样),就会调用它。

同时,如果用户直接调用object.__getattribute__() ,则__getattr__()的钩子将被绕开。

通过类调用

像 A.x 这样的点操作符查找的逻辑在type.__getattribute__()中。步骤与object.__getattribute__()相似,但是实例字典查找改为搜索类的 method resolution order (MRO)。

如果找到了一个描述器,那么将通过desc.__get__(None, A)调用它。

通过super调用

super 的点操作符查找的逻辑在super()返回的对象的__getattribute__()方法中。

类似super(A, obj).m形式的点分查找将在obj.__class__.__mro__中搜索紧接在A之后的基类B,然后返回B.__dict__['m'].__get__(obj, A)。如果m不是描述器,则直接返回其值。

调用逻辑总结:

描述器的机制嵌入在objecttypesuper()__getattribute__()方法中。 要记住的重要点是:

  • 描述器由__getattribute__()方法调用。

  • 类从objecttypesuper()继承此机制。

  • 由于描述器的逻辑在__getattribute__()中,因而重写该方法会阻止描述器的自动调用。

  • object.__getattribute__()type.__getattribute__() 会用不同的方式调用__get__()。前一个会传入实例,也可以包括类。后一个传入的实例为 None ,并且总是包括类。

  • 数据描述器始终会覆盖实例字典。

  • 非数据描述器会被实例字典覆盖。

property

调用property()是构建数据描述器的简洁方式, 其签名为:

1
property(fget=None, fset=None, fdel=None, doc=None) -> property

典型用法:

1
2
3
4
5
class C:
    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的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
37

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):
        return type(self)(fget, self.fset, self.fdel, self.__doc__)

    def setter(self, fset):
        return type(self)(self.fget, fset, self.fdel, self.__doc__)

    def deleter(self, fdel):
        return type(self)(self.fget, self.fset, fdel, self.__doc__)

需要特别注意的是getter, setter, deleter, 我们在用property时使用的是装饰器,而自定义的函数各自被传递进来生成property

函数和方法

Python 的面向对象功能是在基于函数的环境构建的,通过描述器__get__的两种访问方式,实现绑定函数(即方法)和非绑定函数(普通函数): descr.__get__(self, obj, type=None) -> value 利用obj=None实现普通函数,利用obj=some_obj实现方法(绑定self)

可以使用types.MethodType手动创建方法,其行为基本等价于:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    "Emulate Py_MethodType 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)

为了支持自动创建方法,函数包含 get() 方法以便在属性访问时绑定其为方法。这意味着函数其是非数据描述器,它在通过实例进行点查找时返回绑定方法,其运作方式如下:

1
2
3
4
5
6
7
8
9
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)

staticmethod

静态方法返回底层函数,不做任何更改。调用 c.f 或 C.f 等效于通过object.__getattribute__(c, "f")object.__getattribute__(C, "f") 查找。这样该函数就可以从对象或类中进行相同的访问。

使用非数据描述器,纯 Python 版本的 staticmethod() 如下所示:

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

classmethod

使用非数据描述器协议,纯 Python 版本的 classmethod() 如下:

 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)

类方法的一种用途是创建备用类构造函数。例如,类方法 dict.fromkeys() 从键列表创建一个新字典。纯 Python 的等价实现是:

1
2
3
4
5
6
7
8
class Dict(dict):
    @classmethod
    def fromkeys(cls, iterable, value=None):
        "Emulate dict_fromkeys() in Objects/dictobject.c"
        d = cls()
        for key in iterable:
            d[key] = value
        return d

成员对象和__slots__

当一个类定义了 slots,它会用一个固定长度的 slot 值数组来替换实例字典,它的用处有以下几点:

  • 可以立刻检测出拼写错误
  • 可以创建一个不可变对象,这个对象通过描述器管理存储在__slots__中的私有属性
  • 节约内存。在linux 64位的版本上,一个有两个attribute的实例当使用slots时占用48个字节,而不使用将占用152个字节
  • 阻止那些需要实例字典的工具,如functools.cached_property()

slots副作用:(来源Python slots 详解

  • 每个继承子类需要重新定义slots
  • 实例只能包含那些在__slots__中定义的属性,不够灵活
  • 实例不能有弱引用(weakref)目标,否则要记得把__weakref__放进__slots__

参考资料: