Nombres à virgule flottante
En C#, pour stocker des nombres réels on peut utiliser les types:
float
oudouble
qui sont des types de nombres binaires à virgule flottante oudecimal
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 binairese
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
.
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;
}
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:
- Propriété du projet (clique droit sur le projet dans Visual Studio)
- Onglet "Build"
- Cliquer sur "Advanced"
- 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
etdouble
ne conviennent pas dans tous les cas pour stocker des nombres même si leur intervalle de valeurs est plus grand que le typedecimal
. - Les
float
et lesdouble
sont moins précis que lesdecimal
mais ils permettent d'effectuer des calculs plus rapidement. - Il faut éviter de faire des tests avec des nombres
float
oudouble
en effectuant des égalités strictes. - La conversion de chaînes de caractères en
float
oudouble
peut mener assez souvent à des pertes de précisions. - Les variables de type
float
oudouble
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 exceptionsSystem.OverflowException
. Dans le cas des typesfloat
etdouble
, il n'y a pas d'exceptions.
- Decimal floating point in .NET: http://csharpindepth.com/Articles/General/Decimal.aspx
- Binary floating point and .NET: http://csharpindepth.com/Articles/General/FloatingPoint.aspx
- Getting Started with C#: http://www.informit.com/articles/article.aspx?p=23211
- How to check in C# if the given double number is normal, i.e. is neither zero, subnormal, infinite, nor NaN: http://stackoverflow.com/questions/26575345/how-to-check-in-c-sharp-if-the-given-double-number-is-normal-i-e-is-neither-ze
- Difference between decimal, float and double in .NET?: http://stackoverflow.com/questions/618535/difference-between-decimal-float-and-double-in-net
- Behind the scenes, what's happening with decimal value type in C# -.NET?: http://stackoverflow.com/questions/3294153/behind-the-scenes-whats-happening-with-decimal-value-type-in-c-net
- Arithmetic Overflow in C#: https://chodounsky.net/2014/01/22/arithmetic-overflow-in-c-number/
- Handling Arithmetic Overflows in C#: http://www.shujaat.net/2012/04/arithmetic-overflows-in-c.html
- Arithmetic Overflow in .NET - Some Nitty Gritties: http://www.codeproject.com/Articles/1097872/Arithmetic-Overflow-and-Underflow-in-Net-Some-knit
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.