[Python 进阶] Python描述器的介绍及基于描述器协议的属性(property)、方法(method)简介

基于Python官方文档的翻译+个人注解

由Jeza Chen 发表于 January 7, 2020

最近看了下《Python Cookbook》,对Python3的描述器部分还是不太了解,书里面也没有太详细的介绍,所以参考了下官方文档学习。

因为官方文档的翻译不太齐全,所以参考了一些博客的翻译加以改动,并加下自己的一些注解。

摘要

文章主要定义了描述符,概述描述符协议,并说明如何调用描述符。下文展示了一个自定义的描述器,以及几个Python内置的描述器,如属性(property), 静态方法(static method), 类方法。文章通过给出一个纯Python的实现和示例应用来展示每个描述器是怎么工作的。

学习描述器不仅让你接触到更多的工具,还可以让你更深入地了解Python,让你体会到Python设计的优雅之处。

定义和简介

一般地,一个描述器是一个包含 “绑定行为” 的对象,对其属性的存取被描述器协议中定义的方法覆盖。这些方法有:__get__()__set__()__delete__()。如果某个对象中定义了这些方法中的任意一个,那么这个对象就可以被称为一个描述器。

注:说白了,描述器就是一个类,且实现了以下方法的任意一个:__get__()__set__()__delete__()

属性访问的默认行为是从一个对象的字典(__dict__)中获取、设置或删除属性。例如,a.x 的查找顺序会从 a.__dict__['x'] 开始,然后是 type(a).__dict__['x'],接下来依次查找 type(a) 的基类,不包括元类。 如果找到的值是定义了某个描述器方法的对象(注:该对象就是描述器),则 Python 可能会重载默认行为并转而发起调用描述器方法。具体发生在优先级链的哪个环节则要根据所定义的描述器方法及其被调用的方式来决定。

注:就是说,对象属性的访问顺序是:实例的__dict__、类的__dict__、基类的__dict__。比如,类B是类A的子类,而b是B的实例,则Python寻找b.x时,首先看b的__dict__有没有x,如果没有,继续找B.__dict__A.__dict__,以此类推。

描述符是功能强大的通用协议。它们是属性,方法,静态方法,类方法和super()背后的实现机制。Python 2.2引入的新样式类是用描述器实现的。描述符简化了底层的C代码,并为日常Python程序提供了一组灵活的新工具。

描述器协议

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

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

descr.__delete__(self, obj) -> None

以上是所有的描述器方法。若一个对象实现了以上任意一个方法,就会成为描述器。当其作为(类或实例的)属性时,默认的查找行为会被重写(覆盖)。

如果一个对象同时定义了 __get__()__set__(),它叫做资料描述器(data descriptor);仅定义了 __get__() 的描述器叫非资料描述器(常用于方法,当然其他用途也是可以的)

资料描述器和非资料描述器的区别在于:相对于实例的字典的优先级。如果实例字典中有与描述器同名的属性,如果描述器是资料描述器,优先使用资料描述器,如果是非资料描述器,优先使用字典中的属性

注:举个例子,若实例a的有一个方法和其中属性重名时,比如都叫 spam。 Python访问 a.spam 的时候优先访问实例字典(a.__dict__)中的属性,因为实例函数的实现是个非资料描述器)

>>> class A:
...     def spam(self):
...             print("aaa")
...     def __init__(self):
...             self.spam = "aaa"
... 
>>> 
>>> a = A()
>>> a.spam
'aaa'

要想创建一个只读的资料描述器,需同时定义 __set____get__,并在 __set__ 方法中引发一个 AttributeError 异常。定义一个引发异常的 __set__ 方法足以让一个描述器成为资料描述器。

注:以下是一个简单的实例:

>>> class Area:
...     def __init__(self, area):
...             self._area = area
...     def __set__(self, obj, val):
...             raise AttributeError('Cannot Set')
...     def __get__(self, obj, objtype):
...             return self._area
... 
>>> class A:
...     x = Area(233)
... 
>>> a = A()
>>> a.x
233
>>> a.x = 222
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in __set__
AttributeError: Cannot Set

发起调用描述符

描述器可直接调用: d.__get__(obj)

更常见的情况是描述器作为属性访问时被自动调用。具体来讲,obj.d 会在 obj 的字典中找 d ,如果 d 定义了 __get__ 方法,那么 d.__get__(obj) 会依据下面的优先规则被调用。(调用的细节取决于 obj 是一个类还是一个实例。另外,描述器只对于新式对象和新式类才起作用。新式类即继承于 object 的类。)

若obj是一个对象(新式类的一个对象),当访问obj.d时,方法 object.__getattribute__() 把 b.x 变成 type(b).__dict__['x'].__get__(b, type(b)) 。具体实现是依据这样的优先顺序:资料描述器优先于实例变量,实例变量优先于非资料描述器,__getattr__()方法(如果对象中包含的话)具有最低的优先级。完整的C语言实现可以在 Objects/object.c 中 PyObject_GenericGetAttr() 查看。

若obj是一个类,方法 type.__getattribute__() 把 B.x 变成 B.__dict__['x'].__get__(None, B) 。用Python语言描述就是:

def __getattribute__(self, key):
    "Emulate type_getattro() in Objects/typeobject.c"
    v = object.__getattribute__(self, key)
    if hasattr(v, '__get__'):
       return v.__get__(None, self)
    return v

需要记住的重要几点:

  • 描述器的调用是因为 __getattribute__()

  • 重写 __getattribute__() 方法会阻止正常(automatic)的描述器调用;

  • object.__getattribute__()type.__getattribute__()__get__() 的调用方式不一样;

  • 资料描述器总是比实例字典优先;

  • 非资料描述器可能会被实例字典重写(即实例字典优先于非资料描述器);

super() 返回的对象同样有一个定制的 __getattribute__() 方法用来调用描述器。调用 super(B, obj).m() 时会先在 obj.__class__.__mro__ 中查找与B紧邻的基类A,然后返回 A.__dict__['m'].__get__(obj, A) 。如果m不是描述器,原样返回 m 。如果实例字典中找不到 m ,会回溯继续调用 object.__getattribute__() 查找。(即在 __mro__ 中的下一个基类中查找)

super_getattro() 的实现细节可在源文件 Objects/typeobject.c查看(使用C语言实现的) ,一个等价的Python实现可前往 Guido’s Tutorial查阅。

以上展示了描述器的机制是在 object, type, 和 super 的 __getattribute__() 方法中实现的。由 object 派生出的类自动继承了这个机制,或者它们有个有类似机制的元类。同理,如果你想在某些类上关闭这些描述器行为,可以重写该类的 __getattribute__() 方法。

描述器例子

下面的代码中定义了一个资料描述器,每次 get 和 set 都会打印一条消息。重写 __getattribute__() 是另一个可以使所有属性都拥有这个行为的方法。但是,描述器在监视特定属性的时候是很有用的。

注:即重写__getattribute__()方法会改变所有属性的访问行为,如果只对某些属性的行为感兴趣,使用描述器是最好的方案。

class RevealAccess(object):
    """A data descriptor that sets and returns values
       normally and prints a message logging their access.
    """

    def __init__(self, initval=None, name='var'):
        self.val = initval
        self.name = name

    def __get__(self, obj, objtype):
        print('Retrieving', self.name)
        return self.val

    def __set__(self, obj, val):
        print('Updating', self.name)
        self.val = val

>>> class MyClass(object):
...     x = RevealAccess(10, 'var "x"')
...     y = 5
...
>>> m = MyClass()
>>> m.x
Retrieving var "x"
10
>>> m.x = 20
Updating var "x"
>>> m.x
Retrieving var "x"
20
>>> m.y
5

这个协议非常简单,并且提供了令人激动的可能性。在Python中,很多方法都是基于描述器协议的,但它们的用途实在是太普遍,以致于Python作者将它们打包成独立的函数形式(即表面上看不出它们是基于描述器协议的)。像属性(property), 方法(bound和unbound method), 静态方法(static method)和类方法(class method)其实都是基于描述器协议的。

下文将介绍属性(property), 方法(bound和unbound method), 静态方法(static method)和类方法(class method),并简单说明它们的本质。

属性(property)

调用 property() 是建立资料描述器的一种简洁方式,从而可以在访问属性时触发相应的方法调用。这个函数的原型:

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

下面展示了一个典型应用:定义一个托管属性(Managed Attribute) x 。

class C(object):
    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的等价实现:

class Property(object):
    "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__)

当用户接口已经被授权访问属性之后,若客户需求发生了变化,导致属性需要进一步处理才能返回给用户。这时 property() 能够给以上情形提供很大帮助。

例如,一个电子表格类提供了访问单元格的方式: Cell('b10').value 。 之后,对这个程序的改善要求在每次访问单元格时重新计算单元格的值。然而,程序员并不想影响那些客户端中直接访问属性的代码。那么解决方案是将属性访问包装在一个属性资料描述器中:

class Cell(object):
    . . .
    def getvalue(self):
        "Recalculate the cell before returning value"
        self.recalc()
        return self._value
    value = property(getvalue)

函数和方法

Python的面向对象特征是建立在基于函数的环境之上的。非资料描述器把两者无缝地连接起来

类的字典把方法当做函数存储。在定义类的时候,方法通常用关键字 def 和 lambda 来声明。这和创建函数是一样的。唯一的不同之处是类方法的第一个参数是用来表示对象实例的。Python约定,这个参数通常是 self, 但也可以叫 this 或者其它任何名字。

为了支持方法调用(注:即将函数转换成方法),函数(function)包含一个 __get__() 方法以便在属性访问时绑定方法。这就是说所有的函数都是非资料描述器,它们返回绑定(bound)还是非绑定(unbound)的方法取决于他们是被实例调用还是被类调用。用Python代码来描述就是:

class Function(object):
    . . .
    def __get__(self, obj, objtype=None):
        "Simulate func_descr_get() in Objects/funcobject.c"
        return types.MethodType(self, obj, objtype)

下面运行解释器来展示实际情况下函数描述器是如何工作的:

>>> class D(object):
     def f(self, x):
          return x

>>> d = D()
>>> D.__dict__['f'] # 存储成一个function
<function f at 0x00C45070>
>>> D.f             # 从类来方法,返回unbound method
<unbound method D.f>
>>> d.f             # 从实例来访问,返回bound method
<bound method D.f of <__main__.D object at 0x00B18C90>>

静态方法和类方法

非资料描述器为将函数绑定成方法这种常见模式提供了一个简单的实现机制。

简而言之,函数有个方法 __get__() ,当函数被当作属性访问时,它就会把函数变成一个实例方法。非资料描述器把 obj.f(*args) 的调用转换成 f(obj, *args) 。 调用 klass.f(*args) 就变成调用 f(*args)

下面的表格总结了绑定(即函数绑定方法)和它最有用的两个变种(staticmethod静态方法、classmethod类方法):

  从一个实例(如obj)中访问 从一个类(如klass)中访问
function f(obj, *args) f(*args)
staticmethod f(*args) f(*args)
classmethod f(type(obj), *args) f(klass, *args)

静态方法

静态方法(static method)原样返回函数,调用 c.f 或者 C.f 分别等价于 object.__getattribute__(c, "f") 或者 object.__getattribute__(C, "f") 。即无论是从一个对象还是一个类中访问该类函数,这个函数都能同样地被访问到。

静态方法的用途

不需要 self 变量的方法适合用做静态方法。

例如, 一个统计包可能包含一个用来统计实验数据的类。这个类提供了一般的方法,来计算平均数,中位数,以及其他基于数据的描述性统计指标。然而,这个类可能包含一些概念上与统计相关但不依赖具体数据的函数(注:即该方法不依赖某些具体数据,也不受内部状态的影响)。比如 erf(x) 就是一个统计工作中经常用到的,但却不依赖于特定数据的函数。它可以从类或者实例调用: s.erf(1.5) –> .9332 或者 Sample.erf(1.5) –> .9332.

既然staticmethod将函数原封不动的返回,那下面的代码看上去就很正常了:

>>> class E(object):
     def f(x):
          print x
     f = staticmethod(f)

>>> print E.f(3)  # 通过类来访问
3
>>> print E().f(3)  # 通过实例来访问
3
静态方法的纯Python实现

利用非资料描述器, staticmethod() 的纯Python版本看起来像这样:

class StaticMethod(object):
 "Emulate PyStaticMethod_Type() in Objects/funcobject.c"

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

 def __get__(self, obj, objtype=None):
      return self.f  # 无论是通过实例还是类来访问该描述器,原封不动地放回函数即可

类方法

不像静态方法,类方法需要在调用函数之前会在参数列表前添上class的引用作为第一个参数。不管调用者是对象还是类,这个格式是一样的:

>>> class E(object):
     def f(klass, x):
          return klass.__name__, x
     f = classmethod(f)

>>> print E.f(3)
('E', 3)
>>> print E().f(3)
('E', 3)
类方法的用途

当一个函数不需要相关的数据做参数而只需要一个类(而不是实例)的引用的时候,这个特征就显得很有用了。类方法的一个用途是用来创建不同的构造器。在Python 2.3中, dict.fromkeys() 可以依据一个key列表来创建一个新的字典。等价的Python实现就是:

class Dict:
    . . .
    def fromkeys(klass, iterable, value=None):
        "Emulate dict_fromkeys() in Objects/dictobject.c"
        d = klass()
        for key in iterable:
            d[key] = value
        return d
    fromkeys = classmethod(fromkeys)

因此,一个新的字典就可以这么创建:

>>> Dict.fromkeys('abracadabra')
{'a': None, 'r': None, 'b': None, 'c': None, 'd': None}
类方法的纯Python实现

用非资料描述器协议, classmethod() 的纯Python版本实现看起来像这样:

class ClassMethod(object):
     "Emulate PyClassMethod_Type() in Objects/funcobject.c"

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

     def __get__(self, obj, klass=None):
          if klass is None:  # 如果通过实例来访问,klass可能为None
               klass = type(obj)
          def newfunc(*args):
               return self.f(klass, *args)
          return newfunc