Haskell (GHC) 中的列表是如何实现的?

Posted

技术标签:

【中文标题】Haskell (GHC) 中的列表是如何实现的?【英文标题】:How are lists implemented in Haskell (GHC)? 【发布时间】:2011-02-10 22:50:36 【问题描述】:

我只是对 Haskell 中列表的一些确切实现细节感到好奇(GHC 特定的答案很好)——它们是简单的链表,还是有任何特殊的优化?更具体地说:

    length(!!)(例如)是否必须遍历列表? 如果是这样,它们的值是否以任何方式缓存(即,如果我调用 length 两次,是否必须两次迭代)? 访问列表后面是否涉及遍历整个列表? 是否已记住无限列表和列表推导式? (即,对于fib = 1:1:zipWith (+) fib (tail fib),每个值是递归计算的,还是依赖于先前计算的值?)

任何其他有趣的实现细节将不胜感激。提前致谢!

【问题讨论】:

Haskell 还有arrays 和"mutable arrays"。 【参考方案1】:

列表在 Haskell 中没有特殊的操作处理。它们的定义如下:

data List a = Nil | Cons a (List a)

只是带有一些特殊符号:[a] 代表List a[] 代表Nil(:) 代表Cons。如果您定义相同并重新定义所有操作,您将获得完全相同的性能。

因此,Haskell 列表是单链接的。由于惰性,它们经常被用作迭代器。 sum [1..n] 在常量空间中运行,因为此列表中未使用的前缀会随着总和的进行而被垃圾收集,并且在需要它们之前不会生成尾部。

至于 #4:所有 Haskell 中的值都是被记忆的,除了函数不会为它们的参数保留一个备忘录表。因此,当您像以前那样定义 fib 时,结果将被缓存,并且将在 O(n) 时间内访问第 n 个斐波那契数。但是,如果您以这种明显等效的方式定义它:

-- Simulate infinite lists as functions from Integer
type List a = Int -> a

cons :: a -> List a -> List a
cons x xs n | n == 0    = x
            | otherwise = xs (n-1)

tailF :: List a -> List a
tailF xs n = xs (n+1)

fib :: List Integer
fib = 1 `cons` (1 `cons` (\n -> fib n + tailF fib n))

(花点时间注意与您的定义的相似之处)

然后结果不共享,第 n 个斐波那契数将在 O(fib n)(这是指数)时间内访问。您可以说服函数与 data-memocombinators 之类的记忆库共享。

【讨论】:

感谢您的详细解答! 有人能澄清一下“函数不为它们的参数保留一个备忘录表”是什么意思吗?这篇文章似乎是说,如果您自己定义列表,您将获得相同的性能 - 最后说如果您确实这样做,您将不会获得相同的性能。有什么区别? @nupanick,不同之处在于示例定义使用Int -> a(一个函数)作为列表的模型,所以它没有被记忆。如果您以通常的方式定义自己的列表:data List a = Nil | Cons a (List a),则会发生记忆。基本上,不会被记住的唯一事情是,如果您致电 f 1 并稍后再次致电 f 1。将重新计算不同的函数应用程序(即使是相同的参数)。【参考方案2】:

据我所知(我不知道其中有多少是 GHC 特有的)

    length(!!) 必须遍历列表。

    我认为列表没有任何特殊优化,但有一种技术适用于所有数据类型。

    如果你有类似的东西

    foo xs = bar (length xs) ++ baz (length xs)
    

    那么length xs 将被计算两次。

    但如果你有

    foo xs = bar len ++ baz len
      where len = length xs
    

    那么它只会计算一次。

    是的。

    是的,一旦计算了命名值的一部分,它就会被保留,直到名称超出范围。 (语言不需要这个,但这是我理解实现行为的方式。)

【讨论】:

对于 2.,我的意思是如果我有 doubleLength xs = length xs + length xs(我知道是做作的),它会计算两次长度吗? @eman:见编辑。我认为它只会计算一次。如果我错了,我相信会有更多知识渊博的人很快纠正我。 GHC 默认不做公共子表达式消除。这是因为它在某些情况下可能是灾难性的,例如:sum [1..10^6] / fromIntegral (length [1..10^6]),如果 [1..10^6] 在这里共享,那么由于 GC 负载,此计算需要 8 MB 并且需要很长时间。在这里,重新计算列表比共享列表要好得多。但是,如果您命名它,那您是对的-例如。 let len = length xs in bar len ++ baz len -- 然后它将被共享。这不在标准中,只是 GHC 和所有其他合理的编译器。 :-) @luqui:所以在这种情况下,除非你有一个命名表达式,否则它会计算 length xs 两次? @eman,在你的例子中,是的。 GHC 可能能够判断出共享 int 类型的表达式不可能泄漏,但我认为不会。【参考方案3】:

如果是,它们的值是否以任何方式缓存(即,如果我调用 length 两次,是否必须两次迭代)?

GHC does not perform full Common Subexpression Elimination。例如:

-# NOINLINE aaaaaaaaa #-
aaaaaaaaa :: [a] -> Int
aaaaaaaaa x = length x + length x

-# NOINLINE bbbbbbbbb #-
bbbbbbbbb :: [a] -> Int
bbbbbbbbb x = l + l where l = length x

main = bbbbbbbbb [1..2000000] `seq` aaaaaaaaa [1..2000000] `seq` return ()

-ddump-simpl

Main.aaaaaaaaa [NEVER Nothing] :: forall a_adp.
                                  [a_adp] -> GHC.Types.Int
GblId
[Arity 1
 NoCafRefs
 Str: DmdType Sm]
Main.aaaaaaaaa =
  \ (@ a_ahc) (x_adq :: [a_ahc]) ->
    case GHC.List.$wlen @ a_ahc x_adq 0 of ww_anf  __DEFAULT ->
    case GHC.List.$wlen @ a_ahc x_adq 0 of ww1_Xnw  __DEFAULT ->
    GHC.Types.I# (GHC.Prim.+# ww_anf ww1_Xnw)
    
    

Main.bbbbbbbbb [NEVER Nothing] :: forall a_ado.
                                  [a_ado] -> GHC.Types.Int
GblId
[Arity 1
 NoCafRefs
 Str: DmdType Sm]
Main.bbbbbbbbb =
  \ (@ a_adE) (x_adr :: [a_adE]) ->
    case GHC.List.$wlen @ a_adE x_adr 0 of ww_anf  __DEFAULT ->
    GHC.Types.I# (GHC.Prim.+# ww_anf ww_anf)
    

注意aaaaaaaaa 调用GHC.List.$wlen 两次。

(实际上因为x需要保留在aaaaaaaaa中,所以比bbbbbbbbb慢2倍以上。)

【讨论】:

以上是关于Haskell (GHC) 中的列表是如何实现的?的主要内容,如果未能解决你的问题,请参考以下文章

在 haskell-stack 中设置 GHC 选项的各种方法如何协同工作

GHC Haskell 中的自动记忆功能是啥时候?

如何在 Haskell 平台中安装具有分析支持的 ghc 和 base

GHC Haskell 中啥时候自动记忆?

使用 GHC API 评估 Haskell 语句/表达式

GHC 内部结构:类型系统是不是有 C 实现?