goto
声明が嫌いな人が多いようで、少し正直する必要があると感じました。
私は「感情」の人々が、goto
結局はコードの理解と、考えられるパフォーマンスへの影響について(誤解)まで煮詰められると信じています。質問に答える前に、まず、それがどのようにコンパイルされるかについての詳細のいくつかを説明します。
ご存じのとおり、C#はILにコンパイルされ、ILはSSAコンパイラーを使用してアセンブラーにコンパイルされます。これがどのように機能するかについて少し洞察を与えてから、質問自体に答えてみましょう。
C#からILへ
まず、C#コードが必要です。簡単に始めましょう:
foreach (var item in array)
{
// ...
break;
// ...
}
フードの下で何が起こるかについての良い考えをあなたに与えるために、私は一歩ずつこれを行います。
最初の変換:からforeach
同等のfor
ループへ(注:ここでは配列を使用しています。IDisposableの詳細を知りたくないためです。この場合、IEnumerableも使用する必要があります):
for (int i=0; i<array.Length; ++i)
{
var item = array[i];
// ...
break;
// ...
}
2番目の翻訳:for
およびbreak
は、より簡単な同等物に翻訳されます。
int i=0;
while (i < array.Length)
{
var item = array[i];
// ...
break;
// ...
++i;
}
そして3番目の変換(これはILコードに相当します):ブランチに変更break
しwhile
ます。
int i=0; // for initialization
startLoop:
if (i >= array.Length) // for condition
{
goto exitLoop;
}
var item = array[i];
// ...
goto exitLoop; // break
// ...
++i; // for post-expression
goto startLoop;
コンパイラーはこれらのことを1つのステップで実行しますが、プロセスについての洞察を提供します。C#プログラムから進化したILコードは、最後のC#コードの文字変換です。あなたはここで自分のために見ることができます:https://dotnetfiddle.net/QaiLRz( 'view IL'をクリックして)
ここで確認したことの1つは、プロセス中にコードがより複雑になることです。これを観察する最も簡単な方法は、同じことを達成するためにますます多くのコードが必要になるという事実によるものです。また、、、およびは実際にはの省略形であると主張するforeach
場合もありますが、これは部分的に当てはまります。for
while
break
goto
ILからアセンブラーへ
.NET JITコンパイラはSSAコンパイラです。ここでは、SSAフォームの詳細や最適化コンパイラーの作成方法については詳しく説明しません。多すぎますが、何が起こるかについての基本的な理解は得られます。理解を深めるには、コンパイラの最適化について読み始めることをお勧めします(簡単な紹介として、この本を気に入っています:http : //ssabook.gforge.inria.fr/latest/book.pdf //ssabook.gforge.inria.fr/latest/book.pdf)とLLVM(llvm.org)を。
すべての最適化コンパイラは、コードが簡単で予測可能なパターンに従うという事実に依存しています。FORループの場合、グラフ理論を使用して分岐を分析し、次に分岐内のcycliなどを最適化します(たとえば、後方分岐)。
ただし、ループを実装するための前方分岐があります。ご想像のとおり、これは実際には次のように、JITが修正する最初のステップの1つです。
int i=0; // for initialization
if (i >= array.Length) // for condition
{
goto endOfLoop;
}
startLoop:
var item = array[i];
// ...
goto endOfLoop; // break
// ...
++i; // for post-expression
if (i >= array.Length) // for condition
{
goto startLoop;
}
endOfLoop:
// ...
ご覧のとおり、これで後方ループができました。これが小さなループです。ここでまだ厄介なのは、私たちのbreak
声明が原因で最終的に分岐したブランチだけです。これを同じ方法で移動できる場合もありますが、そのままにしておく場合もあります。
では、なぜコンパイラはこれを行うのでしょうか?ループを展開できれば、ベクトル化できるかもしれません。定数が追加されていることを証明できる場合もあります。つまり、ループ全体が薄い空気に消えてしまう可能性があります。要約すると:(分岐を予測可能にすることで)パターンを予測可能にすることで、特定の条件がループに保持されていることを証明できます。つまり、JIT最適化中に魔法をかけることができます。
しかし、ブランチはこれらの素晴らしい予測可能なパターンを壊す傾向があります。それは、オプティマイザが何か嫌いなことです。Break、continue、goto-これらはすべて、これらの予測可能なパターンを破ることを意図しているため、実際には「良い」ものではありません。
また、この時点で、単純なforeach
方が予測しやすいこと、そしてあちこちにある一連のgoto
ステートメントよりも理解しやすいことも理解する必要があります。(1)可読性と(2)オプティマイザーの観点からは、どちらも優れたソリューションです。
言及する価値のあるもう1つの点は、レジスタを変数に割り当てるコンパイラの最適化(レジスタ割り当てと呼ばれるプロセス)に非常に関連があることです。ご存知かもしれませんが、CPUには限られた数のレジスタしかなく、ハードウェアのメモリの中で最速のメモリです。最も内側のループにあるコードで使用される変数は、レジスターが割り当てられる可能性が高くなりますが、ループ外の変数はそれほど重要ではありません(このコードはおそらくヒットが少ないため)。
助けて、あまりにも複雑...どうすればいいですか?
肝心な点は、常に自由に使用できる言語構造を使用する必要があるということです。これにより、通常(暗黙的に)コンパイラーの予測可能なパターンが構築されます。可能な場合は奇妙な枝を避けるようにしてください(特に:break
、continue
、goto
またはreturn
何の真ん中で)。
ここでの朗報は、これらの予測可能なパターンが読みやすく(人間の場合)、簡単に見つけられる(コンパイラの場合)ことです。
これらのパターンの1つは、SESEと呼ばれます。これは、単一エントリー、単一出口を表しています。
そして今、私たちは本当の質問に行きます。
次のようなものがあるとします。
// a is a variable.
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a)
{
// break everything
}
}
}
これを予測可能なパターンにする最も簡単な方法は、単純にif
完全に排除することです。
int i, j;
for (i=0; i<100 && i*j <= a; ++i)
{
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
}
他の場合では、メソッドを2つのメソッドに分割することもできます。
// Outer loop in method 1:
for (i=0; i<100 && processInner(i); ++i)
{
}
private bool processInner(int i)
{
int j;
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
return i*j<=a;
}
一時変数?良い、悪い、または醜い?
ループ内からブール値を返すように決定することもできます(ただし、個人的にはSESE形式を好みます。これは、コンパイラーがそれを表示する方法であり、読みやすくなるためです)。
一部の人々は、一時変数を使用する方がきれいだと思い、次のような解決策を提案します。
bool more = true;
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { more = false; break; } // yuck.
// ...
}
if (!more) { break; } // yuck.
// ...
}
// ...
私は個人的にこのアプローチに反対しています。コードのコンパイル方法をもう一度見てください。これがこれらの素晴らしい予測可能なパターンで何をするかを考えてみましょう。写真をゲット?
そうです、私にそれを綴らせてください。何が起こるか:
- コンパイラーはすべてをブランチとして書き出します。
- 最適化ステップとして、コンパイラーはデータフロー分析を行い、
more
たまたま制御フローでのみ使用される奇妙な変数を削除しようとします。
- 成功した場合、変数
more
はプログラムから削除され、ブランチのみが残ります。これらのブランチは最適化されるため、内側のループから1つのブランチのみが取得されます。
- 失敗した場合、変数
more
は最も内側のループで確実に使用されるため、コンパイラーが変数を最適化しないと、レジスターに割り当てられる可能性が高くなります(貴重なレジスターメモリを消費します)。
つまり、要約すると、コンパイラのオプティマイザはmore
、制御フローにのみ使用されていることを理解するために多くのトラブルに巻き込まれ、最良のケースでは、それを外部の外側の単一のブランチに変換しますループ。
言い換えれば、最良のシナリオは、これと同等のものになることです。
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a) { goto exitLoop; } // perhaps add a comment
// ...
}
// ...
}
exitLoop:
// ...
これに関する私の個人的な意見は非常に単純です。これが私たちがずっと意図していたことである場合、コンパイラと可読性の両方について世界をより簡単にし、それをすぐに書いてみましょう。
tl; dr:
結論:
- 可能であれば、forループで単純な条件を使用します。可能な限り高水準の言語構造に固執してください。
- すべてが失敗し、
goto
またはのどちらかが残っている場合bool more
は、前者を優先してください。