前言
本文主要是笔者学习the-super-tiny-compiler的笔记整理,有感兴趣的同学可以直接访问github上该项目地址the-super-tiny-compiler,这里先做下简单的项目介绍,该项目是实现一个简单的微型编译器,他能够帮助你了解编译器是如何实现的,如果你想要了解编译相关知识或者你准备深入学习babel,以此作为起点是非常不错的选择,顺带一提该项目的作者James Kyle,也是开发Babel的大神之一
如果你的英文能力过关,我建议你去直接阅读原项目,如果你的英文阅读能力受限,文本可以当做大致的翻译版本
编译器的运行过程
我们以Babel为例(Babel其实就是一个代码编译器),他的运行过程大致如下(图片made bu coderwhy ^^)
简要来说大多数的编译器执行过程可以分为三个步骤:
- Parsing(解析过程)
这个过程要经词法分析、语法分析、构建AST(抽象语法树)一系列操作
- Transformation(转化过程)
这个过程就是将上一步解析后的内容,按照编译器指定的规则进行处理,形成一个新的表现形式
- Code Generation(代码生成)
将上一步处理好内容转再化为新的代码
以上就是编译器的大致执行流程,接下来按照上述步骤进行分析,项目中的案例如下
LISP 代码 (add 2 (subtract 4 2))
C 代码 add(2, subtract(4, 2))
释义: 2 + ( 4 - 2 )
将使用自己的书写的编译器将LISP代码转化为C代码
过程一:Parsing(解析)
一般解析可以分为2个步骤
一: 词法解析
词法分析是使用tokenizer(分词器)或者lexer(词法分析器),将源码拆分成tokens,tokens是一个放置对象的数组,其中的每一个对象都可以看做是独立词(数字,标签,标点,操作符...)的描述信息
如:
(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: ')' },
]
二: 语法解析
将tokens重新整理成语法相互关联的表达形式 ,这种表达形式一般被称为中间层或者AST(抽象语法树),上述tokens转换后形成的抽象语法树,对于AST(抽象语法树)不了解的同学,可以和我一样暂时把他当做就是一个对象,这个对象描述了tokens组合的关系
{
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',
}]
}]
}]
}
过程二:Transformation(转化)
这个过程主要是改写AST(抽象语法树),或者根据当前AST(抽象语法树)生成一个新的AST(抽象语法树),这个过程可以是相同语言,或者可以直接将AST(抽象语法树)翻译为其他语言
注意看上述生成的AST(抽象语法树),有一些特殊的对象,都具有自己的类型描述,他们就是这个“树”上的节点,如下所示
// 数字片段节点
{
type: 'NumberLiteral',
value: '2',
}
// 调用语句节点
{
type: 'CallExpression',
name: 'subtract',
params: [{
type: 'NumberLiteral',
value: '4',
}, {
type: 'NumberLiteral',
value: '2',
}]
}
在案例中我们是想将LISP语言转化为C语言,因此我们需要构建一个新的AST(抽象语法树),这个创建的过程就需要遍历这个“树”的节点并读取其内容,由此引出 Traversal(遍历) 和 Visitors (访问器)
Traversal(遍历)
顾名思义这个过程就是,遍历这个AST(抽象语法树)的所有节点,这个过程使用深度优先原则,大概执行顺序如下
1. Program - 最顶层开始
2. CallExpression (add) - 调用语句 (add) 程序主体的第一个元素
3. NumberLiteral (2) - 数字片段 (2) 调用语句(add)的第一个参数
4. CallExpression (subtract) - 调用语句 (subtract) 调用语句(add)的第二个参数
5. NumberLiteral (4) - 数字片段 (4) 调用语句(subtract)的第一个参数
6. NumberLiteral (2) - 数字片段 (2) 调用语句(subtract)的第二个参数
Visitors (访问器)
访问器最基本的思想是:创建一个“访问器”对象,这个对象可以处理不同类型的节点函数,如下所示
const visitor = {
NumberLiteral(){}, // 处理数字类型节点
CallExpression(){} // 处理调用语句类型节点
}
我们在遍历节点的时候,当enter(进入)到该节点,我们就会调用针对于这个节点的相关函数,同时这个节点和其符节点(当做参考)作为参数传入
const visitor = {
NumberLiteral(node,parent){},
CallExpression(node,parent){}
}
在enter的时候我们会调用访问器,同时在exit(离开)的时候我们也希望能够调用访问器,当我们enter一个节点的时候,最外层节点就相当于一个分支,他是一个节点,这个分支的内部依然存在若干节点,就像我们上边,遍历的那样具体图示如下:
-Program //分支
- CallExpression // 节点....
- NumberLiteral
- CallExpression
- NumberLiteral
- NumberLiteral
我们会按照深度优先的原则,依次遍历到这个分支的最内层,当达到最内层的时候,我们针对当前分支的访问就完成了,我们会依次exit接点,这个过程是由内向外的,具体图示如下
-> Program (enter)
-> CallExpression (enter)
-> Number Literal (enter)
<- Number Literal (exit)
-> Call Expression (enter)
-> Number Literal (enter)
<- Number Literal (exit)
-> Number Literal (enter)
<- Number Literal (exit)
<- CallExpression (exit)
<- CallExpression (exit)
<- Program (exit)
为了能够处理到enter和exit,我们的访问器最终会做成这个样子
const visitor = {
NumberLiteral:{
enter(node, parent) {},
exit(node, parent) {},
}
}
过程三:Code Generation(生成代码)
最后就是代码生成阶段了,这部分工作有些和过程二重合,但是大部分生成代码指的是获取AST(抽象语法树),再将其演变回代码的形式,简而言之大部分的代码生成器主要过程是,不断的访问Transformation生成的AST(抽象语法树)或者再结合tokens,按照指定的规则,将“树”上的节点打印拼接最终还原为新的code,自此编译器的执行过程就结束了
具体实现
了解大致的过程后,现在来实现一个微型的编译器
第一步: 将代码解析为tokens,这个过程我们需要tokenzier(分词器)函数
function tokenizer (input) {
let current = 0; //记录当前访问的位置
let tokens = [] // 最终生成的tokens
// 循环遍历input
while (current < input.length) {
let char = input[current];
// 如果字符是开括号,我们把一个新的token放到tokens数组里,类型是`paren`
if (char === '(') {
tokens.push({
type: 'paren',
value: '(',
});
current++;
continue;
}
// 闭括号做同样的操作
if (char === ')') {
tokens.push({
type: 'paren',
value: ')',
});
current++;
continue;
}
//空格检查,我们关心空格在分隔字符上是否存在,但是在token中他是无意义的
let WHITESPACE = /\s/;
if (WHITESPACE.test(char)) {
current++;
continue;
}
//接下来检测数字,这里解释下 如果发现是数字我们如 add 22 33 这样
//我们是不希望被解析为2、2、3、3这样的,我们要遇到数字后继续向后匹配直到匹配失败
//这样我们就能截取到连续的数字了
let NUMBERS = /[0-9]/;
if (NUMBERS.test(char)) {
let value = '';
while (NUMBERS.test(char)) {
value += char;
char = input[++current];
}
tokens.push({ type: 'number', value });
continue;
}
// 接下来检测字符串,这里我们只检测双引号,和上述同理也是截取连续完整的字符串
if (char === '"') {
let value = '';
char = input[++current];
while (char !== '"') {
value += char;
char = input[++current];
}
char = input[++current];
tokens.push({ type: 'string', value });
continue;
}
// 最后一个检测的是name 如add这样,也是一串连续的字符,但是他是没有“”的
let LETTERS = /[a-z]/i;
if (LETTERS.test(char)) {
let value = '';
while (LETTERS.test(char)) {
value += char;
char = input[++current];
}
tokens.push({ type: 'name', value });
continue;
}
// 容错处理,如果我们什么都没有匹配到,说明这个token不在我们的解析范围内
throw new TypeError('I dont know what this character is: ' + char);
}
return tokens
}
第二步: 我们将生成好的tokens转化为AST,我们现在定义parser函数,接收我们上一步处理好的tokens
function parser (tokens) {
let current = 0; //访问tokens的下标
//walk函数辅助我们遍历整个tokens
function walk () {
let token = tokens[current]
// 现在就是遍历出每一个token,根据其类型生成对应的节点
if (token.type === 'number') {
current++
return {
type: 'NumberLiteral',
value: token.value
}
}
if (token.type === 'string') {
current++;
return {
type: 'StringLiteral',
value: token.value,
};
}
//这里处理调用语句
if (token.type === 'paren' && token.value === "(") {
token = token[++current]
//这里以一个例子解释(add 2 3) 这样的代码 "(" 就是 paren token ,而接下来的node其实就是那个 name 类型的token "add"
let node = {
type: "CallExpression",
value: token.value,
params: []
}
//获取name后我们需要继续获取接下来调用语句中的参数,直到我们遇到了")",这里会存在嵌套的现象如下
// (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: ')' },
]
*/
token = tokens[++current];
//这里我们通过递归调用不断的读取参数
while (
(token.type !== 'paren') || (token.type === 'paren' && token.value !== ')')
) {
node.params.push(walk())
token = tokens[current] //因为参数的if判断里会让 current++ 实际上就是持续向后遍历了tokens,然后将参数推入params
}
// 当while中断后就说明参数读取完了,现在下一个应该是")",所以我们++越过
current++
return node // 最终将CallExpression节点返回了
}
//当然这里做了容错处理,如果没有匹配到预计的类型,就说明出现了,parse无法识别的token
throw new TypeError(token.type);
}
// 现在我们创建AST,树的最根层就是Program
let ast = {
type: 'Program',
body: [],
};
//然后我们通过调用walk遍历tokens将tokens内的对象,转化为AST的节点,完成AST的构建
while (current < tokens.length) {
ast.body.push(walk());
}
return ast;
}
第三步: 遍历和访问生成好的AST
现在我们已经有AST了,然后我们希望能够通过访问器访问不同的节点,当我们遇到不同的节点的时候,调用访问器的不同函数
// traverse(ast,visitor) 迭代器(抽象语法树,访问器)
traverse(ast, {
Program: {
enter(node, parent) {
// ...
},
exit(node, parent) {
// ...
},
},
CallExpression: {
enter(node, parent) {
// ...
},
exit(node, parent) {
// ...
},
},
NumberLiteral: {
enter(node, parent) {
// ...
},
exit(node, parent) {
// ...
},
}
})
接下来是traverse函数
function traverse (ast, visitor) {
//遍历数组,在遍历数组的同时会调用traverseNode来遍历节点
function traverseArray (array, parent) {
array.forEach(child => {
traverseNode(child, parent)
});
}
function traverseNode (node, parent) {
// 判断访问器中是否有合适处理该节点的函数
let methods = visitor[node.type];
// 如果有就执行enter函数,因为此时已经进入这个节点了
if (methods && methods.enter) {
methods.enter(node, parent);
}
//接下来就根据node节点类型来处理了
switch (node.type) {
case 'Program':
traverseArray(node.body, node); //如果你是ast的根部,就相当于树根,body中的每一项都是一个分支
break;
case 'CallExpression':
traverseArray(node.params, node); //这个和Program一样处理,但是这里是为了遍历params,上面是为了遍历分支
break;
// 字符串和数字没有子节点需要访问直接跳过
case 'NumberLiteral':
case 'StringLiteral':
break;
// 最后容错处理
default:
throw new TypeError(node.type);
}
// 当执行到这里时,说明该节点(分支)已经遍历到尽头了,执行exit
if (methods && methods.exit) {
methods.exit(node, parent);
}
}
//我们从ast开始进行节点遍历,因为ast没有父节点所以传入null
traverseNode(ast, null);
}
第四步: transformer转化
现在我们已经生成好AST了。在这一步需要使用到转换器,帮我们刚才生成的AST转化为新的AST
记得我们的案例
LISP 代码 (add 2 (subtract 4 2))
C 代码 add(2, subtract(4, 2))
转化为AST后(下面这个AST我直接截取了项目的展示)
* Original AST | Transformed AST
* ----------------------------------------------------------------------------
* { | {
* type: 'Program', | type: 'Program',
* body: [{ | body: [{
* type: 'CallExpression', | type: 'ExpressionStatement',
* name: 'add', | expression: {
* params: [{ | type: 'CallExpression',
* type: 'NumberLiteral', | callee: {
* value: '2' | type: 'Identifier',
* }, { | name: 'add'
* type: 'CallExpression', | },
* name: 'subtract', | arguments: [{
* params: [{ | type: 'NumberLiteral',
* type: 'NumberLiteral', | value: '2'
* value: '4' | }, {
* }, { | type: 'CallExpression',
* type: 'NumberLiteral', | callee: {
* value: '2' | type: 'Identifier',
* }] | name: 'subtract'
* }] | },
* }] | arguments: [{
* } | type: 'NumberLiteral',
* | value: '4'
* ---------------------------------- | }, {
* | type: 'NumberLiteral',
* | value: '2'
* | }]
* (sorry the other one is longer.) | }
* | }
* | }]
* | }
我们需要由左边的AST转化为右边的AST,具体代码如下
function transformer (ast) {
// 将要被返回的新的AST
let newAst = {
type: 'Program',
body: [],
};
// 这里相当于将在旧的AST上创建一个_content,这个属性就是新AST的body,因为是引用,所以后面可以直接操作就的AST
ast._context = newAst.body;
// 用之前创建的访问器来访问这个AST的所有节点
traverser(ast, {
// 针对于数字片段的处理
NumberLiteral: {
enter (node, parent) {
// 创建一个新的节点,其实就是创建新AST的节点,这个新节点存在于父节点的body中
parent._context.push({
type: 'NumberLiteral',
value: node.value,
});
},
},
// 针对于文字片段的处理
StringLiteral: {
enter (node, parent) {
parent._context.push({
type: 'StringLiteral',
value: node.value,
});
},
},
// 对调用语句的处理
CallExpression: {
enter (node, parent) {
// 在新的AST中如果是调用语句,type是`CallExpression`,同时他还有一个`Identifier`,来标识操作
let expression = {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: node.name,
},
arguments: [],
};
// 在原来的节点上再创建一个新的属性,用于存放参数
node._context = expression.arguments;
// 这里需要判断父节点是否是调用语句,如果不是,那么就使用`ExpressionStatement`将`CallExpression`包裹,因为js中顶层的`CallExpression`是有效语句
if (parent.type !== 'CallExpression') {
expression = {
type: 'ExpressionStatement',
expression: expression,
};
}
parent._context.push(expression);
},
}
});
return newAst;
}
最后一步: 新代码生成
到这一步就是用新的AST,遍历其每一个节点,根据指定规则生成最终新的代码
function codeGenerator(node) {
// 我们以节点的种类拆解(语法树)
switch (node.type) {
// 如果是Progame,那么就是AST的最根部了,他的body中的每一项就是一个分支,我们需要将每一个分支都放入代码生成器中
case 'Program':
return node.body.map(codeGenerator)
.join('\n');
// 如果是声明语句注意看新的AST结构,那么在声明语句中expression,就是声明的标示,我们以他为参数再次调用codeGenerator
case 'ExpressionStatement':
return (
codeGenerator(node.expression) +
';' //
//
);
// 如果是调用语句,我们需要打印出调用者的名字加括号,中间放置参数如生成这样"add(2,2)",
case 'CallExpression':
return (
codeGenerator(node.callee) + //callee就是调用标示add
'(' + // 加括号
node.arguments.map(codeGenerator) //拼接参数
.join(', ') + // 加括号
')'
);
// 如果是识别就直接返回值 如: (add 2 2),在新AST中 add就是那个identifier节点
case 'Identifier':
return node.name;
// 如果是数字就直接返回值
case 'NumberLiteral':
return node.value;
// 如果是文本就给值加个双引号
case 'StringLiteral':
return '"' + node.value + '"';
// 容错处理
default:
throw new TypeError(node.type);
}
}
最终导出
最终我们将按照上面的步骤实现compiler完成这个微型编译器,注意这个过程的顺序
function compiler(input) {
let tokens = tokenizer(input);
let ast = parser(tokens);
let newAst = transformer(ast);
let output = codeGenerator(newAst);
return output;
}
现在可以将所以东西导出了
module.exports = {
tokenizer,
parser,
traverser,
transformer,
codeGenerator,
compiler,
};