Version en ligne

Tutoriel : Maîtrisez les nombres à virgule en C

Table des matières

Maîtrisez les nombres à virgule en C
Utilisation des nombres à virgule en C
IEEE 754 : le codage en mémoire d’un nombre flottant
Un peu de mathématiques
IEEE 754 : Exceptions & arrondis
Comparer des nombres flottants
Mais qu'en dit la norme C ?

Maîtrisez les nombres à virgule en C

Utilisation des nombres à virgule en C

Vous souhaitez manipuler dans vos programmes en C de très grands nombres et/ou des nombres à virgule ? Ou alors vous avez déjà essayé mais vous rencontrez des problèmes incompréhensibles ? Ce cours vous est destiné !
Vous y apprendrez tout ce qu'il faut savoir sur les nombres à virgule en C.

Au programme :

Prérequis :

Utilisation des nombres à virgule en C

IEEE 754 : le codage en mémoire d’un nombre flottant

Tout d'abord, une présentation des nombres à virgule flottante !

Bon, je ne vais pas vous expliquer ce qu'est un nombre à virgule. Si vous ne savez pas ce que c'est, ce tutoriel ne vous sera d'aucune utilité. :lol:
On parle de virgule flottante car on peut faire varier la place de la virgule en variant la puissance de 10 (puisque tout nombre peut être écrit avec une puissance de 10). Par exemple : 42,1337 = 42,1337 ×100 = 0,421337 ×102 = 42133,7 ×10-3.

En informatique, on parle de virgule flottante par opposition à virgule fixe, qui indique une méthode de représentation en mémoire d'un nombre avec un nombre fixe de chiffres après la virgule. En C, nous avons des nombres à virgule flottante.

Remarque : On parle de la partie entière pour désigner la partie qui se trouve avant la virgule, et de partie décimale (ou fractionnaire) pour celle qui se trouve après.
Je pourrai également parler de la notation scientifique. Ce terme désigne le nombre écrit avec un seul chiffre avant la virgule et multiplié par une puissance de 10 ; par exemple, l’écriture scientifique de 3141,6 sera 3,1416 ×103.
Enfin, on appelle partie significative d'un nombre, ce nombre écrit écrit en notation scientifique sans la puissance de 10 qui va derrière (c'est-à-dire le nombre à virgule avec un seul chiffre avant la virgule) ; par exemple, 3,1416 est la partie significative dans notre exemple précédent.

La base de la base

Les types pour représenter un nombre à virgule

En C, un nombre à virgule flottante peut être représenté par les types float et double (il existe aussi long double). Comme pour les types entiers, leur taille en mémoire dépend de l'architecture de l'ordinateur, mais les valeurs sont très fréquemment 32 bits (4 octets) pour un float et 64 bits (8 octets) pour un double. Pour vous assurer de la taille chez vous, vous pouvez faire :

printf("taille d'un float : %u bytes\n", sizeof(float));
printf("taille d'un double : %u bytes\n", sizeof(double));

Intérêt des nombres flottants

Vous pouvez utiliser les types flottants dans vos programmes si vous souhaitez manipuler des nombres à virgule, mais également pour stocker de très grands nombres. En effet, un float permet d'atteindre 1038, et un double 10308 ! Toutefois, pour des raisons de précision, vous ne pourrez évidemment pas conserver la valeur exacte d’un nombre à 38 ou 308 chiffres, ce serait trop beau (il y aurait besoin de bien plus de 32 ou 64 bits) : votre nombre sera arrondi.

Écrire une constante

La syntaxe pour écrire un nombre à virgule est : -3141.59e7 (ou -3141.59E7, avec un E majuscule), ce qui signifie -3141,59 ×107. Attention, on met un point et non une virgule ! La lettre E collée au nombre signifie « exposant ». Celui-ci peut très bien être négatif comme dans 945.68e-3 (ce qui signifie 945.68 ×10-3).

De plus, si la partie entière ou la partie décimale d’un nombre vaut 0, on peut l’omettre ; de même pour l’exposant. Toutefois, il doit toujours y avoir au moins un point ou un exposant d'écrit, pour signifier au compilateur qu’on veut un nombre à virgule). Par exemple, les constantes suivantes sont équivalentes :

0.0e0 ;     0.0 ;
 0.e0 ;      0. ;
 .0e0 ;      .0 ;
  0e0 ;

En revanche, écrire 0 tout court produira un entier. En effet, le compilateur ne voyant ni point ni lettre E, il ne peut pas savoir qu'il s'agit d'un nombre à virgule flottante.

Le type par défaut d'une constante à virgule flottante est double, mais on peut le changer avec un suffixe collé après la constante :

Par exemple, .1e-3f sera de type float, 42.1337e-3l de type long double.

Enfin, il peut être utile de savoir que le C99 permet aussi d'écrire ses constantes flottantes en hexadécimal ! :diable: Pour cela :

Par exemple, -0xC45.8p3 signifie - 0x C45,8 ×23 (soit - 3141,5 ×23 = - 25 132).

L'intérêt à part faire mal au crâne ? Obtenir un nombre exact ! Les explications suivent…

Des constantes inexactes

Attention : tous les nombres décimaux (c'est-à-dire écrits en base 10) ne sont pas forcément représentables de façon finie en binaire, et certains sont donc arrondis ! Ainsi, 0,1 en base 10 s'écrit en binaire 0b0,00011001100110011… avec une infinité de 0011 (de même que 1/3 = 0,3333… en base 10).
Une partie significative écrite en base 10 ainsi qu'une puissance de 10 négative (c'est-à-dire qu'on divise par une puissance de 10) peuvent donc mener à des nombres inexacts.

En fait, seuls les nombres dont la partie décimale est le résultat d'une division par une puissance de 2 (comme .5 = 1/2, .75 = 3/4 ou encore .625 = 5/8) sont représentables en binaire avec un nombre fini de décimales.
Spécifier la partie significative en hexadécimal (base 16) et l'exposant en termes de puissance de 2 permet donc un nombre exact (dans la limite de la capacité du type, bien sûr ^^ ).

Quelques informations pratiques

Les fonctions d'entrée et de sortie formatées : printf & scanf

Vous voudrez sûrement savoir quels sont les formateurs pour lire ou écrire des nombres flottants. Voici un petit tableau résumé. Pour des infos complètes sur l'utilisation de printf et scanf (il y a plein d’options pour configurer l’affichage) : RTFM, biensûr !

Formateurs

Utilisation

"%f", "%F"

Affiche le double « simplement », comme vous avez l'habitude de l'écrire : l'affichage est du type [-]XXXX.XXX, où les X sont des chiffres de 0 à 9 et [-] symbolise le signe « moins » éventuel.

"%e", "%E"

Affiche le double en écriture scientifique, c'est-à-dire avec un seul chiffre avant la virgule et un exposant introduit par la lettre e (ou E pour "%E") ; l'affichage est donc du type [-]X.XXXXXXeYY (ou [-]X.XXXXXXEYY).

"%g", "%G"

Une sorte de « combinaison » des formateurs précédents : utilise le premier style si le nombre n'est pas trop grand ou trop petit, le deuxième style sinon.

Remarquez que tous ces formateurs attendent des flottants de type double. Il n'existe pas de formateurs pour float, ce qui n'est pas dramatique car vous pouvez convertir vos nombres de float vers double pour les afficher (ce qui est fait automatiquement avec la syntaxe de printf).
Le C99 vous permet d'ajouter la lettre L majuscule entre % et le formateur afin de correspondre à un long double.

Formateurs

Utilisation

"%e""%E",
"%f""%F",
"%g""%G"

Lit un nombre à virgule flottante et l'écrit dans la variable de type float indiquée. Le nombre lu doit être écrit de la même manière que vous écrivez une constante dans votre code source.

Contrairement à printf, tous ces formateurs sont équivalents.

Attention ! Contrairement à printf, scanf travaille par défaut avec des float. Pour lire un double, il faut ajouter la lettre 'l' minuscule entre % et le formateur ; pour un long double, il faut ajouter 'L' (ou 'll').

Les fonctions mathématiques avec <math.h>

Matheux, vous serez comblés : la bibliothèque standard du C met à votre disposition toute une gamme de fonctions mathématiques : valeur absolue, maximum de deux nombres, arrondis en tous sens, puissances, fonctions trigonométriques, exponentielles et logarithmes, etc. Ces fonctions sont définies dans le header <math.h>, qu'il vous faudra donc inclure dans vos sources.

Comme vous faire la liste complète et détaillée des fonctions disponibles serait inutile et fastidieux, je vous invite à consulter le manuel qui est fait pour ça (tout ça pour ne pas dire encore une fois : RTFM…).
De plus, chacune des fonctions existantes ci-dessus se décline en fait en trois versions :

Vous devez néanmoins retenir une chose. Ces fonctions ne sont pas incluses dans l'exécutable avec le reste de la bibliothèque standard, lors de l'édition des liens (la phase suivant la compilation). Il faut les lier manuellement.
Sous GCC, cela se fait en passant le paramètre -lm lors de cette phase.
Si vous travaillez avec Code::Blocks, alors il est probable que ce dernier ajoute automatiquement cette liaison. Si ce n'est pas le cas, vous pouvez suivre la démarche suivante :

Voilà, on a fini avec les fondamentaux. Vous avez maintenant des connaissances suffisantes pour manier les nombres à virgule en C, vous pouvez aller jouer dans le jardin mais ce serait dommage de s'arrêter en si bon chemin… Après cette brève introduction en la matière, nous allons nous plonger au cœur des nombres flottants pour en étudier le moindre détail. Pas trop fatigué ? Il vaudrait mieux, parce qu'on vient à peine de commencer. Yêêêhaaa !


IEEE 754 : le codage en mémoire d’un nombre flottant

IEEE 754 : le codage en mémoire d’un nombre flottant

Utilisation des nombres à virgule en C Un peu de mathématiques

On va maintenant s'intéresser à la manière dont sont représentés en mémoire les nombres à virgule flottante ! Attention, on va faire une grosse partie théorique, alors préparez vos barres céréalées en cas d'hypoglycémie. :D

Ce que vais vous raconter dans cette partie n'est pas dans la norme C. La suite de ce cours se base sur la norme IEEE 754 (ou plus précisément ANSI/IEEE Std 754-1985). Celle-ci spécifie :

Ne vous inquiétez pas pour les deux derniers points, on en reparlera.

Elle est proposée par l'IEEE (Institute of Electrical and Electronics Engineers, l'Institut des Ingénieurs Électriciens et Électroniciens), une organisation américaine qui est devenue une référence en matière d'informatique.
Cette norme est très suivie (en fait, sur la très grande majorité des ordinateurs aujourd'hui), mais vous pouvez très bien tomber sur une implémentation qui utilise un autre mode de représentation. :( Un point sur la norme C sera fait à la fin de ce chapitre (ainsi qu'une manière de déterminer si IEEE 754 est bien employé sur votre implémentation).
Ce format aura son importance plus loin dans ce tutoriel.

Principes généraux

Tout nombre à virgule peut être écrit en notation scientifique, c'est-à-dire avec un seul chiffre avant la virgule et multiplié par une puissance de b (b étant la base dans laquelle on écrit le nombre : 10 pour la base décimale, 2 pour le binaire, 16 pour l'hexa, etc.). Par exemple, le nombre -3141,5 est égal à -3,1415 ×103 . L'écriture binaire de ce nombre est 0b -110001000101,1, soit 0b-1,100010001011 ×211.

Pour stocker un nombre à virgule flottante, on se base sur sa notation binaire scientifique. En mémoire, un nombre flottant se décompose donc en 3 parties. À partir du bit de poids fort, on a :

IEEE 754 spécifie deux grands formats basés sur ce modèle ; les deux types principaux du C pour les nombres à virgule flottante correspondent à ces deux formats.

Nom dans la
norme IEE 754

Type en C

Taille (en bits)

Chiffres
significatifs
(en base 10)

Valeurs absolues possibles

Total

s

e

m

minimum

maximum

simple précision

float

32 bits

1

8

23

7

1,2 ×10-38

3,4 ×10+38

double précision

double

64 bits

1

11

52

16

2,2 ×10-308

1,8 ×10+308

Nous détaillerons plus tard les deux dernières colonnes.

En résumé (pour un float de 32 bits), la représentation de -3141,5 est :

schéma : de l'écriture décimale à la représentation en mémoire d'un nombre flottant normalisé

Les différents types de nombres représentables

Il y a cependant des cas particuliers, car un nombre à virgule flottante peut représenter autre chose que des nombres « normaux ». Il peut aussi valoir :

Quelques calculs avec ces valeurs particulières :

\begin{matrix} ext{Le symbole}\pm ext{dans le r\'esultat d'une division signifie que le signe} \\ ext{d\'epend de ceux du num\'erateur et du d\'enominateur selon la r\`egle suivante :} \\ ext{- deux signes identiques : signe +} \\ ext{- deux signes diff\'erents : signe -} \\ \end{matrix}

\begin{matrix} x \div 0 &=& \pm\infty \\ 0 \div 0 &=& NaN \\ \\ x \div \infty &=& \pm0 \\ \infty \div \infty &=& NaN \\ \\ 0 imes \infty &=& NaN \\ \\ (+\infty) + (+\infty) &=& +\infty \\ (+\infty) - (+\infty) &=& NaN \\ \end{matrix}

Pour représenter tout ce petit monde, on utilise des valeurs spéciales de l'exposant (qui ne peuvent donc pas être utilisées pour des nombres normaux) :

Un petit tableau pour récapituler tout ça de manière visuelle (pour un float de 32 bits)…

Type

Représentation mémoire

Valeur

binaire

hexadécimal

Not a Number

[0/1] — 11111111 — 11111111111111111111111

[0/1] — 11111111 — 00000000000000000000001

[7/F]F FF FF FF

[7/F]F 80 00 01

NaN

NaN

Infini

[0/1] — 11111111 — 00000000000000000000000

[7/F]F 80 00 00

± \infty

Nombre normalisé

[0/1] — 11111110 — 11111111111111111111111

[0/1] — 00000001 — 00000000000000000000000

[7/F]F 7F FF FF

[0/8]0 80 00 00

± 3,4028235 ×10+38

± 1,1754944 ×10-38

Nombre dénormalisé

[0/1] — 00000000 — 11111111111111111111111

[0/1] — 00000000 — 00000000000000000000001

[0/8]0 7F FF FF

[0/8]0 00 00 01

± 1,1754942 ×10-38

± 1,4012985 ×10-45

Zéro

[0/1] — 00000000 — 00000000000000000000000

[0/8]0 00 00 00

± 0

À titre informatif, je vous mets aussi le tableau équivalent pour un double de 64 bits (sans le binaire, ça prend trop de place).

Type

Représentation mémoire

Valeur

Not a Number

[7/F]F FF FF FF   FF FF FF FF

[7/F]F F0 00 00   00 00 00 01

NaN

NaN

Infini

[7/F]F F0 00 00   00 00 00 00

± \infty

Nombre normalisé

[7/F]F EF FF FF   FF FF FF FF

[0/8]0 10 00 00   00 00 00 00

± 1,7976931348623157 ×10+308

± 2,2250738585072014 ×10-308

Nombre dénormalisé

[0/8]0 0F FF FF   FF FF FF FF

[0/8]0 00 00 00   00 00 00 01

± 2,2250738585072010 ×10-308

± 5,0000000000000000 ×10-324

Zéro

[0/8]0 00 00 00   00 00 00 00

± 0

Notez que pour passer d'un nombre positif à son équivalent négatif, il suffit d'additionner 0x 8000 0000 à sa représentation en mémoire (ou 0x 8000 0000 0000 0000 s'il s'agit d'un double). Ceci nous servira plus tard. ;)

Courage, cette partie théorique n'est pas encore finie, et peut-être que vous trouvez ça barbant, mais ça nous servira pour la suite.

Nombres dénormalisés

Nous allons maintenant nous attarder sur les nombres dénormalisés. C'est qu'ils sont traîtres, les bougres.

Un nombre dénormalisé est un nombre si petit (en valeur absolue) que l'on ne peut pas le représenter en mémoire en se basant sur son écriture scientifique. Par exemple, 0b 1011 ×2-133 (soit environ 1,0101905 ×10-39) a pour écriture scientifique 0b 1,011 ×2-130 ; cependant, on ne peut pas stocker l'exposant -130 dans un float.

On ruse donc en écrivant le nombre avec le plus petit exposant possible.Question : quel est cet exposant (pour un float) ?
Si vous avez répondu -127 (et je suis sûr que vous l'avez fait, si vous suivez encore ce cours), vous êtes tombés dans le piège. Mouahaha. :p

Mais pourtant, c'est bien l'exposant non-décalé minimal, celui qui sert pour représenter des nombres dénormalisés ?

Tout à fait. En fait, les gens de chez IEEE (qui sont très très intelligents, si si) ont décidés de compliquer la chose. Ils ont choisi que, bien que l'exposant non-décalé en mémoire soit -127 (pour un float), l'exposant réel d'un nombre dénormalisé serait -126, c'est-à-dire le même que pour les plus petits nombres normalisés !

Pourquoi cela ? Pour assurer une sorte de continuité avec les premiers nombres normalisés. Puisqu'ils ont le même exposant réel, les nombres dénormalisés et les premiers nombres normalisés sont à intervalles réguliers :

Citation : liste des nombres dénormalisés et premiers nombres normalisés (pour un float)

0b 0,00000000000000000000000 ×2-126   // zéro
0b 0,00000000000000000000001 ×2-126   // 1er nombre dénormalisé
0b 0,00000000000000000000010 ×2-126   // nombre dénormalisé suivant
   …
0b 0,11111111111111111111110 ×2-126   // avant-dernier nombre dénormalisé
0b 0,11111111111111111111111 ×2-126   // dernier nombre dénormalisé
0b 1,00000000000000000000000 ×2-126   // 1er nombre normalisé
0b 1,00000000000000000000001 ×2-126   // nombre normalisé suivant
   …
0b 1,11111111111111111111110 ×2-126   // avant-dernier nombre normalisé de cet exposant
0b 1,11111111111111111111111 ×2-126   // dernier nombre normalisé de cet exposant

Revenons à nos moutons. Comme je le disais, on écrit donc le nombre avec le plus petit exposant possible, soit dans notre exemple 0b+ 0,0001011 ×2-126.
On traduit ensuite ceci de façon similaire à ce qu'on a fait pour un nombre normalisé : suivez les couleurs !

schéma : de l'écriture décimale à la représentation en mémoire d'un nombre flottant dénormalisé

Vous comprenez maintenant pourquoi le bit implicite est à 0 pour un nombre dénormalisé.

Pour un double, l'exposant sera -1022 ; de manière générale, il s'agit de 1 - d\'ecalage.

Mais pourquoi avoir inventé les nombres dénormalisés ? o_O Il aurait été plus simple de décider qu'à l'exposant décalé nul correspondaient également des nombres habituels (dont l'exposant réel aurait bien été -127 et non -126).

C'est vrai, on peut cependant trouver plusieurs raisons :

Cependant, un reproche qu'on pourrait faire aux nombres dénormalisés est qu'en plus de tout compliquer pour nous autres pauvres humains, ils perdent en précision au fur et à mesure qu'ils se rapprochent de zéro : c'est ce que nous allons voir dans la partie suivante.


Utilisation des nombres à virgule en C Un peu de mathématiques

Un peu de mathématiques

IEEE 754 : le codage en mémoire d’un nombre flottant IEEE 754 : Exceptions & arrondis

Dans cette partie, nous allons continuer à approfondir les aspects théoriques. Elle va se révéler très mathématique. >_

Retrouver la valeur du nombre flottant

Non, restez ! Bon, si vraiment le mot « mathématiques » vous fait pousser des boutons, vous pouvez passer cette partie, mais elle est intéressante pour bien comprendre la conversion de la représentation en mémoire vers la valeur.

Pour la suite, on note :

Pour retrouver la partie significative de la notation scientifique, il suffit de faire 1+\frac{mantisse}{2^m} si le nombre est normalisé, ou 0+\frac{mantisse}{2^m} s'il est dénormalisé.

Ensuite, il suffit de multiplier par la puissance de 2 indiquée par l'exposant réel, et d'adapter le signe selon le bit de signe :

Si l'on reprend les exemples de tout à l'heure…

Exemple 1 : Nombre normalisé

schéma : de la représentation en mémoire à l'écriture décimale d'un nombre flottant normalisé

\[\begin{aligned}flottant &=\quad -\enspace (1 + \frac{ extit{0b} hinspace 10001000101100000000000}{2^{23}}) imes2^{11}\\ &=\quad -\enspace (1 + \frac{4,478976 imes10^6}{2^{23}}) imes2^{11} \quad\quad=\enspace -3141{,}5\end{aligned}\]
On retrouve bien -3141,5. :)

Exemple 2 : Nombre dénormalisé

schéma : de la représentation en mémoire à l'écriture décimale d'un nombre flottant dénormalisé

\[\begin{aligned}flottant &=\quad +\enspace (0 + \frac{ extit{0b} hinspace 00010110000000000000000}{2^{23}}) imes2^{-127+1}\\ &=\quad +\enspace (0 + \frac{720896}{2^{23}}) imes2^{-126} \quad\quad\approx\enspace +\enspace 1{,}0101905 imes10^{-39}\end{aligned}\]
Là aussi, on retrouve bien le nombre de départ.

Intervalle entre les nombres en fonction de l'exposant

Selon la norme IEEE 754, les nombres consécutifs de même exposant (qu'ils soient normalisés ou pas) sont « placés » à intervalle régulier. En effet, pour passer d'un nombre au nombre suivant, on ajoute toujours \delta=\quad 0{,}00000000000000000000001 imes2^{exposant} \enspace= \frac{1}{2^m} imes2^{exposant} \enspace= 2^{exposant-m}.

Cet intervalle double quand on passe d'un exposant à l'exposant supérieur. Quelques valeurs remarquables pour un float (ne les apprenez pas !) :

Précision et chiffres significatifs

Vous aurez remarqué une colonne « chiffres significatifs » dans le premier tableau. C'est ce dont on va parler ici.

Mais qu'est-ce qu'un chiffre significatif ? Le nombre de chiffres significatifs d'un nombre, c'est tout simplement le nombre de chiffres utilisés pour écrire ce nombre (dans une base numérique donnée). Par exemple, 43,1337 a 6 chiffres significatifs.

En physique et en chimie, on y voue une attention particulière. En effet, les mesures n'étant jamais exactes, on s'en sert pour indiquer le degré de précision de la mesure (qui varie selon les instruments). En conséquence, pour un physicien, 42,1337 est différent de 42,13370 ; le second nombre est plus précis, car il indique quel est le 5e chiffre après la virgule tandis que le premier nombre s'arrête au 4e (le 5e chiffre pourrait être 0, 1, 2, 3, 4, on n’en sait pas plus).

On compte les chiffres significatifs à partir du premier chiffre de gauche différent de 0, ce qui signifie que 3,1416 et 003,1416 sont équivalents (ils ont tous les deux 5 chiffres significatifs).

Vous pouvez maintenant comprendre la colonne du tableau : elle indique le nombre de chiffres significatifs en base 10 que nous permet chaque format :

Maintenant, contrôle surprise : combien de chiffres significatifs en base 2 permettent ces formats ? :diable: Mais si, vous pouvez tout à fait répondre, réfléchissez un peu.
C'est tout simple : la mantisse représentant la partie significative du nombre, un nombre flottant a autant de chiffres significatifs en base 2 que sa mantisse occupe de bits.
Hmm hmm. Vous êtes certain ? N'oubliez pas le bit implicite ! Il faut donc ajouter 1 à ce nombre. En vérité, un nombre normalisé a donc m+1 chiffres significatifs en binaire, soit 23+1=24 pour le format 32 bits, ou 52+1=53 pour le format 64 bits.
On appelle souvent le nombre de chiffres significatifs en base 2 la précision tout court.

Mais ce n'est pas aussi simple ! Cette « formule » pour la précision n'est valable que pour les nombre normalisés. Voyez-vous pourquoi ?
Rappelez-vous que pour les nombres dénormalisés, le bit implicite, c'est-à-dire la partie entière du nombre, est 0. Comme c'est le premier chiffre, on ne le compte pas dans les chiffres significatifs.

Ah bah alors, pour un nombre dénormalisé, la précision c'est juste le nombre de bits de la mantisse ?

Que nenni, jeune padawan. Un court schéma vaut mieux que de longues explications.

schéma : précision (nombre de chiffres significatifs en base 2) d'un nombre flottant normalisé ou non

Comme vous le voyez, plus un nombre dénormalisé est proche de zéro, moins il est précis.


IEEE 754 : le codage en mémoire d’un nombre flottant IEEE 754 : Exceptions & arrondis

IEEE 754 : Exceptions & arrondis

Un peu de mathématiques Comparer des nombres flottants

Bien. On vient de se farcir deux chapitres de théorie pure sur la représentation en mémoire d'un nombre à virgule flottante. Vous en avez marre ? Tant mieux, on change de sujet ! Bon, ça va rester théorique, mais un peu moins quand même.
La norme IEEE 754 ne se limite pas à la représentation des flottants ; elle définit également des exceptions et des modes d'arrondis. Décortiquons tout ça.

Les exceptions

Une exception est une sorte de « signal  qui est envoyé dans certains cas, afin de traiter les erreurs les cas particuliers qui, s'ils étaient ignorés, seraient des erreurs. Cette définition est en fait générale (le terme vous est peut-être familier si vous faites du C++).

C'est encore flou pour vous ? Ça ne fait rien, vous allez mieux comprendre avec cette liste.
IEEE définit cinq exceptions :

Et là, j'ai une transition de malââââde !…

Les arrondis

Les nombres stockés en mémoire ayant un nombre fini de chiffres significatifs (essayez de stocker un nombre infini de chiffres…), les calculs peuvent mener à des arrondis. IEEE 754 définit quatre modes d'arrondis :

C'est là que ça devient intéressant (enfin ça l'était déjà avant, c'est une façon de parler, n'est-ce-pas). Les imprécisions s'accumulent au fil des calculs, et au final vous pouvez obtenir quelque chose d'incohérent !

Ces imprécisions se manifestent par exemple lorsqu'on manipule deux nombres dont les exposants sont très éloignés. Par exemple, 4.2e17 + 13.37 devrait donner 42000000000000001337 soit 4.2000000000000001337e17, mais il y a plus de chiffres significatifs qu'on ne peut en stocker ; le nombre sera donc arrondi, et finalement il vaudra 4.2e17 comme si l'opération n'avait pas eu lieu !
Certains opérateurs entraînent beaucoup d'arrondis, comme l'addition ou la soustraction. Au contraire, d'autres, tels la multiplication ou la division, sont beaucoup plus sûrs. L'exemple précédent devrait vous aider à comprendre pourquoi. ;)

Autre source de problèmes : comme vu précédemment, tous les nombres décimaux ne sont pas représentables de façon finie en binaire, et certains (comme 1.0/10.0 = .1) sont donc arrondis.

Enfin, et en conséquence de ce qui vient d'être dit :

Citation : Mewtow

Les opérations avec les flottants ne sont pas associatives : l'ordre dans lequel on fait un calcul change le résultat. Par exemple, 1 - (.2 + .2 + .2 + .2 + .2) aura un résultat différent de (((((1-.2)-.2)-.2)-.2)-.2).

Pas convaincus ? Vérifions ça !

#include <stdio.h>
int main(void) {
   printf("%g\n%g\n",  (1.0 - .2 - .2 - .2 - .2 - .2),
                       (1.0 - (.2 + .2 + .2 + .2 + .2))  );
   return 0;
}

Compilé chez moi, j'obtiens ceci :

5.55112e-017
0

Ça parle tout seul.

Remarque hors-sujet (mais importante)

De façon plus générale, il faut rester vigilant lorsqu'on écrit des expressions impliquant des flottants, car des expressions a priori équivalentes, du point de vue d'un humain, se révèlent en fait différentes en pratique. >_ L'exemple précédent l'illustre bien. D'autres cas parlants sont imaginables.

Cette liste est tirée de la norme C99 (ISO/IEC 9899:TC3), que je vous invite à consulter si vous en voulez une plus complète (localisation dans le draft PDF de la norme : Annex F — F.8.2 Expression transformations & F.8.3 Relational operators (p. 464-466)).

L'environnement des flottants : <fenv.h>

Cet ensemble de paramètres (les exceptions et le mode d'arrondi) forme ce qu'on appelle en informatique (c'est une définition générale) un environnement. C'est le cadre dans lequel on travaille.

Cet environnement pour les flottants est accessible avec le header standard (C99) <fenv.h>. Il permet de manipuler les exceptions (les surveiller, en lever, etc.) et les modes d'arrondis (savoir quel est le mode utilisé et en changer). Je ne détaillerai pas son utilisation. Si vous voulez en savoir plus, consultez (par exemple) cette page Wikipédia ou cette page du site d'Open Group.


Un peu de mathématiques Comparer des nombres flottants

Comparer des nombres flottants

IEEE 754 : Exceptions & arrondis Mais qu'en dit la norme C ?

L'infâme traîtrise de l'opérateur ==

Bon. On a donc vu que l'utilisation des flottants en C est semée d'embûches : on risque des arrondis et des expressions « normalement » équivalentes ne le sont pas.
Mais ce n'est pas tout ! Les comparaisons vont aussi vous donner du fil à retordre. :( En effet, l'opérateur de comparaison == renvoie vrai si ses deux opérandes sont EXACTEMENT égales, ou s'il compare un zéro positif et un zéro négatif. Or, avec les problèmes d'arrondis, une différence minuscule peut s'être glissée entre les deux nombres testés. Ainsi, vous risquez de vous retrouver avec des égalités qui devraient être vraies, mais qui sont fausses !

Cela peut faire planter lamentablement un programme (boucles infinies, instructions dans un bloc conditionnel jamais exécutées…), et la source du problème est difficilement identifiable pour un œil non averti.

Un petit exemple ? 4.2e17 == 4.2e17 + 13.37 renverra vrai (d'après l'exemple précédent).

Autre exemple :

int main(void) {
   float f=0;
   int i;
   
   for(i=0; i<100; ++i)
      f+= .01;
   
   if(f==1)   printf("f==1\n");
   else       printf("f!=1,  f==%f", f);
   
   return 0;
}

La sortie sera : f!=1,  f==0.999999.

Évidemment, ces exemples sont stupides, mais ils permettent de mieux saisir dans quels cas se pose ce problème.

Les utilisateurs de GCC seront intéressés de savoir qu'il existe une option -Wfloat-equal qui déclenche un warning dès que l'on utilise l'opérateur == sur des nombres flottants.

Citation : Vous

Argghh…! mais c'est abominable !

Hé oui, c'est horrible. Il est encore temps d'abandonner définitivement l'informatique et de vous mettre au patchwork.

Non attendez, revenez ! Bien sûr, il existe des astuces. Et heureusement !

Alors, récapitulons : on cherche à comparer deux nombres à virgule flottante (float ou double) en tenant compte d'une marge d'erreur due aux arrondis ; on va pour cela écrire une fonction qui renverra un booléen (vrai ou faux, 1 ou 0), pour remplacer l'opérateur ==.

L'écart absolu & l'écart relatif

La première méthode consiste tout simplement à mesurer l'écart entre les deux nombres. On parle d'écart absolu (par opposition à l'écart relatif que nous verrons par la suite). Si cet écart est inférieur à une certaine valeur (souvent appelée epsilon, ce qui pour un mathématicien signifie « valeur très petite »), alors on considère que les deux nombres sont égaux.

#include <math.h>   // pour la fonction fabs, renvoyant la valeur absolue d'un flottant

#define  EPSILON  1e-8

/* ici pour des doubles, mais on fait la même chose pour des floats */
int doublesAreEqual(double a, double b) {
   return fabs(a-b) <= EPSILON;
/* La fonction fabs renvoie la valeur absolue (c'est-à-dire positive) du nombre
   de type double passé en argument ; ses équivalents (C99) sont fabsf pour les
   float, et fabsl pour les long double (pensez donc à adapter votre code en
   conséquence pour comparer les autres types flottants). */
}

Ici, fabs nous renvoie l'écart absolu entre a et b. Le reste est facile à comprendre. Simple, non ?

Oui, mais c'est encore loin d'être parfait : en effet, un écart de 1e-8 (soit 0.00000001) peut s'avérer judicieux pour comparer des nombres compris entre 0,1 et 1 (par exemple, en fonction de la précision que vous souhaitez), mais trop petit pour des nombres entre 100 et 1000, ou trop grand pour des nombres entre 0,00001 et 0,0001 ; si vous comparez des nombres compris entre 0,00000001 et 0,0000001, vous avez même une marge d'erreur de 100% !
N'employez donc cette méthode que si vous êtes sûr de l'ordre de grandeur des nombres à comparer, et choisissez un epsilon adapté.

Pour pallier à ce problème et faire une fonction plus générique, une solution serait de passer l'écart maximal en argument à la fonction et non de se baser sur une constante de préprocesseur ; ainsi, l'utilisateur pourrait fournir un écart adapté à l'ordre de grandeur des nombres qu'il veut comparer.
Allons plus loin. On va employer l'écart relatif. Celui-ci ramène l'écart absolu dans les proportions des nombres comparés :

#include <math.h>

#define  EPSILON  1e-8

int doublesAreEqual(double a, double b) {
   if(a==b)   return 1;   // si a et b valent zéro  (explications plus bas)
   
   double absError= fabs(a-b);   // écart absolu
   a= fabs(a);   // on ne garde que les valeurs absolues pour la suite …
   b= fabs(b);   // … pour pouvoir calculer le nombre le plus grand en valeur absolue
   
   return  ( absError / (a>b? a:b) )  <=  EPSILON;
}

On divise l'écart absolu par le plus grand des deux nombres en valeur absolue (signification du ternaire), pour avoir quelque chose d'adapté à l'ordre de grandeur des deux nombres.

La ligne commençant par if(a==b) vous paraît sans doute bizarre. Elle est là pour gérer le cas où a et b sont égaux à zéro. En effet, sans elle, on aurait une division par zéro qui vaudrait NaN, et la comparaison serait donc toujours fausse. On compare donc les deux nombres pour que la fonction renvoie vrai s'ils sont identiques et égaux à zéro (positif ou négatif).

Cependant, il subsiste un problème : dans le cas de deux nombres très proches de zéro, l'écart relatif sera très important (car on divise par un tout petit nombre) alors que ces deux nombres seront très proches… Pour y remédier, on réintroduit l'écart absolu : la fonction retournerait vrai si l'écart absolu ou l'écart relatif (au moins l'un des deux) est inférieur à une valeur donnée (qu'on passe en argument à la fonction). D'où le code définitif :

#include <math.h>

#define  EPSILON  1e-8

int doublesAreEqual(double a, double b, double maxAbs, double maxRel) {
   if(a==b)   return 1;
   
   double absError= fabs(a-b);
   a= fabs(a);
   b= fabs(b);
   
   return   absError <= maxAbs   ||   ( absError / (a>b? a:b) )  <=  maxRel;
}

La représentation en mémoire convertie en entier

Maintenant, vous avez du code à peu près potable et fonctionnel. Toutefois, il existe une autre manière de faire, plus pratique mais un peu plus hard. Accrochez-vous, ça va secouer. :pirate:

Imaginez qu'au lieu de se baser sur l'écart entre les deux nombres, on cherche à déterminer combien de nombres possibles les séparent ? Ainsi, on aimerait placer une marge d'erreur, non sur la valeur elle-même des nombres flottants, mais sur leur « éloignement », pour pouvoir dire par exemple : « J'accepte les 5 nombres en dessous et les 5 nombres au dessus de la valeur machin ».

Eh bien, grâce au format de l'IEEE, c'est possible !

Ce format garantit que « si deux nombres du même type à virgule flottante sont consécutifs, alors leurs représentations entières le sont aussi (selon le bit de poids fort pour déterminer le signe, et non la règle du complément à 2). »

Que signifie ce charabia ? Eh bien, prenons 2 nombres de type float codés sur 32 bits selon le format IEEE 754 (évidemment, cela s'applique aussi aux double) :

Valeur du float                       représentation en mémoire
                     binaire                                      hexadécimal    en base 10
+1.9999998           0   0111111 1   1111111 11111111 11111110    3F FF FF FE    1073741822
+1.9999999           0   0111111 1   1111111 11111111 11111111    3F FF FF FF    1073741823

Ces 2 nombres sont « consécutifs », il ne peut pas y avoir d'autre nombre du même type dont la valeur serait comprise entre les 2. Or, que constate-t-on ? Leurs représentations en mémoire, si on les lit comme des nombres entiers, sont également consécutives !

Pour savoir si les deux nombres à comparer sont « voisins », il suffit donc de comparer leur représentation en mémoire convertie en nombre entier.

Mais comment accéder à cette représentation entière ?

Ben, c'est simple, il suffit de faire (int)monFloat … Surtout pas ! En faisant ça, on convertit le nombre à virgule en nombre entier, et on obtient donc le nombre de départ arrondi à l'unité. Ça n'a rien à voir avec ce que l'on veut. La bonne formule est donc, tenez-vous bien :
*(int*)&monFloat
Je vous laisse méditer là-dessus. :p Ce n'est pas vraiment compliqué, quand on y pense. Quelques explications si vraiment vous bloquez :

pour comprendre cette expression, il faut en fait la lire de droite à gauche :

Maintenant, du code avec ce que je viens de vous dire :

#include <stdlib.h>   // pour la fonction abs, renvoyant la valeur absolue d'un entier
#include <stdint.h>   /* header du C99, qui fournit des types entiers de taille fixe :
                         —  int32_t,  int64_t : entier signé de 32 ou 64 bits ;
                         — uint32_t, uint64_t : entier non-signé de 32 ou 64 bits. */

#define  INTREPOFFLOAT(f)   ( *(int32_t*)&(f) )   // représentation entière d'un float (32 bits)
#define  INTREPOFDOUBLE(d)  ( *(int64_t*)&(d) )   // représentation entière d'un double (64 bits)

#define  MAXULPS  5

/* ici pour des floats, mais on fait exactement pareil pour des doubles */
int floatsAreEqual(float a, float b) {
   if(a==b)   return 1;
   
   return abs( INTREPOFFLOAT(a) - INTREPOFFLOAT(b) )  <=  MAXULPS;
/* attention à la fonction abs, qui prend un int en argument ; un int fait 16
   ou 32 bits : il peut donc être trop petit pour contenir les 32 bits de la
   représentation entière d'un float, et sera de toutes façons insuffisant
   pour les 64 bits de celle d'un double. Voyez la fonction labs qui prend un
   long int, ou llabs (C99) qui prend un long long int, ou mieux, écrivez vos
   propres fonctions de valeur absolue, pour 32 et 64 bits (avec les types de
   <stdint.h>) ; ainsi, vous n'aurez plus de problèmes de taille des types
   pouvant varier. Ici, je garde les fonctions standards par souci de clarté. */
}

La constante MAXULPS (de ULP, « Unit of Least Precision », c'est-à-dire la valeur qui sépare deux flottants consécutifs) nous fournit notre marge d'erreur. Si l'écart entre les représentations entières est inférieur à MAXULPS, alors on considère que les nombres sont égaux. :)

Ici, la ligne commençant par if(a==b) est là pour gérer le cas où les deux nombres seraient +0.0 et -0.0. En effet, +0.0 et -0.0 ont des représentations entières très différentes, ce qui fait que la fonction retournerait faux sans ce test préalable.

En outre, remarquez qu'on utilise les types entiers définis dans le header standard <stdint.h> au lieu des types habituels (int, long int…). En effet, la taille de ces derniers dépend de l'implémentation et n'est donc pas connue, il serait donc dangereux (non portable) de s'appuyer dessus ; au contraire, la taille de int32_t et int64_t est connue et fixe.
Ce header a été introduit avec C99, c'est pourquoi je vous ai dit qu'on allait devoir se baser sur cette version du langage C.

Un peu de maths pour vous aider à choisir votre marge d'erreur !

Une variation de \Delta mantisse dans la représentation entière de la mantisse codée sur m bits correspond à une variation de la valeur du flottant donnée par la formule suivante : \Delta flottant=\enspace \frac{\Delta mantisse}{2^m} imes 2^{exposant} \enspace= \Delta mantisse imes 2^{exposant-m} (où exposant est l'exposant réel). Cette formule provient directement de celle donnant l'intervalle entre les nombres consécutifs en fonction de l'exposant.

Pour un nombre flottant compris entre 1 et 2, une variation d'un ULP correspond donc à une variation de valeur du nombre flottant de \frac{1}{2^{23}} \approx 1{,}192 imes10^{-7} pour un float et \frac{1}{2^{52}} \approx 2{,}220 imes10^{-16} pour un double.

Si vous choisissez comme moi une marge de 5 ULP, alors votre marge de valeur (pour un nombre flottant compris entre 1 et 2) sera \frac{5}{2^{23}} \approx 5{,}960 imes10^{-7} pour un float et \frac{5}{2^{52}} \approx 1{,}110 imes10^{-15} pour un double.

Mais (hé oui, encore un « mais ») il reste encore un détail à régler, et à ce stade j'aimerais que vous leviez tous la main pour me le dire. Allez, un indice : ça concerne la parenthèse de la phrase en italique de tout à l'heure… :-° Ben oui, le signe ! Les flottants, selon la norme IEEE 754, sont signés selon le principe du bit de signe et non du complément à 2. Or, les entiers (du moins sur la grande majorité des ordinateurs aujourd'hui) sont stockés… selon la règle du complément à 2.

Un exemple pour bien voir (je ne vous met plus le binaire, vous êtes grands maintenant) :

Valeur du float              représentation en mémoire
                         hexadécimal    en base 10 selon le complément à 2
+4.2038954 e-45          00 00 00 03     3
+2.8025969 e-45          00 00 00 02     2
+1.4012985 e-45          00 00 00 01     1
+0.0000000               00 00 00 00     0
-0.0000000               80 00 00 00    -2147483648
-1.4012985 e-45          80 00 00 01    -2147483647
-2.8025969 e-45          80 00 00 02    -2147483646
-4.2038954 e-45          80 00 00 03    -2147483645

Comme vous le voyez, le dernier nombre est inférieur à l'avant-dernier, et pourtant sa représentation entière (en signed comme en unsigned) est supérieure !
En vérité, cela ne porte pas à conséquence si l'on compare deux nombres négatifs, car on ne s'intéresse qu'à l'écart entre les représentations entières, qui lui ne change pas ; le problème se pose lorsque l'on compare deux nombres de signes opposés.

Heureusement, il existe une solution. Il suffit de convertir les nombres négatifs selon la règle du bit de signe, en nombres négatifs selon la règle du complément à 2. Je vous laisse chercher ; aidez-vous de l'exemple ci-dessus…
Trouvé ? Il suffit de garder la valeur telle quelle si le flottant est positif, ou s'il est négatif de soustraire 0x 80 00 00 00 à la représentation entière puis d'inverser le signe de cette représentation. Cela revient à faire l'opération suivante :
représentation = 0x 8000 0000 - représentation ;
Similairement, pour un double de 64 bits, on fera représentation = 0x 8000 0000 0000 0000 - représentation.

On obtient alors ceci :

Valeur du float              représentation transformée
                         hexadécimal    en base 10 selon le complément à 2
+4.2038954 e-45          00 00 00 03     3
+2.8025969 e-45          00 00 00 02     2
+1.4012985 e-45          00 00 00 01     1
+0.0000000               00 00 00 00     0
-0.0000000               00 00 00 00     0   *    (* = a été transformé)
-1.4012985 e-45          FF FF FF FF    -1   *
-2.8025969 e-45          FF FF FF FE    -2   *
-4.2038954 e-45          FF FF FF FD    -3   *

Comme vous le voyez, les représentations entières sont maintenant cohérentes, on peut les comparer sans problème. Et même les deux zéros (positif/négatif) sont égaux !

Du code, du code !

#include <stdlib.h>
#include <stdint.h>

#define  INTREPOFFLOAT(f)   ( *(int32_t*)&(f) )
#define  INTREPOFDOUBLE(d)  ( *(int64_t*)&(d) )

#define  MAXULPS  5

int floatsAreEqual(float a, float b) {
   int32_t aInt= INTREPOFFLOAT(a);   // représentations entières
   int32_t bInt= INTREPOFFLOAT(b);
   
   if(aInt<0)   aInt= 0x80000000 - aInt;   // ou 0x8000000000000000 pour des doubles
   if(bInt<0)   bInt= 0x80000000 - bInt;
/* NOTE: on teste (aInt<0) et non (a<0). En effet, si a==-0.0 (zéro négatif),
   alors le test (a<0) renverrait faux, et on garderait la représentation de
   -0.0, à savoir 0x80000000. La règle du complément à 2 garde l'avantage du
   bit de signe : si le bit de poids fort est à 1, alors le nombre entier est
   négatif, et réciproquement ; on peut donc utiliser le test sur l'entier et
   non sur le flottant pour savoir s'il faut « transformer » la représentation. */
   
   return abs( aInt - bInt )  <=  MAXULPS;
}

Attention à bien utiliser les types signés (int32_t et int64_t) et non les non signés (uint32_t et uint64_t).

Bon, ce n'est pas encore parfait, mais c'est très convenable. Quelques points améliorables :

Ces détails peuvent être corrigés avec des vérifications supplémentaires. À ce sujet, les macros de test de nombres flottants (C99) peuvent servir. En résumé (je vous invite à consulter le manuel avec le lien précédent) :

Citation : Le manuel : fpclassify, isfinite, isnormal, isnan, isinf

Depuis le C99, le header <math.h> définit les macros isfinite, isnormal, isnan et isinf ; elles prennent toutes un nombre flottant en argument (peu importe son type), et renvoient un booléen indiquant respectivement si le nombre est fini, normalisé, NaN ou infini (le retour de isinf n'est pas forcément 1 ou 0).
La macro fpclassify est également définie. On l'utilise comme les autres, et sa valeur de retour indique le type du nombre flottant (FP_ZERO, FP_SUBNORMAL, FP_NORMAL, FP_INFINITE, FP_NAN).

Code complet

Je vous propose finalement un code complet.

J'y ai introduit une fonction cmpFloats (ou cmpDoubles) de mon cru qui permet une comparaison plus générale : en effet, à la manière de strcmp, elle renvoie -1 si a>b, 0 si a==b ou 1 si a<b ; elle renvoie par ailleurs -2 si l'un des deux nombres au moins est NaN.
Ainsi, il devient plus facile de tester les deux nombres (j'ai de plus écrit des macros simples pour faciliter les comparaisons). En effet, avant, pour tester par exemple a<b (strictement inférieur), il fallait faire if(a<b && !floatsAreEqual(a,b)), ce qui était plus lourd à écrire.

Header

#ifndef INCLUDE_CMPFLOATS_H
#define INCLUDE_CMPFLOATS_H


#include <stdint.h>
#include <math.h>   /* pour les macros de test des nombres
                       flottants ( isnan() et isinf() ) */


/* représentation entière du nombre en virgule flottante */
#define  INTREPOFFLOAT(f)   ( *(int32_t*)&(f) )
#define  INTREPOFDOUBLE(d)  ( *(int64_t*)&(d) )

/* marge maximale séparant deux nombres flottants considérés comme égaux,
   en termes d'ULP (« Unit of Least Precision ») */
#define  MAXULPSFLOAT   5
#define  MAXULPSDOUBLE  5


/* renvoie vrai (1) si les 2 nombres sont égaux, faux (0) sinon */
int floatsAreEqual(float a, float b);
int doublesAreEqual(double a, double b);


/* renvoie  -1 si a>b,  0 si a==b,  1 si a<b,  ou -2 si a ou b est NaN */
int cmpFloats(float a, float b);
int cmpDoubles(double a, double b);

/* macros booléennes (à utiliser dans des tests simples) */
#define  CMPFLOATS_EQUAL(a,b)     (cmpFloats((a),(b))==0)      // =>  a==b
#define  CMPFLOATS_UNEQUAL(a,b)   (cmpFloats((a),(b))!=0)      // =>  a!=b
#define  CMPFLOATS_GT(a,b)        (cmpFloats((a),(b))==-1)     // =>  a>b
#define  CMPFLOATS_LT(a,b)        (cmpFloats((a),(b))==1)      // =>  a<b
//#define  CMPFLOATS_GTEQUAL(a,b)   (cmpFloats((a),(b))!=1)      // =>  a>=b
//#define  CMPFLOATS_LTEQUAL(a,b)   (cmpFloats((a),(b))!=-1)     // =>  a<=b
#define  CMPFLOATS_GTEQUAL(a,b)   ((cmpFloats((a),(b))-1)&2)   // =>  a>=b
#define  CMPFLOATS_LTEQUAL(a,b)   (cmpFloats((a),(b))>=0)      // =>  a<=b
#define  CMPFLOATS_NAN(a,b)       (cmpFloats((a),(b))==-2)     // =>  a==NaN || b==NaN

#define  CMPDOUBLES_EQUAL(a,b)     (cmpDoubles((a),(b))==0)    // =>  a==b
#define  CMPDOUBLES_UNEQUAL(a,b)   (cmpDoubles((a),(b))!=0)    // =>  a!=b
#define  CMPDOUBLES_GT(a,b)        (cmpDoubles((a),(b))==-1)   // =>  a>b
#define  CMPDOUBLES_LT(a,b)        (cmpDoubles((a),(b))==1)    // =>  a<b
//#define  CMPDOUBLES_GTEQUAL(a,b)   (cmpDoubles((a),(b))!=1)    // =>  a>=b
//#define  CMPDOUBLES_LTEQUAL(a,b)   (cmpDoubles((a),(b))!=-1)   // =>  a<=b
#define  CMPDOUBLES_GTEQUAL(a,b)   ((cmpDoubles((a),(b))-1)&2) // =>  a>=b
#define  CMPDOUBLES_LTEQUAL(a,b)   (cmpDoubles((a),(b))>=0)    // =>  a<=b
#define  CMPDOUBLES_NAN(a,b)       (cmpDoubles((a),(b))==-2)   // =>  a==NaN || b==NaN


#endif  //INCLUDE_CMPFLOATS_H

Remarquez que j'ai mis des macros en commentaires (correspondant à <= et >=), qui on été remplacées par d'autres. En effet, ces macros ne sont plus valables si cmp… renvoie -2 (qui est le code pour NaN).

L'utilisation de ces macros est conseillée dans le cas d'un test « simple », c'est-à-dire avec un seul test ; par exemple :

instructions1;
if(CMPFLOATS_GT(a,b)) { // a>b
   instructions2;
}
instructions3;

En revanche, il vaut mieux éviter de les enchaîner pour traiter différentes possibilités (si a<b, faire machin, si a>b, faire truc...) le else est aussi à éviter (car il engloberait aussi le cas de NaN, ce qui dans la plupart des cas n'est pas voulu) :

instructions1;
if(CMPFLOATS_GT(a,b)) { // a>b
   instructions2;
}
else if(CMPFLOATS_LT(a,b)) { // a<b
   /*  /!\  on appelle la fonction cmpFloats 2 fois  */
   instructions2b;
}
else // a==b
   instructions2t;   /*  /!\  ce code est aussi exécuté dans le cas de NaN !  */
}
instructions3;

Il vaut mieux utiliser un switch dans ce cas, qui n'appelle la fonction qu'une seule fois tout en permettant un contrôle précis :

instructions1;
switch(cmpFloats(a,b)) {
 case -1: // a>b
   instructions2;
   break;
 case  0: // a==b
   instructions2t;
   break;
 case  1: // a<b
   instructions2b;
   break;
 default: break; // NaN
}
instructions3;

Autre exemple pour bien saisir l'utilisation du switch :

instructions1;
switch(cmpFloats(a,b)) {
 case -1: // a>b
 case  0: // a==b
   instructions2;   // ce code est donc exécuté si a>=b
   break;
 case  1: // a<b
   instructions2b;
   break;
 default: break; // NaN
}
instructions3;

Fichier source

#include "cmpfloats.h"




/* valeur absolue d'un entier de 32 ou 64 bits */
uint32_t abs32(int32_t x) {   return x<0? -x : x;   }
uint64_t abs64(int64_t x) {   return x<0? -x : x;   }




int floatsAreEqual(float a, float b) {
   
   /* vérification pour NaN : si l'un des deux nombres est NaN, alors on
      retourne toujours faux */
   if(isnan(a) || isnan(b))
      return 0;
   
   /* vérification pour les infinis : si l'un des deux nombres est infini,
      alors on ne retourne vrai que si les deux nombres sont strictement
      égaux (tous les deux +inf ou -inf) */
   if(isinf(a) || isinf(b))
      return a==b;
   
   int32_t aInt= INTREPOFFLOAT(a);
   int32_t bInt= INTREPOFFLOAT(b);
   if(aInt<0)   aInt= 0x80000000 - aInt;
   if(bInt<0)   bInt= 0x80000000 - bInt;
   return abs32( aInt - bInt )  <=  MAXULPSFLOAT;
}

int doublesAreEqual(double a, double b) {
   if(isnan(a) || isnan(b))
      return 0;
   
   if(isinf(a) || isinf(b))
      return a==b;
   
   int64_t aInt= INTREPOFDOUBLE(a);
   int64_t bInt= INTREPOFDOUBLE(b);
   if(aInt<0)   aInt= 0x8000000000000000LL - aInt;
   if(bInt<0)   bInt= 0x8000000000000000LL - bInt;
   return abs64( aInt - bInt )  <=  MAXULPSDOUBLE;
}




int cmpFloats(float a, float b) {
   if(isnan(a) || isnan(b))
      return -2;   // -2 si on a au moins un NaN
   
   if(isinf(a) || isinf(b))
      return (a<b)? -1 : (a>b)? 1 : 0;   // gestion des infinis similaire à ci-dessous
   
   int32_t aInt= INTREPOFFLOAT(a);
   int32_t bInt= INTREPOFFLOAT(b);
   if(aInt<0)   aInt= 0x80000000 - aInt;
   if(bInt<0)   bInt= 0x80000000 - bInt;
   return (abs32(aInt-bInt) <= MAXULPSFLOAT)?  0   // 0 si les nombres sont égaux
                               : (aInt<bInt)?  1   // 1 si a<b
                               :              -1;  // -1 si a>b
}

int cmpDoubles(double a, double b) {
   if(isnan(a) || isnan(b))
      return -2;
   
   if(isinf(a) || isinf(b))
      return (a<b)? -1 : (a>b)? 1 : 0;
   
   int64_t aInt= INTREPOFDOUBLE(a);
   int64_t bInt= INTREPOFDOUBLE(b);
   if(aInt<0)   aInt= 0x8000000000000000LL - aInt;
   if(bInt<0)   bInt= 0x8000000000000000LL - bInt;
   return (abs64(aInt-bInt) <= MAXULPSDOUBLE)?  0
                                : (aInt<bInt)?  1
                                :              -1;
}

Ce code est à compiler en C99.

Si vous programmez en C++, pourquoi ne pas surcharger les opérateurs de comparaison ? Cela vous simplifiera la vie (toutefois, vous risquerez alors, à la longue, d'oublier que vous avez fait quelque chose pour comparer tranquillement des flottants, et un jour ça ne marchera plus car vous n'aurez plus inclus votre petit header magique.) :-°


IEEE 754 : Exceptions & arrondis Mais qu'en dit la norme C ?

Mais qu'en dit la norme C ?

Comparer des nombres flottants

Citation : Le Zéro harassé

Oh non ! Encore de la théorie !

Rassurez-vous, si vous êtes fatigués, vous pouvez passer cette partie. Elle se destine aux petits curieux qui voudraient aller plus loin pour savoir plus précisément quelle relation entretient IEEE 754 vis à vis de la norme C, et comment déterminer si le compilateur suit bien les formats IEEE 754 ou pas. Car en C, il y a foule de gourous barbus qui se cramponnent à la norme comme une huître à son rocher, la citent comme un texte sacré, et viennent hurler à l’hérésie au moindre bout de code non « portable ». Et ils ont bien raison.

IEEE 754 et la norme C

La norme C90 était très floue sur ce sujet, et n'imposait ni ne privilégiait aucun format pour les nombres à virgule flottante. Le C99 a changé cela. En effet, la norme C99 (alias ISO/IEC 9899:TC3, téléchargeable ici en PDF) introduit le support de la norme IEEE 754.

Voici un extrait le montrant (issu de la liste des changements majeurs depuis le C90) :

Citation : ISO/IEC 9899:TC3 — Foreword (§5, p. xi-xii)

This second edition cancels and replaces the first edition, ISO/IEC 9899:1990 […]. Major changes from the previous edition include:
[…]
— IEC 60559 (also known as IEC 559 or IEEE arithmetic) support
[…]

Quoi ? C'est quoi IEC 60559 ? Encore une nouvelle norme au nom tordu !

Oulala, pas de panique ! IEC 60559, c'est juste un autre nom de IEEE 754.

Cette norme est donc supportée par le langage C depuis sa version C99. Attention ! Supporté ne veut pas dire imposé. Les compilateurs ne sont pas obligés d'adopter les formats IEEE 754. C'est juste que s'ils le font, ils doivent suivre les règles de support spécifiées par la norme C99.

Ce passage le montre clairement :

Citation : ISO/IEC 9899:TC3 — 6.2.6: Representation of types — General (§1, p.37)

The representations of all types are unspecified except as stated in this subclause. […]

Cela signifie que la représentation de n'importe quel type (et pas seulement les flottants) est inconnue ; elle reste aux choix du compilateur.

Certains compilateurs peuvent supporter plusieurs formats ; dans ce cas, vous devrez spécifier lequel utiliser avec des arguments (sauf si vous voulez garder le format par défaut). GCC, pour sa part, implémente IEEE 754 par défaut, ses utilisateurs peuvent donc dormir sur leurs deux oreilles. :)

Mais quelles sont les règles de support de IEEE 754 selon le C99 ?

La norme C99 comporte une annexe (l'annexe F) dédiée aux nombres flottants, où se trouve la réponse à cette question. En voici le début :

Citation : ISO/IEC 9899:TC3 — Annex F (normative): IEC 60559 floating-point arithmetic (p.444)

F.1 Introduction

This annex specifies C language support for the IEC 60559 floating-point standard. […] An implementation that defines __STDC_IEC_559__ shall conform to the specifications in this annex. […]

F.2 Types

The C floating types match the IEC 60559 formats as follows:
— The float type matches the IEC 60559 single format.
— The double type matches the IEC 60559 double format.
— The long double type matches an IEC 60559 extended format, else a non-IEC 60559 extended format, else the IEC 60559 double format.
Any non-IEC 60559 extended format used for the long double type shall have more precision than IEC 60559 double and at least the range of IEC 60559 double.
Recommended practice
The long double type should match an IEC 60559 extended format.

[…]

This annex specifies C language support for the IEC 60559 floating-point standard. […] An implementation that defines __STDC_IEC_559__ shall conform to the specifications in this annex. […]

F.2 Types

The C floating types match the IEC 60559 formats as follows:
— The float type matches the IEC 60559 single format.
— The double type matches the IEC 60559 double format.
— The long double type matches an IEC 60559 extended format, else a non-IEC 60559 extended format, else the IEC 60559 double format.
Any non-IEC 60559 extended format used for the long double type shall have more precision than IEC 60559 double and at least the range of IEC 60559 double.
Recommended practice
The long double type should match an IEC 60559 extended format.

[…]

The C floating types match the IEC 60559 formats as follows:
— The float type matches the IEC 60559 single format.
— The double type matches the IEC 60559 double format.
— The long double type matches an IEC 60559 extended format, else a non-IEC 60559 extended format, else the IEC 60559 double format.
Any non-IEC 60559 extended format used for the long double type shall have more precision than IEC 60559 double and at least the range of IEC 60559 double.
Recommended practice
The long double type should match an IEC 60559 extended format.

[…]

La deuxième partie dit que le type float du langage C doit correspondre au format simple précision (32 bits) de IEC 60559 (alias IEEE 754), etc., etc.
Il est aussi question du type long double, dont j'ai peu parlé dans ce tutoriel ; sachez qu'en C, son format est moins bien défini, mais qu'il correspond souvent au format de double précision étendue de IEEE 754 (dont je n'ai pas parlé non plus), ou alors au format de double précision tout court (comme un double).

Je ne parlerai pas de la suite de cette annexe, vous pouvez la lire si vous voulez (vous êtes grands). Elle décrit notamment le comportement des opérations sur les flottants.

Enfin, sachez que :

Citation : Taurre

un système peut visiblement encoder les nombres flottants suivant le format défini par la norme IEEE 754, sans pour autant remplir toutes les conditions de l'annexe F de la norme C99 (cf ce sujet).

Les conditions en questions sont surtout des détails du comportement des calculs. Dans le cadre de ce tutoriel, qui s'est surtout focalisé sur les formats de représentation des flottants, ça ne devrait pas poser trop de problèmes.

Le non-respect partiel de la norme IEEE 754 peut aussi être le fait d'options du compilateur. Par exemple, l'option d'optimisation -ffast-math de GCC améliore les performances en accélérant les calculs sur les nombres flottants, mais enfreint certaines règles de IEEE 754.

Je reviens sur l'introduction, elle contient quelque chose d'intéressant. Il est dit que si la macro __STDC_IEC_559__ est définie, alors c'est le format IEEE 754 qui est utilisé. Cela peut vous être utile pour faire des tests ou adapter votre code. ;)
Cependant, le contraire n'est pas vrai ! Vous pouvez parfaitement avoir une implémentation qui suit IEEE 754 mais qui ne définit pas cette constante. Cela semble être le cas de GCC sous Windows (portage MinGW par exemple), car GCC laisse cette définition aux headers du système ; or, ceux de Windows ne définissent pas __STDC_IEC_559__, en partie parce qu'il y aurait un risque d'incompatibilité entre GCC et la bibliothèque C de Windows.

En pratique : savoir si l'implémentation utilise IEEE 754

Puisqu'on ne peut pas compter sur la constante __STDC_IEC_559__ pour nous renseigner, il faut trouver un autre moyen de déterminer si oui ou non on travaille avec IEEE 754.

Pour cela, le meilleur moyen reste de se renseigner auprès de votre compilateur favori et/ou de votre plateforme cible.

Toutefois, si vous tenez vraiment à faire cette vérification avec du code, je peux vous offrir des pistes…

L'avantage est que le bon fonctionnement du code serait indépendant du compilateur utilisé, facilitant ainsi les échanges de code.

Notez toutefois que les propositions ci-dessus vérifient la représentation en mémoire, mais pas les différentes opérations sur les flottants.

Voilà, ce cours touche à sa fin ! Il a été très théorique, j'espère que vous avez digéré.
Vous savez maintenant comment vous servir des nombres à virgule en C, et comment ils fonctionnent sous le capot. On a aussi vu les difficultés de leur utilisation, et comment les contourner.

J'espère que vous avez apprécié le voyage, et bon code ! Faites-nous de beaux programmes mathématiques, je compte sur vous. :D

Si vous voulez aller encore plus loin, je ne peux que vous conseiller de lire ce tutoriel qui décrit comment sont gérés les nombres flottants au niveau matériel (le processeur).

Sources :

Liens additionnels :


Comparer des nombres flottants