Python虚拟机类机制之instance对象(六)
instance对象中的__dict__
在Python虚拟机类机制之从class对象到instance对象(五)这一章中最后的属性访问算法中,我们看到“a.__dict__”这样的形式。
# 首先寻找'f'对应的descriptor(descriptor在之后会细致剖析) # 注意:hasattr会在<class A>的mro列表中寻找符号'f' if hasattr(A, 'f'): descriptor = A.f type = descriptor.__class__ if hasattr(type, '__get__') and (hasattr(type, '__set__') or 'f' not in a.__dict__): return type.__get__(descriptor, a, A) # 通过descriptor访问失败,在instance对象自身__dict__中寻找属性 if 'f' in a.__dict__: return a.__dict__['f'] # instance对象的__dict__中找不到属性,返回a的基类列表中某个基类里定义的函数 # 注意:这里的descriptor实际上指向了一个普通的函数 if descriptor: return descriptor.__get__(descriptor, a, A)
在前一章中,我们看到从<class A>创建<instance a>时,Python虚拟机仅为a申请了16个字节的内存,并没有额外创建PyDictObject对象的动作。不过在<instance a>中,24个字节的前8个字节是PyObject,后8个字节是为两个PyObject *申请的,难道谜底就在这多出的两个PyObject *?
在创建<class A>时,我们曾说到,Python虚拟机设置了一个名为tp_dictoffset的域,从名字上判断,这个可能就是instance对象中__dict__的偏移位置。下图1-1展示了我们的猜想:
图1-1 猜想中的a.__dict__
图1-1中,虚线画的dict对象就是我们期望中的a.__dict__。这个猜想可以在PyObject_GenericGetAttr中与上述的伪代码得到证实:
object.c
PyObject * PyObject_GenericGetAttr(PyObject *obj, PyObject *name) { PyTypeObject *tp = obj->ob_type; PyObject *descr = NULL; PyObject *res = NULL; descrgetfunc f; Py_ssize_t dictoffset; PyObject **dictptr; …… dictoffset = tp->tp_dictoffset; if (dictoffset != 0) { PyObject *dict; //处理变长对象 if (dictoffset < 0) { Py_ssize_t tsize; size_t size; tsize = ((PyVarObject *)obj)->ob_size; if (tsize < 0) tsize = -tsize; size = _PyObject_VAR_SIZE(tp, tsize); dictoffset += (long)size; assert(dictoffset > 0); assert(dictoffset % SIZEOF_VOID_P == 0); } dictptr = (PyObject **) ((char *)obj + dictoffset); dict = *dictptr; if (dict != NULL) { Py_INCREF(dict); res = PyDict_GetItem(dict, name); …… } } …… }
如果dictoffset小于0,意味着A是继承自str这样的变长对象,Python虚拟机会对dictoffset进行一些处理,最终仍然会使dictoffset指向a的内存额外申请的位置。而PyObject_GenericGetAttr正是根据这个dictoffset获得一个dict对象。更进一步,查看函数g中有设置self(即<instance a>)中设置的a属性,这个instance对象的属性设置动作也会访问a.__dict__,而且这个动作最终调用的PyObject_GenericSetAttr也是a.__dict__最初被创建的地方:
object.c
int PyObject_GenericSetAttr(PyObject *obj, PyObject *name, PyObject *value) { PyTypeObject *tp = obj->ob_type; PyObject *descr; …… dictptr = _PyObject_GetDictPtr(obj); if (dictptr != NULL) { PyObject *dict = *dictptr; if (dict == NULL && value != NULL) { dict = PyDict_New(); if (dict == NULL) goto done; *dictptr = dict; } …… } …… }
其中_PyObject_GetDictPtr的代码就是PyObject_GenericGetAttr中根据dictoffset获得dict对象的那段代码
再论descriptor
在上面的伪代码中出现了“descriptor”,这个命名其实是有意为之,目的是唤起前面我们在Python虚拟机类机制之填充tp_dict(二)这一章中所描述过的descriptor。前面我们看到,在PyType_Ready中,Python虚拟机会填充tp_dict,其中与操作名对应的是一个个descriptor,那时我们看到的是descriptor这个概念在Python内部是如何实现的。现在,我们将要剖析的是descriptor在Python的类机制究竟会起到怎样的作用
在Python虚拟机对class对象或instance对象进行属性访问时,descriptor将对属性访问的行为产生重大的影响,一般而言,对于一个Python中的对象obj,如果obj.__class__对应的class对象中存在__get__、__set__和__delete__三种操作,那么obj就可以称为Python的一个descriptor。在slotdefs中,我们会看到__get__、__set__、__delete__对应的操作:
typeobject.c
static slotdef slotdefs[] = { …… TPSLOT("__get__", tp_descr_get, slot_tp_descr_get, wrap_descr_get, "descr.__get__(obj[, type]) -> value"), TPSLOT("__set__", tp_descr_set, slot_tp_descr_set, wrap_descr_set, "descr.__set__(obj, value)"), TPSLOT("__delete__", tp_descr_set, slot_tp_descr_set, wrap_descr_delete, "descr.__delete__(obj)"), …… }
在前面几章我们看到了PyWrapperDescrObject、PyMethodDescrObject等对象,它们对应的class对象中分别为tp_descr_get设置了wrapperdescr_get、method_get等函数,所以,它们是descriptor
如果细分,那么descriptor还可分为如下两种:
- data descriptor:type中定义了__get__和__set__的descriptor
- non data descriptor:type中只定义了__get__的descriptor
在Python虚拟机访问instance对象的属性时,descriptor的一个作用是影响Python虚拟机对属性的选择。从PyObject_GenericGetAttr的伪代码可以看出,Python虚拟机会在instance对象自身的__dict__中寻找属性,也会在instance对象对应的class的mro列表中寻找属性,我们将前一种属性称为instance属性,而后一种称为class属性
虽然PyObject_GenericGetAttr里对属性进行选择的算法比较复杂,但是从最终的效果上,我们可以总结处如下的两条规则:
- Python虚拟机按照instance属性、class属性的顺序选择属性,即instance属性优先于class属性
- 如果在class属性中发现同名的data descriptor,那么该descriptor会优先于instance属性被Python虚拟机选择
这两条规则在对属性进行设置时仍然会被严格遵守,换句话说,如果执行"a.value = 1",就算在A中发现一个名为"value"的no data descriptor,那么还是会设置a.__dict__['value'] = 1,而不会设置A中已有的属性
当最终获得的属性是一个descriptor,最神奇的事发生了,Python虚拟机不是简单的返回descriptor,而是如伪代码所示的那样,调用descriptor.__get__,将调用的结果返回,在下面的代码示例中,展示了descriptor对属性访问行为的影响:
descriptor改变返回值
>>> class A(list): ... def __get__(self, instance, owner): ... return "A.__get__" ... >>> class B(object): ... value = A() ... >>> b = B() >>> b.value 'A.__get__' >>> s = b.value >>> type(s) <class 'str'>
instance属性优先于non data descriptor
>>> class A(list): ... def __get__(self, instance, owner): ... return "A.__get__" ... >>> class B(object): ... value = A() ... >>> b = B() >>> b.value = 1 >>> b.__dict__["value"] 1 >>> b.__class__.__dict__["value"] []
data descriptor优先于instance属性
>>> class A(list): ... def __get__(self, instance, owner): ... return "A.__get__" ... def __set__(self, instance, value): ... print("A.__set__") ... self.append(value) ... >>> class B(object): ... value = A() ... >>> b = B() >>> b.value = 1 A.__set__ >>> b.__dict__["value"] Traceback (most recent call last): File "<stdin>", line 1, in <module> KeyError: 'value' >>> b.__class__.__dict__["value"] [1]
前面我们说,当访问的属性最终对应的是一个descriptor时,会调用其__get__方法,并将__get__的结果作为返回。其实这个说法不是完全正确的,仔细对比type_getattro和PyObject_GenericGetAttr的代码,我们会发现它们在对待descriptor上存在差异。在PyObject_GenericGetAttr中,如果查询到的descriptor存在于class对象的tp_dict中,会调用其__get__方法;若它存在于instance对象的tp_dict中,则不会调用其__get__方法
>>> class A(object): ... def __get__(self, instance, owner): ... return "Python" ... >>> class B(object): ... desc_in_class = A() ... >>> B.desc_in_class 'Python' >>> b = B() >>> b.desc_in_class 'Python' >>> b.desc_in_class = A() >>> b.desc_in_class <__main__.A object at 0x000000FBDD76C908>
到这里,我们已经看到,descriptor对属性访问的影响主要在两个方面:其一是对访问顺序的影响,其二是对访问结果的影响,第二种影响正是类的成员函数调用的关键
函数变身
demo1.py
class A(object): name = "Python" def __init__(self): print("A::__init__") def f(self): print("A::f") def g(self, aValue): self.value = aValue print(self.value) a = A() a.f() a.g(10)
在前面讨论创建class A对象时,我们看到A.__dict__中保存了一个与符号"f"对应的PyFunctionObject对象,所以在伪代码中的descriptor对应的就是一个PyFunctionObject对象。先抛开伪代码中确定最终返回值的过程不说,我们从另一个角度来看一看,假设PyFunctionObject作为LOAD_ATTR的最终结果,在LOAD_ATTR指令代码的最后被SET_TOP压入到运行时栈,那会有什么后果呢?
在A的成员函数f的def语句中,我们看到一个self参数,self在Python中是不是一个有效的参数呢?还是它仅仅是语法意义上的占位符?这一点可以从g中看到答案,在函数g中有再这样的语句:self.value = aValue。这条语句毫无疑问地揭示了self是一个货真价实的参数,所以也表明了函数f也是一个带参函数。现在,问题来了,根据我们之前对函数机制的分析,Python通常会将参数事先压入运行时栈中,但是demo1.py中的a.f语句编译后的指令序列中可以看到,Python在获得a.f对应的对象后,没有进行任何普通函数调用时将参数压入栈的动作,而是直接执行了CALL_FUNCTION指令
a.f()调用指令
16 31 LOAD_NAME 2 (a) 34 LOAD_ATTR 3 (f) 37 CALL_FUNCTION 0 40 POP_TOP
这里没有任何像参数的东西在栈中,栈中只有一个可能是a.f的PyFunctionObject对象,那么这个遗失的self参数究竟在什么地方?
既然栈中没有参数,而栈中唯一的PyFunctionObject对象又需要参数,那么说明,我们之前的推理可能是错误的,所以,栈中的对象只能是另一种我们尚未了结的对象,由于是通过访问属性"f"得到的这个对象,所以一个合理的假设是:在这个对象中,还包含函数f的参数:self
在之前介绍函数机制的时候,我们似乎忘记介绍一个对象PyFunction_Type,这是PyFunctionObject对象对应的class对象,观察PyFunction_Type对象,我们会发现与__get__对应的tp_descr_get被设置为&func_descr_get,这意味着这里的A.f实际上是一个descriptor。由于PyFunc_Type中并没有设置func_descr_set,所以A.f是一个non data descriptor。此外,由于在a.__dict__中没有f符号的存在,所以根据伪代码中的算法,a.f的的返回值将被descriptor改变,其结果将是A.f.__get__,也就是func_descr_get(A.f, a, A)
funcobject.c
PyTypeObject PyFunction_Type = { …… func_descr_get, /* tp_descr_get */ …… }; …… static PyObject * func_descr_get(PyObject *func, PyObject *obj, PyObject *type) { if (obj == Py_None) obj = NULL; return PyMethod_New(func, obj, type); }
func_descr_get将A.f对应的PyFunctionObject进行了一番包装,通过PyMethod_New在PyFunctionObject的基础上创建了一个新的对象,于是,我们再进入到PyMethod_New
funcobject.c
PyObject * PyMethod_New(PyObject *func, PyObject *self, PyObject *klass) { register PyMethodObject *im; …… im = free_list; if (im != NULL) { //使用缓冲池 free_list = (PyMethodObject *)(im->im_self); PyObject_INIT(im, &PyMethod_Type); } else { //不使用缓冲池,直接创建PyMethodObject对象 im = PyObject_GC_New(PyMethodObject, &PyMethod_Type); if (im == NULL) return NULL; } im->im_weakreflist = NULL; Py_INCREF(func); im->im_func = func; Py_XINCREF(self); //这里就是self对象 im->im_self = self; Py_XINCREF(klass); im->im_class = klass; _PyObject_GC_TRACK(im); return (PyObject *)im; }
这里我们可以知道,原先运行时栈中已经不再是PyFunctionObject对象,而是PyMethodObject对象。看到free_list这样熟悉的字眼,我们可以立即判断出,在PyMethodObject的实现和管理中,Python采用了缓冲池的技术,现在来看一看这个PyMethodObject
typedef struct { PyObject_HEAD PyObject *im_func; //可调用的PyFunctionObject对象 PyObject *im_self; //用于成员函数调用的self参数,instance对象 PyObject *im_class; //class对象 PyObject *im_weakreflist; } PyMethodObject;
在PyMethod_New中,分别将im_func、im_self、im_class设置了不同的值,结合a.f,分别对应符号"f"所对应的PyFunctionObject对象,符号"a"对应的instance对象,以及<class A>对象
在Python中,将PyFunctionObject对象和一个instance对象通过PyMethodObject对象结合在一起的过程就称为成员函数的绑定。下面的代码清晰地展示了在访问属性时,发生函数绑定的结果:
>>> class A(object): ... def f(self): ... pass ... >>> a = A() >>> a.__class__.__dict__["f"] <function A.f at 0x000000FBDD74E620> >>> a.f <bound method A.f of <__main__.A object at 0x000000FBDD76CE80>>
无参函数的调用
在LOAD_ATTR指令之后,指令"37 CALL_FUNCTION 0"开始了函数调用的动作,之前我们研究过对于PyFunctionObject对象的调用,而对于PyMethodObject对象,情况则有些不同,如下:
ceval.c
static PyObject * call_function(PyObject ***pp_stack, int oparg) { int na = oparg & 0xff; int nk = (oparg >> 8) & 0xff; int n = na + 2 * nk; PyObject **pfunc = (*pp_stack) - n - 1; PyObject *func = *pfunc; PyObject *x, *w; …… if (PyCFunction_Check(func) && nk == 0) { …… } else { //[1]:从PyMethodObject对象中抽取PyFunctionObject对象和self参数 if (PyMethod_Check(func) && PyMethod_GET_SELF(func) != NULL) { PyObject *self = PyMethod_GET_SELF(func); func = PyMethod_GET_FUNCTION(func); //[2]:self参数入栈,调整参数信息变量 *pfunc = self; na++; n++; } if (PyFunction_Check(func)) x = fast_function(func, pp_stack, n, na, nk); else x = do_call(func, pp_stack, na, nk); …… } …… return x; }
调用成员函数f时,显示传入的参数个数为0,也就是说,调用f时,Python虚拟机没有进行参数入栈的动作。而f显然至少需要一个实例对象的参数,而正是在call_function中,Python虚拟机为PyMethodObject进行了一些参数处理的动作
Python虚拟机执行a.f()时,在call_function中,代码[1]处的判断将会成立,其中PyMethod_GET_SELF被定义为:
classobject.h
#define PyMethod_GET_SELF(meth) \ (((PyMethodObject *)meth) -> im_self)
在call_function中,func变量指向一个PyMethodObject对象,在上述代码[1]处成立后,在if分支中又会将PyMethodObject对象中的PyFunctionObject对象和instance对象分别提取出来,在if分支中有一处最重要的代码,即[2]处,pfunc指向的位置正是运行时栈中存放PyMethodObject对象的位置,那么这个本来属于PyMethodObject对象的地方改为存放instance对象究竟有什么作用呢?在这里,Python虚拟机以另一种方式完成了函数参数入栈的动作,本来属于PyMethodObject对象的内存空间被用作了函数f的self参数的容身之处,图1-1展示了运行call_function时运行时栈的变化情况:
图1-1 设置self参数
a是设置pfunc之前的运行时栈,b表示设置了pfunc之后的运行时栈。在call_function中,接着还会通过PyMethod_GET_FUNCTION将PyMethodObject对象中的PyFunctionObject对象取出,随后在[2]处,Python虚拟机完成了self参数的入栈,同时还调整了维护着参数信息的na和n,调整后的结果意味着函数会获得一个位置参数,看一看class A中的f的def语句,self正是一个位置参数
由于func在if分支之后指向了PyFunctionObject对象,所以接下来Python执行引擎将进入fast_function。到了这里,剩下的动作就和我们之前所分析的带参函数的调用一致。实际上a.f的调用是指上就是一个带一个位置参数的一般函数调用,而在fast_function,作为self参数的<instance a>被Python虚拟机压入到了运行时栈中,由于a.f仅仅是一个带位置参数的函数,所以Python执行引擎将进入快速通道,在快速通道中,运行时栈中的这个instance对象会被拷贝到新的PyFrameObject对象的f_localsplus中
ceval.c
static PyObject * fast_function(PyObject *func, PyObject ***pp_stack, int n, int na, int nk) { PyCodeObject *co = (PyCodeObject *)PyFunction_GET_CODE(func); PyObject *globals = PyFunction_GET_GLOBALS(func); PyObject *argdefs = PyFunction_GET_DEFAULTS(func); PyObject **d = NULL; int nd = 0; PCALL(PCALL_FUNCTION); PCALL(PCALL_FAST_FUNCTION); if (argdefs == NULL && co->co_argcount == n && nk == 0 && co->co_flags == (CO_OPTIMIZED | CO_NEWLOCALS | CO_NOFREE)) { //创建新的PyFrameObject对象f PyFrameObject *f; f = PyFrame_New(tstate, co, globals, NULL); if (f == NULL) return NULL; fastlocals = f->f_localsplus; //[1]:获得栈顶指针 stack = (*pp_stack) - n; for (i = 0; i < n; i++) { //[2]: fastlocals[i] = *stack++; } …… } …… }
在调用fast_function时,参数的数量n已经由执行CALL_FUNCTION时的0变为了1,所以代码[1]处的stack指向的位置就和图1-1中pfunc指向的位置是一致的了,在代码的[2]处将<instance a>作为参数拷贝到函数的参数区fastlocals中,必须将它放置到栈顶,也就是以前PyMethodObject对象所在的位置上,也也就是前面call_function那个赋值操作的原因
带参函数的调用
Python虚拟机对类中带参的成员函数的调用,其原理和流程与无参函数的调用是一致的,我们来看看a.g(10)的字节码序列:
17 41 LOAD_NAME 2 (a) 44 LOAD_ATTR 4 (g) 47 LOAD_CONST 2 (10) 50 CALL_FUNCTION 1 53 POP_TOP
可以看到,和调用成员函数f的指令序列几乎完全一致,只是多了一个"47 LOAD_CONST 2 (10)"。对于这个指令我们不会陌生,在分析函数机制的时候,我们看到它是用来将函数所需的参数压入到运行时栈中。对于g,真正有趣的地方在于考察函数的实现代码,从而可以看到那个作为self参数的instance对象的使用:
>>> dis.dis(A.g) 11 0 LOAD_FAST 1 (aValue) 3 LOAD_FAST 0 (self) 6 STORE_ATTR 0 (value) 12 9 LOAD_FAST 0 (self) 12 LOAD_ATTR 0 (value) 15 PRINT_ITEM 16 PRINT_NEWLINE
显然,其中的LOAD_FAST、LOAD_ATTR、STORE_ATTR这些字节码指令都涉及到了作为self参数的instance对象,有兴趣的同学可以分析一下STORE_ATTR的代码,可以发现其中也有类似于LOAD_ATTR中PyObject_GenericGetAttr的属性访问算法
其实到了这里,我们可以在更高的层次俯视一下Python的运行模型,最核心的模型其实非常简单,可以简化为两条规则:
- 在某个名字空间中寻找符号对应的对象
- 对从名字空间中得到的对象进行某些操作
抛开面向对象花里胡哨的外表,其实我们会发现,class对象其实就是一个名字空间,instance对象也是一个名字空间,不过这些名字空间通过一些特殊的规则关联在一起,使得符号的搜索过程变得复杂,从而实现了面向对象这种编程模式