理解Python对象的属性和描述器

对于Python对象的属性是如何读取的,我一直存在一些疑问。对对象的属性赋值,什么时候直接指向一个新的对象,什么时候会直接抛出AttributeError错误,什么时候会通过Descriptor?Python的descriptor是怎么工作的?如果对a.x进行赋值,那么a.x不是应该直接指向一个新的对象吗?但是如果x是一个descriptor实例,为什么不会直接指向新对象而是执行__get__方法?经过一番研究和实验尝试,我大体明白了这个过程。

__getattr__ __getattribute__和__setattr__

对于对象的属性,默认的行为是对对象的字典(即__dict__)进行get set delete操作。比如说,对a.x查找x属性,默认的搜索顺序是a.__dict__[‘x’],然后是type(a).__dict__[‘x’],然后怼type(a)的父类(metaclass除外)继续查找。如果查找不到,就会执行特殊的方法。

__getattr__只有在当对象的属性找不到的时候被调用。

__getattribute__ 每次都会调用这个方法拿到对象的属性,即使对象存在。

输出如下:

__setattr__每次在对象设置属性的时候都会调用。

判断对象的属性是否存在用的是内置函数hasattrhasattr是C语言实现的,看了一下源代码,发现自己看不懂。不过搜索顺序和本节开头我说的一样。以后再去研究下源代码吧。

总结一下,取得一个对象的属性,默认的行为是:

  1. 如果定义了 __getattribute__ 会无条件执行 __getattribute__ 的逻辑
  2. 查找对象的__dict__
  3. 如果没有,就查找对象的class的__dict__,即type(a).__dict__['x']
  4. 如果没有,就查找父类class的__dict__
  5. 如果没有,就执行__getattr__(如果定义了的话)
  6. 否则就抛出AttributeError

对一个对象赋值,默认的行为是:

  1. 如果定义了__set__方法,会通过__setattr__赋值
  2. 否则会更新对象的__dict__

但是,如果对象的属性是一个Descriptor的话,会改变这种默认行为。

Python的Descriptor

对象的属性可以通过方法来定义特殊的行为。下面的代码,Homework.grade可以像普通属性一样使用。

但是,如果有很多这样的属性,就要定义很多setter和getter方法。于是,就有了可以通用的Descriptor。

Descriptor是Python的内置实现,一旦对象的某个属性是一个Descriptor实例,那么这个对象的读取和赋值将会使用Descriptor定义的相关方法。如果对象的__dict__和Descriptor同时有相同名字的,那么Descriptor的行为会优先。

实现了__get__()__set__()方法的叫做data descriptor,只定义了__get__()的叫做non-data descriptor(通常用于method,本文后面有相应的解释)。上文提到,data descriptor优先级高于对象的__dict__但是non-data descriptor的优先级低于data descriptor。上面的代码删掉__set__()将会是另一番表现。

如果需要一个“只读”的属性,只需要将__set__()抛出一个AttributeError即可。只定义__set__()也可以称作一个data descriptor。

调用关系

对象和类的调用有所不同。

对象的调用在object.__getattribute__()b.x转换成type(b).__dict__['x'].__get__(b, type(b)),然后引用的顺序和上文提到的那样,首先是data descriptor,然后是对象的属性,然后是non-data descriptor。

对于类的调用,由type.__getattribute__()B.x转换成B.__dict__['x'].__get__(None, B)。Python实现如下:

需要注意的一点是,Descriptor默认是由__getattribute__()调用的,如果覆盖__getattribute__()将会使Descriptor失效。

Function,ClassMethod和StaticMethod

看起来这和本文内容没有什么关系,但其实Python中对象和函数的绑定,其原理就是Descriptor。

在Python中,方法(method)和函数(function)并没有实质的区别,只不过method的第一个参数是对象(或者类)。Class的__dict__中把method当做function一样存储,第一个参数预留出来作为self。为了支持方法调用,function默认有一个__get__()实现。也就是说,所有的function都是non-data descriptor,返回bound method(对象调用)或unbound method(类调用)。用纯Python实现,如下。

bound和unbound method虽然表现为两种不同的类型,但是在C源代码里,是同一种实现。如果第一个参数im_self是NULL,就是unbound method,如果im_self有值,那么就是bound method。

总结:Non-data descriptor提供了将函数绑定成方法的作用。Non-data descriptor将obj.f(*args)转化成f(obj, *args),klass.f(*args)转化成f(*args)。如下表。

Transformation Called from an Object Called from a Class
function f(obj, *args) f(*args)
staticmethod f(*args) f(*args)
classmethod f(type(obj), *args) f(klass, *args)

可以看到,staticmethod并没有什么转化,和function几乎没有什么差别。因为staticmethod的推荐用法就是将逻辑相关,但是数据不相关的functions打包组织起来。通过函数调用、对象调用、方法调用都没有什么区别。staticmethod的纯python实现如下。

classmethod用于那些适合通过类调用的函数,例如工厂函数等。与类自身的数据有关系,但是和实际的对象没有关系。例如,Dict类将可迭代的对象生成字典,默认值为None。

classmethod的纯Python实现如下。

最后的话

一开始只是对对象的属性有些疑问,查来查去发现还是官方文档最靠谱。然后认识了Descriptor,最后发现这并不是少见的trick,而是Python中的最常见对象——function时时刻刻都在用它。从官方文档中能学到不少东西呢。另外看似平常、普通的东西背后,可能蕴含了非常智慧和简洁的设计。

相关阅读

  1. hasattr的陷阱
  2. Effective Python: Item 31
  3. Descriptor HowTo Guide


理解Python对象的属性和描述器”已经有10条评论

  1. 定义好了数据描述符,给别的类用,为什么在别的类里,要把使用到数据描述符的属性作为类属性才行而不能作为实例属性?

  2. > 总结一下,取得一个对象的属性,默认的行为是:

    其实第一个应该是「如果有 __getattribute__ 函数,直接调用其」而不是「查找对象的__dict__」吧。

    • 是的,已经修改。

      不过这里我也不是很清楚查找 dict (现文中 2-4 的步骤)是不是由 __getattribute__ 实现的。如果是的话,感觉不应该并列放在一起。

  3. > 如果定义了__set__方法,会通过__setattr__赋值

    应该是「如果定义了__setattr__方法,会通过__setattr__赋值」吧???

Leave a comment

您的电子邮箱地址不会被公开。 必填项已用 * 标注