Si vous êtes développeur, vous connaissez très bien les types de base de variable : integer, boolean, character, string, float.. Rassurez-vous, on ne va pas revenir sur tous ces types. Mais, ce fameux float par exemple, vous êtes-vous déjà demandé ce qu’il voulait vraiment dire ou comment il pouvait être représenté ?
Non ?
Et si je vous disais que le float gagne à être connu et qu’il est important d’en savoir plus sur lui ? Laissez-moi vous exposer le fond de ma pensée avec quelques cas concrets.
Le commencement : pourquoi un article sur les flottants ?
Ainsi commence mon histoire. Un jour, je rentre paisiblement de vacances, et en me baladant sur le Slack REDLab, je vois le message d’un collègue, qui tient à peu près ce langage :
“Ouais, Python, c’est naze, il sait pas faire des calculs simples ! Alors qu’en C++ ça marche !”
Le message s’accompagne de la ligne de code suivante :
>>> 2.2*3
6.6000000000000005
Alors que le test en C++, lui, affiche fièrement un “6,6” bien rond… Moi, fervent défenseur du Python, une question commence à me tarauder : « est-ce que mon collègue a raison ? ».
Resituons le problème dans un contexte plus large. De votre côté, vous avez peut-être parfois rencontré des situations similaires. Quelques exemples :
- vous avez fait un test unitaire, avec une assertion d’égalité entre deux nombres réels, qui n’est pas passée.
- vous avez écrit une condition, du style “float x = 0.1; if (x == 0.1) {…” qui n’a pas été vérifiée, ou bien, selon les langages, “if 0.1 + 0.1 + 0.1 == 0.3: … » (on rediscutera de ces possibles différences)
- si vous avez codé un système antimissile, une erreur de ce type peut être la seule raison pour laquelle vous n’aurez plus l’occasion de faire une autre erreur du même type.
Mais pourquoi tout cela s’est passé ? Essayons de démystifier tout cela.
Une infinité de nombres face à une mémoire limitée ...
Qu’on parle de nombres entiers, ou de nombres décimaux (“nombres à virgule”), il en existe une infinité. Mais ça, je pense que vous le savez déjà.
Lorsqu’on veut travailler avec des nombres en informatique, il faut pouvoir les représenter en mémoire. On a besoin de les stocker dans des “cases”. Mais aussi sophistiqué soit votre ordinateur, sa mémoire est finie. On ne sait pas vraiment si l’univers est fini ou infini, mais qu’importe la réponse, les ordinateurs à mémoire infinie, ce n’est pas encore pour demain, ni même après-demain ! 😉
Cela nous amène à considérer le postulat suivant (et cela va nous donner le ton pour la suite) : il n’est pas possible de représenter tous les nombres (entiers ou décimaux) qui existent de façon exacte dans un ordinateur.
Les nombres entiers... kesako ?
Vous vous posez peut-être la question suivante :
“Mais Robin (ah oui bonjour, moi c’est Robin !), tu viens de dire qu’on ne pouvait pas représenter les entiers, mais par exemple, je n’ai jamais eu de problème en testant 2 + 1 = 3 !”
Et pourtant, n’en perdons pas notre latin, il y a bel et bien un problème avec les nombres entiers, mais le problème est ici de nature différente. Pour l’expliquer, on va devoir aborder quelques notions d’écriture binaire.
Vous savez probablement qu’en informatique, on stocke les nombres en binaire, donc en base 2. Base 2, kesako ? Cela signifie simplement qu’on ne dispose que de 2 chiffres pour écrire, qui sont 0 ou 1. Dans un disque dur, cela peut se traduire physiquement par un champ magnétique positif ou négatif (si vous voulez creuser le sujet, je vous renvoie à cet article ou bien à la page Wikipédia du disque dur).
En décimal (ou en base 10, celle qu’on utilise tous les jours), on dispose comme vous le savez de 10 chiffres, de 0 à 9. Lorsqu’on vous a appris à lire un chiffre à l’école, on vous a probablement appris à le décomposer en unités, dizaines, centaines, etc. Par exemple avec le chiffre 342 :
- 3 centaines
- 4 dizaines
- 2 unités
En fait, en vous apprenant à lire un chiffre, on vous a déjà inculqué quelques notions de calcul. Votre cerveau traduit 342 comme 3 x 100 + 4 x 10 + 2 x 1.
Et si l’on utilise des puissances de 10 pour représenter tout ça, on a 342 = 3 x 10^2 + 4 x 10^1 + 2 x 10^0.
Revenons à notre base 2. Et bien en base 2, c’est exactement la même chose ! On dispose de 2 chiffres différents, et notre base n’est pas 10 mais 2 (incroyable !). C’est-à-dire qu’au lieu de décomposer notre chiffre en multiples de puissances de 10, on va utiliser les puissances de 2. On rappelle rapidement les premières puissances de 2 pour la forme :
- 2^0 = 1
- 2^1 = 2
- 2^2 = 4
- 2^3 = 8
Et ainsi de suite. On pourrait refaire l’exemple de 342, mais ce serait un peu long, donc on va prendre 14. Comment peut-on écrire 14 en additionnant des puissances de 2 ? Sachant pour rappel que l’on ne dispose que des chiffres 0 et 1 auxquels nous allons associer une puissance de 2, chaque puissance ne pouvant donc être utilisée plus d’une fois.
Pour obtenir 14 avec des chiffres associés à des puissances de 2, la seule possibilité (indépendamment de l’ordre dans lequel vous avez additionné les chiffres) est 2 + 4 + 8. Le compte est bon ? Si l’on veut se rapprocher de l’écriture en puissances de 10, on aura donc 14 = 1 x 2^3 + 1 x 2^2 + 1 x 2^1 + 0 x 2^0.
Pour 342, nos chiffres 3, 4 et 2 apparaissaient dans sa décomposition en puissances de 10. En suivant une logique similaire avec la décomposition de 14 en puissances de 2, on a alors la décomposition suivante : 1, 1, 1 et 0. 14 s’écrit donc en binaire “1110”.
Pas trop mal à la tête ? Ce qu’il faut retenir, c’est que si je veux écrire 14 en binaire, il va me falloir 4 cases puisque j’ai 4 chiffres binaires. Mais si je n’en ai que 2, ou 3… alors il sera impossible d’écrire 14.
Creusons encore… Avec 4 cases, ou bits, je ne peux représenter que 2^4 = 16 nombres entiers différents (de 0 à 15 pour des nombres positifs). Et si j’essaie de mettre 16, ou 17, ou plus dans mes 4 cases ? Vous pouvez faire le test, en regardant dans la documentation de votre langage la limite des variables de type integer. Si vous dépassez cette limite, vous allez “faire le tour” et revenir au début. Par exemple, en imaginant qu’on essaie de mettre 16 dans une variable entière positive de 4 bits, on va obtenir 0 : il faut imaginer qu’on est monté jusqu’à 15, puis voyant qu’on allait dépasser la limite on a pris peur et on est revenu à 0. En suivant le même raisonnement, on obtiendrait 1 avec le nombre 17 etc…
Pour conclure avec les nombres entiers, les calculs sont exacts tant qu’on ne dépasse pas la limite. C’est pour ça que vous n’avez jamais eu de souci avec 2+1=3. Mais essayez 1 trillion + 10 trilliards !
“Mais ça Robin, ça n’arrive jamais en pratique !”
Ah oui ? Jetez donc un coup d’oeil au crash d’Ariane 5 et excusez par la même occasion mes exemples un peu morbides.
Et ces fameux flottants alors ?
En informatique, les nombres réels sont représentés par ce qu’on appelle les flottants. Si vous ne savez pas ce que sont les nombres réels, ce sont tous les nombres qu’on utilise : les nombres entiers, les nombres à virgule, et tous les nombres bizarres comme ce fameux π (“pi”) dont certains mathématiciens ont fait une obsession.
En base 10, donc celle qu’on utilise au quotidien sans même s’en rendre compte, il y a des nombres qu’on ne peut pas écrire formellement car leur représentation est infinie, au hasard.. pi ! Ou alors par exemple 1/3, qui s’écrit 0,33333….
En base 2, on a le même problème. Sauf que cela nous semble moins intuitif. Par exemple, le nombre 0,1 d’apparence très simple a une représentation infinie en base 2.
Et comment on fait quand on essaie de mettre une représentation infinie dans un nombre de cases fini ? La réponse est simple et elle a déjà évoquée en mode teaser : on ne peut pas. On va donc être obligé de s’arrêter d’écrire lorsqu’on n’aura plus de place en mémoire, et on n’aura finalement pas écrit notre nombre mais une approximation de celui-ci. Le nombre obtenu est un flottant, qui représente notre nombre réel.
Cela nous éclaire sur notre question initiale qui était “pourquoi ma variable s’affiche avec une valeur différente de celle attendue ?”. Et bien parce que cette valeur n’est pas représentable de manière exacte en machine, comme notre “simple” 0,1 ou notre 6,6 du début. De plus, notre 6,6 était obtenu par une opération, et les opérations peuvent ajouter de l’erreur. Et oui, vous n’aviez déjà pas une valeur exacte au départ, si en plus vous vous amusez à faire des petits calculs par-dessus et bien plus vous allez faire de calculs, plus votre marge d’erreur va augmenter !
Il reste la question “pourquoi ça marche en C++ alors ?”. Encore une fois, la réponse est simple : ça ne marche pas en C++. Mais C++ est gentil, et vous fait un bel arrondi au moment d’afficher votre valeur. Mais cela ne veut pas dire qu’elle est exacte et c’est d’ailleurs le même principe pour les calculatrices !
“Mais alors, est-ce qu’il y a des valeurs qui sont exactes ?“
Oui. Hourra! La plupart des nombres entiers (“pas trop grands”), ou les nombres obtenus avec des divisions par 2 (0,5, 0,25, 0,125…) notamment.
Si vous voulez en savoir plus, et si vous n’avez pas encore mal à la tête, je vous encourage à lire la dernière partie, éventuellement dans une prochaine lecture. Si ce n’est pas le cas, ce n’est pas grave, vous avez vu l’essentiel, et merci de m’avoir lu !
Les flottants (suite)... pour les courageux
Je vous propose de rentrer dans le dur mais surtout dans le détail de la représentation d’un flottant en binaire. Cette façon de représenter est régie par une norme, qui s’appelle la norme IEEE 754, et qui nous donne 2 possibilités :
- la “simple précision” (le type de variable correspondant est souvent appelé float)
- la “double précision” (le type double donc).
Comme leur nom semble le suggérer, les double sont plus précis… mais nécessitent 64 bits (8 octets) contre 32 bits (soit 4 octets) pour les float. Et oui, comme du simple au double…
Pour les 2 représentations, il y a un bit réservé au signe, qui va donc simplement nous dire si le nombre est positif ou négatif (0 pour un nombre positif, 1 pour un négatif).
Le restant des bits est divisé en 2 parties : la mantisse et l’exposant.
- en simple précision, il y a 8 bits d’exposant et 23 de mantisse
- en double précision, il y a 11 bits d’exposant et 52 de mantisse
Illustration de la représentation binaire
La représentation utilisée fonctionne sur le même principe que l’écriture scientifique en base 10. Celle-ci consiste à écrire un nombre sous la forme d’un nombre entre 1 et 10 (potentiellement à virgule) multiplié par une puissance de 10. Par exemple, 1500 s’écrit 1,5 x 10^3 en notation scientifique. Cela permet de représenter de façon compacte de très grands nombres qui ont beaucoup de chiffres : par exemple, 2 milliards, donc 2 000 000 000, s’écrit 2 x 10^9.
Pour les nombres flottants, on va donc écrire un nombre sous la forme d’un nombre entre 1 et 2 (base 2, vous vous souvenez) multiplié par une puissance de 2.
Mais qu’est-ce que l’on va écrire en mémoire pour représenter ces nombres flottants ?
L'exposant
Pour l’exposant, on va mettre la puissance qui est associée à 2. Cette puissance est forcément entière dans notre notation, on va donc la coder exactement comme un nombre entier dans les bits réservés à l’exposant (comme nous avons fait avec 14 plus haut).
En vérité, on ne représente pas exactement cette puissance dans l’exposant, car l’exposant peut être positif ou négatif, et la logique n’est pas la même que pour les nombres entiers.. On doit lui rajouter un biais, qui vaut 127 en simple précision et 1023 en double précision, puis coder la valeur obtenue.
La mantisse
Et la mantisse ? On sait que le nombre qui multiplie la puissance de 2 est entre 1,00 et 1,999… (il n’est pas possible d’avoir 2). Tous ces nombres commencent par 1, on n’a pas donc pas besoin de l’écrire puisque c’est toujours le cas. On va donc se contenter de représenter ce qui est après le “1,”. Pour cela, on va faire comme avec les entiers en binaire, sauf qu’au lieu d’utiliser les puissances “positives” de 2, donc 2,4, 8…, on va utiliser les puissances “négatives” : 2^(-1), 2^(-2).. En fait, cela nous donne toujours des nombres à virgule entre 0 et 1. On a par exemple :
- 2^(-1) = 0,5
- 2^(-2) = 0,25
Et ainsi de suite.
Mais ce n’est pas toujours aussi simple, notamment lorsque l’écriture est infinie. Il existe une méthode algorithmique pour trouver l’exposant et la mantisse, pour plus de détails 👉 ici.
Prenons un exemple : sous la forme mantisse / exposant, 12 s’écrit 1,5 x 2^3. 1,5 est la mantisse et 3 l’exposant.
Mais n’oublions pas le bit de signe, qui vient en premier dans la représentation. Ici, 12 est positif, donc on mettra 0 dans ce premier bit.
Pour l’exposant, il vaudra ici 3. Mais à cause du biais que l’on a évoqué, il faudra coder dans les bits de l’exposant 127 + 3 = 130 en simple précision et 1023 + 3 = 1026 en double précision.
Et pour la mantisse, elle vaut 1,5 dans cet exemple. On a donc besoin de représenter uniquement 0,5 puisqu’on ne reprécise pas le premier “1,”. On aura alors seulement 1 chiffre à 1 et tous les autres à 0 pour la mantisse, puisque 2^(-1) = 0,5. Ici, la mantisse peut se représenter exactement, mais c’est loin d’être toujours le cas, et c’est ce qui nous cause ces fameuses erreurs d’arrondi.
Si l’on veut écrire la représentation complète du flottant 12, par exemple en simple précision, on aura donc :
0 10000010 10000000000000000000000
Il y aurait encore des milliers de choses passionnantes à écrire sur le sujet et j’espère vous en avoir présenté le principal, surtout si vous êtes arrivés jusqu’ici 💪.
Merci de m’avoir lu ! Si vous aussi vous avez un collègue qui râle sur ses erreurs de calcul, n’hésitez pas à lui envoyer cet article 😉
Robin
Références : https://docs.python.org/3/tutorial/floatingpoint.html