【问题标题】:Lazy evaluation of terms in an infinite list in HaskellHaskell中无限列表中术语的延迟评估
【发布时间】:2012-06-13 21:43:16
【问题描述】:

我很好奇无限列表的运行时性能,例如 下一个:

fibs = 1 : 1 : zipWith (+) fibs (tail fibs)

这将创建一个无限的斐波那契数列列表。

我的问题是,如果我执行以下操作:

takeWhile (<5) fibs

fibs 评估列表中的每个术语多少次?它似乎 因为takeWhile 检查每个项目的谓词函数 列表中,fibs 列表将多次评估每个术语。这 前 2 个学期免费提供。当takeWhile 想要评估时 (&lt;5) 在第三个元素上,我们会得到:

1 : 1 : zipWith (+) [(1, 1), (1)] => 1 : 1 : 3

现在,一旦 takeWhile 想要在第 4 个元素上评估 (&lt;5)fibs 的递归性质将再次构造列表,如 以下:

1 : 1 : zipWith (+) [(1, 2), (2, 3)] => 1 : 1 : 3 : 5

似乎我们需要再次计算第三个元素 想要评估第 4 个元素的值。此外,如果 takeWhile 中的谓词很大,表示函数是 做更多需要做的工作,因为它正在评估前面的每一个 列表中的元素多次。我的分析是正确的还是 Haskell 做一些缓存来防止这里的多次评估?

【问题讨论】:

  • 2(通常出现在标准斐波那契数列中的13 之间)发生了什么?
  • 当然取决于实现,但一个体面的人只会评估列表中的每个元素一次。

标签: haskell lazy-evaluation


【解决方案1】:

这是一个自引用的惰性数据结构,其中结构的“后面”部分按名称引用前面的部分。

最初,该结构只是一个未计算的指针返回自身的计算。随着它的展开,价值在结构中被创造出来。稍后对结构中已计算部分的引用能够找到已经在那里等待它们的值。无需重新评估零件,也无需额外工作!

内存中的结构开始时只是一个未计算的指针。一旦我们查看第一个值,它看起来像这样:

> take 2 fibs

(指向 cons 单元格的指针,指向 '1',尾部保存第二个 '1',以及指向函数的指针,该函数保存对 fibs 的引用和 fibs 的尾部。

再评估一个步骤会扩展结构,并滑动引用:

所以我们展开结构,每次都会产生一个新的未评估尾部,这是一个闭包,包含对最后一步的第一个和第二个元素的引用。这个过程可以无限继续:)

由于我们通过名称引用先前的值,GHC 很乐意为我们将它们保留在内存中,因此每个项目只评估一次。

【讨论】:

  • 完美利用真空;真的在这里大放异彩。
  • 非常棒的答案,感谢您投入时间。
  • 我会补充一点,一旦你不再需要结构的初始部分,垃圾收集器会注意到并自动释放内存以供重用。因此,即使以这种方式使用内存以避免重新计算,对于fibs 的许多元素的单次迭代,任何时候都只会将最后两项保留在内存中。
  • 这个闭包的范围究竟是如何确定的?我对慢斐波那契的情况感到困惑,其中 slow_fib n = slow_fib(n-1) + slow_fib(n-2),为什么不存储先前计算的值。是函数作用域和全局作用域的区别吗?
【解决方案2】:

插图:

module TraceFibs where

import Debug.Trace

fibs :: [Integer]
fibs = 0 : 1 : zipWith tadd fibs (tail fibs)
  where
    tadd x y = let s = x+y
               in trace ("Adding " ++ show x ++ " and " ++ show y
                                   ++ "to obtain " ++ show s)
                        s

哪个产生

*TraceFibs> fibs !! 5
Adding 0 and 1 to obtain 1
Adding 1 and 1 to obtain 2
Adding 1 and 2 to obtain 3
Adding 2 and 3 to obtain 5
5
*TraceFibs> fibs !! 5
5
*TraceFibs> fibs !! 6
Adding 3 and 5 to obtain 8
8
*TraceFibs> fibs !! 16
Adding 5 and 8 to obtain 13
Adding 8 and 13 to obtain 21
Adding 13 and 21 to obtain 34
Adding 21 and 34 to obtain 55
Adding 34 and 55 to obtain 89
Adding 55 and 89 to obtain 144
Adding 89 and 144 to obtain 233
Adding 144 and 233 to obtain 377
Adding 233 and 377 to obtain 610
Adding 377 and 610 to obtain 987
987
*TraceFibs>

【讨论】:

    【解决方案3】:

    当某个东西在 Haskell 中被求值时,它会一直被求值,只要它被同名引用1

    在下面的代码中,l 列表只计算一次(这可能很明显):

    let l = [1..10]
    print l
    print l -- None of the elements of the list are recomputed
    

    即使某些东西被部分评估,该部分仍然被评估:

    let l = [1..10]
    print $ take 5 l -- Evaluates l to [1, 2, 3, 4, 5, _]
    print l          -- 1 to 5 is already evaluated; only evaluates 6..10
    

    在您的示例中,当评估 fibs 列表的元素时,它会保持评估状态。由于zipWith 的参数引用了实际的fibs 列表,这意味着压缩表达式在计算列表中的下一个元素时将使用已经部分计算的fibs 列表。这意味着没有元素被计算两次。

    1这当然不是语言语义严格要求的,但在实践中总是如此。

    【讨论】:

      【解决方案4】:

      这样想。变量fib 是一个指向惰性值的指针。 (您可以将下面的惰性值视为像(不是真正的语法)Lazy a = IORef (Unevaluated (IO a) | Evaluated a) 这样的数据结构;即,它一开始是未评估的,并带有一个 thunk;然后当它被评估时,它“更改”为记住该值的东西。)因为递归表达式使用变量fib,它们有一个指向相同惰性值的指针(它们“共享”数据结构)。当有人第一次评估fib 时,它会运行thunk 来获取值并记住该值。并且因为递归表达式指向 same 惰性数据结构,当他们评估它时,他们将看到评估的值。当他们遍历惰性“无限列表”时,内存中只会有一个“部分列表”; zipWith 将有两个指向“列表”的指针,它们只是指向同一“列表”的先前成员的指针,因为它以指向同一列表的指针开头。

      请注意,这并不是真正的“记忆”;这只是引用相同变量的结果。函数结果一般不会“记忆”(下面会低效):

      fibs () = 0 : 1 : zipWith tadd (fibs ()) (tail (fibs ()))
      

      【讨论】:

        猜你喜欢
        • 2017-07-31
        • 1970-01-01
        • 2019-07-21
        • 2016-09-12
        • 2011-03-03
        • 1970-01-01
        • 2016-08-14
        • 2016-06-20
        • 2021-05-17
        相关资源
        最近更新 更多