Python源码剖析——02虚拟机
阅读原文时间:2021年09月25日阅读:1

《Python源码剖析》笔记


第七章:编译结果


运行一个Python程序会经历以下几个步骤:

  • 由解释器对源文件(.py)进行编译,得到字节码(.pyc文件)
  • 然后由虚拟机按照字节码一条一条执行对应的指令

程序运行时,Python会将编译结果都存放在内存中的PyCodeObject对象中。每一个名字空间都对应着一个PyCodeObject对象。

typedef struct {
    PyObject_HEAD
    int co_argcount;        /* #arguments, except *args */
    int co_nlocals;        /* #local variables */
    int co_stacksize;        /* #entries needed for evaluation stack */
    int co_flags;        /* CO_..., see below */
    PyObject *co_code;        /* instruction opcodes */
    PyObject *co_consts;    /* list (constants used) */
    PyObject *co_names;        /* list of strings (names used) */
    PyObject *co_varnames;    /* tuple of strings (local variable names) */
    PyObject *co_freevars;    /* tuple of strings (free variable names) */
    PyObject *co_cellvars;      /* tuple of strings (cell variable names) */
    /* The rest doesn't count for hash/cmp */
    PyObject *co_filename;    /* string (where it was loaded from) */
    PyObject *co_name;        /* string (name, for reference) */
    int co_firstlineno;        /* first source line number */
    PyObject *co_lnotab;    /* string (encoding addr<->lineno mapping) See
                   Objects/lnotab_notes.txt for details. */
    void *co_zombieframe;     /* for optimization only (see frameobject.c) */
    PyObject *co_weakreflist;   /* to support weakrefs to code objects */
} PyCodeObject;

可以看到里面定义了一大堆东西,其中字节码指令序列保存在co_code。


Pyc文件是PyCodeObject对象的二进制文件形式,但是生成pyc文件的时候,除了PyCodeObject对象还有其他数据。

值得注意的是,并不是所有的PyCodeObject对象都会被保存为pyc文件,如果只是简单的使用python demo.py并不会产生一个demo.pyc,因为这是没有必要的。想得到pyc文件由很多方法,其中import操作是最常见的触发机制。

文件创建:

在写入pyc时,会先写入magic number和时间信息。magic number的作用是保证兼容性(字节码指令变化等原因),可兼容版本的magic number会被设置为相同,那么调用时通过查看magic number就可以知道程序是否兼容。时间信息是用来保证pyc的有效性,可以让源文件和pyc随时保持同步。

Python在把对象写进pyc时,所有数据都是字节流,显然应该有一种机制去区分数据,所以Python在写入一个对象之前,会先写入TYPE_LIST、TYPE_CODE、TYPE_INT等等这样的标识,这样在读取的时候就能获得对象的类型和起止位置。

写入一个对象的函数为w_object,其实现非常暴力,就是不断的if…else if…判断对象类型,然后遍历对象内部,然后依次写入。所以这里简单的认为Python中的诸多类型可由整型和字符串构成,其写入方法分别为w_long、w_string。

在写入整型时,直接简单的一个字节一个字节写入即可。

[Objects/marshal.c]

static void
w_long(long x, WFILE *p)
{
    w_byte((char)( x      & 0xff), p);
    w_byte((char)((x>> 8) & 0xff), p);
    w_byte((char)((x>>16) & 0xff), p);
    w_byte((char)((x>>24) & 0xff), p);
}

在写入字符串时稍微复杂一点。

typedef struct {
    FILE *fp;
    PyObject *strings; /* dict on marshal, list on unmarshal */
} WFILE;

这里简化了WFILE的定义,只看这两个。

对于字符串来说,其实现了intern机制。那么我们在将字符串对象写入pyc时,如果只是单纯的写入一个字符串然后标记是否需要intern,那么可想而知,pyc文件中可能会出现大量的冗余字符串,而这些其实是不必要的。所以,Python实现了一个strings指针,在写入pyc时,strings会指向一个PyDictObject对象,这个对象实际维护着(PyStringObject,PyIntObject)键值对,以表示某个字符串插入intern字典的顺序(序号)。这样每次写入一个需要intern的字符串就先标记TYPE_INTERNED,然后写入字符串;写入一个重复的字符串时,只需要标识TYPE_STRINGREF,再写入对应序号即可。但是PyDictObject不能按索引随机查询,写入序号有什么用?其实在读pyc的时候,strings所代表的不再是一个PyDictObject,Python会把他定义为PyListObject,然后在遇到TYPE_INTERNED时append到List后面,这样字符串就能按顺序插入到List中,读到TYPE_STRINGREF时即可按序号获取对象。


第八章:虚拟机框架



在C中,不同执行环境中对相同的变量名可以有不同的解释,这是因为C能够区分执行环境。但是根据上面所提到的PyCodeObject,显然他只能提供某个作用域中的静态信息,并不能提供执行环境,所以虚拟机执行的并不是PyCodeObject对象,而是一个PyFrameObject对象,它可以模拟栈帧的效果。

[Objects/frameobject.h]

typedef struct _frame {
    PyObject_VAR_HEAD
    struct _frame *f_back; /* previous frame, or NULL */
    PyCodeObject *f_code;  /* code segment */
    PyObject *f_builtins;  /* builtin symbol table (PyDictObject) */
    PyObject *f_globals;   /* global symbol table (PyDictObject) */
    PyObject *f_locals;       /* local symbol table (any mapping) */
    PyObject **f_valuestack;   /* points after the last local */  

    PyObject **f_stacktop;
    PyObject *f_trace;    /* Trace function */  

    PyObject *f_exc_type, *f_exc_value, *f_exc_traceback;  

    PyThreadState *f_tstate;
    int f_lasti;      /* Last instruction if called */
    int f_iblock;     /* index in f_blockstack */
    PyTryBlock f_blockstack[CO_MAXBLOCKS]; /* for try and loop blocks */
    PyObject *f_localsplus[1]; /* locals+stack, dynamically sized */
} PyFrameObject;

PyFrameObject类似于栈帧,其定义如上。

f_back可以指向上一个PyFrameObject对象的位置,以此形成了一条执行环境链。

f_code存放PyCodeObject对象。

f_localsplus是由PyFrameObject维护的一个动态内存,主要给变量和栈使用。f_localsplus会先将一些需要使用的变量放进动态内存,剩余部分给栈使用。由f_valuestack指示栈底,f_stacktop指示栈顶。


一个.py文件通常被认为一个模块,想要使用一个模块必须要加载这个模块。

加载分为两种:import加载和主模块加载。无论哪种加载方式,加载时Python都会执行模块中的表达式。

每个模块都对应着一个名字空间,名字空间中维护着很多约束,约束即是每个对象和其对应名字的一个关联关系(name,obj),可由PyDictObject实现。我们在进行属性引用的时候就会用到名字空间。


作用域就是一些约束的能够起作用范围,每个作用域都会有自己的名字空间。

在作用域中,我们可以直接访问作用域内的名字。如果要使用其他名字空间的名字,就要使用类似A.name这样的方法。访问名字的行为称之为名字引用。

3.1、LGB规则


上面的PyFrameObject里有三个指针没有介绍:locals,globals,builtins。locals名字空间即是当前(函数)的名字空间,globals名字空间是模块作用域上的名字空间,builtins名字空间是Python定义的顶层作用域名字空间(dir、range等函数所在)。

对于嵌套作用域,遵循最内嵌套作用域规则(LGB规则):一个名字对其内部的作用域可见,除非内部作用域有一个一样的名字将他遮蔽。简单来说,Python对于一个名字,会按照locals,globals,builtins的顺序查找。

3.2、LEGB规则


E表示enclosing,表示直接外围作用域,因为Python在2.2引入了嵌套函数。

所以Python对于一个名字,优先查询当前名字空间,否则每次查找直接外围作用域,直到找到builtins为止。


Python执行拥有一个进程,进程内存在一个主线程和其他线程。线程的状态由PyThreadState对象进行抽象,线程共享资源。

对于模块(module)而言,因为存在大量import,如果每个线程独立拥有模块集合将增大内存消耗,所以所有线程贡献这些模块。

对于进程,由PyInterpreterState实现,多线程环境下,通常Python只有一个interpreter,多个PyThreadState对象轮流使用这个interpreter。

Python中使用全局解释器锁GIL(Global Interpreter Lock)来实现线程同步,后面会讲。

PyThreadState和PyInterpreterState


每个PyThreadState中都会维护一个PyFrameObject对象的列表,可以简单认为就是一堆函数栈帧,在函数调用时使用。

在虚拟机跑的时候,先获得当前线程的PyFrameObject,设为当前执行环境。在进入一个新的PyFrameObject对象时,将新的对象f_back指向旧对象,这样就形成了一条运行时frame的链。


第九章:一般表达式执行


本章主要讲虚拟机怎么通过运行时栈和名字空间执行一般表达式的字节码指令。

9.1、9.2其实就是讲了一些运行时,表达式被转化为字节码指令,Python虚拟机会根据指令进行相关操作,然后对这些操作举了一些例子。

在load一些值的时候,他们会从固定的空间中取出然后被压入运行时栈f_localsplus(就是PyFrameObject中的那个)。

进行名字关联时,将值从栈中pop出来,然后把名字和值关联到名字空间中(比如locals等等)。

举个例子:

d = {"a":1, "b":2}

现在创建了一个字典d,并且有两个初始化的值。编译得到字节码指令序列。

12 BUILD_MAP 0
15 DUP_TOP
16 LOAD_CONST 0
19 ROT_TWO
20 LOAD_CONST 2
23 STORE_SUBSCR
24 DUP_TOP
25 LOAD_CONST 3
28 ROT_TWO
29 LOAD_CONST 4
32 STORE_SUBSCR
33 STORE_NAME 2

BUILD MAP创建了一个空的字典压入了运行时栈。

DUP_TOP增加了栈顶元素的计数并且把栈顶的元素再次压入栈(后面告诉你为啥)。

LOAD_CONST 0从consts中读取序号为0的元素(就是值1)压入栈。

ROT_TWO让栈顶两个元素交换位置。

LOAD_CONST 2从consts中读取序号为2的元素(就是键“a”)压入栈。

STORE_SUBSCR会取出栈顶前三个元素,把(key,value)对添加到字典中。这是因为之前DUP_TOP再次压入了字典,所以这里能很轻易的获得键值对所在的字典是哪个。然后栈顶指针回退3格,并减去三格内元素引用。

到这里,第一个"a":1就已经添加完了,同样的道理添加第二个。

最后,STORE_NAME 2从names读取序号为2的元素(就是"d"),然后pop得到栈顶的值,然后写入名字空间中。

这样我们就在名字空间中拥有了一个字典d。


书中只讲了一个例子。

a = 1
b = a
c = a + b
print c

第一行简单不讲。

b = a编译后字节码指令为。

0 LOAD_NAME 0
3 STORE_NAME 1

LOAD_NAME从names中按照序号获得变量名(拿到"a"),然后会在名字空间中查找变量名对应的只是多少。这里的搜索就是按照上文讲的LEGB规则。找到值后入栈,否则抛出异常。STORE_NAME从栈中pop出值,将("b",1)插入名字空间。

然后执行加法操作c = a + b,编译得到以下指令。

12 LOAD_NAME 0
15 LOAD_NAME 1
18 BINARY_ADD
19 STORE_NAME 2

只讲一下BINARY_ADD。其实也很容易想到,出栈两个数执行相加函数然后入栈。特别的,Python对于Int和String对象的相加进行了优化。

最后print c,编译后得到字节码。

22 LOAD_NAME 2
25 PRINT_ITEM
26 PRINT_NEWLINE

LOAD_NAME 2将c的值从名字空间中取出压入栈中。

PRINT_ITEM从栈顶pop出值,然后进行打印。特别的,print时会判断一个叫stream的东西。如果stream为NULL则使用标准输出流。


第十章:虚拟机控制流


本章还是看字节码指令。


本节介绍Python虚拟机对if控制流字节码指令的实现。

简单写一个if语句:

a = 1
if a > 10:
    print "a > 10"
elif a <= -2:
    print "a <= -2"
...
else:
    print "unknow"

编译后得到:

[a > 10]
6 LOAD_NAME 0
9 LOAD_CONST 1
12 COMPARE_OP 4
15 JUMP_IF_FALSE 9
18 POP_TOP
[print "a > 10"]
19 LOAD_CONST 2
22 PRINT_ITEM
23 PRINT_NEWLINE
24 JUMP_FORWARD 72
27 POP_TOP
[elif a <= -2]
28 LOAD_NAME 0
...

1.1、COMPARE_OP


a > 10中有一个比较的指令COMPARE_OP 4。这里的比较参数由Python定义。

#define Py_LT 0
#define Py_LE 1
#define Py_EQ 2
#define Py_NE 3
#define Py_GT 4
#define Py_GE 5

其比较的实现就是一个switch判断是什么类型的比较,然后通过类型自定义的比较进行。特别的,Python对于整型的比较进行了特化,加快了PyIntObject的判断速度。因为C没有bool,所以其比较的结果使用PyIntObject的0/1表示false或者true。

1.2、跳跃指令


能看到,如果a > 10返回false,Python将执行指令15 JUMP_IF_FALSE 9。这是一个跳跃指令,表示程序将跳跃9个字节,当前next_instr(指向下一个指令地址)为18,加9后跳到27 POP_TOP

Python对于跳跃指令的实现和优化是这样的:

由于很多字节码指令事实上通常是顺序出现的(COMPARE_OP后通常跟JUMP_IF_FALSE/JUMP_IF_TRUE),那么Python会对指令码进行预测。

[ceval.c]
#define PREDICT(op)             if (*next_instr == op) goto PRED_##op
#define PREDICTED(op)           PRED_##op: next_instr++
#define PREDICTED_WITH_ARG(op)  PRED_##op: oparg = PEEKARG(); next_instr += 3

Python使用PREDICT宏判断下一条指令是否是我预测的指令,如果是则直接使用goto跳跃到那个预测指令成功所要做的事,以提升效率。PREDICTED和PREDICTED_WITH_ARG就是生成跳跃标识的宏,会放在每个case之前,可以看到预测成功后next_instr直接跳过了预测的指令进行下一步。(所以PREDICT只是Python的优化操作而已)

接下来就要介绍真正的跳跃指令了。

不带参数的指令预测成功直接next_instr++就跳跃成功。

带参数的指令预测成功:

[ceval.c]
#define PREDICTED_WITH_ARG(op)  PRED_##op: oparg = PEEKARG(); next_instr += 3
#define PEEKARG()       ((next_instr[2]<<8) + next_instr[1])
#define JUMPBY(x)       (next_instr += (x))

首先,要跳跃就要知道跳跃几个字节,oparg = PEEKARG()得到我们的跳跃长度,因为参数占两个字节,next_instr[2]是高8位,next_instr[1]是低8位。那么我们就已经得到了所需跳跃的指令字节长度oparg。然后Python会执行宏JUMPBY(或者其他指令同理),next_instr就直接指向跳跃后的指令了。那么就完成可跳跃指令。


以书中所举为例:

lst = [1, 2]
...
for i in lst:
12 SET_LOOP 19
15 LOAD_NAME 0
18 GET_ITER
19 FOR_ITER 11
22 STORE_NAME 1
    print i
...
29 PRINT_NEWLINE
30 JUMP_ABSOLUTE 19
33 POP_BLOCK
...

在for循环刚开始,12 SET_LOOP 19中Python会使用PyTryBlock的b_level去存储栈的深度。PyTryBlock内存是从PyFrameObject中的f_blockstack内存池中获得。15 LOAD_NAME 0将list入栈。

typedef struct {
    int b_type;          /* what kind of block this is */
  int b_handler;    /* where to jump to find handler */
  int b_level;      /* value stack level to pop to */
} PyTryBlock;

此时,18 GET_ITER中Python将获得栈顶的list并取得其迭代器。顺带一说,list迭代器是由其类型对象得到的,其类型对象中存在获得迭代器的函数指针tp_iter。list的迭代器对象其实只是对list的简单封装。

[listobject.c]
typedef struct {
    PyObject_HEAD
    long it_index;
    PyListObject *it_seq; /* Set to NULL when iterator is exhausted */
} listiterobject;

可以看到其实里面只保存了当前访问的index值。

得到迭代器对象后,Python会将栈中的list对象通过SET_TOP宏强转为迭代器对象。

接下来就是不断循环。

19 FOR_ITER 11将从栈顶取得迭代器对象,然后通过迭代器tp_iternext得到下一个元素。

如果能得到元素,则往下进行后续循环内操作。

可以看到循环操作的结尾是30 JUMP_ABSOLUTE 19

#define JUMPTO(x)       (next_instr = first_instr + (x))

看指令名字就应该明白,直接跳到绝对位置***。Python虚拟机使用宏JUMPTO实现,first_instr为PyCodeObject中co_code的开始地址。那么直接跳到19,也就是19 FOR_ITER 11这条指令。

如果19 FOR_ITER 11取下一个元素返回得到NULL,则证明循环应当结束。可以看到这条指令其实是带参的,11表示终止迭代后跳11个字节到下一个指令,即33 POP_BLOCK

POP_BLOCK归还PyTryObject,并且将栈恢复到对象b_level所存储的深度。那么循环就结束了。

可以看到PyTryObject里还有一些属性这里并没有用到,这是因为这个对象是多用途的。


和for差不多,只讲两个continue和break。

continue很简单,直接用JUMP_ABSOLUTE指令绝对位置跳跃会到循环开始即可。

break通过指令BREAK_LOOP实现。Python实现BREAK_LOOP的过程为:将why设置为WHY_NOT表明是正常退出;通过PyTryBlock将栈恢复到while之前的状态;通过PyTryBlock的b_handler得到跳出循环后的第一条指令的绝对地址(b_handler会在SET_LOOP指令中设置)。


4.1、内建异常处理


在虚拟机执行过程中,如果出现异常,将抛出异常并返回异常指示码。这里抛出的异常是一个对象,Python会将异常放至PythreadState的curexc_type中,那么现在已经将异常保存在线程对象里了。

假设这里是个除0异常,那么继续执行,虚拟机得到除0后整数对象为NULL,此时,虚拟机发现异常,设置why=WHY_EXCEPTION。进入异常处理流程。

平时用过Python就应该知道,Python发生异常报错会有一大串异常信息指示是在哪些地方发生了错误。这是因为异常处理过程中使用了traceback对象。

[traceback.h]
typedef struct _traceback {
   PyObject_HEAD
   struct _traceback *tb_next;
   struct _frame *tb_frame;
   int tb_lasti;
   int tb_lineno;
} PyTracebackObject;

发现异常后,Python将创建保存线程状态对象中的traceback对象。可以看到traceback中保存了当前的frame,执行到的行号等等信息。每个PyFrameObject都对应着一个traceback对象。抛出异常后,Python会寻找excep语句,如果不存在则通过PyFrameObject对象的f_back返回上一个栈帧继续找,同时生成新的traceback对象,二者用tb_next相连形成一条trace链。沿着栈帧链不断回退,进行栈帧展开。最后没有接受异常则调用PyErr_Print提示异常。

4.2、py提供的异常控制


在使用try…except…语句是,如果在try模块抛出了一个异常,那么Python和4.1讲的一样,会将异常存入线程状态对象中,然后将why设为WHY_NOT,异常入栈。此时就可以跳到程序员写的except进行处理。Python通过JUMPTO宏跳到except对应的指令,COMPARE_TO比较异常是否和except列出的匹配,匹配则继续执行程序员给出的异常处理指令;不匹配则重回异常状态,栈帧展开。


第十一章:虚拟机函数机制


[funcobject.h]
typedef struct {
  PyObject_HEAD
  PyObject *func_code;   /* A code object */
  PyObject *func_globals;    /* A dictionary (other mappings won't do) */
  PyObject *func_defaults;   /* NULL or a tuple */
  PyObject *func_closure;    /* NULL or a tuple of cell objects */
  PyObject *func_doc;       /* The __doc__ attribute, can be anything */
  PyObject *func_name;   /* The __name__ attribute, a string object */
  PyObject *func_dict;   /* The __dict__ attribute, a dict or NULL */
  PyObject *func_weakreflist;    /* List of weak references */
  PyObject *func_module; /* The __module__ attribute, can be anything */
} PyFunctionObject;

Python的函数也是对象,即PyFunctionObject对象。函数对象在def时被创建,函数对象可以存在多个,但是其func_code都会指向同一个PyCodeObject。


1.1、函数创建


函数创建在def的时候发生。虚拟机在执行def语句时,使用MAKE_FUNCTION指令,创建一个PyFunctionObject对象。首先虚拟机将函数对应的PyCodeObject对象入栈,然后出栈,创建PyFunctionObject对象,并初始化各个元素,包括func_code指示PyCodeObject,func_globals设置global空间名字等等。然后将函数对象入栈,保存在local名字空间。


1.2、函数调用


Python在call_function中实现函数调用。通过进出栈得到PyFunctionObject对象(有参数还要计算参数相关量个数),然后Python会创建新的PyFrameObject对象进行递归调用。而PyFunctionObject对象所保存的信息其实就是为frame所准备的,其func_globals即是新的frame的globa名字空间,func_code即是frame的PyCodeObject对象。


2.1、参数类别


Python中参数分为四种:位置参数,键参数,扩展位置参数,扩展键参数。

非键参数使用必须在键参数之前。

2.2、参数详解


[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;
    ...
}

位置参数:

在执行调用函数的函数call_function之前,虚拟机会先得到函数的参数个数信息oparg,其低字节记录位置参数个数,高字节记录键参数个数。易知,na为位置参数个数,nk为键参数个数。由于键参数有两个值所以n = na + 2 * nk。由于PyFunctionObject先入栈,参数信息后入栈,所以向栈回退n + 1得到函数对象。

顺带一提,不论是扩展位置参数还是扩展键参数,Python都当做一个变量而不是参数去处理。

然后程序流程进入fast_function。

在这里会创建函数对象对应的PyFrameObject,然后把属性传递给frame。虚拟机会将参数拷贝到PyFrameObject的f_localsplus中,Python在将参数压入运行时栈时按照从左到右顺序,故拷贝到f_localsplus也是从左到右顺序。f_localsplus之前也说了这是用来当动态内存的,前面一部分用来存放参数等等,后面的用来当运行时栈。

在访问位置参数时,Python直接通过f_localsplus的地址偏移位置直接找到对应的值,而不是通过名字空间。相关指令如LOAD_FASTSTORE_FAST就是读写f_localsplus。

当函数存在默认值时:

如果参数含默认值,在MAKE_FUNCTION指令的实现中,在向栈中压入PyFunctionObject对象后,再将所有默认值压入栈,然后将默认值弹出加入PyTupleObject,将这个tuple设置为PyFunctionObject.func_defaults的值,这样默认值就绑定了。

在执行函数的时候,会先把参数传入PyFrameObject中,然后虚拟机会判断是否需要使用默认参数。当传入的位置参数个数小于总位置参数时,说明使用默认位置参数。虚拟机从第一个应该放默认参数的位置开始判断,如果当前f_localsplus的位置是NULL,那么就放入默认参数。

存在键参数时:

在编译时,Python会将函数的参数名称记录在变量名表co_varnames中,因为之前已经看到了键参数会同时压入参数名称和值。那么我们直接遍历键参数的key和value,然后遍历co_varnames查看是否存在这个键参数的名字。巧妙的是,co_varnames中参数的顺序和f_localsplus参数的顺序是一致的,那么如果遍历的时候找到键参数名字,那么就等于顺便找到了在f_localsplus运行时栈中参数存放的位置,直接替换;没找到键参数名字,那么可能就是扩展键参数了,后面再讲。当键参数设置完了,再进行默认值设置。

扩展位置参数和扩展键参数:

正如之前提到的,扩展位置参数和扩展键参数是通过局部变量实现的,其中扩展位置参数通过PyTupleObject实现,扩展键参数通过PyDictObject实现。

对于有*lst扩展位置参数的函数,def时会将编译后得到的PyCodeObject的co_flags 添加CO_VARARGS标记;对于有*keys扩展键参数的函数,会将编译后得到的PyCodeObject的co_flags添加CO_VARKEYWORDS标记。

在读取位置参数时,如发现传入的位置参数个数大于给定位置参数个数,则判断是否传入了扩展位置参数。Python创建PyTupleObject存储这些扩展位置参数,然后将tuple放到f_localsplus中位置参数后第一个内存中。

前面提到,Python虚拟机在查找co_varnames中键参数的名字失败后,会判断其是否为扩展键参数,创建PyDictObject存储扩展键参数。并将其放到f_localsplus中扩展位置参数tuple的后面。


函数局部变量内存大小总量分配是固定的,故Python直接在f_localsplus中读写局部变量,而不是采用local名字空间,以静态方法提高效率。


在一个外部函数中嵌套了一个内函数,内部函数里运用了外函数的变量。这样就构成了一个闭包。

base = 1
def get_cmp(base):
    def cmp(value):
        return value < base
    return cmp
real_cmp = get_cmp(10)
real_cmp(2)    #true
real_cmp(11) #false

以上例子就是闭包的应用。在例子中,内部嵌套函数的base似乎与外部函数get_cmp的cmp形成了某种绑定的效果。

其具体实现:

在PyCodeObject中存在以下两个属性:

co_cellvars:tuple,保存嵌套的作用域中使用的变量名集合

co_freevars:tuple,保存使用了的外部作用域的变量名集合

虚拟机在调用外部函数的时候,会查看co_cellvars以获得被内部函数使用的变量名集合。如果有这样的变量名,则创建一个PyCellObject对象。

[cellobject.h]
typedef struct {
   PyObject_HEAD
   PyObject *ob_ref;  /* Content of the cell or NULL when empty */
} PyCellObject;

可以看到PyCellObject只是维护了一个变量的引用,初始为NULL。这个cell对象会被存放在外部函数的f_localsplus的局部变量的后面。此时我们要获得这个cell对象只需要知道其索引即可。当外部函数执行是,在这个变量被赋值的时候,虚拟机会将ob_ref引用指向它的真实内存。

在内部函数的函数对象生成后,虚拟机将把cell对象全都打包成tuple然后让内部函数的PyFunctionObject对象的一个引用指向tuple。

在内部函数对象调用时,虚拟机将把PyFunctionObject对象中tuple的cell对象取出来,复制到内部函数frame的f_localsplus中,位置在free对象区。

闭包案例

def my_func(*args):
    fs = []
    for i in xrange(3):
        def func():
            return i * i
        fs.append(func)
    return fs

fs1, fs2, fs3 = my_func()
print fs1()
print fs2()
print fs3()

这里还有一个很有意思的闭包相关的代码,结果不是0,1,4而是4,4,4。

Decorator装饰器是一个函数,接受一个函数作为参数,返回值是一个函数的调用。

def say(func):
    def f():
        print 'say...'
        func()
    return f

@say
def name():
    print 'my name is Li hua'

'''
say...
my name is Li hua
'''
name()

等价于:name = say(name)


第十二章:虚拟机中的类


做一个约定,记

type对象:Python内建类型

class对象:用户自定义类型


从目前我们已知的信息来看,class对象并不能继承type对象。

class MyInt(int):
    def __add__(self, other):
        return int.__add__(self, other) +10;

如上所示,MyInt要继承int并实现一个加法操作,但是根据目前所知,int类型中并未定义一个叫做"__add__"的操作(虽然有PyInt_Type.tp_as_number.nb_add,但是虚拟机并不知道)。那么我们要对其做一个关联操作(操作名,函数),在遇到操作名时调用操作,理论上就可以了。

现在讲一下class创建过程。

Python启动时,会对类型系统进行初始化,这个操作在PyType_Ready中进行。包括设置基类base和基类列表bases等等。

然后就是重要的填充tp_dict。

1.1、slot


首先,Python内部已经定义好了操作名和操作的集合,是一个slotdefs的全局数组。

typedef struct wrapperbase slotdef;

struct wrapperbase {
    char *name;  //操作名
    int offset;  //偏移量
    void *function;
    wrapperfunc wrapper;
    char *doc;
    int flags;
    PyObject *name_strobj;
};

static slotdef slotdefs[] = {
    TPSLOT("__str__", tp_print, NULL, NULL, ""),
    TPSLOT("__repr__", tp_print, NULL, NULL, ""),
    TPSLOT("__getattribute__", tp_getattr, NULL, NULL, ""),
    TPSLOT("__getattr__", tp_getattr, NULL, NULL, ""),
    TPSLOT("__setattr__", tp_setattr, NULL, NULL, ""),
    ...
};

可以看到,slotdefs定义了大量的操作,这些操作全都封装在slot中。

slot是Python内部表示PyTypeObject中定义操作的概念,slot包含了操作名,相对PyHeapTypeObject的偏移值等等数据。这里先给出PyHeapTypeObject的定义。

[object.h]
typedef struct _heaptypeobject {
    PyTypeObject ht_type;
    PyNumberMethods as_number;
    PyMappingMethods as_mapping;
    PySequenceMethods as_sequence;
    PyBufferProcs as_buffer;
    PyObject *ht_name, *ht_slots;
} PyHeapTypeObject;

我们可以看到,slotdefs中出现一种特殊情况:同一操作名却对应了多种不同的方法。那么Python虚拟机要怎么分辨该使用哪个呢?这时候就需要用到PyHeapTypeObject了。在创建每个slot的时候,会计算一个offset,这个offset表示当前操作函数相对PyHeapTypeObject的偏移值。

#define offsetof(type, member) ( (int) & ((type*)0) -> member)

比如我当前slot操作定义的函数是tp_as_number,那么tp_as_number是定义在PyNumberMethods中的,使用上面的宏指令进行查询,就能得到tp_as_number相对于PyHeapTypeObject的偏移位置。得到了这个之后就简单了,Python会根据offset的大小对slotdefs重新排序,offset越小的排在越前面,这样我们就得到了一个优先级排序了。说句人话,在定义操作名出现多个操作方法时,Python虚拟机选择的优先级为:PyNumberMethods > PyMappingMethods > PySequenceMethods。

这一切都在init_slotdefs()函数中实现(这个函数只能被调用一次),插入tp_dict时,如果一个操作名字已经出了那么直接continue,否则插入新的键值对。

1.2、descriptor


看到上面好像已经结束了,但是仔细看一下slot,发现slot不是PyCodeObject,他不能直接放到tp_dict中。那么就需要对slot包装一下了,我们称之为descriptor。

与PyTypeObject关联的descriptor为PyWrapperDescrObject。

[descrobject.h]
#define PyDescr_COMMON \
    PyObject_HEAD \
    PyTypeObject *d_type; \
    PyObject *d_name

typedef struct {
    PyDescr_COMMON;
    struct wrapperbase *d_base;
    void *d_wrapped; /* This can be any function pointer */
} PyWrapperDescrObject;

可以看到,wrapperdescriptor里关联了一个slot和操作对应函数指针。里面的d_type关联PyWrapperDescr_Type,调用descriptor时就会调用其中的tp_call(wrapperdescr_call)。

那么现在我们终于得到最终的关联情况了。

在关联操作名-操作函数时,遍历排序后的slotdefs,对于每一个slot,创建一个descriptor,如果

slotdef->name已经出现在tp_dict了,那么跳过这个优先级低的;然后保存(name,descriptor)。

这里其实还有一个问题,就是要得到d_wrapped。我们已经知道了offset是相对PyHeapTypeObject的偏移值,但是真实的函数实现是在PyTypeObject里的,那么咋整?其实也容易想到,因为每个函数在methods里的相对位置是不变的,比如(nb_add在PyNumberMethods里的相对位置总是不变),那么我们只要找到当前这个函数在PyHeapTypeObject中PyNumberMethods,PyMappingMethods,PySequenceMethods中的哪一个,计算出和它的相对偏移量,然后type中取出对应指针的地址加上偏移量即可获得函数地址。


MRO即Method Resolve Order,是类对象属性的解析顺序。

因为Python允许多重继承,那么就会带来一些问题,比如菱形问题。所以Python使用MRO来做一个规定。

Python虚拟机将创建一个tuple对象用来存储一组class对象,这个tuple的顺序就是虚拟机解析属性时的MRO顺序。这个tuple将被保存在PyTypeObject.tp_mro中。

那这个tuple怎么来呢?

深度遍历继承关系(想象成一棵树来表示),每遍历到一个class就把他记录到list中。如果多次遍历到同一个class,只保留最后一个。list的第一个class永远是自己。最后我们就得到了这个mro的tuple。

然后就可以继承基类操作了。

虚拟机按照mro顺序遍历class(除了第一个也就是自己),然后把基类中有自己没有的操作拷贝到对象里面。

最后在基类中填充子类列表。在PyTypeObject中有一个tp_subclasses列表表示所有继承自该类型的class对象。Python虚拟机会向所有父类tp_subclasses中添加该子类。


1、创建class对象

class A(object):
    name = ‘Python’
    def __init__(self):
        print 'A:__init__'
    def f(self):
        print 'f'

a = A()
a.f()

class的元信息指关于class的信息,比如属性、方法、实例化内存大小等等。

如上举例,虚拟机会先将'A'压入运行时栈,然后新建一个tuple,里面存放A的所有基类并压入栈。然后将A对应的PyCodeObject对象压入栈,创建一个PyFunctionObject对象。此时,虚拟机会使用CALL_FUNCTION调用这个函数对象,生成PyFrameObject对象,虚拟机会将A的动态元信息记录到函数对象的local名字空间中,即(name,'Python')、(__init__,PyCodeObject)、(f,PyCodeObject)。此时,我们已经得到了A的动态元信息,都存储在local名字空间了。然后虚拟机将会把local名字空间压入运行时栈,随后PyFrameObject调用结束,虚拟机将local名字空间出栈,以返回值形式return给调用函数对象的CALL_FUNCTION处,再压入运行时栈。现在栈中只存在'A',tuple,local名字空间。至此动态元信息都已经拿到了。

随后Python将创建class。

我们要创建一个class,必须要知道申请的内存大小,怎么创建等等,显然这些并不在动态元信息中,这些都在静态元信息metaclass中,其包含所有class对象如何创建的信息。如果用户未指定__metaclass__,那么虚拟机将使用第一基类(bases列表中第一个基类)作为metaclass。

此时虚拟机使用type作为metaclass。

我们之前已经得到了类名,基类tuple,local名字空间,他们同样会被打包成一个tuple传入创建class的函数PyObject_Call。虚拟机将获得最佳metaclass和最佳base,这里是type和object。然后虚拟机会尝试分配内存。已知基类还在PyType_Ready中继承他的基类的tp_alloc,这里type的基类是object,那么就继承了object的PyType_GenericAlloc,最终申请内存大小metatype->tp_basicsize+metatype->tp_itemsize,从PyType_Type定义可得,等于sizeof(PyHeapObject) + sizeof(PyMemberDef)。这里好像出现了一个眼熟的PyHeapObject,其实他就是为用户自定义class对象准备的(所以PyHeapTypeObject里面直接就创建PyNumberMethods而不是指针)。所以对于内建对象,其methods在编译时确定,内存分离;而自定义对象,PyTypeObject和methods内存时连续的。然后设置class对象的各个域,属性表等等。最后对其调用PyType_Ready进行初始化动作。


实例化a = A()的过程,相当于调用class A的type所对应的tp_call。在PyType_Type.tp_call中又调用了A.tp_new创建实例化对象。这里的new操作继承自object,object_new调用了A.tp_alloc,然而这个继承自object的PyType_GenericAlloc,申请A.tp_basicsize+A.tp_itemsize大小。这里的tp_basicsize为PyBaseObject_Type + 8(两个PyObject*)= 24。tp_itemsize = 0。

看起来并没有为实例化对象分配什么内存,只有前面的PyObject的16字节。

对于实例化,虚拟机会对其进行初始化操作,执行__init__,因为重写了__init__,所以最终这个方法会指向用户定义的初始化的slot。

class对象实例化分为两步:

instance = class.__new__ (class, args, kwds)

class.__init__(instance, args, kwds)


5.1、属性、descriptor和__dict__


提出一个概念,descriptor,这边又提了一遍,这在slot那里也说过了。这里详细说一下。对于一个对象,如果他的class对象有__get__,__set__,__delete__中的一个或者多个操作,那么就称它为Python的descriptor。descriptor的一个实例就是class对象的一个属性。

对于descriptor,分为两类:

  1. data descriptor:type定义了__get__,__set__
  2. non-data descriptor:type仅定义了__get__

在寻找属性的过程中,严格遵循以下规则:

Python虚拟机会优先选择实例对象中定义的属性,然后再去判断class对象的属性,除非class对象的同名属性是个data descriptor。当descriptor属性是class对象的一个descriptor时,调用的是descriptor.__get__的返回结果,设置时是把值传入descriptor.__set__去设置;相反,当descriptor属性是实例对象的一个descriptor时,则直接返回这个descriptor。

可以看下面的例子感受一下。

class Attr(object):
    def __init__(self):
        self.value = 0  

    def __set__(self, instance, value):
        print 'Attr():__get__'
        self.value = value  

    def __get__(self, instance, owner):
        print 'Attr():__get__'
        return self.value  

class Bttr(object):
    def __init__(self):
        self.value = 0  

    def __get__(self, instance, owner):
        print 'Bttr():__get__'
        return self.value

class A(object):
  a = Attr()
  b = Bttr()

a = A()
a.a = 10
print a.a
print a.__dict__
'''
Attr():__get__
Attr():__get__
10
{}
'''
a.b = 10
print a.b
print a.__dict__
'''
10
{'b': 10}
'''

可以看到,上述例子遵循了规则。当class中存在data descriptor时,使用class的descriptor,则实例对象的__dict__为空;否则使用实例对象的属性,在__dict__中创建了属性。

为了访问实例对象的属性,Python为每个实例对象都分配了一个__dict__,看名字就知道他是一个字典,这个字典可以将属性名和属性关联。在实例化的时候,分配了两个PyObject_,其中一个指针指向真实的__dict__内存地址,这个PyObject_指针的位置由class中的tp_dictoffset表示的偏移量指示。

5.2、成员函数


观察class里的函数,我们可以看到,每个函数都会带一个参数self,但是观察指令码可以发现这个self参数并没有被压入栈,好像这个self不存在一样,那这个self参数到底存不存在?

查看PyFunction_Type,能看到PyFunction_Type对象只有tp_descr_get=&func_descr_get没有set,那么说明成员函数对象是一个non-data descriptor,其返回值为func_descr_get的调用结果,其结果为PyMethodObject对象。

[classobhect.h]
typedef struct {
    PyObject_HEAD
    PyObject *im_func;   /* 要调用的PyFunctionObject对象 */
    PyObject *im_self;   /* self参数,就是instance对象 */
    PyObject *im_class;  /* class对象 */
    PyObject *im_weakreflist; /* List of weak references */
} PyMethodObject;

现在能看出来了,其实你调用了一个函数,PyMethodObject直接把class对象和实例对象绑定到这里面来了,这个self参数就在这里。PyMethodObject通过缓冲池管理。

调用无参成员函数时,虚拟机和执行之前讲的调用函数流程一样,进入call_function。这个时候,虚拟机认为这个函数是无参函数,虽然其实是带了一个位置参数self。通过判断PyMethodObject的im_self是否存在来看是否需要处理这个self参数。然后虚拟机会把PyFunctionObject和self拿出来,执行self参数入栈,并修改na和n。然后就是普通带参函数执行了。

调用带参成员函数时和上面基本一样。

5.3、Bound Method和Unbound Method


对实例对象的函数属性进行引用称为Bound Method,通过类对象进行函数属性的引用称为Unbound Method。其中Bound Method在上面已经讲了。

使用类对象的函数时,我们需要这样A.fun(a),将实例对象传进去。看上面我们Bound Method的实现可以看到,无论如何,对于成员函数的实现都需要一个self参数,这个参数就是一个实例对象。虚拟机在调用Unbound Method时,会帮你把实例对象绑定到PyMethodObject里的im_self。每次调用都会重新绑定self,而不是Bound Method那样只需要绑定一次。


-虚拟机部分END-

手机扫一扫

移动阅读更方便

阿里云服务器
腾讯云服务器
七牛云服务器

你可能感兴趣的文章