编译器深度解析:从理论到实践
📚 项目地址 : github.com/frontzhm/mi...
🚀 在线体验 : 克隆项目后运行
npm test
即可体验完整的编译器流程
bash
# 克隆项目
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项目结构清晰,每个阶段都有独立的模块:
bash
src/
├── tokenizer/ # 词法分析器
├── parser/ # 语法分析器
├── transformer/ # 代码转换器
├── codeGenerator/ # 代码生成器
└── compiler/ # 编译器主入口
阶段一:词法分析(Tokenizer)
输入 :源代码字符串 "(add 2 (subtract 4 2))"
输出:Token数组
词法分析是编译器的第一步,它将输入的字符串分解为tokens。在我们的例子中,需要识别以下类型的tokens:
- 括号:
(
和)
- 数字:
2
,4
等 - 标识符:
add
,subtract
等 - 空白字符:空格、换行等
实现思路
词法分析器的核心是一个状态机,通过遍历输入字符串的每个字符,根据字符类型生成相应的token。实现逻辑如下:
arduino
输入字符串: "(add 2 (subtract 4 2))"
↓
字符遍历状态机
↓
输出Token数组
状态机流程图:
typescript
开始 → 读取字符 → 判断字符类型
↓
┌─────────────────┐
│ '(' 或 ')' │ → 生成paren token
│ 数字字符 │ → 生成number token
│ 字母字符 │ → 生成name token
│ 空白字符 │ → 跳过
└─────────────────┘
↓
继续下一个字符
javascript
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指针前移
字符处理优先级:
markdown
1. 括号字符 → 立即生成token
2. 空白字符 → 跳过处理
3. 字母字符 → 连续读取生成name token
4. 数字字符 → 连续读取生成number token
词法分析结果
对于输入"(add 2 (subtract 4 2))"
,词法分析器会生成以下tokens:
javascript
[
{ 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树
解析流程图:
csharp
开始解析
↓
遇到 '(' → 创建CallExpression节点
↓
解析函数名 → 设置节点name
↓
递归解析参数 → 添加到params数组
↓
遇到 ')' → 返回完整节点
递归下降解析示意图:
csharp
输入: [(, add, 2, (, subtract, 4, 2, ), )]
↓
Program
└── CallExpression (add)
├── NumberLiteral (2)
└── CallExpression (subtract)
├── NumberLiteral (4)
└── NumberLiteral (2)
详细实现逻辑:
- 节点创建函数:为不同节点类型提供创建函数
- 递归下降解析:使用walk函数递归处理tokens
- 状态管理:current指针跟踪当前token位置
- 递归处理:遇到嵌套结构时递归调用walk函数
javascript
// 节点创建辅助函数
function createRootNode() {
return {
type: "Program",
body: []
};
}
function createNumberNode(value) {
return {
type: "NumberLiteral",
value
};
}
function createCallExpressionNode(name) {
return {
type: "CallExpression",
name,
params: []
};
}
export function parser(tokens) {
const rootNode = createRootNode();
let current = 0; // 当前token位置指针
let curMax = tokens.length - 1; // 最大token索引
// 核心递归函数
function walk() {
let token = tokens[current];
// 处理数字字面量
if (token.type === "number") {
return createNumberNode(token.value);
}
// 处理函数调用表达式
if (token.type === "paren" && token.value === "(") {
token = tokens[++current]; // 跳过左括号,获取函数名
const node = createCallExpressionNode(token.value);
token = tokens[++current]; // 移动到第一个参数
// 递归解析所有参数
while (!(token.type === "paren" && token.value === ")")) {
node.params.push(walk()); // 递归解析参数
token = tokens[++current]; // 移动到下一个token
}
return node;
}
}
// 主解析循环
while (current <= curMax) {
rootNode.body.push(walk());
current++;
}
return rootNode;
}
递归下降解析步骤:
markdown
1. 遇到number token → 创建NumberLiteral节点
2. 遇到( token → 创建CallExpression节点
3. 递归解析参数 → 添加到params数组
4. 遇到) token → 返回完整节点
语法分析结果
对于tokens数组,语法分析器会生成以下AST:
javascript
{
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
包装
实现思路
代码转换器使用访问者模式,通过astTypeMap
定义不同节点类型的转换规则。核心思想是:
LISP AST → 访问者模式转换 → JavaScript AST
转换流程图:
markdown
开始转换
↓
遍历AST节点
↓
根据节点类型查找转换规则
↓
应用转换规则 → 生成新节点
↓
递归处理子节点
↓
返回转换后的AST
AST结构对比图:
csharp
LISP风格AST: JavaScript风格AST:
Program Program
└── CallExpression (add) └── ExpressionStatement
├── NumberLiteral (2) └── CallExpression
└── CallExpression (subtract) ├── callee: Identifier (add)
├── NumberLiteral (4) └── arguments: [...]
└── NumberLiteral (2)
转换规则映射:
css
CallExpression → {
type: 'CallExpression',
callee: { type: 'Identifier', name: node.name },
arguments: node.params.map(transform)
}
NumberLiteral → {
type: 'NumberLiteral',
value: node.value
}
详细实现逻辑:
- 转换规则定义:通过astTypeMap定义节点类型转换规则
- 访问者模式:使用transform函数递归处理节点
- 对象处理:handleObject函数处理嵌套对象结构
- 表达式包装:为每个表达式添加ExpressionStatement包装
javascript
// 节点类型转换映射表
const astTypeMap = {
CallExpression: {
type: 'CallExpression',
callee: { type: 'Identifier', name: (node) => node.name },
arguments: (node) => node.params.map(param => transform(param))
},
NumberLiteral: {
type: 'NumberLiteral',
value: (node) => node.value
}
};
// 核心转换函数
function transform(node) {
const typeHandler = astTypeMap[node.type];
if (!typeHandler) return node;
// 递归处理对象结构
function handleObject(obj) {
const result = {};
for (const key in obj) {
if (typeof obj[key] === 'function') {
// 执行函数获取值
result[key] = obj[key](node);
} else if (typeof obj[key] === 'object') {
// 递归处理嵌套对象
result[key] = handleObject(obj[key]);
} else {
// 直接复制值
result[key] = obj[key];
}
}
return result;
}
const result = handleObject(typeHandler);
return result;
}
// 主转换函数
export function transformer(ast) {
return {
type: 'Program',
body: ast.body.map(node => ({
type: 'ExpressionStatement', // 添加表达式语句包装
expression: transform(node) // 转换表达式
}))
};
}
转换过程详解:
markdown
1. 遍历AST节点 → 查找转换规则
2. 应用转换规则 → 生成新节点结构
3. 递归处理子节点 → 保持树形结构
4. 添加包装节点 → 符合目标语法
转换结果
转换后的AST结构如下:
javascript
{
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 → 递归代码生成 → 目标代码字符串
代码生成流程图:
markdown
开始生成
↓
遍历AST节点
↓
根据节点类型选择生成策略
↓
递归生成子节点代码
↓
组合生成完整代码
↓
返回目标代码
节点类型生成规则:
arduino
Program → 遍历body,生成所有语句
ExpressionStatement → 生成表达式 + ';'
CallExpression → 生成 '函数名(参数列表)'
NumberLiteral → 生成数字值
递归生成示意图:
scss
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节点
- 类型分发:根据节点类型选择生成策略
- 字符串拼接:将子节点代码组合成完整代码
- 语法格式化:添加必要的语法符号(分号、逗号等)
javascript
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;
}
代码生成步骤:
markdown
1. 识别节点类型 → 选择生成策略
2. 递归处理子节点 → 生成子代码
3. 组合代码片段 → 形成完整代码
4. 添加语法符号 → 符合目标语法
生成过程示例:
scss
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));
编译器主入口
将所有阶段组合在一起:
完整编译流程图:
scss
源代码字符串
↓
"(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));"
javascript
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),每个功能都有对应的测试:
javascript
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生成为目标代码
这个项目虽然简单,但包含了编译器的核心概念和实现技巧。通过这个实践,我们不仅理解了编译器的工作原理,还掌握了递归下降解析、访问者模式、递归代码生成等重要技术。