求值顺序:一步一步走给你看
第 1 章那个 myIf 为什么必然崩溃?为什么 && 能短路,而你自己写的 or(a, b) 函数不能?为什么 Java 里 log.debug("x=" + expensive()) 即使日志关掉了也会白白算一遍?这三个问题看起来毫不相干,但它们是同一个问题。而这个问题的答案,只需要你盯着一台把求值过程慢放的机器看两分钟。
求值是什么:一个极其朴素的答案
反复地:找到一个「可以动手算」的子表达式,把它替换成它的值。换到没得换为止。
那个「可以动手算」的子表达式有个名字,叫 redex(reducible expression,可归约式)。
就这样。没有别的了。
说起来太抽象。看着它走一遍。下面这台机器就是 DrRacket 的 Stepper 在做的事(第 2 章告诉过你,用教学语言可以在本机看到真的):
黄色 = 正在归约的 redex。绿色 = 刚算出来的值。变淡 = 根本没被求值的部分。
规则一:应用序 —— 先算参数,再调用
看那台机器的第一个例子。(+ 1 (* 2 (- 5 3))) 的归约顺序是从最里面开始的:先 (- 5 3),再 (* 2 2),最后 (+ 1 4)。
为什么?因为要调用一个函数,必须先把它的参数变成值。函数拿到的是值,不是表达式。
这个策略叫 应用序(applicative order),或者按值调用(call-by-value)。
Racket、Kotlin、Java、JS、Python、C、Go —— 你用过的几乎所有语言都是应用序。(Haskell 是个著名的例外,我们第 12 章会去看它。)
顺便:顺序到底是从左到右吗?
这个细节很多人没想过,而且不同语言的答案不一样。实测 Racket:
(define (tag x) (display x) x) (list (tag 'a) (tag 'b) (tag 'c)) ; 打印:abc ; ⇒ '(a b c)
Racket 保证从左到右。但注意 —— Scheme 标准(R7RS)不保证,它明确说求值顺序是未指定的,实现想怎么来都行。Racket 是主动收紧了这一条。
还有一个更细的:函数位置和参数位置,谁先算?
((begin (display "F") +) (tag 1) (tag 2)) ; 打印:F12 ; ↑ 函数位置先算
先算函数位置,再从左到右算参数。
f(i++, i++) 在 C 里是未定义行为。不同编译器、不同优化级别,结果可以不一样。这是 C 里最经典的坑之一。
Java、Kotlin、JS 都明确保证从左到右,所以你可能从来没被这个坑过。但如果你写过 C/C++,你会知道这个自由度带来了多少痛苦。
教训:「未指定」不是中立的,它是把成本转嫁给了每一个读代码的人。Racket 和 Java 的选择(明确规定)是对的。
规则二:可是 if 不守这个规矩
现在看那台机器的第三个例子。
(if (> 3 2)
(begin (display "yes") 1)
(begin (display "no") 2))
屏幕上只出现 yes。no 那一支从头到尾没有被求值过。
如果 if 是一个普通函数,这不可能发生。按应用序,调用之前必须把三个参数全算出来 —— 两句话会一起打印。
所以:
if 不是函数,它是特殊形式(special form)特殊形式 = 不按普通调用规则求值的东西。它们由求值器特殊照顾,享有「可以不算某些参数」的特权。
Racket 里的特殊形式(核心的就这么些):
| 特殊形式 | 它凭什么特殊 |
|---|---|
if / cond | 只求值一支 |
and / or | 短路:算到能定结论就停 |
quote | 一个都不求值(第 4 章) |
lambda | 不求值函数体(否则函数还没被调用就先跑了) |
define | 不求值名字(否则 (define x 1) 会先去查 x 的值,而它还不存在) |
set! | 同上,不求值左边那个名字 |
数一数:整个语言的核心特殊形式,不到十个。其余的一切都是函数或宏。
三个问题合流
现在,第 1 章那三个看起来毫不相干的问题,可以一句话全部解释掉了。
问题一:myIf 为什么崩
fun myIf(cond: Boolean, then: Int, otherwise: Int) =
if (cond) then else otherwise
myIf(x != 0, 100 / x, 0) // x = 0 时崩溃
因为 myIf 是函数,函数守应用序 —— 100 / x 在进入函数体之前就已经被算了。你写的那个 if 一次都没执行到。
问题二:&& 为什么能短路,你的函数不能
// 这个能短路
if (user != null && user.isActive) { … }
// 你写的这个不能
fun and(a: Boolean, b: Boolean) = a && b
and(user != null, user.isActive) // ✗ user 为 null 时 NPE
因为 && 是语法(特殊形式),and 是函数。语言设计者给了 && 特权,没给你。
问题三:Java 的日志为什么白算
// Java:即使日志级别是 INFO,expensiveToString() 也照样被调用
log.debug("state = " + expensiveToString());
// ↑ 这是一个参数,参数一定会被求值
因为字符串拼接是在调用 debug 之前发生的。debug 内部再怎么判断「日志级别不够,直接 return」都已经晚了 —— 钱已经花掉了。
Java 的解法是手动包一层 lambda(Java 8 之后的 Supplier):
log.debug(() -> "state = " + expensiveToString()); // ↑ 现在参数是一个「还没跑的函数」,只有真的要打印时才调用它
看清楚这个动作:把「一个值」换成「一个能算出这个值的函数」。这个动作有个名字,叫 thunk(延迟计算包)。它是「在应用序的语言里模拟惰性」的唯一手段 —— 第 12 章你会看到它的完整形态。
Kotlin 的那条缝:inline + lambda
Kotlin 做了一件其他 JVM 语言没做的事。看这个:
inline fun myIf(cond: Boolean, then: () -> Int, otherwise: () -> Int): Int =
if (cond) then() else otherwise()
myIf(x != 0, { 100 / x }, { 0 }) // ✓ 不崩了!
它成功了。为什么?因为参数现在是 lambda —— 传进去的是「一个还没跑的计算」,不是「一个算好的值」。myIf 决定调用哪个 (),另一个就永远不会跑。
而 inline 让这件事零开销:编译器把 lambda 的函数体直接搬到调用点,连那个 lambda 对象都不会被创建出来。反编译看,它和你手写 if 生成的字节码几乎一样。
所以 Kotlin 给了你一半的语言设计权。这是它相对 Java 最被低估的优势之一 —— run、let、apply、use、synchronized、measureTimeMillis、Compose 的整个 DSL,全都建立在这条缝上。
差在调用点的语法。对比:
// Kotlin:调用者必须知道并写出那对花括号
myIf(c, { 100 / x }, { 0 })
// 内置的 if:什么都不用写
if (c) 100 / x else 0
; Racket:用宏定义的 my-if,调用点和内置 if 长得一模一样 (define-syntax-rule (my-if c t e) (cond [c t] [else e])) (my-if (not (= x 0)) (/ 100 x) 0) ; ← 没有花括号,没有 lambda ; 调用者根本不知道这是个宏
这就是「特殊形式」和「高阶函数」的最后那道差别:
- Kotlin 的方案泄露了实现细节 —— 调用者必须配合你写
{}。你的抽象是「漏」的。 - 宏的方案不泄露 —— 你定义的东西和内置语法无法区分。
when、for、cond在 Racket 里全都是宏,而你用它们的时候完全感觉不到。
Kotlin 的 inline 让你能推迟求值。
宏让你能改变语法。
这是卷 V 的主题。
另一条路:如果默认就不求值参数呢
看那台机器的最后一个例子。
((lambda (x) 1) (loop-forever))
应用序(Racket、Kotlin、JS):先算参数 → (loop-forever) 永远算不完 → 死循环。虽然函数体压根用不到 x。
正规序(normal order):不算参数,直接把表达式代进函数体 → 函数体是 1,x 从没被用到 → (loop-forever) 一次都没被碰过 → 立刻返回 1。
| 应用序(按值调用) | 正规序 / 惰性(按需调用) | |
|---|---|---|
| 什么时候算参数 | 调用之前,全算 | 用到的时候才算,用不到就不算 |
| 谁用它 | Racket、Kotlin、Java、JS、Python、Go | Haskell;Racket 的 #lang lazy |
| 优点 | 好推理(副作用发生的时机是确定的)、好优化 | 能处理无穷结构;不浪费计算 |
| 缺点 | 会白算;无法表达无穷 | 副作用的时机变得极难推理;内存泄漏(thunk 堆积)难查 |
这就是 Haskell 能写 take 5 [1..](从无穷列表里取 5 个)的原因,也是 Kotlin 需要 Sequence、JS 需要 function*、Java 需要 Stream 的原因 —— 它们都在手动模拟正规序。
第 12 章我们会亲手造一个。
回流
每当你设计一个 API,问自己一句:「这个参数,调用者是否可能希望它不被求值?」
如果答案是「有可能」,那你就不能把它设计成普通参数。你有三个选择:
| 方案 | 怎么写 | 代价 |
|---|---|---|
| lambda 参数 | fun log(msg: () -> String) | 调用点要写 {};不 inline 的话有对象分配 |
| inline + lambda | inline fun log(msg: () -> String) | 零开销,但调用点仍要写 {};有 inline 的传染性 |
| 惰性容器 | Sequence / Flow / Lazy | 整条链都要是惰性的,中途 toList() 就前功尽弃 |
典型的「我早该这么设计」场景:
- 日志:
log.debug { "expensive: ${dump()}" }—— Kotlin 的kotlin-logging就是这么设计的,而 Java 的 slf4j 用的是{}占位符(另一种绕法:把拼接推迟到库内部) - 断言 / 前置检查:
require(cond) { "错误信息: ${buildReport()}" }—— 注意 Kotlin 标准库的require和check的第二个参数就是 lambda,正是为了这个 - 默认值:
map.getOrElse(key) { computeExpensiveDefault() },而不是getOrDefault(key, computeExpensiveDefault())—— 后者一定会算 - 错误路径:
?: throw IllegalStateException(buildDetailedMessage())—— 这个是安全的(?:是语法,短路),但orElse(buildDetailedMessage())(Java Optional)不安全,要用orElseGet { … }
Java Optional.orElse vs orElseGet 这个坑,坑过无数人 —— 现在你知道它的名字了:应用序。
下一章
我们已经知道了求值的顺序。下一章问一个空间问题:
这些一层套一层的调用,堆在哪儿?为什么有的会堆爆,有的不会?
而这将解释:为什么 Racket 敢用递归写循环,为什么 JS 在一万层就崩,以及 Kotlin 那个 tailrec 关键字为什么是一个非常聪明的设计。