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对象也是一个名字空间,不过这些名字空间通过一些特殊的规则关联在一起,使得符号的搜索过程变得复杂,从而实现了面向对象这种编程模式 

posted @ 2018-09-13 21:09  北洛  阅读(1737)  评论(0编辑  收藏  举报