手把手教你实现一个简单的编译器
1、 概述
今天我们将学习开发一个编译器,但是呢,这个编译器并不是说什么都能都编译,它只是一个超级小的编译器,主要用于说明编译器的一些基本的原理。
我们这个编译器可以将类似于lisp语言的函数调用编译成类似于C语言的函数调用。如果你对lisp语言和C语言这两者都不熟悉,没关系,什么语言其实无所谓,但接下来还是会给你一个快速的介绍。
如果我们有两个函数分别是add和subtract,如果用它们来计算下面的表达式:
2 + 2
4 - 2
2 + (4 - 2)
那么在lisp语言中它可能长这样子:
(add 2 2) // 2 + 2
(subtract 4 2) // 4 - 2
(add 2 (subtract 4 2)) // 2 + (4 - 2)
而在C语言中它长这个样子:
add(2, 2)
subtract(4, 2)
add(2, subtract(4, 2))
相当简单吧?
好吧,这是因为这仅仅只是我们这个编译器所需要处理的情形。 这既不是list语言的完整语法,也不是C语言的完整语法。 但这点语法已经足以用来演示现代编译器所做的大部分工作。
大部分编译器所做的工作都可以分解为三个主要的步鄹: 解析、转换和代码生成。
解析。 解析就是将原始代码转换成代码的抽象表示。
转换。 转换就是以这个抽象表示为基础,做编译器想做的任何事情。
代码生成。 代码生成就是将转换后的抽象表示变成新的代码。
2、 解析
解析通常分为两个阶段:词法分析和句法分析。
词法分析。 词法分析通常是使用一个标记器(或词法分析器)将原始代码拆分成叫做标记的东西。而标记是一些微小的对象组成的数组,它们通常用来描述一些孤立的语法片段,它们可以是数字、标签、标点符号、操作符等等。
语法分析。 语法分析将词法分析得到的标记重新格式化为用于描述语法的每个部分及其相互关系的表示。 这被称为中间表示或抽象语法树(AST)。抽象语法树(简称AST)是一个深度嵌套的对象,用于以一种既好用又能提供很多信息的形式表式代码。
对于下面的语法:
(add 2 (subtract 4 2))
标记可能长下面这个样子:
[
{ type: 'paren', value: '(' },
{ type: 'name', value: 'add' },
{ type: 'number', value: '2' },
{ type: 'paren', value: '(' },
{ type: 'name', value: 'subtract' },
{ type: 'number', value: '4' },
{ type: 'number', value: '2' },
{ type: 'paren', value: ')' },
{ type: 'paren', value: ')' },
]
然后它对应的抽象语法树(AST)可能长下面这个样子:
{
type: 'Program',
body: [{
type: 'CallExpression',
name: 'add',
params: [{
type: 'NumberLiteral',
value: '2',
}, {
type: 'CallExpression',
name: 'subtract',
params: [{
type: 'NumberLiteral',
value: '4',
}, {
type: 'NumberLiteral',
value: '2',
}]
}]
}]
}
3、 转换
在解析之后,编译器的下一步鄹是转换。 同样,这不过就是将最后一步的抽象语法树(AST)拿过来对它做一定的改变。这种改变多种多样,可以是在同一种语言中进行改变,也可以直接将抽象语法树转换成另外一种完全不同的新语言。
让我们来看看我们将如何转换一个抽象语法树(AST)。
你可能已经注意到,我们的抽象语法树里面有一些非常类似的元素。 这些元素对象有一个type属性。 这每一个对象元素都被称为一个AST节点。 这些节点上定义的属性用于描述AST树上的一个独立部分。
我们可以为数字字面量(NumberLiteral)建立一个节点:
{
type: 'NumberLiteral',
value: '2',
}
或者是为调用表达式(CallExpression)创建一个节点:
{
type: 'CallExpression',
name: 'subtract',
params: [...nested nodes go here...],
}
当转换AST树的时候,我们可能需要对它进行add、remove、replace等操作。 我们可以增加新节点,删除节点或者我们完全可以将AST树搁一边不理,然后基于它创建一个全新的AST。
由于我们这个编译器的目标是将lisp语言转换成C语言,所以我们会聚焦创建一个专门用于目标语言(在这里是C语言)的全新AST。
3.1 遍历
为了浏览所有这些节点,我们需要能够遍历它们。 这个遍历过程是对AST的每个节点进行深度优先访问。
{
type: 'Program',
body: [{
type: 'CallExpression',
name: 'add',
params: [{
type: 'NumberLiteral',
value: '2'
}, {
type: 'CallExpression',
name: 'subtract',
params: [{
type: 'NumberLiteral',
value: '4'
}, {
type: 'NumberLiteral',
value: '2'
}]
}]
}]
}
所以对于上面的AST,我们需要像这样走:
Program - 从AST树的顶层开始。
CallExpression (add) - 移动到Program的body属性的第一个元素。
NumberLiteral (2) - 移动到CallExpression(add)的第一个参数。
CallExpression (subtract) - 移动到CallExpression(add)的第二个参数。
NumberLiteral (4) - 移动到CallExpression(subtract)的第一个参数。
NumberLiteral (2) - 移动到CallExpression(subtract)的第二个参数。
如果我们直接操作这个AST而不是创建一个单独的AST,我们可能需要在这里引入各种抽象概念。 但是我们正在尝试做的事情,只需要访问树中的每个节点就足够了。
使用“访问”这个词的原因是因为这个词能够很好的表达如何在对象结构上操作元素。
3.2 访问者
这里最基本的思路就是我们创建一个访问者对象,这个对象拥有一些方法,这些方法可以接受不同的节点类型。
比如下面这样:
var visitor = {
NumberLiteral() {},
CallExpression() {},
};
当我们遍历AST的时候,一旦我们碰到一个与指定类型相匹配的节点,我们就会调用访问者对象上的方法。
为了让这个函数比较好用,我们给它传递了该节点以及它的父节点:
var visitor = {
NumberLiteral(node, parent) {},
CallExpression(node, parent) {},
};
然而,这里也会有可能出现在退出时调用东西。 想象一下我们前面提