Deux nombres flottants égaux... ne sont pas égaux !
1 décembre 2014 —Il y a quelques années de cela, je me suis un jour retrouvé face à une fonctionnalité inattendue1 d’une application e-commerce : une action, qui n’aurait dû être effectuée que si le montant enregistré sur une commande était différent de la somme des montants des produits la composant2, était déclenchée bien plus souvent que nécessaire.
En fouillant dans les portions de code en rapport avec ces points, voici globalement comment le prix total de la commande était déterminé3 :
$priceShipping = 0.00;
$priceTotal = 35.93 - $priceShipping;
Le prix total des produits figurant sur la commande était calculé comme ceci4 :
$pricesItems = [4.99, 4.99, 4.99, 4.99, 4.99, 5.49, 5.49];
$priceTotalItems = 0;
foreach ($pricesItems as $priceItem) {
$priceTotalItems += $priceItem;
}
Jusqu’ici, rien de bien gênant.
Par contre, les choses s’enveniment sur la condition suivante :
if ($priceTotal === $priceTotalItems) {
echo "Prix égaux\n";
}
else {
echo "Prix différents\n";
}
Naïvement, en écrivant cette condition, quelqu’un s’est dit qu’il allait comparer le contenu de deux variables et que si les nombres correspondant n’étaient pas égaux, une action spécifique5 serait lancée. OK, pourquoi pas ?
À titre de vérification, dumpons le contenu des deux variables contenant les deux montants, avant les quelques lignes reproduites juste au-dessus :
var_dump($priceTotalItems, $priceTotal);
Passons à l’exécution de ces quelques portions de code.
La sortie est reproduite ci-dessous… Et elle est quelque peu surprenante :
$ php test-01.php
float(35.93)
float(35.93)
Prix différents
Surprise !
De toute évidence, 35.93
et 35.93
sont égaux, non ? Et pourtant, la sortie indique que ces deux montants sont différents !
Pour s’en assurer, ré-exécutons ces quelques lignes de code, en augmentant la précision d’affichage :
$ php -d precision=40 test-01.php
float(35.93000000000000682121026329696178436279)
float(35.92999999999999971578290569595992565155)
Prix différents
Et voila ! En travaillant avec une précision d’affichage supérieure à celle utilisée par PHP par défaut, nous obtenons plus de décimales dans nos dumps de debug – et libre à nous de constater que nos deux nombres flottants, même s’ils paraissent égaux si l’on s’arrête à deux décimales, ne le sont en réalité pas si on regarde plus loin.
La leçon à retenir est qu’il ne faut pas comparer en égalité deux nombres flottants.
À la place, il est plus sûr de vérifier si l’écart entre eux est inférieur à un seuil qui nous semble acceptable.
Par exemple, dans notre cas où nous manipulons des montants en euro, nous pourrions écrire ceci :
if (abs($priceTotal - $priceTotalItems) <= 0.0001) {
echo "Prix considérables comme égaux\n";
}
else {
echo "Prix considérables comme différents\n";
}
Les nombres flottants de PHP sont représentés en interne par le format IEEE 754 double et ne sont donc pas exacts.
Il est impératif de ne pas oublier ce fait lorsqu’on est amené à en manipuler ainsi qu’à effectuer des calculs dessus !
Petite question pour finir :
Est-ce que
0.1 + 0.2
est égal à0.3
?
Tout de même, pour aller un peu plus loin, n’oublions pas que PHP, à son habitude, se repose sur les fonctionnalités que C fournit. Nous pourrions donc écrire le programme suivant en C, pour voir ce qu’il en est avec ce langage :
#include <stdio.h>
int main()
{
double price_shipping = 0;
double price_total = 35.93 - price_shipping;
double prices_items[] = {4.99, 4.99, 4.99, 4.99, 4.99, 5.49, 5.49};
double price_total_items = 0;
for (int i=0 ; i<7 ; i++) {
price_total_items += prices_items[i];
}
if (price_total == price_total_items) {
printf("Prix égaux\n");
}
else {
printf("Prix différents\n");
}
return 0;
}
Pour compiler (avec gcc, ici) :
$ gcc -W -Wall --std=c99 test-01.c -o test-01
Et à l’exécution, nous obtenons le même type de résultat qu’avec PHP :
$ ./test-01
Prix différents
Un point intéressant : dans ce cas précis, si nous utilisons des float
en simple précision (et pas des double
s en double précision), le programme indiquera que les deux montants totaux sont égaux (la différence qui les sépare doit porter sur plus loin que la précision des float
s).
Bien évidemment, nous obtiendrions la même chose avec le même programme ré-écrit en JAVA :
class Test01
{
public static void main(String[] args) {
double price_shipping = 0;
double price_total = 35.93 - price_shipping;
double prices_items[] = {4.99, 4.99, 4.99, 4.99, 4.99, 5.49, 5.49};
double price_total_items = 0;
for (int i=0 ; i<7 ; i++) {
price_total_items += prices_items[i];
}
if (price_total == price_total_items) {
System.out.println("Prix égaux");
}
else {
System.out.println("Prix différents");
}
}
}
Par contre, cette fois-ci, l’affichage par défaut mettrait immédiatement en évidence le fait que ces deux montants ne sont pas égaux, puisque price_total
serait affiché comme valant 35.93
, alors que price_total_items
provoquerait la sortie de 35.93000000000001
.
Ne nous arrêtons pas là : nous obtiendrions encore la même chose avec un script en Ruby :
price_shipping = 0.00
price_total = 35.93 - price_shipping
prices_items = [4.99, 4.99, 4.99, 4.99, 4.99, 5.49, 5.49]
price_total_items = 0
prices_items.each do |price_item|
price_total_items += price_item
end
if price_total == price_total_items
puts "Prix égaux\n"
else
puts "Prix différents\n"
end
Afficher (en paramètres par défaut) nos deux montants donnerait le même type de sortie qu’en JAVA.
En somme, vous l’aurez compris, IEEE 754 est utilisé à peu près partout lorsqu’il s’agit de manipuler des nombres flottants – et il nous revient de connaître leurs limitations et de veiller à ne pas nous faire avoir ;-)
-
Il y a quelques temps, j’ai entendu dire qu’il n’était pas bon de parler de bugs dans les applications sur lesquelles je bossais, que cela faisait peur. Le terme de fonctionnalité inattendue s’est imposé comme une réponse à cette remarque. Et même si celle-ci n’était pas vraiment à prendre au sérieux, j’ai retenu ce fonctionnalité inattendue, que j’affectionne quelque peu ^^ ↩︎
-
Le montant est enregistré au niveau de la commande lors du paiement de celle-ci. Si un produit est annulé plus tard, la somme des montants des produits restant devient différente du montant enregistré sur la commande – c’est un comportement voulu et normal. ↩︎
-
Pour cet exemple,
0.00
correspond aux frais de port et35.93
au montant enregistré sur la commande – ces deux informations provenant de la base de données. ↩︎ -
Ici encore, les prix individuels des produits composant la commande proviennent en réalité de la base de données. ↩︎
-
Bien évidemment, dans l’application dont cette portion de code quelque peu simplifiée est extraite, l’action spécifique est un peu plus complexe qu’un affichage ;-) ↩︎