Python 描述符(descriptor)

Python 中有一个很少被使用或者用户自定义的特性,那就是描述符(descriptor),但它是@property, @classmethod, @staticmethodsuper的底层实现机制,我今天就扒一扒它,官方文档对描述符的介绍如下

In general, a descriptor is an object attribute with “binding behavior”, one whose attribute access has been overridden by methods in the descriptor protocol: __get__(), __set__(), and __delete__(). If any of those methods are defined for an object, it is said to be a descriptor.

描述符是绑定了行为的对象属性(object attribute),实现了描述符协议(descriptor protocol),描述符协议就是定义了__get__(),__set__(),__delete__()中的一个或者多个方法,将描述符对象作为其他对象的属性进行访问时,就会产生一些特殊的效果。

上面的定义可能还是有些晦涩,一步步来

默认查找属性

在没有描述符定义情况下,我们访问属性的顺序如下,以a.x为例

  1. 查找实例字典里的属性就是a.__dict__['x']有就返回
  2. 往上查找父类的字典就是a.__class__.__dict__['x']有就返回
  3. 上面都没有就查找父类的基类(不包括元类(metaclass))
  4. 如果定义了__getattr__就会返回此方法
  5. 最后都没有抛出AttributeError
>>> class A:
...     x = 8
...
...
>>> class B(A):
...     pass
...
>>> class C(B):
...     def __getattr__(self, name):
...         if name == 'y':
...             print("call getattr method")
...         else:
...             raise AttributeError
...
...
...
>>> C.__mro__
(<class '__main__.C'>, <class '__main__.B'>, <class '__main__.A'>, <class 'object'>)
>>> a = C()
>>> a.x
8
>>> a.y
call getattr method
>>> a.__dict__
{}
>>> a.x = 99
>>> a.x
99
>>> a.__dict__
{'x': 99}

__getattr__是实例访问没有定义的属性时调用的方法,需要特别定义

描述符协议

object.__get__(self, instance, owner=None)

  • 在访问属性时被调用
  • self是描述符本身,instance是使用描述符的实例,owner是使用描述符的类。
  • 这里调用要分为类属性的调用(调用owner上)和实例对象属性(instance上)的调用。当调用类属性的时候instance=None
  • 返回值或者AttributeError

object.__set__(self, instance, value)

  • 在属性赋值时被调用
  • value为赋的值
  • 无返回值

object.__delete__(self, instance)

  • 在属性被删除时调用
  • 无返回值

object.__set_name__(self, owner, name)

  • owner类创建时被调用,给描述符命名,python3.6 新增
  • name为使用描述符的类的类属性的名字
  • 无返回值

某个类只要定义了以上方法的一个或者多个就是实现了描述符协议,在作为某个对象属性时就是描述符,从而这个对象属性被重写默认的查找行为(上文所述)。描述符分数据描述符(data descriptors)非数据描述符(non-data descriptors)定义了__get__方法的叫非数据描述符,其它情况都是数据描述符,一般定义了__get____set__方法。数据描述符和非数据描述符对属性的查找顺序影响很大

当访问访问属性时,如a.xa为实例访问x属性,如果x是描述符就不再遵守默认的查找行为,看情况优先级如下

  • 如果a中的实例字典有同名的x描述符,且为数据描述符,则数据描述符优先访问
  • 如果a中的实例字典有同名的x描述符,且为非数据描述符,则实例字典里面的优先访问

所以在有描述符的情况下实例属性的查找顺序:数据描述符 > 实例字典 > 非数据描述符

描述符实例

有了上面的理论,我们来看实例

数据描述符(Data Descriptors)

class DataDescriptor:
    """A data descriptor that sets and returns values
       normally and prints a message logging their access.
    """
    def __init__(self, initval):
        self.initval = initval

    def __get__(self, instance, owner):
        print(f"get ... instance: {instance!r}, owner: {owner!r}")
        return self.initval

    def __set__(self, instance, value):
        print(f"set ... instance: {instance!r}, value: {value!r}")
        self.initval = value

以上DataDescriptor定义了__get____set__方法,当用作一个对象属性时就是一个数据描述符

>>> class Person:
...     age = DataDescriptor(10)
...
...
>>> p = Person()
>>> p.__dict__
{}
>>> p.age
get ... instance: <__main__.Person object at 0x110a68590>, owner: <class '__main__.Person'>
10
>>> p.age = 18
set ... instance: <__main__.Person object at 0x110a68590>, value: 18
>>> p.__dict__
{}
>>> p.__dict__['age'] = 100
>>> p.age
get ... instance: <__main__.Person object at 0x110a68590>, owner: <class '__main__.Person'>
18

DataDescriptor(10)对象(age)就是一个数据描述符,根据上文的优先级实例字典是不会对它产生影响的所以p.age还是返回18

需要注意的是描述符作用在对象属性(类属性)上才是描述符,也就是说不能定义在__init__方法下

>>> class Person:
...     age = DataDescriptor(10)
...
...     def __init__(self):
...         self.weight = DataDescriptor(50)
...
...
...
>>> p = Person()
>>> p.weight
<__main__.DataDescriptor object at 0x1085a2250>

上面age是描述符,weight不是。访问p.weight属性只返回DataDescriptor的实例对象

还有一个问题是age其实是一个类属性,Person的所有实例共享age这个实例变量,任何一个实例修改会导致所有的实例都更改。具体参看Python 中的类变量(class variables)和实例变量(instance variables)

>>> class Person:
...     age = DataDescriptor(10)
...
...
>>> p1 = Person()
>>> p2 = Person()
>>> p1.age
get ... instance: <__main__.Person object at 0x10817b090>, owner: <class '__main__.Person'>
10
>>> p2.age
get ... instance: <__main__.Person object at 0x108159210>, owner: <class '__main__.Person'>
10
>>> p1.age = 18
set ... instance: <__main__.Person object at 0x10817b090>, value: 18
>>> p1.age
get ... instance: <__main__.Person object at 0x10817b090>, owner: <class '__main__.Person'>
18
>>> p2.age
get ... instance: <__main__.Person object at 0x108159210>, owner: <class '__main__.Person'>
18

p1.age更改后p2.age的值也随之改变了,可以使用一个字典存储每个实例对应的值

from weakref import WeakKeyDictionary

class DataDescriptor:
    def __init__(self, default):
        self.default = default
        self.data = WeakKeyDictionary()

    def __get__(self, instance, owner):
        return self.data.get(instance, self.default)

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError(f"Negative value not allowed: {value}")
        self.data[instance] = value

这样确保了每个实例对应的值都相互不影响,这里使用了弱引用字典防止内存爆表。还在赋值的时候做了非负检查

>>> class Person:
...     age = DataDescriptor(1)
...
...
>>> p1 = Person()
>>> p2 = Person()
>>> p1.age
1
>>> p2.age
1
>>> p1.age = 18
>>> p1.age
18
>>> p2.age
1

最后一个问题就是正因为用的是字典存储专属于实例的数据,特殊情况是如果实例对象(instance)不可哈希,那就会报错

>>> class MyList(list):
...     x = DataDescriptor(10)
...
...
>>> m = MyList()
>>> m.x
Traceback (most recent call last):
...
TypeError: unhashable type: 'MyList'

Mylist继承自list,所以传入的实例instance是不可哈希的,一个解决办法就是每次使用描述符的时候给它取个名字加标签

class DataDescriptor:
    def __init__(self, default, name):
        self.default = default
        self.name = name

    def __get__(self, instance, owner):
        return instance.__dict__.get(self.name, self.default)

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError(f"Negative value not allowed: {value}")
        instance.__dict__[self.name] = value

class MyList(list):
    x = DataDescriptor(1, 'x')

m = MyList()
print(m.x)     # 1
m.x = 8
print(m.x)     # 8

用一开始传入的name作为键,就避免了有可能键是不可哈希的问题,另一方面此方法涉及到每个实例的字典__dict__,因为这是一个数据描述符访问属性的时候优先调用__get__或者__set__方法,查找顺序优先于实例字典,然后我们在方法里面可以安全的访问对象的实例字典instance.__dict__,这有点绕但没有问题。把值存储在各对象的实例字典里面即解决不同实例相互影响问题又解决内存问题。但每次传name会有点麻烦可不可以不传呢,python3.6 中对描述符协议新增了__set_name__特殊方法可以轻松获取描述符的名字,所以也可以这么写

class DataDescriptor:
    def __init__(self, default):
        self.default = default

    def __get__(self, instance, owner):
        return instance.__dict__.get(self.name, self.default)

    def __set__(self, instance, value):
        if value < 0:
            raise ValueError(f"Negative value not allowed: {value}")
        instance.__dict__[self.name] = value

    def __set_name__(self, owner, name):
        print(f"set name called name: {name!r}")
        self.name = name

__set_name__方法会在类属性定义的时候被调用,获取名字(x)

>>> class MyList(list):
...     x = DataDescriptor(10)
...
...
set name called owner: <class '__main__.MyList'>, name: 'x'
>>> m1 = MyList()
>>> m2 = MyList()
>>> m1.x
10
>>> m2.x
10
>>> m1.x = 99
>>> m1.x
99
>>> m2.x
10
>>> m1.__dict__
{'x': 99}

以上DataDescriptor可以在任何对象上使用,并且不受多个实例相互影响了。

非数据描述符(Non-Data Descriptors)

再来看一个非数据描述符

class NonDataDescriptor:
    """A non-data descriptor
    """
    def __init__(self, initval):
        self.initval = initval

    def __get__(self, instance, owner):
        print(f"get ... instance: {instance!r}, owner: {owner!r}")
        return self.initval

只定义一个__get__方法的为非数据描述符

>>> class Student:
...     age = NonDataDescriptor(13)
...
...
>>> s = Student()
>>> s.age
get ... instance: <__main__.Student object at 0x1109c3e10>, owner: <class '__main__.Student'>
13
>>> s.__dict__
{}
>>> s.age = 18
>>> s.__dict__
{'age': 18}
>>> s.age
18
>>> Student.age
get ... instance: None, owner: <class '__main__.Student'>
13

可以看出非数据描述符的优先级比实例字典低,赋值会存放到__dict__中,也是这个原因如果有多个实例相互之间赋值也不影响,不需要像上面那样单独为每个实例保存一份值,Student.age访问的是类变量所以instanceNone

描述符的调用

访问属性时obj.d,如果d是描述符定义了__get__方法,要分两种情况因为obj可以是类或者实例,也就是说obj.d可能是类属性或者实例属性

  • 对于obj是实例时,底层调用object.__getattribute__()实现,把obj.b转化成type(obj).__dict__['b'].__get__(obj, type(obj))
  • 对于obj是类时,调用object.__getattribute__()时,如把Cls.b转化成Cls.__dict__['b'].__get__(None, Cls)

描述符的创建

有多个方式可以创建描述符

  • 通过使用property()创建
  • 创建一个类并实现描述符协议

通过使用property()创建

python 提供了property()函数,可以用来创建描述符

class Person:
    def __init__(self, initval):
        self._x = initval

    def get_x(self):
        print("get ...")
        return self._x

    def set_x(self, value):
        print("set ...")
        self._x = value

    def del_x(self):
        print("del ...")
        del self._x

    age = property(get_x, set_x, del_x, "I'm the 'age' property.")

Person定义了age属性,其实age就是一个描述符

>>> Person.age
<property object at 0x10310b290>
>>> p = Person(10)
>>> p.age
get ...
10
>>> p.__dict__
{'_x': 10}
>>> del p.age
del ...
>>> p.age = 18
set ...
>>> p.__dict__
{'_x': 18}
>>> p.age
get ...
18

此方法可以看到ageproperty objectproperty()函数实现为数据描述符。因此,实例字典是无法覆盖的(name不在__dict__中),但从上面发现其实我们引入了_x私有变量。这种方法对某个属性的定义非常好用,python 还特地提供了语法糖@property写起来更加方便,以前文章也有介绍 Python 中@propery 使用

class Person:
    def __init__(self, initval):
        self.__age = initval

    @property
    def age(self):
        print("get ...")
        return self.__age

    @age.setter
    def age(self, value):
        print("set ...")
        self.__age = value

    @age.deleter
    def age(self):
        print("del ...")
        del self.__age

创建一个类并实现描述符协议

创建一个类并覆盖任意一个描述符方法__set____ get____delete____set_name__,之前我们创建的DataDescriptorNonDataDescriptor都是用的此方法,当需要某个属性在多个不同的类或者实例都可以使用时,例如类型验证,值检查,都可以使用该方法创建。

试想如果我们需要类型验证很多的属性用上述@property的方法就写起来比较繁琐了要写多个@property块定义,用此方法就很简单,如

class Foo:
    a = DataDescriptor(1)
    b = DataDescriptor(2)
    ....

实际使用

只读属性和惰性求值

class ReadonlyNumber(object):
    """
    实现只读属性(实例属性初始化后无法被修改)
    利用了 data descriptor 优先级高于 obj.__dict__ 的特性
    当试图对属性赋值时,总会先调用 __set__ 方法从而抛出异常
    """
    def __init__(self, value):
        self.value = value

    def __get__(self, instance, owner):
        return self.value

    def __set__(self, instance, value):
        raise AttributeError(
            "'%s' is not modifiable" % self.value
         )


class LazyProperty(object):
    """
    实现惰性求值(访问时才计算,并将值缓存)
    利用了 obj.__dict__ 优先级高于 non-data descriptor 的特性
    第一次调用 __get__ 以同名属性存于实例字典中,之后就不再调用 __get__
    """
    def __init__(self, fun):
        self.fun = fun

    def __get__(self, instance, owner):
        if instance is None:
            return self
        value = self.fun(instance)
        setattr(instance, self.fun.__name__, value)
        return value


class Circle(object):

    pi = ReadonlyNumber(3.14)

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

    @LazyProperty
    def area(self):
        print('Computing area')
        return self.pi * self.radius ** 2

y = Circle(3)
y.area             # 28.26

ReadonlyNumber描述符实现了只读属性,LazyProperty实现了属性值缓存这里用到了装饰器

函数与方法

上面我们已经看到property是一个数据描述符。接下来我们看看函数。

类中的函数就是方法,其实函数就是一个非数据描述符只定义了__get__()方法,所以能被实例字典覆盖

>>> class D:
...     def f(self, x):
...         return x
...
...
>>> d = D()
>>> D.__dict__['f']             # 通过类字典访问f,不调用__get__
<function D.f at 0x108b17e60>
>>> D.f                         # 通过类属性访问,调用__get__
<function D.f at 0x108b17e60>
>>> D.__dict__['f'].__get__(None, D)  # 手动调用__get__方法
<function D.f at 0x108b17e60>
>>> D.f.__qualname__
'D.f'
>>> d
<__main__.D object at 0x108486710>
>>> d.f                         # 实例属性调用__get__,返回bound method
<bound method D.f of <__main__.D object at 0x108486710>>
>>> type(d).__dict__['f'].__get__(d, type(d))  # 手动调用
<bound method D.f of <__main__.D object at 0x108486710>>
# 绑定的方法内部存储了函数地址、绑定此方法的实例、以及绑定实例的类
>>> d.f.__func__                # 函数
<function D.f at 0x108b17e60>
>>> d.f.__self__                # 实例对象
<__main__.D object at 0x108486710>
>>> d.f.__class__               # 类
<class 'method'>
>>> d.f = 100
>>> d.f
100

我们知道类方法就是定义在类内部的函数只是第一个参数(self)接收自身实例对象,当使用 dot notation(.)访问时,把实例对象传给第一个参数。因为函数f是一个非数据描述符,当调用d.f(*args)时,内部的__get__方法会把d.f(*args)转化成f(d, *args),当调用D.f(*args)是转化成f(*args),这就是非数据描述符干的事情。

静态方法和类方法

没错静态方法和类方法也是和上面函数调用同样的原理,如类方法调用(从类调用)内部__get__就是把OneClass.f(*args)转化成f(OneClass, *args),静态方法同理,官方文档提供了如下的转化表格

转型 从实例对象调用 从类调用
函数 f(ojb, *args) f(*args)
静态方法 f(*args) f(*args)
类方法 f(type(obj), *args) f(kclass, *args)

小结

  1. 描述符要实现描述符协议(实现__set__, __get__, __delete__, __set_name__方法)
  2. 描述符必须作为对象属性(类属性)
  3. 描述符的查找顺序:数据描述符 > 实例字典 > 非数据描述符

Reference

  1. docs.python.org
  2. realpython.com
  3. www.ibm.com
  4. www.jianshu.com
  5. zhuanlan.zhihu.com