谷歌大牛 Steve Yegge 曾说过:“如果你不知道编译器是怎样工作的,那你也并不知道计算机是怎样工作的。如果你不是 100% 确定你是否知道编译器是怎样工作的,那你其实并不知道它们是怎样工作的。”
这跟你是新手还是经验丰富的软件开发人员无关:如果你不知道编译器和解释器是怎样工作的,那么你就不知道计算机是怎样工作的。就这么简单。
那么,你知道编译器和解释器是怎样工作的吗?我的意思是,你 100% 确定自己知道它们是怎样工作的吗?如果你不知道。
或者说如果你不知道,并且因此而感到不安。
别着急。如果你留下来学习完整个系列,并且和我一起构造一个解释器和编译器,你最终将会知道它们是怎样工作的。并且你将会变成一个自信快乐的人,至少我希望是这样。
为什么要学习解释器和编译器?有三个理由:
1、为了写一个解释器或者一个编译器,你必须综合应用一些技能。编写一个解释器或者编译器,将会帮助你提高这些技能,让你变成一个更优秀的软件开发者。同时这些技术在编写其它软件(非编译器和解释器)时同样很有用。
2.你确实想知道计算机是怎样工作的。常常解释器和编译器看起来像魔术。而你对这种魔术觉得不太舒服。你想弄清楚构造一个解释器和编译器的过程,弄明白它们是怎样工作的,弄明白这里面所有的事。
3.你想创建你自己的语言或者是特定领域的语言。如果你创建了它,那么你同样需要为它创建一个编译器或解释器。最近,人们重新兴起了对新语言的兴趣。你几乎每天都能看新语言的出现:Elixir、Go、Rust,只是随便举几个例子。
好了,那什么是解释器,什么是编译器呢?
解释器和编译器的目标就是将使用高级语言编写的源程序转换成另一种形式。什么形式?稍安勿燥,在本系列的后续部分中,你将会很确切地了解到源程序将被转换成什么。
现在你可能会对解释器和编译器之间有什么区别感到好奇。对于本系列,我们约定,如果一个翻译器将源程序翻译成机器语言,那么它就是一个编译器。如果一个翻译器直接处理并运行源程序,不先把源程序翻译成机器语言,那么它就是一个解释器。直观上它看起来会是这个样子:
我希望此时此刻,你很确信你的愿意学习,并且构建一个解释器和编译器。关于这个解释器系列,你有什么期待呢?
你看这样行不行。我们为 Pascal 语言的一个大子集创建一个简单的解释器。在这个系列的最后,你将得到一个能够工作的解释器以及像 Python 的 pdb 一样的源代码级别的调试器。
那么问题来了,为什么选 Pascal?首先,这不是我为了这个系列捏造的语言:这是一个真实的编程语言,具有许多重要的语言结构。其次,有些计算机书籍虽然旧,但实用,这些书的例子用了 Pascal 语言。(我承认这不是一个选择它来构造解释器的不可抗拒的理由,但我认为这也是一个很好的学习非主流语言的机会:)
这是有一个使用 Pascal 编写的阶乘的例子。你将可以用自己的解释器和调试器来解释和调试这段代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
program
factorial
;
function
factorial
(
n
:
integer
)
:
longint
;
begin
if
n
=
0
then
factorial
:
=
1
else
factorial
:
=
n
*
factorial
(
n
-
1
)
;
end
;
var
n
:
integer
;
begin
for
n
:
=
0
to
16
do
writeln
(
n
,
'! = '
,
factorial
(
n
)
)
;
end
.
|
我们使用 Python 来实现 Pascal 的解释器,但是你也可以使用其它任何语言来实现它,思想的表达不应局限于任何特定的语言。
好了,让我们开始行动吧。预备,开始!
你将通过编写一个四则运算表达式的解释器(俗称计算器),来完成对解释器和编译器的第一次进军。今天的目标很简单:让计算器能够处理个位整数的加法,比如 3 + 5。下面是你的计算器的代码,对不起,是你的解释器的代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
|
# Token types
#
# EOF (end-of-file) token is used to indicate that
# there is no more input left for lexical analysis
INTEGER
,
PLUS
,
EOF
=
'INTEGER'
,
'PLUS'
,
'EOF'
class
Token
(
object
)
:
def
__init__
(
self
,
type
,
value
)
:
# token type: INTEGER, PLUS, or EOF
self
.
type
=
type
# token value: 0, 1, 2. 3, 4, 5, 6, 7, 8, 9, '+', or None
self
.
value
=
value
def
__str__
(
self
)
:
"""String representation of the class instance.
Examples:
Token(INTEGER, 3)
Token(PLUS '+')
"""
return
'Token({type}, {value})'
.
format
(
type
=
self
.
type
,
value
=
repr
(
self
.
value
)
)
def
__repr__
(
self
)
:
return
self
.
__str__
(
)
class
Interpreter
(
object
)
:
def
__init__
(
self
,
text
)
:
# client string input, e.g. "3+5"
self
.
text
=
text
# self.pos is an index into self.text
self
.
pos
=
0
# current token instance
self
.
current_token
=
None
def
error
(
self
)
:
raise
Exception
(
'Error parsing input'
)
def
get_next_token
(
self
)
:
"""Lexical analyzer (also known as scanner or tokenizer)
This method is responsible for breaking a sentence
apart into tokens. One token at a time.
"""
text
=
self
.
text
# is self.pos index past the end of the self.text ?
# if so, then return EOF token because there is no more
# input left to convert into tokens
if
self
.
pos
>
len
(
text
)
-
1
:
return
Token
(
EOF
,
None
)
# get a character at the position self.pos and decide
# what token to create based on the single character
current_char
=
text
[
self
.
pos
]
# if the character is a digit then convert it to
# integer, create an INTEGER token, increment self.pos
# index to point to the next character after the digit,
# and return the INTEGER token
if
current_char
.
isdigit
(
)
:
token
=
Token
(
INTEGER
,
int
(
current_char
)
)
self
.
pos
+=
1
return
token
if
current_char
==
'+'
:
token
=
Token
(
PLUS
,
current_char
)
self
.
pos
+=
1
return
token
self
.
error
(
)
def
eat
(
self
,
token_type
)
:
# compare the current token type with the passed token
# type and if they match then "eat" the current token
# and assign the next token to the self.current_token,
# otherwise raise an exception.
if
self
.
current_token
.
type
==
token_type
:
self
.
current_token
=
self
.
get_next_token
(
)
else
:
self
.
error
(
)
def
expr
(
self
)
:
"""expr -> INTEGER PLUS INTEGER"""
# set current token to the first token taken from the input
self
.
current_token
=
self
.
get_next_token
(
)
# we expect the current token to be a single-digit integer
left
=
self
.
current_token
self
.
eat
(
INTEGER
)
# we expect the current token to be a '+' token
op
=
self
.
current_token
self
.
eat
(
PLUS
)
# we expect the current token to be a single-digit integer
right
=
self
.
current_token
self
.
eat
(
INTEGER
)
# after the above call the self.current_token is set to
# EOF token
# at this point INTEGER PLUS INTEGER sequence of tokens
# has been successfully found and the method can just
# return the result of adding two integers, thus
# effectively interpreting client input
result
=
left
.
value
+
right
.
value
return
result
def
main
(
)
:
while
True
:
try
:
# To run under Python3 replace 'raw_input' call
# with 'input'
text
=
raw_input
(
'calc> '
)
except
EOFError
:
break
if
not
text
:
continue
interpreter
=
Interpreter
(
text
)
result
=
interpreter
.
expr
(
)
print
(
result
)
if
__name__
==
'__main__'
:
main
(
)
|
将上面的代码保存为文件 calc1.py ,或者直接从 Github 上下载。在深入代码之前,以命令行运行,观察其运行结果。好好把玩这个程序。这是在我的笔记本上的一个示例会话(如果你使用 Python3,代码中 raw_input 需改写为 input )
1
2
3
4
5
6
7
8
|
$
python
calc1
.
py
calc
>
3
+
4
7
calc
>
3
+
5
8
calc
>
3
+
9
12
calc
>
|
为了让计算器工作正常,不抛出异常,输入应该遵循几条规则:
- 输入只能是个位数的整数
- 当前支持的算术运算只有加法
- 在输入中不允许出现空格
为了使计算器简单这些限制是必要的。别担心,很快你将使它变得很复杂。
好了,现在让我们深入理解解释器是怎样工作的,以及它是怎样计算算术表达式的。
当你在命令行下输入 3+5 时,解释器得到一串字符 “3+5”。为了让解释器真正地理解怎么处理这一字符串,第一步需要将 “3+5”切分成不同的部分,我们称之为记号(tokens)。一个记号(token)是一对类型·值。举例来说,记号 “3”的类型为 INTEGER,相对应的值为整数3。
将输入的字符串切分成记号的过程被称作词法分析。所以,第一步解释器需要读取输入并把它转换成一系列的记号。解释器做这部分工作的组件被称作词法分析器(lexical ananlyzer,简称lexer)。你也许碰到过其它的叫法,像扫描程序(scanner),分词器(tokenizer)。它们意思都相同:解释器或者编译器中把输入字符串转换成一串记号的组件。
Interpreter 类中的 get_next_token 方法就是一个词法分析器。每当你调用它,就能得到从传入的字符串中创建的记号里的下一个。我们来仔细看看这个方法,看它是怎样把字符串转换成记号的。输入的字符串被保存在变量 text 中,pos 是字符串的一个索引值(把字符串想象成一个字符的数组)。pos 被初始化成 0,指向字符 ‘3’。get_next_token 首先测试这个字符是不是一个数字。如果是,pos 加 1 右移并返回一个类型是 INTEGER 值为 3 的 Token 实例,代表一个整型数字 3:
现在 pos 指向 text 中的字符 ‘+’。当你下次调用 get_next_token 方法时,它先测试在 pos 位置的字符是不是一个数字,然后测试它是不是一个加号,现在它的确是加号。于是 get_next_token 对 pos 加 1 并返回一个类型为 PLUS 值为 ‘+’ 的 Token 实例:
现在 pos 指向字符 ‘5’。当你再次调用 get_next_token 方法时,这个方法检查它是不是一个数字,它是,所以它对 pos 加 1 并且返回一个新的类型为 INTEGER,值被设置成 5 的 Token 实例。
由于现在 pos 指向字符串 “3+5”尾部的后一个位置,每次调用 get_next_token 将会返回一个 EOF Token 对象。
动手试试,看看你的计算器中词法分析器是怎样工作的:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
>>>
from
calc1
import
Interpreter
>>>
>>>
interpreter
=
Interpreter
(
'3+5'
)
>>>
interpreter
.
get_next_token
(
)
Token
(
INTEGER
,
3
)
>>>
>>>
interpreter
.
get_next_token
(
)
Token
(
PLUS
,
'+'
)
>>>
>>>
interpreter
.
get_next_token
(
)
Token
(
INTEGER
,
5
)
>>>
>>>
interpreter
.
get_next_token
(
)
Token
(
EOF
,
None
)
>>>
|
现在解释器能访问从输入字符串中等到的记号流,解释器需要对这些记号做一些事:在平滑的记号流中找到一种结构,记号流是从 lexer 的 get_next_token 而来。你的解释器期望在这串记号中找到这么一种结构:INTERGER -> PLUS -> INTEGER。也就是说,解释器尝试找到这种序列的记号:整数后面跟着一个加号,再后面跟着一个整数。
查找这种结构并对其进行解释的方法是 expr。这个方法验证记号的序列是不是跟期望的一样,比如 INTEGER -> PLUS -> INTEGER。验证成功后,用 PLUS 左边的记号的值加上 PLUS 右边的记号的值就得到了结果,这样就成功地解释了传递给解释器的算术表达式。
expr 方法使用 辅助方法(helper method) eat 来验证传递给 eat 方法的记号类型与 current token 类型是否匹配。匹配成功 eat 方法获取下一个记号,并把下一个记号赋值给变量 current_token,如此实际上就把匹配的记号“吃掉了”,并把一个虚拟的指向记号流的指针向前移动了。如果在记号中的结构与所期望的 INTEGER PLUS INTEGER 序列不对等,eat 方法抛出一个异常。
概括一下解释器是怎样计算算术表达式的:
- 解释器接受一个输入字符串,比如说“3+5”
- 解释器调用 expr 方法从词法分析器 get_next_token 得到的记号流中查找一种结构。这种结构的形式为:INTEGER PLUS INTEGER。在确认了这种结构后,它将通过对两个 INTEGER 的记号做加法来解释输入。对于解释器来说在,这时要做的就是把两个整数加起来,即 3 和 5。
恭喜自己吧。你刚学会怎样构建你的第一个解释器!
现在是练习时间。
你不会认为读了这篇文章就足够了,对吗?那好,不要嫌弄脏手,完成下面的练习:
- 修改代码,使得多位的整型数字也能做为输入,比如 “12+3”
- 添加一个方法处理空格,使得你的计算器能处理包含空格的输入,比如 “ 12 + 3”
- 修改代码用 ‘-’ 代替 ‘+’,使其能计算像 “7-5” 的表达式
检查你是否理解了
1.什么是解释器?
2.什么是编译器?
3.解释器和编译器何不同?
4.什么是记号(token)?
5.将输入切分成记号的过程名叫什么?
6.解释器中负责词法分析的部分叫什么?
7.这部分在解释器及编译器中共同的名字叫什么?
在结束这篇文章前,我希望你能保证学习解释器和编译器。并且我希望你马上做这件事。不要把它放在一边。不要等。如果你已经略读了这篇文章,请再看一遍。如果你已经仔细阅读但没做练习——现在就做。如果你只做了部分练习,完成剩下的。签下承诺保证,今天就开始学习解释器和编译器!
我, ,身体健康,思想健全,在此郑重保证从今天开始学习解释器和编译器直到有一天 100% 知道它们是怎么工作的!
签名:
日期:
签上你的名字、日期,把它放在你能天天看到的地方,确保坚持你的承诺。并且记住承诺的定义:
“承诺就是做你曾经说过要做的事,即使说这话时的好心情早已不在了。”——Darren Hardy
好了,今天就这么多了,在这个小系列的下一篇文章中,你将得以扩展你的计算器,让它支持更多的算术运算。敬请期待。
如果你不想等本系列的第二部分,并且迫不及待地想开始深入解释器和编译器,这有我推荐的一张有帮助的书单。
1.《编程语言实现模式》(实用主义程序员)
2.《Writing Compilers and Interpreters: A Software Engineering Approach》
3.《现代编译原理——java语言描述》
4.《现代编译程序设计》
5.《编译原理技术和工具》(第二版)