decimal vs double, część I

W kilku wpisach chciałbym przedstawić różnicę między tymi dwoma typami. Osoby piszące aplikacje finansowe z pewnością znają te różnice bo to właśnie w tych typach aplikacji, double powodował bardzo poważne błędy.

Przed przeczytaniem wpisu, niezbędne będzie przypomnienie sobie następujących informacji:

1. Przeliczanie systemu binarnego, szczególnie części dziesiętnej.
2. Liczby zmiennoprzecinkowe.

Nie będę tego opisywał na blogu, ponieważ są to zagadnienia wyjaśniane już wiele razy – ale pisząc aplikacje wysokopoziomowe niestety łatwo zapomnieć o podstawach informatyki.

Typ double jest najpopularniejszy wśród programistów, ale bardzo często nie ma to uzasadnienia. Zacznijmy od napisania takiego programu:

float a = 0.1f;
double b = a;

Console.WriteLine(a);
Console.WriteLine(b);

Na ekranie zobaczymy:

image

Dlaczego? W końcu mamy taką samą liczbę i double ma większą dokładność niż float, stąd może wydawać się dziwne, że druga linia nie reprezentuje dokładnie liczby 0.1.

Inna sprawa, to przecież próbujemy wyświetlić prostą liczbę 0.1, 1/10. Niestety w rzeczywistości 0.1 nie ma dokładnej reprezentacji binarnej.

Zróbmy podobny eksperyment ale z 0.5 Na ekranie wyświetli się:

image

Jak widać, z 0.5 nie ma takiego już problemu. Spróbujmy zamienić 0.5 ma system binarny:

0.5 * 2 = 1 stąd w systemie binarnym 0.5 to po prostu 0.1. Nie ma żadnego problemu, aby przechować taką wartość w formie mantysy i wykładnika.

A co w przypadku 0.1?

0.1 * 2 = 0.2,  0.0

0.2 * 2 = 0.4,  0.00

0.4 * 2 = 0.8, 0.000

0.8 * 2 = 1.6, 0.0001

0.6 * 2 = 1.2, 0.00011

0.2 * 2 = 0.4, 0.000110 // i zaczynamy od nowa…

Problem w tym, że dziesiętnej liczby 0.1(1/10), nie da zamienić się na dokładną binarną reprezentację – powyższy przykład pokazuje, że liczba ma nieskończoną reprezentacje tak jak w systemie dziesiętnym ma 1/3 (0.333333…).

Przechowując 0.1 w double albo float, tak naprawdę nie przechowujemy dokładnej wartości, a wartość którą udało przechować się w pamięci.

Dlaczego zatem double wygląda mniej dokładniej niż float? Tak naprawdę, obie liczby zawierają dokładnie tą samą wartość a wszystko jest związane ze sposobem formatowania i wyświetlania na ekranie. Możemy użyć modyfikatora G7, aby wyświetlić liczby z tą samą precyzją. Innymi słowy, 0.1 w float będzie miał reprezentację typu 0.00011000011 a po konwersji do double, część mantysy będzie wyzerowana (to już zależy od kompilatora). Nie wiadomo tak naprawdę co z pozostałymi bitami stanie się, dlatego należy unikać mieszania float z double.

Na ekranie widzimy inne wyniki, ponieważ metoda ToString zdaje sobie sprawę, że float ma dokładność do 7 cyfr, a double do 16 – stąd wie, jak zaokrąglić liczbę. Normalnie, gdybyśmy użyli double b = 0.1, liczba 0.1 inaczej byłaby przechowania niż po rzutowaniu z float – więcej miejsc po przecinku moglibyśmy przechować w pamięci.

Wniosek z powyższych rozważań jest następujący – jeśli chcesz przechowywać wartości, które występują w systemie dziesiętnym, nie używaj double. Double zawsze używa reprezentacji binarnej o podstawie 2:  mantysa * 2^wykładnik.

Decimal jest również liczbą zmiennoprzecinkową. Tak samo jak float czy double, przechowuje w pamięci mantysę i wykładnik. Podstawa jest jednak zawsze równa 10 – stąd można dokładnie przechować 0.1 w pamięci.

Ma to olbrzymie znaczenie dla aplikacji finansowych, gdzie operuje się na wartościach dziesiętnych – pieniądzach. Wyobraźmy sobie następujący kod:

static void Main(string[] args)
{
  double doubleResult = 0;
  decimal decimalResult = 0;
  const int n = 100000*10000;

  for (int i = 0; i < n; i++)
  {
      doubleResult += 0.1;
      decimalResult += 0.1m;
  }

  Console.WriteLine(doubleResult);
  Console.WriteLine(decimalResult);
}

image

Jak widać, ze względu na fakt, że double nie przechowuje dokładnie 0.1 a jedynie wartość przybliżoną, błędy narastają, powodując na końcu poważną różnice. W aplikacjach, gdzie wykonujemy wiele obliczeń (np. konta bankowe itp.) jest to nieakceptowalne.

Zmienne double\float mają jednak pewne zalety. Przede wszystkim są dużo szybsze (ale o tym w przyszłym poście), ponieważ wspierane są przez sprzęt (CPU).

Double\float przechowują DUŻO większe liczby:

Console.WriteLine(float.MaxValue);
Console.WriteLine(double.MaxValue);
Console.WriteLine(decimal.MaxValue);

image

Zaglądając do MSDN dowiemy się również o następujących technicznych parametrach powyższych typów:

Typ Zasięg Precyzja Wielkość
float

-3.4 × 10^38 to +3.4 × 10^38

7 cyfr 32 bity
double ±5.0 × 10^324 to ±1.7 × 10^308 15-16 64
decimal (-7.9 x 10^28 to 7.9 x 10^28) / (10^(0 to 28)) 28-29 128

Decimal ma dużo mniejszy zasięg ale za to potrafi przechować liczby z wyższą dokładnością. Wyjątkiem są jednak bardzo małe liczby:

double a =  0.000000000000000000000000000001;
decimal b = 0.000000000000000000000000000001m;
Console.WriteLine(a);
Console.WriteLine(b);

image

Innymi słowy, float\double należy używać dla bardzo dużych albo bardzo małych liczb.

Proszę zauważyć, że w większości przypadków nie potrzebujemy tak wysokiej dokładności jaką daje nam decimal. W przypadku aplikacji przemysłowych, wszelkie sensory już dostarczają tak wysoką niedokładność, że nie ma to znaczenia. Ponadto, one w końcu operują na wartościach binarnych. Decimal warto używać, gdy liczby pochodzą od człowieka (który operuje systemem dziesiętnym) oraz gdy wykonujemy na nich operacje matematyczne. W przyszłym wpisie pokażę, różnicę w wydajności ponieważ jest ona znacząca.

Istnieje jeszcze jedna drobna różnica. float\double mają pojęcia takie jak NaN, PositiveInfinity oraz NegativeInfinity. Natomiast w przypadku decimal, wyrzucany jest wyjątek, gdy będziemy próbować dzielić przez zero.

One thought on “decimal vs double, część I”

  1. Jon Skeet poruszał podobny temat na ABB DevDay :P.
    Swoją drogą warto dodać jak to się robi w innych technologiach.
    Znane mi są 2 podejścia:
    1) Liczenie groszy – typ money albo brak typu to tak naprawdę long (albo złożenie dwóch longów) reprezentujących sumę w jednostce niepodzielnej w przypadku Polski w groszach.
    2) W Javie często używa się typu BigDecimal którego struktura jest opisana w tym http://stackoverflow.com/questions/2501176/java-bigdecimal-memory-usage wątku na StackOverflow.
    Wiedza ta może być przydatna jeżeli chcemy integrować swoją aplikację finansową z aplikacją w innej technologii (np. drukarki fiskalne liczą grosze).

Leave a Reply

Your email address will not be published.