Skip to main content

附录 B - 惰性求值(Lazy evaluation)

本附录内容包括:

  • 惰性求值如何影响数据结构
  • thunk 的基本模型
  • 空间泄漏与时间泄漏
  • undefinednewtype 的求值行为

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) ()

它只强制到最外层元组构造器,不会强制两个字段变成 37

如果需要完全求值,常用工具是 deepseq 包中的 deepseqforce。它依赖 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 构造器是真实的数据构造器。求值到弱头范式时,需要知道值是不是 DataBoxnewtype 的包装在运行时会被擦除,因此它的求值行为更接近内部字段。

这会影响 seq

data D = D Int
newtype N = N Int

表达式:

seq (D undefined) ()

可以返回 (),因为 D undefined 的最外层构造器已经可见。

而:

seq (N undefined) ()

会更接近强制内部的 undefined,因为 newtype 没有真实运行时构造器可停留。

这类差异平时不常直接遇到,但它说明了一点:Haskell 的“值是否已经求到足够程度”与数据构造器密切相关。datanewtype、严格字段和 seq 都会影响这个边界。

可以给字段加严格标记:

代码清单 B.8 严格字段

data StrictPair = StrictPair !Int !Int

当构造 StrictPair 时,两个 Int 字段会被求值到弱头范式。严格字段适合那些几乎总会被用到、且不希望积累 thunk 的字段,例如计数器、统计信息和数值向量的元素。

不过,不要默认把所有字段都设为严格。惰性有时正是我们想要的,例如按需读取大结构、表示无限数据,或避免计算某些分支。严格性是设计选择,不是越多越好。

总结

  • Haskell 默认按需求值,表达式只有在结果被需要时才会计算。
  • 惰性让无限列表和按需数据处理成为可能。
  • thunk 表示尚未求值的计算,运行时可以在需要时计算并共享结果。
  • Haskell 通常求值到弱头范式,也就是看见最外层构造器即可。
  • 大量未求值 thunk 可能导致空间泄漏。
  • 对大列表做累积计算时,通常使用 foldl' 或严格累加器。
  • deepseq 可以强制完全求值,但应主要用于明确的性能和资源边界。
  • undefined 可以拥有任何类型,但一旦被求值就会崩溃。
  • newtype 是零运行时成本包装,它的求值行为与 data 包装不同。
  • 严格性应该按需求设计,在清晰边界处使用,而不是盲目添加。