Il me semble que les gens n'aiment pas goto
beaucoup une déclaration, j'ai donc ressenti le besoin de clarifier un peu les choses.
Je crois que les `` émotions '' des gens goto
se résument finalement à la compréhension du code et aux (idées fausses) sur les implications possibles en termes de performances. Avant de répondre à la question, je vais donc d'abord entrer dans certains détails sur la façon dont il est compilé.
Comme nous le savons tous, C # est compilé en IL, qui est ensuite compilé en assembleur à l'aide d'un compilateur SSA. Je vais vous donner un aperçu de la façon dont tout cela fonctionne, puis essayer de répondre à la question elle-même.
De C # à IL
Nous avons d'abord besoin d'un morceau de code C #. Commençons simple:
foreach (var item in array)
{
// ...
break;
// ...
}
Je vais faire cette étape par étape pour vous donner une bonne idée de ce qui se passe sous le capot.
Première traduction: de foreach
à la for
boucle équivalente (Remarque: j'utilise un tableau ici, car je ne veux pas entrer dans les détails d'IDisposable - auquel cas je devrais également utiliser un IEnumerable):
for (int i=0; i<array.Length; ++i)
{
var item = array[i];
// ...
break;
// ...
}
Deuxième traduction: le for
et break
est traduit en un équivalent plus simple:
int i=0;
while (i < array.Length)
{
var item = array[i];
// ...
break;
// ...
++i;
}
Et troisième traduction (c'est l'équivalent du code IL): on change break
et while
en une branche:
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;
Alors que le compilateur fait ces choses en une seule étape, il vous donne un aperçu du processus. Le code IL qui découle du programme C # est la traduction littérale du dernier code C #. Vous pouvez le voir par vous-même ici: https://dotnetfiddle.net/QaiLRz (cliquez sur 'voir IL')
Maintenant, une chose que vous avez observée ici est que pendant le processus, le code devient plus complexe. La façon la plus simple d'observer cela est le fait que nous avions besoin de plus en plus de code pour accomplir la même chose. Vous pourriez arguer que foreach
, for
, while
et break
sont en fait de courtes mains pour goto
, qui est en partie vrai.
De l'IL à l'assembleur
Le compilateur .NET JIT est un compilateur SSA. Je n'entrerai pas dans tous les détails du formulaire SSA ici et comment créer un compilateur d'optimisation, c'est tout simplement trop, mais je peux donner une compréhension de base de ce qui va se passer. Pour une compréhension plus approfondie, il est préférable de commencer à lire sur l'optimisation des compilateurs (j'aime ce livre pour une brève introduction: http://ssabook.gforge.inria.fr/latest/book.pdf ) et LLVM (llvm.org) .
Chaque compilateur d'optimisation repose sur le fait que le code est simple et suit des modèles prévisibles . Dans le cas des boucles FOR, nous utilisons la théorie des graphes pour analyser les branches, puis optimisons des choses comme les cycli dans nos branches (par exemple les branches en arrière).
Cependant, nous avons maintenant des branches avancées pour implémenter nos boucles. Comme vous l'avez peut-être deviné, c'est en fait l'une des premières étapes que le JIT va corriger, comme ceci:
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:
// ...
Comme vous pouvez le voir, nous avons maintenant une branche en arrière, qui est notre petite boucle. La seule chose qui est encore méchante ici est la branche avec laquelle nous nous sommes retrouvés en raison de notre break
déclaration. Dans certains cas, nous pouvons procéder de la même manière, mais dans d'autres, il est là pour rester.
Alors pourquoi le compilateur fait-il cela? Eh bien, si nous pouvons dérouler la boucle, nous pourrons peut-être la vectoriser. Nous pourrions même être en mesure de prouver qu'il n'y a que des constantes ajoutées, ce qui signifie que toute notre boucle pourrait disparaître dans l'air. Pour résumer: en rendant les motifs prévisibles (en rendant les branches prévisibles), nous pouvons prouver que certaines conditions tiennent dans notre boucle, ce qui signifie que nous pouvons faire de la magie lors de l'optimisation JIT.
Cependant, les branches ont tendance à briser ces beaux modèles prévisibles, ce qui est quelque chose que les optimiseurs n'apprécient donc pas. Break, continue, goto - ils ont tous l'intention de briser ces modèles prévisibles - et ne sont donc pas vraiment «sympas».
Vous devez également réaliser à ce stade qu'un simple foreach
est plus prévisible qu'un tas de goto
déclarations qui vont partout. En termes de (1) lisibilité et (2) du point de vue de l'optimiseur, c'est à la fois la meilleure solution.
Une autre chose à noter est qu'il est très pertinent d'optimiser les compilateurs pour attribuer des registres aux variables (un processus appelé allocation de registre ). Comme vous le savez peut-être, il n'y a qu'un nombre fini de registres dans votre CPU et ce sont de loin les pièces de mémoire les plus rapides de votre matériel. Les variables utilisées dans le code qui se trouve dans la boucle la plus interne sont plus susceptibles d'obtenir un registre affecté, tandis que les variables en dehors de votre boucle sont moins importantes (car ce code est probablement moins frappé).
Aide, trop de complexité ... que dois-je faire?
L'essentiel est que vous devez toujours utiliser les constructions de langage dont vous disposez, qui construisent généralement (implicitement) des modèles prévisibles pour votre compilateur. Essayez d'éviter les branches étranges si possible ( en particulier: break
, continue
, goto
ou return
au milieu de rien).
La bonne nouvelle ici est que ces modèles prévisibles sont à la fois faciles à lire (pour les humains) et faciles à repérer (pour les compilateurs).
L'un de ces modèles est appelé SESE, qui signifie Single Entry Single Exit.
Et maintenant, nous arrivons à la vraie question.
Imaginez que vous ayez quelque chose comme ça:
// a is a variable.
for (int i=0; i<100; ++i)
{
for (int j=0; j<100; ++j)
{
// ...
if (i*j > a)
{
// break everything
}
}
}
La façon la plus simple d'en faire un modèle prévisible est simplement d'éliminer if
complètement le:
int i, j;
for (i=0; i<100 && i*j <= a; ++i)
{
for (j=0; j<100 && i*j <= a; ++j)
{
// ...
}
}
Dans d'autres cas, vous pouvez également diviser la méthode en 2 méthodes:
// 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;
}
Variables temporaires? Bon, mauvais ou laid?
Vous pourriez même décider de renvoyer un booléen depuis la boucle (mais je préfère personnellement le formulaire SESE car c'est ainsi que le compilateur le verra et je pense que c'est plus propre à lire).
Certaines personnes pensent qu'il est plus propre d'utiliser une variable temporaire et proposent une solution comme celle-ci:
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.
// ...
}
// ...
Je suis personnellement opposé à cette approche. Regardez à nouveau comment le code est compilé. Réfléchissez maintenant à ce que cela fera avec ces jolis motifs prévisibles. Obtenez l'image?
Bon, permettez-moi de l'épeler. Ce qui va arriver, c'est que:
- Le compilateur écrira tout sous forme de branches.
- En tant qu'étape d'optimisation, le compilateur effectuera une analyse du flux de données dans le but de supprimer l'étrange
more
variable qui n'est utilisée que dans le flux de contrôle.
- En cas de succès, la variable
more
sera supprimée du programme et seules les branches resteront. Ces branches seront optimisées, vous n'obtiendrez qu'une seule branche hors de la boucle interne.
- En cas d'échec, la variable
more
est définitivement utilisée dans la boucle la plus interne, donc si le compilateur ne l'optimise pas, elle a de grandes chances d'être allouée à un registre (qui consomme une précieuse mémoire de registre).
Donc, pour résumer: l'optimiseur de votre compilateur aura beaucoup de mal à comprendre qu'il more
n'est utilisé que pour le flux de contrôle, et dans le meilleur des cas, il le traduira en une seule branche en dehors de l'extérieur pour boucle.
En d'autres termes, le meilleur scénario est qu'il aboutira à l'équivalent de ceci:
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:
// ...
Mon opinion personnelle à ce sujet est assez simple: si c'est ce que nous voulions depuis le début, rendons le monde plus facile pour le compilateur et la lisibilité, et écrivons cela tout de suite.
tl; dr:
Conclusion:
- Utilisez une condition simple dans votre boucle for si possible. Tenez-vous autant que possible aux constructions linguistiques de haut niveau dont vous disposez.
- Si tout échoue et que vous vous retrouvez avec l'un
goto
ou l' autre bool more
, préférez le premier.