Problèmes courants: Imprécision des calculs mathématiques (2e partie)

le 15/07/2010 par Henri Tremblay
Tags: Software Engineering

Nous avons déterminé dans la première partie que les nombres à virgule flottante sont à proscrire.

Nos armes seront donc le BigDecimal en Java, le type decimal en .Net. Malheureusement, d'autres pièges pavent notre chemin.

Notes:

  • Sous Oracle, le type NUMBER(p,s) peut être soit décimal si p (et optionnellement s) est spécifié et sera à virgule flottante sinon. Conclusion, toujours spécifier p (et s pour avoir des décimales).
  • Pour un Web Service, la valeur d'un type xs:decimal sera sous forme texte (ie. "123.456") et sera donc précis et mappé sans problème vers un BigDecimal (Java) ou decimal (.Net).

Utilisation des décimaux

Littéraux

J'ai souvent le commentaire suivant: "J'aime pas les BigDecimal, on ne peut pas utiliser les opérateurs du langage avec". C'est vrai, mais en attendant que Java supporte la surcharge d'opérateur (et c'est pas demain la veille), vous n'avez pas le choix, il faut utiliser les méthodes de la classe BigDecimal.

Le respect de la première règle ci-haut proscrit les choses suivantes:

BigDecimal d = new BigDecimal(1.0); // initialisation par un double
BigDecimal d = BigDecimal.valueOf(1.0); // aussi une initialisation par un double
boolean b = (d.doubleValue() == 0.0); // comparaison avec un double
BigDecimal d = BigDecimal.valueOf(d1.doubleValue() * d2.doubleValue()); // passage par un double pour calcul mathématique

remplacer tout cela par

BigDecimal d = new BigDecimal("1.0"); // crée très précisément un BigDecimal de mantisse 10 et d'échelle 1
BigDecimal d = BigDecimal.valueOf(10, 1); // Même résultat que la ligne précédente
boolean b = (d.signum() == 0); // signum compare avec zéro et retourne -1, 0 ou 1. Pour une valeur différente de zéro, faire un compareTo
BigDecimal d = d1.multiply(d2); // tout le calcul se fait en type décimal

Échelle

L'échelle (scale) indique le nombre de chiffres à droite de la virgule. Il faut bien comprendre que deux chiffres apparemment identiques peuvent être en fait différents de par leur échelle. C'est pour cette raison qu'en Java, dans l'exemple ci-dessous, b sera faux

BigDecimal d1 = new BigDecimal("1.0");
BigDecimal d2 = new BigDecimal("1.00");
boolean b = d1.equals(d2); // false

L'échelle a aussi un impact lors d'opérations mathématiques.

Note: L'affichage d'un BigDecimal (d.toString()) affichera toujours l'échelle en entier et non uniquement les zéros significatifs.

Additions et soustractions

L'échelle du résultat d'une addition ou soustraction sera la plus grosse échelle des deux opérandes.

BigDecimal d1 = new BigDecimal("1.0"); // échelle de 1
BigDecimal d2 = new BigDecimal("2.00"); // échelle de 2
BigDecimal r = d1.add(d2); // résultat d'échelle 2
System.out.println(r); // affiche 3.00

Multiplications

L'échelle du résultat d'une multiplication sera la somme des échelles des opérandes (car mathématiquement, le résultat ne sera jamais sur plus).

BigDecimal d1 = new BigDecimal("1.0"); // échelle de 1
BigDecimal d2 = new BigDecimal("2.00"); // échelle de 2
BigDecimal r = d1.multiply(d2); // résultat d'échelle 3
System.out.println(r); // affiche 2.000

Divisions

Tout se complique pour les divisions. En effet, l'échelle théorique du résultat d'une division est potentiellement infinie (si le résultat de la division est un nombre périodique par exemple). C'est très différent de l'arithmétique en virgules flottantes qui stockera, dans sa taille finie, la valeur la plus proche du résultat réel. Vous aurez donc les résultats suivants:

BigDecimal d1 = new BigDecimal("1.0");
BigDecimal d2 = new BigDecimal("3.0");
System.out.println(d1.divide(d2, RoundingMode.HALF_UP)); // affiche 0.3
System.out.println(d1.divide(d2, 4, RoundingMode.HALF_UP)); // affiche 0.3333
System.out.println(d1.divide(d2)); // ArithmeticException

Si la règle d'arrondi n'est pas spécifié, comme le BigDecimal ne peut décider comment arrondir par lui-même, il retourne une exception *et c'est une bonne chose*. En effet, il ne faut jamais arrondir au hasard. Si vous ne savez pas comment arrondir, demandez à votre MOA. L'arrondi est systématiquement lié à une règle métier (ex.: arrondir à la précision de la monnaie). Je vous recommande d'ailleurs de mettre dans vos tests unitaires des chiffres testant l'arrondi pour prévenir les problèmes.

Comparaison

C'est l'un des comportements laissant le plus perplexes les développeurs. Nous en avons parlé plus haut, le equals du BigDecimal compare aussi l'échelle. Il ne faut donc jamais l'utiliser si l'on souhaite comparer la valeur du BigDecimal. C'est là où compareTo vient à notre secours. Il se comporte comme nous le voudrions intuitivement.

BigDecimal d1 = new BigDecimal("1.0");
BigDecimal d2 = new BigDecimal("1.00");
int i = d1.compareTo(d2); // 0, ce qui veut dire de même valeur selon le contrat de compareTo

À noter: Comparativement à la comparaison entre nombres à virgule flottante, il n'est pas nécessaire d'ajouter un delta lors des comparaisons. Le décimal étant toujours précis, la comparaison sans delta le sera donc aussi.

Conversion

Il faut prendre quelques précautions lors de la conversion d'une BigDecimal vers une valeur entière. En effet, celui-ci arrondira à l'unité. Il est souvent plus sécuritaire de vérifier s'il y a des décimales lors de la conversion. Les méthodes xxxValueExact() ont donc été mise à disposition pour faire très exactement ceci. Une ArithmeticException sera lancée si une conversion sans arrondi est impossible.

BigDecimal d1 = new BigDecimal("1.1");
System.out.println("i: " + d1.intValue()); // affiche 1
System.out.println(d1.intValueExact()); // lance une ArithmeticException

.NET

L'article s'est pour l'instant beaucoup penché sur Java. L'équivalent .Net du BigDecimal est le decimal. De même qu'en Java, il doit être utilisé en tout temps au lieu du float et double.

Littéraux

Le type decimal étant un type primitif de .Net, C# fournit une syntaxe littérale pour ce dernier (suffixe m). Les zéros non significatifs sont utiles pour déterminer l'échelle.

decimal d = 99.9m; // le m indiquant qu'il s'agit d'un décimal
decimal b = 9.00m; // decimal d'échelle 2

Il est d'ailleurs judicieusement nécessaire de convertir explicitement d'une virgule flottante à un décimal.

decimal a = (decimal) 9.0; // conversion explicite nécessaire et perte de précision potentielle. À ne pas faire!

Calculs mathématiques

Être un type primitif a d'autres avantages, les calculs mathématiques sont plus élégants qu'en Java.

decimal d = 1.0m * 2.0m; // +, -, /, * et % sont utilisables
decimal b = decimal.Multiply(1.0m, 2.0m); // l'équivalent en méthode statique est aussi disponible

La division n'a pas besoin et ne peut avoir de règle d'arrondi. Elle fera au mieux dans les 96 bits d'espace. Il ne faut toutefois pas oublier qu'un arrondi sera éventuellement nécessaire et sera comme toujours guidé par une règle métier.

Comparaison

Comparativement à Java, le compare et le equals donne le même résultat peut importe l'échelle. C'est beaucoup plus intuitif, mais implique qu'il est impossible de comparer en voulant considérer l'échelle.

Console.WriteLine(decimal.Compare(1.00m, 1.0m)); // affiche 0, donc égal
Console.WriteLine(1.0m.CompareTo(1.00m)); // autre façon de comparer, retourne aussi 0
Console.WriteLine(1.0m.Equals(1.00m)); // affiche true

Il est d'ailleurs impossible de connaître l'échelle d'un décimal facilement en .Net. Pour avoir l'échelle, il faut faire ceci:

int scale = (System.Decimal.GetBits(monDecimal)[3] >> 16) & 31; // oui oui, ça s'invente pas

ou passer par un ToString, trouver le séparateur décimal (en fonction de la culture) et compter le nombre de chiffres à sa droite.

Conversion

Au niveau conversion, il existe des méthodes ToXXX permettant la conversion vers un autre type. Le concept de ToXXXExact n'existe pas. Il faut donc s'assurer au préalable qu'il n'y aura pas de perte de précision. Une solution possible est ceci:

decimal toInt = 1.1m;
int i = decimal.ToInt32(toInt);
Console.WriteLine(toInt.Equals(new decimal(i))); // n'est pas égal, car nous avons perdu les décimales

Conclusion

Le but n'est pas de comparer, mais vous l'aurez compris

  • La version Java demande de bien comprendre son implémentation, mais sévi en cas de mauvaise utilisation
  • La version .Net se veut plus conviviale, mais est donc plus permissive et rend plus compliqués certaines opérations.

Mais peu importe le langage, les choses importantes à retenir se résument à

  • Ne pas utiliser de types à virgule flottante pour représenter des décimaux
  • Avoir en tête qu'en informatique de gestion tous les nombres ou presque sont des décimaux
  • Penser aux arrondis et les faire suivant une règle métier
  • Bien avoir en tête les spécificités du langage avant utilisation

Une seule nuance à ces règles. Il est possible que pour des raisons de performance vous deviez utiliser des nombres à virgule flottante. Par exemple, dans le cadre du simulation de Monte-Carlo. Je ne peux dans ce cas que vous recommander de bien vous documenter sur la manipulation des nombres à virgule flottante. Il existe de nombreuses méthodes pour minimiser les problèmes mais le tout reste relativement complexe et sors du cadre de cet article.  Ne les utilisez donc qu'en dernier recours.

En conclusion, appliquez ces règles religieusement et vous devriez être parés pour éviter les soucis de calcul de vos futures applications d'entreprise. Je vous souhaite de précis calculs et vous dis à la prochaine.