惰性与流:无穷是可以计算的
这一章我要给你一个包含所有自然数的列表。不是「很大」,是无穷。然后我们对它 map、filter,从里面筛出所有素数,最后取前十五个 —— 而这一切只需要三行代码。这不是魔术,它的机制你在第 7 章已经见过了:把「一个值」换成「一个能算出这个值的函数」。而这个机制,正是 Kotlin 的 Sequence、JS 的 function*、Java 的 Stream 共同的祖先。但惰性是要付账的,这一章也会把账单摊开。
先造一个「还没算的值」
(define p (delay (begin (displayln "[真的算了]") (* 6 7)))) ; ← 到这里,屏幕上什么都没有。它还没算。 (force p) ; 打印「[真的算了]」,然后 ⇒ 42 (force p) ; ⇒ 42 ← 但这次没有打印!它记住了结果
delay 把一个表达式包成一个「承诺」(promise):我能算出这个值,但我先不算。force 是来讨债的:现在给我。
而且它只算一次 —— 第二次 force 直接返回缓存的结果。这个「算一次然后缓存」的行为叫 memoization,它是惰性求值的标配(否则每次访问都重算,惰性就成了纯粹的浪费)。
delay 不是什么底层魔法。它就是把表达式包进一个零参数的 lambda(一个 thunk),再加个缓存:
; delay 的本质(简化版)
(define-syntax-rule (my-delay expr)
(let ([done? #f] [result #f])
(lambda ()
(unless done?
(set! result expr) ; ← 第一次才真的算
(set! done? #t))
result)))
(define (my-force p) (p)) ; force 就是「调用它」
「推迟计算」的唯一手段,就是把它包进一个函数。因为函数体在被调用之前不会执行 —— 这是第 7 章「lambda 是特殊形式,不求值函数体」的直接应用。
你在 Java 里写的 Supplier<T>、在 Kotlin 里写的 () -> T、在 JS 里写的 () => expr —— 全都是同一个东西。它就是 thunk。
三行代码,造出无穷
现在是关键的一跳。一个「流」= 一个头 + 一个还没算的尾巴:
(define-syntax-rule (cons-stream a b) (cons a (delay b))) ; ← 头立刻算,尾巴推迟 (define (s-car s) (car s)) (define (s-cdr s) (force (cdr s))) ; ← 要尾巴的时候,才把它算出来
(注意 cons-stream 必须是宏,不能是函数。为什么?因为如果它是函数,按应用序,b 在传进去之前就会被求值 —— 那就前功尽弃了。这是第 7 章那个问题的又一次现身:「推迟求值」这件事,函数做不到,只有语法能做到。)
然后:
(define (integers-from n) (cons-stream n (integers-from (add1 n)))) ; ↑ 这里在递归调用自己,而且没有终止条件! (define nat (integers-from 1)) ; ← 所有自然数
盯着这三行看一会儿。
integers-from 调用自己,没有基础情况,没有终止条件。如果这是普通的递归,它会立刻栈溢出。
但它不会。因为那个递归调用被 delay 包住了 —— 它是一个「承诺」,不是一次调用。只有当有人真的去要下一个元素时,它才往前走一步。
> (s-take nat 10) '(1 2 3 4 5 6 7 8 9 10) > (s-take (s-map (lambda (x) (* x x)) nat) 5) '(1 4 9 16 25) ; ← 对无穷列表做 map > (s-take (s-filter even? nat) 5) '(2 4 6 8 10) ; ← 对无穷列表做 filter
nat 不是「一个很长的列表」。它在内存里只占几十个字节。
它是「一个数字 1,加上一个还没被逼问过的承诺」。你要第二个元素,它才不情愿地算出 2,然后又给你一个新的承诺。
惰性把「一个数据结构」变成了「一个过程」。你操作的不是数据,是一台按需吐东西的机器。
压轴:无穷的素数流
既然能 filter,那就能筛。埃拉托色尼筛法,写成流:
(define (sieve s)
(cons-stream
(s-car s) ; 头一定是素数
(sieve (s-filter ; 剩下的,先把头的倍数全滤掉
(lambda (x) (not (zero? (modulo x (s-car s)))))
(s-cdr s)))))
(define primes (sieve (integers-from 2)))
> (s-take primes 15) '(2 3 5 7 11 13 17 19 23 29 31 37 41 43 47)
四行代码,一个无穷的素数流。而且它的结构就是筛法的定义本身:「取第一个数(它是素数),把它的倍数全划掉,对剩下的重复这个过程。」
如果没有惰性,这个程序写不出来 —— 你必须先决定「我要多少个素数」,然后写一个带上界的循环。惰性让你能把「算法」和「要多少」分开描述。这是一个真正的表达力提升,不是语法糖。
看看谁被算了
惰性最实际的收益是不做无用功。下面这台机器把「谁被真的算了」标出来:
Racket 自带的流
上面那套是手工的(为了让你看见机制)。实际用的时候,Racket 有 racket/stream:
(require racket/stream) (stream->list (stream-take (in-naturals 1) 5)) ; ⇒ '(1 2 3 4 5) (stream->list (stream-take (stream-map sqr (in-naturals 1)) 5)) ; ⇒ '(0 1 4 9 16) (stream-first (stream-filter even? (in-naturals 1))) ; ⇒ 2
还有一个更狠的选择:#lang lazy。整个文件默认惰性求值,像 Haskell 一样:
#lang lazy (define nat (cons 1 (map add1 nat))) ; ← 自己引用自己! (take 10 nat) ; ⇒ '(1 2 3 4 5 6 7 8 9 10)
看那个定义:nat 的定义里用到了 nat 自己。在严格语言里这是死循环,在惰性语言里这是一个完全正常的、优雅的定义。这就是「换一个 #lang 就换一门语言」的威力(第 23 章)。
账单:惰性不是免费的
① 每个元素都要包一个 thunk。对象分配、间接调用、缓存标志位。在小数据量上,惰性比急切慢 —— 你付了机制的钱,却没省下多少计算。
② 副作用的时机变得不可预测。这是最阴险的一条:
// Kotlin:这段代码打印顺序是什么?
listOf(1, 2, 3).asSequence()
.map { println("map $it"); it * 2 }
.filter { println("filter $it"); it > 2 }
.toList()
// 输出:map 1 / filter 2 / map 2 / filter 4 / map 3 / filter 6
// ↑ 不是「map 完三个再 filter 三个」,而是一个一个走完整条链
惰性把「按阶段执行」变成了「按元素执行」。如果你的 lambda 里有副作用(日志、计数器、网络请求),执行顺序会和你的直觉完全不同。
③ 内存泄漏(thunk 堆积)。Haskell 社区最著名的坑:foldl 会攒一大串没算的 thunk,最后一次性引爆。「还没算的东西」也是要占内存的,而且它占的比算完的结果多。
Racket 的选择是明智的:默认严格,需要惰性时显式 delay。「显式」意味着你知道自己在付什么钱。
回流
| 语言 | 惰性序列 | 怎么造无穷的 |
|---|---|---|
| Kotlin | Sequence<T> | generateSequence(1) { it + 1 }sequence { var i = 0; while(true) yield(i++) } |
| JS / TS | Generator / Iterator | function* nat() { let i = 1; while (true) yield i++; } |
| Java | Stream<T> | Stream.iterate(1, i -> i + 1) |
| Kotlin 协程 | Flow<T> | 异步版的惰性流 —— 同一个思想 + 挂起 |
什么时候用 Sequence(这条判据能直接用):
| 情况 | 用 | 为什么 |
|---|---|---|
会提前退出(first、find、any、take(n)) | Sequence | 省下后面全部的计算。收益可能是几个数量级 |
链很长(map → filter → map → …)且集合大 | Sequence | 避免每一步都生成一个中间列表 |
| 数据源无穷或很大(文件行、数据库游标) | Sequence | 不然内存直接爆 |
| 集合小(几十、几百个元素) | List | Sequence 的 thunk 开销反而更贵。别为了「显得高级」而用 |
| 只有一步操作 | List | 没有中间集合可省,Sequence 纯亏 |
一句话:Sequence 的收益来自「不做无用功」,所以只有当「有大量无用功可省」时它才划算。把它当成一个优化手段,而不是一种风格。
还有一个陷阱:不要在 Sequence 的 lambda 里放副作用。你以为是按阶段执行的,实际是按元素交错执行的(看上面那个打印顺序)。日志会乱,计数器会错,而且极难查。
卷 III 结束
四章下来,你有了一套完整的数据处理方法论:
- 设计配方(第 9 章):数据的形状决定代码的形状
- fold(第 10 章):那个形状本身可以被抽象成函数
- ADT + match(第 11 章):让非法状态无法表示
- 惰性(第 12 章):把「算法」和「要多少」分开
这四章是全书最「实用」的部分 —— 你明天就能用上。
而从下一章开始,我们要去一个更深的地方。
卷 IV 只讲一个概念。这个概念你每天都在用 —— 你用它写 throw、写 return、写 yield、写 await —— 但你从来不知道它的名字。
而当你知道之后,Kotlin 那个 Continuation<T> 接口,会突然变得一目了然。