Zacznę od krótkiego wprowadzenia na temat liczb zmiennoprzecinkowych i ich reprezentacji binarnej opisanej w standardzie IEEE 754. Jeśli doskonale znasz ten standard i sposób operowania na liczbach zmiennoprzecinkowych to artykuł nie będzie dla Ciebie zaskoczeniem. Jeśli nie jesteś ciekawy/a jak jest reprezentowany typ zmiennoprzecinkowy i skąd wywodzi się problem to wystarczy przewinąć artykuł na dół do miejsca Przykład w Javie.
Liczby zmiennoprzecinkowe to nic innego jak notacja naukowa do zapisywania bardzo wielkich liczb np. 1,23*1052 lub bardzo małych 2,33*10-24. Każdy element takiego zapisu ma swoją nazwę, dla liczby 1,23*1052 :
M – mantysa, tutaj równa 1,23
P – podstawy, tutaj równa 10
E – wykładnik, tutaj równe 52
S – znak, tutaj dodatni +
ten zapis nazwano zmiennoprzecinkowym, ponieważ jedną liczbę możemy zapisać na wiele sposobów przemieszczając przecinek. Przemieszczenie przecinka w lewo powoduje zwiększenie wykładnika o 1 a przesunięcie przecinka o jedno miejsce w prawo zmniejsza wykładnik o 1 i tak np:
0,123*1053 jest równe 1,23*1052
12,3*1051 jest równe 1,23*1052
Działania dodawania i odejmowania wykonujemy po wyrównaniu wykładników liczb zmiennoprzecinkowych, a po wykonaniu działania sprowadzamy liczbę do postaci znormalizowanej:
1,23*1052 + 0,277*1053 = 1,23*1052 + 2,77*1052 = 4*1052
postać znormalizowana liczby zmiennoprzecinkowej to taka postać w której mantysa jest z zakresu [1, P) (gdzie P to podstawa – w systemie dziesiętnym P = 10, w binarnym = 2)
Mnożenie i dzielenie wykonujemy bez wyrównania wykładników lecz musimy dokonać działań również na wykładnikach:
0,20*102 + 0,015*103 = (0,2 * 0,015)*102+3 = 0,003*105 = 0,3*103
Z powyższych przykładów możemy trafnie wywnioskować, że mnożenie i dzielenie jest prostsze niż dodawanie i odejmowanie, ponieważ nie musimy wyrównywać wykładników.
To jak w takim razie jest reprezentowana liczba zmiennoprzecinkowa w komputerze?
W pamięci, liczby niestety są przechowywane w postaci binarnej co pozwala nam świetnie przechowywać liczby całkowite i wartości logiczne ale przechowywanie wartości ułamkowych w systemie binarnym niestety jest problematyczne. Tak jak w systemie dziesiętnym nie możemy zapisać niektórych liczb np. ⅓ = 0,3333(3) tak samo w systemie binarnym nie jesteśmy w stanie zapisać na przykład dokładnie liczby 0,1:
001111011100110011001100110011012 = 1.600000023841858*2-4 = 0.100000001490116119384765625
oczywiście po zaokrągleniu do 8 miejsca po przecinku uzyskamy wartość 0,1.
Sposób w jaki jest dokładnie reprezentowana liczba jest opisane w standardzie IEEE 754, dzięki standaryzacji na wszystkich komputerach i procesorach reprezentacja liczby zmiennoprzecinkowej jest podobna. W artykule będziemy rozpatrywali reprezentacje typu float czyli liczby zmiennoprzecinkowej pojedynczej precyzji, ponieważ reprezentacja liczby podwójnej precyzji różni się tylko i wyłącznie ilością bitów.
Poniżej jest przedstawiona pewna pamięć 32 bitowa która przechowuje jedną liczbę float, jak widać cały kod binarny liczby jest podzielony na 3 części: Znak (S), Wykładnik (E) oraz Mantysę (M).

- Znak znajduje się na pierwszym bicie, jeśli S = 1 wtedy liczba jest ujemna gdy S = 0 liczba jest dodatnia. Aby zmienić znak w całej liczbie wystarczy zanegować tylko i wyłącznie jeden bit.
- Wykładnik ma 8 bitów i przechowuje liczby ze znakiem w kodzie z nadmiarem z zakresu podobnego do zmiennej byte czyli od -127 (0000 0000) do 128 (1111 1111). W przypadku liczby double zakres ten jest większy i wynosi -1023 do 1024 ponieważ wykładnik jest złożony z 11 bitów.
- Mantysa ma 23 bity (w double 53 bity) i przechowuje liczby w stałoprzecinkowym kodzie U1 z zakresu 1 do 2 czyli np. 1,23 1,9009, 1,00001 itp.. Pierwszy bit mantysy zawsze jest równy 1 dlatego nie jest zapamiętywany w pamięci tylko jest odtwarzany podczas wykonywania obliczeń.
- Podstawa – w notacji naukowej korzystamy z podstawy o wartości 10 (ponieważ dla człowieka jest ona bardziej naturalna) w IEEE 754 podstawą jest liczba 2 i to sprawia, że IEEE 754 nie jest od razu zrozumiałe dla człowieka oraz problemy z reprezentacją niektórych liczb które w systemie dziesiętnym nie stanową problemu.
Spróbujmy zamienić zmiennoprzecinkową binarną reprezentację liczby:
0.33 = 00111110101010001111010111000011(IEEE 754)
na liczbę dziesiętną. Na początku rozdzielmy liczbę według rys.1;
P = 2
S = 0 – dodatnia
E = 0111 1101 = 26 + 25 + 24 + 23 + 22 + 20 – 127= 125 – 127 = -2
M = 01,01010001111010111000011 = 2-2 + 2-4 + 2-8 + 2-9 + 2-10 + 2-11 + 2-13 + 2-15 + 2-16 + 2-17 + 2-22 + 2-23 = 1.320000052452087
00111110101010001111010111000011(IEEE 754) = 0.33000001311302175
Jak widzimy powyżej, liczba 0,33 także nie może zostać dokładnie zapisana w standardzie IEEE 754 i wykorzystywane jest do tego jego przybliżenie. Jakie zatem liczby można zapisać dokładnie w systemie binarnym? Jedynie liczby które da się zapisać za pomocą sumy potęg (z uwzględnieniem ujemnych potęg) liczby 2 np. 0.625, 0.375, 0.5, itp.
Powyższe przykłady wszystkie opierały się na małych liczbach co nie było zbytnio spektakularne. Teraz pokażę przykład jak bardzo typ float nie jest użyteczny w dokładnych obliczeniach. Spróbujmy zamienić liczbę L = 100 000 000 000 na zmiennoprzecinkową pojedynczej precyzji:
S = 0
To co należy najpierw zrobić to zamienić L na stałoprzecinkowy kod U1. Potem zaś będziemy normalizowali liczbę tak aby zapisać ją w notacji naukowej. Zatem liczba L w postaci binarnej U1 ma wartość:
M = L = 1011101001000011101101110100000000000,0(U1)
liczba ta nie posiada części ułamkowej. Teraz musimy mantysę sprowadzić do postaci znormalizowanej czyli takiej w której liczba jest z zakresu [1, 0). Aby to zrobić należy zwiększać potęgę i jednocześnie przesuwać przecinek w lewo do momentu napotkania ostatniej jedynki w kodzie. W ten sposób uzyskamy znormalizowaną mantysę oraz wykładnik liczby:
M = 1,011101001000011101101110100000000000(U1)
E = 36 = 10100011(bias=127)
po dopasowaniu do schematu z rys. 1 uzyskujemy liczbę binarną o wartości:
010100011011101001000011101101110100000000000(IEEE 754)
skreślona liczba pokazuje nam jakiej części liczby nie udało się upakować w liczbie float i to właśnie powoduje błąd przy reprezentacji dużych liczb zapisanych w standardzie IEEE 754. Podobne błędy uzyskamy podczas zapisywania dużych liczb z częścią ułamkową.
po zamianie powyższej liczby do postaci dziesiętnej otrzymamy wartość: 99999997952, gdyby w formacie float były robione przelewy pieniężne to każda transakcja na kwotę 100 mld zł byłaby obciążona błędem 2048 zł. W przypadku typu double także występują takie błędy jednakże przy większych liczbach, ponieważ mantysa formatu double zawiera o 30 bitów więcej.
Przykład w Javie
Małe liczby:
Nadszedł wreszcie czas na przykład praktyczny który zainteresuje na pewno większą grupę odbiorców niż nudna teoria i obliczenia. Spójrzmy jak są wyświetlane liczby w prostym programie konsolowym napisanym w Javie:
float floatNumber = 0.1f; double doubleNumber = 0.1; System.out.format("Java liczbę 0.1 przedstawia na ekranie tak:%n", floatNumber); System.out.format("float: %f%n", floatNumber); System.out.format("double: %f%n", doubleNumber); System.out.format("%nale w rzeczywistości zaokrągla przechowywaną w zmiennej jej faktyczną wartość:%n"); System.out.format("float: %s%n", new BigDecimal(floatNumber)); System.out.format("double: %s%n", new BigDecimal(doubleNumber)); System.out.format("%nDlatego porównanie liczby 0.1f i 0.100000001490116119384765625f da wynik pozytywny co nie jest prawdą w sensie matematycznym:%n"); boolean isEqual = 0.1f == 0.100000001490116119384765625f; System.out.format("0.1f == 0.100000001490116119384765625f -> %b", isEqual);
wynik działania programu dla osób które nigdy nie słyszały o problemach z liczbami zmiennoprzecinkowymi może być zaskakujący:
Java liczbę 0.1 przedstawia na ekranie tak: float: 0.100000 double: 0.100000 ale w rzeczywistości zaokrągla przechowywaną w zmiennej jej faktyczną wartość: float: 0.100000001490116119384765625 double: 0.1000000000000000055511151231257827021181583404541015625 Dlatego porównanie liczby 0.1f i 0.100000001490116119384765625f da wynik pozytywny co nie jest prawdą w sensie matematycznym: 0.1f == 0.100000001490116119384765625f -> true
jak już wcześniej pisałem jest to skutek przechowywania liczb dziesiętnych w systemie dwójkowym w formacie zmiennoprzecinkowym. Niestety wielu liczb nie da się zapisać skończoną lub ograniczoną do 64 liczbą bitów.
Duże liczby:
Drugi przykład będzie opierał się na powyższym kodzie jednakże wartości zostaną zmienione na:
float floatNumber = 100000000000f; double doubleNumber = 100000000000;
wynik działania programu na pewno jest jeszcze bardziej zaskakujący(dla osób które nie czytały powyższej teorii ;)), ponieważ w typie float widzimy bardzo duży błąd przy niewielkiej liczbie:
Java liczbę 100 000 000 000f przedstawia na ekranie tak: float: 99999997952.000000 double: 100000000000.000000 ale w rzeczywistości zaokrągla przechowywaną w zmiennej jej faktyczną wartość: float: 99999997952 double: 100000000000 Dlatego porównanie liczby 100000000000f i 99999997952f da wynik pozytywny co nie jest prawdą w sensie matematycznym: 100000000000f == 99999997952f -> true
co ciekawsze Java przy wypisywaniu wartości liczby 100mld nie potrafi jej zaokrąglić (z czym nie ma problemu przy większej liczbie 1bln). To na co trzeba jeszcze uważać to duże liczby z częścią ułamkową przy używaniu typu float już przy 10mln część ułamkowa jest gubiona w przypadku double część ułamkowa jest gubiona przy liczbie ok 1016. Poniższy program demonstruje jak część ułamkowa jest gubiona podczas przechowania zmiennej typu float oraz double:
float floatNumber = 10000000.17f; double doubleNumber = 10000000000000000.17d; System.out.format("Rzeczywiste wartości przechowywane w zmiennej:%n"); System.out.format("float: %s%n", new BigDecimal(floatNumber)); System.out.format("double: %s%n", new BigDecimal(doubleNumber));
wynik:
Rzeczywiste wartości przechowywane w zmiennej: float: 10000000 double: 10000000000000000
Działania na liczbach zmiennoprzecinkowych:
To na co jeszcze trzeba zwrócić uwagę przy korzystaniu z typów zmiennoprzecinkowych to wykonywanie na nich działań arytmetycznych. Jeśli dodajemy wielokrotnie liczby które są zapisane w przybliżonej wartości a nie dokładnej wtedy błąd jest propagowany na obliczenia przez co wynik działania także zawiera błąd. Bardzo prosty przykład w którym dodajemy 10 liczb o wartości 0.1:
double sum = 0; for(int i = 0 ; i<10; i++) { sum += 0.1; } System.out.println(sum); System.out.println(1.0);
wynik powinien być równy 1 jednakże w konsoli otrzymujemy następujący wynik:
0.9999999999999999 1.0
widoczne jest to, że wynik nie jest taki jaki powinien być. Co więcej, wypisaliśmy już zaokrąglone wartości. Oczekiwaną wartość wyniku bez problemu możemy zapisać w formacie zmiennoprzecinkowym z wartością dokładną jednak działanie wykonywane było na niedokładnych liczbach i taki też wynik otrzymaliśmy – niedokładny.
Kiedy używać a kiedy nie typów zmiennoprzecinkowych?
Po pierwsze nie należy się obawiać korzystać z typów float i double.Musimy być świadomi, że porównywanie 2 liczb zmiennoprzecinkowych może być obarczone błędem, że operujemy na przybliżeniach a nie na dokładnych wartościach i że nie wykorzystujemy tych typów do obliczeń w których duże liczby mają istotne wartości ułamkowe.
Typu float możemy używać np. przy obliczeniach związanych z grafiką gdzie nie używamy dużych wartości i nie operujemy na dużych liczbach tylko na liczbach do kilku miejsc po przecinku, które mogą być obarczone niedużym błędem. Typu float możemy używać do przechowywania wartości temperatury, wilgotności itp. o ile nie są to sprzęty dużej dokładności.
Typu double należy używać wszędzie tam gdzie zależy nam na większej dokładności w stosunku do float i chcemy zmniejszyć ryzyko wystąpienia błędów. Jeśli chcemy używać dużych wartości liczbowych z wartością ułamkową także możemy użyć double jednakże trzeba pamiętać o ograniczeniach.
Jak dokonać bardzo dokładnych obliczeń?
Aby dokonać bardzo dokładnych obliczeń i nie martwić się ograniczeniami związanymi z liczbami zmiennoprzecinkowymi musimy wykorzystać obiekty które udostępnia standardowa biblioteka Javy. Już w poprzednim kodzie można było zauważyć wykorzystanie obiektów BigDecimal które przechowują dokładną wartość liczb i działania na tych liczbach nie są obarczone błędami. Jednakże i tutaj trzeba uważać, ponieważ stworzenie obiektu BigDecimal w ten sposób:
BigDecimal wrongNumber = new BigDecimal(0.1);
spowoduje stworzenie obiektu BigDecimal z wartością równą wartości rzeczywistej liczby zmiennoprzecinkowej double – czyli przypisanie wartości przybliżonej a nie takiej jakiej byśmy oczekiwali. Aby przypisać taką wartość jaką chcemy do zmiennej możemy dokonać to w kilka sposobów:
BigDecimal rightNumber1 = new BigDecimal("0.1"); BigDecimal rightNumber2 = new BigDecimal(new char[]{'0','.','1'}); BigDecimal rightNumber3 = BigDecimal.valueOf(1,1);
tak stworzone obiekty przechowują w pamięci dokładnie wartość 0.1 bez przybliżenia. Niestety kosztem takiego przechowywania i używania takich liczb jest wygoda i szybkość operacji na nich. Oraz ograniczenia związane z niemożliwością przechowywanie liczb w okresie np. 1/3 wtedy otrzymujemy wyjątek: java.lang.ArithmeticException: Non-terminating decimal expansion; no exact representable decimal result.
Jeśli chodzi o szybkość to stworzyłem prosty program który wykonuje działanie mnożenia na liczbach double oraz na BigDecimal:
public class BigDecimalSpeedTest { public static void main(String[] args) { int count = 1000000; doubleSpeedTest(count); bigDecimalSpeedTest(count); } public static void doubleSpeedTest(int count) { long startTime = System.currentTimeMillis(); double num1 = 1.00002; double num2 = 1.00001; for (int i = 0; i < count; i++) { num1 *= num2; } System.out.format("Double speed: %f%n", ((System.currentTimeMillis()-startTime)/1000.0)); System.out.format("value: %f%n", num1); } public static void bigDecimalSpeedTest(int count) { long startTime = System.currentTimeMillis(); BigDecimal num1 = new BigDecimal("1.00002"); BigDecimal num2 = new BigDecimal("1.00001"); for (int i = 0; i < count; i++) { num1 = num1.multiply(num2); } System.out.format("%n%nBigDecimal speed: %f%n", ((System.currentTimeMillis()-startTime)/1000.0)); System.out.format("value: %s%n", num1.toPlainString()); } }
wybrałem takie liczby aby double miał w miarę dokładny wynik nie wychodzący poza zakres liczby zmiennoprzecinkowej ale jednocześnie taki żeby nie było mnożenia przez liczbę całkowitą. Wynik programu dla 1mln elementów ukazuje jaki problem stwarza używanie klas BigDecimal:
Double speed: 0.003000 BigDecimal speed: 394.692000
spowodowane jest to ustawieniami domyślnymi podczas wykonywania działań które nie ustawiają limitu na miejsca po przecinku. Ustawienie ręczne ilości interesujących nas liczb odrazu przyspiesza obliczenia i tak podmiana linii 24 na:
num1 = num1.multiply(num2, new MathContext(100, RoundingMode.HALF_EVEN));
zwiększa prędkość obliczeń dzięki czemu obliczenie wykonuje się w przyzwoitym czasie:
Double speed: 0.003000 value: 22025.805015 BigDecimal speed: 0.425000 value: 22025.80501368146047692813238363027130922546792929630301858964426059979848001496308826175772309176812
jak widać wynik double jest dokładny do 5 miejsca po przecinku zatem nie zawsze jest konieczność używania BigDecimal, zważywszy na to że przy wielu obliczeniach działa 100 razy wolniej.
Podsumowanie
To czy użyjemy typów zmiennoprzecinkowych czy BigDecimal zależy od wymagań i specyfiki problemu. W 99% przypadków typ double nam wystarczy, nawet do aplikacji z domowym budżetem lub prostych obliczeń fizycznych. Jednak gdy od systemu zależy życie ludzkie, pieniądze lub wyższe cele należy dobrze rozpoznać problem i gdy nie jesteśmy pewni lepiej poświęcić wydajność aby zyskać dokładność obliczeń. Używając typów double należy pamiętać aby przy ich porównywaniu wziąć pod uwagę, że precyzja typu float wynosi 7 znaków a typu double 16.