Exécutez 0.1 + 0.2 dans un shell Python et vous n’obtenez pas 0.3. Vous obtenez 0.30000000000000004. Essayez en JavaScript, Java, Go, C ou Rust et vous retrouvez la même traîne de dix-sept chiffres. Chacun de ces langages fait exactement ce que lui impose le matériel. Un nombre qui demanderait une infinité de chiffres binaires est compressé dans 64 bits, et quelque chose doit céder.

Pourquoi 0.1 ne peut pas être stocké exactement en binaire

Les ordinateurs stockent les nombres à virgule flottante en base 2. En base 2, la fraction 1/10 a un développement périodique, tout comme 1/3 se répète indéfiniment en base 10 (0.3333...). Développé en détail, 0.1 en binaire commence ainsi :

1
0.1₁₀ = 0.000110011001100110011...₂  (se répète indéfiniment)

Aucune troncature à un nombre fini de bits ne retombe exactement sur 0.1. Un “double” IEEE 754 sur 64 bits (1 bit de signe, 11 bits d’exposant, 52 bits de mantisse stockés, 53 bits de précision en comptant le 1 implicite initial) arrondit ce développement infini à la valeur la plus proche qu’il peut représenter, toujours une fraction de la forme J / 2^N. Pour 0.1, cette valeur est :

1
3602879701896397 / 2^55

En décimal, cela donne 0.1000000000000000055511151231257827021181583404541015625. Python vous l’affiche. Chaque extrait de cet article n’utilise que la bibliothèque standard, donc inutile de monter un environnement virtuel pour suivre :

1
2
3
4
>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)
>>> 0.1 == 0.1000000000000000055511151231257827021181583404541015625
True

0.2 porte sa propre erreur d’arrondi, légèrement différente. Additionnez les deux valeurs déjà imparfaites et les erreurs ne s’annulent pas : elles se combinent en un résultat un cran au-dessus du double le plus proche de 0.3. D’où 0.30000000000000004.

Quels nombres la virgule flottante stocke vraiment sans erreur

Tous les décimaux ne sont pas condamnés. Une fraction binaire se termine quand son dénominateur, réduit au minimum, est une puissance de deux. 0.5 vaut 1/2. 0.25 vaut 1/4. 0.125 vaut 1/8. Ceux-là sont exacts :

1
2
3
4
>>> 0.5 + 0.25 == 0.75
True
>>> 0.1 + 0.2 == 0.3
False

Les décimaux que les gens tapent réellement, comme les prix et les pourcentages, n’ont presque jamais un dénominateur puissance de deux. L’erreur de 0.1 + 0.2 devient ainsi le résultat normal de l’arithmétique décimale sur des flottants binaires, et elle s’accumule. Additionnez 0.1 dix fois dans n’importe quel langage qui utilise des doubles, vous n’arrivez pas à 1.0 :

1
2
3
4
5
>>> total = 0.0
>>> for _ in range(10):
...     total += 0.1
>>> total
0.9999999999999999

Dix additions. C’est tout ce qu’il faut. Remarquez que la notation Big O indique la vitesse de croissance de cette boucle avec la taille de l’entrée, pas si le résultat est correct : la justesse ici est une vérification à part, que vous devez faire vous-même.

Comment comparer des nombres à virgule flottante sans risque

Puisque == sur des flottants n’est pas fiable, comparez-les avec une tolérance : vérifiez que deux nombres sont assez proches, pas identiques. Chaque langage courant a son idiome pour ça.

LangageComparaison sûre
Pythonmath.isclose(a, b) — tolérance relative 1e-9 par défaut
JavaScriptMath.abs(a - b) < Number.EPSILON * échelle
JavaMath.abs(a - b) < 1e-9, ou BigDecimal pour le décimal exact
C#Math.Abs(a - b) < 1e-9, ou le type natif decimal
Gomath.Abs(a-b) <= tolérance (aucun helper intégré)

En Python :

1
2
3
4
5
>>> import math
>>> 0.1 + 0.1 + 0.1 == 0.3
False
>>> math.isclose(0.1 + 0.1 + 0.1, 0.3)
True

math.isclose adapte sa tolérance à la grandeur des nombres (rel_tol * max(abs(a), abs(b))), donc le même appel fonctionne à petite et à grande échelle. Son abs_tol par défaut vaut cependant 0.0. Si l’une des deux valeurs peut légitimement être nulle ou très proche de zéro, passez un abs_tol explicite, sinon la comparaison échouera sur des valeurs que vous considéreriez égales.

Le Number.EPSILON de JavaScript (2⁻⁵², environ 2.22e-16) est l’écart entre 1 et le double représentable suivant. Utilisé seul, ce n’est donc un seuil valable que pour des nombres proches de 1. Mettez-le à l’échelle pour tout le reste :

1
2
3
4
5
function nearlyEqual(x, y, scale = 1) {
  return Math.abs(x - y) < Number.EPSILON * scale;
}
nearlyEqual(0.1 + 0.2, 0.3);              // true
nearlyEqual(1000.1 + 1000.2, 2000.3, 2000); // true, l'échelle correspond à la grandeur

Une assertion comme result == 0.3 dans un test est un bug qui attend une entrée, une plateforme ou un compilateur différent pour se révéler. Qu’il s’agisse d’un test unitaire ou de quelque chose situé plus haut dans la pyramide de tests, remplacez la comparaison exacte par une vérification avec tolérance.

Quand la virgule flottante est le mauvais outil : l’argent et les comptages

Une tolérance corrige la comparaison. Elle ne corrige pas le stockage. Si vous additionnez des prix ou tenez un solde cumulé, l’erreur d’arrondi est le problème lui-même, et la solution consiste à arrêter d’utiliser float/double pour cette valeur.

La version la plus simple : stockez l’argent comme un entier exprimé dans la plus petite unité. 19,99 € est l’entier 1999 centimes, pas le flottant 19.99. L’addition et la soustraction entre entiers n’ont aucune erreur d’arrondi. Ne convertissez en chaîne décimale qu’au moment d’afficher la valeur.

Quand il vous faut de vraies fractions décimales avec des règles d’arrondi exactes (une comptabilité, un calcul de taxes, tout ce qui doit se réconcilier au centime), utilisez un type décimal :

1
2
3
>>> from decimal import Decimal
>>> Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
True

Java a BigDecimal pour le même usage ; C# a un type decimal natif sur 128 bits avec environ 28-29 chiffres décimaux significatifs. Les trois stockent les chiffres en base 10, donc les fractions décimales que les gens écrivent sont conservées sans arrondi. Cela se paie en vitesse et en verbosité. Les types décimaux sont plus lents que les flottants natifs et exigent un mode d’arrondi explicite pour la division. Ça vaut le coût pour le solde d’une banque. C’est un mauvais calcul pour une boucle numérique intensive.

Quand l’imprécision de la virgule flottante ne compte pas

Les transformations graphiques, les simulations physiques, les poids des modèles de machine learning et le traitement audio tournent sur des flottants exprès. Une erreur relative de l’ordre de 1e-16 est largement sous ce que ces domaines peuvent percevoir, et le matériel natif pour les flottants est nettement plus rapide que l’arithmétique décimale.

La question à se poser sur un nombre n’est donc pas s’il a une virgule. Demandez-vous si vous comptez des choses discrètes et exactes, comme de l’argent ou le résultat attendu d’un test, ou si vous mesurez une grandeur continue qui était approximative dès le départ, comme la position d’un pixel ou la lecture d’un capteur. Les valeurs comptées ont besoin d’entiers ou d’un type décimal. Les valeurs mesurées sont exactement ce pour quoi la virgule flottante a été conçue, et les faire passer en Decimal les rend plus lentes sans les rendre réellement plus justes.

La prochaine fois que vous voyez un champ de solde déclaré en float ou un test qui affirme result == 0.3, décidez de quel type de nombre il s’agit et corrigez le type de stockage ou la comparaison dès cette revue, avant qu’un centime manquant n’apparaisse en production.

Articles associés