前言
原本是想了解一下balel,发现这玩意本质就是一个编译器或者是代码转换器。然后正好看到了 the-super-tiny-compiler,这个项目就是以教程的形式来完成一个微型编译器。
然后根据它的教程,本人翻译外加自己的一点点理解写了第一篇文章。
概念
编译器工作主要步骤:
- Parsing:将原始代码解析成代码的抽象表示(AST)。
- Transformation :接受这个抽象表示并进行操作以执行编译器想要的任何操作。
- Code Generation :获取代码的转换表示并将其转换为新代码。
Parsing
解析(Parsing)通常分为两个阶段:词法分析( Lexical Analysis)和句法分析(Syntactic Analysi)。
Lexical Analysis
词法分析将原始代码分割并转换为token
Tokens 是一组微小的对象,描述了语法的一个独立部分。 它们可以是数字、标签、标点符号、运算符等等。
Syntactic Analysis
Syntactic Analysis takes the tokens and reformats them into a representation that describes each part of the syntax and their relation
语法分析将tokens格式化为可以表现其语法和他们之间关系的抽象表示,可以称其为抽象语法树(Abstract Syntax Tree)
AST是一种深度嵌套的对象,它以易于使用并告诉我们大量信息的方式表示代码。
Tokens:
[
{ 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',
}]
}]
}]
}
Transformation
编译器的下一个阶段是转换(Transformation)。 同样,这只是从Parsing获取 AST 并对其进行更改。 它可以用同一种语言操作 AST,也可以将其翻译成一种全新的语言。
AST内有结构相似的许多元素,这些节点具有定义的属性,这些对象具有类型属性。 其中每一个都称为 AST 节点,用于描述树的一个孤立部分。
"NumberLiteral":
{
type: 'NumberLiteral',
value: '2',
}
"CallExpression":
{
type: 'CallExpression',
name: 'subtract',
params: [...nested nodes go here...],
}
我们可以通过增加、去除、替换AST节点属性,来增加节点、去除节点或者根据已有节点生成新节点。
由于我们的目标可能是一种新的语言,所以我们需要根据具体的新语言创建全新的AST。
Traversal
为了知道所有这些节点,我们需要能够遍历它们。 这个遍历过程以深度优先的方式到达 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'
}]
}]
}]
}
树结构如下
- Program
- CallExpression (add)
- NumberLiteral (2)
- CallExpression (subtract)
- NumberLiteral (4)
- NumberLiteral (2)
对于以上AST,我们的遍历顺序是
- Program
- CallExpression (add)
- NumberLiteral (2)
- CallExpression (subtract)
- NumberLiteral (4)
- NumberLiteral (2)
Visitors
这里用到一种设计模式——访问者模式(Visitor Pattern)。
这里的基本思想是,我们将创建一个“visitor”对象,该对象具有接受不同节点类型的方法。
var visitor = {
NumberLiteral: {
enter(node, parent) {},
exit(node, parent) {},
},
CallExpression: {
enter(node, parent) {},
exit(node, parent) {},
}
};
当我们遍历 AST 时,每当我们“enter”匹配类型的节点时,我们都会调用该visitor的方法。
当我们的树如下所示时。
- Program
- CallExpression
- NumberLiteral
- CallExpression
- NumberLiteral
- NumberLiteral
当我们向下走时,我们将到达有死胡同的树枝。 当我们完成树的每个分支时,我们就“退出”它。 因此,沿着树向下走,我们“进入”每个节点,然后向上走,我们“退出”。
-> 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)
可能会有人不懂为啥要用这个visitor遍历一遍??enter函数具体是用来干啥的??
回顾一下之前说的:
“由于我们的目标可能是一种新的语言,所以我们需要根据具体的新语言创建全新的AST。”
我们的目的不是对原有的树进行修改,而是根据旧AST构建新AST。
Code Generation
代码生成器以多种不同的方式工作,一些编译器将重用之前的标记(tokens),其他编译器将创建代码的单独表示形式,以便它们可以线性打印节点,但据我所知,大多数编译器将使用我们刚刚创建的相同 AST, 这就是我们要关注的重点。
实际上,我们的代码生成器将知道如何“打印”AST 的所有不同节点类型,并且它将递归调用自身来打印嵌套节点,直到所有内容都打印成一长串代码。
实战
接下来需要实现一个简单编译器,功能是将LISP的代码转换为我们常用的类C语言的形式。
| LISP | C | |
|---|---|---|
| 2 + 2 | (add 2 2) | add(2, 2) |
| 4 - 2 | (subtract 4 2) | subtract(4, 2) |
| 2 + (4 - 2) | (add 2 (subtract 4 2)) | add(2, subtract(4, 2)) |
按照之前说的概念我们可以将模块进行拆分,最终可以分别实现以下4个函数:
- tokenizer
- parser
- transformer
- codeGenerator
tokenizer
所有需要转换的代码我们都可以理解为一个长文本字符串,该函数的作用是将输入的字符串转换成tokens。
input:代码文本
output:tokens
函数框架如下所示:
function tokenizer(input) {
let current = 0;
let tokens = [];
while (current < input.length) {
let char = input[current];
// 然后判断char是什么来做不同的事
...
}
return tokens;
}
然后我们来判断字符类型,先按简单的来,一段代码中我们一般会有以下几种类型字符:
- 括号——"("、")",先不考虑中括号、大括号等。
- 空格,代码分隔符,生成token过程中可以直接跳过
- 数字,假设只有整数
- 字符串,必须用双引号包裹("")
- 变量,假设只有小写字母
括号
这里只需提取字符,所以左右括号一样
if (char === '(' || char === ')') {
tokens.push({
type: 'paren',
value: char,
});
current++;
continue;
}
空格
使用正则表达式匹配空格
let WHITESPACE = /\s/;
if (WHITESPACE.test(char)) {
current++;
continue;
}
数字
使用正则表达式匹配数字。
注意:1、123、321654这些都算一个数字。
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;
}
变量
由小写字母组成的变量名也由正则表达式提取。
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;
}
Parser
将tokens解析为抽象语法树
input:tokens
output:AST
解析函数的整体如下所示,非常的简短,本质上就是遍历tokens然后将其添加到ast.body中。具体的节点是怎么生成的关键在walk函数。
function parser(tokens) {
let current = 0;
function walk() {}
let ast = {
type: 'Program',
body: [],
};
while (current < tokens.length) {
// walk??
ast.body.push(walk());
}
return ast;
}
walk函数的作用是遍历tokens生成树形节点。
对于number和string类型的token只需直接返回节点对象。
对于表达式的解析会比以上两者稍微复杂点。在LISP中表达式都是由括号包裹组成的,所以一旦找到第一个左括号就意味着接下来是段调用表达式。回顾一下LISP和C对比的表格,不难发现LISP的调用表达式由三部分组成:调用函数名、左值、右值。(没用过LISP,这里讲的不对的地方可以帮忙指出)
function walk() {
let token = tokens[current];
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 = tokens[++current];
let node = {
type: 'CallExpression',
name: token.value,
params: [],
};
// 下一个token一般为左值
token = tokens[++current];
// 寻找与当前左括号对应的右括号,递归可以会帮我们处理括号内部其余的括号
while (
(token.type !== 'paren') ||
(token.type === 'paren' && token.value !== ')')
) {
node.params.push(walk());
// 这里current不需要加,因为walk函数结束都会自动+1
token = tokens[current];
}
current++;
return node;
}
throw new TypeError(token.type);
}
transformer
转换主要做两件事:1. 遍历旧树,2. 遍历过程中构建新树
input:ast
output:newAst
traverser
按照深度优先遍历整个ast,其代码如下所示。
如果节点是一段程序或者一个调用表达式那么可能会有子节点,递归调用traverseNode遍历其子节点。
function traverser(ast, visitor) {
function traverseArray(array, parent) {
array.forEach(child => {
traverseNode(child, parent);
});
}
function traverseNode(node, parent) {
let methods = visitor[node.type];
if (methods && methods.enter) {
methods.enter(node, parent);
}
switch (node.type) {
case 'Program':
traverseArray(node.body, node);
break;
case 'CallExpression':
traverseArray(node.params, node);
break;
case 'NumberLiteral':
case 'StringLiteral':
break;
default:
throw new TypeError(node.type);
}
if (methods && methods.exit) {
methods.exit(node, parent);
}
}
traverseNode(ast, null);
}
以上代码只是在遍历,更加关键的一个问题是遍历过程中中enter到底是干嘛的,当然是为了生成新节点,具体怎么做呢。
这里主要是传入vistor对象,就是之前提到的观察者。visitor需要根据不同的节点类型做不同的事。
function transformer(ast) {
let newAst = {
type: 'Program',
body: [],
};
ast._context = newAst.body;
traverser(ast, {
NumberLiteral: {
enter(node, parent) {
parent._context.push({
type: 'NumberLiteral',
value: node.value,
});
},
},
StringLiteral: {
enter(node, parent) {
parent._context.push({
type: 'StringLiteral',
value: node.value,
});
},
},
CallExpression: {
enter(node, parent) {
let expression = {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: node.name,
},
arguments: [],
};
node._context = expression.arguments;
if (parent.type !== 'CallExpression') {
// 如果当前表达式的父级节点不是表达式
// 那么这整段表达式都将作为一个表达式语句整体
expression = {
type: 'ExpressionStatement',
expression: expression,
};
}
parent._context.push(expression);
},
}
});
return newAst;
}
codeGenerator
input:newAst
output:code
代码生成器也通过递归调用得到完整的代码字符串。
function codeGenerator(node) {
switch (node.type) {
// 程序段:直接对其所有子节点递归
case 'Program':
return node.body.map(codeGenerator)
.join('\n');
// 表达式语句:通过递归将表达式子节点展开
case 'ExpressionStatement':
return (
codeGenerator(node.expression) +
';'
);
// 调用表达式:通过递归将表达式子节点展开
case 'CallExpression':
return (
codeGenerator(node.callee) +
'(' +
node.arguments.map(codeGenerator)
.join(', ') +
')'
);
// 标识符:返回标识符名称,一般是函数名
case 'Identifier':
return node.name;
case 'NumberLiteral':
return node.value;
case 'StringLiteral':
return '"' + node.value + '"';
default:
throw new TypeError(node.type);
}
}
compiler
然后就可以把这些模块组合起来完成tiny-compiler啦
function compiler(input) {
const tokens = tokenizer(input);
const ast = parser(tokens);
const newAst = transformer(ast);
const output = codeGenerator(newAst);
return output;
}
写在最后
源码大家可以直接去github原作者那查看,代码都很短