十道题:你以为你懂求值
下面十段代码,你每天都在写。它们全都不是脑筋急转弯 —— 每一段都是你在真实项目里敲过的东西。请你先猜输出,再展开看答案。然后问自己一个更狠的问题:不是「我猜对了吗」,而是「我能把为什么讲清楚吗」。这两件事之间的距离,就是这本书存在的理由。
先说清楚这一章要干什么。
你是一个能干活的工程师。你写 Kotlin 或者 JS,写了很多年,代码能上线,review 别人的时候你是挑毛病的那个。这一章不打算教你任何新东西 —— 它要做的只有一件事:让你发现,有十个地方你其实一直是靠背的。
「靠背」不丢人。绝大多数工程师对绝大多数语言特性都是靠背的:for (let …) 比 for (var …) 好,因为「闭包会捕获」;Sequence 比 List 省,因为「它是惰性的」;suspend 函数不能随便调,因为「它需要协程作用域」。这些规则你都记得住,也都用得对。
但记住规则和推导出规则,是两种完全不同的状态。前者在遇到规则没覆盖的新情况时会当场失灵,后者不会。这本书就是把你从前者搬到后者 —— 而搬运的路线,恰好要绕道一门叫 Racket 的语言。
为什么要绕这个远路,第 2 章会说。现在,先做题。
不要跳着看答案。每道题先在心里给一个输出,再展开。答案里我会告诉你它对应本书的哪一章 —— 十道题就是这本书的目录,只是伪装成了一份体检报告。
第一组:你其实不知道闭包捕获了什么
const fns = [];
for (var i = 0; i < 3; i++) {
fns.push(() => i);
}
console.log(fns.map(f => f()));
看答案
[3, 3, 3] 这题大概率你答对了 —— 它太有名了。但请回答第二个问题:为什么?
常见的解释是「闭包捕获了变量 i,而循环结束时 i 已经是 3 了」。这个解释是对的,但它偷偷跳过了最关键的一环:「捕获」到底捕获了什么?
如果闭包捕获的是值,那三个闭包应该分别记住 0、1、2。可它们没有。所以闭包捕获的不是值,是盒子 —— 是那个叫 i 的存储位置本身。三个闭包记着同一个盒子,循环结束后盒子里躺着 3,于是它们一起看到 3。
那 let 为什么就好了?因为 let 让每一轮迭代拥有一个新的盒子。这不是「let 捕获值、var 捕获引用」—— 两者都捕获盒子,区别在于有几个盒子。
这个「盒子」有个正式的名字,叫环境帧。一旦你能把环境帧画出来,这道题连同它的十几个变种(Kotlin 的 lambda 捕获、Java 的 effectively final、Python 的 late binding)会一起变成常识。→ 第 5 章
fun myIf(cond: Boolean, then: Int, otherwise: Int): Int =
if (cond) then else otherwise
// 用它来做一个「安全除法」
fun safeDiv(x: Int): Int = myIf(x != 0, 100 / x, 0)
println(safeDiv(0))
看答案
ArithmeticException: / by zero 崩溃。
myIf 是一个普通函数。调用一个函数之前,Kotlin 必须先把所有参数算成值 —— 包括 100 / x。所以 x = 0 时,除零发生在 myIf 的第一行代码执行之前。cond 是不是 false 根本不重要,我们压根没走到那一步。
换句话说:你无法用函数实现 if。
这句话值得多读两遍,因为它的推论比它本身更重要 —— if、&&、||、?:、?. 这些东西全都不是函数,它们是语法。它们能「短路」、能「只算一支」,唯一的原因是编译器给了它们特权:不按普通调用规则来。
而你,作为一个用户,拿不到这个特权。你没法给 Kotlin 加一个自己的短路运算符,也没法写一个 unless。你只能等语言设计者发慈悲。
(Kotlin 给了一条缝:inline 函数 + lambda 参数,可以做到接近的效果。为什么是「接近」而不是「等同」,第 7 章会讲。而在 Racket 里,你可以真的加一个 unless,四行,而且它和内置的 if 是同一个等级的公民。→ 第 7 章、第 18 章)
fun slow(n: Int): Int { println("算了 $n"); return n * n }
val a = listOf(1, 2, 3, 4, 5)
.map { slow(it) }
.filter { it > 5 }
.first()
val b = listOf(1, 2, 3, 4, 5)
.asSequence()
.map { slow(it) }
.filter { it > 5 }
.first()
看答案
a:5 次b:3 次
a 走的是 List:map 是急切的,它老老实实把 5 个全算了,生成一个新列表 [1,4,9,16,25],然后 filter 再遍历一遍,最后 first() 拿走第一个(9)。前面四个的计算,有两个是白干的。
b 走的是 Sequence:它一次搬一个元素走完整条链。1 → 算出 1 → 不过 filter;2 → 算出 4 → 不过;3 → 算出 9 → 过了 → first() 满足了,立刻停。4 和 5 从来没被碰过。
你大概率答对了。那么第二个问题:Sequence 凭什么能做到?
它的秘密是:Sequence.map 没有真的做 map。它只是返回了一个「记着我要做 map 这件事」的对象。真正的计算,要等到有人来要元素时才发生。
这个「先记着,别算,等人要了再算」的模式有个名字:惰性求值。Kotlin 用 Sequence 模拟它,Java 用 Stream,JS 用 function*,C# 用 IEnumerable —— 四种语言,四套 API,全是在给同一个缺失的语言特性打补丁。而在 Haskell 里,这是默认行为;在 Racket 里,你用 delay 三行就能自己造一个。→ 第 12 章
第二组:你其实不知道调用栈是什么
// 不加 tailrec:n = 100000 时 StackOverflowError
fun sum(n: Int, acc: Int = 0): Int =
if (n == 0) acc else sum(n - 1, acc + n)
// 加了 tailrec:跑得飞快,不崩
tailrec fun sum2(n: Int, acc: Int = 0): Int =
if (n == 0) acc else sum2(n - 1, acc + n)
两个函数的函数体一模一样。既然编译器有能力把它优化成循环(sum2 证明了这一点),为什么它不默认对 sum 也这么做?
看答案
这道题没有「输出」,只有理由。而理由有两层,大部分人只知道第一层。
第一层:JVM 不支持尾调用。字节码里没有「复用当前栈帧去调用另一个方法」这条指令。所以 tailrec 不是 JVM 干的活儿 —— 是 Kotlin 编译器把你的递归改写成了 while 循环。反编译一下 sum2,你会看到一个货真价实的 while。
第二层(更有意思的):为什么要你显式写这个关键字?
因为「这个调用到底在不在尾位置」是一件很容易搞错的事,而搞错的后果是静悄悄的性能悬崖。看这个:
tailrec fun f(n: Int): Int =
if (n == 0) 0 else 1 + f(n - 1) // ← 编译器报错!
这里的 f(n-1) 不在尾位置 —— 它返回之后还要做一次加法。Kotlin 会当场报错:「a function is marked as tail-recursive but no tail calls are found」。
这个设计很聪明:你声明意图,编译器验证意图。如果它默默地「能优化就优化」,你就永远不知道自己那个函数到底会不会爆栈 —— 直到线上炸了。
而在 Racket 里,尾调用不是优化,是语言标准的强制要求。任何处在尾位置的调用必须复用栈帧,不允许实现者偷懒。所以 Racket 程序员用递归写循环是天经地义的,根本不需要一个关键字来求编译器开恩。
顺便说个反直觉的:上面那个非尾递归的 1 + f(n-1),在 Racket 里递归十万层也不会崩。为什么?因为 Racket 的栈在堆上,能一直长。它只是费内存(O(n)),不会 StackOverflowError。会当场崩的是 JVM 和 V8 —— 它们的栈是一块固定大小的内存。→ 第 8 章
function* g() {
const x = yield 1;
console.log("x =", x);
return x * 2;
}
const it = g();
console.log(it.next()); // ?
console.log(it.next(42)); // ?
看答案
{value: 1, done: false} 然后打印 x = 42,再 {value: 84, done: true}
如果你答对了,恭喜 —— 你比大多数 JS 程序员更懂生成器。但真正的问题在这里:
第一次 next() 时,函数停在了 yield 1 这个位置。它停下来的时候,「函数体里还没跑完的那一半」去哪了?它显然没有消失 —— 因为第二次 next(42) 时它又从那里接着跑了,而且 42 变成了 yield 表达式的返回值,赋给了 x。
所以:JS 引擎把「函数剩下的部分」保存成了一个东西,存在了某个地方。这个「函数剩下的部分」——「拿到一个值之后接下来要做的全部事情」——就是续延(continuation)。
yield 做的事一共三件:① 把「函数剩下的部分」打包存起来;② 跳回调用者(把 1 交给它);③ 下次 next(42) 时,把 42 塞进那个包,让它接着跑。
这不是 JS 的发明。1975 年 Scheme 就把这个能力直接交给用户了,而且是不加任何限制的完全体,叫 call/cc。你在第 14 章会亲手用它造出生成器 —— 大约二十行。→ 第 13、14、15 章
suspend fun loadUser(id: String): User {
val json = api.fetch(id) // 挂起点
return parse(json)
}
把它编译成字节码再反编译回 Java,函数签名会变成什么样?提示:它多了一个参数,而那个参数的类型名,就是这道题的答案。
看答案
它变成了:
// 反编译后(示意) fun loadUser(id: String, cont: Continuation<User>): Any
Continuation<T> —— 「续延」。
这不是巧合,也不是 JetBrains 随手起的名字。它就是上一题里那个东西。
每一个 suspend 函数,编译器都会偷偷加一个参数:Continuation,它代表「这个函数返回之后,接下来要做的一切」。函数走到挂起点时,把这个续延交给调度器,然后自己 return 走人,把线程还回去。等结果到了,调度器再拿着这个续延,让计算从当初停下的地方接着跑。
这套把戏有个名字:CPS 变换(Continuation-Passing Style,续延传递风格)。它是 Scheme 社区在 1970 年代发明并用烂了的技术。Kotlin 的协程不是什么新东西 —— 它是编译器替你做了一遍 Racket 程序员手写了四十年的事。
知道这一点有什么用?用处大了。一旦你明白 suspend 的本质是「多传一个『接下来要做什么』的参数」,那么下面这些原本要死记的规则,会全部变成推论:
- 为什么
suspend函数只能在suspend函数里调用?因为它需要一个续延,而只有 suspend 上下文里才有。 - 为什么普通的 lambda 里不能调
suspend?因为普通 lambda 的签名里没有那个参数。 - 为什么
suspend函数的「返回」可能发生在另一个线程上?因为恢复它的是调度器,调度器爱在哪儿跑就在哪儿跑。
→ 第 15 章(那一章你会看到左边 Racket 手写、右边 Kotlin 编译器生成,两栏并排)
第三组:你其实不知道代码和数据的边界在哪
#define SWAP(a, b) { int tmp = a; a = b; b = tmp; }
int tmp = 1, y = 2;
SWAP(tmp, y);
printf("%d %d\n", tmp, y);
看答案
1 2 —— 没换成。而且编译器一句警告都不给。
展开之后是这样:
{
int tmp = tmp; // ← 宏的 tmp 遮住了我的 tmp
tmp = y; // ← 改的是宏的那个
y = tmp;
}
宏作者用了一个叫 tmp 的临时变量。调用者碰巧也有一个叫 tmp 的变量。于是宏里那个 int tmp 把外面的遮住了,整段代码在一个错误的变量上忙活了半天。
这个现象叫变量捕获(variable capture)。它是「宏 = 邪恶」这个流传了几十年的偏见的直接来源。
C 程序员的解法是命名约定:把它叫 __swap_tmp_x9z_,然后祈祷没人重名。这不是解法,这是希望。
而 Racket 的宏在语言层面上不可能发生这件事。宏里引入的每一个绑定都会自动改名成一个宇宙中独一无二的名字 —— 你想撞都撞不上(真要撞得显式请求,见第 20 章)。这个性质叫卫生(hygiene),它不是一个补丁,它是宏系统设计的起点。
顺带一提:Rust 的 macro_rules! 直接抄的 Racket 的 syntax-rules —— 连名字、连省略号语法、连卫生规则都抄了。你如果写过 Rust 宏,第 18 章会让你有一种回家的感觉。→ 第 19 章
在 Java / Kotlin 里,如果你想在编译期生成代码(比如 Room、Dagger、Moshi 干的事),你得学一整套东西:javax.annotation.processing、javax.lang.model、Element、TypeMirror、KSP 的 KSClassDeclaration、JavaPoet / KotlinPoet 的构建器 DSL……几千行 API,一整套并行的类型体系。
问题:为什么?为什么「操作代码」需要一整套专门的 API?
看答案
因为在这些语言里,「代码」和「数据」是两种东西。
你的程序在运行时能操作的,是 String、List、User 这些数据。而你写的那些 if、class、fun ——那些是代码,它们在编译后就蒸发了,运行时根本不存在。
所以,当你想「写一个程序去生成另一个程序」时,你面对的第一个问题是:我拿什么来表示那个被生成的程序?
Java 的答案是:我们再造一套数据类型来模拟代码。于是有了 Element、TypeMirror、MethodSpec…… 这是一整个平行宇宙,一套「代码的影子」。你要先把它学会,才能开始干活。KotlinPoet 的 FunSpec.builder("foo").addStatement(...) 就是在用字符串拼一个函数。
而在 Lisp 里,这个问题从一开始就不存在。
因为 Lisp 的代码本来就是数据。(+ 1 2) 是一段代码,同时它字面上就是一个三元素的表:第一个元素是符号 +,后面两个是数字。你想操作它?用 car、cdr、map —— 就是你操作任何列表的那些函数。你想生成代码?造一个表就行了。
这个性质叫同像性(homoiconicity)。它是 Lisp 最核心、也是最难向别人解释清楚的那个优势 —— 因为它不是「多了一个特性」,而是少了一堵墙。
KSP 之所以难写,Babel plugin 之所以难写,不是因为写它们的人笨。是因为他们在跟一堵不该存在的墙搏斗。→ 第 4 章、第 20 章
listOf(1, 2, 3).fold("") { acc, x -> acc + x } // "123"
// ↑ ↑
// 累加器 元素
listOf(1, 2, 3).foldRight("") { x, acc -> acc + x } // "321"
// ↑ ↑
// 元素 累加器
Kotlin 标准库这么设计,是随手拍的脑袋吗?还有:为什么一个得到 "123",另一个得到 "321"?
看答案
不是拍脑袋。这个参数顺序,忠实地反映了两种折叠在结构上的区别 —— 而这个区别在 Lisp 里是肉眼可见的。
foldRight(Racket 里叫 foldr)是这样展开的:
(foldr f init '(1 2 3)) = (f 1 (f 2 (f 3 init))) ; ↑ ↑ ; 元素 「右边折完的结果」 ← 所以元素在前
fold(Racket 里叫 foldl)是这样的:
(foldl f init '(1 2 3)) = (f 3 (f 2 (f 1 init))) ; 它带着一个累加器从左往右走,边走边算
参数顺序反映的是「谁在里面、谁在外面」。一旦你把这两个展开式写出来,参数顺序就不再需要记了 —— 它是推导出来的。
而「为什么一个是 123 一个是 321」,答案同样在展开式里:foldRight 从最右边的元素开始拼,fold 从最左边开始拼,而字符串拼接不满足交换律,所以顺序反了。
顺便,一个能让你终身受用的调试技巧:搞不清 fold 方向的时候,把函数换成 cons 跑一遍。
(foldr cons '() '(1 2 3)) ; ⇒ '(1 2 3) 原样复制 —— foldr 保形 (foldl cons '() '(1 2 3)) ; ⇒ '(3 2 1) 顺手反转 —— foldl 反向
答案立刻自己现形。还有一件事:map、filter、reverse、length 全都是 fold 的特例,第 10 章会一个一个造给你看。→ 第 10 章
你现在能写出一门语言的解释器吗?不是玩具计算器 —— 是一个支持变量、函数定义、闭包、递归、条件的、真的能跑的解释器。
需要多久?一周?一个月?
看答案
大约一百行,一个下午。
而且核心只有两个互相调用的函数:
(define (my-eval expr env) ; 把表达式变成值
(cond
[(number? expr) expr]
[(symbol? expr) (lookup expr env)]
[(eq? (car expr) 'lambda) (make-closure expr env)]
[(eq? (car expr) 'if) ...]
[else (my-apply (my-eval (car expr) env)
(map (lambda (e) (my-eval e env)) (cdr expr)))]))
(define (my-apply f args) ; 把函数用在值上
(if (primitive? f)
(apply-primitive f args)
(my-eval (closure-body f)
(extend-env (closure-params f) args (closure-env f)))))
就这些。这两个函数互相咬着尾巴 —— eval 遇到函数调用就叫 apply,apply 为了算函数体又回头叫 eval —— 一路把整棵语法树吃完。
这个东西叫元循环求值器(metacircular evaluator):用一门语言,写出这门语言自己的解释器。它是 SICP 第四章的高潮,也是这本书的高潮。
为什么它在 Racket 里只要一百行,而在 Java 里要几千行?因为前面九道题的答案在这里全部汇合了:
- 不用写词法/语法分析器 —— 因为
read已经把代码读成表了(题 8) - 不用设计 AST 的类型体系 —— 因为表就是 AST(题 8)
- 闭包只是一个「代码 + 环境」的结构体 —— 因为你已经知道闭包捕获的是环境(题 1)
- 递归函数不用特殊处理 —— 因为环境链天然支持(题 1)
if必须单独判 —— 因为它不能按普通调用来做(题 2)
而一旦你写出了这台机器,一件事会永久地改变:你会开始把所有语言都看成「某台机器上的配置」,而不是「必须背下来的规则」。这就是这本书要卖给你的东西。→ 第 21、22 章
算算分
不是「答对几道」—— 猜答案不难,很多题你在网上见过。真正的记分标准是:有几道题,你能不查资料、当场把「为什么」讲给一个初级工程师听,并且讲得让他信服?
| 能讲清的题数 | 诊断 | 这本书对你的价值 |
|---|---|---|
| 0 – 3 | 你是靠规则在编程 | 最大。你会经历好几次「原来如此」,而且是那种回不去的 |
| 4 – 7 | 你知道很多「是什么」,但零散 | 很大。这本书会把你零散的知识串成一条链子 —— 它们本来就是一个东西 |
| 8 – 10 | 你已经想过这些事了 | 那你缺的是亲手做一遍。直接从第 13 章(续延)和第 21 章(元循环)开始也行 |
十道题,其实是同一道题
回头看一眼。这十道题横跨闭包、求值顺序、惰性、栈、生成器、协程、宏、代码生成、fold、解释器 —— 看起来是十个互不相干的话题。
但它们全都是同一个问题的不同侧面:
当你写下一个表达式,机器到底做了什么?
它什么时候算(题 2、3、7 —— 求值时机)?
它在哪个环境里算(题 1 —— 环境链)?
它算完之后接下来干什么(题 5、6 —— 续延)?
这套规则本身能不能改(题 7、8、10 —— 宏与元编程)?
「求值」这两个字,装得下整本书。
你每天用的 Kotlin 和 JS,是把这四个问题的答案焊死之后发给你的成品。焊得很好,很好用 —— 但焊死了。你看不见接缝,也改不了。
Racket 不焊。它把这四个问题拆开摆在桌上,然后把螺丝刀递给你。这就是为什么绕这个远路是值得的:你不是去学一门新语言,你是去看一眼你天天在用的那台机器的内部。
暂时还没有代码可带。但有一个提问的姿势可以从现在开始用:
下次你在 Kotlin 或 JS 里遇到一个「反直觉」的行为,别急着去 Stack Overflow 抄一个解法。先问这四个问题:
- 什么时候算的?(是不是我以为没算的东西其实已经算了?)
- 在哪个环境里算的?(这个变量指的到底是哪个盒子?)
- 算完之后去哪了?(这个值接下来交给谁?谁在等它?)
- 这条规则是语法还是函数?(我能不能自己写一个同样的东西?)
这四个问题能解释掉你职业生涯里绝大多数的「见鬼了」。这本书剩下的 24 章,就是在把这四个问题的答案,一个一个地给你。
下一章,我们去把 Racket 装上 —— 然后我会给你看两个大多数教程都不讲、但会成为你这本书里最好的朋友的工具。