Les instructions conditionnelles non triviales doivent-elles être déplacées vers la section d'initialisation des boucles?

21

J'ai eu cette idée de cette question sur stackoverflow.com

Le modèle suivant est courant:

final x = 10;//whatever constant value
for(int i = 0; i < Math.floor(Math.sqrt(x)) + 1; i++) {
  //...do something
}

Le point que j'essaie de faire est que l'énoncé conditionnel est quelque chose de compliqué et ne change pas.

Est-il préférable de le déclarer dans la section d'initialisation de la boucle, en tant que tel?

final x = 10;//whatever constant value
for(int i = 0, j = Math.floor(Math.sqrt(x)) + 1; i < j; i++) {
  //...do something
}

Est-ce plus clair?

Que faire si l'expression conditionnelle est simple, comme

final x = 10;//whatever constant value
for(int i = 0, j = n*n; i > j; j++) {
  //...do something
}
Celeritas
la source
47
Pourquoi ne pas simplement le déplacer sur la ligne avant la boucle, vous pouvez également lui donner un nom raisonnable.
jonrsharpe
2
@Mehrdad: Ils ne sont pas équivalents. Si xest de grande ampleur, Math.floor(Math.sqrt(x))+1est égal à Math.floor(Math.sqrt(x)). :-)
R ..
5
@jonrsharpe Parce que cela élargirait la portée de la variable. Je ne dis pas nécessairement que c'est une bonne raison de ne pas le faire, mais c'est la raison pour laquelle certaines personnes ne le font pas.
Kevin Krumwiede
3
@KevinKrumwiede Si la portée est un problème, limitez-la en plaçant le code dans son propre bloc, par exemple, { x=whatever; for (...) {...} }ou, mieux encore, demandez-vous s'il y a suffisamment de choses pour que ce soit une fonction distincte.
Blrfl
2
@jonrsharpe Vous pouvez également lui donner un nom raisonnable lors de sa déclaration dans la section init. Ne pas dire que je le mettrais là; il est encore plus facile à lire s'il est séparé.
JollyJoker

Réponses:

62

Ce que je ferais, c'est quelque chose comme ça:

void doSomeThings() {
    final x = 10;//whatever constant value
    final limit = Math.floor(Math.sqrt(x)) + 1;
    for(int i = 0; i < limit; i++) {
         //...do something
    }
}

Honnêtement, la seule bonne raison de cram initialiser j(maintenant limit) dans l'en-tête de boucle est de le garder correctement défini. Tout ce qu'il faut pour faire en sorte qu'un non-problème soit une belle portée de fermeture étanche.

Je peux apprécier le désir d'être rapide mais ne sacrifie pas la lisibilité sans une vraie bonne raison.

Bien sûr, le compilateur peut être optimisé, l'initialisation de plusieurs variables peut être légale, mais les boucles sont suffisamment difficiles à déboguer telles quelles. Veuillez être gentil avec les humains. Si cela nous ralentit vraiment, c'est bien de le comprendre suffisamment pour le réparer.

candied_orange
la source
Bon point. Je supposerais de ne pas s'embêter avec une autre variable si l'expression est quelque chose de simple, par exemple, for(int i = 0; i < n*n; i++){...}vous n'assigneriez pas n*nà une variable, n'est-ce pas?
Celeritas
1
Je le ferais et je l'ai. Mais pas pour la vitesse. Pour plus de lisibilité. Le code lisible a tendance à être rapide en soi.
candied_orange
1
Même le problème de l'étendue disparaît si vous marquez comme une constante ( final). Qui se soucie si une constante qui a une application du compilateur l'empêchant de changer est accessible plus tard dans la fonction?
jpmc26
Je pense qu'une grande chose est ce que vous attendez. Je sais que l'exemple utilise le sqrt mais que faire si c'était une autre fonction? La fonction est-elle pure? Vous attendez-vous toujours aux mêmes valeurs? Y a-t-il des effets secondaires? Les effets secondaires doivent-ils se produire à chaque itération?
Pieter B
38

Un bon compilateur générera le même code de toute façon, donc si vous recherchez des performances, n'effectuez un changement que s'il se trouve dans une boucle critique et que vous l'avez réellement profilé et trouvé que cela fait une différence. Même si le compilateur ne peut pas l'optimiser, comme les gens l'ont souligné dans les commentaires sur le cas des appels de fonction, dans la grande majorité des situations, la différence de performances va être trop petite pour mériter la considération d'un programmeur.

Toutefois...

Nous ne devons pas oublier que le code est principalement un moyen de communication entre les humains, et vos deux options ne communiquent pas très bien aux autres humains. La première donne l'impression que l'expression doit être calculée à chaque itération, et la seconde étant dans la section d'initialisation implique qu'elle sera mise à jour quelque part dans la boucle, où elle est vraiment constante tout au long.

Je préférerais en fait qu'il soit retiré au-dessus de la boucle et fait finalpour que cela soit immédiatement et abondamment clair pour quiconque lit le code. Ce n'est pas idéal non plus car cela augmente la portée de la variable, mais votre fonction englobante ne devrait pas contenir beaucoup plus que cette boucle de toute façon.

Karl Bielefeldt
la source
5
Une fois que vous commencez à inclure des appels de fonction, il devient beaucoup plus difficile pour le compilateur d'optimiser. Le compilateur devrait avoir une connaissance spéciale que Math.sqrt n'a pas d'effets secondaires.
Peter Green
@PeterGreen S'il s'agit de Java, la JVM peut le comprendre, mais cela peut prendre un certain temps.
chrylis -on strike-
La question est balisée à la fois C ++ et Java. Je ne sais pas à quel point la JVM Java est avancée et quand elle peut et ne peut pas le comprendre, mais je sais que les compilateurs C ++ en général ne peuvent pas le comprendre, mais une fonction est pure à moins qu'elle n'ait une annotation non standard indiquant au compilateur ainsi, ou le corps de la fonction est visible et toutes les fonctions indirectement appelées peuvent être détectées comme pures selon les mêmes critères. Remarque: sans effet secondaire ne suffit pas pour le sortir de la condition de boucle. Les fonctions qui dépendent de l'état global ne peuvent pas non plus être déplacées hors de la boucle si le corps de la boucle peut modifier l'état.
hvd
Cela devient intéressant une fois que Math.sqrt (x) est remplacé par Mymodule.SomeNonPureMethodWithSideEffects (x).
Pieter B
9

Comme l'a dit @Karl Bielefeldt dans sa réponse, ce n'est généralement pas un problème.

Cependant, il s'agissait à un moment d'un problème courant en C et C ++, et une astuce est survenue pour contourner le problème sans réduire la lisibilité du code - itérer en arrière, jusqu'à0 .

final x = 10;//whatever constant value
for(int i = Math.floor(Math.sqrt(x)); i >= 0; i--) {
  //...do something
}

Maintenant, le conditionnel dans chaque itération est exactement >= 0ce que chaque compilateur compilera en 1 ou 2 instructions d'assemblage. Chaque processeur fabriqué au cours des dernières décennies devrait avoir des vérifications de base comme celles-ci; en faisant une vérification rapide sur ma machine x64, je vois que cela se transforme de manière prévisible en cmpl $0x0, -0x14(%rbp)(valeur de comparaison longue-int 0 vs registre rbp décalé -14) et jl 0x100000f59( passez à l'instruction suivant la boucle si la comparaison précédente était vraie pour "2nd-arg <1er argument ») .

Notez que j'ai supprimé le + 1de Math.floor(Math.sqrt(x)) + 1; pour que les mathématiques fonctionnent, la valeur de départ doit être int i = «iterationCount» - 1. Il convient également de noter que votre itérateur doit être signé; unsigned intne fonctionnera pas et avertira probablement le compilateur.

Après avoir programmé dans des langages basés sur C pendant environ 20 ans, je n'écris maintenant que des boucles d'itération d'index inversé, sauf s'il existe une raison spécifique de réitérer l'index. En plus des vérifications plus simples dans les conditions, l'itération inversée passe souvent également à côté de ce qui serait autrement des mutations de tableau gênantes pendant l'itération.

Slipp D. Thompson
la source
1
Tout ce que vous écrivez est techniquement correct. Le commentaire sur les instructions peut cependant être trompeur, car tous les processeurs modernes ont également été conçus pour fonctionner correctement avec les boucles d'itération directe, qu'ils aient ou non une instruction spéciale pour eux. Dans tous les cas, la plupart du temps est généralement passé à l' intérieur de la boucle, sans effectuer d'itération.
Jørgen Fogh
4
Il convient de noter que les optimisations de compilateur modernes sont conçues avec le code "normal" à l'esprit. Dans la plupart des cas, le compilateur générera du code tout aussi rapide, que vous utilisiez ou non des "astuces" d'optimisation. Mais certaines astuces peuvent en fait entraver l' optimisation en fonction de leur complexité. Si l'utilisation de certaines astuces rend votre code plus lisible ou aide à détecter les bogues courants, c'est formidable, mais ne vous faites pas croire que "si j'écris du code de cette façon, ce sera plus rapide". Écrivez le code comme vous le feriez normalement, puis profilez pour trouver les endroits où vous devez optimiser.
0x5453
Notez également que les unsignedcompteurs fonctionneront ici si vous modifiez la vérification (le moyen le plus simple consiste à ajouter la même valeur des deux côtés); par exemple, pour tout décrément Dec, la vérification (i + Dec) >= Decdoit toujours avoir le même résultat que la signedvérification i >= 0, avec les deux signedet les unsignedcompteurs, tant que le langage a des règles enveloppantes bien définies pour les unsignedvariables (en particulier, -n + n == 0doit être vrai pour les deux signedet unsigned). Notez cependant que cela peut être moins efficace qu'une >=0vérification signée , si le compilateur n'a pas d'optimisation pour cela.
Justin Time - Rétablir Monica
1
@JustinTime Oui, l'exigence signée est la partie la plus difficile; l'ajout d'une Decconstante à la fois à la valeur de départ et à la valeur de fin fonctionne, mais la rend beaucoup moins intuitive, et si vous l'utilisez icomme index de tableau, vous devrez également faire un unsigned int arrayI = i - Dec;dans le corps de la boucle. J'utilise simplement l'itération directe lorsque je suis coincé avec un itérateur non signé; souvent avec une i <= count - 1conditionnelle pour garder la logique parallèle aux boucles d'itération inverse.
Slipp D. Thompson
1
@ SlippD.Thompson Je ne voulais pas Decspécifiquement ajouter aux valeurs de début et de fin, mais déplacer la vérification de l'état des Decdeux côtés. for (unsigned i = N - 1; i + 1 >= 1; i--) /*...*/ Cela vous permet de l'utiliser inormalement dans la boucle, tout en garantissant que la valeur la plus basse possible sur le côté gauche de la condition est 0(pour empêcher le bouclage d'interférer). Il est certainement beaucoup plus simple d'utiliser l'itération directe lorsque vous travaillez avec des compteurs non signés.
Justin Time - Rétablir Monica
3

Cela devient intéressant une fois que Math.sqrt (x) est remplacé par Mymodule.SomeNonPureMethodWithSideEffects (x).

Fondamentalement, mon modus operandi est le suivant: si l'on s'attend à ce que quelque chose donne toujours la même valeur, alors ne l'évaluez qu'une seule fois. Par exemple, List.Count, si la liste est censée ne pas changer pendant le fonctionnement de la boucle, puis obtenez le nombre en dehors de la boucle dans une autre variable.

Certains de ces "décomptes" peuvent être étonnamment coûteux, en particulier lorsque vous traitez avec des bases de données. Même si vous travaillez sur un ensemble de données qui n'est pas censé changer pendant l'itération de la liste.

Pieter B
la source
Lorsque le comptage coûte cher, vous ne devriez pas du tout l'utiliser. Au lieu de cela, vous devriez faire l'équivalent defor( auto it = begin(dataset); !at_end(it); ++it )
Ben Voigt
@BenVoigt L'utilisation d'un itérateur est certainement le meilleur moyen pour ces opérations, je l'ai simplement mentionné pour illustrer mon point sur l'utilisation de méthodes non pures avec des effets secondaires.
Pieter B
0

À mon avis, c'est très spécifique à la langue. Par exemple, si j'utilise C ++ 11, je soupçonne que si la vérification de l'état est unconstexpr fonction, le compilateur serait très susceptible d'optimiser les multiples exécutions car il sait qu'il produira la même valeur à chaque fois.

Cependant, si l'appel de fonction est une fonction de bibliothèque qui n'est pas constexpr le compilateur, il va presque certainement l'exécuter à chaque itération car il ne peut pas en déduire (sauf s'il est en ligne et peut donc être déduit comme pur).

Je connais moins Java, mais étant donné que JIT est compilé, je suppose que le compilateur a suffisamment d'informations à l'exécution pour probablement intégrer et optimiser la condition. Mais cela reposerait sur une bonne conception du compilateur et le compilateur décidant cette boucle était une priorité d'optimisation que nous ne pouvons que deviner.

Personnellement , je pense qu'il est un peu plus élégante de mettre la condition dans la boucle si vous le pouvez, mais si elle est complexe , je l'écrire dans une constexprou inlinefonction, ou vos langues equivalant à laisser entendre que la fonction est pure et optimisable. Cela rend l'intention évidente et maintient le style de boucle idiomatique sans créer une énorme ligne illisible. Il donne également à la vérification de condition un nom s'il s'agit de sa propre fonction afin que les lecteurs puissent immédiatement voir de façon logique à quoi sert la vérification sans la lire si elle est complexe.

Vality
la source