Le document d'accompagnement pour l'algorithmique et la programmation en seconde mentionne page 13 :

Un ordinateur ne travaille pas avec des nombres réels, mais avec des flottants, c’est-à-dire un sous-ensemble des nombres décimaux dont la précision est limitée par des contraintes liées au codage en mémoire.

C’est ainsi qu’en Python, le test d’égalité 3+10**(-16)==3 s’évalue en True alors que le test 3+10**(-15)==3 s’évalue en False. On retiendra qu’il faut éviter de tester l’égalité entre deux flottants, et préférer la recherche d’une précision donnée. En revanche, bien sûr, il n’y a aucun problème à comparer deux nombres entiers.

C'est tout à fait exact et le programme de seconde ne demande pas d'enseigner les subtilités des flottants aux élèves. Cependant, au cours de leurs manipulations, les élèves peuvent obtenir des résultats qui semblent aberrants si on assimile les flottants aux réels, comme dans l'exemple cité. Également, on constate que 1.1 + 1.1 donne 2.2 mais que 1.1 + 1.1 + 1.1 donne 3.3000000000000003. Il peut être utile au professeur d'en savoir un peu plus afin de pouvoir en dire suffisamment aux élèves pour que l'informatique et les résultats qu'elle donne n'apparaissent pas comme quelque chose d'ésotérique. Cela permet aussi de comprendre pourquoi certaines façons de faire qu'on pouvait être contraint d'utiliser avec certaines calculatrices limitées sont à bannir.

Dans cette note, je donne des éléments concernant le comportement des flottants, ce qui m'amènera à parler un peu de la façon dont ils sont représentés dans la mémoire de l'ordinateur. Je n'aborderai pas les entiers. Bien qu'il y a des choses à dire sur les entiers en machine, en Python les entiers proposés se comportent exactement comme les entiers mathématiques : tout va bien.

Le fonctionnement interne : les flottants

Nombres dyadiques

Nul n'en sera surpris, les flottants sont représentés en mémoire en base 2. Seuls sont donc éventuellement représentables les nombres qui admettent une écriture à virgule en base deux : on les appelle les nombres dyadiques ; ils sont à la base 2 ce que les décimaux sont à la base 10. Un réel est dyadique si et seulement s'il admet une écriture sous la forme \(\frac A{2^k}\) avec \(A\) et \(k\) des entiers.

Ainsi, tout nombre dyadique est décimal, mais la réciproque est fausse : 0.1, qu'on est tenté d'utiliser comme pas dans un algorithme, est un mauvais choix car il n'est pas dyadique : lorsqu'on écrit 0.1 en Python, on ne manipule pas 0.1 mais le flottant le plus proche de cette quantité.

Mais bien sûr tous les nombres dyadiques ne sont pas représentables comme flottants. Pour écrire un flottant, on se donne un certain nombre de chiffres binaires (bits), souvent 64 sur les machines modernes : on pourra donc représenter au maximum \(2^{64}\) nombres dyadiques. Cela signifie qu'on ne pourra pas représenter les dyadiques dont l'écriture comprend trop de chiffres binaires : on sera obligé d'arrondir.

Reste à décider quels dyadiques on représente. On doit choisir un intervalle en dehors duquel aucun nombre ne sera représentable. Mais on doit aussi choisir une résolution, un pas d'un représentable au représentable suivant. Une possibilité est de fixer un pas uniforme pour tout l'intervalle représentable : c'est le système de la virgule fixe, qui revient en fait à compter avec des entiers (bornés) avec une unité plus petite. C'est pratique dans les calculs monétaires par exemple : on sait qu'on veut partout une précision au millième d'euro, donc on compte en millièmes d'euros.

Les flottants

Au contraire, le système de la virgule flottante établit un pas qui dépend de l'ordre de grandeur du nombre à représenter, ce qui permet de conserver le même nombre de chiffres significatifs partout.

Tout réel non nul peut s'écrire de façon unique en écriture scientifique en base 2, c'est-à-dire \((-1)^s\times m\times 2^e\) avec \(s\in\{0,1\}\), \(m\in[1,2[\) et \(e\in\mathbb Z\). Ces nombres sont appelés signe, mantisse et exposant du réel. On utilise 1 bit pour représenter le signe et un nombre conventionnel de bits pour la mantisse, en pratique 52, et pour l'exposant, en pratique 11. Un flottant est constitué en mémoire par la mise bout-à-bout du bit de signe, des 11 bits qui représentent (astucieusement) l'exposant et des 52 bits qui représentent (tout aussi astucieusement) la mantisse.

On dispose donc sur presque tout l'intervalle \([-2^{2^{10}}, 2^{2^{10}}]\) d'une précision à 53 chiffres binaires significatifs. Pourquoi 53 alors qu'on n'a que 52 bits pour la mantisse ? Parce que la mantisse étant dans l'intervalle \([1, 2[\), elle commence forcément par le chiffre 1, et on n'a donc pas besoin de le noter en mémoire, ce qui fait gagner un chiffre.

Conséquences pratiques

Quand on arrive à l'ordre de grandeur \(2^{53}\), l'intervalle qui sépare un flottant du flottant suivant devient plus grand que 1 : il y a des entiers qui ne sont pas représentables (tous les entiers impairs, puis tous entiers pairs qui ne sont pas des multiples de 4, etc.). Voilà pourquoi on ne doit pas tester la divisibilité d'un entier a par un entier b en faisant a/b == a//b (ou pire a/b == int(a/b)) : outre que ça fait calculer deux divisions dont une en flottants, cette dernière n'est plus assez précise dès l'ordre de grandeur \(2^{53}\). Le test proposé conduit à considérer \(2^{53} + 1\) comme un entier pair. La division entière avec // donne bien sûr \(2^{52}\) et la division en flottants se déroule comme suit : quand on fait (2**53+1)/2, Python convertit les deux opérandes en flottants car / est l'opérateur de division flottante ; mais pour2**53+1, cela donne 2.0**53 dont la division par 2.0 donne 2.0**52, qui est converti en 2**52 pour la comparaison avec l'entier trouvé de l'autre côté du == (à moins que ce ne soit l'autre qui soit converti en flottant, je ne sais plus).

Heureusement, Python propose l'opérateur modulo qui permet de faire ce test avec a % b == 0 ce qui est exact, plus rapide et plus élégant.

Il y a d'autres conséquences, notamment le fait que l'addition n'est pas associative sur les flottants (elle est en revanche commutative). Plus généralement, on subit des pertes de précision lorsqu'on ajoute ou soustrait des flottants dont les ordres de grandeurs sont très différents : pour le faire, il est nécessaire d'exprimer temporairement les deux flottants avec l'exposant le plus fort pour pouvoir traiter bit à bit les mantisses ainsi alignées. Mais en raison de la taille limitée de ces dernières, on perd au passage les chiffres les moins significatifs de l'opérande le plus petit. 2.0**53 + 1.0 en est un cas extrême : le petit opérande est si petit devant l'autre qu'il disparait complètement lors de cet alignement.

Représentation pour l'affichage

Tout ceci explique pourquoi il n'est pas étonnant que 1.1+1.1+1.1 ne donne pas 3.3 : on manipule des quantités qui ne sont pas ce qu'on croit. Mais pourquoi le résultat 3.3000000000000003 apparait-il soudain alors qu'on avait un gentil 2.2 quand on évaluait 1.1+1.1 ?

C'est que les entrées et sorties de la console Python sont en base 10 : après avoir calculé avec les flottants, Python doit produire une écriture décimale correspondant au résultat. Compte tenu de l'existence d'un écart entre chaque flottant et le suivant, il y a en fait une infinité d'écritures décimales qui conviendraient. Python choisit la plus courte écriture décimale ayant la propriété suivante : si on copie-colle ce résultat affiché comme nouvelle entrée, le flottant qui sera ainsi lu sera le même.

En résumé 1.1 ne vaut pas 1.1 (mais un peu plus), 1.1+1.1 ne vaut pas 2.2 mais un flottant un peu plus grand ; cependant si on saisit 2.2, on retombe sur ce flottant. Tandis que 3.3 s'évalue en un flottant qui est différent de celui obtenu en faisant 1.1+1.1+1.1 (le premier est un peu en dessous de 3.3, le deuxième un peu au dessus).

On peut connaitre la valeur précise qu'on manipule en écrivant un nombre à virgule avec le module decimal de Python :

In [1]: import decimal
 
In [2]: decimal.Decimal.from_float(1.1)
Out[2]: Decimal('1.100000000000000088817841970012523233890533447265625')
 
In [3]: decimal.Decimal.from_float(1.1+1.1)
Out[3]: Decimal('2.20000000000000017763568394002504646778106689453125')
 
In [4]: decimal.Decimal.from_float(2.2)
Out[4]: Decimal('2.20000000000000017763568394002504646778106689453125')
 
In [5]: decimal.Decimal.from_float(1.1+1.1+1.1)
Out[5]: Decimal('3.300000000000000266453525910037569701671600341796875')
 
In [6]: decimal.Decimal.from_float(3.3)
Out[6]: Decimal('3.29999999999999982236431605997495353221893310546875')

Et les calculatrices ?

Il existe d'autres façons de manipuler des pseudo-réels en mémoire, notamment le « décimal codé binaire », qui consiste, en gros, à écrire dans la mémoire les nombres en base 10 comme on le ferait en math. On obtient alors un comportement plus proche de ce qui se passe en math. En particulier on peut manipuler 1.1.

Le cout de cette représentation est double : on consomme plus de mémoire et on calcule plus lentement, parce qu'on ne bénéficie pas des circuits de calcul optimisés du processeur qui travaillent avec les flottants.

Conclusion

Encore une fois, on ne va pas enseigner tout cela en seconde. Mais j'espère avoir fourni quelques éléments qui pourront aider à répondre à des questions d'élèves curieux.

Pour aller plus loin sur l'aspect technique, on peut lire l'article Virgule flottante de Wikipédia par exemple.

Guillaume Connan propose un article expliquant plus conceptuellement comment on peut avoir des garanties sur les calculs en flottants : le lemme de Sterbenz (1973) assure que pour deux flottants tels que \(\frac x2\leq y\leq 2\,x\), la différence \(x-y\) est calculée sans erreur ; plus généralement, l'erreur commise lors de l'addition de deux flottants est elle-même un flottant, dont on peut récupérer exactement la valeur ! Cela permet de concevoir des algorithmes (Dekker-Kahan notamment) d'addition qui minimisent l'accumulation de ces erreurs.

Ces limitations intrinsèques posent des difficultés dans les algorithmes numériques. Le pivot de Gauss, notamment, est numériquement instable Pour les algorithmes itératifs (méthode d'Euler, etc.), choisir un pas plus petit n'améliore pas toujours le résultat ; on peut estimer le pas \(h\) optimal pour un problème et une méthode données ; voir (si on le trouve car il est épuisé) l'excellent Analyse numérique et Équations différentielles de Jean-Pierre Demailly, dont je ne résiste pas au plaisir de citer l'introduction :

Il existe des logiciels libres spécialisés dans le calcul numérique qui implémentent les principaux algorithmes utiles sous forme de librairies toutes prêtes – Scilab est l’un des plus connus [les bibliothèques numpy et scipy de Python le sont désormais aussi] – mais d’un point de vue pédagogique et dans un premier temps au moins, il est bien plus formateur pour les étudiants de mettre vraiment “la main dans le cambouis” en programmant eux-mêmes les algorithmes. Nous ne citerons pas d’environnements ni de logiciels propriétaires équivalents, parce que ces logiciels dont le fonctionnement intime est inaccessible à l’utilisateur sont contraires à notre éthique scientifique ou éducative, et nous ne souhaitons donc pas en encourager l’usage.