如果你写过,甚至只是使用过 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!”
def hello()
print("Hello, World!")
这是它变成的字节码(翻译成人类可读的形式)
2 0 LOAD_GLOBAL 0 (print)
2 LOAD_CONST 1 ('Hello, World!')
4 CALL_FUNCTION 1
如果你输入那个 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 解释器中,然后运行
import dis
dis.dis(hello)
函数 dis.dis()
将反汇编一个函数、方法、类、模块、编译的 Python 代码对象或包含源代码的字符串字面量,并打印一个人类可读的版本。 dis
模块中的另一个方便的函数是 distb()
。 你可以将 Python 回溯对象传递给它,或者在引发异常后调用它,它将反汇编在异常发生时调用栈顶部的函数,打印其字节码,并插入一个指向引发异常的指令的指针。
查看 Python 为每个函数构建的已编译代码对象也很有用,因为执行函数会利用这些代码对象的属性。 这是一个查看 hello()
函数的例子
>>> hello.__code__
<code object hello at 0x104e46930, file "<stdin>", line 1>
>>> hello.__code__.co_consts
(None, 'Hello, World!')
>>> hello.__code__.co_varnames
()
>>> hello.__code__.co_names
('print',)
代码对象可以作为函数上的属性 __code__
访问,并携带一些重要的属性
co_consts
是函数体中出现的任何字面量的元组co_varnames
是一个元组,包含函数体中使用的任何局部变量的名称co_names
是函数体中引用的任何非局部名称的元组
许多字节码指令——特别是那些加载要推送到栈上的值或将值存储在变量和属性中的指令——都使用这些元组中的索引作为它们的参数。
所以现在我们可以理解 hello()
函数的字节码列表
LOAD_GLOBAL 0
:告诉 Python 查找由co_names
的索引 0 处的名称引用的全局对象(即print
函数)并将其推到求值栈上LOAD_CONST 1
:获取co_consts
的索引 1 处的字面值并将其推送(索引 0 处的值是字面量None
,它存在于co_consts
中,因为如果未达到显式return
语句,则 Python 函数调用具有隐式返回值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 字节码并理解它的面向栈的编程模型如何工作,是扩展你的编程知识的一种简洁的方式。
进一步阅读
如果你想了解更多关于 Python 字节码、Python 虚拟机以及它们如何工作的信息,我推荐以下资源
- 《Python 虚拟机内部》(Inside the Python Virtual Machine)by Obi Ike-Nwosu 是一本免费的在线书籍,它深入研究了 Python 解释器,详细解释了 Python 实际是如何工作的。
- 《用 Python 编写的 Python 解释器》(A Python Interpreter Written in Python)by Allison Kaptur 是一个用 Python 本身构建 Python 字节码解释器的教程——还能用什么呢——它实现了运行 Python 字节码的所有机制。
- 最后,CPython 解释器是开源的,你可以 在 GitHub 上阅读它。 字节码解释器的实现在文件
Python/ceval.c
中。 这是 Python 3.6.4 版本的该文件; 字节码指令由第 1266 行开始的switch
语句处理。
要了解更多信息,请参加 James Bennett 在 PyCon Cleveland 2018 上的演讲,字节漫谈:理解Python字节码。
3 条评论