本文是对the-super-tiny-compiler仓库的翻译,原文章(代码):https://github.com/jamiebuild…
明天咱们一起入手写一个编译器,但不是咱们平时所说的编译器,而是一个超级超级小的编译器,小到如果你把本文件的所有正文都删了,真正的代码也就200多行。
咱们将把lisp
格调的函数调用编译成C
格调的函数调用,如果你对这两个不相熟的话,让我来简略介绍一下。
如果咱们有两个函数:add
和subtract
,它们会写成上面的样子:
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))
是不是很简略?
很好,这就是咱们要编译的,尽管这并不是一个残缺的LISP
或C
语法,然而这小局部的语法足以向咱们展现一个古代编译器的次要局部。
大多数的编译器都会分成三个次要的阶段:解析(Parsing)、转换(Transformation)以及生成代码(Code Generation)。
1.Parsing会将源代码转换成更形象的代码示意;
2.Transformation会对这个形象的代码示意进行任何它想要的操作;
3.Code Generation会把操作完的代码形象示意生成新代码;
解析(Parsing)
解析通常分为两个阶段:词法剖析和语法分析。
1.词法剖析会应用一个叫做分词器(tokenizer)的货色来把源代码切割成一个个叫做标记(token)的货色;
tokens
是一个数组,外面每项都是用来形容语法中一个独立块的最小对象,它们能够是数字、标签、标点、运算符等等。
2.语法分析会把标记重新组合,用来形容语法的每个局部,并建设起它们之间的分割,这个别被称作为“形象语法树”。
一个形象语法树(简称为AST
),是一个深层嵌套的对象,以一种又简略又能通知咱们大量信息的形式来示意代码。
对于上面的语法:
(add 2 (subtract 4 2))
token
列表是上面这样的:
[
{ 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)
编译器的下一个阶段是转换,再强调一次,这个阶段只是把上个阶段生成的AST
拿来进行一些批改,它能够放弃原来的语言,也能够把它翻译成全新的语言。
让咱们看看如何转换AST
。
你可能会留神到咱们AST
里的元素看起来都十分类似,这些对象都有一个type
属性,每个节点都被称为AST
节点,这些节点上都定义了一些属性,用来形容树的一个局部。
咱们能够为NumberLiteral
创立一个节点:
{
type: 'NumberLiteral',
value: '2',
}
或者为CallExpression
创立一个节点:
{
type: 'CallExpression',
name: 'subtract',
params: [...嵌套节点...],
}
当转换AST
的时候,咱们能够通过这些形式来操作节点:增加
、移除
、替换
属性,咱们能够增加新节点,或者咱们能够不论现有的AST
,间接在它的根底上创立一个新的AST
。
因为咱们的指标是一个新语言,所以咱们将基于目标语言创立一个全新的AST
。
遍历(Traversal)
为了在所有节点中穿梭,咱们须要可能遍历它们,这个遍历的过程会以深度优先的形式达到每个节点。
{
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
,咱们将顺次拜访:
1.Program – 从
AST
的顶层开始2.CallExpression (add) – 挪动到Program的body列表的第一个元素
3.NumberLiteral (2) – 挪动到CallExpression的params列表的第一个元素
4.CallExpression (subtract) – 挪动到CallExpression的params列表的第二个元素
5.NumberLiteral (4) – 挪动到CallExpression (subtract)的params列表的第一个元素
6.NumberLiteral (2) – 挪动到CallExpression (subtract)的params列表的第二个元素
如果咱们间接操作这个AST
,而不是从新创立一个,咱们可能会在这里引入各种抽象概念。但其实间接拜访(visiting)树的每个节点就够咱们应用了。
我之所以应用“拜访”(visiting)这个词,是因为这里存在这样一种模式,即如何示意对对象构造上的元素的操作。
访问者(Visitors)
基本思路是创立一个visitor
拜访器对象,提供一些承受不同节点类型的办法。
var visitor = {
NumberLiteral() {},
CallExpression() {},
};
当咱们遍历AST
,每当遇到一个匹配的节点时,咱们会调用这个拜访器上对应节点类型的办法。
为了能让这些办法更有用,咱们会传入两个参数,以后遍历到的节点,以及它的父节点。
var visitor = {
NumberLiteral(node, parent) {},
CallExpression(node, parent) {},
};
然而,当退出时也存在须要拜访的可能性,设想一下咱们之前列表模式的树结构:
- 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)
为了反对这种状况,最终的拜访器是这样的:
var visitor = {
NumberLiteral: {
enter(node, parent) {},
exit(node, parent) {},
}
};
生成代码(Code Generation)
编译器的最初一个阶段是生成代码,有时编译器会做一些和转换重合的事件,但大多数状况下,生成代码只是意味着把AST
转换回代码字符串。
代码生成器有几种不同的工作形式,一些编译器会重用之前的token
,其余的会创立一个独立的代码示意,这样就能够线性的打印节点,但据我所知,大多数的都会间接应用咱们刚刚创立的AST
,咱们也会这么干。
实际上咱们的代码生成器晓得如何去打印AST
上所有不同类型的节点,它会递归调用本人去打印所有嵌套节点,直到所有内容都被打印到一个长长的代码字符串中。
小结一下
下面就是咱们要做的编译器,它蕴含了一个真正编译器的所有局部。
但这并不意味着所有编译器都和我下面形容的一样,每个编译器可能都有不同的用处,所以它们除了我下面提到的内容外,可能它们还会有更多的步骤。
然而你当初应该会对大多数编译器有一个总体的根本的意识。
既然我曾经把编译器的内容都介绍完了,当初你是否能本人写一个编译器了呢?
开个玩笑了,上面让我来帮你一起实现它。
开始吧。。。
代码实现
分词器
咱们将从解析的第一个阶段开始,应用分词器进行词法剖析。
咱们要做的只是把代码字符串分解成一个token数组:
(add 2 (subtract 4 2)) => [{ type: 'paren', value: '(' }, ...]
函数接管一个代码字符串为入参,咱们要做两件事:
function tokenizer(input) {
// `current`变量就像一个游标,跟踪咱们在代码中以后的地位
let current = 0;
// `tokens`数组用来寄存生成的token
let tokens = [];
// 咱们从创立一个while循环开始,在循环中会依照咱们想要的递增量来更新current
// 这样做是因为可能一个循环里会屡次更新current,因为一个token的长度是任意的
while (current < input.length) {
// 以后地位的字符
let char = input[current];
// 首先要查看的是左括号`(`,前面会用于`CallExpression`,然而当初咱们只关怀字符
// 查看是否是左括号:
if (char === '(') {
// 如果匹配到了,增加一个类型为`paren`的token,设置它的值为`(`
tokens.push({
type: 'paren',
value: '(',
});
// 递增`current`
current++;
// 跳过以后循环,进入下一个循环
continue;
}
// 接下来查看是否是右括号`)`,和方才一样:匹配到右括号,增加一个新的token,递增current,最初跳过以后循环进入下一个循环
if (char === ')') {
tokens.push({
type: 'paren',
value: ')',
});
current++;
continue;
}
// 持续,接下来咱们要查看的是空白符,空白符是用来分隔字符的,但它实际上并不重要,所以不会把它当做一个token进行增加
// 所以这里咱们仅仅查看是否匹配到了空白符,匹配到了就跳过
let WHITESPACE = /\s/;
if (WHITESPACE.test(char)) {
current++;
continue;
}
// 下一个token类型是number,这和之前的几种不一样,因为数字可能有任意长度,咱们须要把数字整体作为一个token进行增加
//
// (add 123 456)
// ^^^ ^^^
// 尽管有六个字符,然而只算两个独自的token
//
// 当遇到序列中的第一个数字时,咱们就开始了...
let NUMBERS = /[0-9]/;
if (NUMBERS.test(char)) {
// 创立一个value变量,用来保留整个数字
let value = '';
// 接下来遍历这之后的每一个字符,直到遇到非数字字符
while (NUMBERS.test(char)) {
// 拼接以后数字
value += char;
// 更新current,挪动到下一个字符
char = input[++current];
}
// 之后咱们增加一个number类型的token
tokens.push({ type: 'number', value });
// 持续
continue;
}
// 咱们也要减少对字符串的反对,即任何被双引号包裹起来的字符(")
//
// (concat "foo" "bar")
// ^^^ ^^^ 字符串类型的token
//
// 咱们先检查一下结尾的引号("):
if (char === '"') {
// 创立一个value变量用来保留token的值
let value = '';
// 跳过结尾的双引号
char = input[++current];
// 遍历之后的每一个字符,直到遇到结尾的双引号
while (char !== '"') {
// 更新value
value += char;
// 移到下一个字符
char = input[++current];
}
// 跳过结尾的双引号
char = input[++current];
// 增加一个string类型的token
tokens.push({ type: 'string', value });
continue;
}
// 还剩最初一种`name`类型的token,这是一个字母模式的字符,不是数字,作为咱们的lisp语法里的函数名
//
// (add 2 4)
// ^^^
// Name token
//
let LETTERS = /[a-z]/i;
if (LETTERS.test(char)) {
let value = '';
// 同样的,还是循环遍历之后的所有字符
while (LETTERS.test(char)) {
value += char;
char = input[++current];
}
// 增加一个`name`类型的token,而后持续到下一个循环
tokens.push({ type: 'name', value });
continue;
}
// 最初,如果到这里还有咱们没有匹配到的字符,那就相当于语法有误,咱们搞不定了,那么就间接抛错而后停止循环
throw new TypeError('I dont know what this character is: ' + char);
}
// 最初的最初,咱们的分词器只有返回token列表就能够了
return tokens;
}
解析器
对于解析器来说,要做的是把token
列表转换成AST
:
[{ type: 'paren', value: '(' }, ...] => { type: 'Program', body: [...] }
定义一个parser
函数,接管token
列表作为参数:
function parser(tokens) {
// 同样的,咱们保护一个`current`变量作为游标
let current = 0;
// 然而这里咱们将应用递归,而不是while循环,定义一个递归函数
function walk() {
// 先获取并保留以后地位的token
let token = tokens[current];
// 咱们将把每种类型的token分成不同的代码门路,从`number`类型的token开始
//
// 判断是否是一个`number`类型的token
if (token.type === 'number') {
// 如果是的话,先递增一下current
current++;
// 返回一个新的AST节点,类型是`NumberLiteral`,它的value就是token的value
return {
type: 'NumberLiteral',
value: token.value,
};
}
// `string`类型和`number`类型一样,创立一个`StringLiteral`类型的节点并返回
if (token.type === 'string') {
current++;
return {
type: 'StringLiteral',
value: token.value,
};
}
// 接下来,咱们要找的是`CallExpressions`,这从咱们遇到左括号开始
if (
token.type === 'paren' &&
token.value === '('
) {
// 递增current,跳过左括号,因为它在AST里不须要
token = tokens[++current];
// 创立一个根底的`CallExpression`节点,而后把值设置为以后token的value,因为左括号的左边紧接着就是函数名
let node = {
type: 'CallExpression',
name: token.value,
params: [],
};
// 递增current跳过函数名token
token = tokens[++current];
// 接下来遍历前面的节点作为调用表达式`CallExpression`的参数`params`,直到遇到右括号
//
// 这就是递归的用途,咱们将依赖递归来解析一组可能有限嵌套的节点
//
// 为了解释这一点,让咱们再看看Lisp代码,你能够看到`add`办法有一个数字参数和一个嵌套的`CallExpression`,同样它又存在两个数字参数:
//
// (add 2 (subtract 4 2))
//
// 你也会留神到token列表中存在多个右括号:
//
// [
// { 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: ')' }, <<< 右括号
// ]
//
// 咱们将依赖嵌套的`walk`函数来递增`current`,直到所有的`CallExpression`之后
// 因而咱们创立一个`while`循环,递归调用`walk`,直到遇到右括号
// 译者注:这里其实就是考查递归思维,如果一个工作能够拆解成更小的子工作,且子工作和大工作的逻辑是一样的就能够应用递归,对于这里来说,add函数的参数的类型是任意的,能够是数字,能够是字符串,也能够是另外一个函数,另一个函数又会遇到和add函数一样的问题,所以间接交给递归函数执行,对于add来说,你只有返回AST节点就能够了。
while (
(token.type !== 'paren') ||
(token.type === 'paren' && token.value !== ')')
) {
// 调用递归函数,它将返回一个AST节点,增加到以后的`params`列表里
node.params.push(walk());
token = tokens[current];
}
// 递增current,用来跳过右括号
current++;
// 返回节点
return node;
}
// 同样的,如果遇到咱们无奈辨认的token就抛错
throw new TypeError(token.type);
}
// 创立一个`AST`的根节点`Program`
let ast = {
type: 'Program',
body: [],
};
// 接下来开启一个循环,来增加节点到`ast.body`数组里
// 这里应用循环是因为可能有多个并列的`CallExpression`
//
// (add 2 2)
// (subtract 4 2)
//
while (current < tokens.length) {
ast.body.push(walk());
}
// 最初返回ast即可
return ast;
}
遍历
到这里咱们曾经有AST
了,咱们想能通过拜访器来拜访不同类型的节点。咱们须要可能在遇到匹配类型的节点时调用拜访器上的办法。
traverse(ast, {
Program: {
enter(node, parent) {
// ...
},
exit(node, parent) {
// ...
},
},
CallExpression: {
enter(node, parent) {
// ...
},
exit(node, parent) {
// ...
},
},
NumberLiteral: {
enter(node, parent) {
// ...
},
exit(node, parent) {
// ...
},
},
});
所以咱们定义一个traverser
函数,接管一个AST
和一个拜访器,外部还会再定义两个函数…
function traverser(ast, visitor) {
// `traverseArray`函数用来遍历数组,外面会调用上面定义的`traverseNode`函数
function traverseArray(array, parent) {
array.forEach(child => {
traverseNode(child, parent);
});
}
// `traverseNode`接管一个`node`和它的父节点
function traverseNode(node, parent) {
// 首先确认匹配到的`type`是否在拜访器里有对应办法
let methods = visitor[node.type];
// 如果存在`enter`办法,那么就调用它,传入以后节点和父节点
if (methods && methods.enter) {
methods.enter(node, parent);
}
// 接下来依据类型类型来别离解决
switch (node.type) {
// 从顶层节点`Program`开始,因为Program节点的属性`body`是数组类型,所以调用`traverseArray`办法来遍历
// (记住`traverseArray`办法外部会顺次调用`traverseNode`,所以会递归遍历树)
case 'Program':
traverseArray(node.body, node);
break;
// `CallExpression`类型也是一样的,只不过遍历的是它的`params`属性
case 'CallExpression':
traverseArray(node.params, node);
break;
// `NumberLiteral`和`StringLiteral`类型的节点没有子节点,所以间接跳过
case 'NumberLiteral':
case 'StringLiteral':
break;
// 还是同样的,如果呈现了咱们无奈辨认的节点就抛错
default:
throw new TypeError(node.type);
}
// 如果存在`exit`办法,在这里调用,传入`node`和它的`parent`
if (methods && methods.exit) {
methods.exit(node, parent);
}
}
// 最初咱们调用`traverseNode`来开启遍历,传入ast,因为顶层节点没有`parent`,所以传null
traverseNode(ast, null);
}
译者注:这个办法其实就是树的深度优先遍历,而后在前序遍历的地位调用拜访器的enter
办法,在后序遍历地位调用拜访器的exit
办法。
转换
接下来,转换器(transformer),它会把咱们构建的AST
,再加上一个拜访器visitor
,一起传给traverser
函数,而后返回一个新的AST
。
----------------------------------------------------------------------------
原 AST | 转换后的 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'
| }]
(不好意思,左边的比拟长) | }
| }
| }]
| }
----------------------------------------------------------------------------
所以咱们的transformer
函数会承受一个lisp
的AST
作为参数:
(译者注:要了解上面这个函数,还是先要搞清楚从旧的到新的都做了哪些转换,回到下面的比照,能够看到CallExpression
节点的type
没变,然而把name
属性批改成了callee
,另外参数列表由params
变成了arguments
,最初如果CallExpression
节点的父节点不是CallExpression
节点的话那么会创立一个ExpressionStatement
节点来包裹,所以转换过程是这样的,咱们首先创立一个新的AST
根节点,然而咱们遍历的是旧的AST
,所以怎么能在新的AST
上增加节点呢,能够通过在旧的AST
节点上创立一个属性来援用新的AST
上的列表属性,这样就能够在遍历旧的树时往新的树的列表里增加节点。)
function transformer(ast) {
// 新AST,和之前的AST一样,也要有一个Program节点
let newAst = {
type: 'Program',
body: [],
};
// 接下来我要做一个小改变,在父节点上增加一个`context`属性,而后会把每个节点都增加到它们父节点的`context`里,通常状况下你会有一个更好的形象,然而为了咱们的目标,这样做更简略
//
// 须要留神的是旧的AST里的context属性只是新AST属性的一个援用
ast._context = newAst.body;
// 接下来调用traverser办法,传入AST和一个拜访器对象
traverser(ast, {
// 第一个访问者接管`NumberLiteral`类型的节点
NumberLiteral: {
// 进入时
enter(node, parent) {
// 创立一个新的`NumberLiteral`节点,增加到父节点的context里
parent._context.push({
type: 'NumberLiteral',
value: node.value,
});
},
},
// 接下来是`StringLiteral`
StringLiteral: {
enter(node, parent) {
parent._context.push({
type: 'StringLiteral',
value: node.value,
});
},
},
// 而后是`CallExpression`
CallExpression: {
enter(node, parent) {
// 创立一个新节点`CallExpression`,外面嵌套一个`Identifier`节点
let expression = {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: node.name,
},
arguments: [],
};
// 接下来咱们给原`CallExpression`节点定义一个新的context属性,援用咱们方才新创建的节点的arguments属性,这样在遍历旧节点的参数时就能够给新的节点增加参数了
node._context = expression.arguments;
// 接下来检查一下父节点是否是`CallExpression`节点
// 如果不是的话...
if (parent.type !== 'CallExpression') {
// 创立一点`ExpressionStatement`节点来包裹`CallExpression`节点,这样做是因为顶层的`CallExpression`在JavaScript里实际上是语句
expression = {
type: 'ExpressionStatement',
expression: expression,
};
}
// 最初,把(可能是被包裹的) `CallExpression`节点增加到父节点的`context`里
parent._context.push(expression);
},
}
});
// 函数的最初返回新创建的AST
return newAst;
}
生成代码
当初让咱们来看最初一个阶段:生成代码。
咱们的代码生成器会递归的调用本人,把树中的每个节点都打印到一个微小的字符里。
function codeGenerator(node) {
// 咱们将按节点类型进行别离解决
switch (node.type) {
// 如果是`Program`节点,那就遍历它的`body`列表,对每个节点调用codeGenerator办法,而后把它们用换行符拼接起来
case 'Program':
return node.body.map(codeGenerator)
.join('\n');
// 对于`ExpressionStatement`节点,对它的expression节点调用对每个节点调用codeGenerator办法办法,而后再增加一个分号...
case 'ExpressionStatement':
return (
codeGenerator(node.expression) +
';' // << (...在一个语句的开端增加分号是符合标准的)
);
// 对于`CallExpression`节点,咱们要打印的是`callee`,而后拼接一个左括号,而后遍历参数`arguments`的每个节点,调用codeGenerator办法把它们转成字符串,而后用逗号拼接起来,最初再增加一个右括号
case 'CallExpression':
return (
codeGenerator(node.callee) +
'(' +
node.arguments.map(codeGenerator)
.join(', ') +
')'
);
// 对于`Identifier`节点,只有返回name属性的值即可
case 'Identifier':
return node.name;
// 对于`NumberLiteral`节点,返回它的value属性值
case 'NumberLiteral':
return node.value;
// 对于`StringLiteral`节点,须要应用双引号来包裹它的value值
case 'StringLiteral':
return '"' + node.value + '"';
// 如果遇到无奈辨认的节点,那么抛错
default:
throw new TypeError(node.type);
}
}
最终的编译器~
最初让咱们来创立一个compiler
函数,在这个函数里把下面的所有流程串起来:
1. input => tokenizer => tokens
2. tokens => parser => ast
3. ast => transformer => newAst
4. newAst => generator => output
function compiler(input) {
let tokens = tokenizer(input);
let ast = parser(tokens);
let newAst = transformer(ast);
let output = codeGenerator(newAst);
// 把代码生成后果返回就ok了
return output;
}
功败垂成
当初,让咱们把下面所有的函数导出:
module.exports = {
tokenizer,
parser,
traverser,
transformer,
codeGenerator,
compiler,
};
总结
正文太多可能影响浏览代码,能够点此浏览纯享版https://github.com/wanglin2/the-super-tiny-compiler/blob/master/the-super-tiny-compiler.js。
本文由博客一文多发平台 OpenWrite 公布!
发表回复