Problèmes courants: Imprécision des calculs mathématiques (1ère partie)

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

J'inaugure aujourd'hui une nouvelle chronique que j'ai appelée problèmes courants. J'y traiterai l'une après l'autre les erreurs classiques rencontrées à travers mes années d'informatique.

Ce premier article de la série visera à démystifier les calculs mathématiques et à établir de bonnes pratiques au sein d'une application d'entreprise. Par application d'entreprise, nous entendons une application gérant des montants d'argent, des prix, des quantités. Il a été coupé en deux, la première partie expliquant le problème, la deuxième montrant comment le gérer en Java et .Net.

Bill travaille sur un logiciel de paiement de commissions. Il doit ajouter une commission de 1,2$. Il se fait donc une petite méthode faisant cette opération et le petit test unitaire qui va avec.

@Test
 public void testAddCommission() {
  double actual = addCommission(1000000.1);
  assertEquals(1000001.3, actual, 0);
 }

 public static double addCommission(double nominal) {
  return nominal + 1.2f;
 }

java.lang.AssertionError: expected:<1000001.3> but was:<1000001.3000000477>

"Ah ben zut alors! C'est pas le bon résultat".

Qu'est-ce qui se passe?

Virgules flottantes vs décimaux

Les nombres à virgule flottante (floating point numbers) ont été introduits pour des raisons de performance (et uniquement pour ça) dans les ordinateurs. Ils sont devenus omniprésents depuis l'arrivée du Intel 80486 et son coprocesseur de virgules flottantes. Cela permet de faire une multiplication ou une division en 1 cycle de processeur. Il s'agit des types float, double et quad (leur précision respective dépend du langage mais chacun double la précision du précédent).

Avec des décimaux, c'est plus lent (en gros autant d'opérations que quand vous posiez une multiplication au primaire, sauf qu’heureusement un ordinateur va plus vite et fait moins de fautes...). Par décimaux nous entendons le type decimal (en .Net) et BigDecimal (en Java).

Pourquoi utilisez de décimaux dans ce cas me direz-vous? Car ils n'utilisent pas la même représentation.

Dans les deux cas, nous avons une mantisse et un exposant. Toutefois, la mantisse d'un décimal est un entier. Celle de la virgule flottante, un nombre entre 0 et 1 en base 2. Il faut bien comprendre que les chiffres à droite de la virgule sont donc puissance de fraction de 2, car nous sommes en base 2. Par exemple, 0,1 en base 2 vaut 0,5 en base 10.

Un autre exemple: 7.5, s'écrit 75E-1 en décimal, la mantisse est de 75 et l'exposant sera -1. Pour la virgule flottante, nous aurons 0.75E1 en base 10, ce qui donne 0.11 en base 2 ((1/2)^1 + (1/2)^2) pour la mantisse et 1 pour l'exposant. En gros, il s'agit d'une série de puissance d'1/2 au lieu d'un série de puissance de 1/10. C'est ce changement de représentation qui permet des calculs si rapide, car comme on le sait, les ordinateurs aiment bien le binaire.

C'est un problème de base

Les problèmes surviennent quand le nombre se représente parfaitement en base 10, mais ne peut l'être en base 2. L'exemple courant est 0.1. Sa valeur en base 2 est périodique (0.000110011001100...). Nous ne pouvons par le représenter précisément. La norme IEEE 754 régissant l'arithmétique des nombres à virgule flottante s'efforce donc de les exprimer au mieux et de gérer les arrondis, mais la perte de précision reste.

Il est très important de comprendre que les problèmes dont on parle sont des problèmes de représentation et non pas des problèmes de précision. Les problèmes de précision, on sait les gérer. Il suffit d'augmenter la précision. La précision n'est pas suffisante dans un double et hop, on passe à un quad. Mais notre 0,1 ne pourra toujours pas être représenté précisément. À noter, ce n'est pas dépendant du langage. Tous les représentent de la même façon et utilisent le coprocesseur de virgules flottantes pour leurs calculs.

L'exemple de 0.1 en plus détaillé:

float f = 0.1f;
System.out.println(f); // affiche 0.1
BigDecimal d = new BigDecimal(f);
System.out.println(d); // affiche 0.100000001490116119384765625

On pourrait croire que le passage vers le BigDecimal détruit le chiffre. Et non. Pour compliquer l'histoire, c'est l'affichage du float qui est faux. La vraie valeur du float est celle affichée par le BigDecimal. Mais l'algorithme d'affichage des virgules flottantes est d'afficher autant de bits que nécessaire pour faire la différence entre deux valeurs adjacentes. Donc, en fait, l'affichage tronque la vraie valeur.

Conclusion

Comme nous avons brièvement vu plus haut, le type décimal est différent. Il est composé d'une mantisse et d'une échelle, mais la mantisse est sous forme d'entier. La valeur décrite est donc dans tous les cas parfaitement représentée et n'a pas besoin d'être arrondie. Cette précision est essentielle dans le cadre d'une application manipulant des montants d'argent par exemple.

D'ailleurs, nos ancêtres le savaient. Aucun programmeur Cobol de l'époque n'utiliserait une virgule flottante. Malheureusement, le savoir s'est perdu en chemin. Le plus cuisant exemple est Java qui n'avait même pas de type décimal (BigDecimal) lors de sa création.

Cette erreur a été réparé et en prime, le BigDecimal java n'a virtuellement pas de restriction de taille (et donc de problème de précision). Le decimal .Net est de son côté sur 128 bits. C'est largement suffisant pour prévenir les erreurs de précision étant donnée l'échelle des montants des applications d'entreprise.

Première règle: Ne jamais utiliser de nombres à virgule flottante dans vos applications de type entreprise. Dans aucun cas! Même pour vos littéraux. Mettez une règle Checkstyle s'assurant que personne ne les utilise.

Il y a bien sûr des exceptions à cette règle (performance), mais elles sont rarissimes.

La suite de cet article détaillera l'usage des types décimaux (BigDecimal et decimal) en Java et .Net. En effet, même s'ils préviennent les problèmes de représentation, d'autres erreurs vous attendent à l'orée du bois.