卷 II · 求值CH 07深度 07/25

求值顺序:一步一步走给你看

第 1 章那个 myIf 为什么必然崩溃?为什么 && 能短路,而你自己写的 or(a, b) 函数不能?为什么 Java 里 log.debug("x=" + expensive()) 即使日志关掉了也会白白算一遍?这三个问题看起来毫不相干,但它们是同一个问题。而这个问题的答案,只需要你盯着一台把求值过程慢放的机器看两分钟。

redex应用序特殊形式短路按需求值

求值是什么:一个极其朴素的答案

◆ 求值的全部定义

反复地:找到一个「可以动手算」的子表达式,把它替换成它的值。换到没得换为止。

那个「可以动手算」的子表达式有个名字,叫 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
;       ↑ 函数位置先算

先算函数位置,再从左到右算参数。

⚠ 而 C / C++ 至今不保证

f(i++, i++) 在 C 里是未定义行为。不同编译器、不同优化级别,结果可以不一样。这是 C 里最经典的坑之一。

Java、Kotlin、JS 都明确保证从左到右,所以你可能从来没被这个坑过。但如果你写过 C/C++,你会知道这个自由度带来了多少痛苦。

教训:「未指定」不是中立的,它是把成本转嫁给了每一个读代码的人。Racket 和 Java 的选择(明确规定)是对的。

规则二:可是 if 不守这个规矩

现在看那台机器的第三个例子。

(if (> 3 2)
    (begin (display "yes") 1)
    (begin (display "no") 2))

屏幕上只出现 yesno 那一支从头到尾没有被求值过

如果 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 最被低估的优势之一 —— runletapplyusesynchronizedmeasureTimeMillis、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 的方案泄露了实现细节 —— 调用者必须配合你写 {}。你的抽象是「漏」的。
  • 宏的方案不泄露 —— 你定义的东西和内置语法无法区分whenforcond 在 Racket 里全都是宏,而你用它们的时候完全感觉不到。

Kotlin 的 inline 让你能推迟求值。
宏让你能改变语法

这是卷 V 的主题。

另一条路:如果默认就不求值参数呢

看那台机器的最后一个例子。

((lambda (x) 1) (loop-forever))

应用序(Racket、Kotlin、JS):先算参数 → (loop-forever) 永远算不完 → 死循环。虽然函数体压根用不到 x

正规序(normal order):不算参数,直接把表达式代进函数体 → 函数体是 1x 从没被用到 → (loop-forever) 一次都没被碰过立刻返回 1

应用序(按值调用)正规序 / 惰性(按需调用)
什么时候算参数调用之前,全算用到的时候才算,用不到就不算
谁用它Racket、Kotlin、Java、JS、Python、GoHaskell;Racket 的 #lang lazy
优点好推理(副作用发生的时机是确定的)、好优化能处理无穷结构;不浪费计算
缺点会白算;无法表达无穷副作用的时机变得极难推理;内存泄漏(thunk 堆积)难查

这就是 Haskell 能写 take 5 [1..](从无穷列表里取 5 个)的原因,也是 Kotlin 需要 Sequence、JS 需要 function*、Java 需要 Stream 的原因 —— 它们都在手动模拟正规序。

第 12 章我们会亲手造一个。

回流

⟲ 回流 · 一条能救你的判断题

每当你设计一个 API,问自己一句:「这个参数,调用者是否可能希望它不被求值?」

如果答案是「有可能」,那你就不能把它设计成普通参数。你有三个选择:

方案怎么写代价
lambda 参数fun log(msg: () -> String)调用点要写 {};不 inline 的话有对象分配
inline + lambdainline fun log(msg: () -> String)零开销,但调用点仍要写 {};有 inline 的传染性
惰性容器Sequence / Flow / Lazy整条链都要是惰性的,中途 toList() 就前功尽弃

典型的「我早该这么设计」场景:

  • 日志log.debug { "expensive: ${dump()}" } —— Kotlin 的 kotlin-logging 就是这么设计的,而 Java 的 slf4j 用的是 {} 占位符(另一种绕法:把拼接推迟到库内部)
  • 断言 / 前置检查require(cond) { "错误信息: ${buildReport()}" } —— 注意 Kotlin 标准库的 requirecheck 的第二个参数就是 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 关键字为什么是一个非常聪明的设计。