我有一个关于在Haskell中使用数组实现缓存(memoization)的问题。以下模式有效:
f = (fA !)
where fA = listArray...
但事实并非如此(程序的速度表明每次调用都会重新创建数组):
f n = (fA ! n)
where fA = listArray...
在where子句之外定义fA(在“全局范围”中)也适用于任一模式。
我希望有人能指出我对上述两种模式之间差异的技术解释。
请注意,我使用的是最新的GHC,我不确定这只是编译器的特性还是语言本身的一部分。
编辑:!用于数组访问,所以fA! 5表示C ++语法中的fA [5]。我对Haskell的理解是(fA!)n与(fA!n)相同......对于我来说,写一个“f n = fA!n”(没有括号)会更常规。无论如何,无论我如何括号,我都会得到相同的行为。答案 0 :(得分:7)
Haskell标准未指定行为差异。所有它必须说的是功能是相同的(在给定相同输入的情况下将产生相同的输出)。
然而,在这种情况下,有一种简单的方法可以预测大多数编译器所遵循的时间和内存性能。我再次强调,这不是必不可少的,只有大多数编译器都这样做。
首先将两个示例重写为纯lambda表达式,扩展部分:
f = let fA = listArray ... in \n -> fA ! n
f' = \n -> let fA = listArray ... in fA ! n
编译器使用let绑定来指示共享。保证是在给定环境(局部变量集,lambda体,类似的东西)中,没有参数的let绑定的右侧最多将被评估一次。 fA在前者的环境是整个程序,因为它不在任何lambda之下,但后者的环境因为它在lambda下而更小。
这意味着在后者中,对于每个不同的n,fA 可以被评估一次,而在前者中这是被禁止的。
即使使用多参数函数,我们也可以看到这种模式有效:
g x y = (a ! y) where a = [ x ^ y' | y' <- [0..] ]
g' x = (\y -> a ! y) where a = [ x ^ y' | y' <- [0..] ]
然后在:
let k = g 2 in k 100 + k 100
我们可能会多次计算2 ^ 100,但是:
let k = g' 2 in k 100 + k 100
我们只计算一次。
如果你正在处理memoization,我推荐Hackage上的数据memocombinators,它是一个不同形状的备忘表库,所以你不必自己动手。
答案 1 :(得分:5)
查找正在发生的事情的最佳方法是告诉编译器使用-v4
输出其中间表示。输出很大,有点难以阅读,但是应该让你能够准确找出生成的代码中的差异,以及编译器是如何到达那里的。
您可能会注意到fA
在第一个示例中被移动到函数之外(到“全局范围”)。在你的第二个例子中,它可能不是(意味着它将在每次调用时重新创建)。
不被移动到函数之外的一个可能原因是因为编译器认为它取决于n
的值。在您的工作示例中,n
没有fA
依赖。
但我认为编译器在第二个例子中避免移动fA
的原因是因为它试图避免空间泄漏。考虑如果fA
而不是您的数组是无限列表(您使用!!
运算符),会发生什么。想象一下,您使用大数字(例如f 10000
)调用了一次,后来只用小数字(f 2
,f 3
,f 12
...)调用它。早期调用的10000个元素仍在内存中,浪费空间。因此,为避免这种情况,每次调用函数时,编译器都会再次创建fA
。
在第一个例子中可能不会发生空间泄漏避免,因为在这种情况下f
实际上只被调用一次,返回一个闭包(我们现在处于纯函数和命令世界的前沿,所以事情变得更加微妙了。这个闭包取代了原来的函数,它永远不会被再次调用,所以fA
只被调用一次(因此优化器可以自由地将它移到函数之外)。在第二个例子中,f
不会被闭包替换(因为它的值取决于参数),因此会再次被调用。
如果您想尝试了解更多内容(这有助于阅读-v4
输出),您可以查看Spineless Tagless G-Machine论文(citeseer link)。
关于你的最后一个问题,我认为这是编译器的特点(但我可能是错的)。但是,如果所有编译器都做同样的事情,即使它不是语言的一部分,也不会让我感到惊讶。
答案 2 :(得分:0)
很酷,谢谢你的答案,这对你有很大的帮助,我肯定会在Hackage上查看数据memocombinators。来自C ++的重要背景,我一直在努力理解Haskell将使用给定程序做什么(主要是在复杂性方面),这些教程似乎没有进入。