我正在努力学习Haskell,我偶然发现了以下内容:
myAdd (x:xs) = x + myAdd xs
myAdd null = 0
f = let n = 10000000 in myAdd [1 .. n]
main = do
putStrLn (show f)
使用GHC进行编译时,会产生堆栈溢出。作为一名C / C ++程序员,我本以期望编译器进行尾调用优化。
我不喜欢在这样的简单情况下我必须“帮助”编译器,但有什么选择?我认为要求上面给出的计算在不使用O(n)内存的情况下完成是合理的,并且不需要推迟使用专门的函数。
如果我不能自然地陈述我的问题(即使是像这样的玩具问题),并期望在时间和方面有合理的表现。空间,Haskell的大部分吸引力都将丢失。
答案 0 :(得分:20)
首先,确保您使用-O2
进行编译。它会导致很多性能问题消失:)
我能看到的第一个问题是null
只是一个变量名。你想要[]
。它在这里是等效的,因为唯一的选项是x:xs
和[]
,但它并不总是。
这里的问题很简单:当你致电sum [1,2,3,4]
时,它看起来像这样:
1 + (2 + (3 + (4 + 0)))
由于Haskell的非严格语义而没有减少对数字的任何添加。解决方案很简单:
myAdd = myAdd' 0
where myAdd' !total [] = total
myAdd' !total (x:xs) = myAdd' (total + x) xs
(您需要在源文件的顶部{-# LANGUAGE BangPatterns #-}
来编译它。)
这会在另一个参数中累积添加,实际上是尾递归(你的不是; +
处于尾部位置而不是myAdd
)。但实际上,它并不是我们在Haskell中关心的尾部递归;这种区别主要与严格的语言有关。这里的秘密是total
上的 bang模式:它强制每次调用myAdd'
时对其进行评估,因此没有未评估的加法积累,并且它在恒定空间中运行。在这种情况下,GHC实际上可以用-O2
来解决这个问题,这要归功于它的严格性分析,但我认为通常最好明确你想要严格的和你不想要的东西。
请注意,如果添加是懒惰的,那么您的myAdd
定义可以正常工作;问题是你正在使用严格操作对列表执行 lazy 遍历,最终导致堆栈溢出。这主要是算术,它对标准数字类型(Int,Integer,Float,Double等)是严格的。
这非常难看,每当我们想写一个严格的折叠时,写这样的东西会很痛苦。值得庆幸的是,Haskell为此做好了抽象准备!
myAdd = foldl' (+) 0
(您需要添加import Data.List
来编译它。)
foldl' (+) 0 [a, b, c, d]
与(((0 + a) + b) + c) + d
类似,只是在(+)
的每个应用程序中(我们将二进制运算符+
称为函数值),迫使评估价值。生成的代码更清晰,更快速,更易于阅读(一旦您知道列表折叠如何工作,您就可以理解使用它们编写的任何定义比递归定义更容易。)
基本上,这里的问题并不是编译器无法弄清楚如何使程序高效 - 而是让它像你一样高效地改变它的语义,优化应该永远不会做。 Haskell的非严格语义肯定会给程序员带来更多“传统”语言(如C语言)的学习曲线,但随着时间的推移它变得越来越容易,一旦你看到Haskell的非严格性提供的强大功能和抽象,你永远不会想要去回来:))
答案 1 :(得分:9)
扩展评论中暗示的示例ehird:
data Peano = Z | S Peano
deriving (Eq, Show)
instance Ord Peano where
compare (S a) (S b) = compare a b
compare Z Z = EQ
compare Z _ = LT
compare _ _ = GT
instance Num Peano where
Z + n = n
(S a) + n = S (a + n)
-- omit others
fromInteger 0 = Z
fromInteger n
| n < 0 = error "Peano: fromInteger requires non-negative argument"
| otherwise = S (fromInteger (n-1))
instance Enum Peano where
succ = S
pred (S a) = a
pred _ = error "Peano: no predecessor"
toEnum n
| n < 0 = error "toEnum: invalid argument"
| otherwise = fromInteger (toInteger n)
fromEnum Z = 0
fromEnum (S a) = 1 + fromEnum a
enumFrom = iterate S
enumFromTo a b = takeWhile (<= b) $ enumFrom a
-- omit others
infinity :: Peano
infinity = S infinity
result :: Bool
result = 3 < myAdd [1 .. infinity]
result
的定义是True
myAdd
,但如果编译器转换为尾递归循环,它将不会终止。因此,转换不仅是效率的变化,也是语义的变化,因此编译器不能这样做。
答案 2 :(得分:7)
关于&#34的一个有趣的例子;问题是为什么编译器无法优化看起来很容易优化的东西。&#34;
我们说我来自Haskell到C ++。我曾经写过foldr
,因为在Haskell中foldr
通常比foldl
更有效,因为懒惰和列表融合。
所以我试图为C中的(单链接)列表写一个foldr
并抱怨为什么效率非常低:
int foldr(int (*f)(int, node*), int base, node * list)
{
return list == NULL
? base
: f(a, foldr(f, base, list->next));
}
效率低下并不是因为有问题的C编译器是象牙塔理论家为了自己的满意而开发的一种不切实际的玩具工具,但因为所讨论的代码对于C来说是非常不恰当的。
不能在C中编写高效的foldr
:你只需要一个双向链表。在Haskell中,类似地,您可以编写有效的foldl
,您需要foldl
的严格注释才能有效。标准库提供foldl
(无注释)和foldl'
(带注释)。
在Haskell中左侧折叠列表的想法与使用C中的递归向后迭代单个链接列表的想法相同。编译器可以帮助普通人,而不是变态lol。
由于您的C ++项目可能没有向后迭代单链接列表的代码,我的HNC项目只包含1 foldl
我在掌握Haskell之前写错了。你几乎不需要在Haskell中foldl
。
您必须忘记前向迭代是自然且最快的,并了解向后迭代是。前向迭代(左折叠)不会按照您的意图执行,直到您注释:它执行三次传递 - 列表创建,thunk链构建和thunk评估,而不是两次(列表创建和列表遍历)。请注意,在不可变世界列表中,只能有效地向后创建列表:a:b是O(1),而++ [b]是O(N)。
后向迭代也没有做你想要的事情。它可以通过C背景进行一次而不是三次。它不会创建一个列表,将其遍历到底部然后向后遍历(2遍) - 它在创建列表时遍历列表,即1遍。通过优化,它只是一个循环 - 没有创建实际的列表元素。在优化关闭的情况下,它仍然是O(1)空间操作,具有更大的常量开销,但解释时间稍长。
答案 3 :(得分:1)
因此,我将讨论两个关于您的问题的事情,首先是性能问题,然后是表达性问题,即必须帮助编译器看似微不足道的事情。
效果
问题是你的程序实际上不是尾递归的,也就是说,没有单个调用可以替换递归的函数。让我们看看当我们展开myAdd [1..3]
时会发生什么:
myAdd [1,2,3]
1 + myAdd [2,3]
1 + 2 + myAdd [3]
正如您所看到的,在任何给定的步骤中,我们都不能用函数调用替换递归,我们可以通过将1 + 2减少到3来简化表达式,但这不是尾递归的意思。
所以这是一个尾递归的版本:
myAdd2 = go 0
where go a [] = a
go a (x:xs) = go (a + x) xs
让我们看看如何评估go 0 [1,2,3]
:
go 0 [1,2,3]
go (1+0) [2,3]
go (2 + 1 + 0) [3]
如您所见,在每一步,我们只需要跟踪
一个函数调用,只要第一个参数是
严格评估我们不应该得到一个指数空间
爆炸,事实上,如果您使用优化进行编译(-O1
或-O2
)
ghc很聪明,可以自己解决这个问题。
<强>表现力强>
好吧所以在haskell中推理性能有点困难,但大部分时间你都不需要。问题是你可以使用组合器来确保效率。上面的特定模式由foldl
(及其严格的表兄foldl'
)捕获,因此myAdd
可写为:
myAdd = foldl (+) 0
如果你用优化编译它,它将不会给你一个指数空间爆炸!