web-dev-qa-db-fra.com

Un exemple réaliste où l’utilisation de BigDecimal pour la monnaie est strictement préférable à l’utilisation de double

Nous savons que l'utilisation de double pour la devise est sujette aux erreurs et n'est pas recommandée. Cependant, je ne vois pas encore d'exemple réaliste, où BigDecimal fonctionne tandis que double échoue et ne peut pas être simplement corrigé par un arrondi.


Notez que des problèmes triviaux 

double total = 0.0;
for (int i = 0; i < 10; i++) total += 0.1;
for (int i = 0; i < 10; i++) total -= 0.1;
assertTrue(total == 0.0);

ne comptez pas car ils sont résolus de manière triviale en arrondissant (dans cet exemple, une valeur comprise entre zéro et seize décimales suffirait).


Les calculs impliquant la somme de grandes valeurs peuvent nécessiter une transition intermédiaire, mais étant donné que monnaie totale en circulation étant USD 1e12, Java double (c'est-à-dire que la norme double précision IEEE ) avec ses 15 chiffres décimaux est événement suffisant pour centimes.


Les calculs impliquant une division sont en général imprécis même avec BigDecimal. Je peux construire un calcul qui ne peut pas être effectué avec doubles, mais peut être effectué avec BigDecimal en utilisant une échelle de 100, mais ce n'est pas quelque chose que vous pouvez rencontrer en réalité.


Je ne prétends pas qu'un tel réaliste exemple n'existe pas, c'est juste que je ne l'ai pas encore vu.

Je conviens également que l’utilisation de double est plus sujette aux erreurs.

Exemple

Ce que je recherche, c’est une méthode comme celle-ci (basée sur la réponse de Roland Illig)

/** 
  * Given an input which has three decimal places,
  * round it to two decimal places using HALF_EVEN.
*/
BigDecimal roundToTwoPlaces(BigDecimal n) {
    // To make sure, that the input has three decimal places.
    checkArgument(n.scale() == 3);
    return n.round(new MathContext(2, RoundingMode.HALF_EVEN));
}

avec un test comme

public void testRoundToTwoPlaces() {
    final BigDecimal n = new BigDecimal("0.615");
    final BigDecimal expected = new BigDecimal("0.62");
    final BigDecimal actual = roundToTwoPlaces(n);
    Assert.assertEquals(expected, actual);
}

Lorsque cela est réécrit naïvement à l'aide de double, le test peut échouer (ce n'est pas le cas pour l'entrée donnée, mais pour les autres). Cependant, cela peut être fait correctement:

static double roundToTwoPlaces(double n) {
    final long m = Math.round(1000.0 * n);
    final double x = 0.1 * m;
    final long r = (long) Math.rint(x);
    return r / 100.0;
}

C'est moche et sujet aux erreurs (et peut probablement être simplifié), mais il peut être facilement encapsulé quelque part. C'est pourquoi je cherche plus de réponses.

35
maaartinus

Lorsque vous arrondissez double price = 0.615 à deux décimales, vous obtenez 0,61 (arrondi au bas), mais vous attendez probablement 0,62 (arrondi au plus, en raison des 5).

En effet, le double 0,615 correspond en réalité à 0,614999999999999999911112258029987476766109466552734375.

18
Roland Illig

Les principaux problèmes que vous rencontrez dans la pratique sont liés au fait que round(a) + round(b) n’est pas nécessairement égal à round(a+b). En utilisant BigDecimal, vous maîtrisez parfaitement le processus d’arrondi et vous pouvez donc faire sortir vos sommes correctement.

Lorsque vous calculez les taxes, par exemple, la TVA à 18%, il est facile d’obtenir des valeurs qui ont plus de deux décimales quand elles sont représentées exactement. Alors arrondir devient un problème.

Supposons que vous achetiez 2 articles à 1,3 $ chacun

Article  Price  Price+VAT (exact)  Price+VAT (rounded)
A        1.3    1.534              1.53
B        1.3    1.534              1.53
sum      2.6    3.068              3.06
exact rounded   3.07

Ainsi, si vous effectuez les calculs avec un double et un tour pour imprimer le résultat, vous obtiendrez un total de 3,07, tandis que le montant de la facture devrait en réalité être de 3,06.

11
Henry

Donnons ici une réponse «moins technique, plus philosophique»: pourquoi pensez-vous que «Cobol» n’utilise pas l’arithmétique en virgule flottante pour traiter les devises?!

("Cobol" entre guillemets, comme dans: approches existantes pour résoudre les problèmes du monde réel).

Signification: il y a près de 50 ans, lorsque les gens ont commencé à utiliser des ordinateurs pour leur travail dans le domaine financier, ils ont vite compris que la représentation "en virgule flottante" ne fonctionnait pas pour le secteur financier (attendez-vous peut-être à des créneaux rares comme l'indique la question ). 

Et gardez à l’esprit: jadis, les {abstractions} étaient vraiment chères! C'était assez cher d'avoir un peu ici et un registre là-bas; et pourtant, il devient vite évident pour les géants sur les épaules desquels nous nous tenons… que l'utilisation de "points flottants" ne résoudrait pas leurs problèmes; et qu'ils devaient compter sur autre chose; plus abstrait - plus cher!

Notre industrie avait plus de 50 ans pour trouver un «point flottant qui fonctionne pour la monnaie» - et le commun la réponse est toujours: ne le fait pas. Au lieu de cela, vous vous tournerez vers des solutions telles que BigDecimal. 

9
GhostCat

Supposons que vous ayez 1000000000001,5 (c'est dans la gamme 1e12) de l'argent. Et vous devez en calculer 117%.

En double, il devient 1170000000001.7549 (ce nombre est déjà imprécis ). Appliquez ensuite votre algorithme round, et il devient 1170000000001.75.

En arithmétique précise, il devient 1170000000001.7550, arrondi à 1170000000001.76. Aïe, vous avez perdu 1 cent.

Je pense que c'est un exemple réaliste, où double est inférieur à l'arithmétique précise.

Bien sûr, vous pouvez résoudre ce problème d’une manière ou d’une autre (même, vous pouvez implémenter BigDecimal en utilisant le double arihmetic, donc d’une certaine manière, le double peut être utilisé pour tout, et ce sera précis), mais quel est le but?

Vous pouvez utiliser double pour l'arithmétique entière, si les nombres sont inférieurs à 2 ^ 53. Si vous pouvez exprimer vos calculs avec ces contraintes, le calcul sera précis (la division nécessite des précautions particulières, bien sûr). Dès que vous quittez ce territoire, vos calculs peuvent être imprécis.

Comme vous pouvez le constater, 53 bits ne suffisent pas, double ne suffit pas . Mais, si vous stockez de l'argent sous forme de nombre décimal à virgule fixe (je veux dire, stockez le nombre money*100, si vous avez besoin d'une précision en cents), 64 bits pourraient alors suffire, vous pouvez donc utiliser un entier 64 bits au lieu de BigDecimal.

3
geza

Ligne du bas à l'avant:

Exemple réaliste simple où double échoue: 

Tous les types numériques plus grands peuvent être parfaitement simulés par des types numériques plus petits en utilisant des listes de types de nombres plus petits et en conservant un enregistrement d'éléments tels que le signe et la décimale. Ainsi, un type numérique n'échoue que lorsque son utilisation équivaut à une complexité de code plus élevée et/ou à une vitesse plus lente. 

BigDecimal ne diminue pas beaucoup la complexité du code lorsque vous savez comment gérer les multiplications et divisions double pour éviter un débordement. Cependant, il peut y avoir des situations où BigDecimal est potentiellement plus rapide que double.

Cependant, il ne devrait y avoir aucun cas où il est strictly meilleur (au sens mathématique) que double. Pourquoi? Parce que les calculs double sont implémentés sous forme d'opérations unitaires dans les processeurs modernes (au cours d'un cycle), tout calcul efficace à virgule flottante de grande précision, à sa base, utilise une sorte de type numérique double-esque ou est plus lent que optimal. 

En d'autres termes, si un double est une brique, un BigDecimal est une pile de briques. 



Alors, commençons par définir ce que «mauvais» signifie dans le contexte de «double est mauvais pour l’analyse financière».


Un nombre à virgule flottante double est une liste d'états binaires. Ainsi, si vous n’avez accès qu’à des classes et à des entiers 32 bits, vous pouvez "recréer" un double simplement en enregistrant la position du signe décimal, du signe, etc. et en maintenant une liste d’entiers. 

L'inconvénient de ce processus est que vous auriez une base de code beaucoup plus complexe et buggée pour gérer cela. De plus, double étant égal à la taille de mot d'un processeur 64 bits, les calculs seront donc plus lents avec votre classe contenant une liste d'entiers. 



Maintenant, les ordinateurs sont très rapides. Et à moins d'écrire du code bâclé, vous ne remarquerez pas la différence entre double et votre classe avec sa liste d'entiers pour les opérations O(n) (une pour la boucle).

Ainsi, le principal problème ici est la complexité du code écrit (complexité de l'utilisation, de la lecture, etc.). 



Puisque la complexité du code est l’enjeu principal, envisagez une situation financière dans laquelle vous multipliez les fractions de nombreuses fois. 

Cela peut provoquer underflow , qui correspond à l’erreur d’arrondi dont vous parlez. 

Le correctif pour le flux inférieur est de prendre le journal: 

// small numbers a and b
double a = ...
double b = ...

double underflowed_number = a*pow(b,15); // this is potentially an inaccurate calculation. 

double accurate_number = pow(e,log(a) + 15*log(b)); // this is accurate

Maintenant, la question est: est-ce trop complexe à gérer pour vous? 

Ou, mieux encore: est-ce trop complexe à gérer pour vos collègues? Est-ce que quelqu'un va venir nous dire: "Waouh, ça a l'air vraiment inefficace, je vais juste le remettre en a*pow(b,15)"?

Si oui, utilisez simplement BigDecimal; Sinon: double, à l'exception du calcul du sous-dépassement, sera plus léger en termes d'utilisation et de syntaxe ... et la complexité du code écrit ne représente pas un gros problème de toute façon. 


Avec une mise en garde: si vous effectuez des calculs significatifs impliquant la solution de contournement du sous-flux dans un contexte de calcul complexe, tel qu'une boucle imbriquée sur un sous-programme interne exécuté sur le back-end d'une banque, vous devez alors tester avec BigDecimal, plus rapide.

Donc, la réponse à votre question: 

// at some point, for some large_number this *might* be slower, 
// depending on hardware, and should be tested:
for (i=1; i<large_number; i++){
    for(j=1;j<large_number;j++){
        for(k=1;k<large_number;k++){
            // switched log to base 2 for speed
            double n = pow(2,log2(a) + 15*log2(b));
        }
    }
}

// this *might* be faster:
for (i=1; i<large_number; i++){
    for(j=1;j<large_number;j++){
        for(k=1;k<large_number;k++){
            BigDecimal n = a * pow(b,15);
        }
    }
}

Je vais ajouter un complot asymptotique si j'en ai le temps. 

0
bordeo

Ce qui suit semble être une mise en œuvre décente d'une méthode qui devait "arrondir au cent près". 

private static double roundDowntoPenny(double d ) {
    double e = d * 100;
    return ((int)e) / 100.0;
}

Cependant, le résultat de ce qui suit montre que le comportement ne correspond pas à ce que nous attendons.

public static void main(String[] args) {
    System.out.println(roundDowntoPenny(10.30001));
    System.out.println(roundDowntoPenny(10.3000));
    System.out.println(roundDowntoPenny(10.20001));
    System.out.println(roundDowntoPenny(10.2000));
}

Sortie:

10.3
10.3
10.2
10.19 // Not expected!

Bien sûr, une méthode peut être écrite qui produit le résultat souhaité. Le problème est qu’il est en réalité très difficile de le faire (et de le faire partout où il faut manipuler les prix).

Pour chaque système numérique (base-10, base-2, base-16, etc.) avec un nombre fini de chiffres, il existe des logiques qui ne peuvent pas être stockés exactement. Par exemple, 1/3 ne peut pas être stocké (avec des chiffres finis) en base 10. De même, 3/10 ne peuvent pas être stockés (avec des chiffres finis) en base-2. 

Si nous devions choisir un système numérique pour stocker des éléments rationnels arbitraires, le système choisi importerait peu - tout système choisi aurait des éléments rationnels qui ne pourraient pas être stockés exactement.

Cependant, les humains ont commencé à attribuer des prix bien avant le développement des systèmes informatiques. Par conséquent, nous voyons des prix comme 5.30 plutôt que 5 + 1/3. Par exemple, nos bourses utilisent des prix décimaux, ce qui signifie qu’elles acceptent les ordres et n’émettent des cours que pour les prix pouvant être représentés en base 10. De même, cela signifie qu'ils peuvent émettre des devis et accepter des commandes dont les prix ne peuvent pas être représentés avec précision en base 2.

En stockant (transmettant, manipulant) ces prix en base 2, nous nous appuyons essentiellement sur la logique d’arrondi pour toujours arrondir correctement nos chiffres (représentation) de base 2 à la base 10 exacte. 

0

L’utilisation de BigDecimal serait particulièrement utile pour les formes de monnaie numériques à valeur élevée, telles que la cyprtomonnaie (BTC, LTC, etc.), les actions, etc. 8 chiffres significatifs. Si votre code provoque accidentellement une erreur d’arrondi à 3 ou 4 chiffres, les pertes peuvent alors être extrêmement importantes. Perdre de l'argent à cause d'une erreur d'arrondi ne va pas être amusant, surtout si c'est pour les clients. 

Bien sûr, vous pourriez probablement utiliser un double pour tout si vous vous assuriez de tout faire correctement, mais il serait probablement préférable de ne pas prendre de risque, surtout si vous partez de zéro. 

0
Ryan - Llaver