10-PyCodeObject对象和pyc文件
10-Python中的PyCodeObject对象与pyc文件
楔子
当我们想要执行一个py文件的时候,只需要python xxx.py即可,但是你有没有想过这背后的流程是怎么样的呢?从这里开始我们就开始进入到Python虚拟机的环节了,之前都是在介绍Python中的一些内置对象,不过虚拟机的执行流程、以及背后的原理却更是值得我们关注的。
这里我们先来说一下Python执行py文件的流程:
1. 首先将文件里面的内容读取出来, 所以从这个角度上讲, 文件名不一定非要是.py结尾, .txt也是可以的, 只要文件里面的内容符合Python代码规范即可
2. 读取文件里面的内容之后会对其进行分词, 将源代码切分成一个一个的token
3. 然后Python编译器会对token进行语法解析, 建立抽象语法树(AST, abstract syntax tree)
4. 编译器再将得到AST编译成字节码
5. 最终由Python虚拟机来执行字节码
首先我们从中看到了Python编译器、Python虚拟机,而且我们平常还会说Python解释器,那么三者之间有什么区别呢?
实际上Python解释器 = Python编译器 + Python虚拟机,Python编译器负责将Python源代码编译成字节码
(包括文件读取、分词、建立AST、编译成字节码)
,Python虚拟机负责执行这些字节码。
那么Python编译器和Python虚拟机都在什么地方呢?如果打开Python的安装目录,会发现有一个python.exe,点击的时候会通过它来启动一个终端。
但问题是这个文件大小还不到100K,不可能容纳一个解释器加一个虚拟机,所以下面还有一个python38.dll,没错,编译器、虚拟机都藏身于python38.dll当中。
因此Python代码并不是直接就一步到胃、变成机器码,而是先被Python编译器编译成字节码,中间存在一个编译的过程。而之所以要存在编译,是为了能够让虚拟机更快速的执行,而且还可以尽早的检测出语法上的错误。
那么下面我们就来看看Python中的字节码长什么样子。
Python编译器的编译结果–PyCodeObject对象
我们知道Python代码的编译结果是字节码,里面必然隐藏了Python运行的秘密,因此不管是深入理解虚拟机还是调优Python的运行效率,字节码都是绕不过去的一个坎。
注意:我们这里会研究字节码,但是不会研究Python是怎么编译得到字节码。因为Python编译器的工作原理和其它语言基本类似,很多关于编译原理的书籍都有介绍,编译这个过程不是Python特有的。并且研究Python的编译过程,对于我们开发帮助不是很大。
所以我们只需要知道Python解释器的背后有一个编译器会通过”读取文件”、”对源代码分词”、”分词之后会语法解析建立AST”、”对AST编译”得到字节码即可,至于这一列步骤是怎么做的、是怎么将源代码变成的字节码不是我们需要关心的,我们的重点是研究字节码本身以及虚拟机。
PyCodeObject对象和pyc文件
首先做Python开发的肯定都见过这个pyc文件,它一般位于__pycache__
目录中,那么这个pyc文件和字节码之间有什么关系呢?
首先我们知道,Python执行这个文件首先要进行的动作就是编译,编译会得到字节码。然而除了字节码之外,还应该包含一些其它的信息,这些信息也是Python运行的时候所必须的。
在编译过程中,像常量值、字符串这些源代码当中的静态信息都会被Python编译器收集起来,并且这些静态信息也都会体现在编译之后的结果里面。在Python运行期间,这些源文件提供的静态信息都会被存储在一个运行时的对象当中,当Python运行结束时,这个运行时对象中所包含的信息还会被存储在一种文件中。这个对象和文件就是我们接下来要探讨的重点:PyCodeObject对象和pyc文件。
Python中的字节码只是一个PyBytesObject对象、或者说一段字节序列,PyCodeObject对象中有一个成员co_code,它是一个指针,指向了这段字节序列。但是这个对象除了有co_code指向字节码之外,还有很多其它成员,负责保存代码涉及到的常量、变量
(名字、符号)
等等所以我们知道了,pyc文件里面的内容是PyCodeObject对象。对于Python编译器来说,PyCodeObject对象才是其真正的编译结果,而pyc文件是这个对象在硬盘上表现形式。
在程序运行期间,编译结果存在于内存的PyCodeObject对象当中,而Python结束运行之后,编译结果又被保存到了pyc文件当中。当下一次运行的时候,Python会根据pyc文件中记录的编译结果直接建立内存中的PyCodeObject对象,而不需要再度重新编译了。
python源码中的PyCodeObject对象
我们说Python编译器会将Python源代码编译成字节码,虚拟机执行的也是字节码,所以要理解虚拟机的运行时(runtime)
行为,就必须要先掌握字节码。而我们说字节码是被底层结构体PyCodeObject的成员co_code指向,那么我们就必须来看看这个结构体了,它的定义位于 *Include/code.h* 中。
1 | typedef struct { |
这里面的每一个成员,我们后面都会逐一演示进行说明。总之Python编译器在对Python源代码进行编译的时候,对于代码中的每一个block,都会创建一个PyCodeObject与之对应。但是多少代码才算得上是一个block呢?事实上,Python有一个简单而清晰的规则:当进入一个新的名字空间,或者说作用域时,我们就算是进入了一个新的block了。这里又引出了名字空间,别急,我们后面会一点一点说,总之先举个栗子:
1 | class A: |
我们仔细观察一下上面这个文件,它在编译完之后会有三个PyCodeObject对象,一个是对应整个py文件的,一个是对应class A的,一个是对应def foo的。因为这是三个不同的作用域,所以会有三个PyCodeObject对象。
在这里,我们开始提及Python中一个至关重要的概念–名字空间(name space)、也叫命名空间、名称空间,都是一个东西。名字空间是符号的上下文环境,符号的含义取决于名字空间。更具体的说,一个变量名对应的变量值什么,在Python中是不确定的,需要命名空间来决定。
对于某个符号、或者名字(我们在前面系列中说过Python的变量只是一个名字)
,比如说上面代码中的a,在某个名字空间中,它可能指向一个PyLongObject对象;而在另一个名字空间中,它可能指向一个PyListObject对象。但是在一个名字空间中,一个符号只能有一种含义。而且名字空间可以一层套一层的形成一条名字空间链
,Python虚拟机在执行的时候,会有很大一部分时间消耗在从名字空间链
中确定一个符号所对应的对象是什么。这也侧面说明了,Python为什么比较慢。
如果你现在名字空间还不是很了解,不要紧,随着剖析的深入,你一定会对名字空间和Python在名字空间链上的行为有着越来越深刻的理解。总之现在需要记住的是:一个code block对应一个名字空间(或者说作用域)
、同时也对应一个PyCodeObject对象。在Python中,类、函数、module都对应着一个独自的名字空间,因此都会有一个PyCodeObject与之对应。
如何在Python中访问PyCodeObject对象
那么我们如何才能在Python中获取到PyCodeObject对象呢?PyCodeObject对象在Python中也是一个对象,它的类型对象是<class 'code'>
。但是这个类,底层没有暴露给我们,所以code对于Python来说只是一个没有定义的变量罢了。
但是我们可以通过其它的方式进行获取,首先来看看如何通过函数来获取该函数对应的字节码。
1 | def func(): |
我们可以通过函数的__code__
拿到底层对应的PyCodeObject对象,当然也可以获取里面的属性,我们来演示一下。
co_argcount:可以通过位置参数传递的参数个数
1 | def foo(a, b, c=3): |
foo中的参数a、b、c都可以通过位置参数传递,所以结果是3;对于bar,显然是两个,这里不包括\*args
;而函数func,显然是两个,因为参数c只能通过关键字参数传递。
co_posonlyargcount:只能通过位置参数传递的参数个数,python3.8新增
1 | def foo(a, b, c): |
注意:这里是只能通过位置参数传递的参数个数。
co_kwonlyargcount:只能通过关键字参数传递的参数个数
1 | def foo(a, b=1, c=2, *, d, e): |
这里是d和e,它们必须通过关键字参数传递。
co_nlocals:代码块中局部变量的个数,也包括参数
1 | def foo(a, b, *, c): |
局部变量:a、b、c、name、age、gender,所以我们看到在编译成字节码的时候函数内局部变量的个数就已经确定了,因为它是静态存储的。
co_stacksize:执行该段代码块需要的栈空间
1 | def foo(a, b, *, c): |
这个不需要关注
co_firstlineno:代码块在对应文件的起始行
1 | def foo(a, b, *, c): |
如果函数出现了调用呢?
1 | def foo(): |
如果执行foo,那么会返回函数bar,调用的就是bar函数的字节码,那么得到就是def bar():
所在的行数。因为每个函数都有自己独自的命名空间,以及PyCodeObject对象。
co_names:一个元组,保存代码块中不在当前作用域的变量
1 | c = 1 |
我们看到print、c、list、int、str都是全局或者内置变量,函数、类也可以看成是变量,它们都不在当前foo函数的作用域中。
co_varnames:一个元组,保存在当前作用域中的变量
1 | c = 1 |
a、b、d是位于当前foo函数的作用域当中的,所以编译阶段便确定了局部变量是什么。
co_consts:常量池,一个元组对象,保存代码块中的所有常量。
1 | x = 123 |
co_consts里面出现的都是常量,而[1, 2, 3]
和{"a": 1}
,则是将里面元素单独拿出来了。不过可能有人好奇里面的None是从哪里来的。首先a和b是不是函数的参数啊,所以co_consts里面还要有两个常量,但是我们还没传参呢,所以使用None来代替。
co_freevars:内层函数引用的外层函数的作用域中的变量
1 | def f1(): |
co_cellvars:外层函数中作用域中被内层函数引用的变量,本质上和co_freevars是一样的
1 | def f1(): |
co_filename:代码块所在的文件名
1 | def foo(): |
co_name:代码块的名字,通常是函数名或者类名
1 | def foo(): |
co_code:字节码
1 | def foo(a, b, /, c, *, d, e): |
pyc文件
每一个PyCodeObject对象中都包含了一个co_code指针,指向code block中所有代码经过编译后得到的byte code序列。前面我们说到,Python会将字节码序列和PyCodeObject对象一起存储在pyc文件中。但不幸的是,事实并不总是这样。有时,当我们运行一个简单的程序时并没有产生pyc文件,因此我们猜测:有些python程序只是临时完成一些琐碎的工作,这样的程序仅仅只会运行一次,然后就不会再使用了,因此也就没有保存至pyc文件的必要。
如果我们在代码中加上了一个import abc这样语句,再执行你就会发现Python为其生成了pyc文件,这就说明import会触发pyc的生成。实际上,在运行过程中,如果碰到import abc这样的语句,那么Python会在设定好的path中寻找abc.pyc或者abc.dll文件,如果没有这些文件,而是只发现了abc.py,那么Python会先将abc.py编译成PyCodeObject,然后创建pyc文件,并将PyCodeObject写到pyc文件里面去。接下来,再对abc.pyc进行import动作,对,并不是编译成PyCodeObject对象之后直接使用,而是先写到pyc里面去,然后将pyc文件的PyCodeObject对象重新在内存中复制出来。
关于python的import机制,我们后面章节会剖析,这里只是用来完成pyc文件的触发。当然得到pyc文件有很多方法,比如使用py_compile模块。
1 | # a.py |
执行b.py的时候,会发现创建了a.cpython-38.pyc。另外关于pyc文件的创建位置,会在当前文件的同级目录下的__pycache__
目录中创建,名字就叫做:py文件名.cpython-版本号.pyc
通过compile获取PyCodeObject对象
事实上我们已经介绍了一种方法去获取相应的PyCodeObject对象,但是还有没有其他的方法呢?答案是通过内置函数compile,但是在介绍compile之前,先介绍一下eval和exec。
eval:传入一个字符串,然后把字符串里面的内容拿出来。
1 | a = 1 |
exec:传入一个字符串,把字符串里面的内容当成语句来执行,这个是没有返回值,或者说返回值是None。
1 | exec("a = 1") # 等价于把a = 1这个字符串里面的内容当成语句来执行 |
compile:关键来了,它执行后返回的就是一个code对象
1 | statement = "a, b = 1, 2" |
我们后面在分析PyCodeObject的时候,会经常使用compile的方式。
pyc文件的生成
创建pyc文件的具体过程
前面我们提到,Python通过import module进行加载时,如果没有找到相应的pyc或者dll文件,就会在py文件的基础上自动创建pyc文件。所以想要了解pyc文件是怎么创建的,只需要了解PyCodeObject是如何写入的即可。关于写入pyc文件,主要写入三个内容:
1. magic number
这是Python定义的一个整数值,不同版本的Python会定义不同的magic number,这个值是为了保证Python能够加载正确的pyc。比如Python3.7不会加载3.6版本的pyc,因为Python在加载这个pyc文件的时候会首先检测该pyc的magic number,如果和自身的magic number不一致,则拒绝加载。
2. pyc的创建时间
这个很好理解,因为编译完之后要是把源代码修改了怎么办呢?因此会判断源代码的最后修改时间和pyc文件的创建时间,如果pyc文件的创建时间比源代码修改时间要早,说明在生成pyc之后,源代码被修改了,那么会重新编译新的pyc,而反之则会直接加载pyc。
3.PyCodeObject对象
这个不用说了,肯定是要存储的。当然还有字节码,不过PyCodeObject里面的co_code指向了这个字节码,所以我们就直接说PyCodeObject对象了。
文件的写入
既然要写入,那么肯定要有文件句柄,我们来看看:
1 | //位置:Python/marshal.c |
写入magic number和时间:
写入magic number和时间都是调用了PyMarshal_WriteLongToFile
,我们来看看长什么样子。
1 | void |
写入PyCodeObject对象:
写入PyCodeObject对象则是调用了PyMarshal_WriteObjectToFile
,我们也来看看长什么样子。
1 | void |
可以看到本质上还是调用了w_byte,但是在这里面我们并没有看到诸如:列表、元组之类的数据的存储过程,注意最后的w_complex_object,关键来了。
1 | //源代码很长, 具体逻辑就不贴了 |
源代码很长,这里就不一一分析了,可以自行查看。不过虽然长,但是逻辑很简单,就是对不同的对象、执行不同的写动作。然而其最终目的都是通过w_byte写到pyc文件中。换句话说,Python在往pyc写入list对象时,只是将list中包含的数值或者字符串等对象写到了pyc文件中。同时这也意味着,Python在加载pyc文件时,必须基于这些数值或字符串重新构造出list对象。
对于PyCodeObject对象,很显然,w_object会遍历PyCodeObject中的所有域,将这些域依次写入。
1 | PyCodeObject *co = (PyCodeObject *)v; |
但是当面对一个PyListObject对象时,会有什么变化呢?没错,会和PyCodeObject一样,w_object还是会遍历,然后将PyListObject对象中的每一个元素依次写入到pyc文件中。
1 | //可以看到PyTupleObject、PyListObject、PyDictObject都是采用了相同的姿势 |
我们看到无论对于哪一个对象,在写入之前,都会先调用W_TYPE写一个类似于类型的东西,是的,诸如TYPE_LIST、TYPE_TUPLE、TYPE_DICT这样的标识,对于pyc文件的加载起着至关重要的作用。
之前说过,Python仅仅将容器里面的数值和字符串写入到pyc文件。当PyCodeObject写入到pyc之后,所有的数据就变成了字节流,类型信息就丢失了。
1 | def func(): |
然鹅如果没有类型信息,那么当python再次加载pyc文件的时候,就没办法知道字节流中隐藏的结构和蕴含的信息,所以Python必须往pyc文件写入一个标识,这些标识正是Python定义的类型信息。如果Python在pyc中发现了这样的标识,则预示着上一个对象结束,新的对象开始,并且也知道新对象是什么样的对象,从而也知道该执行什么样的加载动作,这些标识也是可以看到的。
1 | //marshal.c |
到了这里可以看到,其实Python对于PyCodeObject对象的导出实际上是不复杂的,因为不管什么对象,最后都为归结为两种简单的形式,一种是数值写入,一种是字符串写入。上面都是对数值的写入,比较简单,仅仅需要按照字节一次写入pyc即可。然而在写入字符串的时候,Python设计了一种比较复杂的机制,有兴趣可以自己阅读源码,这里不再介绍。
1 | # a.py |
我们之前说对于这样的一个py文件,会创建三个PyCodeObject对象,但是写到pyc文件里面的只有一个PyCodeObject对象,这难道不就意味着有两个PyCodeObject丢失了吗?其实很明显,有两个PyCodeObject对象是位于另一个PyCodeObject对象当中的。因此foo和A对应的PyCodeObject对象位于a.py这个PyCodeObject对象当中,准确的说是位于co_consts指向的常量池当中。
1 | def f1(): |
我们看到f2对应的PyCodeObject确实位于f1的常量池当中,但其实说白了不过f1的常量池当中有一个指针在指向f2对应PyCodeObject罢了。只不过在写入的时候,也会把指针的内容一块写进去,所以也可以理解就是写了3个PyCodeObject对象。不过这都不是重点,重点是PyCodeObject对象是可以嵌套的,当在一个作用域内部发现了一个新的作用域,那么新的作用域对应的PyCodeObject对象会位于外层作用域的PyCodeObject对象的常量池中,或者说被常量池中的一个指针指向。
而在写入pyc的时候会从最外层、也就是模块的PyCodeObject对象开始写入,如果碰到了包含的另一个PyCodeObject对象,那么就会递归地执行写入新的PyCodeObject对象的操作。如此下去,最终所有的PyCodeObject对象都会写入到pyc文件当中,因此pyc文件当中的PyCodeObject对象也是以一种嵌套的关系联系在一起的。
1 | def foo(): |
这里问一下,上面那段代码中创建了几个PyCodeObject对象呢?
答案是6个,首先全局是一个,foo函数一个,bar函数一个,类A一个,类A里面的foo函数一个,类A里面的bar函数一个,所以一共是6个。
而且这里的PyCodeObject对象是层层嵌套的,一开始是对整个全局创建PyCodeObject对象,然后遇到了函数foo,那么再为函数foo创建一个PyCodeObject对象,依次往下。所以如果是常量值、字符串等等,则相当于是静态信息,直接存储起来便可;可如果是函数、类,那么会为其创建新的PyCodeObject对象,然后收集起来,所以A里面的foo函数对应的PyCodeObject对象是存在A对应PyCodeObject对象里面的;而A对应的PyCodeObject对象则是存在全局对应的PyCodeObject对象里面,当然此时还有外层的foo、bar函数。。
Python的字节码与反编译
关于Python的字节码,是后面章节剖析虚拟机的重点,现在先来看一下。我们知道Python执行源代码之前会对其进行编译得到PyCodeObject对象,里面的co_code指向了字节码序列,Python虚拟机会根据这些字节码序列来进行一系列的操作(当然也依赖其它的静态信息)
,从而完成对程序的执行。
当然每一个操作在python中都对应一个操作指令、或者操作数,总共一共定义了121个。其实说白了每个指令不过是一个整数罢了。
1 |
|
然后我们可以通过反编译的方式查看一下:
1 | # Python中的dis模块是专门干这件事情 |
字节码反编译后的结果多么像汇编语言!其中,第一列是源代码行号,第二列是字节码偏移量,第三列是操作数。
关于反编译的内容,我们会在剖析函数的时候,深入介绍。