卷 III · 数据CH 12深度 12/25

惰性与流:无穷是可以计算的

这一章我要给你一个包含所有自然数的列表。不是「很大」,是无穷。然后我们对它 mapfilter,从里面筛出所有素数,最后取前十五个 —— 而这一切只需要三行代码。这不是魔术,它的机制你在第 7 章已经见过了:把「一个值」换成「一个能算出这个值的函数」。而这个机制,正是 Kotlin 的 Sequence、JS 的 function*、Java 的 Stream 共同的祖先。但惰性是要付账的,这一章也会把账单摊开。

delay/forcethunk无穷流Sequencegenerator

先造一个「还没算的值」

(define p (delay (begin (displayln "[真的算了]") (* 6 7))))

; ← 到这里,屏幕上什么都没有。它还没算。

(force p)     ; 打印「[真的算了]」,然后 ⇒ 42
(force p)     ; ⇒ 42     ← 但这次没有打印!它记住了结果

delay 把一个表达式包成一个「承诺」(promise):我算出这个值,但我先不算。force 是来讨债的:现在给我。

而且它只算一次 —— 第二次 force 直接返回缓存的结果。这个「算一次然后缓存」的行为叫 memoization,它是惰性求值的标配(否则每次访问都重算,惰性就成了纯粹的浪费)。

◆ delay 是什么做的?就是一个 lambda

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「显式」意味着你知道自己在付什么钱。

回流

⟲ 回流 · 你的语言里的流,和一条实用的判据
语言惰性序列怎么造无穷的
KotlinSequence<T>generateSequence(1) { it + 1 }
sequence { var i = 0; while(true) yield(i++) }
JS / TSGenerator / Iteratorfunction* nat() { let i = 1; while (true) yield i++; }
JavaStream<T>Stream.iterate(1, i -> i + 1)
Kotlin 协程Flow<T>异步版的惰性流 —— 同一个思想 + 挂起

什么时候用 Sequence(这条判据能直接用):

情况为什么
提前退出firstfindanytake(n)Sequence省下后面全部的计算。收益可能是几个数量级
链很长(mapfiltermap → …)且集合Sequence避免每一步都生成一个中间列表
数据源无穷或很大(文件行、数据库游标)Sequence不然内存直接爆
集合(几十、几百个元素)ListSequence 的 thunk 开销反而更贵。别为了「显得高级」而用
只有一步操作List没有中间集合可省,Sequence 纯亏

一句话:Sequence 的收益来自「不做无用功」,所以只有当「有大量无用功可省」时它才划算。把它当成一个优化手段,而不是一种风格

还有一个陷阱:不要在 Sequence 的 lambda 里放副作用。你以为是按阶段执行的,实际是按元素交错执行的(看上面那个打印顺序)。日志会乱,计数器会错,而且极难查。

卷 III 结束

四章下来,你有了一套完整的数据处理方法论:

  • 设计配方(第 9 章):数据的形状决定代码的形状
  • fold(第 10 章):那个形状本身可以被抽象成函数
  • ADT + match(第 11 章):让非法状态无法表示
  • 惰性(第 12 章):把「算法」和「要多少」分开

这四章是全书最「实用」的部分 —— 你明天就能用上。

而从下一章开始,我们要去一个更深的地方。

卷 IV 只讲一个概念。这个概念你每天都在用 —— 你用它写 throw、写 return、写 yield、写 await —— 但你从来不知道它的名字。

而当你知道之后,Kotlin 那个 Continuation<T> 接口,会突然变得一目了然。