Aller au contenu
Topos Uranos

Résumé

Les nombres à décimales d’un programme ne sont pas les nombres réels des mathématiques : ils forment un sous-ensemble fini de rationnels dont le dénominateur est une puissance de deux, et toute valeur qui n’appartient pas à ce sous-ensemble est remplacée par la plus proche qui lui appartient. Cette leçon étudie comment un nombre à virgule flottante est stocké sur 64 bits (signe, exposant et mantisse), pourquoi 0.10.1 n’a pas de représentation exacte et quelle valeur prend sa place, combien de chiffres sont sûrs en float et en double, comment s’accumulent les erreurs d’arrondi, pourquoi deux réels ne se comparent pas avec == et comment on les compare avec une tolérance justifiée, et ce que sont l’infini et la valeur non numérique que le format réserve aux résultats qui ne tiennent pas ou n’existent pas.

Objectifs d’apprentissage

  1. Expliquer pourquoi 0.10.1 n’a pas de représentation binaire exacte et quelle valeur est stockée à sa place.
  2. Prévoir l’effet de l’arrondi dans des sommes répétées et dans la somme de nombres d’ordres de grandeur très différents.
  3. Comparer des nombres réels avec une tolérance adéquate au lieu de ==, en justifiant le choix de la tolérance.
  4. Choisir entre float et double selon la précision requise, et reconnaître l’infini et la valeur non numérique.

L’écriture scientifique en base 2

La leçon Les entiers et leur représentation a montré qu’un entier de nn bits représente exactement 2n2^n valeurs consécutives. Or les nombres à décimales posent une difficulté d’un autre ordre : entre deux réels quelconques il y en a une infinité d’autres, et aucun nombre fini de bits ne peut tous les distinguer. La solution qu’adoptent tous les ordinateurs actuels est celle-là même dont la science se sert pour écrire des grandeurs très grandes ou très petites : l’écriture scientifique. En base 10, la distance moyenne de la Terre au Soleil s’écrit 1.496×10111.496 \times 10^{11} mètres ; le nombre a un signe, quelques chiffres significatifs (la mantisse, 1.4961.496) et une puissance de la base (l’exposant, 1111), qui dit où va la virgule. S’il en est ainsi, un nombre fixe de chiffres couvre des grandeurs d’ordres très différents, au prix de ne garder que les premiers chiffres de chacune.

L’ordinateur fait de même en base 2. Tout nombre non nul s’écrit d’une seule manière sous la forme

x=(−1)s×1.m1m2m3…m52 (2)×2ex = (-1)^{s} \times 1.m_1 m_2 m_3 \ldots m_{52} \, {}_{(2)} \times 2^{e}

où ss est le signe (0 pour un nombre positif, 1 pour un négatif), 1.m1m2…1.m_1 m_2 \ldots est la mantisse écrite en binaire, dont le premier chiffre est toujours 1, et ee est l’exposant. Que le premier chiffre soit toujours 1 a une conséquence pratique : il n’est pas nécessaire de le stocker, et le bit ainsi économisé est gagné pour la précision. Le nom de virgule flottante décrit précisément cette forme : la virgule n’occupe pas une place fixe parmi les chiffres, mais « flotte » selon l’exposant.

Le type double de C++ utilise, dans tous les compilateurs actuels, le format binaire double précision normalisé au niveau international, sur 64 bits : 1 bit pour le signe, 11 bits pour l’exposant et 52 bits pour la mantisse, sans le chiffre initial. L’exposant est stocké avec un biais : le champ de 11 bits contient e+1023e + 1023, de sorte qu’il stocke des valeurs non signées de 1 à 2046 pour des exposants de −1022-1022 à 10231023 (les valeurs 0 et 2047 du champ sont réservées au zéro, aux nombres très petits, à l’infini et à la valeur non numérique, que l’on verra à la fin de la leçon). Le type float utilise le format simple précision, sur 32 bits : 1 de signe, 8 d’exposant et 23 de mantisse.

Les bits d’un double peuvent se voir. Le programme suivant utilise std::bit_cast, de l’en-tête <bit>, qui réinterprète les 64 bits du nombre comme un entier non signé de 64 bits, sans en changer aucun ; on s’en sert ici sans plus d’explication, comme d’un instrument pour regarder la mémoire. La spécification {:064b} écrit cet entier en binaire avec 64 chiffres, comme dans la leçon précédente, et {:016X} en hexadécimal avec 16.

Programme en C++
#include <bit>#include <cstdint>#include <print>​int main(){    const double tenth{0.1};    // Les 64 bits du nombre, lus comme un entier non signé.    const std::uint64_t bits{std::bit_cast<std::uint64_t>(tenth)};    std::println("{:064b}", bits);    std::println("{:016X}", bits);}
Sortie
00111111101110011001100110011001100110011001100110011001100110103FB999999999999A
ExempleLes 64 bits de 0.1

La figure regroupe les 64 bits qu’a écrits le programme et les lit champ par champ.

Démonstration

  1. 1+11+52=641 + 11 + 52 = 64

    Les 64 bits, en hexadécimal 3FB999999999999A. On les lit de gauche à droite : 1 bit de signe, 11 d’exposant et 52 de mantisse.

  2. s=0s = 0

    Le signe est 0 : le nombre est positif.

  3. e=1019−1023=−4e = 1019 - 1023 = -4

    Le champ de l’exposant, 0111111101101111111011 en binaire, vaut 1019 ; on lui retranche le biais, 1023, et l’exposant est −4-4.

  4. m≈1.6000000000000000888m \approx 1.6000000000000000888

    La mantisse, avec le 1 initial qui n’est pas stocké, est 1.1001100110011…10101.1001100110011\ldots1010 en binaire : le groupe 10011001 se répète et le dernier groupe, 10101010, est arrondi vers le haut. Sa valeur est d’environ 1.60000000000000008881.6000000000000000888.

  5. x=0.1000000000000000055511…≠0.1x = 0.1000000000000000055511\ldots \neq 0.1

    Le nombre stocké est +1.6000000000000000888×2−4+1.6000000000000000888 \times 2^{-4}, c’est-à-dire 0.1000000000000000055511…0.1000000000000000055511\ldots : très près de 0.10.1, mais pas 0.10.1.