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:
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ę:
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); }
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);
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);
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.
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).