「ワンリターンのみ」という概念はどこから来たのですか?


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 Guard Clauseは、メソッドの先頭にリターンを追加します。そして、それは私の意見ではコードをずっときれいにします。
ピョートルペラ

3
それは、構造化プログラミングの概念から来ました。返り値が1つしかない場合、返される直前に何かを行うようにコードを簡単に変更したり、簡単にデバッグしたりできると主張する人もいます。
マーティンクネフ

3
この例は、どちらかといえば強い意見を持っていないと思われる単純なケースだと思います。single-entry-single-exitの理想は、15のreturnステートメントやまったく戻らない2つのブランチのような狂気の状況から私たちを遠ざけるためです。
メンドータ

2
それは私が今まで読んだ最悪の記事の一つです。著者は、実際に何かを達成する方法を考え出すよりも、OOPの純度について空想することに多くの時間を費やしているようです。式と評価ツリーには価値がありますが、代わりに通常の関数を書くことができる場合は価値がありません。
DeadMG

3
条件を完全に削除する必要があります。答えは42です
cambunctious

回答:


1119

「単一エントリ、単一出口」は、ほとんどのプログラミングがアセンブリ言語、FORTRAN、またはCOBOLで行われたときに書かれました。現代の言語はダイクストラが警告していた慣行をサポートしていないため、広く誤解されています。

「単一エントリ」とは、「関数の代替エントリポイントを作成しない」ことを意味します。もちろん、アセンブリ言語では、どの命令でも関数を入力できます。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)

「シングル出口は、」機能のみを返すべきであることを意味する文の直後に呼び出し、以下:1位。関数が1つの場所からのみ戻る必要があるという意味ではありません構造化プログラミングが書かれたとき、関数が別の場所に戻ることによってエラーを示すことは一般的な習慣でした。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は単なる間接的な後藤でした。
ケビンクライン

5
@kevin:ええ、しかしあなたによると、これはそれが発明されたものでさえも意味しません。(ところで、フレッドは「シングルエグジット」の現在の解釈の好みであると実際に確信しています。)また、Cはconstここにいるユーザーの多くが生まれる前から持っていたので、資本定数はもう必要ありません。 Cでも。Javaはこれらの悪い古いCの習慣を​​すべて保持していました
SBI

3
したがって、例外はこの単一出口の解釈に違反しますか?(または、より原始的ないとこsetjmp/longjmp?)
メイソン・ウィーラー

2
opはシングルリターンの現在の解釈について尋ねましたが、この答えは最も歴史的なルーツを持つものです。言語をVB(.NETではない)のすばらしさと一致させたい場合を除き、単一の戻り値を規則として使用しても意味がありません。同様に、非短絡ブール論理も使用することを忘れないでください。
-acelent

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()
}

このような言語では、基本的に3つのオプションがあります。

  • クリーンアップコードを複製します。
    あー 冗長性は常に悪いです。

  • を使用し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初期returnのsまたは例外に関係なく実行される句に属します。
dan04

15
Java 7の@ dan04では、finallyほとんどの時間は必要ありません。
R.マルティーニョフェルナンデス

93
@Steven:もちろん、それを示すことができます!実際、複雑で複雑なコードは、コードをより簡単に理解しやすくするために表示できる機能を使用して表示できます。すべてが悪用される可能性があります。重要なのは、理解しやすいようにコードを記述し、それが SESEをウィンドウから外すことを伴う場合、そうすることで、異なる言語に適用される古い習慣を気にすることです。しかし、コードを読みやすくしたと思うなら、変数による実行を制御することをheしません。そのようなコードをほぼ20年で見たことを覚えていないだけです。
sbi

21
@Karl:確かに、JavaのようなGC言語の深刻な欠点は、1つのリソースをクリーンアップする必要があることを軽減しますが、他のすべてのリソースで失敗することです。(C ++を使用して、すべてのリソースのために、この問題を解決RAIIを(私だけ置く。)しかし、私も専用メモリで話していなかったmalloc()free()の例のようにコメントに)、私は一般的にリソース話していました。また、GCがこれらの問題を解決することを暗示していませんでした。(GCがすぐにfinally使えるC ++については言及しました。)私が理解していることから、Javaではこの問題を解決するために使用されています。
sbi

10
@sbi:関数(プロシージャ、メソッドなど)にとって、ページの長さよりも重要なのは、関数が明確に定義されたコントラクトを持つことです。任意の長さの制約を満たすために切り取られたために明確なことをしていない場合、それは悪いことです。プログラミングとは、異なる、時には対立する勢力を互いに戦わせることです。
ドナルドフェローズ

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
また、関数からのすべてのexit *をキャッチするために1つのブレークポイントのみを設定する必要があるため、デバッグが容易になることも付け加えます。一部のIDEでは、関数の閉じ括弧にブレークポイントを設定して同じことを実行できると信じています。(* exitを呼び出さない限り)
Skizz

3
同様の理由で、新しい関数を返すたびに挿入する必要がないため、関数の拡張(追加)も簡単になります。たとえば、関数呼び出しの結果でログを更新する必要があるとします。
ジェフサホール

63
私はそのコードを維持した場合は正直なところ、私はむしろ賢明定義の必要があるだろう_function()と、return適切な場所での、およびという名前のラッパーfunction()よりも、余分なログを処理する単一の持っているfunction()すべてのリターンを作るためにゆがんだロジックを備えた単一の出口に適合します-pointだけで、そのポイントの前に追加のステートメントを挿入できます。
ruakh

11
いくつかのデバッガ(MSVS)では、あなたは、最後の閉じかっこにブレークポイントを置くことができます
Abyx

6
印刷!=デバッグ。それはまったく議論ではありません。
ピョートルペラ

53

「シングルエントリ、シングル出口」は、1970年代初頭の構造化プログラミング革命に端を発したもので、編集者へのEdsger W. Dijkstraからの手紙「GOTO Statement考慮された有害」によって開始されました。構造化プログラミングの背後にある概念は、Ole Johan-Dahl、Edsger W. Dijkstra、Charles Anthony Richard Hoareによる古典的な本「Structured Programming」に詳しく記載されています。

「有害と考えられるGOTOステートメント」は、今日でも読む必要があります。「構造化プログラミング」は古くなっていますが、それでも非常にやりがいがあり、開発者の「必読」リストの最上位に位置している必要があります。たとえば、スティーブマッコネルなどのリストをはるかに上回ります。(Dahlのセクションでは、C ++およびすべてのオブジェクト指向プログラミングのクラスの技術的基盤であるSimula 67のクラスの基本を説明します。)


6
この記事は、GOTOが頻繁に使用されたCの数日前に書かれました。彼らは敵ではありませんが、この答えは間違いなく正しいです。関数の最後にないreturnステートメントは、実質的にgotoです。
user606723

31
また、この記事は、プロシージャ、関数、コールスタックなどの概念をバイパスして、別の関数のランダムポイントに直接移動するなど、goto文字通りどこにでも行くことができる時代に書かれましたgoto。Cのsetjmp/ longjmpは、私が知っている唯一の準例外的なケースであり、それでも両端からの協力が必要です。(ただし、例外はほぼ同じことを行うことを考慮して、「例外的」という言葉を使用した半イロニック...)基本的に、この記事は長い間使われていなかった実践を思いとどまらせます。
CHAO

5
「有害と考えられる後藤の声明」の最後の段落から:「[2]で、Guiseppe Jacopiniはgo to声明の(論理的な)不必要性を証明したようです。任意のフロー図を多少機械的にジャンプに変換する練習1つ少ない、しかし、推奨されるべきではない。そして、得られた流れ図は、元のものよりもより透明であると期待することはできない。
hugomg

10
これは質問と何の関係がありますか?はい、ダイクストラの仕事は最終的にSESE言語につながりました。バベッジの仕事もそうだった。また、関数内に複数の出口点があることについて何かを述べていると思われる場合は、おそらくこの論文を読み直してください。そうではないからです。
jalf

10
@ジョン、あなたは実際に答えずに質問に答えようとしているようです。それは素晴らしい読書リストですが、あなたはこのエッセイと本が質問者の懸念について言うべきものを持っているというあなたの主張を正当化するために何も引用も言い換えもしていません。確かに、コメント以外では、質問について実質的なことは何も言わなかった。この答えを拡大することを検討してください。
Shog9

35

Fowlerをリンクすることは常に簡単です。

SESEに反する主な例の1つは、ガード条項です。

ネストされた条件をガード句に置き換える

すべての特別な場合にガード条項を使用する

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
もう1つの悪い例:else-ifで簡単に修正できます。
ジャック

1
あなたの例は公平ではありません、これはどうですか:double getPayAmount(){double ret = normalPayAmount(); if(_isDead)ret = deadAmount(); if(_isSeparated)ret = separatedAmount(); if(_isRetired)ret = retiredAmount(); return ret; };
チャーベル

6
@Charbelそれは同じものではありません。場合_isSeparated_isRetiredの両方の真のことができます(そしてなぜそれが可能ではないでしょうか?)、あなたは間違った金額を返します。
hvd

2
@Konchog「ネストされた条件は、ガード句よりも実行時間を短縮します」これには主に引用が必要です。私はそれがまったく確実に真実であることに疑問を持っています。この場合、たとえば、生成されたコードの観点から、論理的な短絡とは異なる早期返還はどうですか?たとえそれが重要であったとしても、その違いがわずかなスライバー以上である場合を想像することはできません。そのため、コードを読みにくくすることで時期尚早の最適化を適用し、わずかに高速なコードにつながると思われることについての実証されていない理論的なポイントを満たします。ここではそれを行うません
underscore_d

1
@underscore_d、あなたは正しい。コンパイラに大きく依存しますが、より多くのスペースを必要とします。「A」テスト(1); branch_fail end; test(2); branch_fail end; test(3); branch_fail end; {CODE} end:return; 「B」テスト(1); branch_good next1; 戻り; next1:test(2); branch_good next2; 戻り; next2:test(3); branch_good next3; 戻り; next3:{CODE} return;
コンチョグ

11

しばらく前に、このトピックに関するブログ記事を書きました。

結論として、このルールは、ガベージコレクションや例外処理を持たない言語の時代に由来するということです。この規則が現代言語のより良いコードにつながることを示す正式な研究はありません。これによりコードが短くなったり読みやすくなったりする場合は、無視してください。これを主張しているJavaの人たちは、時代遅れの無意味なルールに従って盲目的に疑問を呈しています。

この質問はStackoverflowでも質問されています


ねえ、私はもうそのリンクに到達できません。どこかにホストされているバージョンがまだアクセス可能ですか?
ニックハートリー

こんにちは、QPT、良いスポットです。ブログの投稿を戻し、上記のURLを更新しました。今すぐリンクする必要があります!
アンソニー

それだけではありません。SESEを使用すると、正確な実行タイミングを管理するのがはるかに簡単になります。入れ子になった条件は、多くの場合、スイッチを使用してリファクタリングできます。戻り値があるかどうかだけではありません。
コンチョグ

あなたがそれを支持する正式な研究がないと主張しようとするなら、あなたはそれに反対するものにリンクすることをあなたに選ぶでしょう。
Mehrdad

Mehrdad、それをサポートする正式な研究があれば、それを見せてください。それで全部です。反対の証拠を主張することは、立証責任をシフトしています。
アンソニー

7

1回戻るとリファクタリングが簡単になります。return、break、またはcontinueを含むforループの内部に対して「抽出メソッド」を実行してみてください。制御フローが壊れているため、これは失敗します。

ポイントは、完璧なコードを書くふりをしている人はいないと思います。そのため、コードはリファクタリング中に「改善」され、拡張されるようになります。したがって、私の目標は、コードを可能な限りリファクタリングしやすくすることです。

制御フローブレーカーが含まれていて、機能を少しだけ追加したい場合、機能を完全に再定式化する必要があるという問題にしばしば直面します。これは、孤立したネスティングへの新しいパスを導入する代わりに、制御フロー全体を変更するため、非常にエラーが発生しやすくなります。末尾に1つだけの戻りがある場合、またはガードを使用してループを終了する場合は、当然、より多くのネストとコードがあります。しかし、コンパイラーとIDEがサポートするリファクタリング機能が得られます。


同じことが変数にも当てはまります。アーリーリターンのような制御フロー構造を使用する代わりになります。

変数は、既存の制御フローが維持されるようにコードを断片に分割することをほとんど妨げません。「抽出メソッド」を試してください。IDEは、記述した内容からセマンティクスを導出できないため、制御フローの事前リファクタリングのみを実行できます。
oopexpert

5

複数のreturnステートメントは、単一のreturnステートメントに対するGOTOを持つことに等しいという事実を考慮してください。これは、breakステートメントの場合と同じです。したがって、一部の人は、私のように、すべての意図と目的のためにGOTOを検討します。

ただし、この種のGOTOは有害であるとは考えておらず、正当な理由が見つかった場合は、実際のGOTOをコードで使用することをためらいません。

私の一般的なルールは、GOTOはフロー制御専用です。ループには決して使用しないでください。GOTOを「上向き」または「後ろ向き」にしないでください。(これがブレーク/リターンの仕組みです)

他の人が述べたように、以下は有害と考えられるGOTOステートメントを 読む必要がありますが、
これは1970年にGOTOが過度に使用されたときに書かれたことに留意してください。すべてのGOTOが有害であるとは限りません。通常の構造の代わりに使用しない限り、GOTOの使用をお勧めしません。

私は、通常のケースでは決して起こらないはずの障害のためにエリアから逃げる必要があるエラーケースでそれらを使用すると便利だと思います。ただし、GOTOを使用する代わりに早めに戻ることができるように、このコードを別の関数に入れることも検討する必要がありますが、これも不便です。


6
gotoを置き換えるすべての構造化された構造は、gotoの観点から実装されます。たとえば、ループ、「if」および「case」。これは悪いことではありません-実際は逆です。また、それは「意図と目的」です。
アンソニー

Touche、しかしこれは私のポイントを変えない…それはちょうど私の説明をわずかに間違っている。しかたがない。
user606723

GOTOは、(1)ターゲットが同じメソッドまたは関数内にあり、(2)コードの方向が順方向である(コードをスキップする)、および(3)ターゲットが別のネストされた構造内にない限り、常に問題ありませんif-caseの途中からelse-caseの途中までGOTO)。これらの規則に従えば、GOTOの誤用はすべて視覚的にも論理的にも非常に強力なコードの匂いがします。
ミッコランタライネン

3

循環的な複雑さ

SonarCubeが循環的複雑度を決定するために複数のreturnステートメントを使用するのを見てきました。returnステートメントが多いほど、循環的複雑度が高くなります

戻り型の変更

複数の戻り値は、戻り値の型を変更する場合、関数の複数の場所で変更する必要があることを意味します。

複数の出口

戻り値の原因を理解するには、条件ステートメントと併せてロジックを慎重に検討する必要があるため、デバッグが難しくなります。

リファクタリングされたソリューション

複数のreturnステートメントの解決策は、必要な実装オブジェクトを解決した後、それらを単一のリターンを持つポリモーフィズムに置き換えることです。


3
複数の戻り値から複数の場所で戻り値を設定することは、循環的な複雑さを排除するものではなく、出口の場所を統一するだけです。特定のコンテキストで循環的複雑度が示す可能性のある問題はすべて残っています。「戻り値の原因を理解するには、条件ステートメントと一緒にロジックを慎重に検討する必要があるため、デバッグが難しくなります。」再び、戻り値を統合してもロジックは変わりません。コードがどのように機能するかを理解するために慎重に勉強する必要がある場合は、リファクタリングする必要があります。
WillD
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.