前言
经过初探代码编译一系列文章[公众号:前端小菜鸟001]的介绍与实践,大家应该对 compiler [编译器]的实现与执行过程有了一定的认识与理解了。
下面我们来进一步探究一下 编译器是怎么实现不同语言之间的转换的
。
(语言A => 语言B 并且不影响程序的正确执行结果)
正文
为了更明白更清晰的理解 编译器是怎么实现语言A转换成语言B 的, 下面用一个张概略图说明一下。
(为了方便 本文将语言A 简写为 L-A, 语言B简写为 L-B)
-
1. L-A / L-B
首先来介绍一下,需要转换的 原始语言A 与 目标语言B 的差异:
// L-A
(add 2 (subtract 4 2))
// L-B
add(2, subtract(4, 2))
其对应的 AST 语法树结构:
聪明的人应该可以看出来,在实现对不同语言的代码转换过程中,核心的工作除了常用的的词法分析、语法分析外,还有就是对 AST 的 transform 和 对新生成 AST 的目标代码生成环节。 下面对每一步进行一一介绍。
-
2. 词法分析 (tokenizer)
词法分析的过程 在之前的几篇文章中已经多次进行介绍与实践,
在这我们不对其进行不展开详细讨论。
tokens 如下所示:
const input = '(add 2 (subtract 4 2))';
const 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: ')' }
];
function tokenizer(input) {
let tokens = []
let current = 0;
while(current < input.length) {
let char = input[current]
// '(' ')' 括号
if(char === '(' || char === ')') {
tokens.push({
type: 'para',
value: char
})
current++;
continue;
}
// 空格
let WHITESPACE = /\s/;
if(WHITESPACE.test(char)) {
current++;
continue;
}
// 0-9 数字
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;
}
// string "xx" 字符串
if(char === '"') {
// ...
}
// name
let LETTERS = /[a-z]/i;
if(LETTERS) {
// ...
}
throw new TypeErroe(char)
}
return tokens
}
-
3. 语法分析 (parser)
根据词法分析得到的tokens, 以及上面所描述的 L-A AST的语法结构,可以对 parser 进行编程实现,把 tokens 转换成 L-A 对应的 AST 语法树。
function parser(tokens) {
const current = 0;
// 定义 ast 根结点
let ast = {
type: 'Program',
body: [],
};
while(current < tokens.length) {
ast.body.push(walk());
}
// 定义一个递归调用的`walk`函数
function walk() {
// 获取当前要处理的 token
let token = tokens[current];
// 如果当前值是 number 或者 string 则返回一个ast节点
if (token.type === 'number') {
current++;
return {
type: 'NumberLiteral',
value: token.value,
};
}
if (token.type === 'string') {
current++;
return {
type: 'StringLiteral',
value: token.value,
};
}
// 如果是 '(' 括号则创建一个 `CallExpression` ast 节点
if (token.type === 'paren' && token.value === '(') {
// 获取'('括号后面的 token
token = tokens[++current];
// 创建一个 `CallExpression` ast节点,并把当前token设置为其name
let node = {
type: 'CallExpression',
name: token.value,
params: [],
};
// 获取 name 后面的 token
token = tokens[++current];
// 处理 `CallExpression` 节点的后代节点
while ((token.type !== 'paren') ||
(token.type === 'paren' && token.value !== ')')
) {
// 递归调用`walk`函数,将其返回的节点并挂到node.params
node.params.push(walk());
token = tokens[current];
}
// 跳过 ')' 括号
current++;
return node;
}
}
return ast;
}
// 最终 ast 结果
const 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'
}]
}]
}]
};
至此,我们得到了 L-A 的 AST, 后面将进入极其重要的 transform 环节。
-
4. 转换 (transformer)
转换阶段,这一步是从上面最后一步获取到的 AST 并对其进行更改。 它可以操纵AST 当作相同的语言进行处理,也可以将其翻译成一种全新的语言。
下面看一下 如何转换 AST的。
或许已经发现,在AST 中,包含着看起来都很相似的元素,这些对象都有 type 属性,每一个都属于一个 AST 节点,这些定义了属性的节点在其上都描述了一个树的独立的部分。
在转换AST时,可以通过 添加/删除/替换属性 来操作节点,可以添加新节点、删除节点、或者丢弃现有的 AST,并基于它创建一个全新的 AST。
因为这里的目标是转换成一种新的语言,因此将重点放在创建一种针对目标语言的全新 AST。
为了能访问到所有的节点,需要利用深度优先算法来遍历整个 AST。
如果我们直接手工操作此 AST(增删改),而不是创建单独的AST,则可能会引入各种抽象概念,但是,对于我们这里将要做的工作,只需要 逐个的访问树上每一个节点就够了。
visitor 具体定义如下:
// 定义一个带有 node-types处理的 visitor, 为了更有效这里添加了对父节点的引用
var visitor = {
NumberLiteral(node, parent) {},
CallExpression(node, parent) {},
};
// ast 结构
- Program
- CallExpression
- NumberLiteral
- CallExpression
- NumberLiteral
- NumberLiteral
// ast深度优先算法处理 //回溯
-> 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处理,最终将 visitor定义成这样
var visitor = {
NumberLiteral: {
enter(node, parent) {},
exit(node, parent) {},
}
// ...
};
现在,有了 visitor 我们就可以对不相同的 AST node-type 进行处理了,
下面我们定义一个可以接受AST和访问者的遍历器函数。
function traverser(ast, visitor) {
// 支持数组类型的遍历
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);
}
switch (node.type) {
case 'Program':
traverseArray(node.body, node);
break;
//
case 'CallExpression':
traverseArray(node.params, node);
break;
// `NumberLiteral` 和 `StringLiteral` 类型不做任何 node 处理
case 'NumberLiteral':
case 'StringLiteral':
break;
}
// 节点exit 时钩子
if (methods && methods.exit) {
methods.exit(node, parent);
}
}
// 遍历 ast 节点
traverseNode(ast, null);
}
接下来 实现一个 transformer ,构建一个新的 AST
function transformer(ast) {
// 创建新的 ast根结点
let newAst = {
type: 'Program',
body: [],
};
// 为了方便,这里将 body的引用 copy到 ast._context
ast._context = newAst.body;
// 遍历 ast
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) {
// 创建一个 'CallExpression'
let expression = {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: node.name,
},
arguments: [],
};
// 在'CallExpression' 节点上 定义一个新的引用
node._context = expression.arguments;
//
if (parent.type !== 'CallExpression') {
// 用`ExpressionStatement`包装一下`CallExpression`节点。
expression = {
type: 'ExpressionStatement',
expression: expression,
};
}
parent._context.push(expression);
}
}
})
return newAst;
}
const newAst = {
type: 'Program',
body: [{
type: 'ExpressionStatement',
expression: {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: 'add'
},
arguments: [{
type: 'NumberLiteral',
value: '2'
}, {
type: 'CallExpression',
callee: {
type: 'Identifier',
name: 'subtract'
},
arguments: [{
type: 'NumberLiteral',
value: '4'
}, {
type: 'NumberLiteral',
value: '2'
}]
}]
}
}]
};
-
5. 代码生成 (code-generator)
最后一个阶段代码生成,代码生成器将递归调用自身,以将树中的每个节点输出为一个长字符串。
function codeGenerator(node) {
switch (node.type) {
// 如果是 'Program', 将会 map 遍历其 body 属性,并插入换行符
case 'Program':
return node.body.map(codeGenerator)
.join('\n');
// 如果是`ExpressionStatement` 用括号包装并且嵌套调用代码生成器表达式,并加上分号
case 'ExpressionStatement':
return (
codeGenerator(node.expression) +
';'
);
// 对于`CallExpression`,则打印 `callee`并添加一个左括号,
// 然后将遍历`arguments`数组中的每个节点,并通过代码生成器运行它们,
// 然后用逗号将它们连接起来,然后添加右括号。 用括号对其包装
case 'CallExpression':
return (
codeGenerator(node.callee) +
'(' +
node.arguments.map(codeGenerator)
.join(', ') +
')'
);
case 'Identifier':
return node.name;
// 对于 `NumberLiteral` 直接返回
case 'NumberLiteral':
return node.value;
// 对于 `StringLiteral`, 用 “ ” 包装
case 'StringLiteral':
return '"' + node.value + '"';
}
}
const output = "add(2, subtract(4, 2))";
到此,编译器已经完整的具备了 L-A 转换成L-B 的全部功能。从 input 到 output, 经历了四个阶段 最终完成了编译器的代码转换。
1. input => tokenizer => tokens
2. tokens => parser => ast
3. ast => transformer => newAst
4. newAst => generator => output
如果你已经看到这里,那么恭喜你,你已经超越了 80% 的读者
结语
到此为止,初探代码编译系列到此告一段落,我想如果你能把这四篇文章全部搞懂并且手动去实现其涉及的代码,对前端日常开发肯定会有一个新的认识,不论是 工程化中用到对 babel 还是 eslint 等等, 其底层原理都是很相似的。
参考文献:
ps:如有不足 欢迎指正
一只前端小菜鸟 | 求知若渴 | 梦想与爱皆不可辜负