来点儿编译原理(1):实现一个小型四则运算编译器

暑假的时候曾经翻译过一篇很不错的关于编译器的文章:
但这篇文章真的太简略了,其中编译器前端最重要的文法分析只是一笔带过,没有介绍任何理论和方法,虽然最后确实实现了一个超简单的编译器,但我感觉还远远不够。
这段时间闲着没事的时候去详尽地学了学编译原理,自己也实现了一个很简单的编译器(至少比上面那个稍微复杂一点),所以就用这篇文章记录一下吧。
当然我自己水平有限,肯定不如专门做编译器的巨巨那样熟悉西方的那一套的理论,所以这篇文章面向的读者大概是『没系统接触过编译原理的、看得懂JavaScript的程序员』。
零、目标及动机
DEMO的地址在这里:
http://starkwang.github.io/naive-complier/demo /
我们的目标是实现一个把C风格算式转换为Lisp风格的编译器,比如:
'1 + 2' => '(+ 1 2)'
'1 + 2 * 3' => '(+ 1 (* 2 3))'
'(1 + 2) * 3' => '(* (+ 1 2) 3)'
有人可能要问,编译原理这种晦涩难懂的东西和前端切图仔有什么关系呢?
事实上编译原理已经深入到前端开发的各个角落,比如 React 的 JSX 语法,它是需要编译到普通的 JS 才能正常运行的:
// JSX语法
<MyComponent foo="0">
<Child1 foo="1">Hello</Child1>
<Child2 foo="2"/>
</MyComponent>
// 实际运行时需要被转换为下面这样的代码:
React.createElement(
MyComponent,
{foo: "0"},
React.createElement(Child1, {foo: "1"}, "Hello"),
React.createElement(Child2, {foo: "2"})
);
还有每次发布部署的时候一般都会做压缩混淆代码,这从性质上讲也是一种『编译』:
// 源码:
function myLongAndStupidFunctionName(){
doSomeThing();
}
function doSomeThing(){
//......
}
// 压缩混淆之后:
function a(){b();}function b(){/*......*/}
还有诸如Babel、TypeScript、Flow、Webpack2的Tree-Shaking等等等等,就不再列举了,总之学一点编译原理总是能派上用场的。
一、编译器的大致结构
那么就开始吧,首先我们先了解一下编译器大概的结构,我们使用括号来标注某个过程:
输入字符串
->(词法分析器tokenizer)-> 符号串
->(文法分析器parser)-> 抽象语法树AST
->(后端代码生成)-> 目标代码字符串
举个例子,比如我们希望把『1 + 1』这段代码转换为『(+ 1 1)』,那么首先词法分析器会将代码转换为一串 token:
var token = tokenizer('1 + 1');
//=> ['1', '+', '1']
// 实际上这样表达会更严谨,但为了方便起见,我们还是用上面那样简单的表示法
// [
// {type: 'NUMBER', value:'1'},
// {type: 'OPERATOR', value:'+'},
// {type: 'NUMBER', value:'1'}
// ]
然后就是文法分析的过程,由符号串生成一个AST:
var ast = parser(token);
//=>
// {
// type: 'root',
// child: [{
// type: 'number',
// child: ['1']
// }, {
// type: 'operator',
// child: ['+']
// }, {
// type: 'number',
// child: ['1']
// }]
// }
// 不同的文法以及分析方法会有不同的AST结构,上面只是一个范例
有了这个AST之后,我们可以对它做任何想做的事情,比如生成Lisp风格的表达式:
var code = transformer(ast);
//=> '(+ 1 1)'
二、词法分析和Tokenizer
编译器的第一个部分是 Tokenizer,它的作用是把输入的字符串转换为一个由 token 组成的集合:
tokenizer('(11 + 22) * 33')
//=> ['(', '11', '+', '22', ')', '33']
/**
* 由于我们这个编译器的合法输入字符极其有限,只有数字、括号、加减乘除,
* 所以没有使用更抽象的对象(比如 {type: 'NUMBER', value: '1'} 这样的结构)
* 去表示每一个 token,直接使用字符串会稍微简化后面的流程。
**/
是的,Tokenizer 的实现一点都不难,它本质上是一个有限状态机(DFA),在这里只是切割了一下字符串,它也不是这篇文章的重点,所以就不贴代码上来了,具体代码可以看这里(当然存在比我更好更优雅的实现):
https:// github.com/starkwang/na ive-complier/blob/master/tokenizer.js
三、文法分析和Parser
1、文法的基本概念
文法分析才是这篇文章的重点,什么是文法呢? 文法就是一组规则,它定义了哪些符号串是合法句子 ,比如有一种语言的文法如下:
A -> Ab
| a
它表示,『A』这个符号可以派生出『Ab』,也可以派生出『a』,我们把这样能生出其它符号的符号称为“ 非终结符 ”;同理,a和b不能生出符号,所以称为“ 终结符 ”。
为了方便说明,后面出现的 非终结符一般使用大写字母开头 ,而 终结符使用小写字母 。
上面这个简单的文法可以派生出诸如『ab』、『aab』、『aaaaaaaaab』这样的符号串。按照同样的思路我们很容易就能写出一个普通四则运算的文法:
Expr -> Expr op Factor 规则1
| Factor 规则2
op -> + 规则3
| - 规则4
| * 规则5
| / 规则6
Factor -> (Expr) 规则7
| num 规则8
根据这个文法,可以生成所有的四则运算表达式,比如连续使用规则1、5、7、1、3、8......展开生成式,就能得到『num * (num + num)』
Expr
->(规则1)-> Expr op Factor
->(规则5)-> Expr * Factor
->(规则7)-> Expr * (Expr)
->(规则1)-> Expr * (Expr op Factor)
->(规则3)-> Expr * (Expr + Factor)
->(规则8)-> Expr * (Expr + num)
->(....)-> num * (num + num)
由它展开的AST是这样的:

2、消除文法的歧义
上面的例子很简洁明了,看起来没有什么问题。那么我们现在考虑一下大部分类Algol语言使用的『if-then-else』这种结构的文法,直觉上它的文法是:
Statement -> if Expr then Statement else Statement
| if Expr then Statement
| CodeBlock
看起来是对的,但如果有这样的输入符号串,就会导致歧义:
if Expr1 then if Expr2 then CodeBlock1 else CodeBlock2
没有发现?使用缩进来表示语句之间的关系,就很容易看到问题所在了:
// 语义1
if Expr1
then if Expr2
then CodeBlock1
else CodeBlock2
// 语义2
if Expr1
then if Expr2
then CodeBlock1
else CodeBlock2
发现了吗?问题在于最后那个『else CodeBlock2』,我们无法知道它究竟是和哪一个『if』配对,这直接导致了歧义的文法,是没有办法分析的。
所以我们需要纠正这种文法上的缺陷,这就需要重写我们的文法,比如我们可以把文法改写成下面这样:
Statement -> if Expr then WithElse else Statement 规则1
| if Expr then Statement 规则2
| CodeBlock 规则3
WithElse -> if Expr then WithElse else WithElse 规则4
| CodeBlock 规则5
然后之前的歧义问题就会得到消除:
Statement
->(规则2)-> if Expr then Statement
->(规则1)-> if Expr then if Expr then WithElse else Statement
->(规则3)-> if Expr then if Expr then WithElse else CodeBlock
->(规则5)-> if Expr then if Expr then CodeBlock else CodeBlock
3、文法的结构优先权
仅仅把文法的歧义消除是远远不够的,下面我们回到四则运算表达式上,假设有一个这样的输入:
num - num * num
如果用之前的那种文法,稍微推导一下,就会发现展开后的AST是这样的:
嗯,这里我们错误地把前三个符号『num - num』解析成了一个独立的表达式,也就是说我们把输入识别成为了『(num - num) * num』这种形式,这和数学中的运算法则是不符的,因为乘除法的优先级是高于加减法的。
解决方法和之前一样,我们需要 改写我们的文法,赋予乘除法更高的优先级 (但不能高于括号),于是我们可以写出下面这样严谨的文法:
Expr -> Expr + Term
| Expr - Term
| Term
Term -> Term * Factor
| Term / Factor
| Factor
Factor -> (Expr)
| num
好的,这个文法完全没有错误了,但它仍然有一些小缺陷,下面我们继续。
4、递归下降分析、消除左递归
有了正确严谨的文法,下面我们要进入真正的文法分析阶段了。下面我们要介绍的是『自顶向下、递归下降』的分析方法,考虑这样一种极简单的文法:
A -> aB B -> aB | b
看起来是不是有点眼熟?额。。。先别管眼不眼熟,我们通过这个文法可以比较简单地构建出一个递归下降的分析器:
// 一个极简的递归下降分析器
function parser(token) {
var i = 0;
function nextWord() {
return token[i++];
}
var word = nextWord();
if (A() && !word) {
return true;
} else {
return false;
}
/**
* A -> aB
**/
function A() {
if (word == 'a') {
word = nextWord();
return B();
} else {
console.error('Unexpected token:', word);
}
}
/**
* B -> aB
* | b
**/
function B() {
if (word == 'a') {
word = nextWord();
return B();
} else if (word == 'b') {
word = nextWord();
return true;
} else {
console.error('Unexpected token:', word);
}
}
}
parser(['a', 'b']); //=> true
