递归本身就是一种功能吗?


116

...或者只是一种做法?

我之所以这样问是因为我的教授曾提出过一个论点:我因没有在课堂上讨论递归而递归调用函数而失去了信誉,我的论点是我们通过学习return和方法隐式地学习了它。

我在这里问是因为我怀疑有人有明确的答案。

例如,以下两种方法有什么区别:

public static void a() {
    return a();
    }

public static void b() {
    return a();
    }

除了“ a永远继续”(在实际程序中,当提供无效输入时,它可以正确地再次提示用户),a和之间是否有根本区别b?对于未优化的编译器,它们的处理方式有何不同?

归根结底,通过学习可以归结为是否return a()b我们为此还学会了return a()a。有吗


24
精彩的辩论。我想知道您是否向您的教授这样解释了。如果您这样做,我认为他应该给您您的信用损失。
Michael Yaworski 2014年

57
递归甚至不是计算机科学专有的概念。Fibonacci函数,阶乘运算符以及数学(可能还有其他领域)中的许多其他事物都(或至少可以)递归表示。教授是否要求您也忽略这些事情?
Theodoros Chatzigiannakis 2014年

34
教授应该给他额外的荣誉,因为他提出了一种解决问题的优雅方法,或者说出了开箱即用的思维方式。

11
任务是什么?我经常想知道的一个问题是,当您提交编程作业时,被标记的内容,解决问题的能力或使用所学知识的能力。这两个不一定相同。
2014年

35
FWIW,提示输入直到正确为止不是使用递归的好地方,它很容易溢出堆栈。对于此特定实例,最好使用a() { do { good = prompt(); } while (!good); }
凯文

Answers:


113

要回答您的特定问题:不,从学习语言的角度来看,递归不是功能。如果您的教授真的停靠了您,则使用了他尚未教过的“功能”,这是错误的。

在两行之间阅读时,一种可能性是通过使用递归,您避免使用曾经被认为是其课程学习成果的功能。例如,也许您根本不使用迭代,或者您仅使用for循环而不是同时使用forwhile。通常,一项任务旨在测试您做某些事情的能力,如果您避免做这些事情,您的教授根本无法授予您为此功能留出的分数。但是,如果这确实是造成您丢分的原因,则教授应将其视为自己的学习经历-如果证明某些学习成果是作业的标准之一,则应向学生明确说明。

话虽如此,我同意其他大多数意见和答案,认为迭代是比递归更好的选择。原因有两个,虽然其他人在某种程度上已经涉及到它们,但我不确定他们是否已经完全解释了它们背后的想法。

堆栈溢出

更为明显的是,您可能会遇到堆栈溢出错误。实际上,您编写的方法不太可能真正导致错误,因为用户必须多次输入错误才能实际触发堆栈溢出。

但是,要记住的一件事是,不仅方法本身,而且调用链中位于上位或下位的其他方法都将位于堆栈中。因此,随便吞噬可用的堆栈空间对于任何方法都非常不礼貌。没人愿意在每次编写代码时都经常担心可用的堆栈空间,这是因为其他代码可能会不必要地消耗掉其中的大量内存。

这是称为抽象的软件设计中更通用的原理的一部分。本质上,当您致电时DoThing(),您需要关心的只是事情已经完成。你不应该约的实现细节担心如何,它的完成。但是贪婪地使用堆栈会破坏这一原理,因为每一位代码都必须担心调用链中其他地方的代码可以安全地假定有多少堆栈留给了它。

可读性

另一个原因是可读性。代码应该向往的理想是成为人类可读的文档,其中每一行都简单地描述了它在做什么。采取以下两种方法:

private int getInput() {
    int input;
    do {
        input = promptForInput();
    } while (!inputIsValid(input))
    return input;
}

private int getInput() {
    int input = promptForInput();
    if(inputIsValid(input)) {
        return input;
    }
    return getInput();
}

是的,它们都可以使用,是的,它们都很容易理解。但是如何用英语描述这两种方法?我认为应该是这样的:

我将提示您输入,直到输入有效,然后将其返回

我将提示输入,然后如果输入有效,则将其返回,否则将获取输入并返回该结果

也许您会想到后者的措辞稍微不那么笨拙,但我认为您总是会发现第一个在概念上将对您实际要执行的操作进行更准确的描述。这并不是说递归总是不太可读。对于闪耀的情况(例如遍历树),您可以在递归和另一种方法之间进行相同类型的并排分析,并且几乎可以肯定的是,递归提供的代码逐行清晰易懂。

孤立地看,这两个都是小问题。这极不可能真的导致堆栈溢出,并且可读性的提高很小。但是任何程序都将包含许多这些小决定,因此即使孤立地将它们无关紧要,重要的是要学习使它们正确的背后原理。


8
您可以扩展一下递归不是功能的断言吗?我已经在回答中指出了这一点,因为并非所有编译器都必须支持它。
哈里·约翰斯顿

5
并非所有语言也都必须支持递归,因此不一定只是选择正确的编译器的问题-但是您很正确地说“功能”是一种固有的模棱两可的描述,足够公平。从某人学习编程的观点(现在很普遍)的角度来看,第二点也是很公平的,而首先没有任何机器代码编程的背景。:-)
哈里·约翰斯顿

2
请注意,“可读性”问题是语法问题。递归没有天生就“不可读”。实际上,归纳是表达归纳数据结构(如循环,列表和序列等)的最简单方法。而且大多数数据结构都是归纳的。
nomen 2014年

6
我认为您已经在措辞上堆砌了甲板。您从功能上描述了迭代版本,反之亦然。我认为对这两者的逐行描述将是“我将提示输入。如果输入无效,我将继续重复提示,直到获得有效输入为止。然后我将其退还。” 与“我会提示您输入。如果输入有效,我将返回它。否则,我将返回重做的结果。” (我的孩子在上学前就了解复习的功能概念,所以我认为这是递归概念的合法英文摘要。)
pjs 2014年

2
@HarryJohnston缺少对递归的支持将是现有功能的一个例外,而不是缺少新功能。特别地,在这个问题的上下文中,“新功能”的意思是“我们还没有教过的有用行为存在”,这不是递归的,因为它是对教功能的逻辑扩展(即,过程包含说明和过程调用为说明)。好像教授教了一个学生加法,然后又责骂他多次增加相同的值,因为“我们还没有涵盖乘法”。
nmclean 2014年

48

要回答字面问题而不是元问题:在并非所有编译器和/或语言都必须允许它的意义上,递归一项功能。在实践中,所有(普通)现代编译器-当然所有Java编译器都可以使用它!-但并非普遍如此。

作为为什么不支持递归的人为例子,考虑一个将函数的返回地址存储在静态位置的编译器。例如,对于没有堆栈的微处理器的编译器可能就是这种情况。

对于这样的编译器,当您调用这样的函数时

a();

它实现为

move the address of label 1 to variable return_from_a
jump to label function_a
label 1

以及a()的定义,

function a()
{
   var1 = 5;
   return;
}

被实现为

label function_a
move 5 to variable var1
jump to the address stored in variable return_from_a

希望a()在这种编译器中递归调用时出现的问题很明显;编译器不再知道如何从外部调用返回,因为返回地址已被覆盖。

对于我实际使用的编译器(我认为是70年代末或80年代初),它不支持递归,这个问题比这要微妙得多:返回地址将存储在堆栈中,就像在现代编译器中一样,但是局部变量不是没错 (从理论上讲,这应该意味着对于没有非静态局部变量的函数可以进行递归,但是我不记得编译器是否明确支持该函数。出于某种原因,它可能需要隐式局部变量。)

展望未来,我可以想象到特殊的场景-也许是高度并行的系统-不必为每个线程提供堆栈可能是有利的,因此只有在编译器可以将其重构为循环的情况下才允许递归。(当然,我上面讨论的原始编译器不能执行诸如重构代码之类的复杂任务。)


例如,C预处理器不支持宏的递归。宏定义的行为类似于函数,但是您不能递归调用它们。
2014年

7
您的“人为的例子”并不是所有人都想得到的:Fortran 77标准不允许函数递归地调用自身-原因很像您所描述的。(我相信完成该功能时要跳转到的地址存储在功能代码本身的末尾,或等同于这种排列的内容。)有关内容,请参见此处
亚历克西斯

5
例如,着色器语言或GPGPU语言(例如GLSL,Cg,OpenCL C)不支持递归。就此而言,“并非所有语言都支持”这一论点当然是有效的。递归假定堆栈的等效项(它不一定必须是堆栈,但需要某种方法以某种方式存储返回地址和功能帧)。
达蒙2014年

我在1970年代初期开发的Fortran编译器没有调用堆栈。每个子例程或函数都有用于返回地址,参数及其自身变量的静态存储区。
Patricia Shanahan 2014年

2
默认情况下,甚至某些版本的Turbo Pascal都禁用了递归,并且您必须设置一个编译器指令才能启用它。
dan04

17

老师想知道你是否学习过。显然,您没有按照他教给您的方法解决问题(好的方法;迭代),因此认为您没有解决。我全力以赴提出创造性的解决方案,但是在这种情况下,我必须出于不同的原因与您的老师达成共识:
如果用户多次提供了无效的输入(例如,按住Enter键),则会出现堆栈溢出异常,并且您的解决方案将崩溃​​。此外,该迭代解决方案更加有效且易于维护。我认为这就是您的老师应该给您的原因。


2
我们没有被告知要以任何特定方式执行此任务;我们了解了方法,而不仅仅是迭代。另外,我会根据个人喜好选择哪一个更容易阅读:我选择了对我来说看起来不错的东西。SO错误对我来说是新的,尽管递归本身是一个功能的想法似乎仍然没有成立。

3
“我会根据个人喜好选择哪一个更容易阅读”。同意 递归不是Java功能。这些是。
mike

2
@Vality:消除尾声?某些JVM可能会这样做,但请记住,它还需要维护异常的堆栈跟踪。如果它允许消除尾部调用,那么天真生成的堆栈跟踪可能变得无效,因此某些JVM出于这个原因不执行TCE。
icktoofay,2014年

5
无论哪种方式,依靠优化来减少损坏的代码的方式都是非常糟糕的形式。
cHao 2014年

7
+1,看到在Ubuntu最近登录屏幕是当用户不断创出回车键,同样happend到Xbox破
塞巴斯蒂安

13

减去分数是因为“我们没有在课堂上介绍递归”。如果您了解了如何调用函数A,该函数调用了函数B,该函数调用了函数C,该函数返回了B,又返回了B,返回了返回给调用者的A,并且老师没有明确告诉您这些必须是不同的函数(例如,在旧的FORTRAN版本中就是这种情况),没有理由A,B和C不能都具有相同的功能。

另一方面,我们必须查看实际代码来确定在特定情况下使用递归是否确实是正确的选择。没有很多细节,但这听起来确实是错误的。


10

关于您所问的特定问题,有很多观点需要考虑,但是我可以说的是,从学习语言的角度来看,递归本身并不是一项功能。如果您的教授真的停靠了您,使用了他还没有教过的“功能”,那是错的,但是就像我说的,这里还有其他观点需要考虑,这实际上使教授在扣除分数时是正确的。

从我的问题中可以得出的结论是,在输入失败的情况下使用递归函数来请求输入不是一个好习惯,因为每个递归函数的调用都会推送到堆栈中。由于此递归由用户输入驱动,因此可能具有无限递归功能,从而导致StackOverflow。

在问题中提到的这两个示例之间没有什么区别(但在其他方面也有所不同)-在两种情况下,返回地址和所有方法信息都被加载到堆栈中。在递归情况下,返回地址就是方法调用之后的行(当然,返回地址并不完全是您在代码本身中看到的,而是在编译器创建的代码中看到的)。在Java,C和Python中,与迭代(通常)相比,递归的开销相当大,因为它需要分配新的堆栈框架。更何况,如果输入无效的次数过多,您可能会得到堆栈溢出异常。

我相信教授会扣除点,因为递归被认为是其本身的主题,没有编程经验的人不太可能会想到递归。(当然,这并不意味着他们不会,但是不太可能)。

恕我直言,我认为教授扣除您的分数是正确的。您可以轻松地将验证部分带入另一种方法,并像这样使用它:

public bool foo() 
{
  validInput = GetInput();
  while(!validInput)
  {
    MessageBox.Show("Wrong Input, please try again!");
    validInput = GetInput();
  }
  return hasWon(x, y, piece);
}

如果确实以这种方式可以解决您所做的事情,那么您的做法就是不好的做法,应该避免。


方法本身的目的是验证输入,然后调用并返回另一个方法的结果(这就是它返回自身的原因)。具体来说,它将检查井字游戏中的移动是否有效,然后返回hasWon(x, y, piece)(仅检查受影响的行和列)。

您可以轻松地仅接受验证部分,然后将其放入另一个名为“ GetInput”的方法中,然后像我在答案中所写的那样使用它。我已经编辑了答案,看起来应该是什么样子。当然,您可以使GetInput返回一个保存所需信息的类型。
Yonatan Nir ​​2014年

1
Yonatan Nir:递归是什么不好的做法?也许JVM会崩溃,因为Hotspot VM由于字节码安全性而无法优化,而东西将是一个很好的论点。除了使用不同的方法外,您的代码有什么不同?

1
递归并不总是一个坏习惯,但是如果可以避免递归并且保持代码干净且易于维护,则应避免递归。在Java,C和Python中,与迭代相比(一般而言),递归的开销相当大,因为它需要分配新的堆栈框架。在某些C编译器中,可以使用编译器标志消除这种开销,该开销将某些类型的递归(实际上是某些类型的尾部调用)转换为跳转而不是函数调用。
Yonatan Nir ​​2014年

1
尚不清楚,但是如果您用无限次数的递归替换了一个循环,那么那就不好了。Java不保证优化尾部调用,因此您可能很容易用完堆栈空间。在Java中,请勿使用递归,除非可以保证迭代次数有限(通常与数据总大小相比为对数)。
海德2014年

6

也许您的教授还没有教过它,但是听起来您已经准备好学习递归的优缺点。

递归的主要优点是,递归算法通常更容易编写,而且写得更快。

递归的主要缺点是递归算法会导致堆栈溢出,因为递归的每个级别都需要向堆栈中添加一个额外的堆栈帧。

对于生产代码而言,缩放可导致生产中的递归级别比程序员的单元测试多得多,缺点通常胜于优点,并且在可行时通常避免使用递归代码。


1
任何具有潜在风险的递归算法都可以始终被重写以使用显式堆栈-毕竟,调用堆栈只是一个堆栈。在这种情况下,如果将解决方案改写为使用堆栈,则看起来很可笑-进一步证明递归答案不是一个很好的答案。
2014年

1
如果堆栈溢出是一个问题,你应该使用的语言/运行时,它支持尾调用优化,比如.NET 4.0或任何功能的编程语言
塞巴斯蒂安

并非所有的递归都是尾调用。
沃伦·露

6

关于特定问题,递归是一个功能,我倾向于说是,但是在重新解释了问题之后。可以使用递归的语言和编译器有多种常见的设计选择,并且确实存在图灵完备的语言,根本不允许递归。换句话说,递归是由语言/编译器设计中的某些选择启用的功能。

  • 支持一流的功能使递归成为可能,而且只需极少的假设。有关示例,请参见在Unlambda中编写循环,或者此钝的Python表达式不包含自引用,循环或赋值:

    >>> map((lambda x: lambda f: x(lambda g: f(lambda v: g(g)(v))))(
    ...   lambda c: c(c))(lambda R: lambda n: 1 if n < 2 else n * R(n - 1)),
    ...   xrange(10))
    [1, 1, 2, 6, 24, 120, 720, 5040, 40320, 362880]
    
  • 使用后期绑定或定义前向声明的语言/编译器使递归成为可能。例如,尽管Python允许以下代码,但这是设计选择(后期绑定),而不是图灵完备系统的要求。相互递归函数通常取决于对前向声明的支持。

    factorial = lambda n: 1 if n < 2 else n * factorial(n-1)
    
  • 允许递归定义类型的静态类型语言有助于启用递归。在Go中查看Y组合器的实现。如果没有递归定义的类型,仍然可以在Go中使用递归,但是我相信Y组合器是不可能的。


1
这使我的头发生了爆炸,尤其是Unlambda +1
John Powell

定点组合器很难。当我决定学习函数式编程时,我强迫自己学习Y组合器,直到理解为止,然后将其应用于编写其他有用的函数。花了我一段时间,但值得。
wberry

5

根据您的问题,我可以推断出,在输入失败的情况下,使用递归函数来请求输入不是一个好习惯。为什么?

因为每个递归函数调用都被压入堆栈。由于此递归是由用户输入驱动的,因此可能具有无限递归功能,从而导致StackOverflow :-p

有一个非递归循环可以做到这一点。


有问题的方法以及方法本身的目的是通过各种检查来验证输入。如果输入无效,则该过程重新开始,直到输入正确为止(如指示)。

4
@fay但是,如果输入无效的次数过多,则会收到StackOverflowError。递归更为优雅,但在我看来,通常比常规循环更麻烦(由于堆栈)。
迈克尔·亚沃斯基

1
那么,这是一个有趣且很好的观点。我没有考虑过那个错误。但是,通过while(true)调用相同的方法可以达到相同的效果吗?如果是这样,我不会说这支持递归之间的任何区别,这是众所周知的。

1
@fay while(true)是一个无限循环。除非您有一条break声明,否则我看不出要点,除非您试图使程序崩溃。我的意思是,如果您调用相同的方法(即递归),则有时会给您一个StackOverflowError,但如果使用whileor for循环,则不会。常规循环根本不存在该问题。也许我误会了你,但是我对你的回答是“否”。
迈克尔·亚沃斯基

4
老实说,这似乎是教授取消分数的真正原因=)他可能没有很好地解释它,但这是一个有效的抱怨,说您以某种方式使用它,如果没有的话,会被认为是非常糟糕的风格。完全在更严重的代码中存在缺陷。
指挥官香菜Sal 2014年

3

递归是一个编程概念,一个功能(例如迭代)和一种实践。正如您从链接中看到的那样,该主题有很多研究领域。也许我们不需要深入探讨这些观点。

递归功能

简而言之,Java隐式支持它,因为它允许一个方法(基本上是一个特殊的函数)具有自身的知识以及组成其所属类的其他方法的知识。考虑一种不是这种情况的语言:您将能够编写该方法的主体a,但无法在其中包含a对它的调用。唯一的解决方案是使用迭代来获得相同的结果。在这种语言中,您将必须区分知道自己存在的功能(通过使用特定的语法标记)和不知道的功能!实际上,一整组语言确实可以做到这一点(例如,请参见LispML系列)。有趣的是,Perl甚至允许匿名函数(所谓的lambdas)以递归方式调用自己(再次使用专用语法)。

没有递归?

对于甚至不支持递归可能性的语言,通常还有另一种解决方案,即定点组合器,但是它仍然需要该语言支持所谓的一流对象(即可能是在语言本身内操纵)。

递归实践

以某种语言提供该功能并不一定意味着它是惯用语言。在Java 8中,包含了lambda表达式,因此采用功能性方法进行编程可能会变得更加容易。但是,有一些实际考虑事项:

  • 语法仍然不是很友好的递归
  • 编译器可能无法检测到该实践并对其进行优化

底线

幸运的是(或更准确地说,为了易于使用),Java确实让方法默认情况下会意识到它们的本身,因此支持递归,因此这并不是一个实际的问题,但仍然是一个理论问题,我想您的老师想专门解决这个问题。此外,鉴于语言的最新发展,将来它可能会变得很重要。

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.