了解Python字节码是什么,Python如何使用它来执行代码,以及了解它可以帮我们干什么。
如果你曾经编写亦或只是使用Python语言,那么你可能已经习惯了看Python源码文件; 源码的文件名以.py结尾。或许你也已经注意到了另一种类型的文件,文件名以.pyc结尾,或许你已经听说过它们就是Python的“字节码”文件。(但在Python 3上却难觅其踪 -- 原因是它们不再与.py文件出现在同一个目录中,而是放在一个名为__pycache__的子目录中了)。或许你也已听说过这是一种程序加速机制。通过防止Python每次运行时都重新解析源代码从而加快程序运行。
但是,除了“哦,这就是Python字节码”,你真的知道这些文件中的内容以及Python如何使用它们吗?
如果回答是"否",那么今天就是你的幸运日!我将带您了解Python字节码的含义,Python如何使用它来执行代码,以及了解它可以帮我们干什么。
Python如何工作
Python经常被称为是一种解释型语言 -- 一种源代码在程序运行时被即时翻译成原生CPU指令的语言 - 但这只说对了一部分。与其他许多解释型语言一样,Python实际上将源代码编译为一组虚拟机指令,Python的解释器就是该虚拟机的一个具体实现。这种跑在虚拟机内部的中间格式被称为“字节码”。
因此,Python留下的.pyc文件不仅仅是源代码的一个“更快”或“优化”版本; 实际上,它们是在程序运行时由Python的虚拟机来执行的字节码指令。
我们来看一个例子。这是一个用Python编写的经典的“Hello, World!” :
下面是转换后的字节码(转换成可读形式):
如果您键入该hello()函数并使用CPython解释器运行它,则上面就是Python所执行的内容。不过,这些似乎看起来有点奇怪,所以让我们深入了解一下发生了什么。
Python虚拟机内部
CPython使用的是基于栈的虚拟机。也就是说,它完全围绕着栈数据结构来运行(您可以将一项内容“压入”栈,放到栈结构的“顶部”,或者从栈“顶部”“弹出”一项内容)。
CPython使用三种类型的堆栈:
- 调用栈。这是Python程序运行的主要结构。它具有一项内容 -- “栈帧” - 栈的底部就是程序的入口,对于每个当前激活的函数调用,该调用都会压入一个新栈帧到调用栈中,并且每次函数调用结束返回时,对应的栈帧都会被弹出。
- 在每一栈帧中,都有一个执行栈(也称为数据栈)。这个栈是执行Python函数的地方,执行Python代码主要包括把相关数据压入栈,执行逻辑操作,结束后从栈中弹出。
- 同样在每一栈帧中,都有一个块堆栈。Python使用它来跟踪某些类型的控制结构:循环块,try/except块和with块将所有相关内容都压入块堆栈,当退出一个结构时,块堆栈则弹出相应内容。该操作有助于Python在任何特定时刻知道哪些块处于活动状态,例如,像continue或break语句就需要知道操作哪个具体的逻辑块,否则可能影响逻辑块的正确性。
尽管有一些指令用于执行其他操作(如跳转到特定指令或操作块堆栈),但Python的大部分字节码指令都是用来操作当前调用栈帧中的执行栈
为了感受这一点,假设我们有一些调用函数的代码,如:my_function(my_variable, 2)。Python会将其转换为四个字节码指令序列:
- 一条 LOAD_NAME 指令,查找函数对象my_function并将其压入到执行栈的顶部。
- 另一条 LOAD_NAME 指令,查找变量my_variable并将其压入到执行栈顶部
- 一条 LOAD_CONST 指令,将常量数值2压入到执行栈的顶部
- 一条 CALL_FUNCTION 指令
该 CALL_FUNCTION 指令的参数为2,表示Python需要从栈顶部弹出两个位置参数; 那么被调用的函数将位于最前面,并且它也可以被弹出(对于涉及关键字参数的函数,会使用不同的指令 -- CALL_FUNCTION_KW - - 但操作原理类似,并且第三条指令会使用 CALL_FUNCTION_EX 来处理*或**相关的参数的拆包操作)。一旦Python准备就绪,将在调用栈上分配一个新栈帧,为函数调用准备局部变量,并在该栈帧中执行my_function内的字节码。一旦完成,该栈帧将从调用栈中弹出,并在原来的栈帧中将my_function 返回值压入到执行栈顶部。
访问和理解Python字节码
如果你也想玩玩这个,Python标准库中的dis模块就非常有用了; dis模块为Python字节码提供了一个“反汇编程序”,从而可以轻松获取人为可读的版本并查找各种字节码指令。dis模块的文档涵盖了相关内容,并提供了字节码指令以及它们的作用和参数的完整清单。
例如,要获取之前hello()函数的字节码列表,我将它键入Python解释器中,然后运行:
函数dis.dis()会对函数,方法,类,模块,编译过的Python代码对象或包含有源代码的字符串文字进行反汇编,并打印出可读的版本。dis模块中另一个方便的功能是distb()。您可以将它传递给Python traceback对象,或者在引发异常之后调用它,它会在异常时反编译调用栈中的最顶层函数,打印其字节码,并在指令中插入一个指向引发异常指令的指针。
此外,它对于查看Python为每个函数所构建的编译过的代码对象也很有用,因为执行函数有时会用到这些代码对象的属性。以下是查看该hello()功能的示例:
代码对象可以通过函数的__code__属性来进行访问,并包含一些其他的重要的属性:
- co_consts 是一个包含有函数体中出现的任何字面常量的元组,
- co_varnames 是一个包含函数体中使用的任何局部变量名称的元组
- co_names 是一个包含函数体中引用的任何非本地变量名称的元组
许多字节码指令 - 尤其是那些涉及到需要压入堆栈加载内容或将内容存储到变量和属性中的指令 - 将会使用这些元组中的索引作为它们的参数。
所以现在我们可以了解该hello()函数的字节码列表:
- LOAD_GLOBAL 0:告诉Python在co_names(print函数)的索引0处通过引用的名称寻找全局对象并将其压入到执行栈
- LOAD_CONST 1:将co_consts索引1处的字面常量取出并将其压入栈(co_consts中索引0处的值是None,因为Python函数中如果没有显式的return表达式,将会使用隐式调用,返回None值)
- CALL_FUNCTION 1:告诉Python调用一个函数; 它需要从堆栈中弹出一个位置参数,然后新的堆栈顶部将是要调用的函数。
“原始”字节码 - 不具有可读性字节码 - 可以通过代码对象的co_code的属性来访问。如果您想尝试手动反汇编函数,则可以使用列表dis.opname从十进制字节值中查找相应字节码指令的名称。
使用字节码
现在你已经了解了这么多,你可能会想:“好吧,我猜这很酷,但是知道这个有什么实际用途呢?” 抛开单纯的满足一下好奇心,理解Python字节码在如下几个方面是挺有用的。
首先,理解Python的执行模型可以帮助你理解你的代码。人们喜欢开玩笑地说C是一种“便携式汇编程序”,你可以很好地猜测C语言源代码会翻译成哪些对应的机器指令。对于Python, 理解字节码会有类似的效果 - 如果您可以预想到Python源代码会被转换成怎样的字节码,你就可以更好地决定如何编写和优化它。
其次,了解字节码对于回答一些有关Python的问题相当有帮助。例如,我经常看到Python新手程序员想知道为什么某些结构比其他结构更快(比如为什么{}会比dict()快)。知道如何访问和读取Python字节码就可以让你找出答案(尝试:dis.dis("{}")与dis.dis("dict()"))。
最后,理解字节码以及Python如何执行它,为不经常参与的特定类型编程的Python程序员提供了一个有用的视角:面向堆栈的编程。如果您曾经使用过像FORTH或Factor这样的面向堆栈的语言,这可能没什么新鲜的,但如果您之前没有接触过这些编程方法,那么了解Python字节码并了解其面向堆栈的编程模型是如何工作的就是一个拓展提升您的编程知识的好方法。