递归是一个特征吗?

时间:2014-05-10 18:18:22

标签: recursion

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

我问这个是因为与我的教授发生争执:我因为我们没有在课堂上报道递归而递归调用一个函数而失去了信誉,我的论点是我们通过学习{{1和方法。

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

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

return

除了“public static void a() { return a(); } public static void b() { return a(); } 永远持续”(在实际程序中,它被正确用于在提供无效输入时再次提示用户),a和{{1之间是否存在任何根本区别}}?对于未经优化的编译器,它们的处理方式有何不同?

最终,归结为是否从ab学习,我们也从return a()学到了b。我们呢?

9 个答案:

答案 0 :(得分:111)

回答您的具体问题:不,从学习语言的角度来看,递归并不是一个特征。如果您的教授确实停靠了您使用"功能"他还没教过,那是错的。

在线之间阅读,一种可能性是通过使用递归,你避免使用一个本来应该是他的课程学习成果的功能。例如,您可能根本没有使用迭代,或者您可能只使用for个循环而不是同时使用forwhile。通常,作业旨在测试你做某些事情的能力,如果你避免这样做,你的教授根本无法授予你为该特征留出的标记。然而,如果这真的是你失去分数的原因,那么教授应该把这作为他或她自己的学习经历 - 如果证明某些学习成果是作业的标准之一,那应该向学生清楚地解释

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

Stack Overflows

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

但是,要记住的一件事是,不仅方法本身,而且调用链中更高或更低的其他方法将在堆栈上。因此,随意吞噬可用的堆栈空间对于任何方法来说都是非常不礼貌的事情。没有人希望在编写代码时不得不经常担心空闲堆栈空间,因为其他代码可能不必要地使用了很多代码。

这是称为抽象的软件设计中更一般原则的一部分。基本上,当你打电话给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();
}

是的,这些都有效,是的,它们都很容易理解。但这两种方法怎么用英语描述呢?我认为它类似于:

  

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

  

我会提示输入,如果输入有效,我会返回它,否则我得到输入并返回结果

也许你可以想到后者的措辞略显笨拙,但我认为你总会发现第一个是概念性地描述你实际上要做的事情。这并不是说递归总是不太可读。对于像树遍历那样闪耀的情况,你可以在递归和另一种方法之间进行相同类型的并排分析,并且你几乎肯定会发现递归会给出代码,这些代码更直接地逐行自我描述。

孤立地说,这两点都是小点。它不太可能真正导致堆栈溢出,并且可读性的提高很小。但是,任何计划都将成为许多这些小决策的集合,所以即使孤立起来并不重要,学习正确的原则背后也很重要。

答案 1 :(得分: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年代早期),不支持递归,问题比这更微妙:返回地址将存储在堆栈中,就像在现代编译器中一样,但是局部变量不是。 (从理论上讲,这应该意味着对于没有非静态局部变量的函数可以进行递归,但我不记得编译器是否明确支持它。由于某种原因,它可能需要隐式局部变量。)

展望未来,我可以想象一些特殊的场景 - 可能是大量并行系统 - 不必为每个线程提供堆栈可能是有利的,因此只有在编译器可以将其重构为循环时才允许递归。 (当然,我上面讨论的原始编译器不能完成复杂的任务,比如重构代码。)

答案 2 :(得分:17)

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

答案 3 :(得分:13)

扣除积分因为&#34;我们没有报道课堂上的递归&#34;太可怕了。如果你学会了如何调用函数A,它调用函数B调用函数C,函数C返回给B,返回给A返回给调用者,老师没有明确告诉你这些函数必须是不同的函数(例如,在旧的FORTRAN版本中就是这种情况),没有理由A,B和C不能都是相同的功能。

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

答案 4 :(得分:9)

关于您提出的具体问题,有很多观点可以看,但我可以说的是,从学习语言的角度来看,递归本身并不是一个特征。如果你的教授确实停靠了你使用他尚未教授的“特征”的标记,那就错了,但就像我说的那样,这里还有其他观点要考虑,这实际上使教授在扣除积分时是正确的。

从我的问题中可以推断出,在输入失败的情况下使用递归函数请求输入不是一个好习惯,因为每个递归函数的调用都会被推送到堆栈。由于这种递归是由用户输入驱动的,因此可以使用无限递归函数,从而产生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);
}

如果你所做的确实能以这种方式解决,那么你所做的是一种不好的做法,应该避免。

答案 5 :(得分:6)

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

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

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

对于生产代码,在生产代码中,缩放可以导致生产中的递归级别比程序员的单元测试更多,但缺点通常超过优势,并且在实际应用中通常会避免使用递归代码。

答案 6 :(得分:6)

关于具体问题,递归是一个特征,我倾向于说是,但在重新解释问题之后。语言和编译器有一些共同的设计选择可以使递归成为可能,并且图灵完备语言确实存在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组合器是不可能的。

答案 7 :(得分:5)

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

因为每个递归函数调用都会被推送到堆栈。由于这种递归是由用户输入驱动的,因此可以具有无限递归函数,从而产生StackOverflow: - p

有一个非递归循环来实现这一目标。

答案 8 :(得分:3)

Recursion是一个编程概念,一个功能(如迭代)和一个实践。正如您从链接中看到的那样,有一个专门针对该主题的大型研究领域。也许我们不需要在主题中深入了解这些要点。

作为特征的递归

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

没有递归?

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

作为练习的递归

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

  • 语法仍然不是很友好的递归
  • 编制者可能无法检测到该做法并optimize it

底线

幸运的是(或者更准确地说,为了易于使用),Java确实让方法默认了解自己,从而支持递归,所以这不是一个实际问题,但它仍然是一个理论问题,我想你的老师想要专门解决它。此外,鉴于最近语言的演变,它可能会在未来变成重要的东西。