Une histoire de précision

Ecrit le , 4 minutes de bouquinage

Hier (05.01.2020), j'ai eu l'agréable surprise d'avoir un bug (qui n'en est pas un, on verra) pour le moins surprenant au premier abord. Mais avant de parler un peu de quoi il s'agit, plantons le décor au bon endroit.

Le décor

Pour les besoins d'un projet encore à ses débuts, je dois manipuler des grands nombres qui servent d'identifiants en base. Ces identifiants sont sur 64 bits. Si vous avez l'oreille, vous remarquerez certainement que j'utilise des snowflakes (exemple: 133943881938112512). PostgreSQL prend mes IDs sans soucis. PHP peut les traiter sans soucis. Par contre en Javascript, c'est là que commencent les problèmes...

Flottez flottants !

En Javascript, les variables Number sont en fait des nombres à virgule flottante 64 bits (doc MDN sur les Number) ou float64. Dans la mémoire d'un ordinateur, les nombres flottants sont stockés de façon un peu particulière comme décrit dans l'IEEE 754:

  • 1 bit pour le signe
  • 11 bits pour un exposant biaisé1
  • 52 bits pour la partie fraction du nombre. Le nombre 52 sera important juste après.

Je vous épargne les détails de maths par rapport à la conversion du nombre que je désire stocker vers une représentation hexadécimale parce que c'est pas le but de cet article. Ce qu'il faut savoir, c'est qu'un nombre à virgule flottante est normalisé de telle manière à ne stocker que la partie fractionnaire. Puis tous les éléments sont mis dans l'ordre décrit ci-dessus.

133943881938112512 donne, en binaire, un entier de 57 bits. Il n'y a pas besoin d'être un fou furieux de maths pour constater que 57 bits ne rentreront pas dans la partie fraction de 52 bits d'un float64. L'excédant est simplement perdu et arrondi à la décimale la plus proche pour que ça rentre dans 52 bits. C'est ainsi qu'on arrive avec 0.666666...667 sur les calculatrices. Le 6 périodique qui dépasse est arrondi à la décimale la plus proche.

Le problème...

Le problème ? La perte de précision et l'arrondi qui va avec. En effet, au lieu d'avoir le nombre attendu pour la suite du traitement, j'ai eu 133943881938112510. Sauf que... Ce nombre, qui est supposé être un identifiant, est utilisé dans une requête. Ce même nombre, n'existe pas dans la table qui met en rapport les utilisateurs et les "groupes". Il est donc normal que la base ne retourne pas de ligne et que le script plante lors de l'exécution.

Cette perte de précision peut paraître absurde au premier abord: comment tu veux perdre en précision sur un entier ? Elle prend tout son sens quand on découvre que les Number sont en réalité stockés comme des float64 , peu importe si c'est un entier ou un nombre décimal.

Le mot de la fin

Il s'agit de cette catégorie de bugs difficilement explicables ou sur lesquels on peut bloquer pendant un moment si on ne comprend pas comment les nombres sont stockés. Dans le cas de Javascript, comme dit précédemment, les objets Number sont des float64 peut importe que ce soient des entiers ou des nombres décimaux avec les problèmes que ça amène sur des gros nombres.

Je sais qu'il existe une classe BigInt que je pourrais utiliser pour stocker des entiers avec une précision arbitraire. J'ai préféré refaire le serveur Websocket en Python qui peut facilement désérialiser des JSON sans perdre de précision sur des grands nombres.


1

L'exposant biaisé prend l'exposant du nombre en représentation scientifique et ajoute un coefficient à cet exposant. 512.128 peut être représenté comme 5.12128 × 102. Le coefficient pour un float64 est de 1023. Donc l'exposant biaisé est 1025.