编译器深度解析:从理论到实践
📚 项目地址: github.com/frontzhm/mi…
🚀 在线体验: 克隆项目后运行npm test即可体验完整的编译器流程
# 克隆项目
git clone https://github.com/frontzhm/mini-compiler.git
cd mini-compiler
# 安装依赖
npm install
# 运行测试
npm test
# 运行单个测试文件
npm test -- src/compiler/index.spec.js
引言
编译器是计算机科学中的核心概念,它将人类可读的源代码转换为机器可执行的代码。理解编译器的工作原理不仅有助于更好地理解编程语言,还能提升编程能力和系统设计思维。
本文将通过实现一个简单的编译器,LISP代码转换为JavaScript代码,将(add 2 (subtract 4 2))转换为add(2, subtract(4, 2)),来深入理解编译器的四个核心阶段:词法分析、语法分析、代码转换和代码生成。
编译器概述
编译器是一个复杂的程序,它将源代码转换为目标代码。现代编译器通常包含以下四个主要阶段:
- 词法分析(Lexical Analysis):将源代码字符串分解为有意义的词法单元(tokens)
- 语法分析(Syntax Analysis):将tokens组织成抽象语法树(AST)
- 代码转换(Code Transformation):将AST转换为另一种形式的AST
- 代码生成(Code Generation):将转换后的AST生成为目标代码
项目结构
我们的mini-compiler项目结构清晰,每个阶段都有独立的模块:
src/
├── tokenizer/ # 词法分析器
├── parser/ # 语法分析器
├── transformer/ # 代码转换器
├── codeGenerator/ # 代码生成器
└── compiler/ # 编译器主入口
阶段一:词法分析(Tokenizer)
输入:源代码字符串 "(add 2 (subtract 4 2))"
输出:Token数组
词法分析是编译器的第一步,它将输入的字符串分解为tokens。在我们的例子中,需要识别以下类型的tokens:
- 括号:
(和) - 数字:
2,4等 - 标识符:
add,subtract等 - 空白字符:空格、换行等
实现思路
词法分析器的核心是一个状态机,通过遍历输入字符串的每个字符,根据字符类型生成相应的token。实现逻辑如下:
输入字符串: "(add 2 (subtract 4 2))"
↓
字符遍历状态机
↓
输出Token数组
状态机流程图:
开始 → 读取字符 → 判断字符类型
↓
┌─────────────────┐
│ '(' 或 ')' │ → 生成paren token
│ 数字字符 │ → 生成number token
│ 字母字符 │ → 生成name token
│ 空白字符 │ → 跳过
└─────────────────┘
↓
继续下一个字符
const tokenizer = (code) => {
const tokens = [];
let current = 0;
const length = code.length;
while (current < length) {
let char = code[current];
// 处理括号
if (char === "(") {
tokens.push({ type: "paren", value: "(" });
current++;
}
if (char === ")") {
tokens.push({ type: "paren", value: ")" });
current++;
}
// 跳过空白字符
const whiteSpace = /\s/;
if (whiteSpace.test(char)) {
current++;
}
// 处理字母(标识符)
let LETTERS = /[a-z]/i;
if (LETTERS.test(char)) {
let value = "";
while (LETTERS.test(char) && current < length) {
value += char;
current++;
char = code[current];
}
tokens.push({ type: "name", value });
}
// 处理数字
let NUMBERS = /[0-9]/;
if (NUMBERS.test(char)) {
let value = "";
while (NUMBERS.test(char) && current < length) {
value += char;
current++;
char = code[current];
}
tokens.push({ type: "number", value });
}
}
return tokens;
};
详细实现逻辑:
- 初始化阶段:创建tokens数组存储结果,current指针跟踪当前位置
- 字符遍历:使用while循环逐个处理字符
- 字符分类处理:
- 括号字符:立即生成paren类型token
- 空白字符:跳过不处理
- 字母字符:连续读取生成name类型token
- 数字字符:连续读取生成number类型token
- 指针管理:每处理一个字符,current指针前移
字符处理优先级:
1. 括号字符 → 立即生成token
2. 空白字符 → 跳过处理
3. 字母字符 → 连续读取生成name token
4. 数字字符 → 连续读取生成number token
词法分析结果
对于输入"(add 2 (subtract 4 2))",词法分析器会生成以下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: ")" }
]
阶段二:语法分析(Parser)
输入:Token数组
输出:抽象语法树(AST)
语法分析器将tokens组织成抽象语法树(AST)。AST是源代码的树状表示,每个节点代表源代码中的一个构造。
AST节点类型
我们的编译器需要处理以下节点类型:
Program:程序根节点NumberLiteral:数字字面量CallExpression:函数调用表达式
实现思路
语法分析器使用递归下降解析算法,通过walk函数递归处理tokens。核心思想是:
Token数组 → 递归下降解析 → AST树
解析流程图:
开始解析
↓
遇到 '(' → 创建CallExpression节点
↓
解析函数名 → 设置节点name
↓
递归解析参数 → 添加到params数组
↓
遇到 ')' → 返回完整节点
递归下降解析示意图:
输入: [(, add, 2, (, subtract, 4, 2, ), )]
↓
Program
└── CallExpression (add)
├── NumberLiteral (2)
└── CallExpression (subtract)
├── NumberLiteral (4)
└── NumberLiteral (2)
详细实现逻辑:
- 节点创建函数:为不同节点类型提供创建函数
- 递归下降解析:使用walk函数递归处理tokens
- 状态管理:current指针跟踪当前token位置
- 递归处理:遇到嵌套结构时递归调用walk函数
export function parser(tokens) {
const ast = { type: "Program", body: [] }
let i = 0
function walk() {
// walk就是返回特定节点,但是注意部分节点会连续用到好几个token,所以walk在节点的维度上没有移动指针,节点内部可能移动指针,外部遍历的时候,指针往后移动
if (tokens[i].type === 'number') {
return { type: "NumberLiteral", value: tokens[i].value }
}
if (tokens[i].type === 'paren' && tokens[i].value === '(') {
i++
// 获取name
const name = tokens[i].value
const node = { type: "CallExpression", name, params: [] }
i++
// 获取参数,直到结束
while (i < tokens.length && !(tokens[i].type === 'paren' && tokens[i].value === ')')) {
node.params.push(walk())
i++
}
// 结束的时候,i停留在)这里,在节点维度,没有移动i
return node
}
}
while (i < tokens.length) {
ast.body.push(walk())
// 这里移动
i++
}
return ast
}
递归下降解析步骤:
1. 遇到number token → 创建NumberLiteral节点
2. 遇到( token → 创建CallExpression节点
3. 递归解析参数 → 添加到params数组
4. 遇到) token → 返回完整节点
语法分析结果
对于tokens数组,语法分析器会生成以下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" }
]
}
]
}]
}
阶段三:代码转换(Transformer)
输入:LISP风格AST
输出:JavaScript风格AST
代码转换阶段将一种AST转换为另一种形式的AST。在我们的例子中,需要将LISP风格的AST转换为JavaScript风格的AST。
转换目标
CallExpression→CallExpression(但结构不同)NumberLiteral→NumberLiteral- 添加
ExpressionStatement包装
实现思路
代码转换器使用访问者模式(如果不知道访问模式的话,可以查看文档(juejin.cn/post/756202…
LISP AST → 访问者模式转换 → JavaScript AST
转换流程图:
开始转换
↓
遍历AST节点
↓
根据节点类型查找转换规则
↓
应用转换规则 → 生成新节点
↓
递归处理子节点
↓
返回转换后的AST
AST结构对比图:
LISP风格AST: JavaScript风格AST:
Program Program
└── CallExpression (add) └── ExpressionStatement
├── NumberLiteral (2) └── CallExpression
└── CallExpression (subtract) ├── callee: Identifier (add)
├── NumberLiteral (4) └── arguments: [...]
└── NumberLiteral (2)
转换规则映射:
CallExpression → {
type: 'CallExpression',
callee: { type: 'Identifier', name: node.name },
arguments: node.params.map(transform)
}
NumberLiteral → {
type: 'NumberLiteral',
value: node.value
}
详细实现逻辑:
- 转换规则定义:通过astTypeMap定义节点类型转换规则
- 访问者模式:使用transform函数递归处理节点
- 对象处理:handleObject函数处理嵌套对象结构
- 表达式包装:为每个表达式添加ExpressionStatement包装
export function transformer(ast) {
let newAST;
// 这里的visitor可以更换成其他的visitor,比如将AST转换为另一种语言的AST
const visitor = {
Program: {
enter(node, parent) {
newAST = { type: 'Program', body: [] };
// 等会遍历到body里项的时候,push到newAST.body,用node._context做指针
node._context = newAST.body;
},
},
CallExpression: {
enter(node, parent) {
let newNode = {
type: 'CallExpression',
callee: { type: 'Identifier', name: node.name },
arguments: [],
};
// 等会遍历到arguments里项的时候,push到newNode.arguments,用node._context做指针
node._context = newNode.arguments;
// 如果当前节点的父节点不是CallExpression,则需要用ExpressionStatement包裹
if (parent.type !== 'CallExpression') {
newNode = {
type: 'ExpressionStatement',
expression: newNode,
};
}
// 这里的parent._context可以理解为当前节点的父节点的_context
parent._context.push(newNode);
},
},
NumberLiteral: {
enter(node, parent) {
const newNode = { type: 'NumberLiteral', value: node.value };
// 这里的parent._context可以理解为当前节点的父节点的_context
parent._context.push(newNode);
},
},
};
traverse(ast, visitor);
return newAST;
}
// 这个是通用的遍历AST的函数,可以用于遍历任何AST
function traverse(node, visitor) {
// visitor是共享的,不迭代。但是node和parent是迭代的
traverseNode(node, null);
function traverseNode(node, parent) {
console.log('开始访问', node.type, node.name || node.value || '根节点');
// 获取访问者在这个节点要做的操作
const doThingsInThisNode = visitor[node.type];
// 进入节点的时候,看看有没有要干的事
doThingsInThisNode?.enter?.(node, parent);
// 访问子节点,不同的节点类型,拥有子节点的key不同,所以需要用switch来处理
switch (node.type) {
case 'Program':
node.body.forEach((child) => traverseNode(child, node));
break;
case 'CallExpression':
node.params.forEach((child) => traverseNode(child, node));
break;
default:
break;
}
// 离开节点的时候,看看有没有要干的事
doThingsInThisNode?.exit?.(node, parent);
console.log('结束访问', node.type, node.name || node.value || '根节点');
}
}
转换过程详解:
1. 遍历AST节点 → 查找转换规则
2. 应用转换规则 → 生成新节点结构
3. 递归处理子节点 → 保持树形结构
4. 添加包装节点 → 符合目标语法
转换结果
转换后的AST结构如下:
{
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" }
]
}
]
}
}]
}
阶段四:代码生成(CodeGenerator)
输入:JavaScript风格AST
输出:目标代码字符串 "add(2, subtract(4, 2));"
代码生成器将AST转换为目标代码字符串。
实现思路
代码生成器使用递归遍历AST,为每种节点类型生成相应的代码字符串。核心思想是:
JavaScript AST → 递归代码生成 → 目标代码字符串
代码生成流程图:
开始生成
↓
遍历AST节点
↓
根据节点类型选择生成策略
↓
递归生成子节点代码
↓
组合生成完整代码
↓
返回目标代码
节点类型生成规则:
Program → 遍历body,生成所有语句
ExpressionStatement → 生成表达式 + ';'
CallExpression → 生成 '函数名(参数列表)'
NumberLiteral → 生成数字值
递归生成示意图:
ExpressionStatement
└── CallExpression (add)
├── NumberLiteral (2) → "2"
└── CallExpression (subtract)
├── NumberLiteral (4) → "4"
└── NumberLiteral (2) → "2"
↓
"subtract(4, 2)"
↓
"add(2, subtract(4, 2))"
↓
"add(2, subtract(4, 2));"
详细实现逻辑:
- 递归遍历:使用递归方式遍历AST节点
- 类型分发:根据节点类型选择生成策略
- 字符串拼接:将子节点代码组合成完整代码
- 语法格式化:添加必要的语法符号(分号、逗号等)
export function codeGenerator(node) {
let res = '';
// 根据节点类型分发处理
switch(node.type) {
case 'Program':
// 程序节点:生成所有语句
return node.body.map(codeGenerator).join("");
case 'ExpressionStatement':
// 表达式语句:生成表达式 + 分号
return `${codeGenerator(node.expression)};`;
case 'CallExpression':
// 函数调用:生成 函数名(参数列表)
return `${node.callee.name}(${node.arguments.map(codeGenerator).join(", ")})`;
case 'NumberLiteral':
// 数字字面量:直接返回数字值
return node.value;
}
return res;
}
代码生成步骤:
1. 识别节点类型 → 选择生成策略
2. 递归处理子节点 → 生成子代码
3. 组合代码片段 → 形成完整代码
4. 添加语法符号 → 符合目标语法
生成过程示例:
NumberLiteral(2) → "2"
NumberLiteral(4) → "4"
CallExpression(subtract) → "subtract(4, 2)"
CallExpression(add) → "add(2, subtract(4, 2))"
ExpressionStatement → "add(2, subtract(4, 2));"
代码生成结果
最终生成的代码:add(2, subtract(4, 2));
编译器主入口
将所有阶段组合在一起:
完整编译流程图:
源代码字符串
↓
"(add 2 (subtract 4 2))"
↓
┌─────────────────┐
│ 词法分析器 │ → Token数组
└─────────────────┘
↓
[(, add, 2, (, subtract, 4, 2, ), )]
↓
┌─────────────────┐
│ 语法分析器 │ → LISP风格AST
└─────────────────┘
↓
Program
└── CallExpression (add)
├── NumberLiteral (2)
└── CallExpression (subtract)
├── NumberLiteral (4)
└── NumberLiteral (2)
↓
┌─────────────────┐
│ 代码转换器 │ → JavaScript风格AST
└─────────────────┘
↓
Program
└── ExpressionStatement
└── CallExpression
├── callee: Identifier (add)
└── arguments: [...]
↓
┌─────────────────┐
│ 代码生成器 │ → 目标代码
└─────────────────┘
↓
"add(2, subtract(4, 2));"
import { tokenizer } from "../tokenizer";
import { parser } from "../parser";
import { transformer } from "../transformer";
import { codeGenerator } from "../codeGenerator";
export function compiler(code) {
// 词法分析
const tokens = tokenizer(code);
// 语法分析
const ast = parser(tokens);
// 代码转换
const transformedAst = transformer(ast);
// 代码生成
const generatedCode = codeGenerator(transformedAst);
// 返回生成的代码
return generatedCode;
}
测试验证
项目采用测试驱动开发(TDD),每个功能都有对应的测试:
import { test, expect } from "vitest";
import { compiler } from "./index";
test("compiler", () => {
const code = "(add 2 (subtract 4 2))";
expect(compiler(code)).toBe("add(2, subtract(4, 2));");
});
编译器的深层理解
1. 递归下降解析
我们的parser使用了递归下降解析技术,通过walk函数递归处理嵌套的表达式。这种方法直观且易于理解,是许多编译器的基础。
2. 访问者模式
在transformer中,我们使用了类似访问者模式的设计,通过astTypeMap定义了不同节点类型的处理方式,使得代码结构清晰且易于扩展。
3. 递归代码生成
codeGenerator使用递归方式遍历AST,为每种节点类型生成相应的代码,体现了编译器代码生成的本质。
总结
通过实现这个简单的编译器,我们深入理解了编译器的四个核心阶段:
- 词法分析:将字符串分解为tokens
- 语法分析:将tokens组织成AST
- 代码转换:将AST转换为另一种形式
- 代码生成:将AST生成为目标代码
这个项目虽然简单,但包含了编译器的核心概念和实现技巧。通过这个实践,我们不仅理解了编译器的工作原理,还掌握了递归下降解析、访问者模式、递归代码生成等重要技术。