Python内部是怎么去查找属性的呢?虽然我也写过不少Python代码,但是涉及到比较底层的东西,有时候还是比较茫然。这里看一下,Python怎么从一个对象里去查找属性。
类型与实例
面向对象
这里用“类型”来代表面向对象里的类,英文对应type或class;类实例化得到的对象叫“实例”,英文对应instance或object。
class A:
m = 10
def f(self):
pass
a = A()
这里的A是类型,a是实例。可以由实例获取类型,即a.__class__ is A。
Python的类型和实例,都有一个__dict__,记录了里面包含的内容。而Python的“面向对象”的底层实现,表面看上去就是两点:按照合适的顺序在一串__dict__里找属性;
在合适的时候应用descriptor protocol。
类型的__dict__
一个类里面可以有很多东西,这些东西都放在了类型的__dict__里。例如上面的类型A,里面有个m,有个f,可以直接用A.__dict__查看:
A.__dict__
mappingproxy({'__module__': '__main__',
'm': 10,
'f': ,
'__dict__': ,
'__weakref__': ,
'__doc__': None})
可见这个mappingproxy里面有m和f。
实例的__dict__
实例也有__dict__,例如上面的a,也可以直接查看内容:
a.__dict__
{}
返回了一个空字典,说明这个a实例本身确实没有记录什么东西。
简单的查找
a里面什么都没有,那么为什么可以用a访问m和f呢?先看最简单的情况,用a.m访问类变量m:
a.m
10
当然是可以访问的。这里,属性查找就有:一般,如果实例里面没有,就去类型里面找。
那如果实例里面有呢?
a.__dict__['m'] = 20
a.m
20
A.__dict__['m']
10
这时,a.__dict__里有了新的m,直接用a.m的时候,直接读到的就是实例里面的属性,再没有去类型里面找了。这样属性查找就有:一般,如果实例里面有,就用实例里面的,不会再去类型里找。
Descriptor Protocal
获取简单的变量的时候就是按上面所说的顺序,但是获取复杂的东西,例如一个函数的时候,descriptor就要发挥作用了。
实例方法
对实例a调用f函数,会调用A.f,且第一个参数self会是实例a本身。这底层用到了descriptor protocol,简单说就是,当要获取的这个属性实际上是一个descriptor的时候,就执行这个descriptor的__get__函数,调用的时候把实例和类型都传给这个__get__,最后把__get__的返回值作为最终属性返回。
Python的函数,就是descriptor的一种。
A.f
a.f
>
用a.f去获取f时,返回的并不是function,而是一个bound method。底层上,是先查找实例的__dict__,里面没有名字是f的东西;然后查找类型的__dict__,里面有个名字是f的东西,并且这个东西是个descriptor;然后就调用这个descriptor的__get__,把__get__的返回值作为属性查找的最终结果。对函数,就是把实例和普通函数绑定到一起,得到一个bound method,调用的时候,会把实例当作普通函数的第一个参数传进去。
这样可以达到同样的效果:
A.__dict__['f'].__get__(a, A)
>
或者按照Python文档里所描述的transforms b.x into type(b).__dict__['x'].__get__(b, type(b))
实际上是在类型里找到的,不过因为是个descriptor,于是多执行了一步__get__。
实例先于类型
这里也可以验证下,对函数这种descriptor来说,首先查找实例的__dict__,然后才查找类型的__dict__。
a.__dict__['f'] = 'abc'
a.f
'abc'
可见,实例里的名字覆盖了类型里的名字。
实例里的函数无法触发descriptor
如果把a里面的f替换成一个正常的函数呢,可以override掉A.f么?试了一下,无法触发descriptor,所以粗略地说并不可以:
def f2(self):
print('abc')
a.__dict__['f'] = f2
a.f
成了一个简单的function,并不是bound method,那么实例a并不能自动成为f2函数里的self。不过可以手动把self bind进目标函数的第一个参数,然后塞到实例__dict__里。
data-descriptor
然后是唯一的特殊情况,就是data-descriptor。
@property就会构造一个data-descriptor。
class B:
@property
def n(self):
return 42
b = B()
用实例b读取一下n:
b.n
42
按照顺序,实例b里面没有n,于是就会类型B里查找,果然有,而且恰好是个descriptor,然后调用这个descriptor的__get__函数,property.__get__会调用def n函数,并且将返回值作为最终的属性值。
这不和类里的函数一模一样么!确实一模一样,除非,实例b里也有一个名字为n的东西:
b.__dict__['n'] = 9
b.n
42
为什么实例里明明有一个n,返回的还是42呢?这就是不一样的地方:如果类型里有,并且是个data descriptor,那么就必然使用这个data descriptor,即使实例里有同名属性也不会覆盖类型里的data descriptor。
其实这个特殊情况是有一定道理的,可以想象更新一个property的值,例如执行b.n = 84,如果按朴素的流程,会在实例b的__dict__里面加一个新的n,但这是错误的,实际上应该执行类型B的n.setter(84)。猜测,既然设置data-descriptor会跳过实例,直接调用类型里的函数,那么读取data-descriptor的时候,也跳过实例吧。
__getattr__和__getattribute__
如果实例里没有,类型里也没有,就会调用__getattr__,给用户一个机会处理读取“不存在”的东西,大致对应C里的tp_getattr。这个也是非常有用的,最适合做proxy,或者动态计算属性。例如自动调用rpc。
__getattribute__和__getattr__不一样。本文描述的属性查找顺序,正是__getattribute__需要完整实现的,对应C里的tp_getattro。而__getattr__只有__getattribute__失败的时候才会试图调用。
从__getattribute__失败后,fallback到__getattr__,应该是typeobject.c里的slot_tp_getattr_hook处理的。这个slot_tp_getattr_hook里面,先调用__getattribute__,如果抛出了AttributeError(有PyErr_ExceptionMatches(PyExc_AttributeError)这句判断),就清掉这个异常,再调用脚本里的__getattr__作为结果(PyErr_Clear(); res = call_attribute(self, getattr, name);)。
__mro__
上面都是用简单的类型来描述的,如果涉及到继承层级会稍微复杂一些。例如存在继承链条或者多重继承。但是这里反而很简单,只要将“在这个一个类型里查找”,替换成“在__mro__里的这一串类型里查找”即可。对应C里的_PyType_Lookup:Internal API to look for a name through the MRO.
优先级顺序
到这里,就可以总结出默认的属性查找的顺序了,也就是object.__getattribute__实现的逻辑:类型__dict__里的data descriptor;
实例__dict__里的任何东西;
类型__dict__里的non-data descriptor;
类型__dict__里的其他任何东西。
其实这里的3和4可以并为一条,就是“类型__dict__里除了data descriptor之外的东西”。
虽然不是在__getattribute__里实现的,但是在更外层看来,可以加一条footnote:__getattribute__失败后,试图调用用户提供的__getattr__。
C实现源码解析
寻找关键函数
首先要找到这个函数,可以看文档,或者干脆边试边在代码里找。这里借助dis来看a.m对应的操作:
import dis
dis.dis('a.m')
1 0 LOAD_NAME 0 (a)
2 LOAD_ATTR 1 (m)
4 RETURN_VALUE
看来是这个LOAD_ATTR的opcode。去ceval.c里找到这个opcode:
case TARGET(LOAD_ATTR): {
PyObject *name = GETITEM(names, oparg);
PyObject *owner = TOP();
PyObject *res = PyObject_GetAttr(owner, name);
Py_DECREF(owner);
SET_TOP(res);
if (res == NULL)
goto error;
DISPATCH();
}
应该就是这里的PyObject_GetAttr,然后打断点单步调试一下,就会进到默认的object.__getattribute__的实现了:object.c里的PyObject_GenericGetAttr。这个函数直接调用了_PyObject_GenericGetAttrWithDict,如下:
PyObject *
PyObject_GenericGetAttr(PyObject *obj, PyObject *name)
{
return _PyObject_GenericGetAttrWithDict(obj, name, NULL, 0);
}
源码注解
删除一些错误处理和引用计数,只看关键步骤。注意参数里的*dict为空。
PyObject *
_PyObject_GenericGetAttrWithDict(PyObject *obj, PyObject *name,
PyObject *dict, int suppress)
{
PyTypeObject *tp = Py_TYPE(obj); // 实例的类型 PyObject *descr = NULL; // 在类型里找到的东西,可能是descriptor;可能为空 PyObject *res = NULL; // 最终结果 descrgetfunc f; // descriptor的__get__;可能为空 Py_ssize_t dictoffset;
PyObject **dictptr;
// 在类型mro里根据name找东西,结果可能是descriptor,可能是NULL,可能是其他东西 descr = _PyType_Lookup(tp, name);
f = NULL;
if (descr != NULL) {
// 在类型里找到东西了 // 记录找到的这个东西的__get__,可能为空。 f = Py_TYPE(descr)->tp_descr_get;
if (f != NULL && PyDescr_IsData(descr)) {
// 【1】__get__不为空,是descriptor!并且IsData为true,是data descriptor! // 调用__get__,其返回值作为最终结果 res = f(descr, obj, (PyObject *)Py_TYPE(obj));
goto done;
}
}
// 如果没传obj的dict指针进来,这里就自己找一下 if (dict == NULL) {
/* Inline _PyObject_GetDictPtr */
dictptr = (PyObject **) ((char *)obj + dictoffset);
dict = *dictptr;
}
if (dict != NULL) {
// 实例有__dict__,去里面根据name找东西 res = PyDict_GetItemWithError(dict, name);
if (res != NULL) {
// 【2】在实例的dict里找到东西了,直接作为结果 goto done;
}
}
if (f != NULL) {
// 【3】类型里找到的东西有__get__,在这里一定只能是个non-data descriptor // 调用__get__,返回值作为结果 res = f(descr, obj, (PyObject *)Py_TYPE(obj));
goto done;
}
if (descr != NULL) {
// 【4】类型里找到的东西没有__get__,那这个东西本身作为结果返回 res = descr;
descr = NULL;
goto done;
}
// 找了一串都没有,只能报错了,最常见的“object has no attribute” if (!suppress) {
PyErr_Format(PyExc_AttributeError,
"'%.50s' object has no attribute '%U'",
tp->tp_name, name);
}
done:
return res;
}
注意_PyType_Lookup,在mro里,或者说一串类型里找name对应的东西。
特别注意注释里面的【1】【2】【3】【4】,分别对应了查找属性的几种情况,这里再列一下:类型__dict__里的data descriptor;
实例__dict__里的任何东西;
类型__dict__里的non-data descriptor;
类型__dict__里的其他任何东西。
关键文档
其实这些分析,在文档里都是有迹可循的,甚至可以说文档里写的清清楚楚。这里列一下关键的位置。Python HOWTOs - Descriptor HowTo Guide
两种descriptor的顺序Data and non-data descriptors differ in how overrides are calculated with respect to entries in an instance’s dictionary. ...
属性查找优先级The implementation works through a precedence chain that gives data descriptors priority over instance variables, instance variables priority over non-data descriptors, and assigns lowest priority to __getattr__() if provided.
The Python Language Reference - Data model - The standard type hierarchy - Invoking Descriptors
默认操作实例__dict__里的东西The default behavior for attribute access is to get, set, or delete the attribute from an object’s dictionary.
如果类型是descriptor,则应用descriptor protocolHowever, if the looked-up value is an object defining one of the descriptor methods, then Python may override the default behavior and invoke the descriptor method instead.
方法是non-data descriptor,可以被实例覆盖Python methods (including staticmethod() and classmethod()) are implemented as non-data descriptors. Accordingly, instances can redefine and override methods.
property是一种data descriptor,不可以被实例覆盖The property() function is implemented as a data descriptor. Accordingly, instances cannot override the behavior of a property.
其他
性能问题
可以看到,为了找一个属性,Python干了非常多的事情。减少不必要的类型层级,减少不必要的属性访问层级,减少函数调用都有助于提升性能。缓存for里面用到的属性访问链也有效果。
为什么data-descriptor先于实例__dict__
如果一个类里有很多简单属性,那么每次取属性的时候,例如一个人畜无害的self._xxx,都会去类型mro的所有类型的__dict__里去白白找一遍,找不到,然后才去实例__dict__里找,当层级较深的时候,感觉非常浪费。可能是因为,写入属性的时候,data-descriptor必须先于实例__dict__。这个理由有点牵强。个人感觉更合理的一个理由:Python内置类型的属性访问更频繁,而这些内置类型生成的对象甚至可能根本没有__dict__,而是通过GetSetDescriptorType,MemberDescriptorType等,放在了类型的__dict__里,这样优先找类型里的data descriptor会更快。
>>> int.real
>>> type(int.real)
>>> int.real.__get__
>>> int.real.__set__
这也解释了,为什么__slots__会加快属性查找,因为使用slot,就相当于把可以存在实例__dict__里的属性,提升到了类型里,而这里是优先查找的地方。(当然实际内存还是放在了实例里。)
>>> class A:
__slots__ = ('m', 'n')
>>> A.m
>>> type(A.m)
>>> A.m.__get__
>>> A.m.__set__
可见,内置函数和__slots__,都使用了data descriptor,是属性查找的第一站。
但是无法使用实例的__dict__了,即使在__init__里,也无法动态添加属性。除非这样:
>>> class A:
__slots__ = ('m', 'n', '__dict__')
>>> a = A()
>>> a.k = 10
虽然这样会给实例添加__dict__,但是访问__slots__里的属性的时候,仍然会抄近路。而且这样也可以根据需要给实例动态添加属性了。
动态替换类函数
如果有个正在运行的程序,不能重启,但是需要替换一个函数,这就需要hotfix,也就是在不停机的情况下更新类里的一个函数。知道了属性查找的顺序,只需要找到这个类型,把类型__dict__里的对应函数替换掉即可。我猜CPython的method cache应该会正确处理这种情况。当然还是需要小心已经绑定到实例的方法,这些不能自动被替换。需要注意的还有,不能直接给类型的__dict__赋值,因为那是个只读的mappingproxy,可以直接赋值,跳过__dict__。