Chaque fois que j'ai besoin d'une division, par exemple, d'un contrôle de condition, je voudrais refactoriser l'expression de la division en multiplication, par exemple:
Version originale:
if(newValue / oldValue >= SOME_CONSTANT)
Nouvelle version:
if(newValue >= oldValue * SOME_CONSTANT)
Parce que je pense que cela peut éviter:
Division par zéro
Débordement lorsque oldValue
est très petit
Est-ce correct? Y a-t-il un problème pour cette habitude?
Deux cas courants à considérer:
Évidemment, si vous utilisez l'arithmétique entière (qui tronque), vous obtiendrez un résultat différent. Voici un petit exemple en C #:
public static void TestIntegerArithmetic()
{
int newValue = 101;
int oldValue = 10;
int SOME_CONSTANT = 10;
if(newValue / oldValue > SOME_CONSTANT)
{
Console.WriteLine("First comparison says it's bigger.");
}
else
{
Console.WriteLine("First comparison says it's not bigger.");
}
if(newValue > oldValue * SOME_CONSTANT)
{
Console.WriteLine("Second comparison says it's bigger.");
}
else
{
Console.WriteLine("Second comparison says it's not bigger.");
}
}
Production:
First comparison says it's not bigger.
Second comparison says it's bigger.
Mis à part le fait que la division peut donner un résultat différent lorsqu'elle divise par zéro (elle génère une exception, contrairement à la multiplication), elle peut également entraîner des erreurs d'arrondi légèrement différentes et un résultat différent. Exemple simple en C #:
public static void TestFloatingPoint()
{
double newValue = 1;
double oldValue = 3;
double SOME_CONSTANT = 0.33333333333333335;
if(newValue / oldValue >= SOME_CONSTANT)
{
Console.WriteLine("First comparison says it's bigger.");
}
else
{
Console.WriteLine("First comparison says it's not bigger.");
}
if(newValue >= oldValue * SOME_CONSTANT)
{
Console.WriteLine("Second comparison says it's bigger.");
}
else
{
Console.WriteLine("Second comparison says it's not bigger.");
}
}
Production:
First comparison says it's not bigger.
Second comparison says it's bigger.
Au cas où vous ne me croyez pas, voici un violon que vous pouvez exécuter et voir par vous-même.
D'autres langues peuvent être différentes; gardez à l'esprit, cependant, que C #, comme de nombreux langages, implémente une bibliothèque à virgule flottante norme IEEE (IEEE 754) , vous devriez donc obtenir les mêmes résultats dans d'autres temps d'exécution standardisés.
Si vous travaillez greenfield , vous êtes probablement OK.
Si vous travaillez sur du code hérité et que l'application est une application financière ou sensible qui effectue des opérations arithmétiques et est nécessaire pour fournir des résultats cohérents, soyez très prudent lorsque vous modifiez les opérations. Si vous devez, assurez-vous que vous avez des tests unitaires qui détecteront tout changement subtil dans l'arithmétique.
Si vous faites simplement des choses comme compter des éléments dans un tableau ou d'autres fonctions de calcul générales, vous serez probablement OK. Cependant, je ne suis pas sûr que la méthode de multiplication rende votre code plus clair.
Si vous implémentez un algorithme à une spécification, je ne changerais rien du tout, pas seulement à cause du problème des erreurs d'arrondi, mais pour que les développeurs puissent revoir le code et mapper chaque expression sur la spécification pour s'assurer qu'il n'y a pas d'implémentation défauts.
J'aime votre question car elle couvre potentiellement de nombreuses idées. Dans l'ensemble, je soupçonne que la réponse est cela dépend, probablement sur les types impliqués et la plage de valeurs possible dans votre cas spécifique.
Mon instinct initial est de réfléchir sur le style, ie. votre nouvelle version est moins claire pour le lecteur de votre code. J'imagine que je devrais réfléchir pendant une seconde ou deux (ou peut-être plus) pour déterminer l'intention de votre nouvelle version, alors que votre ancienne version est immédiatement claire. La lisibilité est un attribut important du code, il y a donc un coût dans votre nouvelle version.
Vous avez raison de dire que la nouvelle version évite une division par zéro. Certes, vous n'avez pas besoin d'ajouter de garde (dans le sens de if (oldValue != 0)
). Mais est-ce que cela a du sens? Votre ancienne version reflète un rapport entre deux nombres. Si le diviseur est nul, votre ratio n'est pas défini. Cela peut être plus significatif dans votre situation, par exemple. vous ne devez pas produire de résultat dans ce cas.
La protection contre le débordement est discutable. Si vous savez que newValue
est toujours plus grand que oldValue
, alors vous pourriez peut-être faire cet argument. Cependant, il peut y avoir des cas où (oldValue * SOME_CONSTANT)
débordera également. Je ne vois donc pas beaucoup de gains ici.
Il peut y avoir un argument selon lequel vous obtenez de meilleures performances car la multiplication peut être plus rapide que la division (sur certains processeurs). Cependant, il faudrait de nombreux calculs tels que ceux-ci pour cela à un gain significatif, à savoir. méfiez-vous de l'optimisation prématurée.
En réfléchissant à tout ce qui précède, en général, je ne pense pas qu'il y ait beaucoup à gagner avec votre nouvelle version par rapport à l'ancienne version, en particulier compte tenu de la réduction de la clarté. Cependant, il peut y avoir des cas spécifiques où il y a des avantages.
Non.
J'appellerais probablement cela optimisation prématurée , au sens large, que vous optimisiez pour performance, comme la phrase fait généralement référence, ou toute autre chose qui peuvent être optimisés, tels que Edge-count , lignes de code , ou encore plus largement, des choses comme "design"
L'implémentation de ce type d'optimisation en tant que procédure d'exploitation standard met en danger la sémantique de votre code et potentiellement cache les bords. Il peut être nécessaire de traiter explicitement les cas Edge que vous jugez bon d'éliminer en silence de toute façon. Et, il est infiniment plus facile de déboguer les problèmes autour des bords bruyants (ceux qui lèvent des exceptions) sur ceux qui échouent silencieusement.
Et, dans certains cas, il est même avantageux de "désoptimiser" pour des raisons de lisibilité, de clarté ou d'explicitation. Dans la plupart des cas, vos utilisateurs ne remarqueront pas que vous avez enregistré quelques lignes de code ou cycles de processeur pour éviter la gestion des cas Edge ou la gestion des exceptions. D'un autre côté, un code maladroit ou défaillant en silence, sera affectera les gens - vos collègues à tout le moins. (Et aussi, par conséquent, le coût de construction et de maintenance du logiciel.)
Par défaut à tout ce qui est plus "naturel" et lisible par rapport au domaine de l'application et au problème spécifique. Restez simple, explicite et idiomatique. Optimisez autant que nécessaire pour significatif gains ou pour atteindre un seuil d'utilisabilité légitime.
Notez également: Compilateurs souvent optimisez la division pour vous de toute façon - quand c'est sûr à faites-le.
Habituellement, la division par une variable est de toute façon une mauvaise idée, car généralement, le diviseur peut être nul.
La division par une constante dépend généralement de la signification logique.
Voici quelques exemples pour montrer que cela dépend de la situation:
if ((ptr2 - ptr1) >= n / 3) // good: check if length of subarray is at least n/3
...
if ((ptr2 - ptr1) * 3 >= n) // bad: confusing!! what is the intention of this code?
...
if (j - i >= 2 * min_length) // good: obviously checking for a minimum length
...
if ((j - i) / 2 >= min_length) // bad: confusing!! what is the intention of this code?
...
if (new_length >= old_length * 1.5) // good: is the new size at least 50% bigger?
...
if (new_length / old_length >= 2) // bad: BUGGY!! will fail if old_length = 0!
...
Faire n'importe quoi "chaque fois que possible" est très rarement une bonne idée.
Votre priorité numéro un devrait être l'exactitude, suivie de la lisibilité et de la maintenabilité. Remplacer aveuglément la division par une multiplication chaque fois que possible échouera souvent dans le département de correction, parfois seulement dans des cas rares et donc difficiles à trouver.
Faites ce qui est correct et le plus lisible. Si vous avez des preuves solides que l'écriture de code de la manière la plus lisible provoque un problème de performances, vous pouvez envisager de le modifier. Les revues de soins, de mathématiques et de code sont vos amis.
Concernant la lisibilité du code, je pense que la multiplication est en fait plus lisible dans certains cas. Par exemple, s'il y a quelque chose que vous devez vérifier si newValue
a augmenté de 5% ou plus au-dessus de oldValue
, alors 1.05 * oldValue
est un seuil par rapport auquel tester newValue
, et il est naturel d'écrire
if (newValue >= 1.05 * oldValue)
Mais méfiez-vous des nombres négatifs lorsque vous refactorisez les choses de cette façon (soit en remplaçant la division par la multiplication, soit en remplaçant la multiplication par la division). Les deux conditions que vous avez considérées sont équivalentes si oldValue
est garanti de ne pas être négatif; mais supposons que newValue
est en fait -13,5 et oldValue
est -10,1. alors
newValue/oldValue >= 1.05
correspond à vrai, mais
newValue >= 1.05 * oldValue
correspond à faux.
Notez le fameux article Division par invariants entiers utilisant la multiplication .
Le compilateur fait en fait la multiplication, si l'entier est invariant! Pas une division. Cela se produit même pour la non puissance de 2 valeurs. La puissance de 2 divisions utilise évidemment des décalages de bits et est donc encore plus rapide.
Cependant, pour les entiers non invariants, il est de votre responsabilité d'optimiser le code. Assurez-vous avant d'optimiser que vous optimisez vraiment un véritable goulot d'étranglement et que l'exactitude n'est pas sacrifiée. Attention au débordement d'entier.
Je me soucie de la micro-optimisation, donc j'examinerais probablement les possibilités d'optimisation.
Pensez également aux architectures sur lesquelles votre code s'exécute. Surtout ARM a une division extrêmement lente; vous devez appeler une fonction pour diviser, il n'y a pas d'instruction de division dans ARM.
De plus, sur les architectures 32 bits, la division 64 bits n'est pas optimisée, car I découvert .
Reprenant votre point 2, il évitera en effet un débordement pour un très petit oldValue
. Toutefois, si SOME_CONSTANT
est également très petit, alors votre méthode alternative se terminera par un sous-dépassement, où la valeur ne peut pas être représentée avec précision.
Et inversement, que se passe-t-il si oldValue
est très grand? Vous avez les mêmes problèmes, tout le contraire.
Si vous souhaitez éviter (ou minimiser) le risque de débordement/débordement, la meilleure façon est de vérifier si newValue
est le plus proche en amplitude de oldValue
ou de SOME_CONSTANT
. Vous pouvez ensuite choisir l'opération de division appropriée, soit
if(newValue / oldValue >= SOME_CONSTANT)
ou
if(newValue / SOME_CONSTANT >= oldValue)
et le résultat sera plus précis.
Pour la division par zéro, d'après mon expérience, cela n'est presque jamais approprié d'être "résolu" en mathématiques. Si vous avez une division par zéro dans vos contrôles continus, alors vous avez presque certainement une situation qui nécessite une analyse et tout calcul basé sur ces données n'a aucun sens. Une vérification explicite de division par zéro est presque toujours la bonne décision. (Notez que je dis "presque" ici, parce que je ne prétends pas être infaillible. Je vais juste noter que je ne me souviens pas avoir vu une bonne raison à cela en 20 ans d'écriture de logiciels embarqués, et continuer .)
Cependant, si vous avez un risque réel de débordement/débordement dans votre application, ce n'est probablement pas la bonne solution. Plus probablement, vous devriez généralement vérifier la stabilité numérique de votre algorithme, ou simplement passer à une représentation plus précise.
Et si vous n'avez pas de risque avéré de débordement/débordement, vous ne vous inquiétez de rien. Cela signifie que vous littéralement devez prouver que vous en avez besoin, avec des chiffres, dans des commentaires à côté du code qui expliquent à un responsable pourquoi c'est nécessaire. En tant qu'ingénieur principal examinant le code d'autrui, si je rencontrais quelqu'un qui faisait des efforts supplémentaires, je n'accepterais rien de moins. C'est un peu l'opposé de l'optimisation prématurée, mais cela aurait généralement la même cause fondamentale - l'obsession du détail qui ne fait aucune différence fonctionnelle.
Je pense que ce ne serait pas une bonne idée de remplacer les multiplications par des divisions, car l'ALU (Arithmetic-Logic Unit) du CPU exécute des algorithmes, bien qu'ils soient implémentés dans le matériel. Des techniques plus sophistiquées sont disponibles dans les processeurs plus récents. Généralement, les processeurs s'efforcent de paralléliser les opérations de paires de bits afin de minimiser les cycles d'horloge requis. Les algorithmes de multiplication peuvent être parallélisés assez efficacement (bien que davantage de transistors soient nécessaires). Les algorithmes de division ne peuvent pas être parallélisés aussi efficacement. Les algorithmes de division les plus efficaces sont assez complexes. Généralement, ils nécessitent plus de cycles d'horloge par bit.
Encapsulez l'arithmétique conditionnelle dans des méthodes et des propriétés significatives. Non seulement une bonne dénomination vous dira ce que "A/B" signifie, la vérification des paramètres et la gestion des erreurs peuvent également bien s'y cacher.
Surtout, comme ces méthodes sont composées dans une logique plus complexe, la complexité extrinsèque reste très gérable.
Je dirais que la substitution par multiplication semble une solution raisonnable car le problème est mal défini.