函数语言是否更擅长递归?


41

TL; DR:功能语言比非功能语言处理递归的效果好吗?

我目前正在阅读Code Complete2。在本书的某些时候,作者警告我们有关递归的问题。他说,应尽可能避免这样做,并且使用递归的功能通常不如使用循环的解决方案有效。例如,作者使用递归编写了一个Java函数,以计算数字的阶乘(这样可能不完全相同,因为目前我没有这本书)。

public int factorial(int x) {
    if (x <= 0)
        return 1;
    else
        return x * factorial(x - 1);
}

这是一个不好的解决方案。但是,在函数式语言中,使用递归通常是首选的处理方式。例如,以下是Haskell中使用递归的阶乘函数:

factorial :: Integer -> Integer
factorial 0 = 1
factorial n = n * factorial (n - 1)

并被广泛接受为一个好的解决方案。正如我所看到的,Haskell非常经常使用递归,并且我看不到它被皱眉的任何地方。

所以我的问题基本上是:

  • 功能语言比非功能语言处理递归更好吗?

编辑:我知道我使用的示例不是最好的例子来说明我的问题。我只是想指出,Haskell(通常是功能语言)比非功能语言使用递归的频率更高。


10
恰当的例子:许多功能语言都大量使用了尾部调用优化,而很少有过程语言会这样做。这意味着在这些功能语言中,尾调用递归便宜得多。
约阿希姆·绍尔

7
实际上,您给的Haskell定义非常糟糕。factorial n = product [1..n]更简洁,更有效,并且不会使堆栈大面积溢出n(并且如果需要记忆,则需要完全不同的选项)。product用some 定义fold,它递归定义的,但要格外小心。多数情况下,递归可以接受的解决方案,但是错误/次优的做法仍然很容易。

1
@JoachimSauer-点缀一下,您的评论将是一个有价值的答案。
Mark Booth 2012年

您所做的修改表明您没有听懂我的意见。您给出的定义是一个完美的递归示例,即使在函数式语言中也是如此。我的选择也是递归的(尽管它在库函数中)并且非常有效,只有递归的方式才有所作为。Haskell也是一个奇怪的情况,因为懒惰打破了通常的规则(例如:函数可以在执行尾递归的同时溢出堆栈,并且在不执行尾递归的情况下非常高效)。

@delnan:感谢您的澄清!我将编辑编辑内容;)
marco-fiset 2012年

Answers:


36

是的,他们这样做了,但不仅因为他们可以,而且因为他们必须这样做

这里的关键概念是纯度:纯函数是没有副作用且没有状态的函数。函数式编程语言通常出于多种原因而接受纯正,例如关于代码的推理和避免非显而易见的依赖性。某些语言,尤其是Haskell,甚至达到允许纯代码的程度。程序可能具有的任何副作用(例如执行I / O)都将移至非纯运行时,从而使语言本身保持纯净。

没有副作用意味着您不能拥有循环计数器(因为循环计数器将构成可变状态,而修改这种状态将是副作用),因此,纯函数式语言可以获得的最迭代的方法是遍历列表(此操作通常称为foreachmap)。但是,递归是纯函数编程的自然匹配-递归不需要任何状态,除了(只读)函数参数和(仅写)返回值外。

但是,没有副作用也意味着可以更有效地实现递归,并且编译器可以更积极地对其进行优化。我本人还没有深入研究过此类编译器,但据我所知,大多数功能编程语言的编译器都执行尾部调用优化,有些甚至可能将某些类型的递归构造编译为后台循环。


2
根据记录,消除尾音不依赖于纯度。
围巾岭

2
@scarfridge:当然不是。但是,在给出纯度的情况下,编译器对代码重新排序以允许进行尾部调用要容易得多。
tdammers 2012年

与GHC相比,GCC在TCO方面做得更好,因为您无法在创建thunk时进行TCO。
dan_waterworth 2012年

18

您正在比较递归与迭代。如果没有消除尾部调用,迭代实际上会更有效,因为没有多余的函数调用。同样,迭代可以永远进行下去,而可能由于太多的函数调用而耗尽堆栈空间。

但是,迭代需要更改计数器。这意味着必须有一个可变变量,在纯功能设置中禁止使用该变量。因此功能语言经过特殊设计,无需迭代即可运行,因此简化了函数调用。

但是,这些都没有解决您的代码示例如此精巧的原因。您的示例演示了另一个属性,即模式匹配。这就是Haskell样本没有明确的条件的原因。换句话说,不是简化的递归使您的代码变小;这是模式匹配。


我已经知道什么是模式匹配,我认为这是Haskell的一个很棒的功能,我错过了我使用的语言!
marco-fiset,2012年

@marcof我的观点是,关于递归与迭代的所有讨论都无法解决代码示例的精巧之处。这实际上是关于模式匹配与条件匹配。也许我应该把它放在我的答案中。
chrisaycock 2012年

是的,我也知道:P
marco-fiset,2012年

@chrisaycock:是否有可能将迭代视为尾递归,其中循环主体中使用的所有变量既是参数又是递归调用的返回值?
乔治

@Giorgio:是的,让您的函数接受并返回相同类型的元组。
Ericson2314 2013年

5

从技术上说不,但实际上是。

当您采用功能性方法解决问题时,递归更为常见。这样,旨在使用功能性方法的语言通常包含使递归更容易/更好/更少麻烦的功能。在我的头顶上,有三个常见的问题:

  1. 尾部呼叫优化。正如其他张贴者所指出的那样,功能语言通常需要TCO。

  2. 惰性评估。对Haskell(和其他几种语言)的评价是懒惰的。这会延迟方法的实际“工作”,直到需要它为止。这往往会导致更多的递归数据结构,并因此导致使用递归数据结构。

  3. 不变性。使用函数式编程语言处理的大多数内容都是不可变的。这使递归变得更容易,因为您不必担心对象随时间的变化。例如,您不能将值从其下方更改。同样,许多语言被设计用来检测纯函数。由于纯函数没有副作用,因此编译器在函数运行顺序和其他优化方面具有更大的自由度。

这些东西都没有真正针对功能语言而相对于其他语言,因此它们并不仅仅是因为它们具有功能而变得更好。但是由于它们具有功能性,因此做出的设计决策将倾向于这些功能,因为它们在进行功能编程时更有用(缺点也更少)。


1
回复:1.早期回报与尾注无关。您可以通过尾部调用提前返回,而“延迟”返回也具有尾部调用,并且您可以使用单个简单表达式,而递归调用不在尾部(参见OP的阶乘定义)。

@delnan:谢谢;这是很早的事,距离我研究这件事已经有一段时间了。
Telastyn

1

Haskell和其他功能语言通常使用惰性评估。通过此功能,您可以编写无止境的递归函数。

如果编写递归函数时未定义递归结束的基本情况,则最终将无限次调用该函数和stackoverflow。

Haskell还支持递归函数调用优化。在Java中,每个函数调用都会堆积起来并导致开销。

所以是的,功能语言比其他语言更好地处理了递归。


5
Haskell是极少数非严格语言中的一种-整个ML系列(除了一些增加懒惰的研究衍生产品),所有流行的Lisps,Erlang等都非常严格。另外,第二段似乎不对-正如您在第一段中正确指出的那样,懒惰确实允许无限递归(例如,Haskell的前奏非常有用forever a = a >> forever a)。

@deinan:据我所知,SML / NJ也提供了惰性评估,但这是SML的补充。我还想命名几种惰性函数语言中的两种:Miranda和Clean。
乔治

1

我知道的唯一技术原因是某些功能语言(以及某些命令式语言,如果我记得的话)具有所谓的尾部调用优化,这使得递归方法不必在每次递归调用(即递归调用)时都增加堆栈的大小。或多或少替换堆栈上的当前调用)。

请注意,此优化不适用于任何递归调用,仅适用于尾调用递归方法(即,在递归调用时不保持状态的方法)


1
(1)这种优化仅适用于非常特殊的情况-OP的示例并非如此,许多其他简单函数需要格外小心才能成为尾递归。(2)真正的尾部调用优化不仅可以优化递归函数,还可以消除任何调用后立即返回的空间开销。

@delnan:(1)是的,非常正确。在这个答案的“原始草案”中,我提到过:((2)是的,但在问题范围内,我认为这是多余的。–
史蒂文·埃弗斯

是的,(2)只是一个有用的补充(尽管对于连续传递样式而言必不可少),无需提及的答案是。

1

您可能想看看Garbage Collection的速度快,但是Stack is Faster的速度,这是一篇有关将C程序员认为是“堆”的文档,用于已编译的C的堆栈框架的文章。 。这不是一个确定的答案,但是可以帮助您了解递归的一些问题。

与Bell Labs的Plan 9一起使用的Alef编程语言有一个“成为”语句(请参阅本参考资料的 6.6.4节)。这是一种显式的尾调用递归优化。“但是它用完了调用栈!” 反对递归的论点有可能被消除。


0

TL; DR:是的,它们确实
是递归程序,它是函数式编程中的关键工具,因此,在优化这些调用方面已进行了大量工作。例如,R5RS要求(在规范中!),所有实现都必须处理未绑定的尾部递归调用,而程序员无需担心堆栈溢出。为了进行比较,默认情况下,C编译器甚至不会进行明显的尾部调用优化(尝试对链表进行递归反向),并且在进行一些调用之后,程序将终止(但是,如果您使用- O2)。

当然,在可怕的程序中,例如著名的fib指数示例,编译器几乎没有选择权来执行其“魔术”。因此,应注意不要妨碍编译器进行优化。

编辑:通过fib示例,我的意思是:

(define (fib n)
 (if (< n 3) 1 
  (+ (fib (- n 1)) (fib (- n 2)))
 )
)

0

函数式语言在两种非常特定的递归类型上比较好:尾递归和无限递归。在其他类型的递归中,它们与其他语言一样糟糕,例如您的factorial示例。

这并不是说在这两种范式中都没有一种算法能很好地与常规递归一起使用。例如,任何需要栈式数据结构的东西,例如深度优先树搜索,都是最简单的递归实现。

递归在函数式编程中经常出现,但是它也被过度使用,特别是对于初学者或在初学者的教程中,这可能是因为大多数函数式初学者在命令式编程中都使用过递归。还有其他函数式编程构造,例如列表推导,高阶函数以及对集合的其他操作,它们通常在概念上,风格,简洁性,效率和优化能力上都更加合适。

例如,delnan的建议factorial n = product [1..n]不仅更简洁,更易于阅读,而且高度可并行化。与使用foldreduce如果您的语言product尚未内置已经使用的方法相同。递归是解决此问题的最后方法。您在教程中看到它递归解决的主要原因是作为获得更好解决方案之前的起点,而不是最佳实践的示例。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.