2.2 严格与惰性:两种求值节拍实测


2.2 严格与惰性:两种求值节拍实测

本节摘要:求值策略决定"表达式什么时候被真正算出来"——这是命令式程序员很少面对、函数式程序员必须掌握的时间维度。本节用同一组代码在严格与惰性两种策略下的行为差异做实测:化简顺序、错误抛出时机、无限结构处理、性能与内存特征,并给出策略选择的决策表与 Haskell 中控制严格性的实操手段。

从一个找不到的错误说起

假设有这样一段代码(示意,Python 风格的伪逻辑):

def shout(msg): raise ValueError("消息不能为空") result = [shout(m) for m in []] # 空列表,循环体从不执行 print("ok")

空列表让 shout 一次都没被调用,程序安然打印 ok。现在把视角换到惰性求值的语言,写一个看似等价的构造:

-- Haskell shout :: String -> a shout _ = error "消息不能为空" main :: IO () main = do let xs = map shout [] -- 同样从不执行 let y = head (xs ++ ["safe"]) -- 取第一个元素 putStrLn "ok" -- 先问:这行会执行吗?

答案微妙:putStrLn "ok" 会执行——因为 y 的值直到被使用才需要计算。但如果你在中间加一行 print y,异常立刻炸出,而且报错位置可能指向 print 而不是 shout。**同一段逻辑,求值策略不同,错误的出现时机和地点完全不同。**这就是本节要建立的直觉:函数式代码里,"算"与"写"是分离的两件事,求值策略就是调度它们的节拍器。

一、两种策略的精确定义

严格求值(strict evaluation):参数在进入函数体之前先算完。调用 f(e) 时,先把 e 归约到值,再执行函数。绝大多数主流语言(C、Java、Python、JavaScript)都默认严格——你以为的"理所当然"其实是一种选择。

非严格求值(non-strict / lazy evaluation):参数先原封不动地打包成一个"待算"的悬挂表达式(thunk),只有当函数体真正需要它的值时才拆包计算,且算完通常缓存结果。Haskell 默认惰性到极端:连 if 的两个分支都只算被选中的那个,let 绑定只登记不执行。

用一个判别实验看清差别。定义两个函数,一个用参数,一个不用:

-- Haskell:严格性实验 useIt :: Int -> Int useIt x = x + 1 -- 用到参数 x ignoreIt :: Int -> Int ignoreIt x = 42 -- 完全不用参数 boom :: Int boom = error "爆炸" -- 一个"值",一碰就炸

在 Haskell(默认惰性)里:ignoreIt boom 返回 42——参数是炸药也没关系,因为没人拆包。而 useIt boom 抛异常。把同样的函数放到 OCaml(默认严格)里,ignore_it boom 在传参瞬间就抛异常,因为参数必须先算。同一个表达式、同一个函数名,两个世界给出两种行为——这不是语言 bug,是求值策略的设计分歧。

图:严格与惰性的求值时序对比

图:严格与惰性的求值时序对比

二、实测一:错误时机与控制流

惰性求值让"控制流"失去特权地位。严格语言里,if 是特殊关键字(必须只算一个分支,否则 else 里的除零会炸);惰性语言里,if 只是普通函数——因为分支也是 thunk,不被选中就不会求值。下面用 Haskell 亲手定义一个自己的"if":

-- 自定义条件函数:惰性语言里完全合法,与内建 if 等价 myIf :: Bool -> a -> a -> a myIf True thenBranch _ = thenBranch myIf False _ elseBranch = elseBranch safeDiv :: Double -> Double -> Maybe Double safeDiv _ 0 = Nothing safeDiv a b = Just (a / b) main = do print (myIf True 1 (error "未选中的分支不会炸")) -- 输出 1 print (myIf False (error "未选中的分支不会炸") 2) -- 输出 2

严格语言里这个函数是写不出来的:传参阶段 error 就已经触发。这解释了为什么命令式语言需要 &&||?: 这些"短路特权语法"——它们是严格世界里少数几个被豁免的惰性孤岛。Python 的 and/or 与条件表达式同理。理解了这一点,再回头看 JavaScript 的 lazy iterable、Java 的 Stream 中间操作不立即执行,就会认出它们都是惰性思想的局部移植。

三、实测二:无限结构与性能特征

惰性求值的招牌能力是优雅地处理无限结构。"一百以内的素数"用"从全体素数的无限流里取前若干个"来表达,定义直接对应数学描述:

-- 全体素数的无限列表:筛法在惰性下按需推进 primes :: [Integer] primes = sieve [2..] where sieve (p:xs) = p : sieve [x | x <- xs, x `mod` p /= 0] main = print (take 10 primes) -- [2,3,5,7,11,13,17,19,23,29] -- 只计算到第 10 个素数所需的量,后面的无限部分从未存在

同样的定义在严格语言里直接死循环或内存耗尽,因为 [2..] 会先被算到底。代价的另一面是内存:每个未求值的 thunk 都要在堆上占一个位置。极端情形是累积——表达式不断"登记"却不化简,thunk 链越拉越长,内存曲线持续爬坡,这就是著名的空间泄漏,下一节用一次真实事故完整复盘它。

Python 程序员可以立即体验折中版惰性:生成器表达式。注意它"单次消费、迭代即算"的半惰性特征:

import sys squares_list = [x * x for x in range(10_000_000)] # 严格:立刻全算,占内存数百MB squares_lazy = (x * x for x in range(10_000_000)) # 惰性:只登记规则,几乎不占内存 print(sys.getsizeof(squares_list)) # 数量级:千万字节级 print(sys.getsizeof(squares_lazy)) # 数量级:两百字节上下 print(next(squares_lazy), next(squares_lazy)) # 按需产出:0 1

四、策略选择的决策表

两种策略没有全胜者,选择依据是场景对"时序确定性"与"按需计算"的相对需求:

维度 严格求值 惰性求值
执行时序 可预测,剖析工具直读 按需触发,剖析需专用工具
错误定位 抛出点即案发点 可能延迟到消费点,定位间接
无限/巨大结构 需手工分段 天然支持,定义即数学描述
内存行为 峰值可预估 有 thunk 累积风险,需盯 strictness
默认采用者 OCaml、Scala、F#、ML 系 Haskell、Clean;Miranda 系

实操建议:默认严格、局部惰性是工业界更常见的选择——OCaml、Scala、F# 全部默认严格,按需提供 lazy/Seq/Lazy 构造;Haskell 反其道默认惰性,工程师需要主动用 seqbang 标注、force 把关键路径掰回严格。两种路线殊途同归:成熟的函数式工程,最终都是"精确控制每个表达式的求值时机"。

Haskell 中最常用的两个控制手段,值得现在就记住长相:

-- 手段一:bangPattern 标注,参数在使用前强制求值 {-# LANGUAGE BangPatterns #-} strictSum :: [Int] -> Int strictSum = go 0 where go !acc [] = acc -- !acc:每轮立刻把累加器算成真值,杜绝 thunk 链 go !acc (x:xs) = go (acc + x) xs -- 手段二:seq 与 force,在任意点位显式"拧紧" import Control.DeepSeq (force) import Control.Exception (evaluate) main = do let big = strictSum [1..10_000_000] _ <- evaluate (force big) -- 在指定的边界处强制算完,内存行为可预期 print big

💡 关键直觉:惰性不是"快",是"晚"。晚有晚的红利(跳过没用的计算、支持无限结构),也有晚的账单(内存驻留、时序难测)。性能工作的本质是决定每笔计算"何时入账"。

本节要点回顾

  • 求值策略是时间维度的设计选择:严格是"先算参数再进函数体",惰性是"打包 thunk 需要才算"。
  • 错误时机随策略移动:严格语言的异常在调用点炸,惰性语言可能延迟到消费点。
  • 惰性赋予控制流普通函数身份:自定义 if、短路、无限结构都是同一机制的不同表现。
  • 内存是惰性的主要账单:thunk 累积即空间泄漏,strictSum 的叹号标注是第一修复手段。
  • 默认严格、局部惰性更常见:精确控制每个表达式的求值时机,是函数式性能工作的核心技能。

理论讲完了,下一节进入案发现场:一次真实的空间泄漏事故,从内存曲线异常爬坡,到堆剖析定位到一行看似无害的 fold,到三种修复方案的取舍全过程。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U