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

588 阅读11分钟

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

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

编译器概述

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

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

详细实现逻辑:

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

详细实现逻辑:

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

转换目标

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

详细实现逻辑:

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

详细实现逻辑:

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

总结

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

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

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

参考资料