3.2 環境與閉包:以 Rust 實作詞法作用域#
問題:第 3.1 節的環境有三個缺陷#
上節的 eval 用 Rc<HashMap> 表達環境,Let 每進一層就整個 clone。這在語意上有一個正確性問題與兩個效能問題:
- 正確性:函式值
Value::Fn捕獲環境的方式,必須實現詞法作用域(lexical scoping)——函式看到的是「它被定義時」的環境,不是「它被呼叫時」的環境。ALGOL 60 報告首次精確定義作用域,SICP 與 Scheme 的 "funarg problem"(1970s)則證明:直譯器若不小心,很容易實現成動態作用域(dynamic scoping)——那是 bug,不是特性。 - 效能:每層
clone整個 map 是 O(環境大小),深巢狀時平方級。 - 效能:變數查詢用字串 hash,每次存取都付 hash 成本。
環境鏈:作用域的鏈結串列表示#
標準解法(LISP 1.5 到所有現代直譯器都在用):環境是一層層 frame 組成的鏈,查詢沿鏈向上找:
graph LR
E3[Frame 3<br/>b = 20] --> E2[Frame 2<br/>a = 10] --> E1[Frame 1<br/>全域]
查詢_b -->|Frame 3 命中| R[找到]
查詢_a -->|Frame 3 未中| E2 --> R2[找到]// src/env.rs
use crate::value::Value;
use std::rc::Rc;
#[derive(Debug)]
pub struct Env {
pub vars: Vec<(String, Value)>, // 這一層的綁定
pub parent: Option<Rc<Env>>, // 外層環境
}
impl Env {
pub fn new_root() -> Rc<Env> {
Rc::new(Env { vars: Vec::new(), parent: None })
}
pub fn child(parent: &Rc<Env>) -> Rc<Env> {
Rc::new(Env { vars: Vec::new(), parent: Some(parent.clone()) })
}
pub fn define(&mut self, name: String, val: Value) {
self.vars.push((name, val));
}
pub fn get(&self, name: &str) -> Option<&Value> {
for (n, v) in &self.vars {
if n == name { return Some(v); }
}
self.parent.as_ref().and_then(|p| p.get(name))
}
pub fn set(&mut self, name: &str, val: Value) -> bool {
for (n, v) in self.vars.iter_mut() {
if n == name { *v = val; return true; }
}
false // 外層修改需要 Rc<RefCell<>>,見下文
}
}
進入作用域 = Env::child(建立新 frame,O(1)),離開作用域 = drop Rc<Env>(O(1))。上節的整表 clone 消失了。
閉包:函式捕獲「定義時」的環境#
閉包(closure)一詞來自理論電腦科學的 λ 演算:一個函式值 = λ 項 + 其自由變數的繫結環境。實作上,Value::Fn 裡放一個 Rc<Env> 即可:
// src/value.rs(更新)
pub struct Lambda {
pub params: Vec<String>,
pub body: Expr,
pub env: Rc<Env>, // 定義時的環境
}
Let 與 Fn 的求值:
// src/interp.rs(更新 Let 分支)
Expr::Let { name, value, body } => {
let v = eval(value, env)?;
let new_env = Env::child(env);
// 注意:new_env 要先可變借用插入,再讓 v 可能含對 env 的引用
match v {
Value::Fn(mut lam) => { /* 函式定義走 Fn 分支 */ }
other => { Env::define(&mut /* ... */, name.clone(), other); }
}
eval(body, &new_env)
}
函式值的關鍵行為——閉包在定義處捕獲環境:
Expr::FnLit { params, body } => {
Ok(Value::Fn(Rc::new(Lambda {
params: params.clone(),
body: (**body).clone(),
env: env.clone(), // 捕獲「現在」的環境
})))
}
用一個例子驗證詞法作用域:
let make_adder = fn(n) { fn(x) { x + n } };
let add5 = make_adder(5);
add5(10) // 15
執行時序:
sequenceDiagram
participant M as make_adder(5)
participant F as 內層 fn(x)
M->>M: 求值 fn(x){x+n},捕獲 env{n=5}
M-->>F: 回傳閉包(內含 n=5)
Note over F: 此時 make_adder 早已返回
F->>F: x=10, 沿閉包鏈找到 n=5
F-->>F: 回傳 15如果實現成動態作用域(呼叫時環境),add5(10) 會在呼叫者的環境找 n,找不到就報錯——這就是 funarg 問題的實體。閉包的 Rc<Env> 讓被引用的環境在函式返回後仍然存活。
Rust 的借用檢查如何逼出正確設計#
寫這段程式碼時,Rust 會強迫你面對三個直譯器的本質問題:
1. 為什麼環境要用 Rc 而不是所有權#
閉包捕獲環境後,環境同時被:呼叫者的棧(執行中的函式)、其他閉包、外層作用域引用。這是多所有者共享——Rust 單一所有權模型下必須用 Rc 表達。C 直譯器同樣需要引用計數或 GC,只是 C 要手動寫,忘寫就洩漏或 double-free。
2. 為什麼 Lambda.env 是 Rc<Env> 而 Env.parent 也是 Rc<Env>#
因為環境圖不是樹而是 DAG——多個閉包可以共享同一個外層 frame。Rc 的引用計數保證最後一個使用者 drop 時 frame 才被釋放。直譯器的記憶體管理在這裡第一次「長出 GC 的影子」:Rc 就是引用計數 GC 的最簡形式。真正的 tracing GC(如 CPython、JVM)在第 5 章 VM 的記憶體設計中再回來。
3. 為什麼 set(賦值給外層變數)需要 RefCell#
let counter = fn() { let n = 0; fn() { n = n + 1 } }; // 內層要改外層的 n
Rc<Env> 是共享唯讀的;要跨層修改,環境的儲存必須是內部可變的:
use std::cell::RefCell;
pub struct Env {
pub vars: Vec<(String, RefCell<Value>)>,
pub parent: Option<Rc<Env>>,
}
pub fn set(env: &Rc<Env>, name: &str, val: Value) -> Result<(), String> {
let mut cur = Some(env.clone());
while let Some(e) = cur {
for (n, v) in &e.vars {
if n == name {
*v.borrow_mut() = val;
return Ok(());
}
}
cur = e.parent.clone();
}
Err(format!("undefined variable: {name}"))
}
Rc<RefCell<Value>> 的組合有一個真實危險:循環引用導致記憶體洩漏——閉包捕獲環境,環境裡存了閉包,兩個 Rc 互指,計數永遠不歸零。這正是 1960 年 LISP GC 必須被發明的原因:引用計數不夠,需要 tracing GC(標記-清除)打破循環。Python 的實作則是「引用計數 + 循環偵測」的混合——歷史在 Rust 裡重演了一遍。
名字解析:把字串查詢提前到編譯期#
環境鏈解決了正確性,但每次存取變數仍要沿鏈比對字串。編譯式語言的解法是把它整個搬到前端:解析階段就確定每個變數綁定到哪一層 frame 的第幾個 slot,執行期只做 frame[slot] 數組存取。
// 符號表:解析階段記錄「深度 + slot」
pub struct Scope {
pub slots: HashMap<String, u16>,
pub parent: Option<Box<Scope>>,
pub depth: u8, // 環境鏈要走幾層
}
AST 的 Expr::Var 就要從 Var(String) 升級成帶解析結果的形態:
pub enum Expr {
// ...
Var { name: String, depth: u8, slot: u16 }, // 解析後填入
}
執行期查詢從 O(鏈長 × 每層綁定數) 的字串比對,變成固定的兩次索引:
fn lookup(env: &Rc<Env>, depth: u8, slot: u16) -> &Value {
let mut e = env;
for _ in 0..depth { e = e.parent.as_ref().unwrap(); }
&e.vars[slot as usize]
}
這正是 3.3 節位元碼 Load(u16)/Store(u16) 指令的來源——直譯器慢的病根,往往要在「解析與執行分離」中根治。這條思路的終點(完全靜態的 slot 分配 + 機器碼直接索引)就是編譯器本身。
習題#
- 寫出
let f = fn() { f };(自引用閉包)在 Rust 實作中會遇到的問題,並解釋為什麼這是循環引用。 - 動態作用域在什麼樣的語言裡反而是想要的?(提示:shell 的環境變數、Emacs Lisp 的 buffer-local 變數。)
- 把
Env::get改成帶depth/slot的版本,用#[bench]或簡單計時比較深巢狀下兩種查詢的差距。