Es scheint mir, als ob die Leute eine goto
Aussage nicht mögen , deshalb hatte ich das Bedürfnis, dies ein wenig zu korrigieren.
Ich glaube, die "Emotionen", die Menschen haben, laufen goto
letztendlich darauf hinaus, Code zu verstehen und (falsche Vorstellungen) über mögliche Auswirkungen auf die Leistung. Bevor ich die Frage beantworte, werde ich daher zunächst einige Details zur Kompilierung erläutern.
Wie wir alle wissen, wird C # zu IL kompiliert, das dann mit einem SSA-Compiler zu Assembler kompiliert wird. Ich werde ein paar Einblicke geben, wie das alles funktioniert, und dann versuchen, die Frage selbst zu beantworten.
Von C # nach IL
Zuerst brauchen wir ein Stück C # -Code. Fangen wir einfach an:
foreach (var item in array)
{
// ...
break;
// ...
}
Ich werde dies Schritt für Schritt tun, um Ihnen eine gute Vorstellung davon zu geben, was unter der Haube passiert.
Erste Übersetzung: von foreach
in die äquivalente for
Schleife (Hinweis: Ich verwende hier ein Array, da ich nicht auf Details von IDisposable eingehen möchte - in diesem Fall müsste ich auch eine IEnumerable verwenden):
for (int i=0; i<array.Length; ++i)
{
var item = array[i];
// ...
break;
// ...
}
Zweite Übersetzung: das for
und break
wird in ein einfacheres Äquivalent übersetzt:
int i=0;
while (i < array.Length)
{
var item = array[i];
// ...
break;
// ...
++i;
}
Und dritte Übersetzung (dies entspricht dem IL-Code): Wir ändern uns break
und while
in einen Zweig:
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;
Während der Compiler diese Aufgaben in einem einzigen Schritt ausführt, erhalten Sie einen Einblick in den Prozess. Der IL-Code, der sich aus dem C # -Programm entwickelt, ist die wörtliche Übersetzung des letzten C # -Codes. Sie können sich hier selbst davon überzeugen: https://dotnetfiddle.net/QaiLRz (klicken Sie auf 'IL anzeigen')
Eine Sache, die Sie hier beobachtet haben, ist, dass der Code während des Prozesses komplexer wird. Der einfachste Weg, dies zu beobachten, ist die Tatsache, dass wir immer mehr Code benötigten, um dasselbe zu erreichen. Man könnte auch argumentieren , dass foreach
, for
, while
und break
sind eigentlich Kurz Hände goto
, die zum Teil wahr ist.
Von IL zu Assembler
Der .NET JIT-Compiler ist ein SSA-Compiler. Ich werde hier nicht auf alle Details des SSA-Formulars eingehen und darauf, wie ein optimierender Compiler erstellt wird. Es ist einfach zu viel, kann aber ein grundlegendes Verständnis darüber vermitteln, was passieren wird. Für ein tieferes Verständnis ist es am besten, sich mit der Optimierung von Compilern zu befassen (ich mag dieses Buch für eine kurze Einführung: http://ssabook.gforge.inria.fr/latest/book.pdf befassen ) und LLVM (llvm.org) .
Jeder optimierende Compiler verlässt sich auf die Tatsache, dass Code einfach ist und vorhersehbaren Mustern folgt . Im Fall von FOR-Schleifen verwenden wir die Graphentheorie, um Zweige zu analysieren und dann Dinge wie Zyklen in unseren Zweigen zu optimieren (z. B. Zweige rückwärts).
Wir haben jetzt jedoch Forward-Zweige, um unsere Schleifen zu implementieren. Wie Sie vielleicht vermutet haben, ist dies tatsächlich einer der ersten Schritte, die die JIT wie folgt beheben wird:
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:
// ...
Wie Sie sehen können, haben wir jetzt einen Rückwärtszweig, der unsere kleine Schleife ist. Das einzige, was hier noch böse ist, ist der Zweig, mit dem wir aufgrund unserer break
Aussage gelandet sind. In einigen Fällen können wir dies auf die gleiche Weise verschieben, in anderen bleibt es jedoch bestehen.
Warum macht der Compiler das? Wenn wir die Schleife abrollen können, können wir sie möglicherweise vektorisieren. Wir könnten sogar beweisen können, dass nur Konstanten hinzugefügt werden, was bedeutet, dass unsere gesamte Schleife in Luft aufgehen könnte. Zusammenfassend lässt sich sagen: Indem wir die Muster vorhersehbar machen (indem wir die Zweige vorhersehbar machen), können wir beweisen, dass bestimmte Bedingungen in unserer Schleife gelten, was bedeutet, dass wir während der JIT-Optimierung zaubern können.
Zweige neigen jedoch dazu, diese schönen vorhersehbaren Muster zu brechen, was Optimierer daher nicht mögen. Brechen Sie, fahren Sie fort, gehen Sie - sie alle beabsichtigen, diese vorhersehbaren Muster zu brechen - und sind daher nicht wirklich "nett".
Sie sollten an dieser Stelle auch erkennen, dass eine einfache Aussage foreach
vorhersehbarer ist als eine Reihe von goto
Aussagen, die überall vorkommen. In Bezug auf (1) Lesbarkeit und (2) aus Sicht des Optimierers ist dies die bessere Lösung.
Eine weitere erwähnenswerte Sache ist, dass es für die Optimierung von Compilern sehr relevant ist, Register Variablen zuzuweisen (ein Prozess, der als Registerzuweisung bezeichnet wird ). Wie Sie vielleicht wissen, gibt es in Ihrer CPU nur eine begrenzte Anzahl von Registern, und diese sind bei weitem die schnellsten Speicher in Ihrer Hardware. Variablen, die in Code verwendet werden, der sich in der innersten Schleife befindet, erhalten mit größerer Wahrscheinlichkeit ein Register, während Variablen außerhalb Ihrer Schleife weniger wichtig sind (da dieser Code wahrscheinlich weniger getroffen wird).
Hilfe, zu viel Komplexität ... was soll ich tun?
Das Fazit ist, dass Sie immer die Sprachkonstrukte verwenden sollten, die Ihnen zur Verfügung stehen, wodurch normalerweise (implizit) vorhersehbare Muster für Ihren Compiler erstellt werden. Versuchen Sie , seltsame Zweige wenn möglich zu vermeiden (insbesondere: break
, continue
, goto
oder ein return
in der Mitte nichts).
Die gute Nachricht hier ist, dass diese vorhersehbaren Muster sowohl leicht zu lesen (für Menschen) als auch leicht zu erkennen (für Compiler) sind.
Eines dieser Muster heißt SESE und steht für Single Entry Single Exit.
Und jetzt kommen wir zur eigentlichen Frage.
Stellen Sie sich vor, Sie haben so etwas:
// a is a variable.
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a)
{
// break everything
}
}
}
Der einfachste Weg, dies zu einem vorhersehbaren Muster zu machen, besteht darin, einfach Folgendes if
vollständig zu eliminieren :
int i, j;
for (i=0; i<100 && i*j <= a; ++i)
{
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
}
In anderen Fällen können Sie die Methode auch in zwei Methoden aufteilen:
// 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;
}
Temporäre Variablen? Gut, schlecht oder hässlich?
Sie könnten sogar beschließen, einen Booleschen Wert aus der Schleife zurückzugeben (aber ich persönlich bevorzuge das SESE-Formular, da der Compiler es so sieht und ich denke, es ist sauberer zu lesen).
Einige Leute halten es für sauberer, eine temporäre Variable zu verwenden, und schlagen eine Lösung wie diese vor:
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.
// ...
}
// ...
Ich persönlich bin gegen diesen Ansatz. Sehen Sie sich noch einmal an, wie der Code kompiliert wird. Überlegen Sie nun, was dies mit diesen schönen, vorhersehbaren Mustern bewirken wird. Verstehe?
Richtig, lassen Sie es mich formulieren. Was passieren wird ist das:
- Der Compiler schreibt alles als Zweige aus.
- Als Optimierungsschritt führt der Compiler eine Datenflussanalyse durch, um die seltsame
more
Variable zu entfernen, die nur im Kontrollfluss verwendet wird.
- Bei Erfolg wird die Variable
more
aus dem Programm entfernt und es bleiben nur Verzweigungen übrig. Diese Zweige werden optimiert, sodass Sie nur einen einzigen Zweig aus der inneren Schleife erhalten.
- Wenn dies nicht erfolgreich ist, wird die Variable
more
definitiv in der innersten Schleife verwendet. Wenn der Compiler sie also nicht optimiert, besteht eine hohe Wahrscheinlichkeit, dass sie einem Register zugewiesen wird (das wertvollen Registerspeicher verbraucht).
Zusammenfassend lässt sich sagen, dass der Optimierer in Ihrem Compiler eine Menge Probleme haben wird, um herauszufinden, dass er more
nur für den Kontrollfluss verwendet wird, und ihn im besten Fall in einen einzelnen Zweig außerhalb des äußeren für übersetzt Schleife.
Mit anderen Worten, das beste Szenario ist, dass es das Äquivalent dazu ergibt:
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:
// ...
Meine persönliche Meinung dazu ist ganz einfach: Wenn wir dies die ganze Zeit beabsichtigt haben, lassen Sie uns die Welt sowohl für den Compiler als auch für die Lesbarkeit einfacher machen und das sofort schreiben.
tl; dr:
Endeffekt:
- Verwenden Sie nach Möglichkeit eine einfache Bedingung in Ihrer for-Schleife. Halten Sie sich so weit wie möglich an die Hochsprachenkonstrukte, die Ihnen zur Verfügung stehen.
- Wenn alles fehlschlägt und Sie entweder
goto
oder bleiben bool more
, bevorzugen Sie das erstere.