实现一个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生成为目标代码

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

参考资料

相关推荐
妄小闲2 小时前
网页源代码 企业网站源码 html源码网站
前端·html
Ares-Wang2 小时前
Vue3》》 ref 获取子组件实例 原理
javascript·vue.js·typescript
爱上妖精的尾巴3 小时前
5-16WPS JS宏 map数组转换迭代应用-1(一维嵌套数组结构重组)
开发语言·前端·javascript·wps·jsa
OEC小胖胖3 小时前
交互的脉络:小程序事件系统详解
前端·微信小程序·小程序·微信开放平台
DokiDoki之父3 小时前
web核心—HTTP
前端·网络协议·http
咖啡の猫3 小时前
Vue 简介
前端·javascript·vue.js
Moment3 小时前
写代码也能享受?这款显示器让调试变得轻松又高效!😎😎😎
前端·后端
゜ eVer ㄨ3 小时前
React-router v6学生管理系统笔记
前端·笔记·react.js
m0_526119404 小时前
pdf文件根据页数解析成图片 js vue3
前端·javascript·pdf