我今天在unix中发现了“time”命令,并认为我会用它来检查Haskell中尾递归和正常递归函数之间运行时的差异。
我写了以下函数:
--tail recursive
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
fac' 1 y = y
fac' x y = fac' (x-1) (x*y)
--normal recursive
facSlow :: (Integral a) => a -> a
facSlow 1 = 1
facSlow x = x * facSlow (x-1)
这些都是有效的,请记住它们仅用于此项目,所以我没有费心去检查零或负数。
但是,在为每个编写main方法,编译它们并使用“time”命令运行它们时,两者都具有类似的运行时, normal 递归函数逐渐淘汰尾递归函数。这与我在lisp中关于尾递归优化的内容相反。这是什么原因?
答案 0 :(得分:151)
Haskell使用惰性求值来实现递归,因此将任何东西视为在需要时提供值的promise(这称为thunk)。只有在必要时才能减少thunks,不再需要。这类似于以数学方式简化表达式的方式,因此以这种方式考虑它是有帮助的。评估顺序不由您的代码指定的事实允许编译器进行许多甚至更巧妙的优化,而不仅仅是您习惯的尾部调用消除。 如果您想进行优化,请与-O2
进行比较!
让我们看看我们如何评估facSlow 5
作为案例研究:
facSlow 5
5 * facSlow 4 -- Note that the `5-1` only got evaluated to 4
5 * (4 * facSlow 3) -- because it has to be checked against 1 to see
5 * (4 * (3 * facSlow 2)) -- which definition of `facSlow` to apply.
5 * (4 * (3 * (2 * facSlow 1)))
5 * (4 * (3 * (2 * 1)))
5 * (4 * (3 * 2))
5 * (4 * 6)
5 * 24
120
所以就像你担心的那样,在计算发生之前我们会有数字的积累,但不像你担心,没有一堆facSlow
函数调用等待终止 - 每次减少都会被应用并消失,在其尾迹中留下stack frame(因为(*)
是严格的,因此会触发对其第二个参数的评估)。
Haskell的递归函数不会以非常递归的方式进行求值!唯一堆叠的呼叫是乘法本身。如果将(*)
视为严格的数据构造函数,则这就是所谓的 guarded 递归(尽管通常使用非 -strict数据构造函数来引用它) ,其中遗留下来的是数据构造函数 - 当被进一步访问强制时)。
现在让我们看一下尾递归fac 5
:
fac 5
fac' 5 1
fac' 4 {5*1} -- Note that the `5-1` only got evaluated to 4
fac' 3 {4*{5*1}} -- because it has to be checked against 1 to see
fac' 2 {3*{4*{5*1}}} -- which definition of `fac'` to apply.
fac' 1 {2*{3*{4*{5*1}}}}
{2*{3*{4*{5*1}}}} -- the thunk "{...}"
(2*{3*{4*{5*1}}}) -- is retraced
(2*(3*{4*{5*1}})) -- to create
(2*(3*(4*{5*1}))) -- the computation
(2*(3*(4*(5*1)))) -- on the stack
(2*(3*(4*5)))
(2*(3*20))
(2*60)
120
所以你可以看到尾递归本身并没有为你节省任何时间或空间。它不仅需要比facSlow 5
更多的整体步骤,还会构建一个嵌套的thunk(此处显示为{...}
) - 需要一个额外的空间 - 它描述了未来计算,要执行的嵌套乘法。
然后通过遍历 it 到底部来重新解析这个thunk,重新创建堆栈上的计算。对于这两个版本,这里也存在导致堆栈溢出和非常长的计算的危险。
如果我们想手动优化这一点,我们所要做的就是严格要求。您可以使用严格的应用程序运算符$!
来定义
facSlim :: (Integral a) => a -> a
facSlim x = facS' x 1 where
facS' 1 y = y
facS' x y = facS' (x-1) $! (x*y)
这迫使facS'
在其第二个参数中变得严格。 (它的第一个参数已经严格,因为必须对其进行评估以决定应用facS'
的哪个定义。)
有时严格可以帮助很大,有时这是一个很大的错误,因为懒惰更有效率。这是一个好主意:
facSlim 5
facS' 5 1
facS' 4 5
facS' 3 20
facS' 2 60
facS' 1 120
120
我认为这是你想要实现的目标。
-O2
foldr
和foldl
,并相互测试。试试这两个:
length $ foldl1 (++) $ replicate 1000
"The size of intermediate expressions is more important than tail recursion."
length $ foldr1 (++) $ replicate 1000
"The number of reductions performed is more important than tail recursion!!!"
foldl1
是尾递归的,而foldr1
执行保护递归,以便立即呈现第一个项目以供进一步处理/访问。 (第一个“括号”同时向左,(...((s+s)+s)+...)+s
,强制其输入列表完全结束并构建未来计算的大部分,比其完整结果更快;第二个括号到右边逐渐地s+(s+(...+(s+s)...))
,一点一点地消耗输入列表,所以整个事情能够在恒定的空间中运行,并进行优化。)
您可能需要根据所使用的硬件调整零的数量。
答案 1 :(得分:15)
应该提到fac
函数不是保护递归的良好候选者。尾递归是去这里的方式。由于懒惰,你没有在fac'
函数中获得TCO的效果,因为累加器参数不断构建大的thunk,在评估时需要一个巨大的堆栈。为了防止这种情况并获得预期的TCO效果,您需要严格控制这些累加器参数。
{-# LANGUAGE BangPatterns #-}
fac :: (Integral a) => a -> a
fac x = fac' x 1 where
fac' 1 y = y
fac' x !y = fac' (x-1) (x*y)
如果您使用-O2
(或仅-O
)进行编译,GHC可能会在strictness analysis阶段自行完成此操作。
答案 2 :(得分:6)
您应该查看tail recursion in Haskell上的wiki文章。特别是,由于表达式评估,您想要的递归类型是保护递归。如果你弄清楚幕后发生了什么(在Haskell的抽象机器中)你会得到与严格语言中的尾递归相同的东西。除此之外,您还有一个统一的惰性函数语法(尾递归会将您绑定到严格的评估,而受保护的递归更自然地工作)。
(在学习Haskell时,其余的wiki页面也很棒!)
答案 3 :(得分:0)
如果我没记错的话,GHC会自动将普通递归函数优化为尾递归优化函数。