Haskell 避免多次列表遍历的好处

Haskell 避免多次列表遍历的好处,haskell,functional-programming,ml,Haskell,Functional Programming,Ml,我在函数式语言中看到了许多关于处理列表和构造函数的示例,以在收到一些附加值(通常在生成函数时不存在)后对其元素执行某些操作,例如: (在“惰性评估”下的最后两个示例) 使用严格的函数语言(如ML/OCaml)暂存列表追加 (标题为“登台”的部分) 使用foldr将一个列表与另一个列表进行比较(即生成一个函数将另一个列表与第一个列表进行比较) 在所有这些示例中,作者通常都会指出只遍历原始列表一次的好处。但我无法阻止自己思考“当然,不是遍历N个元素的列表,而是遍历N个计算的链,那又怎样?”。

我在函数式语言中看到了许多关于处理列表和构造函数的示例,以在收到一些附加值(通常在生成函数时不存在)后对其元素执行某些操作,例如:

  • (在“惰性评估”下的最后两个示例)

  • 使用严格的函数语言(如ML/OCaml)暂存列表追加

    (标题为“登台”的部分)

  • 使用foldr将一个列表与另一个列表进行比较(即生成一个函数将另一个列表与第一个列表进行比较)

在所有这些示例中,作者通常都会指出只遍历原始列表一次的好处。但我无法阻止自己思考“当然,不是遍历N个元素的列表,而是遍历N个计算的链,那又怎样?”。我知道这肯定有好处,有人能解释一下吗


编辑:感谢两人的回答。不幸的是,这不是我想知道的。我将尝试澄清我的问题,这样就不会与创建中间列表的问题(更常见的问题)混淆(我已经在很多地方读过)。也谢谢你纠正我的帖子格式

我感兴趣的是,如果你构造了一个要应用于列表的函数,而你还没有必要的值来计算结果(不管它是不是列表)。然后,您无法避免生成对每个列表元素的引用(即使列表结构不再被引用)。并且您有与以前相同的内存访问,但不必解构列表(模式匹配)

例如,请参阅上述ML手册中的“staging”一章。我在ML和Racket中尝试过它,更具体地说是“append”的阶段版本,它遍历第一个列表并返回一个函数在尾部插入第二个列表,而不多次遍历第一个列表。令我惊讶的是,它的速度要快得多,即使考虑到它仍然必须复制列表结构,因为最后一个指针在每种情况下都是不同的

下面是map的一个变体,它应用于列表后,在更改函数时应该更快。由于Haskell并不严格,我必须在
cachedList
中强制计算
listMap[1..100000]
(或者可能不是,因为在第一个应用程序之后,它应该仍然在内存中)

我知道在Haskell中,使用
comb x rest f=…
vs
comb x rest=\f->…
,没有什么区别(如果我错了,请纠正我),但我选择这个版本是为了强调这个想法


更新:经过一些简单的测试,我在Haskell中找不到任何执行时间上的差异。因此,问题只涉及Scheme(至少是我测试过的Racket实现)和ML等严格的语言。

在循环体中执行一些额外的算术指令基本上比执行一些额外的内存获取便宜

遍历意味着要进行大量内存访问,所以越少越好。遍历的融合减少了内存流量,并增加了直线计算负载,因此可以获得更好的性能

具体地,考虑这个程序来计算列表上的一些数学:

go :: [Int] -> [Int]
go = map (+2) . map (^3)
显然,我们通过两次遍历列表来设计它。在第一次和第二次遍历之间,结果存储在中间数据结构中。但是,它是一个惰性结构,因此只需要消耗
O(1)
内存

现在,Haskell编译器立即将两个循环融合为:

go = map ((+2) . (^3))
为什么呢?毕竟,两者都是
O(n)
复杂性,对吗? 差异在于常数因子

考虑到这个抽象:对于第一个管道的每个步骤,我们都会:

  i <- read memory          -- cost M
  j = i ^ 3                 -- cost A
  write memory j            -- cost M
  k <- read memory          -- cost M
  l = k + 2                 -- cost A
  write memory l            -- cost M

i还有一个非常重要的原因。如果只遍历一次列表,并且没有其他引用,则GC可以在遍历列表元素时释放列表元素所占用的内存。此外,如果列表是惰性生成的,则始终只有恒定的内存消耗。例如

导入数据。列表
main=do
设xs=[1..10000000]
sum=foldl'(+)0 xs
len=foldl'(\ \ \ \->(+1))0 xs
打印(总和/长度)
计算
sum
,但需要保留对
xs
的引用,并且无法释放它占用的内存,因为以后需要计算
len
。(反之亦然。)因此程序消耗了大量内存,xs越大,它需要的内存就越多

但是,如果我们只遍历列表一次,它会被延迟创建,元素可以立即被GC,因此无论列表有多大,程序只需要
O(1)
内存

{-#语言模式}
导入数据。列表
main=do
设xs=[1..10000000]
(和,len)=foldl'(\(!s,!l)x->(s+x,l+1))(0,0)xs
打印(总和/长度)

很抱歉提前给出了一个健谈的回答

这可能是显而易见的,但如果我们谈论的是绩效,你应该通过测量来验证假设

几年前,我在思考GHC(STG机器)的操作语义。我也问了自己同样的问题——当然著名的“一次遍历”算法不是很好吗?它看起来只是表面上的一次遍历,但在引擎盖下你也有这个thunks链结构,它通常与原始列表非常相似

我为著名的RepMin问题写了几个版本(严格程度不同)——给定一棵树,树中填充数字,生成形状相同的树,但用所有数字中的最小值替换每个数字。如果我的记忆是正确的(请记住——总是自己验证东西!),那么简单的两次遍历算法比各种聪明的一次遍历算法执行得快得多

我还与Simon Ma分享了我的观察结果
go = map ((+2) . (^3))
  i <- read memory          -- cost M
  j = i ^ 3                 -- cost A
  write memory j            -- cost M
  k <- read memory          -- cost M
  l = k + 2                 -- cost A
  write memory l            -- cost M
  i <- read memory          -- cost M
  j = (i ^ 3) + 2           -- cost 2A
  write memory j            -- cost M