简介
Byterun是一个用Python实现的Python解释器。通过对Byterun的研究,我惊奇地发现Python解释器的基本结构很容易符合500行大小的限制。本章将介绍解释器的结构,并为您提供足够的上下文来进一步研究它。我们的目标并不是解释所有关于解释器的知识——就像编程和计算机科学中许多有趣的领域一样,您可以花费数年时间来深入理解这个主题。
Byterun它的结构类似于Python的主要实现CPython,因此理解Byterun将帮助您理解一般的解释器,特别是CPython解释器。(如果您不知道使用的是哪种Python,那么很可能是CPython。)尽管Byterun很短,但是它能够运行大多数简单的Python程序。
版本注意:
本章基于Python 3.5或更早版本生成的字节码,因为在Python 3.6中对字节码规范做了一些更改。
一个python解释器
在我们开始之前,我们先缩小一下我们理解的解释器的范围。解释器这个词可以用于很多不同的方面当我们讨论python。有的时候,解释器指代的是python repl。通过在命令行键入python得到的交互式提示。有时人们会或多或少地将“Python解释器”与“Python”互换使用,来讨论从头到尾执行Python代码的问题。
这里是一个狭义的解释器,就是执行一个python程序的最后一步。
在解释器接受之前,python执行其他三个步骤:词法分析、解析和编译。这三个步骤将程序源代码包含了python解释器能够理解的结构。解释器的工作是获取这些代码对象并遵循指令。
您可能会惊讶地听说编译是执行Python代码的一个步骤。python是一个解释型语言就像Ruby或者Perl,相对的有C和rust。
然而,这个术语并没有看起来那么精确。大多数解释语言,包括Python,都涉及编译步骤。Python之所以被称为“解释的”,是因为编译步骤所做的工作相对较少(而解释器所做的工作相对较多)。我们将在本章后面看到,Python编译器对程序行为的信息要比C编译器少得多。
基于python构造的python解释器。
Byterun是一个用Python编写的Python解释器。这可能会让您感到奇怪,但它并不比用C编写C编译器更奇怪(实际上,广泛使用的C编译器gcc是用C编写的)。您几乎可以用任何语言编写Python解释器。
使用python编写的主要缺点:慢!!
使用python编写的主要优点:更好学。可以在创建class的时候调用真正的python。更加清晰易懂。
创建一个解释器
在我们开始研究Byterun代码之前,我们需要一些关于解释器结构的高级环境。一个python解释器如何工作呢?
python解释器它就是一个虚拟机,意味着它是一个软件仿生了一台物理计算机。这是特别的虚拟机它是一个栈计算机:它通过执行几种栈操作来执行它的功能。(与寄存器虚拟机相比,该虚拟机在特定位置读写内存)
python解释器是一个字节码解释器:它的指令集称为字节码。这是通过词法分析,解析,编译创建的字节码给解释器运行的。每个代码对象包含了一组指令等待去执行——那就是字节码——还补充了一些些解释器需要的信息。它是python代码的中间代码,它呈现了解释器能够理解的源码形式。就类同于c语言跟汇编和机器之间的关系。
一个微型解释器
让我们说的这个更具体,我们开始做一个很多小的解释器。这个解释器可以运算加法,它可以理解3条指令。所有的代码都被执行通过3个指令运用不同的组合。这三条指令就是。
- LOAD_VALUE
- ADD_TWO_VALUES
- PRINT_ANSWER
我们不考虑编译器,所以也指令集如何创建的无关紧要。
您可以想象编写7 + 5并让编译器发出这三条指令的组合。或者,如果您有正确的编译器,您可以编写Lisp语法,它可以转换成相同的指令组合。解释器并不关心。重要的是我们的解释器得到了一份格式良好的说明。
假设输入:
7 + 5
产生这个指令集:
what_to_execute = {
"instructions": [("LOAD_VALUE", 0), # the first number
("LOAD_VALUE", 1), # the second number
("ADD_TWO_VALUES", None),
("PRINT_ANSWER", None)],
"numbers": [7, 5] }
python解释器是一个栈机器,所以它通过控制栈来添加两个数字。
Python解释器是一个堆栈机器,因此它必须操作堆栈来添加两个数字。解释器将首先执行第一条指令LOAD_VALUE,并将第一个数字推入堆栈。接下来,它将把第二个数字推入堆栈。对于第三条指令ADD_TWO_VALUES,它将弹出两个数字,将它们相加,并将结果推入堆栈。最后,它将弹出堆栈中的答案并打印出来。
LOAD_VALUE指令告诉解释器将一个数字推入堆栈,但仅该指令没有指定哪个数字。
每条指令都需要额外的信息,告诉解释器在哪里找到要加载的数字。
所以我们的指令集有两部分:指令本身,加上指令需要的常量列表。(在Python中,我们称为“instructions”的是字节码,下面的“what to execute”对象是code对象。)
为什么不直接把数字写在说明里呢?想象一下,如果我们把字符串加在一起而不是数字。
我们不希望字符串被指令填充,因为它们可以是任意大的。这种设计还意味着我们可以只复制我们需要的每个对象,例如,添加7 + 7,“numbers”可以是[7]。
您可能想知道为什么需要ADD_TWO_VALUES之外的指令。实际上,对于添加两个数字的简单情况,这个示例有点做作。然而,这条指令是更复杂程序的构建块。例如,仅使用我们到目前为止定义的指令,我们就可以将三个值(或任意数量的值)加在一起,给定这些指令的正确集合。堆栈提供了一种干净的方法来跟踪解释器的状态,并且它将支持更多的复杂性。
现在让我们开始编写解释器本身。解释器对象有一个堆栈,我们将用一个列表表示它。对象还具有描述如何执行每条指令的方法。例如,对于LOAD_VALUE,解释器将把该值推入堆栈。’
class Interpreter:
def __init__(self):
self.stack = []
def LOAD_VALUE(self, number):
self.stack.append(number)
def PRINT_ANSWER(self):
answer = self.stack.pop()
print(answer)
def ADD_TWO_VALUES(self):
first_num = self.stack.pop()
second_num = self.stack.pop()
total = first_num + second_num
self.stack.append(total)
这三个函数实现了我们的解释器能够理解的三条指令。解释器还需要一个部分:一种将所有内容绑定在一起并实际执行它的方法。这个方法run_code接受上面定义的what_to_execute字典作为参数。它遍历每条指令,处理该指令的参数(如果有的话),然后在解释器对象上调用相应的方法。
使用:
interpreter = Interpreter()
interpreter.run_code(what_to_execute)
虽然这个解释器非常有限,但是这个过程几乎与真正的Python解释器添加数字的过程完全相同。即使在这个小例子中,也有一些事情需要注意。
首先,有些指令需要参数。在真正的Python字节码中,大约一半的指令都有参数。参数包含在指令中,非常类似于我们的示例。注意,指令的参数与调用的方法的参数不同。
其次,注意ADD_TWO_VALUES的指令不需要任何参数。相反,要添加到一起的值是从解释器堆栈中弹出的。这是基于堆栈的解释器的定义特性。
请记住,给定有效的指令集,无需对解释器做任何更改,就可以一次添加两个以上的数字。考虑下面的指令集。你希望发生什么?如果您有一个友好的编译器,您可以编写什么代码来生成这个指令集?
what_to_execute = {
"instructions": [("LOAD_VALUE", 0),
("LOAD_VALUE", 1),
("ADD_TWO_VALUES", None),
("LOAD_VALUE", 2),
("ADD_TWO_VALUES", None),
("PRINT_ANSWER", None)],
"numbers": [7, 5, 8] }
至此,我们可以开始了解这个结构是如何扩展的:我们可以在解释器对象上添加描述更多操作的方法(只要有编译器提供格式良好的指令集)。
变量
接下来,让我们将变量添加到解释器中。变量需要一条存储变量STORE_NAME值的指令;用于检索它的指令LOAD_NAME;以及从变量名到值的映射。现在,我们将忽略名称空间和作用域,这样我们就可以在解释器对象本身上存储变量映射。最后,我们必须确保what_to_execute除了常量列表之外,还有一个变量名称列表。
函数可以解析为:
>>> def s():
... a = 1
... b = 2
... print(a + b)
# a friendly compiler transforms `s` into:
what_to_execute = {
"instructions": [("LOAD_VALUE", 0),
("STORE_NAME", 0),
("LOAD_VALUE", 1),
("STORE_NAME", 1),
("LOAD_NAME", 0),
("LOAD_NAME", 1),
("ADD_TWO_VALUES", None),
("PRINT_ANSWER", None)],
"numbers": [1, 2],
"names": ["a", "b"] }
下面是我们的新实现。为了跟踪哪些名称绑定到哪些值,我们将向_init__方法添加一个environment字典。我们还将添加STORE_NAME和LOAD_NAME。这些方法首先查找有问题的变量名,然后使用字典存储或检索其值。
指令的参数现在可以表示两种不同的含义:它们可以是“numbers”列表中的索引,也可以是“names”列表中的索引。解释器通过检查它正在执行的指令知道它应该是哪个。我们将把这个逻辑——以及指令与它们的参数之间的映射——分解成一个单独的方法。
class Interpreter:
def __init__(self):
self.stack = []
self.environment = {}
def LOAD_VALUE(self, number):
self.stack.append(number)
def PRINT_ANSWER(self):
answer = self.stack.pop()
print(answer)
def ADD_TWO_VALUES(self):
first_num = self.stack.pop()
second_num = self.stack.pop()
total = first_num + second_num
self.stack.append(total)
# 存储一个变量
def STORE_NAME(self, name):
val = self.stack.pop()
self.environment[name] = val
# 读取一个变量
def LOAD_NAME(self, name):
val = self.environment[name]
self.stack.append(val)
#给下面调用
def parse_argument(self, instruction, argument, what_to_execute):
""" Understand what the argument to each instruction means."""
numbers = ["LOAD_VALUE"]
names = ["LOAD_NAME", "STORE_NAME"]
if instruction in numbers:
argument = what_to_execute["numbers"][argument]
elif instruction in names:
argument = what_to_execute["names"][argument]
return argument
def run_code(self, what_to_execute):
# 读取指令
instructions = what_to_execute["instructions"]
# 取指令/参数
for each_step in instructions:
instruction, argument = each_step
argument = self.parse_argument(instruction, argument, what_to_execute)
if instruction == "LOAD_VALUE":
self.LOAD_VALUE(argument)
elif instruction == "ADD_TWO_VALUES":
self.ADD_TWO_VALUES()
elif instruction == "PRINT_ANSWER":
self.PRINT_ANSWER()
elif instruction == "STORE_NAME":
self.STORE_NAME(argument)
elif instruction == "LOAD_NAME":
self.LOAD_NAME(argument)
动态查找方法
即使只有5跳指令,run_code方法开始单调了。如果我们保持这样的结构,我们每有一个指令就需要添加一个if分支。这里,我们能够让python使用动态方法查找。我们总会定义一个方法叫foo取执行所谓的foo的指令,因此,我们可以使用Python的getattr函数动态地查找方法,而不是使用大if语句。run_code方法是这样的:
def execute(self, what_to_execute):
instructions = what_to_execute["instructions"]
for each_step in instructions:
instruction, argument = each_step
# 根据指令,找到,得到参数
argument = self.parse_argument(instruction, argument, what_to_execute)
bytecode_method = getattr(self, instruction)
if argument is None:
bytecode_method()
else:
bytecode_method(argument)
真实的python字节码
到了这个程度,我们放弃我们自己的用于调试的字节码,开始使用真正的python字节码。它跟我们用于调试的类似:
>>> def cond():
... x = 3
... if x < 5:
... return 'yes'
... else:
... return 'no'
...
Python在运行时公开了大量内部构件,我们可以直接从REPL访问它们。对于函数对象cond, cond。是与其关联的代码对象,以及cond. code_。co_code是字节码。在编写Python代码时,几乎没有什么好的理由直接使用这些属性,但是它们确实允许我们处理各种各样的混乱—并且为了理解它们,我们可以查看其中的内部机制。
# 原始的字节码
>>> cond.__code__.co_code # the bytecode as raw bytes
b'd\x01\x00}\x00\x00|\x00\x00d\x02\x00k\x00\x00r\x16\x00d\x03\x00Sd\x04\x00Sd\x00
\x00S'
# 字节码对应的数字
>>> list(cond.__code__.co_code) # the bytecode as numbers
[100, 1, 0, 125, 0, 0, 124, 0, 0, 100, 2, 0, 107, 0, 0, 114, 22, 0, 100, 3, 0, 83,
100, 4, 0, 83, 100, 0, 0, 83]
当我们只是打印字节码时,它看起来是不可理解的——我们所能知道的就是它是一系列字节。幸运的是,我们可以使用一个强大的工具来理解它:Python标准库中的dis模块。
dis 是一个字节码的反汇编程序。
一个反汇编程序,输入一些低级代码用于机器中,就像汇编代码或者字节码,然后用一种人类可以理解的方式将它打印出来。当我们使用dis.dis()
>>> dis.dis(cond)
2 0 LOAD_CONST 1 (3)
3 STORE_FAST 0 (x)
3 6 LOAD_FAST 0 (x)
9 LOAD_CONST 2 (5)
12 COMPARE_OP 0 (<)
15 POP_JUMP_IF_FALSE 22
4 18 LOAD_CONST 3 ('yes')
21 RETURN_VALUE
6 >> 22 LOAD_CONST 4 ('no')
25 RETURN_VALUE
26 LOAD_CONST 0 (None)
29 RETURN_VALUE
这是什么意思呢?让我们看到第一个指令LOAD_CONST 作为例子。第一列展示他在源代码的位置。第二列是字节码的索引。告诉我们他在0的位置。第三列就是指令本身的名称,映射到我们人类可读的名字。第四列就是指令的参数,就是这个参数代表的意思。
考虑这个字节码的前几个字节:[100,1,0,125,0,0]。这六个字节用它们的参数表示两条指令。我们可以使用dis.opname,一个从字节到可理解字符串的映射,来找出100和125映射到什么指令:
>>> dis.opname[100]
'LOAD_CONST'
>>> dis.opname[125]
'STORE_FAST'
第二个和第三个字节——1,0——LOAD_CONST的参数。第五第六个字节是STORE_FAST的参数。就像我们之前的例子,LOAD_CONST 需要知道怎么找到常量去加载,STORE_FAST需要找到一个名字去存储。Python的LOAD_CONST就像我们测试解释器的LOAD_VALUE,LOAD_FAST 就像LOAD_NAME。所以这6个字节码代表x=3.(为什么使用两个字节,因为1个字节只能代表256个对象,而使用了两个字节可以代表65536个对象)
条件语句和循环
到目前为止,解释器只是一个接一个地执行指令来执行代码。这是个问题;通常,我们希望多次执行某些指令,或者在某些条件下跳过它们。为了允许我们在代码中编写循环和if语句,解释器必须能够在指令集中跳转。从某种意义上说,Python用字节码中的GOTO语句处理循环和条件!再看一下函数cond的分解。
>>> dis.dis(cond)
2 0 LOAD_CONST 1 (3)
3 STORE_FAST 0 (x)
3 6 LOAD_FAST 0 (x)
9 LOAD_CONST 2 (5)
12 COMPARE_OP 0 (<)
15 POP_JUMP_IF_FALSE 22
4 18 LOAD_CONST 3 ('yes')
21 RETURN_VALUE
6 >> 22 LOAD_CONST 4 ('no')
25 RETURN_VALUE
26 LOAD_CONST 0 (None)
29 RETURN_VALUE
条件语句if x<5 在第三行是一条代码被编译成4个指令LOAD_FAST, LOAD_CONST, COMPARE_OP, and POP_JUMP_IF_FALSE。x<5产生代码去加载x,加载5,然后比较连个数字。指令POP_JUMP_IF_FALSE负责实现if。这条指令将弹出解释器堆栈的顶部值。如果值为真,则什么也不会发生。(值可以是“truthy”—不必是字面上的True对象)
着陆指令称为jump目标,它作为POP_JUMP指令的参数提供。这里,跳跃目标是22。索引22处的指令是第6行上的LOAD_CONST。(用>>标记跳转目标。)如果x < 5的结果为False,那么解释器将直接跳到第6行(返回“no”),跳过第4行(返回“yes”)。因此,解释器使用跳转指令选择性地跳过指令集的部分。
Python循环也依赖于跳转。在下面的字节码中,注意当x < 5时,行生成的字节码与x < 10时几乎相同。在这两种情况下,都会计算比较,然后POP_JUMP_IF_FALSE控制接下来执行哪条指令。在循环体的末尾第4行末尾,指令JUMP_ABSOLUTE总是将解释器发送回循环顶部的第9条指令。当x < 5变为false时,POP_JUMP_IF_FALSE将解释器跳过循环的末尾,跳转到指令34。
>>> def loop():
... x = 1
... while x < 5:
... x = x + 1
... return x
...
>>> dis.dis(loop)
2 0 LOAD_CONST 1 (1)
3 STORE_FAST 0 (x)
3 6 SETUP_LOOP 26 (to 35)
>> 9 LOAD_FAST 0 (x)
12 LOAD_CONST 2 (5)
15 COMPARE_OP 0 (<)
18 POP_JUMP_IF_FALSE 34
4 21 LOAD_FAST 0 (x)
24 LOAD_CONST 1 (1)
27 BINARY_ADD
28 STORE_FAST 0 (x)
31 JUMP_ABSOLUTE 9
>> 34 POP_BLOCK
5 >> 35 LOAD_FAST 0 (x)
38 RETURN_VALUE
探索字节码
去探索下列的问题。
- for循环和while循环对Python解释器有什么区别?
- 如何编写不同的函数来生成相同的字节码?
- elif是如何工作的?那么列表理解呢?
Frames(框)
到目前为止,我们已经了解到Python虚拟机是一个堆栈机器。它步进并跳过指令,将值推入和弹出堆栈。尽管如此,我们的思维模式仍然存在一些空白。在上面的例子中,最后一条指令是RETURN_VALUE,它对应于代码中的return语句。但是指令返回到哪里呢?
要回答这个问题,我们必须增加一层复杂性:框架。框架是一段代码的信息和上下文的集合。当Python代码执行时,将动态地创建和销毁框架。有一个帧对应于函数的每个调用—因此,虽然每个帧有一个与之关联的代码对象,但是一个代码对象可以有多个帧。如果你有一个递归调用自己十次的函数,你会有11个框架——一个用于递归的每一层,一个用于你开始的模块。通常,Python程序中的每个范围都有一个框架。例如,每个模块、每个函数调用和每个类定义都有一个框架。
帧位于调用堆栈上,这与我们目前讨论的堆栈完全不同。(调用堆栈是您最熟悉的堆栈—您已经在异常的回溯中看到了它。以“文件”程序开头的回溯中的每一行。第10行“对应于调用堆栈上的一帧。)我们一直在检查的堆栈—解释器在执行字节码时正在操作的堆栈—我们将调用数据堆栈。还有第三个堆栈,称为块堆栈。块用于某些类型的控制流,特别是循环和异常处理。调用堆栈上的每个帧都有自己的数据堆栈和块堆栈。
让我们一个具体的例子。假设python 解释器是一个当前执行标记了为3的行。解释器是一个解释器正在调用foo的过程中,而foo又是调用bar。(这段代码编写得像一个REPL会话,所以我们首先定义了所需的函数。)目前我们感兴趣的是,解释器正在执行foo(),在底部,然后到达foo的主体,然后到达bar。
>>> def bar(y):
... z = y + 3 # <--- (3) ... and the interpreter is here.
... return z
...
>>> def foo():
... a = 1
... b = 2
... return a + bar(b) # <--- (2) ... which is returning a call to bar ...
...
>>> foo() # <--- (1) We're in the middle of a call to foo ...
3
此时,解释器正处于对bar的函数调用的中间。调用堆栈上有三帧:一帧用于模块级,一帧用于函数foo,一帧用于bar。
字节码指令RETURN_VALUE告诉解释器在帧之间传递一个值。首先,它将弹出调用堆栈顶部帧的数据堆栈的顶部值。然后它从调用堆栈中弹出整个帧并丢弃它。最后,将该值推入下一帧的数据堆栈。
**注意一个严重的错误:**我们在整个虚拟机上只有一个数据堆栈,而不是在每个帧上都有一个数据堆栈。我们有几十个由Python代码片段组成的测试,我们通过Byterun和真正的Python解释器运行这些代码片段,以确保在两个解释器中发生相同的事情。几乎所有这些测试都通过了。唯一不能用的是发电机。最后,更仔细地阅读CPython代码,我们意识到了错误。将数据栈移动到每个帧上解决了这个问题。
回顾这个bug,我惊奇地发现Python很少依赖于每个帧具有不同的数据堆栈。Python解释器中的几乎所有操作都小心地清理数据堆栈,因此帧是否共享相同的堆栈并不重要。在上面的例子中,当bar完成执行时,它将保持它的数据栈为空。即使foo共享相同的堆栈,值也会更低。但是,对于生成器,一个关键特性是能够暂停一个帧,返回到另一个帧,然后返回到生成器帧,并使其处于与离开时完全相同的状态。
Byterun
它主要有4种类:
虚拟机类,它管理最高级结构,特别是帧的调用堆栈,并包含指令到操作的映射。这是上面的整数对象的一个更复杂的版本。
一个框架类。每个Frame实例都有一个代码对象,并管理一些其他必要的状态位,特别是全局和本地名称空间、对调用框架的引用以及执行的最后一条字节码指令。
一个函数类,它将代替真正的Python函数。回想一下,调用一个函数会在解释器中创建一个新框架。我们实现函数以便控制新帧的创建。
一个块类,它只封装块的三个属性。(块的细节并不是Python解释器的核心,所以我们不会在它们上面花太多时间,但是这里包含了它们,这样Byterun就可以运行真正的Python代码了。)