中文站

21分钟学会写编译器

知乎上有一种说法是「编译器、图形学、操作系统是程序员的三大浪漫」。

先不管这个说法是对是错,我们假设一个程序员在国内互联网公司写代码,业余时间不看相关书籍。那么三年之后,他的这些知识会比在校时损耗多少?

很显然,损耗的比例肯定非常高,毕竟国内互联网公司日常开发工作中,程序员基本很少接触这三块知识。大部分程序员工作几年后对编译原理相关的概念只能生理上起反应,脑海里很难再串联起相关概念了。

编译原理的概念有让人看到就头痛的特质,学校里要死记硬背,考试过了巴不得赶紧全忘掉,相信不少同学现在看到下面概念还会觉得蛋疼:

非确定性有限自动机/确定性有限自动机

四元式序列

上下文无关文法/BNF

终结符/非终结符

LL(1)/LR(1)

特设语法制导转换

局部优化

如果要按照课程来,光是背下这些名词和释义别说21分钟了,21天都搞不定。更何况背下来这些名词之后如何写编译器又是另一个问题。

我们很多时候,都只是想快速上手写一个编译器,有可能是因为好奇,有可能是想实现自己的玩具DSL(领域特定语言),或者有可能是为了在约架时候防身。

今天,我们就来看看如何用21分钟的时间学会写编译器,上面的废话大概花费1分钟,接下来还剩20分钟。

正式开始做编译器之前,先以问答的形式对接下来的内容做个简单介绍:

什么是编译器?

广义的编译器可以指任意把一种语言代码转为另一种语言代码的程序。

做编译器实际上都需要做什么?

编译器是一整套工具链,从前端的词法分析、语法分析,到中间表示生成、检查、分析、优化,再到代码生成。

如果是编译器从业者,大部分时间在做中间这块;如果是业余爱好者,大部分时间在做前端和代码生成。

那我们今天做的是个什么编译器?

既然是21分钟,那学会写个gcc肯定是不可能的,就算拿来学Flex+Bison都只能入个门。

我们接下来会先确定一下源语言的语法集,然后设计一下抽象语法树(AST)结构,写一个Parser,把源语言转换成一棵抽象语法树,再写一个CodeGenerator,把语法树转换为目标语言。

也就是说,相比于一个正常的编译器,我们省去了类型检查和中间表示的分析优化,并且为了能21分钟内学会,我们会把源语言定义得特别蠢。

接下来开始正文:

先确定源语言:

这是一门看起来像lisp的四则运算语言,四个双目运算符分别是「add」「sub」「mul」「div」。

多项四则运算可以这样写:

(mul (sub 5 (add 1 2)) 4)

再来确定目标语言:

同样是一门四则运算语言,但是看起来可读性更强,对应的四个双目运算符分别是「+」「-」「*」「/」。

上面源语言的例子编译完后应该是这样:

((5 - (1 + 2)) * 4)

最后确定我们写编译器要用的语言:

选择Haskell,有两个原因,一是写Haskell有大名鼎鼎的ParseC,写Parser非常方便;二是Haskell的代数数据类型的定义本身就是AST。

ParseC的全称是Parser组合子。Parser,抽象理解就是一个输入为字符串输出为类型T的值的函数。ParseC库实现了大量基础Parser和Parser组合子,Parser组合子可以将库自带的基础Parser和用户定义的Parser随意组合成新的更强大的Parser。

举个例子,你实现了一个Parser,功能是根据输入文本返回解析到的标识符名称。ParseC库实现了一个名叫many的parser组合子,跟你自己的Parser组合起来就产生了一个新的Parser:可以根据输入文本返回解析到的标识符名称list。

为什么要用ParseC呢?因为用ParseC定义Parser具有PEG(解析表达式文法,原理不细讲,不影响接下来学习)的所有好处,同时还不用再学习语言之外的知识(比如用flex和bison前要先学习这两者自己的「DSL」)。

当然,其他语言也有类似的库,比如c++有boost::spirit,Java/C#/F#/JS有Haskell的ParseC的工业级实现。这些语言跟Haskell的区别无非在于要写一些额外的逻辑把Parser的解析结果转成AST。

如果没有接触过Haskell的话也没关系,接下来的示例代码都非常declarative,非常self-descriptive,请放心食用。

接下来就开始写代码了,首先我们要定义AST的结构,目的是为了能用这个结构描述一切源语言表达式。

简单分析一下源语言,我们可以直接得出表达式这个概念的递归定义:一个表达式要么是一个字面值,要么是一个双目运算符和两个表达式的求值结果。

然后是字面值这个概念的递归定义:一个字面值要么是一个整型值,要么是一个浮点型值。
在Haskell里面这两个定义写成下面这样:

type BinOp = String

data Val =

    IntVal Integer

    | FloatVal Float

        deriving (Eq, Ord, Show)

data Exp

    ConstExp Val

    | BinOpExp BinOp Exp Exp

        deriving (Eq, Ord, Show)

跟前面的文字定义对应一下:

表达式Exp,要么是一个字面值表达式ConstExp,由一个Val组成;要么是一个双目运算表达式BinOpExp,由一个操作符和两个Exp组成。

值Val,要么是一个整型值IntVal,由一个Integer组成;要么是一个浮点型值FloatVal,由一个Float组成。

接下来开始写Parser。流程是先为AST中的每个节点类型写一个parser,然后再把这些parser组合起来形成能parse出整棵AST的parser。

我们先给自己定个小目标,比如先实现一个int_parser。

p_int :: Parser Integer

p_int  = do s <- getInput

            case readSigned readDec s of

                [(n, s')] -> n <$ setInput s'

                _         -> empty        <?> "p_int"

p_int_val :: Parser Val

p_int_val =  IntVal <$> p_int 

            <?> "p_int_val"

p_int是能从文本中Parse出Integer的Parser定义。而p_int_val改造了p_int,定义了能从文本中Parse出IntVal的Parser。


然后我们把int和float的parser组合起来成为一个val_parser。

p_val :: Parser Val

p_val = listplus [p_int_val,p_float_val]

listplus可以简单理解为并,在具体实现上会做回溯。


同理,我们先分别实现ConstExp的parser和BinOpExp的parser,再把两者组合为exp_parser。


到目前为止,我们的parser部分就完工了。

对Haskell有兴趣的同学,可以安装下ghci,是haskell的REPL,然后加载刚才写好的Parser.hs,在命令行里试一下

Prelude> parse p_exp "" "(mul (sub 5 (add 1 2)) 4)"

可以看到输出结果。稍微排版下,输出结果变成了我们熟悉的树形结构,Op为「mul」的BinOpExp就是树的根节点。整个输出就是一棵AST。

有了这棵AST,我们就可以开始做后续的代码生成了。

CodeGenerator的主体是把Exp转换成目标语言代码的函数:


利用模式匹配这个语言特性实现多态既容易又优雅。

最后再套个壳,比如读源文件,写目标文件,整个编译器就大功告成了。


好了,看到这里,相信你对怎么快速写一个编译器已经有了比较充分的了解。

当然,我不否认,「21分钟学会写编译器」有标题党的嫌疑,如果想按本文介绍的方法从零开始写编译器,即使不用学flex和bison,不用回忆编译原理课程内容,也还是需要了解PEG,了解自己熟悉语言的ParseC库用法。

但是,这仍然是个人认为的最快上手写编译器的方法。远离痛苦的抽象概念,从动手开始,不正是很多同学喜欢上写代码的初心吗?

如之前所说,我们虽然实现了一个编译器,但是这个编译器非常蠢,比如BinOp的存在本身就有问题:

BinOp在AST中用字符串表示,我们就没办法检查两个操作数的类型。

BinOp成了特殊概念,而不是普通的函数。

文章中的一些背景知识由于篇幅原因我没办法一一解释,这里简单列个reference,对相关话题感兴趣的可以去搜索引擎搜索对应的关键字:

haskell的相关概念,看real world haskell即可。

ParseC的相关概念,可以找这几篇文献,「the essence of fp」「monads for fp」「monadic parser combinator」。

编译原理相关概念,不建议看《龙书》,有兴趣的可以翻翻看《Engineering a Compiler》。

本文作者:王迅

文章原发于个人订阅号:gamedev101「说给开发游戏的你」欢迎订阅 https://mp.weixin.qq.com/s/w41frrmg6j5l4610aSi4Zw