Haskell求值策略在基于Representable的递归函数记忆化中的作用及实现原理问询
这问题问得太到位了!我来一步步拆解这个Haskell里的记忆化技巧,把两个核心疑惑给你讲透。
先回顾原文的基础实现
首先原文里的斐波那契实现是用不动点算子fix来递归定义的:
fibOp :: Num a => (Natural -> a) -> (Natural -> a) fibOp v 0 = 0 fibOp v 1 = 1 fibOp v n = v (n-1) + v (n-2) fix f = let x = f x in x fibNaive :: Num a => Natural -> a fibNaive = fix fibOp
这个fibNaive效率极低,原因你也知道:计算大的n时,会反复递归计算同一个小n的值(比如算fib(5)要算fib(4)和fib(3),算fib(4)又要算fib(3)和fib(2),以此类推),完全没有缓存。
理解streamTabulate和streamIndex的作用
原文里提到的streamTabulate和streamIndex是一对互逆函数,先搞懂它们各自做什么:
streamTabulate:把一个Natural -> a的函数转换成惰性无限Stream——简单说就是把函数的所有可能输出按顺序排成一个序列,但这个序列的元素是按需计算的(没用到的位置永远不会算)。streamIndex:把Stream再转回Natural -> a的函数——给一个下标n,就取出Stream的第n个元素。
当我们把它们和fibOp组合成streamIndex . streamTabulate . fibOp时,这个组合函数的逻辑是:
- 接收一个函数v(也就是递归里的“自身”);
- 用
fibOp v生成原来的递归函数; - 用
streamTabulate把这个函数转成一个惰性Stream; - 最后用
streamIndex把Stream转回成可索引的函数。
第一个疑惑:“所有参数共享同一个Stream”是什么意思?
当我们用fix去绑定这个组合函数时,fix的递归特性会让这个函数的输入和输出变成同一个东西——换句话说,fibSmart(也就是fix (streamIndex . streamTabulate . fibOp))本质上是在访问同一个全局的惰性Stream。
举个例子:当你调用fibSmart 5,它会去取这个Stream的第5个元素;而计算这个元素需要第4和第3个元素的值,这时候如果这两个元素还没被计算过,就会触发它们的计算,一旦算完,这些值就会被存在Stream里。后续再调用fibSmart 4或者fibSmart 3时,直接用已经算好的结果,完全不会重复计算。
说白了,这个Stream就是一个全局的缓存容器,所有fibSmart的调用都共享这个缓存,这就是“共享同一个Stream”的核心含义。
第二个核心疑惑:为什么streamTabulate不会被多次求值?依赖Haskell的什么特性?
这就要说到Haskell两个最核心的特性:共享(Sharing)和惰性求值(Lazy Evaluation),尤其是fix的定义带来的共享特性。
先看fix的定义:fix f = let x = f x in x。这里的let绑定是共享绑定——不管你多少次引用x,它对应的表达式只会被计算一次,计算后的结果会被复用,不会重新求值。
回到我们的例子:fibSmart = fix f,其中f = streamIndex . streamTabulate . fibOp,展开后就是:
fibSmart = streamIndex (streamTabulate (fibOp fibSmart))
这里的关键是:streamTabulate (fibOp fibSmart)生成的Stream是一个共享的单一实例。因为fibSmart是let绑定的共享变量,所以fibOp fibSmart也是共享的,进而streamTabulate生成的Stream不会被重复创建。
那什么时候streamTabulate会被求值?只有当第一次访问Stream的某个元素时,才会触发streamTabulate对应的计算,但一旦这个Stream的结构被建立(哪怕大部分元素还是未计算的thunk),后续所有的fibSmart调用都是在这个Stream上操作,不会重新生成新的Stream。
再结合惰性求值:Stream的每个元素都是一个未计算的thunk,只有当你真正需要这个元素的值时,它才会被计算,而且计算后的结果会被缓存下来。比如计算fibSmart 2时,会触发fibOp fibSmart 2的计算,也就是fibSmart 1 + fibSmart 0,这两个调用会分别触发Stream第1和第0个元素的计算,算完后就缓存起来,下次再用直接取。
总结一下,这个记忆化技巧生效的核心就是:
- 共享保证了整个递归过程只生成一个Stream缓存,不会重复创建;
- 惰性求值保证了缓存的元素只在需要时才计算,计算后永久复用,彻底避免了重复计算。
内容的提问来源于stack exchange,提问作者michid

