实现一个mini编译器,来感受编译器的各个流程

编译器深度解析:从理论到实践

📚 项目地址 : 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)),来深入理解编译器的四个核心阶段:词法分析、语法分析、代码转换和代码生成。

编译器概述

编译器是一个复杂的程序,它将源代码转换为目标代码。现代编译器通常包含以下四个主要阶段:

  1. 词法分析(Lexical Analysis):将源代码字符串分解为有意义的词法单元(tokens)
  2. 语法分析(Syntax Analysis):将tokens组织成抽象语法树(AST)
  3. 代码转换(Code Transformation):将AST转换为另一种形式的AST
  4. 代码生成(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;
};

详细实现逻辑:

  1. 初始化阶段:创建tokens数组存储结果,current指针跟踪当前位置
  2. 字符遍历:使用while循环逐个处理字符
  3. 字符分类处理
    • 括号字符:立即生成paren类型token
    • 空白字符:跳过不处理
    • 字母字符:连续读取生成name类型token
    • 数字字符:连续读取生成number类型token
  4. 指针管理:每处理一个字符,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)

详细实现逻辑:

  1. 节点创建函数:为不同节点类型提供创建函数
  2. 递归下降解析:使用walk函数递归处理tokens
  3. 状态管理:current指针跟踪当前token位置
  4. 递归处理:遇到嵌套结构时递归调用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。

转换目标

  • CallExpressionCallExpression(但结构不同)
  • NumberLiteralNumberLiteral
  • 添加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
}

详细实现逻辑:

  1. 转换规则定义:通过astTypeMap定义节点类型转换规则
  2. 访问者模式:使用transform函数递归处理节点
  3. 对象处理:handleObject函数处理嵌套对象结构
  4. 表达式包装:为每个表达式添加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));"

详细实现逻辑:

  1. 递归遍历:使用递归方式遍历AST节点
  2. 类型分发:根据节点类型选择生成策略
  3. 字符串拼接:将子节点代码组合成完整代码
  4. 语法格式化:添加必要的语法符号(分号、逗号等)
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,为每种节点类型生成相应的代码,体现了编译器代码生成的本质。

总结

通过实现这个简单的编译器,我们深入理解了编译器的四个核心阶段:

  1. 词法分析:将字符串分解为tokens
  2. 语法分析:将tokens组织成AST
  3. 代码转换:将AST转换为另一种形式
  4. 代码生成:将AST生成为目标代码

这个项目虽然简单,但包含了编译器的核心概念和实现技巧。通过这个实践,我们不仅理解了编译器的工作原理,还掌握了递归下降解析、访问者模式、递归代码生成等重要技术。

参考资料

相关推荐
崔庆才丨静觅3 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60614 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了4 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅4 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅5 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅5 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment5 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅6 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊6 小时前
jwt介绍
前端
爱敲代码的小鱼6 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax