递归本身是一个特征吗?

Posted

技术标签:

【中文标题】递归本身是一个特征吗?【英文标题】:Is recursion a feature in and of itself? 【发布时间】:2014-06-28 09:05:37 【问题描述】:

...还是只是一种练习?

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

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

比如下面两种方法有什么区别:

public static void a() 
    return a();
    

public static void b() 
    return a();
    

除了“a永远持续”(在实际程序中,当提供无效输入时正确使用它再次提示用户)之外,ab之间有什么根本区别吗?对于未优化的编译器,它们的处理方式有何不同?

最终归结为是否通过从b学习return a(),我们是否也从a学习return a()。我们有吗?

【问题讨论】:

精彩的辩论。我想知道你是否这样向你的教授解释过。如果你这样做了,我认为他应该给你失去的信用。 递归甚至不是计算机科学独有的概念。斐波那契函数、阶乘运算符和许多来自数学(可能还有其他领域)的其他东西都是(或至少可以)递归表达的。教授是否要求你也忘记这些事情? 教授应该给他额外的荣誉,因为他想出了一种优雅的方法来解决问题,或者说开箱即用。 作业是什么?这是我经常想知道的一个问题,当你提交编程作业时,被标记的是什么,你解决问题的能力或者你使用所学知识的能力。这两个不一定相同。 FWIW,提示输入直到正确不是使用递归的好地方,太容易溢出堆栈。对于这种特殊情况,最好使用a() do good = prompt(); while (!good); 之类的东西。 【参考方案1】:

回答您的具体问题:不,从学习语言的角度来看,递归不是一项功能。如果您的教授真的因为使用他尚未教过的“功能”而扣分,那是错误的。

在字里行间,一种可能性是,通过使用递归,您可以避免使用本应作为课程学习成果的功能。例如,也许您根本没有使用迭代,或者您只使用了for 循环而不是同时使用forwhile。一项作业通常旨在测试您做某些事情的能力,如果您避免做这些事情,您的教授根本无法授予您为该功能预留的分数。但是,如果这确实是你失分的原因,教授应该把它当作他或她自己的学习经历——如果展示某些学习成果是作业的标准之一,那么应该向学生清楚地解释.

话虽如此,我同意大多数其他 cmets 并回答迭代是比递归更好的选择。有几个原因,虽然其他人在一定程度上提到了它们,但我不确定他们是否已经完全解释了它们背后的想法。

堆栈溢出

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

但是,要记住的一件事是,不仅方法本身,调用链中更高或更低的其他方法都将在堆栈上。正因为如此,随便占用可用的堆栈空间对于任何方法来说都是一件非常不礼貌的事情。没有人愿意在编写代码时一直担心可用的堆栈空间,因为其他代码可能会不必要地使用大量堆栈空间。

这是软件设计中更普遍的原则的一部分,称为抽象。本质上,当您致电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();

是的,这两种方法都有效,而且它们都很容易理解。但是如何用英语描述这两种方法呢?我想应该是这样的:

我会提示输入直到输入有效,然后返回

我会提示输入,如果输入有效则返回,否则我获取输入并返回结果

也许您可以为后者考虑稍微不那么笨拙的措辞,但我认为您总是会发现,从概念上讲,第一个更准确地描述了您实际尝试做的事情。这并不是说递归总是可读性差。对于它发光的情况,比如树遍历,你可以在递归和另一种方法之间做同样的并行分析,你几乎肯定会发现递归给出的代码更清楚地自我描述,一行一行。

单独来看,这两个都是小问题。这不太可能真正导致堆栈溢出,并且可读性的提高很小。但是任何计划都将是许多这些小决定的集合,因此即使它们孤立地无关紧要,学习正确处理它们背后的原则也很重要。

【讨论】:

您能否扩展您的断言,即递归不是一项功能?我在我的回答中辩称它是,因为并非所有编译器都一定支持它。 并非所有语言都必须支持递归,所以这不一定只是选择正确编译器的问题 - 但你说“特性”是一个固有的模棱两可的描述是完全正确的,所以很公平。从没有任何机器代码编程背景的人学习编程(现在通常如此)的角度来看,您的第二点也是公平的。 :-) 请注意,“可读性”问题是语法问题。关于递归,没有什么本质上是“不可读的”。事实上,归纳是表达归纳数据结构的最简单方法,如循环、列表和序列等。而且大多数数据结构都是归纳的。 我认为你已经用你的措辞堆叠了甲板。您在功能上描述了迭代版本,反之亦然。我认为对两者的更公平的逐行描述是“我将提示输入。如果输入无效,我将不断重复提示,直到获得有效输入。那我就退了。” vs “我会提示输入。如果输入有效,我将返回它。否则我将返回重做的结果。” (我的孩子在学前班时就理解了重做的功能概念,所以我认为这是递归概念的合理英文总结。) @HarryJohnston 缺乏对递归的支持将是现有功能的例外,而不是缺少新功能。特别是,在这个问题的上下文中,“新功能”意味着“我们还没有教过的有用行为存在”,这不适用于递归,因为它是曾经的功能的逻辑扩展教导(即过程包含指令,过程调用是指令)。就好像教授教了一个学生加法,然后因为“我们没有涵盖乘法”而责骂他不止一次地添加相同的值。【参考方案2】:

回答字面问题,而不是元问题:递归一个特性,在某种意义上,并不是所有的编译器和/或语言都必须允许它。在实践中,所有(普通)现代编译器都需要它——当然也包括所有 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 预处理器不支持宏中的递归。宏定义的行为类似于函数,但不能递归调用它们。 您的“人为示例”并不是那么人为:Fortran 77 标准不允许函数递归地调用自己——原因与您所描述的差不多。 (我相信函数完成时跳转到的地址存储在函数代码本身的末尾,或者与这种安排等效的地方。)请参阅 here 了解有关此内容的信息。 着色器语言或 GPGPU 语言(例如,GLSL、Cg、OpenCL C)不支持递归,例如。就目前而言,“并非所有语言都支持它”的论点当然是有效的。递归假定等效于堆栈(它不一定是堆栈,但需要有一种方法来存储返回地址和函数帧以某种方式)。 我在 1970 年代早期研究过的 Fortran 编译器没有调用堆栈。每个子程序或函数都有用于返回地址、参数和它自己的变量的静态内存区域。 甚至某些版本的 Turbo Pascal 默认禁用递归,您必须设置编译器指令才能启用它。【参考方案3】:

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

【讨论】:

我们没有被告知以任何特定方式执行此任务;我们学习了方法,而不仅仅是迭代。另外,我会根据个人喜好决定哪一个更容易阅读:我选择了对我来说看起来不错的东西。 SO 错误对我来说是新的,尽管递归本身就是一个特性的想法似乎仍然没有成立。 “我会根据个人喜好决定哪一个更容易阅读”。同意。递归不是 Java 特性。 These 是。 @Vality:尾调用消除?一些 JVM 可能会这样做,但请记住,它还需要为异常维护堆栈跟踪。如果它允许消除尾调用,那么天真的生成的堆栈跟踪可能会变得无效,因此一些 JVM 出于这个原因不执行 TCE。 无论哪种方式,依靠优化来减少损坏的代码,都是很糟糕的形式。 +1,看到最近在 Ubuntu 中,当用户连续按 Enter 按钮时,登录屏幕被破坏,XBox 也发生了同样的情况【参考方案4】:

因为“我们没有在课堂上介绍递归”而扣分是很糟糕的。如果你学会了如何调用函数 A 调用函数 B 调用函数 C 返回到 B 再返回到 A 返回到调用者,并且老师没有明确告诉你这些必须是不同的函数(例如,在旧的 FORTRAN 版本中就是这种情况),没有理由 A、B 和 C 不能都是同一个函数。

另一方面,我们必须查看实际代码来确定在您的特定情况下使用递归是否真的是正确的做法。细节不多,但听起来确实不对。

【讨论】:

【参考方案5】:

对于您提出的具体问题,有很多观点可供参考,但我可以说的是,从学习语言的角度来看,递归本身并不是一个功能。如果你的教授真的因为使用他还没有教过的“功能”给你扣分,那是错误的,但就像我说的,这里还有其他观点需要考虑,这实际上使教授在扣分时是正确的。

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

您在问题中提到的这两个示例在它们的作用方面没有区别(但在其他方面确实不同) - 在这两种情况下,返回地址和所有方法信息都被加载到堆栈中。在递归情况下,返回地址只是方法调用之后的那一行(当然,它并不完全是您在代码本身中看到的,而是在编译器创建的代码中看到的)。在 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:递归什么时候是不好的做法?也许 JVM 会爆炸,因为 Hotspot VM 由于字节码安全性而无法优化,这将是一个很好的论据。除了使用不同的方法之外,您的代码有何不同? 递归并不总是一种不好的做法,但如果可以避免它并保持代码干净且易于维护,那么应该避免它。在 Java、C 和 Python 中,与迭代(通常)相比,递归相当昂贵,因为它需要分配新的堆栈帧。在某些 C 编译器中,可以使用编译器标志来消除这种开销,它将某些类型的递归(实际上是某些类型的尾调用)转换为跳转而不是函数调用。 不清楚,但如果你用递归替换了无限次迭代的循环,那就不好了。 Java 不保证尾调用优化,因此您可能很容易耗尽堆栈空间。在 Java 中,不要使用递归,除非保证迭代次数有限(通常与数据的总大小相比为对数)。【参考方案6】:

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

递归的主要优点是递归算法通常更容易和更快地编写。

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

对于生产代码,在生产代码中,与程序员的单元测试相比,缩放会导致更多级别的递归,因此缺点通常大于优点,并且在实际情况下通常会避免使用递归代码。

【讨论】:

任何有潜在风险的递归算法总是可以被简单地重写为使用显式堆栈——毕竟,调用堆栈只是一个堆栈。在这种情况下,如果你重写了使用堆栈的解决方案,它看起来会很荒谬 - 进一步证明递归答案不是一个很好的答案。 如果堆栈溢出是一个问题,您应该使用支持尾调用优化的语言/运行时,例如 .NET 4.0 或任何函数式编程语言 并非所有递归都是尾调用。【参考方案7】:

关于具体问题,递归是一个特征,我倾向于说是的,但是在重新解释了这个问题之后。存在使递归成为可能的语言和编译器的常见设计选择,并且确实存在图灵完备的语言that don't allow recursion at all。换句话说,递归是一种通过语言/编译器设计中的某些选择启用的能力。

支持first-class functions 可以在非常小的假设下实现递归;参见writing loops in 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]

使用late binding 或定义forward declarations 的语言/编译器使递归成为可能。例如,虽然 Python 允许以下代码,但这是一种设计选择(后期绑定),而不是 Turing-complete 系统的要求。相互递归函数通常依赖于对前向声明的支持。

factorial = lambda n: 1 if n < 2 else n * factorial(n-1)

Statically typed languages 允许 recursively defined types 有助于启用递归。请参阅此implementation of the Y Combinator in Go。如果没有递归定义的类型,仍然可以在 Go 中使用递归,但我相信 Y 组合器是不可能的。

【讨论】:

这让我头疼,尤其是 Unlambda +1 定点组合器很难。当我决定学习函数式编程时,我强迫自己研究 Y 组合器,直到我理解它,然后将它应用到编写其他有用的函数。花了我一段时间,但非常值得。【参考方案8】:

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

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

有一个非递归循环来做到这一点是要走的路。

【讨论】:

所讨论的方法的大部分,以及方法本身的目的,是通过各种检查来验证输入。如果输入无效,则该过程重新开始,直到输入正确(按照指示)。 @fay 但是如果输入无效的次数太多,你会得到一个 ***Error。递归更优雅,但在我看来,通常比常规循环更成问题(由于堆栈)。 那么,这是一个有趣且有益的观点。我没有考虑过这个错误。但是,通过while(true)调用相同的方法可以达到相同的效果吗?如果是这样,我不会说这支持递归之间的任何区别,很高兴知道它是。 @fay while(true) 是一个无限循环。除非你有 break 声明,否则我看不出它的意义,除非你试图让你的程序崩溃哈哈。我的观点是,如果你调用相同的方法(即递归),它有时会给你一个***Error,但如果你使用whilefor 循环,它不会。常规循环根本不存在该问题。也许我误解了你,但我对你的回答是否定的。 老实说,这对我来说可能是教授扣分的真正原因 =) 他可能没有很好地解释它,但说你使用它的方式是有效的抱怨如果在更严重的代码中没有完全缺陷,将被认为是非常糟糕的风格。【参考方案9】:

Recursion 是一个编程概念、一个特性(如迭代)和一个实践。正如您从链接中看到的那样,有大量研究领域致力于该主题。也许我们不需要深入研究主题来理解这些要点。

作为特征的递归

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

没有递归?

对于甚至不支持递归可能性的语言,通常有另一种解决方案,以Fixed-point combinator 的形式,但它仍然需要语言支持所谓的第一类对象(即对象可以在语言本身内进行操作)。

递归作为一种实践

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

语法仍然不是很友好的递归 编译器可能无法检测到这种做法和optimize it

底线

幸运的是(或者更准确地说,为了便于使用),Java 确实让方法在默认情况下意识到自己,因此支持递归,所以这不是一个真正的实际问题,但它仍然是一个理论上的问题,并且我想你的老师想具体解决这个问题。此外,鉴于该语言最近的发展,它可能会在未来变成重要的东西。

【讨论】:

以上是关于递归本身是一个特征吗?的主要内容,如果未能解决你的问题,请参考以下文章

栈1:栈的特征和常用操作

我应该避免在 iPhone 上递归吗?

python笔记七(递归函数)

你真的懂递归吗?

你真的懂递归吗?

UE4定义递归函数