附录 B - 惰性求值(Lazy evaluation)
本附录内容包括:
- 惰性求值如何影响数据结构
- thunk 的基本模型
- 空间泄漏与时间泄漏
undefined与newtype的求值行为
Haskell 最有辨识度的特性之一是惰性求值。默认情况下,表达式不会在绑定时立刻求值,而是在结果真正被需要时才求值。这带来了一些非常优雅的编程方式,例如无限列表、按需生成数据和组合式流处理。不过,惰性也会带来性能上的意外,尤其是空间占用难以预测。
本附录不会深入运行时系统的所有细节,而是提供一个实用心智模型:什么时候表达式会被求值,什么时候只是挂起的计算,以及如何避免常见性能问题。
B.1 惰性数据结构(Lazy data structures)
先看一个简单表达式:
x = 1 + 2
在严格语言中,x 通常会立刻绑定到结果 3。在 Haskell 中,x 可以先绑定到一个尚未执行的计算。只有当程序需要知道 x 的值时,1 + 2 才会被求值。
惰性最经典的例子是无限列表:
numbers :: [Integer]
numbers =
[1..]
这个列表没有末尾,但下面的表达式仍然可以正常工作:
take 5 numbers
结果是:
[1,2,3,4,5]
原因是 take 5 只需要列表前五个元素。列表剩余部分永远不会被请求,因此也不会被生成。
这让我们可以把数据生成和数据消费分开写:
代码清单 B.1 惰性地生成候选值
evens :: [Integer]
evens =
filter even [1..]
firstTenEvens :: [Integer]
firstTenEvens =
take 10 evens
filter even [1..] 描述所有偶数,take 10 决定只消费前十个。惰性求值让这个组合可行。
惰性也适用于自定义数据结构。考虑一个简单流类型:
代码清单 B.2 无限流
data Stream a = Cons a (Stream a)
from :: Integer -> Stream Integer
from n =
Cons n (from (n + 1))
takeStream :: Int -> Stream a -> [a]
takeStream amount stream =
case amount of
0 -> []
_ ->
case stream of
Cons value rest ->
value : takeStream (amount - 1) rest
from 1 表示从 1 开始的无限流。如果严格地完整构造它,程序永远不会结束。但因为第二个字段按需求值,takeStream 5 (from 1) 可以只取需要的前五个元素。
这种风格很强大,但要理解一点:惰性不是免费优化。它推迟工作,也可能推迟太多工作。
B.2 原理(How it works)
惰性求值的核心概念是 thunk。thunk 是一个尚未求值的表达式及其所需环境。可以把它想象成一张便签,上面写着:“如果以后需要这个值,就按这个步骤计算。”
例如:
let x = expensiveComputation input
in x + x
x 初始可以是一个 thunk。当第一次需要 x 时,运行时会计算它,并把结果保存下来。第二次使用 x 时,可以复用结果,而不是重新计算。这称为共享。
Haskell 通常求值到弱头范式。这个名字听起来吓人,但直觉并不复杂:表达式只会被求值到足以看见最外层构造器。
看列表:
1 : (2 + 3) : []
当它被求值到弱头范式时,只需要知道它最外层是 (:),也就是非空列表。列表头部的 1 已经是值,但尾部中的 2 + 3 不一定需要立刻求值。
看元组:
(1 + 2, 3 + 4)
求值到弱头范式只需要知道最外层是二元组构造器。两个字段可以仍然是 thunk。
这解释了为什么 seq 有时不像初学者预期那样“完全求值”:
seq (1 + 2, 3 + 4) ()
它只强制到最外层元组构造器,不会强制两个字段变成 3 和 7。
如果需要完全求值,常用工具是 deepseq 包中的 deepseq 或 force。它依赖 NFData 类型类,把数据结构递归求值到范式。
代码清单 B.3 使用 deepseq 强制完全求值
import Control.DeepSeq
fullyEvaluate :: NFData a => a -> a
fullyEvaluate value =
force value
在普通业务代码中,不应该到处使用 deepseq。它主要用于性能边界、并行计算、资源管理和避免延迟读取导致的文件句柄问题。
B.3 空间与时间泄露(Leaking space and time)
惰性最常见的性能问题是空间泄漏。所谓空间泄漏,是指程序保留了比预期更多的内存,通常因为积累了大量未求值 thunk。
看一个求和函数:
代码清单 B.4 可能积累 thunk 的求和
sumLazy :: [Int] -> Int
sumLazy =
go 0
where
go acc values =
case values of
[] -> acc
x : xs -> go (acc + x) xs
这段代码看起来是尾递归,但 acc + x 不一定立刻计算。对大列表来说,累加器可能变成一串挂起表达式:
(((0 + x1) + x2) + x3) ...
最后真正需要结果时,程序才一次性计算这串表达式。这会占用大量内存,甚至导致栈或堆压力。
解决方式是让累加器严格。可以使用 seq:
代码清单 B.5 严格累加器
sumStrict :: [Int] -> Int
sumStrict =
go 0
where
go acc values =
case values of
[] -> acc
x : xs ->
let acc' = acc + x
in acc' `seq` go acc' xs
seq 强制 acc' 到弱头范式。对于 Int 这样的简单数值,这就足够了。
也可以使用 bang pattern:
代码清单 B.6 使用 bang pattern
{-# LANGUAGE BangPatterns #-}
sumStrict :: [Int] -> Int
sumStrict =
go 0
where
go !acc values =
case values of
[] -> acc
x : xs -> go (acc + x) xs
!acc 表示函数进入这一分支时会强制求值 acc。
标准库中也提供严格折叠:
import Data.List (foldl')
sumStrict :: [Int] -> Int
sumStrict =
foldl' (+) 0
这是最常用写法。一般来说,如果要对大列表进行累积计算,优先考虑 foldl',而不是 foldl。
时间泄漏则是另一类问题。它指的是工作被推迟到不合适的时间,导致延迟集中爆发。比如程序读取配置时没有立即解析,直到处理请求时才发现配置错误。或者并行程序创建了很多 thunk,却没有在工作线程中真正求值,最后主线程承担了全部计算。
解决时间泄漏的方式不是让所有代码都严格,而是在边界处明确求值:
- 读取配置后立即解析和验证
- 读取大文件后在关闭句柄前消费必要数据
- 并行任务中确保每个任务实际完成需要的计算
- 长时间运行服务中避免把未求值数据塞进全局状态
惰性让表达式组合更灵活,但性能设计仍然需要清楚知道数据何时被消费。
B.4 undefined 与 newtype
undefined 是一个特殊值。它的类型是:
undefined :: a
它可以假装成任何类型的值,但一旦被求值就会抛出运行时错误。
safe :: Int
safe =
1
unsafe :: Int
unsafe =
undefined
下面这个表达式不会崩溃:
const 1 undefined
因为 const 忽略第二个参数,undefined 没有被求值。
而这个会崩溃:
undefined + 1
因为加法需要知道左侧数值。
undefined 有时用于占位或探索类型,但不应该留在真实业务代码中。它绕过了类型系统给我们的安全感。
newtype 也和求值有关。它定义一个零运行时成本的包装:
代码清单 B.7 newtype 包装
newtype UserId = UserId Int
与 data 不同,newtype 只有一个构造器和一个字段。运行时通常不需要额外包装层。它主要用于给已有类型赋予新语义。
比较:
data DataBox = DataBox Int
newtype NewBox = NewBox Int
data 构造器是真实的数据构造器。求值到弱头范式时,需要知道值是不是 DataBox。newtype 的包装在运行时会被擦除,因此它的求值行为更接近内部字段。
这会影响 seq:
data D = D Int
newtype N = N Int
表达式:
seq (D undefined) ()
可以返回 (),因为 D undefined 的最外层构造器已经可见。
而:
seq (N undefined) ()
会更接近强制内部的 undefined,因为 newtype 没有真实运行时构造器可停留。
这类差异平时不常直接遇到,但它说明了一点:Haskell 的“值是否已经求到足够程度”与数据构造器密切相关。data、newtype、严格字段和 seq 都会影响这个边界。
可以给字段加严格标记:
代码清单 B.8 严格字段
data StrictPair = StrictPair !Int !Int
当构造 StrictPair 时,两个 Int 字段会被求值到弱头范式。严格字段适合那些几乎总会被用到、且不希望积累 thunk 的字段,例如计数器、统计信息和数值向量的元素。
不过,不要默认把所有字段都设为严格。惰性有时正是我们想要的,例如按需读取大结构、表示无限数据,或避免计算某些分支。严格性是设计选择,不是越多越好。
总结
- Haskell 默认按需求值,表达式只有在结果被需要时才会计算。
- 惰性让无限列表和按需数据处理成为可能。
- thunk 表示尚未求值的计算,运行时可以在需要时计算并共享结果。
- Haskell 通常求值到弱头范式,也就是看见最外层构造器即可。
- 大量未求值 thunk 可能导致空间泄漏。
- 对大列表做累积计算时,通常使用
foldl'或严格累加器。 deepseq可以强制完全求值,但应主要用于明确的性能和资源边界。undefined可以拥有任何类型,但一旦被求值就会崩溃。newtype是零运行时成本包装,它的求值行为与data包装不同。- 严格性应该按需求设计,在清晰边界处使用,而不是盲目添加。