21分钟学会写编译器

Wesley13
• 阅读 448

本文来自网易云社区

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

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

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

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

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

  • 四元式序列

  • 上下文无关文法/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是能从文本中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。

p_const_exp :: Parser Exp p_const_exp =  ConstExp <$> p_parse p_val
 <?> "p_const_exp"  p_bin_op_exp :: Parser Exp p_bin_op_exp =  p_between '(' ')' inner <?> "p_bin_op_exp"     where         inner = BinOpExp
 <$> p_parse (listplus [string "add", 
                                string "sub", string "mul", string "div"])
 <*> p_parse p_exp
 <*> p_parse p_exp
 <?> "p_bin_op_exp_inner"  p_exp :: Parser Exp p_exp =  listplus [p_const_exp, p_bin_op_exp]         <?> "p_exp"

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

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

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

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

Right (BinOpExp 
            "mul" 
            (BinOpExp 
                "sub" 
                (ConstExp (IntVal 5)) 
                (BinOpExp 
                    "add" 
                    (ConstExp (IntVal 1)) 
                    (ConstExp (IntVal 2)))) 
            (ConstExp (IntVal 4)))

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

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

exp_gen :: Exp -> Maybe String
exp_gen (BinOpExp op e1 e2) = do
    s1 <- exp_gen e1
    sop <- op_gen op
    s2 <- exp_gen e2
    return (format "({0} {1} {2})" [s1, sop, s2])
exp_gen (ConstExp val) = do
    case val of 
        IntVal int_val -> return (show int_val)
        FloatVal float_val -> return (show float_val)

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

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

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

当然,我不否认,「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》。

原文:21分钟学会写编译器

本文来自网易云社区,经作者王迅授权发布。

了解网易云 :

网易云官网:https://www.163yun.com

网易云社区:https://sq.163yun.com/blog

网易云新用户大礼包:https://www.163yun.com/gift

更多网易研发、产品、运营经验分享请访问网易云社区

点赞
收藏
评论区
推荐文章
blmius blmius
2年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Jacquelyn38 Jacquelyn38
2年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
Wesley13 Wesley13
2年前
Java获得今日零时零分零秒的时间(Date型)
publicDatezeroTime()throwsParseException{    DatetimenewDate();    SimpleDateFormatsimpnewSimpleDateFormat("yyyyMMdd00:00:00");    SimpleDateFormatsimp2newS
Wesley13 Wesley13
2年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
2年前
Java日期时间API系列36
  十二时辰,古代劳动人民把一昼夜划分成十二个时段,每一个时段叫一个时辰。二十四小时和十二时辰对照表:时辰时间24时制子时深夜11:00凌晨01:0023:0001:00丑时上午01:00上午03:0001:0003:00寅时上午03:00上午0
Wesley13 Wesley13
2年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
2年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
2年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
3个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这