返回文章列表

用 Rust 写一个解释器:从 Token 到执行,编译原理没那么玄乎

1227·9 分钟阅读
Rust解释器编译原理Parser

每次看到 1 + 2 * 3 被正确算出 7 而不是 9,都觉得理所当然。但从"字符串"到"结果",中间隔了三道门,每道门的钥匙都不一样。

编译原理在国内被妖魔化得很严重。龙书、虎书、鲸书摆一排,光看书名就劝退一半人。

但真相是:你不需要读完龙书才能写一个解释器。 一个能算 1 + 2 * 3、支持变量赋值、能调函数的 mini 语言,核心代码不超过 400 行。写完之后你会发现,所谓的"编译原理",80% 是在解决一个很朴素的问题:怎么把一串字符,变成一个能跑的东西。

这篇文章,我们就用 Rust 手搓一个 mini 解释器。不依赖 parser generator,不用 nom,从零开始。


先搞清楚:解释器和编译器有什么区别

这两个词经常被混用,但它们解决的是不同的问题。

编译器(Compiler):把源代码翻译成另一种语言,然后结束工作。gcc 把 C 翻译成机器码,tsc 把 TypeScript 翻译成 JavaScript。翻译完编译器就走了,执行是别人的事。

解释器(Interpreter):读源代码,直接执行,不生成中间产物。Python、Ruby、JavaScript(V8 之前)都是这样。你写 print(1+1),解释器当场就读、当场就算、当场就输出。

编译器:源代码 → [翻译] → 目标代码 → [执行] → 结果
解释器:源代码 → [读一行执行一行] → 结果

但实际的现代语言往往是混合的。Python 其实会先把源代码编译成字节码(bytecode),然后再用虚拟机执行字节码。Java 也是。所以"编译"和"解释"不是非此即彼,而是两个极端,大多数语言在中间某个位置。

我们今天写的解释器,走的是最朴素的路线:读源代码 → 构建 AST → 直接遍历 AST 求值。 这种叫 tree-walking interpreter,是理解编译原理的最佳起点。


整体流程:从字符串到结果,经过三步

不管你写的是编译器还是解释器,核心流程都是三步:

源代码字符串
    │
    ▼
┌─────────┐
│  Lexer   │  词法分析:字符流 → Token 序列
└────┬────┘
     │
     ▼
┌─────────┐
│  Parser  │  语法分析:Token 序列 → AST(抽象语法树)
└────┬────┘
     │
     ▼
┌──────────────┐
│  Interpreter │  求值:遍历 AST,执行计算
└──────────────┘
     │
     ▼
  结果输出

每一步的输出是下一步的输入。我们用一个具体的例子走一遍这个流程。

例子:1 + 2 * 3 是怎么算出 7 的

第一步:Lexer

原始字符串是 1 + 2 * 3,Lexer 从左到右扫描,切成 Token:

'1' ' ' '+' ' ' '2' ' ' '*' ' ' '3'
 ↓               ↓               ↓
Number(1)     Number(2)       Number(3)
      ↓               ↓
    Plus            Star

输出:[Number(1), Plus, Number(2), Star, Number(3)]

第二步:Parser

Token 序列是一维的,没有优先级信息。如果从左到右算:1 + 2 = 3,再 3 * 3 = 9,结果就错了。

Parser 的核心工作是处理优先级,把一维的 Token 流变成二维的树结构:

        +
       / \
      1    *
          / \
         2    3

乘法节点在内层(更深),所以它先被求值。2 * 3 = 6,然后 1 + 6 = 7

第三步:Interpreter

遍历这棵树,递归求值:

  1. 要算 +,先算左子树 → 1
  2. 再算右子树 → 遇到 *,继续递归
  3. * 的左子树 → 2,右子树 → 3
  4. 2 * 3 = 6,返回给 +
  5. 1 + 6 = 7

关键理解:树的结构天然就表达了优先级。 这就是为什么我们需要 Parser——不是为了好看,是为了正确性。


什么是 AST(抽象语法树)

AST 是整个流程的核心数据结构。它是一棵树,每个节点代表程序的一个"结构":

  • 叶子节点:字面量(数字、字符串)、变量名
  • 内部节点:运算(加减乘除)、控制流(if/while)、函数调用

let x = 10 + 20; 的 AST 长这样:

Stmt::Let
├── name: "x"
└── value: Expr::BinaryOp
          ├── op: Add
          ├── left: Expr::Number(10)
          └── right: Expr::Number(20)

为什么叫"抽象"语法树?因为它丢掉了所有不影响语义的东西——空格、换行、注释、分号(分号在某些位置是必须的,但它不参与计算)。只留下程序的结构

AST 的好处是好遍历。你要算一个表达式的值,递归走一遍树就行了。你要做类型检查,递归走一遍树就行了。你要优化代码(比如把 1 + 2 在编译期直接替换成 3),还是递归走一遍树。

几乎所有语言工具——编译器、解释器、linter、formatter、IDE 补全——核心都是在操作 AST。


我们要造什么

目标是一个能跑这种代码的解释器:

// 变量绑定
let x = 10;
let y = 20;
 
// 表达式计算
let result = (x + y) * 2;
 
// 内置函数
print(result);  // 输出: 60
 
// 条件 + 循环
if result > 50 {
    print("big");
}
 
let i = 0;
while i < 3 {
    print(i);
    i = i + 1;
}

语法故意设计得像 Rust,学起来没门槛。但核心机制——词法分析、语法分析、求值——和真正的语言是一样的。

最终效果: 写一个 .mini 文件,用 cargo run -- run test.mini 就能跑。


第一道门:Lexer(词法分析)

为什么需要 Lexer

源代码是字符串。字符串对计算机来说就是一坨字节。你需要先把字符串切成有意义的"词"(Token),后面的步骤才有意义。

let x = 10; 对人类来说一眼就能看出五个词:letx=10;。但计算机看到的是 let x = 10;

Lexer 的工作就是:从左到右扫一遍字符流,输出一个 Token 序列。

什么是 Token

Token 是源代码中最小的有意义单位。每个 Token 有一个类型和一个可选的值:

源代码:  let x = 10;
Token:   ↑   ↑  ↑  ↑  ↑
         │   │  │  │  └─ Semicolon
         │   │  │  └──── Number(10)
         │   │  └─────── Eq
         │   └────────── Ident("x")
         └────────────── Let

Token 的类型决定了 Parser 怎么处理它。Number(10) 是字面量,可以直接用;Ident("x") 是变量名,需要去环境里查;Let 是关键字,触发变量绑定逻辑。

两个关键概念:前瞻(Lookahead)和关键字 vs 标识符

前瞻:有些 Token 需要"多看一个字符"才能确定。比如 ===

看到 '=' 时:
  下一个是 '=' → 这是 EqEq(等于比较)
  下一个不是 '=' → 这是 Eq(赋值)

Lexer 需要"偷看"下一个字符但不消费它,这叫 lookahead。实现上就是 peek 方法。

关键字 vs 标识符letifwhile 看起来像标识符(都是字母组成),但它们有特殊含义。处理方式是:先按标识符读完整个单词,然后查表判断它是关键字还是普通标识符。

读到 "let" → 查表 → 关键字 Let
读到 "letx" → 查表 → 没找到 → 标识符 Ident("letx")

这就是为什么关键字不能用作变量名——它们在 Lexer 阶段就被"抢走"了。

定义 Token

#[derive(Debug, Clone, PartialEq)]
pub enum Token {
    // 字面量
    Number(f64),
    String(String),
    Bool(bool),
 
    // 标识符和关键字
    Ident(String),
    Let,
    If,
    Else,
    While,
    Fn,
    Return,
 
    // 运算符
    Plus,       // +
    Minus,      // -
    Star,       // *
    Slash,      // /
    Percent,    // %
    Eq,         // =
    EqEq,       // ==
    NotEq,      // !=
    Lt,         // <
    Gt,         // >
    LtEq,       // <=
    GtEq,       // >=
    And,        // &&
    Or,         // ||
    Not,        // !
 
    // 分隔符
    LParen,     // (
    RParen,     // )
    LBrace,     // {
    RBrace,     // }
    Comma,      // ,
    Semicolon,  // ;
 
    // 特殊
    Eof,
}

手写 Lexer

Lexer 的结构很简单:一个 tokenize 方法,核心就是一个大 match。按字符分类处理:

pub struct Lexer {
    source: Vec<char>,
    pos: usize,
}
 
impl Lexer {
    pub fn tokenize(&mut self) -> Result<Vec<Token>, String> {
        let mut tokens = Vec::new();
 
        while self.pos < self.source.len() {
            let ch = self.source[self.pos];
            match ch {
                // 跳过空白
                ' ' | '\t' | '\r' | '\n' => { self.pos += 1; }
 
                // 单字符 token:直接映射
                '+' => { tokens.push(Token::Plus); self.pos += 1; }
                '-' => { tokens.push(Token::Minus); self.pos += 1; }
                '(' => { tokens.push(Token::LParen); self.pos += 1; }
                // ... 其他单字符同理
 
                // 双字符运算符:需要 lookahead
                '=' => {
                    if self.peek() == Some('=') {
                        tokens.push(Token::EqEq);  // ==
                        self.pos += 2;
                    } else {
                        tokens.push(Token::Eq);     // =
                        self.pos += 1;
                    }
                }
                // '!'、'<'、'>' 同理,都是 peek 一下判断是单字符还是双字符
 
                // 多字符 token:调用辅助方法
                '0'..='9' => { tokens.push(self.read_number()?); }
                '"'       => { tokens.push(self.read_string()?); }
                'a'..='z' | 'A'..='Z' | '_' => { tokens.push(self.read_ident()); }
 
                _ => return Err(format!("unexpected character: '{}'", ch)),
            }
        }
        tokens.push(Token::Eof);
        Ok(tokens)
    }
 
    fn peek(&self) -> Option<char> {
        self.source.get(self.pos + 1).copied()
    }
 
    // 读标识符,然后查关键字表
    fn read_ident(&mut self) -> Token {
        let start = self.pos;
        while self.pos < self.source.len()
            && (self.source[self.pos].is_ascii_alphanumeric() || self.source[self.pos] == '_')
        { self.pos += 1; }
        let word: String = self.source[start..self.pos].iter().collect();
        match word.as_str() {
            "let" => Token::Let,
            "if" => Token::If,
            "while" => Token::While,
            "fn" => Token::Fn,
            "true" => Token::Bool(true),
            _ => Token::Ident(word),  // 不是关键字就是标识符
        }
    }
    // read_number、read_string 同理,都是 while 循环读到边界
}

核心逻辑就是一个 match,每个分支要么直接产出 Token(单字符),要么调辅助方法读多字符。read_ident 的最后一步是查关键字表——这就是"先读词再判断"的实现。

为什么手写而不用 nomlogos 手写的好处是完全控制错误信息,你能精确报出"第 3 行第 15 列,遇到意外字符 @"。而且手写一个 lexer 真的不难——核心逻辑就是一个 match


第二道门:Parser(语法分析)

Token 到 AST

Lexer 给了你一列 Token:[Let, Ident("x"), Eq, Number(10), Semicolon]。但这一列 Token 没有结构。你需要把它变成一棵树(AST,抽象语法树),才能知道"先算乘法再算加法"、"if 的条件是哪个表达式"。

// Token 流:let x = 1 + 2 * 3;
[Let, Ident("x"), Eq, Number(1), Plus, Number(2), Star, Number(3), Semicolon]
 
// AST:
LetStmt {
    name: "x",
    value: BinaryOp {
        op: Add,
        left: Number(1),
        right: BinaryOp {
            op: Mul,
            left: Number(2),
            right: Number(3),
        }
    }
}

注意 2 * 3 被包在内层,这保证了乘法先算。这就是 Parser 的核心工作:从一维的 Token 流,构建出二维的树结构,并且正确处理优先级。

语法规则:用 BNF 描述语言

Parser 需要一个"规则"来判断什么样的 Token 序列是合法的。这个规则叫文法(Grammar),通常用 BNF(巴科斯范式)描述。

我们的 mini 语言的表达式文法:

expr       → or_expr
or_expr    → and_expr ( "||" and_expr )*
and_expr   → equality ( "&&" equality )*
equality   → comparison ( ("==" | "!=") comparison )*
comparison → addition ( ("<" | ">" | "<=" | ">=") addition )*
addition   → multiplication ( ("+" | "-") multiplication )*
multiplication → unary ( ("*" | "/" | "%") unary )*
unary      → ( "-" | "!" ) unary | primary
primary    → NUMBER | STRING | BOOL | IDENT | IDENT "(" args ")" | "(" expr ")"

每条规则对应一个函数。or_expr 最低优先级,primary 最高优先级。优先级越高的运算符,在越深的层级处理,天然就保证了"先乘除后加减"。

递归下降:为什么这样写 Parser

递归下降(Recursive Descent)是最直观的 Parser 写法。核心思想是:每个文法规则对应一个函数,函数之间互相调用。

parse_expr → parse_or → parse_and → parse_equality → ... → parse_primary

调用链从低优先级到高优先级。当 parse_addition 需要解析一个操作数时,它调用 parse_multiplication(更高优先级),而不是自己处理。这就保证了乘法比加法"绑得更紧"。

左递归的陷阱:如果你写成 expr → expr "+" term,函数会无限递归调用自己。改成 expr → term ( "+" term )*(循环而不是递归)就能避免。这是递归下降 Parser 最常见的坑。

运算符优先级一览

我们的 Parser 处理的优先级层次(从低到高):

优先级    运算符           结合性
─────────────────────────────────
最低      ||              左结合
          &&              左结合
          == !=           左结合
          < > <= >=       左结合
          + -             左结合
最高      * / %           左结合
          - ! (一元)      右结合

左结合意味着 1 - 2 - 3 算成 (1 - 2) - 3 = -4,而不是 1 - (2 - 3) = 2。实现上,左结合用 while 循环,右结合用递归调用自身。

定义 AST

#[derive(Debug, Clone)]
pub enum Expr {
    Number(f64),
    String(String),
    Bool(bool),
    Ident(String),
 
    UnaryOp {
        op: UnaryOp,
        expr: Box<Expr>,
    },
    BinaryOp {
        op: BinOp,
        left: Box<Expr>,
        right: Box<Expr>,
    },
 
    Call {
        func: String,
        args: Vec<Expr>,
    },
}
 
#[derive(Debug, Clone)]
pub enum BinOp {
    Add, Sub, Mul, Div, Mod,
    Eq, NotEq, Lt, Gt, LtEq, GtEq,
    And, Or,
}
 
#[derive(Debug, Clone)]
pub enum UnaryOp {
    Neg, Not,
}
 
#[derive(Debug, Clone)]
pub enum Stmt {
    Let {
        name: String,
        value: Expr,
    },
    Expr(Expr),
    If {
        condition: Expr,
        then_branch: Vec<Stmt>,
        else_branch: Option<Vec<Stmt>>,
    },
    While {
        condition: Expr,
        body: Vec<Stmt>,
    },
    FnDef {
        name: String,
        params: Vec<String>,
        body: Vec<Stmt>,
    },
    Return(Option<Expr>),
}

递归下降 Parser

Parser 的经典写法叫"递归下降"(Recursive Descent)。核心思想是:每个语法规则对应一个函数,函数之间互相调用,形成递归。

表达式的优先级处理是重点。优先级从低到高:

or → and → equality → comparison → addition → multiplication → unary → primary

每一层只处理自己优先级的运算符,把更高优先级的表达式"委托"给下一层。

语句解析负责分发:看到 letparse_let,看到 ifparse_if,其他当表达式处理:

fn parse_stmt(&mut self) -> Result<Stmt, String> {
    match self.current() {
        Token::Let    => self.parse_let(),
        Token::If     => self.parse_if(),
        Token::While  => self.parse_while(),
        Token::Fn     => self.parse_fn_def(),
        Token::Return => self.parse_return(),
        _ => {
            let expr = self.parse_expr()?;
            self.expect(&Token::Semicolon)?;
            Ok(Stmt::Expr(expr))
        }
    }
}
 
// 以 let 为例:消费 let → 读变量名 → 消费 = → 解析表达式 → 消费 ;
fn parse_let(&mut self) -> Result<Stmt, String> {
    self.advance();
    let name = self.expect_ident()?;
    self.expect(&Token::Eq)?;
    let value = self.parse_expr()?;
    self.expect(&Token::Semicolon)?;
    Ok(Stmt::Let { name, value })
}

表达式解析是核心。每一层结构完全一样,区别只在运算符和下一层调用:

fn parse_expr(&mut self) -> Result<Expr, String> { self.parse_or() }
 
fn parse_or(&mut self) -> Result<Expr, String> {
    let mut left = self.parse_and()?;           // 先解析更高优先级的操作数
    while self.check(&Token::Or) {               // 循环处理当前优先级的运算符
        self.advance();
        let right = self.parse_and()?;
        left = Expr::BinaryOp {                  // 包装成树节点
            op: BinOp::Or, left: Box::new(left), right: Box::new(right),
        };
    }
    Ok(left)
}
 
// parse_and、parse_equality、parse_comparison、parse_addition 全是同一个模式
// 只有运算符和"下一层调用"不同
 
fn parse_addition(&mut self) -> Result<Expr, String> {
    let mut left = self.parse_multiplication()?;
    while matches!(self.current(), Token::Plus | Token::Minus) {
        let op = match self.current() {
            Token::Plus => BinOp::Add, Token::Minus => BinOp::Sub, _ => unreachable!(),
        };
        self.advance();
        let right = self.parse_multiplication()?;
        left = Expr::BinaryOp { op, left: Box::new(left), right: Box::new(right) };
    }
    Ok(left)
}
 
fn parse_multiplication(&mut self) -> Result<Expr, String> {
    let mut left = self.parse_unary()?;  // 最高优先级,委托给 unary
    while matches!(self.current(), Token::Star | Token::Slash) {
        // ... 同上
    }
    Ok(left)
}

基底:parse_primary 处理叶子节点——数字、字符串、变量名、括号、函数调用。没有更低优先级可以委托了,直接消费 Token:

fn parse_primary(&mut self) -> Result<Expr, String> {
    match self.current().clone() {
        Token::Number(n) => { self.advance(); Ok(Expr::Number(n)) }
        Token::Ident(name) => {
            self.advance();
            if self.check(&Token::LParen) {   // 标识符后面跟 ( → 函数调用
                self.advance();
                let args = self.parse_args()?;
                self.expect(&Token::RParen)?;
                Ok(Expr::Call { func: name, args })
            } else {
                Ok(Expr::Ident(name))          // 否则就是变量引用
            }
        }
        Token::LParen => {                     // 括号:递归解析内部表达式
            self.advance();
            let expr = self.parse_expr()?;
            self.expect(&Token::RParen)?;
            Ok(expr)
        }
        other => Err(format!("unexpected token: {:?}", other)),
    }
}

踩坑记录:std::mem::discriminant 是关键。

check 方法需要比较"是不是同一种 Token",不关心具体值(Number(1.0)Number(2.0) 是同一种)。用 == 比较会因为值不同而失败。std::mem::discriminant 只比较枚举的变体标签,忽略内部数据。


第三道门:求值器(Tree-walking Interpreter)

Tree-walking 是什么意思

Parser 给了我们一棵 AST。求值器的工作就是递归遍历这棵树,遇到节点就执行对应的操作

eval(BinaryOp { op: Add, left: 1, right: 2 })
  → eval(1) = 1
  → eval(2) = 2
  → 1 + 2 = 3

这就是 "tree-walking"——像走迷宫一样在树上走,走到叶子节点就返回值,走到内部节点就计算。

核心问题:变量存在哪?

执行 let x = 10; 之后,x 被存到哪里了?执行 print(x) 的时候怎么找到它?

答案是环境(Environment)——一个链式的 HashMap。每个作用域有自己的环境,环境之间通过"父指针"串起来:

全局环境: { x: 10, y: 20 }
    ↑ 父指针
函数环境: { n: 5 }
    ↑ 父指针
内层块环境: { temp: 100 }

查找变量时,先在当前环境找,找不到就去父环境找,一直找到全局环境。如果全局环境都没有,那就是"未定义变量"错误。

这就是作用域链——几乎所有语言的变量查找都是这个机制。

为什么需要 Box?

AST 节点会递归嵌套:BinaryOpleft 可能又是 BinaryOp。Rust 需要知道每个类型的大小,递归类型必须用 Box(堆分配的指针)来打破无限大小的循环。

// 编译失败:Expr 的大小是无限的
enum Expr {
    BinaryOp { left: Expr, right: Expr },  // Expr 包含 Expr,无限嵌套
}
 
// 正确:Box 是一个指针,大小固定
enum Expr {
    BinaryOp { left: Box<Expr>, right: Box<Expr> },  // Box<Expr> 大小是 8 字节
}

函数调用时发生了什么

函数调用是解释器里最复杂的部分。调用 fibonacci(3) 时:

  1. 查找函数定义:在环境里找 fibonacci,得到它的参数列表和函数体
  2. 创建新环境:函数自己的作用域,父环境是定义时的环境(不是调用时的)
  3. 绑定参数n = 3 写入新环境
  4. 执行函数体:在新环境里执行函数体的每条语句
  5. 处理 return:遇到 return 语句,提前退出,返回值给调用者

第 2 步是闭包的关键。如果父环境用的是"调用时"的环境,内层函数就访问不到外层变量。用"定义时"的环境,闭包才能正确捕获。

return 的处理:ControlFlow

函数里遇到 return,需要立即退出整个函数,不只是退出当前循环或 if 块。怎么做到?

用一个枚举来传递"控制信号":

enum ControlFlow {
    None,           // 正常执行,继续下一条
    Return(Value),  // return 了,立即退出函数
}

每执行一条语句,都检查返回值。如果是 Return,就一路往上传,直到函数调用的那一层。这模拟了真实语言里的"栈帧弹出"。

环境(Environment)

变量存在哪?答案是一个"环境"——本质上就是栈上的 HashMap 链。每进入一个作用域,就创建一个新环境,父环境是外层的那个。

pub struct Environment {
    vars: HashMap<String, Value>,
    parent: Option<Box<Environment>>,
}
 
impl Environment {
    // 查找变量:先在当前环境找,找不到就去父环境找
    pub fn get(&self, name: &str) -> Option<&Value> {
        self.vars.get(name).or_else(|| self.parent.as_ref()?.get(name))
    }
 
    // 赋值(x = 20):如果父环境有这个变量,更新父环境的
    pub fn set(&mut self, name: String, value: Value) {
        if let Some(parent) = &mut self.parent {
            if parent.get(&name).is_some() {
                parent.set(name, value);  // 递归向上找,直到找到定义处
                return;
            }
        }
        self.vars.insert(name, value);
    }
 
    // 定义(let x = 10):只在当前作用域创建
    pub fn define(&mut self, name: String, value: Value) {
        self.vars.insert(name, value);
    }
}

setdefine 的区别define 只在当前作用域创建新变量;set 递归向上找到变量的定义处再更新。这就是 while 循环里 i = i + 1 能工作的秘密。

求值器

运行时的值用一个枚举表示:

pub enum Value {
    Number(f64),
    String(String),
    Bool(bool),
    Fn { params: Vec<String>, body: Vec<Stmt>, env: Environment },  // 闭包:捕获定义时的环境
    Null,
}

核心:eval_expr 递归求值。 遇到叶子节点直接返回值,遇到运算节点递归计算子节点:

fn eval_expr(&mut self, expr: &Expr) -> Result<Value, String> {
    match expr {
        // 叶子节点:直接返回
        Expr::Number(n) => Ok(Value::Number(*n)),
        Expr::Ident(name) => self.env.get(name).cloned()
            .ok_or_else(|| format!("undefined variable: {}", name)),
 
        // 二元运算:先算左右子树,再做运算
        Expr::BinaryOp { op, left, right } => {
            let l = self.eval_expr(left)?;
            let r = self.eval_expr(right)?;
            self.eval_binop(op, &l, &r)
        }
 
        // 函数调用:最复杂的部分
        Expr::Call { func, args } => {
            if func == "print" { /* 内置函数,直接处理 */ }
 
            let func_val = self.env.get(func).cloned()
                .ok_or_else(|| format!("undefined function: {}", func))?;
 
            match func_val {
                Value::Fn { params, body, env } => {
                    // 1. 创建新环境,父环境是定义时的环境(不是调用时的!)
                    let mut fn_env = Environment::extend(env);
                    // 2. 绑定参数
                    for (param, arg) in params.iter().zip(args) {
                        fn_env.define(param.clone(), self.eval_expr(arg)?);
                    }
                    // 3. 在新环境里执行函数体
                    let mut interp = Interpreter { env: fn_env };
                    for stmt in &body {
                        if let ControlFlow::Return(v) = interp.exec_stmt(stmt)? {
                            return Ok(v);  // return 立即退出
                        }
                    }
                    Ok(Value::Null)
                }
                _ => Err(format!("{} is not a function", func)),
            }
        }
        // ... 其他节点类型
    }
}

函数调用那三步就是闭包的核心——用定义时的环境,不是调用时的环境。如果换成调用时的环境,内层函数就访问不到外层变量了。

语句执行(exec_stmt)和二元运算(eval_binop)的代码和其他 match 分支结构类似,这里不全部展开。关键模式是:每进入一个块(if/while),就 Environment::extend 创建子环境;每遇到 ControlFlow::Return,就一路往上传退出函数。


串起来:主程序

fn main() {
    let args: Vec<String> = std::env::args().collect();
    if args.len() < 3 || args[1] != "run" {
        eprintln!("Usage: mini run <file>");
        std::process::exit(1);
    }
 
    let source = std::fs::read_to_string(&args[2])
        .expect("failed to read file");
 
    // 词法分析
    let mut lexer = Lexer::new(&source);
    let tokens = lexer.tokenize().unwrap_or_else(|e| {
        eprintln!("Lexer error: {}", e);
        std::process::exit(1);
    });
 
    // 语法分析
    let mut parser = Parser::new(tokens);
    let stmts = parser.parse().unwrap_or_else(|e| {
        eprintln!("Parser error: {}", e);
        std::process::exit(1);
    });
 
    // 执行
    let mut interp = Interpreter::new();
    interp.run(&stmts).unwrap_or_else(|e| {
        eprintln!("Runtime error: {}", e);
        std::process::exit(1);
    });
}

写一个测试文件 test.mini

fn fibonacci(n) {
    if n <= 1 {
        return n;
    }
    return fibonacci(n - 1) + fibonacci(n - 2);
}
 
let i = 0;
while i < 10 {
    print(fibonacci(i));
    i = i + 1;
}

跑一下:

$ cargo run -- run test.mini
0
1
1
2
3
5
8
13
21
34

能跑斐波那契,说明递归、函数调用、条件判断、循环全部通了。整个解释器核心代码 400 行左右。


进阶:接下来可以做什么

这个 mini 解释器是 tree-walking 的——直接遍历 AST 求值。优点是实现简单,缺点是慢(每次执行都要遍历树结构)。如果想继续深入,有几个方向:

1. Bytecode VM(字节码虚拟机)

把 AST 编译成字节码(一种线性的指令序列),然后用一个虚拟机执行。Python、Lua、Ruby 都是这么做的。

// AST: 1 + 2 * 3
// 字节码:
Push 1
Push 2
Push 3
Mul
Add

字节码比 AST 紧凑得多,cache friendly,而且可以做很多优化(常量折叠、指令合并)。写一个 bytecode VM 是理解 Python/JS 引擎的最好方式。

2. 垃圾回收(GC)

现在我们的 Value::Fnclone 整个环境。真实语言不会这么干——它们用引用计数或者 tracing GC。Rust 的 Rc<RefCell<>> 可以快速原型一个引用计数 GC,但要处理循环引用就得上 Weak 或者写一个 mark-sweep。

3. 类型系统

给 mini 语言加类型检查。在求值之前跑一遍类型推导,报出 1 + "hello" 这种错误。这会用到 unification 算法——和 Haskell/Rust 的类型推导是同一个东西。

4. 用 Rust 的 enum 做 pattern matching

你会发现 Rust 写解释器特别舒服。match expr { Expr::Number(n) => ..., Expr::BinaryOp { op, left, right } => ... } 这种模式匹配,比 Java/Python 里的 if isinstance(expr, Number) 优雅太多。Rust 的穷尽性检查还保证你不会漏掉某个分支——编译器帮你找 bug。


龙书到底要不要读

我的建议:先写代码,再读书。

写完这个 mini 解释器之后,你会发现龙书里的"词法分析"、"语法分析"、"语法制导翻译"这些概念,你已经用过了——只是不知道它们的学术名字。这时候再翻龙书,每个概念都有对应的代码,理解效率翻倍。

龙书真正有价值的是后半部分:优化、代码生成、数据流分析。前半部分——lexer、parser、AST——动手写一遍比看十遍书管用。

如果觉得龙书太厚,推荐两本更友好的:

  • 《Crafting Interpreters》(Bob Nystrom)—— 免费在线,用 Java 和 C 写两个完整的解释器,从 tree-walking 到 bytecode VM
  • 《Writing An Interpreter In Go》(Thorsten Ball)—— 用 Go 写,但思路可以平移到 Rust,代码量小,节奏快

总结

从字符串到可执行的程序,三道门:

  1. Lexer:字符 → Token。纯机械扫描,一个 match 搞定。
  2. Parser:Token → AST。递归下降处理优先级,每层一个函数。
  3. Interpreter:AST → 结果。环境链管理变量,递归遍历求值。

400 行 Rust,一个能跑递归斐波那契的解释器就出来了。没有魔法,没有黑箱。

编译原理不是天书。它是你每天都在用的东西——cargo build 干的就是这三件事,只不过它多了一步"生成机器码"。理解了前三步,第四步只是时间问题。

动手写一个吧。从 1 + 1 开始,加到能跑斐波那契,你会发现——编译原理,原来这么好玩。