“仅返回”的概念从何而来?


1055

我经常跟程序员谁说:“ 不要把多个return语句相同的方法。 ”当我问他们告诉我的原因,我得到的是“ 编码标准是这么说的。 ”或者“ 这是令人困惑的。 ”当他们通过单个return语句向我展示解决方案时,代码对我来说看起来更难看。例如:

if (condition)
   return 42;
else
   return 97;

这很丑,您必须使用局部变量!

int result;
if (condition)
   result = 42;
else
   result = 97;
return result;

50%的代码膨胀如何使程序更易于理解?我个人觉得比较困难,因为状态空间刚刚增加了另一个很容易避免的变量。

当然,通常我会写:

return (condition) ? 42 : 97;

但是许多程序员避免使用条件运算符,而是喜欢长格式。

“仅返回”的概念从何而来?产生这种约定是否有历史原因?


2
这在某种程度上与Guard Clause重构有关。stackoverflow.com/a/8493256/679340保护子句会将返回值添加到方法的开头。在我看来,它使代码更整洁。
2013年

3
它来自结构化编程的概念。有人可能会争辩说,只有一个返回值可以使您轻松地修改代码以在返回之前轻松地执行某些操作或轻松地进行调试。
martinkunev '16

3
我认为这个例子很简单,我不会以一种或另一种方式产生强烈的意见。单入单出的理想状态更多地指导我们摆脱疯狂的局面,例如15条返回语句和另外两个根本不返回的分支!
mendota

2
那是我读过的最糟糕的文章之一。似乎作者花了更多时间幻想自己的OOP的纯度,而不是实际弄清楚如何实现任何目标。表达式树和评估树具有价值,但仅当您可以编写普通函数时才具有价值。
DeadMG

3
您应该完全删除条件。答案是42
cambunctious

Answers:


1119

当大多数编程以汇编语言,FORTRAN或COBOL完成时,将编写“单入口,单出口”。它被广泛误解了,因为现代语言不支持Dijkstra所警告的做法。

“单一入口”表示“不为功能创建替代入口点”。当然,使用汇编语言,可以在任何指令中输入功能。FORTRAN通过以下ENTRY语句支持功能的多个条目:

      SUBROUTINE S(X, Y)
      R = SQRT(X*X + Y*Y)
C ALTERNATE ENTRY USED WHEN R IS ALREADY KNOWN
      ENTRY S2(R)
      ...
      RETURN
      END

C USAGE
      CALL S(3,4)
C ALTERNATE USAGE
      CALL S2(5)

“单退出”意味着一个函数只能返回一处:该语句立即呼叫以下。这并不意味着一个函数只能一个地方返回。当结构化编程是书面的,它是常见的做法如下功能通过返回到备用位置指示错误。FORTRAN通过“备用回报”支持此操作:

C SUBROUTINE WITH ALTERNATE RETURN.  THE '*' IS A PLACE HOLDER FOR THE ERROR RETURN
      SUBROUTINE QSOLVE(A, B, C, X1, X2, *)
      DISCR = B*B - 4*A*C
C NO SOLUTIONS, RETURN TO ERROR HANDLING LOCATION
      IF DISCR .LT. 0 RETURN 1
      SD = SQRT(DISCR)
      DENOM = 2*A
      X1 = (-B + SD) / DENOM
      X2 = (-B - SD) / DENOM
      RETURN
      END

C USE OF ALTERNATE RETURN
      CALL QSOLVE(1, 0, 1, X1, X2, *99)
C SOLUTION FOUND
      ...
C QSOLVE RETURNS HERE IF NO SOLUTIONS
99    PRINT 'NO SOLUTIONS'

这两种技术都很容易出错。使用备用条目通常会使一些变量未初始化。使用替代返回有一个GOTO语句的所有问题,另外一个复杂的问题是分支条件不是与分支相邻,而是在子例程中的某个位置。


38
并且不要忘记意大利面条代码。子例程使用GOTO(而不是返回)退出,将函数调用参数和返回地址保留在堆栈中并不鲜见。单出口被提升为至少将所有代码路径集中到RETURN语句的一种方式。
TMN

2
@TMN:早期,大多数机器没有硬件堆栈。通常不支持递归。子例程参数和返回地址存储在与子例程代码相邻的固定位置。Return只是间接的goto。
凯文·克莱恩

5
@kevin:是的,但是根据您的说法,这甚至不再意味着它的初衷。(顺便说一句,实际上我可以确定Fred询问的是来自“单出口” 的当前解释的偏好。)而且,const自从C 在此诞生许多用户之前就拥有了,因此不再需要资本常数。即使在C 语言中也是如此。但是Java保留了所有那些不良的C语言旧习惯
2011年

3
那么异常是否违反了对“单出口”的解释?(或者他们更原始的堂兄setjmp/longjmp?)
梅森·惠勒

2
即使操作员询问了有关单收益的当前解释,但该答案还是具有最悠久历史根源的答案。有一个在使用单一的回报为没有意义的规则,除非你想让你的语言以匹配VB(不是.NET)迷死。只需记住也要使用非短路布尔逻辑。
2012年

912

这种观念单入口,单出口(SESE)来自具有明确资源管理语言,如C语言和汇编。在C中,这样的代码将泄漏资源:

void f()
{
  resource res = acquire_resource();  // think malloc()
  if( f1(res) )
    return; // leaks res
  f2(res);
  release_resource(res);  // think free()
}

在这种语言中,您基本上有三个选择:

  • 复制清除代码。
    啊。冗余总是不好的。

  • 使用a goto跳至清除代码。
    这要求清除代码是函数中的最后一件事。(这就是为什么有人认为它goto有其地位的原因。并且确实存在于-C中。)

  • 引入局部变量并通过该变量操纵控制流。
    缺点是通过语法操纵该控制流程(想到breakreturnifwhile)是更容易跟踪不是通过变量的状态操作控制流程(因为这些变量都没有的状态,当你在算法)。

在汇编中,这甚至很奇怪,因为调用该函数时您可以跳转到函数中的任何地址,这实际上意味着您几乎可以无限地访问任何函数。(有时这很有用。此类重击是编译器实现C ++多继承场景中this调用virtual函数所需的指针调整的常用技术。)

当您必须手动管理资源时,利用在任何地方进入或退出函数的选项都会导致更复杂的代码,从而导致错误。因此,出现了一种传播SESE的思想流派,以便获得更简洁的代码和更少的错误。


但是,当一种语言具有异常功能时,(几乎)任何功能都可能在(几乎)任何点过早退出,因此无论如何都需要为过早返回做好准备。(我认为finally主要用于Java和using(在实现时IDisposablefinally否则)在C#中;在C ++中使用RAII。)完成此操作后,您不会因为提早return声明而对自己进行清理,所以可能是支持SESE的最有力论据消失了。

留下可读性。当然,带有六条return语句的200 LoC函数随机散布在该函数上并不是很好的编程风格,也不是可读代码。但是,如果没有这些过早的返回,这样的功能也不容易理解。

在没有或不应手动管理资源的语言中,遵守旧的SESE约定几乎没有价值。就像我上面所说的OTOH,SESE通常会使代码更加复杂。这是一种恐龙(除C之外)不能很好地适应当今的大多数语言。它没有帮助提高代码的可理解性,反而阻碍了它。


Java程序员为什么要坚持这一点?我不知道,但是从我的POV(外部)来看,Java从C(它们有意义的地方)采纳了许多约定,并将它们应用于其OO世界(在这里它们是无用的或完全坏的),现在它遵循他们,无论付出什么代价。(按照惯例,在作用域的开头定义所有变量。)

出于非理性的原因,程序员会坚持使用各种奇怪的符号。(深层嵌套的结构化语句(“箭头”)在Pascal之类的语言中曾经被视为漂亮的代码。)对此应用纯逻辑推理似乎无法说服大多数人偏离既定方式。改变这种习惯的最好方法可能是早日教他们做最好的事情,而不是常规的事情。作为编程老师,您可以掌握它。:)


52
对。在Java中,清除代码属于finally执行子句的子句,而不管早​​期returns或异常如何。
2011年

15
在Java 7中,@ dan04甚至finally大部分时间都不需要。
R. Martinho Fernandes

93
@Steven:当然可以证明!实际上,您可以显示具有任何功能的卷积和复杂代码,这些功能也可以使代码更简单易懂。一切都可能被滥用。关键是编写代码以便于理解,并且在这种情况下会把SESE扔到窗外,这确实如此,并且该死于适用于不同语言的旧习惯。但是,如果我认为它使代码更易于阅读,我也将毫不犹豫地控制变量的执行。只是我不记得在近二十年内见过这样的代码。
2011年

21
@Karl:的确,这是Java之类的GC语言的严重缺陷,它们使您不必清理一种资源,而使所有其他资源都无法使用。(C ++使用解决了这个问题的所有资源RAII)。但是我没有说话,甚至只有内存(我只把malloc()free()成作为一个例子的评论),我说的是一般的资源。我也不是说GC可以解决这些问题。(我确实提到过C ++,它没有开箱即用的GC。)据我了解,Java finally是用来解决此问题的。
2011年

10
@sbi:对于功能(过程,方法等)而言,比起不超过一页长更重要的是该功能具有明确定义的契约;如果由于为了满足任意长度限制而将其切碎而没有做清楚的事情,那就不好了。编程是要互相作用,有时是相互冲突。
Donal Fellows,

81

一方面,单个return语句使日志记录以及依赖日志记录的调试形式更加容易。我记得很多次我不得不将函数简化为单返回值,只是为了单点打印出返回值。

  int function() {
     if (bidi) { print("return 1"); return 1; }
     for (int i = 0; i < n; i++) {
       if (vidi) { print("return 2"); return 2;}
     }
     print("return 3");
     return 3;
  }

另一方面,您可以将其重构为function()调用_function()并记录结果。


31
我还要补充一点,它使调试更加容易,因为您只需要设置一个断点即可捕获该函数的所有出口*。我相信有些IDE可以让您在函数的右括号处放置一个断点来执行相同的操作。(*除非你调用exit)
Skizz

3
出于类似的原因,这也使扩展(添加)功能变得更加容易,因为不必在每次返回之前插入新功能。举例来说,假设您需要使用函数调用的结果来更新日志。
JeffSahol 2011年

63
老实说,如果我要维护该代码,则宁愿使用一个明智定义的_function()return在适当的位置使用s,并使用一个名为wrapper的包装function()来处理无关的日志记录,而不是使用一个function()逻辑扭曲的包装使所有返回值适合单个出口-point,这样我就可以在该点之前插入其他语句。
ruakh

11
在某些调试器(MSVS)中,您可以在最后一个闭合括号上放置断点
Abyx

6
打印!=调试。那根本不是争论。
2013年

53

“单项进入,单项退出”起源于1970年代初期的结构化编程革命,该革命由Edsger W. Dijkstra致编辑的信“ GOTO声明被认为有害 ”开始。在Ole Johan-Dahl,Essger W. Dijkstra和Charles Anthony Richard Hoare所著的经典著作“ Structured Programming”中详细介绍了结构化编程背后的概念。

即使在今天,也必须阅读“被认为有害的GOTO声明”。“结构化程序设计”已经过时,但仍然非常非常有益,应该在任何开发人员的“必读”列表中排在首位,远远超过史蒂夫·麦康奈尔。(Dahl的部分介绍了Simula 67中的类基础知识,它们是C ++和所有面向对象编程中类的技术基础。)


6
本文是在C语言被广泛使用的C语言之前的几天编写的。他们不是敌人,但是这个答案肯定是正确的。不在函数末尾的return语句实际上是goto。
user606723 2011年

31
这篇文章还写在goto可以从字面上去到任何地方的时代,比如直接进入另一个函数中的某个随机点,而绕开了过程,函数,调用堆栈等任何概念。如今,没有理智的语言允许直截了当goto。C'S setjmp/ longjmp是唯一的半例外情况下我所知道的,甚至需要从两端合作。(不过,半讽刺的是,考虑到异常的作用几乎相同,所以我在那儿使用了“例外”一词。)基本上,本文不鼓励使用已久的做法。
cHao 2011年

5
从“ Goto语句被认为有害”的最后一段中可以看出:“在[2]中,Guiseppe Jacopini似乎已经证明了go语句的(逻辑)多余之处。该练习或多或少地将任意流程图机械地转换为一个跳转,但是,不建议使用更少的流程图。那么,不能期望所得的流程图比原始流程图更加透明。
hugomg 2011年

10
这与问题有什么关系?是的,Dijkstra的工作最终导致了SESE语言的发展,那又如何呢?巴贝奇的工作也是如此。并且,如果您认为该文件说明了某个函数中具有多个退出点的任何内容,那么也许应该重新阅读该文件。因为没有。
jalf

10
@John,您似乎在尝试回答问题而不实际回答。这是一个不错的阅读清单,但是您没有引用也没有用任何措辞来证明您的观点,即这篇文章和书中有什么可以谈论问询者的问题。确实,除了评论之外,您没有对这个问题发表任何实质性的意见。考虑扩大这个答案。
Shog9

35

链接Fowler总是很容易的。

违反SESE的主要示例之一是保护子句:

用警卫条款代替嵌套条件

在所有特殊情况下使用警卫条款

double getPayAmount() {
    double result;
    if (_isDead) result = deadAmount();
    else {
        if (_isSeparated) result = separatedAmount();
        else {
            if (_isRetired) result = retiredAmount();
            else result = normalPayAmount();
        };
    }
return result;
};  

                                                                                                         http://www.refactoring.com/catalog/arrow.gif

double getPayAmount() {
    if (_isDead) return deadAmount();
    if (_isSeparated) return separatedAmount();
    if (_isRetired) return retiredAmount();
    return normalPayAmount();
};  

有关更多信息,请参见“ 重构 ...的第250页” 。


11
另一个不好的例子:使用else-ifs可以很容易地修复它。
2015年

1
您的示例不公平,如何处理:double getPayAmount(){double ret = normalPayAmount(); 如果(_isDead)ret = deadAmount(); 如果(_isSeparated)ret = splitAmount(); 如果(_isRetired)ret = retiredAmount(); 返回ret };
Charbel

6
@Charbel这不是同一回事。如果_isSeparated_isRetired都可以成立(为什么不可行?),则返回错误的金额。
hvd

2
@Konchog“ 嵌套条件将比保护子句提供更好的执行时间 ”这主要需要引用。我怀疑这确实是真的。例如,在这种情况下,就生成的代码而言,早期返回与逻辑短路有何不同?即使很重要,我也无法想象这种差异将不只是无限小条。因此,您要通过降低代码的可读性来进行过早的优化,只是为了满足有关您认为会导致代码稍快一些的未经验证的理论观点。我们在这里不这样做
underscore_d

1
@underscore_d,您是对的。它很大程度上取决于编译器,但会占用更多空间。查看两个伪程序集,很容易看出为什么保护子句来自高级语言。“ A”测试(1);branch_fail结束;测试(2); branch_fail结束;测试(3); branch_fail结束;{CODE}结尾:返回;“ B”检验(1);branch_good next1; 返回; next1:test(2); branch_good next2; 返回; next2:test(3); branch_good next3; 返回; next3:{CODE}返回;
Konchog

11

不久前,我就此主题写了一篇博客文章。

最重要的是,该规则来自没有垃圾收集或异常处理的语言的时代。没有正式的研究表明该规则可以导致现代语言中更好的代码。只要这会导致代码更短或更易读,就可以忽略它。坚持这一点的Java专家遵循过时的,毫无意义的规则而盲目且毫无疑问。

在Stackoverflow上也曾问过这个问题


嗨,我再也无法访问该链接了。您是否碰巧仍然可以访问某个地方托管的版本?
Nic Hartley'17

嗨,QPT,好地方。我把博客文章带回来并更新了上面的URL。现在应该链接!
安东尼

不仅如此,它还有更多。使用SESE管理精确的执行时间要容易得多。无论如何,嵌套条件条件通常都可以通过开关进行重构。这不仅仅是关于是否有返回值。
Konchog

如果您要声明没有正式的研究来支持它,那么您应该链接到与之相反的研究。
Mehrdad

Mehrdad,如果有正式研究支持它,请显示出来。就这样。坚持反对证据正在转移举证责任。
安东尼

7

一回就可以简化重构。尝试对包含返回,中断或继续的for循环的内部执行“提取方法”。由于您破坏了控制流程,这将失败。

关键是:我想没人会假装编写完美的代码。因此,代码在重构时通常会被“改进”和扩展。因此,我的目标是保持代码尽可能友好的重构。

我经常遇到这样的问题:如果函数包含控制流中断器,并且我只想添加很少的功能,则必须完全重新构造函数。当您更改整个控制流,而不是将新的路径引入隔离的嵌套时,这很容易出错。如果最后只有一个返回,或者如果使用保护退出循环,那么您当然会有更多的嵌套和更多的代码。但是您可以获得编译器和IDE支持的重构功能。


变量也一样。这是使用控制流结构(如早期返回)的替代方法。
Deduplicator

变量通常不会妨碍您以保留现有控制流的方式将代码分成几部分。尝试“提取方法”。由于IDE无法从您编写的内容中派生语义,因此它们仅能够执行控制流预派生重构。
oopexpert

5

考虑以下事实:多个return语句等效于将GOTO包含到单个return语句中。这与break语句相同。因此,像我一样,有些人出于所有意图和目的都将它们视为GOTO。

但是,我认为这些类型的GOTO并不有害,如果我有充分的理由,我会毫不犹豫地在代码中使用实际的GOTO。

我的一般规则是GOTO仅用于流控制。绝对不要将它们用于任何循环,也永远不要转到“向上”或“向后”。(这是中断/返回的工作方式)

正如其他人提到的那样,以下是必须阅读的 认为有害的GOTO声明。
但是,请记住,这是在1970年编写的,当时GOTO的使用方式被过度使用。并非每个GOTO都是有害的,只要您不使用它们而不是常规构造,我就不会劝阻它们的使用,但是在奇怪的情况下,使用常规构造会非常不便。

我发现在由于错误而需要逃逸区域的错误情况下使用它们,在正常情况下有时永远都不会发生,这很有用。但是,您还应该考虑将此代码放入单独的函数中,以便您可以提早返回而不是使用GOTO ...,但是有时这也很不方便。


6
替换goto的所有结构化构造都是根据goto实现的。例如,循环“ if”和“ case”。这不会使它们变坏-实际上相反。同样,它是“意图和目的”。
安东尼

Touche,但这与我的观点没有不同……这只是使我的解释有些错误。那好吧。
2011年

只要(1)目标位于相同的方法或函数中,并且(2)代码中的方向是向前的(跳过某些代码),并且(3)目标不在其他嵌套结构中(例如,GOTO),GOTO应该始终可以从if-case的中间转到else-case的中间)。如果遵循这些规则,则所有滥用GOTO的行为在视觉和逻辑上都会产生强烈的气味。
Mikko Rantalainen

3

圈复杂度

我已经看到SonarCube使用多个return语句来确定圈复杂度。因此,返回语句越多,圈复杂度就越高

退货类型变更

多次返回意味着当我们决定改变返回类型时,我们需要在函数中的多个位置进行改变。

多次退出

调试起来比较困难,因为需要结合条件语句仔细研究逻辑,以了解导致返回值的原因。

重构解决方案

多个return语句的解决方案是在解决所需的实现对象后,将其替换为具有单条返回值的多态。


3
从多个返回到在多个位置设置返回值并不能消除圈复杂度,而只是统一了退出位置。在给定的上下文中,圈复杂性可以表明的所有问题仍然存在。“很难调试,因为需要结合条件语句仔细研究逻辑,以了解导致返回值的原因。”同样,通过统一返回值,逻辑不会改变。如果您必须仔细研究代码以了解其工作原理,则需要对其进行重构,这是一个完整的过程。
WillD
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.