不可变数据与解释器
一、不能改,那怎么加元素
- 这一篇对应官方 Lecture 29(Immutable Data)与 Lecture 30(Interpreters):前半段回答「数据不能改,那怎么用」,后半段回答一个更大的问题——计算机是怎么读懂并运行一段代码的。
- ⚠️ Gleam 片段本机无工具链、未编译验证;Python 部分全部实测。
在 Python 里添加元素非常自然:xs.append(1)。而不可变世界里没有 append——只能构造一个新的。
那岂不是每次加元素都要把整个列表复制一遍?不然。答案叫结构共享:新列表直接复用旧列表的尾巴,只新建一个节点。
二、不可变列表与结构共享
用一个「头 + 尾」的二元组模拟不可变链表(Gleam 的 List 就是这么存的):
1 | def to_list(pair): |
输出:
1 | [2, 3, 4] |
ys[1] is xs 为 True——新列表的尾巴就是旧列表那个对象本身,一个元素都没复制。xs 也毫发无伤。
flowchart LR
Y["ys = (1, ·)"] -->|"rest 指向 xs"| X["xs = (2, ·)"]
X --> A["(3, ·)"]
A --> B["(4, None)"]
Y -.->|"只新建了这 1 个节点"| N["新节点"]
| 操作 | 代价 | 原因 |
|---|---|---|
头部插入 [1, ..xs] |
O(1) |
只新建一个节点,尾巴复用 |
尾部追加 [..xs, x] |
O(n) |
得沿链复制到尾 |
这就是「不可变」没有想象中贵的原因:常用操作是头部插入,而它对结构共享友好。
三、递归代替循环
没有 while,遍历就只能递归。而递归时用模式匹配拆头拆尾最自然:
1 | pub fn sum_list(items: List(Int)) -> Int { |
(未在本机编译验证。)
[] 匹配空表,[first, ..rest] 拆出头和尾。Python 版的等价写法就是「靠递归传新值」:
1 | def sum_list(items): |
输出:
1 | 10 |
注意 [] -> ... 这个分支不能漏。 Gleam 的穷举检查会因此直接报编译错——这算是额外的安全网。但逻辑上该停在哪(比如规模缩到什么程度)仍然得自己想清楚,编译器不管这个。
四、尾递归:调用是不是在最后一个位置
上面那个 sum_list 有个问题:first + sum_list(rest) 里的递归调用还要参与一次加法,所以它不在尾位置。
正确的尾递归写法要把中间结果当参数传下去:
1 | pub fn sum_list(items: List(Int)) -> Int { |
(未在本机编译验证。)
判据一句话:递归调用是不是出现在函数体的最后一个位置? 是,就是尾调用,可以被优化成循环(TCO),不堆栈;不是,就会一层层堆帧。
用 Python 数一数栈深,能直观看到「规模变大、栈就越深」:
1 | max_depth = 0 |
输出:
1 | 10 |
四个元素,栈最深 5 层——递归深度跟着列表长度涨。
需要说清的一点:CPython 完全不做尾调用优化,所以在本机上 sum_acc 的栈深一样是 5,无法用 Python 演示「尾递归不涨栈」。这个差别要到 Gleam / Erlang 上才体现为「长列表会不会耗尽调用栈」。这里只讲判据,不做实测结论。
五、解释器:REPL 就是一个环
现在换话题。前面所有代码都是「Python 解释器」在跑。那解释器自己是怎么工作的?
先明确一个概念:REPL。
| 字母 | 做什么 |
|---|---|
| Read | 读入一串字符 |
| Eval | 求值 |
| 打印结果 | |
| Loop | 回到开头 |
字符串怎么变成可求值的东西?中间要过两道关:
flowchart LR
S["<b>字符流</b><br/>'+ 1 2'"] -->|"词法分析<br/>lexer"| T["<b>token 流</b><br/>+ 1 2"]
T -->|"语法分析<br/>parser"| A["<b>AST</b><br/>['+', 1, 2]"]
A -->|"eval"| V["<b>值</b><br/>3"]
AST(抽象语法树)就是一棵嵌套的列表。 ['+', 1, ['*', 2, 3]] 本身就是一棵树:根是 +,右子树是 *。
这里有一处极易混淆的地方:AST 是「数据」,不是「结果」。
1 | print(['+', 1, 2]) |
输出:
1 | ['+', 1, 2] |
['+', 1, 2] 是一棵树的表示,3 才是求值结果。混淆这两者,是所有解释器问题的总源头。
六、Eval / Apply:两条咬在一起的环
一个最小的计算器解释器,代码短得出人意料:
1 | def calc_eval(exp): |
输出:
1 | 7 |
['+', 1, ['*', 2, 3]] 先算右子树的 6,再加 1,得 7。calc_eval 对自己递归,就是在遍历那棵树。
真正有变量的解释器要引入环境(就是第一篇那个环境图里的帧):
1 | def make_env(parent=None): |
输出:
1 | 30 |
这段代码真的在跑一个解释器:x 存进环境,y 在定义时就求出了 20,最后 x + y 查到两个值算出 30。这里的 lookup 往上爬 parent,跟第一篇讲的作用域查找是完全同一套东西。
完整的解释器把这件事拆成两条环:
flowchart LR
E["<b>eval(exp, env)</b><br/>求值一个表达式"] -->|"遇到函数调用"| A["<b>apply(proc, args)</b><br/>建新帧、绑形参"]
A -->|"逐句 eval 函数体"| E
A -.->|"新帧的 parent 指向<br/>过程<b>定义处</b>的环境"| P["词法作用域<br/>(第一篇的既有规则)"]
两条环互相咬住:eval 里遇到函数调用就转 apply,apply 建好帧又回头 eval 函数体。递归深度 = 程序的嵌套深度。
而且注意 apply 建帧时那个箭头的方向:新帧的 parent 指向过程定义处的环境,不是调用处。这就是第一篇讲过的词法作用域,到这里变成了解释器的一条实现规则。一通百通。
七、小结
| 概念 | 一句话 | 取证状态 |
|---|---|---|
| 结构共享 | 头部插入只新建一个节点 | 实测:ys[1] is xs → True |
| 不可变代价 | 头插 O(1)、尾加 O(n) |
实测:xs 没被改 |
| 递归遍历 | 没 while,拆头拆尾 |
实测:sum_list → 10 |
| 尾递归判据 | 调用在函数体最后一个位置 | 实测栈深随长度涨;TCO 未实测 |
| AST 是数据 | ['+', 1, 2] ≠ 3 |
实测:type 是 list |
| eval 递归 | 对树的子节点递归就是求值 | 实测:calc_eval → 7 |
| apply 建帧 | parent 指定义处 |
实测:小解释器算出 30 |
三条能带走的:
- 不可变不等于慢。 头部插入只新建一个节点,尾巴直接复用旧列表——这叫结构共享。
- 尾递归的判据只问一句:调用是不是函数体最后一个动作。 不是,就会占用调用栈;Gleam/Erlang 会做尾调用优化,CPython 不会。
- AST 是数据,求值结果才是值。 解释器就是「对一棵树递归」;而
apply建帧时parent指向定义处,这是第一篇词法作用域的直接回响。

