PROFDINFO.COM

Votre enseignant d'informatique en ligne

Représentation de nombres décimaux

Nous avons vu dans les chapitres précédents comment l'ordinateur représente un nombre entier (positif ou négatif), ainsi qu'un caractère (qui n'est en fait rien d'autre qu'un nombre associé au caractère). Il ne nous reste que les nombres décimaux (dans le sens de nombres avec une virgule et une partie décimale). La représentation des nombres décimaux en C++ est beaucoup plus complexe que les nombres entiers et c'est pour cette raison qu'on ne va pas apprendre à faire les conversions à la main comme on l'a fait pour les autres nombres. Toutefois, il est important de saisir le principe en gros afin de comprendre les limites de ces types de données.

La virgule flottante

Le nom "float" vient du fait que ce type de données est nommé "nombre en virgule flottante" (ou floating point en anglais) -- et il est ainsi nommé parce que la virgule (ou le point décimal en anglais) "flotte" quelque part dans le nombre. En effet, un float tient sur 4 octets, donc 32 bits, ce qui est la même taille qu'un int. Un int peut représenter des nombres de -2 147 483 648 à 2 147 483 647, donc disons environ de moins deux milliards à deux milliards, soit à peu près de -2 x 109 à 2 x 109. Pourtant, le float, avec les mêmes 32 bits, se vante de pouvoir représenter des nombres de -3.4 x 10-38 à 3.4 x 1038. C'est beaucoup, beaucoup plus gros comme intervalle, et en plus, ça inclut tout un tas de nombres avec des décimales (et même des nombres avec beaucoup de décimales, puisque le nombre 3.4 x 10-38 représente -0,000000000000000000000000000000000000034!) Y'a un truc, c'est sûr.

Comment faire autant avec si peu? La réponse : en étant IMPRÉCIS! On ne stocke pas réellement le nombre -0,000000000000000000000000000000000000034 en mémoire, on stocke -3,4 et on stocke -38 (l'exposant). Si on avait tout un tas de nombres à la place des 0, par contre, on ne pourrait pas tous les retenir. Même principe pour 3.4 x 1038, un gros nombre qu'on peut retenir en ne stockant que 3.4 et 38.

Le principe d'un float est le suivant : on prend un nombre en binaire, incluant une partie décimale, puis on déplace la virgule afin de le transformer en notation standardisée, un peu comme pour la notation scientifique. On veut finir avec un nombre sous la forme 1,101010101010... x 2X. Ensuite, on conserve la partie après la virgule (le plus qu'on peut avec les bits qu'on a), puis on conserve l'exposant. Et voilà, la magie opère! Reste que la partie après la décimale (qu'on appelle aussi la mantisse) doit tenir sur 23 bits et l'exposant sur 8 (le bit qui reste est un bit de signe tout bête).

Par exemple, le nombre décimal suivant:

01010111,10100000

Peut être exprimé sous ces différentes formes, toutes équivalentes :

  • 01010111,10100000 x 20; ou
  • 0101011,110100000 x 21; ou
  • 010101,1110100000 x 22; ou
  • 01010,11110100000 x 23; ou
  • 0101,011110100000 x 24; ou
  • 010,1011110100000 x 25; ou
  • 01,01011110100000 x 26

La dernière est une notation standardisée. Les bits en bleu constituent la mantisse et le nombre en rouge est l'exposant. Ce sont ces parties qui seront conservées dans le float.

Le problème avec les nombres à virgule flottante

Bon, vous me direz : 23 bits de décimales et un exposant allant de -127 à 128, c'est quand même pas mal comme précision. Et vous aurez raison. Toutefois, il faut aussi savoir une chose importante : toute représentation en virgule flottante n'est qu'une estimation du nombre original. Il va de soi que l'estimation est très proche du nombre réel, mais elle n'est la plupart du temps pas tout à fait exacte.

Pour s'en convaincre, il faut d'abord augmenter la précision de l'affichage des nombres à virgule flottante dans un programme C++. En effet, par défaut, 6 chiffres seulement seront utilisés. Par exemple, essayez ceci:

int main()
{
	float n, n2, n3;
	n = 3.3333333333f;
	n2 = 333.3333333333f;
	n3 = 33333.33333333f;

	cout << n << " " << n2 << " " << n3 << endl;
}

Étrangement, le résultat sera:

3.33333 333.333 33333.3

Comme l'affichage n'utilise que 6 chiffres, plus la partie entière est grande, moins il reste de place pour les décimales! Mais tout cela n'est que question d'affichage, le nombre en mémoire n'est pas aussi tronqué. Nous allons donc changer le nombre de chiffres utilisés pour l'affichage, question de voir avec plus de précision. Pour cela, on peut utiliser la fonction setprecision. Il suffit de lui mettre le nombre voulu entre parenthèses et de passer le tout à cout comme ceci:

cout << setprecision(17);

Il ne faut toutefois pas oublier que la fonction setprecision est comprise dans la librairie iomanip. Il faut donc l'inclure au début de notre programme:

#include <iomanip>

Maintenant, on peut essayer de représenter la fraction 1/3. Évidemment, toute représentation décimale de 1/3 sera toujours une approximation, mais on va voir qu'en virgule flottante, quelque chose d'étrange se passe:

int main() 
{ 	
 	float f;
	double d; 	
 	f = 0.3333333333333333333333333333f; 	
	d = 0.3333333333333333333333333333;
	cout << "1/3 float: " << f << endl << "1/3 double: " << d << endl; 
}     

Le résultat de l'exécution est surprenant:

1/3 float: 0.3333332538604736
1/3 double: 0.3333333333333335

On commence à s'éloigner plus sérieusement du 1/3, particulièrement dans le cas du float!

Même si on fonctionne en double, une petite erreur se glisse dans l'interprétation. On peut constater la même chose pour des nombres plus simples. Prenons par exemple la fraction 1/10. On s'attendrait à ce qu'on n'ait pas besoin d'arrondir quoi que ce soit pour représenter 1/10. Essayons:

float f;
double d; 	
f = 0.1f; 	
d = 0.1;
cout << "1/10 float: " << f << endl << "1/10 double: " << d << endl;

Bizarre autant qu'étrange, on obtient le résultat suivant:

1/10 float: 0.10000000149011612
1/10 double: 0.10000000000000001

Même avec un double et une fraction facile, une minuscule erreur se glisse en mémoire. Le problème, c'est que cette erreur peut grossir si on fait plusieurs opérations qui ajouteront chacune leur erreur. Par exemple:

float f = 0.1f + 0.1f + 0.1f + 0.1f + 0.1f + 0.1f + 0.1f + 0.1f + 0.1f + 0.1f;
cout << f << endl;

Alors qu'on devrait obtenir 1, on obtient:

1.0000001192092896

L'erreur n'est pas grande, mais il faut garder en tête qu'elle existe et que si on a besoin d'une grande précision, les nombres à virgule flottante ne sont pas le bon choix à faire.

Considérez le bout de code suivant:

float n, n2, total;

n = 1.345f;
n2 = 1.123f;
total = n + n2;

if (total == 2.468)
{
	cout << "Le total est 2.468" << endl;
}
else
{
	cout << "Le total n'est pas 2.468" << endl;
}

Le résultat devrait maintenant être prévisible:

Le total n'est pas 2.468

Il faut donc retenir que les erreurs dans les nombres à virgule flottante, aussi petites soient-elles, empêchent l'opérateur == de fonctionner correctement.

Mais pourquoi?

On ne peut clairement pas attribuer toutes ces étranges erreurs au simple fait que la mantisse est limitée en espace. Après tout, 23 bits devraient suffir pour bien stocker les nombres ci-haut, non?

L'erreur est plus souvent due à un autre détail important : les puissances de 2. Vous savez maintenant que chaque bit représente une puissance de 2 et que plus on se déplace vers la droite, plus la puissance de deux est petite. Par exemple, pour un nombre à 8 bits, les puissances des bits sont :

 

Puissance de 2 7 6 5 4 3 2 1 0
Valeur 128 64 32 16 8 4 2 1

Mais qu'est-ce qui arrive si on ajoute des décimales? Des nombres après la virgule? Si on a, disons 1011 1001,1101 1101? On sait que la partie à gauche de la virgule vaut 128 + 32 + 16 + 8 + 1, donc 185. Mais qu'en est-il de la partie à droite? Eh bien, de façon fort logique, on continue de diminuer les puissances de 2 de la même façon. 27, 26, 25, 24, 23, 22, 21, 20... puis 2-1, 2-2, 2-3, 2-4, et ainsi de suite. Donc les décimales sont des puissances de 2 négatives! Et que représente un exposant négatif? Une fraction! 2-1 = 1/21, donc 1/2. La puissance suivante vaut 1/4, puis la suivante 1/8, etc. Alors notre 1011 1001,1101 1101 = 185,86328125 (1/2 + 1/4 + 1/16 + 1/32 + 1/64 + 1/256).

On peut voir se dessiner le problème : tous les nombres décimaux ne peuvent pas être exprimés sous forme d'addition de puissances de 2. Et ça serait le cas même si on en avait beaucoup plus à notre disposition.

Le type double

Le type de données double utilise justement deux fois plus d'espace que le float (d'où son nom). Sur les 64 bits (8 octets) disponibles, il y en a 52 réservés à la mantisse (les décimales une fois qu'on a standardisé le nombre, rappelez-vous) et 11 à l'exposant.

Tout le reste fonctionne exactement de la même façon. Le fait d'avoir 8 octets nous permet alors de représenter des nombres avec beaucoup plus de précision (ou des beaucoup plus grands nombres). La plage possible va de -1.7 x 10-308 à 1.7 x 10308!

Ce qu'il faut retenir

  • Un nombre à virgule flottante permet de bien représenter des petits nombres avec beaucoup de décimales ou des très grands nombres sans décimales.
  • Un grand nombre avec beaucoup de décimales ne pourra pas entrer correctement et sera cruellement tronqué.
  • Un très grand nombre, même sans décimales, sera imprécis -- c'est parfait pour des nombres du genre 2.45 x 1022, mais pas pour des nombres du genre 2 456 987 234 222 345 222.
  • Même avec un nombre restreint de décimales, un nombre a de forte chance d'être une approximation à cause du fait qu'on devra le convertir en somme de fractions puissances de 2, fixes et immuables.
  • Un double est plus précis qu'un float, mais le principe des fractions reste le même et les erreurs (bien que moins significatives) seront présentes aussi souvent.
  • À cause de cette imprécision courante, il ne faut jamais utiliser les opérateurs == et != avec des types de données à virgule flottante, puisque les résultats sont imprévisibles.

 

Quelques exercices formatifs

1. Recopiez le programme de somme de floats précédent et trouvez une façon de faire en sorte qu'on puisse tout de même "valider" l'addition. Autrement dit : faites en sorte que le programme indique que l'addition donne bien 2.468 même s'il y a une petite erreur de float.

 

2. Le nombre 0.1 en float s'écrit 0 0111 1011 1001 1001 1001 1001 1001 101. Si on arrange tout ça convenablement, on obtient:

1,1001 1001 1001 1001 1001 101 x 2-4

Si on se débarrasse de l'exposant, on arrive alors à :

0,0001 1001 1001 1001 1001 1001 101

a) Que vaut ce nombre si on additionne les puissances de 2?

b) Quelle est la différence (l'erreur) entre 0.1 et ce nombre?