gitbook/编译原理实战课/docs/259465.md

299 lines
23 KiB
Markdown
Raw Normal View History

2022-09-03 22:05:03 +08:00
# 17 | Python编译器如何用工具生成编译器
你好,我是宫文学。
最近几年Python在中国变得越来越流行我想可能有几个推动力第一个是因为人工智能热的兴起用Python可以很方便地使用流行的AI框架比如TensorFlow第二个重要的因素是编程教育特别是很多面对青少年的编程课程都是采用的Python语言。
不过Python之所以变得如此受欢迎虽然有外在的机遇但也得益于它内在的一些优点。比如说
* Python的语法比较简单容易掌握它强调一件事情只能用一种方法去做。对于老一代的程序员来说Python就像久远的BASIC语言很适合作为初学者的第一门计算机语言去学习去打开计算机编程这个充满魅力的世界。
* Python具备丰富的现代语言特性实现方式又比较简洁。比如它既支持面向对象特性也支持函数式编程特性等等。这对于学习编程很有好处能够带给初学者比较准确的编程概念。
* 我个人比较欣赏Python的一个原因是它能够充分利用开源世界的一些二进制的库比如说如果你想研究计算机视觉和多媒体可以用它调用OpenCV和FFmpeg。Python跟AI框架的整合也是同样的道理这也是Python经常用于系统运维领域的原因因为它很容易调用操作系统的一些库。
* 最后Python还有便于扩展的优势。如果你觉得Python有哪方面能力的不足你也可以用C语言来写一些扩展。而且你不仅仅可以扩展出几个函数你还能扩展出新的类型并在Python里使用这些新类型。比如Python的数学计算库是NumPy它的核心代码是用C语言编写的性能很高。
看到这里你自然会好奇这么一门简洁有力的语言是如何实现的呢吉多·范罗苏姆Python初始设计者在编写Python的编译器的时候脑子里是怎么想的呢
从这一讲开始我们就进入到Python语言的编译器内部去看看它作为一门动态、解释执行语言的代表是如何做词法分析、语法分析和语义分析的又是如何解释执行的以及它的运行时有什么设计特点让它可以具备这些优势。你在这个过程中也会对编译技术的应用场景了解得更加全面。这也正是我要花3讲的时间带领你来解析Python编译器的主要原因。
今天这一讲我们重点来研究Python的词法分析和语法分析功能一起来看看它在这两个处理阶段都有什么特点。你会学到一种新的语法分析实现思路还能够学到CST跟AST的区别。
好了,让我们开始吧。
## 编译源代码,并跟踪调试
首先,你可以从[python.org网站](https://www.python.org/)下载[3.8.1版本的源代码](https://www.python.org/ftp/python/3.8.1/Python-3.8.1.tgz)。解压后你可以先自己浏览一下,看看能不能找到它的词法分析器、语法分析器、符号表处理程序、解释器等功能的代码。
Python源代码划分了多个子目录每个子目录的内容整理如下
![](https://static001.geekbang.org/resource/image/b4/28/b4dffdcfc258350fa6ce81a2dcae7128.jpg)
**首先你会发现Python编译器是用C语言编写的。**这跟Java、Go的编译器不同Java和Go语言的编译器是支持自举的编译器也就是这两门语言的编译器是用这两门语言自身实现的。
实际上用C语言实现的Python编译器叫做**CPython**是Python的几个编译器之一。它的标准库也是由C语言和Python混合编写的。**我们课程中所讨论的就是CPython它是Python语言的参考实现也是macOS和Linux缺省安装的版本。**
不过Python也有一个编译器是用Python本身编写的这个编译器是PyPy。它的图标是一条咬着自己尾巴的衔尾蛇表明这个编译器是自举的。除此之外还有基于JVM的Jython这个版本的优势是能够借助成熟的JVM生态比如可以不用自己写垃圾收集器还能够调用丰富的Java类库。如果你觉得理解C语言的代码比较困难你也可以去看看这两个版本的实现。
在Python的“[开发者指南](https://devguide.python.org/)”网站上有不少关于Python内部实现机制的技术资料。**请注意**这里的开发者指的是有兴趣参与Python语言开发的程序员而不是Python语言的使用者。这就是像Python这种开源项目的优点它欢迎热爱Python的程序员来修改和增强Python语言甚至你还可以增加一些自己喜欢的语言特性。
根据开发者指南的指引你可以编译一下Python的源代码。注意你要用**调试模式**来编译因为接下来我们要跟踪Python编译器的运行过程。这就要使用**调试工具GDB**。
GDB是GNU的调试工具做C语言开发的人一般都会使用这个工具。它支持通过命令行调试程序包括设置断点、单步跟踪、观察变量的值等这跟你在IDE里调试程序的操作很相似。
开发者指南中有如何用调试模式编译Python并如何跟GDB配合使用的信息。实际上GDB现在可以用Python来编写扩展从而给我们带来更多的便利。比如我们在调试Python编译器的时候遇到Python对象的指针PyObject\*就可以用更友好的方式来显示Python对象的信息。
好了接下来我们就通过跟踪Python编译器执行过程看看它在编译过程中都涉及了哪些主要的程序模块。
在tokenizer.c的tok\_get()函数中打一个断点通过GDB观察Python的运行你会发现下面的调用顺序用bt命令打印输出后整理的结果
![](https://static001.geekbang.org/resource/image/0e/c4/0eedee1683034d95e4380e2b4769dac4.jpg)
这个过程是运行Python并执行到词法分析环节你可以看到完整的程序执行路径
1. 首先是python.c这个文件很短只是提供了一个main()函数。你运行python命令的时候就会先进入这里。
2. 接着进入Modules/main.c文件这个文件里提供了运行环境的初始化等功能它能执行一个python文件也能启动REPL提供一个交互式界面。
3. 之后是Python/pythonrun.c文件这是Python的解释器它调用词法分析器、语法分析器和字节码生成功能最后解释执行。
4. 再之后来到Parser目录的parsetok.c文件这个文件会调度词法分析器和语法分析器完成语法分析过程最后生成AST。
5. 最后是toknizer.c它是词法分析器的具体实现。
拓展REPL是Read-Evaluate-Print-Loop的缩写也就是通过一个交互界面接受输入并回显结果。
通过上述的跟踪过程我们就进入了Python的词法分析功能。下面我们就来看一下它是怎么实现的再一次对词法分析的原理做一下印证。
## Python的词法分析功能
首先你可以看一下tokenizer.c的tok\_get()函数。你一阅读源代码就会发现这是我们很熟悉的一个结构它也是通过有限自动机把字符串变成Token。
你还可以用另一种更直接的方法来查看Python词法分析的结果。
```
./python.exe -m tokenize -e foo.py
```
补充其中的python.exe指的是Python的可执行文件如果是在Linux系统可执行文件是python。
运行上面的命令会输出所解析出的Token
![](https://static001.geekbang.org/resource/image/c3/66/c3934c23760c13884c98d979fc250c66.jpg)
其中的第二列是Token的类型第三列是Token对应的字符串。各种Token类型的定义你可以在Grammar/Tokens文件中找到。
我们曾在研究[Java编译器](https://time.geekbang.org/column/article/251937)的时候,探讨过如何解决关键字和标识符的词法规则冲突的问题。**那么Python是怎么实现的呢**
原来Python在词法分析阶段根本没有区分这两者只是都是作为“NAME”类型的Token来对待。
补充Python里面有两个词法分析器一个是用C语言实现的tokenizer.c一个是用Python实现的tokenizer.py。C语言版本的词法分析器由编译器使用性能更高。
所以Python的词法分析功能也比较常规。其实你会发现每个编译器的词法分析功能都大同小异你完全可以借鉴一个比较成熟的实现。Python跟Java的编译器稍微不同的一点就是没有区分关键字和标识符。
接下来,我们来关注下这节课的重点内容:语法分析功能。
## Python的语法分析功能
在GDB中继续跟踪执行过程你会在parser.c中找到语法分析的相关逻辑
![](https://static001.geekbang.org/resource/image/0d/f7/0d96372c3f18fe45cb6f0bbc12fc77f7.jpg)
**那么Python的语法分析有什么特点呢它采用的是什么算法呢是自顶向下的算法还是自底向上的算法**
首先我们到Grammar目录去看一下Grammar文件。这是一个用EBNF语法编写的Python语法规则文件下面是从中节选的几句你看是不是很容易读懂呢
```
//声明函数
funcdef: 'def' NAME parameters ['->' test] ':' [TYPE_COMMENT] func_body_suite
//语句
simple_stmt: small_stmt (';' small_stmt)* [';'] NEWLINE
small_stmt: (expr_stmt | del_stmt | pass_stmt | flow_stmt |
import_stmt | global_stmt | nonlocal_stmt | assert_stmt)
```
通过阅读规则文件你可以精确地了解Python的语法规则。
**这个规则文件是给谁用的呢**实际上Python的编译器本身并不使用它它是给一个**pgen**的工具程序([Parser/pgen](https://github.com/python/cpython/blob/3.9/Parser/pgen/pgen.py))使用的。这个程序能够基于语法规则生成**解析表**Parse Table供语法分析程序使用。有很多工具能帮助你生成语法解析器包括yaccGNU版本是bison、ANTLR等。
有了pgen这个工具你就可以通过修改规则文件来修改Python语言的语法比如你可以把函数声明中的关键字“def”换成“function”这样你就可以用新的语法来声明函数。
pgen能给你生成新的语法解析器。parser.c的注释中讲解了它的工作原理。它是把EBNF转化成一个NFA然后再把这个NFA转换成DFA。基于这个DFA在读取Token的时候编译器就知道如何做状态迁移并生成解析树。
这个过程你听上去是不是有点熟悉?实际上,我们在[第2讲](https://time.geekbang.org/column/article/243685)讨论正则表达式工具的时候就曾经把正则表达式转化成了NFA和DFA。基于这个技术我们既可以做词法解析也可以做语法解析。
实际上Python用的是LL(1)算法。我们来回忆一下LL(1)算法的[特点](https://time.geekbang.org/column/article/244906)**针对每条语法规则最多预读一个Token编译器就可以知道该选择哪个产生式。**这其实就是一个DFA从一条语法规则根据读入的Token迁移到下一条语法规则。
我们通过一个例子来看一下Python的语法分析特点这里采用的是我们熟悉的一个语法规则
```
add: mul ('+' mul)*
mul: pri ('*' pri)*
pri: IntLiteral | '(' add ')'
```
我把这些语法规则对应的DFA画了出来。你会看到它跟采用递归下降算法的思路是一样的只不过换了种表达方式。
![](https://static001.geekbang.org/resource/image/de/06/def9c3178ca00a1ebc5471b4a74acb06.jpg "add: mul ('+' mul)*对应的DFA")
![](https://static001.geekbang.org/resource/image/f6/0d/f6b24be02983c724b945e8a1674yya0d.jpg "mul: pri ('*' pri)*对应的DFA")
![](https://static001.geekbang.org/resource/image/d0/1d/d0a8af5d54571f07b6df4eb965031e1d.jpg "pri: IntLiteral | '(' add ')'对应的DFA")
不过跟手写的递归下降算法为解析每个语法规则写一个函数不同parser.c用了一个通用的函数去解析所有的语法规则它所依据的就是为每个规则所生成的DFA。
主要的实现逻辑是在parser.c的PyParser\_AddToken()函数里你可以跟踪它的实现过程。为了便于你理解我模仿Python编译器用上面的文法规则解析了一下“`2+3*4+5`”,并把整个解析过程画成图。
在解析的过程我用了一个栈作为一个工作区来保存当前解析过程中使用的DFA。
**第1步匹配add规则。**把add对应的DFA压到栈里此时该DFA处于状态0。这时候预读了一个Token是字面量2。
![](https://static001.geekbang.org/resource/image/2a/54/2a9318064fa07108f5484235fb824454.jpg)
**第2步根据add的DFA走mul-1这条边去匹配mul规则。**这时把mul对应的DFA入栈。在示意图中栈是从上往下延伸的。
![](https://static001.geekbang.org/resource/image/6d/59/6d3222404b30yy08d29943a321e6ac59.jpg)
**第3步根据mul的DFA走pri-1这条边去匹配pri规则。**这时把pri对应的DFA入栈。
![](https://static001.geekbang.org/resource/image/78/77/78fca50b5a414fbf74f6229aa4c0a877.jpg)
**第4步根据pri的DFA因为预读的Token是字面量2所以移进这个字面量并迁移到状态3。同时为字面量2建立解析树的节点。**这个时候又会预读下一个Token`'+'`号。
![](https://static001.geekbang.org/resource/image/4a/ff/4a2daba678f9f8fe476e94403267d2ff.jpg)
**第5步从栈里弹出pri的DFA并建立pri节点。**因为成功匹配了一个pri所以mul的DFA迁移到状态1。
![](https://static001.geekbang.org/resource/image/5a/41/5a204c08609187584d88894b5388d741.jpg)
**第6步因为目前预读的Token是`'+'`号所以mul规则匹配完毕把它的DFA也从栈里弹出**。而add对应的DFA也迁移到了状态1。
![](https://static001.geekbang.org/resource/image/a6/a6/a6a49e70fa5216f0cc516981978a5fa6.jpg)
**第7步移进`'+'`号把add的DFA迁移到状态2预读了下一个Token字面量3**。这个Token是在mul的First集合中的所以就走mul-2边去匹配一个mul。
![](https://static001.geekbang.org/resource/image/8f/3c/8f446e247ecab4e9224f59130de4013c.jpg)
按照这个思路继续做解析,直到最后,可以得到完整的解析树:
![](https://static001.geekbang.org/resource/image/be/b5/be5cd83e4c545a9d29c4f41a13fae5b5.jpg)
总结起来Python编译器采用了一个通用的语法分析程序以一个栈作为辅助的数据结构来完成各个语法规则的解析工作。当前正在解析的语法规则对应的DFA位于栈顶。一旦当前的语法规则匹配完毕那语法分析程序就可以把这个DFA弹出退回到上一级的语法规则。
所以说语法解析器生成工具会基于不同的语法规则来生成不同的DFA但语法解析程序是不变的。这样你随意修改语法规则都能够成功解析。
上面我直观地给你解读了一下解析过程。你可以用GDB来跟踪一下PyParser\_AddToken()函数,从而了解得更具体。你在这个函数里,还能够看到像下面这样的语句,这是对外输出调试信息。
```
D(printf(" Push '%s'\n", d1->d_name)); //把某DFA入栈
```
你还可以用“-d”参数运行python然后在REPL里输入程序这样它就能打印出这些调试信息包括什么时候把DFA入栈、什么时候出栈等等。我截取了一部分输出信息你可以看一下。
![](https://static001.geekbang.org/resource/image/d6/f1/d6e523e506687846f7d13a1eaff211f1.jpg)
在Python的语法规则里arith\_expr指的是加减法的表达式term指的是乘除法的表达式atom指的是基础表达式。这套词汇也经常被用于语法规则中你可以熟悉起来。
好了现在你已经知道了语法解析的过程。不过你可能注意到了上面的语法解析过程形成的结果我没有叫做是AST而是叫做**解析树**Parse Tree。看到这里你可能会产生疑问**解析源代码不就会产生AST吗怎么这里是生成一个叫做解析树的东西什么是解析树它跟AST有啥区别**别着急,下面我就来为你揭晓答案。
## 解析树和AST的区别
解析树又可以叫做**CST**Concrete Syntax Tree具体语法树与AST抽象语法树是相对的一个具体一个抽象。
它俩的区别在于:**CST精确地反映了语法规则的推导过程而AST则更准确地表达了程序的结构。如果说CST是“形似”那么AST就是“神似”。**
你可以看看在前面的这个例子中所形成的CST的特点。
![](https://static001.geekbang.org/resource/image/be/b5/be5cd83e4c545a9d29c4f41a13fae5b5.jpg)
首先加法是个二元运算符但在这里add节点下面对应了两个加法运算符跟原来加法的语义不符。第二很多节点都只有一个父节点这个其实可以省略让树结构更简洁。
所以我们期待的AST其实是这样的
![](https://static001.geekbang.org/resource/image/7a/ce/7aa1ea17abafdba3f0cd68f6d14b6ace.jpg)
这就是CST和AST的区别。
理解了这个知识点以后我们拿Python实际的CST和AST来做一下对比。在Python的命令行中输入下面的命令
```
>>> from pprint import pprint
>>> import parser
>>> cst = parser.expr('2+3+4') //对加法表达式做解析
>>> pprint(parser.st2list(cst)) //以美观的方式打印输出CST
```
你会得到这样的输出结果:
![](https://static001.geekbang.org/resource/image/50/6f/508d14e74211a1a0bbf6e8c0b282b76f.jpg)
这是用缩进的方式显示了CST的树状结构其中的数字是符号和Token的编号。你可以从Token的字典dict里把它查出来从而以更加直观的方式显示CST。
我们借助一个lex函数来做美化的工作。现在再显示一下CST就更直观了
![](https://static001.geekbang.org/resource/image/10/fe/104aa190ab9a4c118d1fb5a73187fafe.jpg)
**那么Python把CST转换成AST会是什么样子呢**
你可以在命令行敲入下面的代码来显示AST。它虽然是以文本格式显示的但你能发现它是一个树状结构。这个树状结构就很简洁
![](https://static001.geekbang.org/resource/image/22/bd/2232eb547e255f88e5d867ec147867bd.jpg)
如果你嫌这样不够直观还可以用另一个工具“instaviz”在命令行窗口用pip命令安装instaviz模块以图形化的方式更直观地来显示AST。instaviz是“Instant Visualization”立即可视化的意思它能够图形化显示AST。
```
$ pip install instaviz
```
然后启动Python并敲入下面的代码
![](https://static001.geekbang.org/resource/image/41/fb/41808237c525885d28534fc9514329fb.jpg)
instaviz会启动一个Web服务器你可以在浏览器里通过http://localhost:8080来访问它里面有图形化的AST。你可以看到这个AST比起CST来确实简洁太多了。
![](https://static001.geekbang.org/resource/image/e1/bf/e1700c27cec63f492f5cdb68809d42bf.jpg)
点击代表“`2+3*4+5`”表达式的节点,你可以看到这棵子树的各个节点的属性信息:
![](https://static001.geekbang.org/resource/image/28/c5/28ab9da7c9c4cd005d13fca4d44e69c5.jpg)
总结起来在编译器里我们经常需要把源代码转变成CST然后再转换成AST。生成CST是为了方便编译器的解析过程。而转换成AST后会让树结构更加精简并且在语义上更符合语言原本的定义。
**那么Python是如何把CST转换成AST的呢**这个过程分为两步。
**首先Python采用了一种叫做ASDL的语言来定义了AST的结构。**[ASDL](https://www.cs.princeton.edu/research/techreps/TR-554-97)是“抽象语法定义语言Abstract Syntax Definition Language”的缩写它可以用于描述编译器中的IR以及其他树状的数据结构。你可能不熟悉ASDL但可能了解XML和JSON的Schema你可以通过Schema来定义XML和JSON的合法的结构。另外还有DTD、EBNF等它们的作用都是差不多的。
这个定义文件是Parser/Python.asdl。CPython编译器中包含了两个程序Parser/asdl.py和Parser/asdl\_c.py来解析ASDL文件并生成AST的数据结构。最后的结果在Include/Python-ast.h文件中。
到这里,你可能会有疑问:**这个ASDL文件及解析程序不就是生成了AST的数据结构吗为什么不手工设计这些数据结构呢有必要采用一种专门的DSL来做这件事情吗**
确实如此。Java语言的AST只是采用了手工设计的数据结构也没有专门用一个DSL来生成。
但Python这样做确实有它的好处。上一讲我们说过Python的编译器有多种语言的实现因此基于统一的ASDL文件我们就可以精准地生成不同语言下的AST的数据结构。
在有了AST的数据结构以后**第二步是把CST转换成AST这个工作是在Python/ast.c中实现的入口函数是PyAST\_FromNode()。**这个算法是手写的,并没有办法自动生成。
## 课程小结
今天这一讲我们开启了对Python编译器的探究。我想给你强调下面几个关键要点
* **非自举**。CPython的编译器是用C语言编写的而不是用Python语言本身。编译器和核心库采用C语言会让它性能更高并且更容易与各种二进制工具集成。
* **善用GDB**。使用GDB可以跟踪CPython编译器的执行过程加深对它的内部机制的理解加快研究的速度。
* **编译器生成工具pgen**。pgen能够根据语法规则生成解析表让修改语法的过程变得更加容易。
* **基于DFA的语法解析过程**。基于pgen生成的解析表通过DFA驱动完成语法解析过程整个执行过程跟递归下降算法的原理相同但只需要一个通用的解析程序即可。
* **从CST到AST**。语法分析首先生成CST接着生成AST。CST准确反映了语法推导的过程但会比较啰嗦并且可能不符合语义。AST同样反映了程序的结构但更简洁并且支持准确的语义。
本讲的思维导图我也放在这里了,供你参考:
![](https://static001.geekbang.org/resource/image/1e/34/1e11c1bb92669152c725a35c919b4534.jpg)
## 一课一思
这一讲我们提到Python的词法分析器没有区分标识符和关键字但这样为什么没有影响到Python的语法分析的功能呢你可以结合语法规则文件和对语法解析过程的理解谈谈你的看法。如果你能在源代码里找到确定的答案那就更好了
欢迎你在留言区中分享你的见解,也欢迎你把今天的内容分享给更多的朋友,我们下一讲再见。
## 参考资料
GDB的安装和配置参考[这篇文章](https://github.com/RichardGong/CompilersInPractice/edit/master/python/GDB.md)。