Nombres à virgule flottante en C# en 5 min

Nombres à virgule flottante

En C#, pour stocker des nombres réels on peut utiliser les types:

  • float ou double qui sont des types de nombres binaires à virgule flottante ou
  • decimal qui est un type de nombre décimal à virgule flottante.

Le choix précis d’un de ces types n’est pas anodin et ne doit pas se faire seulement sur le critère de l’intervalle toléré par le type comme ça pourrait être le cas pour les nombres entiers.

Par exemple, le type double est le type qui permet l’intervalle le plus grand pourtant il est le type qui introduit le plus d’erreurs de calcul. Ainsi, il faut avoir en tête toutes les conséquences du choix du type de nombre à virgule flottante pour ne pas être surpris par certains de ces comportements.

Généralités sur les types à virgule flottante

Tous les nombres n’ont pas forcément une représentation exacte dans un type C#. Les nombres entiers ont le plus souvent une représentation exacte mise à part s’ils dépassent l’intervalle de valeurs tolérés par les types entiers (“integral types”):

Alias Type Valeur min Valeur max Taille (bits)
byte System.Byte 0 255 8
sbyte System.SByte -128 127 8
short System.Int16 -32768 32767 16
ushort System.UInt16 0 65535 16
char System.Char 0 65535 16
int System.Int32 –2147483648 2147483647 32
uint System.UInt32 0 4294967295 32
long System.Int64 -9,223272x1018 9.223372x1018 64
ulong System.UInt64 0 18,446744x1018 64

Comme ces intervalles contiennent un nombre fini de valeurs, si le nombre appartient à un des intervalles, il sera représenté de façon exacte dans le type correspondant.

Pour d'autres nombres, trouver une représentation en informatique peut être plus compliqué car:

  • Si le nombre est entier mais n'appartient pas à un des intervalles précédents, il faut pouvoir le représenter avec un nombre fini d'espace mémoire.
  • Certains nombres réels sont irrationnels et ne possède pas un nombre fini de chiffres donc quelque soit le moyen utilisé, mathématiquement il est impossible d'en avoir une
    valeur précise.
  • Sans aller jusqu'au cas des nombres irrationnels, de nombreux nombres rationnels n'ont pas une représentation exacte dans un type informatique parce qu'il faut stocker les chiffres de ce nombre dans un espace mémoire fini et qui est donc limité.
  • Même dans le cas où un nombre décimal a un nombre fini de chiffres, il peut ne pas en exister une représentation exacte car la méthode utilisée pour le stocker dans une variable en C# ne le permet pas.
  • Enfin si ce nombre n'appartient pas à l'intervalle de valeurs d'un des types primitifs en C#, il ne pourra pas être stocké tel quel dans un de ces types.

Pour permettre de stocker le plus grand nombre des nombres réels possibles, on utilise des types à virgule flottante. La virgule flottante permet ainsi de choisir la position de la partie décimale de façon à pouvoir stocker des nombres entiers et des réels non entiers.

Les types à virgule flottante en C# sont:

Alias Type Valeur min Valeur max Chiffres significatifs Taille (bits)
float System.Single -3,4x1038 3,4x1038 7 32
double System.Double -1,7x10308
5x10-324 en valeur absolue
1,7x10308 15 à 16 64
decimal System.Decimal -7,9x1028 ou -(296-1)
10-28 en valeur absolue
7,9x1028 ou 296-1 28 à 29 128

La méthode générale pour stocker les nombres pour ces types est de décomposer un nombre de cette façon:

N = signe x mantisse x 10(exposant)

Par exemple:
87,357 = 87357 x 10-3

La mantisse et l'exposant sont des nombres entiers en sens mathématique. Le signe est 1 ou -1. Suivant les types, la façon de stocker le signe, la mantisse et l'exposant diffère:

  • en fonction de la quantité de mémoire utilisée,
  • la façon de stocker ces nombres en binaire.

Initialisation des types à virgule flottante en C#

Les types à virgule flottante s'initialisent de cette façon:

Type Suffixe Exemple
float f ou F 32.8f
4.246e-15
double d ou D 76d
87.34e121
decimal m ou M 3463.56m

Erreurs possibles

Comparaison

L'erreur la plus facile lorsqu'on utilise des variables avec un type à virgule flottante est de croire qu'il existe systématiquement une comparaison stricte possible entre 2 nombres. Sachant que certains nombres n'ont pas de représentation exacte dans un type à virgule flottante, on peut effectuer des calculs et ne pas avoir des comparaisons strictes possibles à l'issue de ces calculs. L'absence de comporaison stricte est particulièrement vraie pour les types float et double.

Par exemple si on considère la boucle:

for (int i = 0; i < 3000; i++) 
{ 
    if (i == 300) 
    { 
        Console.WriteLine("OK"); 
        break; 
    } 
}

On aura bien le résultat voulu c'est-à-dire que la boucle s'arrête pour i == 300 et qu'on affiche "OK".

Si on exécute:

for (double d = 100; d < 200; d = d + 0.001d) 
{ 
    if (d == 171.714d) 
    { 
        Console.WriteLine("OK"); 
        break; 
    } 
}

On n'aboutira pas à l'affichage de "OK" car les additions introduisent une erreur de calcul. La valeur la plus proche de 171.714 lors de l'exécution de la boucle est 171.714000000342342. L'égalité ne sera donc jamais satisfaite.

Cumul des erreurs de calculs

La 2e erreur possible dans le traitement de types à virgule flottante est le cumul d'erreurs de calculs en dehors des approximations dues aux calculs mathématiques approchées.

Par exemple, en écrivant ce type de boucle, on cumule les erreurs de calculs à chaque multiplication:

double d = 0.00321d; 
while (d < 1e4) 
{ 
    d = d * 10d; 
}

En théorie, cette boucle doit toujours aboutir à un nombre entier. Toutefois, après la première multiplication:

0.00321 x 10

On obtient pas 0.0321 mais 0.032100000000000004.
Au fur et à mesure des multiplications suivantes, les erreurs sont cumulées et on arrivera pas à un résultat entier mais à 32100.000000000007.

Utilisation de chaines de caractères

Dans le cas de conversion entre types à virgule flottante et chaines de caractères, il faut aussi prendre en compte les inexactitudes dans la représentation des nombres.

D'abord on l'a vu dans les exemples précédents, certains nombres n'ont pas de réprésentation exacte:

double d = Double.Parse("0.032100000000000001");

d vaut 0.032100000000000004.

Ensuite, il faut garder en tête que la représentation d'un nombre en chaîne de caractères change en fonction de la culture. Le caractère utilisé pour indiquer la décimal peut être "." ou ",". On peut aussi utiliser "," pour séparer les multiples de 1000: 7,246,876.546.
Il faut donc effectuer les conversions le plus souvent en utilisant la culture invariante:

double d = Double.Parse("2054,43", CultureInfo.InvariantCulture);

Enfin, sachant que les types à virgule flottante comporte un nombre significatif de chiffres, la conversion de chaîne de caractères à type à virgule flottante peut introduire une approximation.

Par exemple:

double d = Double.Parse("0.0000999999998333333343", CultureInfo.InvariantCulture);

d sera égal à 0,00009999999983333333.

Si on fait:

string dAsString = d.ToString();

On aura:
9,99999998333333E-05
Ce qui est différent du nombre d'origine.

Nombres binaires à virgule flottante

Les types binaires à virgule flottante stockent les nombres sous forme de chiffres binaires (0 et 1). Comme indiqué précédemment, le nombre se décompose sous la forme générale:

N = signe x mantisse x 10(exposant)

Stockage des nombres binaires à virgule flottante

La mantisse et l'exposant sont des nombres binaires stockés dans un mot entier comportant 32 bits pour le type float et 64 bits pour le type double:

Type Nombre total de bits Nombre de bits pour le signe Nombre de bits pour la mantisse Nombre de bits pour l'exposant
float 32 1 23 8
double 64 1 52 11

Pour être plus précis, un nombre stocké en double se décompose suivant:

N = (-1)s x 1.m x 2(e - 1023)

Avec:

  • s peut être égale à 0 ou 1.
  • m est une suite de chiffres binaires
  • e est sous sa forme décimal dans la formule toutefois il est bien stocké de façon binaire.

Par exemple:

s m e
1 bit 52 bits 11 bits
0 0011 0011 1101 0011 0000 0111 0000 0000 0000 0000 0000 0000 0000 1000 0000 101
(-1)0 1.0011001111010011000001110000000000000000000000000000 10000000101 en binaire
1029 en décimal
2(1029 - 1023)=26
1 1001100.1111010011000001110000000000000000000000000000 en binaire
76,62657 en décimal

Erreurs possibles

Les types float et double provoquent facilement des erreurs car l'utilisation d'un stockage binaire ne permet pas d'avoir d'avoir une représentation exacte pour beaucoup de nombres. Il arrive fréquemment d'avoir quelques inexactitudes entre l'affectation d'une valeur dans une variable de type double et la valeur effectivement stockée dans la variable.

Comparaison de nombres binaires à virgule flottante

Comme indiqué précédemment, il faut éviter de faire des comporaisons strictes entre binaires à virgule flottante. Il vaut mieux introduire une erreur possible et tenter de calculer la différence entre les nombres et de la comparer avec cette erreur.

Par exemple, au lieu d'écrire une comparaison stricte:

double a = 0.000497d; 
double b = 0.0004971d; 
if (a == b) 
{ 
    // ...
}

On pourrait tenter d'écrire:

double e = 0.00000001; 
if (Math.Abs(a – b) < e) 
{ 
    // ...
}

Le plus difficile est de déterminer la valeur de l'erreur e car elle change suivant l'ordre de grandeur de a et b. Suivant la précision souhaitée, fixer arbitrairement e à "quelques décimales" des double à comparer peut suffire à condition que a et b soient de même ordre de grandeur, par exemple:

if (a > 0 && a < 1) 
{ 
    e = Math.Pow(10, Math.Floor(Math.Log(a)) - 4d); 
}

il n'y a pas de définition absolue de e. Sa valeur varie suivant l'ordre de grandeur de a et b.

Double.Epsilon

Il existe la constante Double.Epsilon qui est la valeur absolue la plus petite entre 2 valeurs de double. Il ne faut pas confondre cette valeur avec les imprécisions de représentation des double. La plupart du temps, Double.Epsilon n'est pas détectable et utiliser cette constante pour tester des égalités entre double aboutira à des comparaisons fausses.

Cas particuliers

Les types float et double peuvent avoir des valeurs particulières qui peuvent mener à des calculs faux. Ces valeurs particulières peuvent résulter d'opérations effectués sur les float ou double.

Dans le cas du type double et du float (System.Single), les valeurs particulières sont:

Nom Explication Valeur Détection
Inférieure à la normale (subnormal) La valeur est très basse et sort de l'intervalle de valeurs du float ou du double . N/A Il n'y a pas de méthode fournie directement par framework.
Infini positif La valeur est très élevée et sort de l'intervalle de valeurs. Double.PositiveInfinity
Single.PositiveInfinity
Double.IsPositiveInfinity()
Single.IsPositiveInfinity()
Infini négatif La valeur sort de l'intervalle de valeurs. Double.NegativeInfinity
Single.NegativeInfinity
Double.IsNegativeInfinity()
Single.IsNegativeInfinity()
NaN "quiet"
(NaN pour Not A Number)
Signifie que le résultat mathématique d'une opération est indéterminé. Double.NaN
Single.NaN
Double.IsNaN()
Single.IsNaN()
NaN "signalling" Signifie que l'opération menée est invalide. Ce type NaN signalle une exception. Double.NaN
Single.NaN
Double.IsNaN()
Single.IsNaN()

Détection d'un binaire à virgule flottante "subnormal":
Sur stackoverflow, une méthode a été proposée qui compare les bits correspondant à l'exposant de façon à vérifier s'ils sont tous à 0:

const long ExponentMask = 0x7FF0000000000000;  // 2047 en décimal 
static bool IsSubnormal(double v) 
{ 
    long bithack = BitConverter.DoubleToInt64Bits(v); 
    if (bithack == 0) return false; 
    return (bithack & ExponentMask ) == 0; 
}
Comparaison Double.NaN == Double.NaN

Le résultat de Double.NaN == Double.NaN est false. Il faut donc plutôt utiliser Double.IsNaN().

Conversion en chaînes de caractères "round-trip"

Il est possible de convertir les double en chaînes de caractères avec un paramètre "round-trip" qui permet de garantir que la valeur numérique convertit en chaîne donnera la même valeur si cette chaîne est parsée pour retrouver la valeur double:

double d1 = 0.82469345678395475; // 17 chiffres  
string s = d1.ToString("R");  
double d2 = double.Parse(s);

Avec l'option "R", d1 est égal à d2.

Dans le cas du double et du float, si une valeur est convertie en string et si la conversion retour de string version double ou float réussit, la valeuir contiendra au maximum 17 chiffres pour les double et 9 chiffres pour les float.

Dans le cas des assemblies compilées en x64 ou en AnyCPU exécutée en 64 bits sur un système 64 bits, la conversion avec l'option "R" peut ne pas réussir, il est préférable d'utiliser l'option "G17".

Plus de détails sur cette option "R" sur MSDN.

Nombre decimal à virgule flottante

Le type decimal en C# stocke un nombre directement sous sa forme décimal de façon à garantir la représentation décimale du nombre et d'éviter les erreurs. Le type decimal est donc plus exacte que les types à virgule flottante binaires. La contrepartie de cette précision est un intervalle de valeurs plus restreint et des performances moins bonnes pour les calculs.

La forme générale pour stocker un nombre est toujours:

N = signe x mantisse x 10(exposant)

Stockage des nombres décimaux à virgule flottante

Pour avoir une forme décimale plus exacte, le type decimal utilise 128 bits répartis de cette façon:

  • 96 bits pour stocker la mantisse. Plus précisemment 3 entiers de 32 bits permettent de stocker cette mantisse, 32 x 3 = 96 bits.
  • 1 bit pour stocker le signe.
  • 5 bits permettent de stocker l'exposant.
  • Les 26 bits restant (1+96+5 = 102 bits) sont à 0.

L'ordre utilisé pour stocker les bits est:

Entier 32 bits Entier 32 bits Entier 32 bits Entier 32 bits
Mantisse Signe Exposant
32 bits 32 bits 32 bits 1 bit Bits 1 à 15 avec une valeur à 0 Bits 16 à 20 Bits 21 à 31 avec une valeur à 0

On peut obtenir les 4 entiers définissant un decimal en faisant Decimal.GetBits().

Le decimal permet d'avoir 28 chiffres significatifs donc même si l'intervalle de valeurs est plus restreint que pour le types binaires à virgule flottante, le decimal permet une plus grande précision.

En effet la mantisse de 96 bits permet de stocker les 28 à 29 chiffres significatifs et l'exposant sur 5 bits permet d'avoir la position de la partie décimale de 0 à 28.
On peut en déduire l'intervalle de valeurs:

  • Le plus petit nombre en valeur absolue est 10-28
  • L'intervalle de valeurs étant de -(296-1) à 296-1.

Erreurs possibles

Le type decimal donne des résultats plus précis que les float et double:

  • les conversions en chaîne de caractères sont plus précises,
  • les comparaisons entre nombres peuvent être strictes et ne nécessitent pas d'introduire une erreur,
  • Il n'existe pas de valeurs particulières comme l'infini ou NaN.

Toutefois, il existe toujours des nombres dont la représentation ne sera pas tout à fait exacte dans un type decimal mais ce cas reste beaucoup plus rare que pour les types float et double. Dans le cas d'une représentation approchée, la valeur est arrondie à la valeur représentable la plus proche. Si 2 valeurs sont aussi proches, la valeur ayant un nombre impair pour le plus petit chiffre significatif est choisie ("banker's rounding").

System.OverflowException

Dans le cas où un nombre est plus petit que la tolérance du type decimal, sa valeur devient 0. Si une opération génère une valeur dépassant l'intervalle de valeurs d'un decimal, une exception du type System.OverflowException est lancée.

Dans le cas des entiers, il est possible d'éviter une exception System.OverflowException lorsqu'une opération dépasse l'intervalle de valeurs de System.Int32, en changeant le comportement du CLR en cas d'un dépassement provoqué par une opération arithmétique ("arithmetic overflow/underflow"). Ce paramêtre se trouve dans:

  1. Propriété du projet (clique droit sur le projet dans Visual Studio)
  2. Onglet "Build"
  3. Cliquer sur "Advanced"
  4. Cocher "Check for arithmetic overflow/underflow".

Si le paramètre est décoché, les dépassements causés par des opérations arithmétiques n'entraîneront pas d'exception.

Ce paramêtre n'a pas d'effets pour le type decimal, le code suivant entraînera toujours une excetion:

decimal first = Decimal.MaxValue;  
decimal second = first + 1;

Les opérations arthmétiques n'entraînent pas d'exceptions pour les types binaires à virgule flottante float et double.

Conclusion

  • Les types float et double ne conviennent pas dans tous les cas pour stocker des nombres même si leur intervalle de valeurs est plus grand que le type decimal.
  • Les float et les double sont moins précis que les decimal mais ils permettent d'effectuer des calculs plus rapidement.
  • Il faut éviter de faire des tests avec des nombres float ou double en effectuant des égalités strictes.
  • La conversion de chaînes de caractères en float ou double peut mener assez souvent à des pertes de précisions.
  • Les variables de type float ou double peuvent contenir des valeurs particulières comme NaN (not a number), une valeur infinie ou un valeur "subnormal" qu'il faut tester avant d'effectuer un calcul.
  • Le type decimal étant plus précis est plus approprié pour les manipulations de nombres dans le domaine financier.
  • Les opérations arithmétiques entraînant des dépassements de l'intervalle du type decimal provoquent des exceptions System.OverflowException. Dans le cas des types float et double, il n'y a pas d'exceptions.
3 responses... add one

Nice, en gros à part pour des chiffres très long on utilise pas float et double

La question est,avec la marge d’erreur de ces type ça les rend quasi inutile ?

Ca dépend aussi du nombre de chiffres significatifs du nombre. Dand la cas où le nombre de chiffres significatifs est importants, il vaut mieux privilégier le type decimal, la contre partie c’est que le type decimal autorise des nombres moins grands ou moins petits que les float ou les double.
Pour rėsumer 2 éléments sont à prendre en compte, la taille du nombre mais aussi le nombre de chiffres significatifs du nombre.

Leave a Reply