Haskell 和尾递归不像其他函数式语言和尾递归那样相处融洽。让我们对一些非常简单的代码进行手动缩减,看看尾递归发生了什么。这是map (1+)的尾递归实现。
go [] r = r
go (x:xs) r = go xs (r ++ [1+x])
另外我们必须牢记(++)的定义:
[] ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)
现在让我们减少go [1,2,3,4,5] []。请记住,[x,y,z] 是 x:(y:(z:[])) 的符号,或简称为 x:y:z:[]。
go [1,2,3,4,5] []
go [2,3,4,5] ([] ++ [2]) -- 2 here is actually the thunk 1+1, but
-- for compactness I am reducing earlier
go [3,4,5] (([] ++ [2]) ++ [3])
go [4,5] ((([] ++ [2]) ++ [3]) ++ [4])
go [5] (((([] ++ [2]) ++ [3]) ++ [4]) ++ [5])
go [] ((((([] ++ [2]) ++ [3]) ++ [4]) ++ [5]) ++ [6])
(((([] ++ [2]) ++ [3]) ++ [4]) ++ [5]) ++ [6]
((([2] ++ [3]) ++ [4]) ++ [5]) ++ [6]
(((2:([] ++ [3]) ++ [4]) ++ [5]) ++ [6]
((2:(([] ++ [3]) ++ [4]) ++ [5]) ++ [6]
(2:((([] ++ [3]) ++ [4]) ++ [5]) ++ [6]
2:(((([] ++ [3]) ++ [4]) ++ [5]) ++ [6]) -- first observable output
2:((([3] ++ [4]) ++ [5]) ++ [6])
2:((3:([] ++ [4]) ++ [5]) ++ [6])
2:(3:(([] ++ [4]) ++ [5]) ++ [6])
2:3:((([] ++ [4]) ++ [5]) ++ [6]) -- second observable output
2:3:(([4] ++ [5]) ++ [6])
2:3:(4:([] ++ [5]) ++ [6])
2:3:4:(([] ++ [5]) ++ [6]) -- third observable output
2:3:4:([5] ++ [6])
2:3:4:5:([] ++ [6]) -- fourth observable output
2:3:4:5:6:[] -- final output
看看输出中的每个项目需要如何从一系列嵌套的括号向外工作?这导致它需要输入大小的二次时间才能获得所有输出。您还将看到前几个项目产生缓慢的行为,并且当您到达列表末尾时它变得越来越快。这种减少说明了这一点。
这里的主要性能问题是将新元素附加到列表的末尾,这所花费的时间与您要附加到的列表的大小成正比。更好的方法是 cons 在前面,这是一个 constant-time 操作。这将导致输出反转,因此您需要反转结果。
go [] r = reverse r
go (x:xs) r = go xs ((1+x):r)
reverse xs = rev xs [] -- roughly from the report prelude
rev [] r = r
rev (x:xs) r = rev xs (x:r)
还有,让我们减少:
go [1,2,3,4,5] []
go [2,3,4,5] [2]
go [3,4,5] [3,2]
go [4,5] [4,3,2]
go [5] [5,4,3,2]
go [] [6,5,4,3,2]
reverse [6,5,4,3,2]
rev [6,5,4,3,2] []
rev [5,4,3,2] [6]
rev [4,3,2] [5,6]
rev [3,2] [4,5,6]
rev [2] [3,4,5,6]
rev [] [2,3,4,5,6]
[2,3,4,5,6] -- first and all observable output!
所以这显然比第一个版本要少。这是 Scheme 和 ML 等严格语言中使用的样式,因为它具有良好的内存性能。但是,它也有一些缺点:
- 必须先消耗所有输入,然后才能产生任何输出。事实上,整个计算是在产生任何结果之前执行的。
- 因此,当给定一个无限列表时,它永远不会产生任何输出。
- 它涉及
reverse,它需要额外的O(n)时间,与我们正在做的事情无关(反转与为每个元素添加一个并保持顺序有什么关系?)。李>
在像 Haskell 这样的惰性语言中,我们可以做得更好。奇怪而美妙的是,我们的做法是写得更天真。
go [] = []
go (x:xs) = (1+x):go xs
并减少:
go [1,2,3,4,5]
2:(go [2,3,4,5]) -- first observable output
2:3:(go [3,4,5]) -- second observable output
2:3:4:(go [4,5]) -- third observable output
2:3:4:5:(go [6]) -- fourth observable output
2:3:4:5:6:(go []) -- fifth observable output
2:3:4:5:6:[] -- final output
它需要更少的工作,甚至在查看列表的其余部分之前就开始产生输出,因此它在流计算中具有良好的性能并且可以处理无限输入。并且实现与您希望的一样简单明了。
我希望这能让您对 Haskell 中尾递归的工作原理有所了解。对于您的示例,我建议删除尾递归并以类似于我们最终的go 的朴素风格重写,使用我希望我从这篇文章中建议的直觉来产生“尽可能大的输入前缀,尽快可能”(请注意,返回 x:xs 会立即产生 x,即使还有更多工作要做来计算 xs——这是(非)行动中的懒惰)。