Jak już wiemy, błędy obliczeń numerycznych mogą wynikać z wielu przyczyn. Często są związane  m.in. z błędnymi danymi wejściowymi, niepoprawnymi zaokrągleniami, obcięciami części ułamkowej, konwersją między systemami liczbowymi oraz reprezentacją bitową.

Zacznijmy od pokazania paru mechanizmów w języku Java, które pomogą nam zrozumieć naturę błędów związanych z reprezentacją liczb zmiennoprzecinkowych. Przypomnijmy, jakimi typami prostymi dysponujemy w języku Java:

  1. boolean – 1 bitowy typ true lub false,

  2. char – 8‑bitowy typ znakowy,

  3. byte – 8‑bitowy typ całkowity,

  4. short – 16‑bitowy typ całkowity,

  5. int – 32‑bitowy typ całkowity,

  6. long – 64‑bitowy typ całkowity,

  7. float – 32‑bitowy typ zmiennoprzecinkowy w standardzie IEEE 754,

  8. double – 64‑bitowy typ zmiennoprzecinkowy w standardzie IEEE 754.

Każdy z typów prostych posiada odpowiadający mu typ referencyjnytyp referencyjnytyp referencyjny, który oznaczamy tak samo, tylko wielką literą. W nowych wersjach Javy wprowadzono mechanizm pakowania oraz rozpakowywania (ang. boxing, unboxing), który dba o to, aby arytmetyka na tych dwóch typach mogła odbywać się bez potrzeby ręcznego odczytywania wartości z obiektów w kodzie.

W klasach odpowiadających wspomnianym typom prostym występuje wiele przydatnych metod związanych z reprezentacją bitową liczb.

  • W klasie Double możemy znaleźć np. metodę double doubleToLongBits(double value), która daną 64‑bitową reprezentację zmiennej value typu double zamieni na 64‑bitową reprezentację typu long.

  • W klasie Long zaś występuje metoda String toBinaryString(long i), która daną zmienną i typu long zamieni na ciąg znaków, który stanowił będzie binarną reprezentację zmiennej i. Niestety, funkcja ta pominie zera wiodące. Możemy jednak to poprawić, uzupełniając otrzymany ciąg odpowiednią ilością zer z przodu.

Wykorzystując wspomniane metody, w prosty sposób napiszemy funkcję, która wypisze na ekranie binarną reprezentację zmiennej typu double.

Linia 1. public static void wypiszBinarnaReprezentacje otwórz nawias okrągły double zmiennoprzecinkowa zamknij nawias okrągły otwórz nawias klamrowy. Linia 2. prawy ukośnik prawy ukośnik zamień bitowo double na long. Linia 3. long zapisBitowy znak równości Double kropka doubleToLongBits otwórz nawias okrągły zmiennoprzecinkowa zamknij nawias okrągły średnik. Linia 4. prawy ukośnik prawy ukośnik zamień long binarnie na String. Linia 5. String str znak równości Long kropka toBinaryString otwórz nawias okrągły zapisBitowy zamknij nawias okrągły średnik. Linia 7. prawy ukośnik prawy ukośnik wypisz poprzedzające zera. Linia 8. for otwórz nawias okrągły int i znak równości 0 średnik i otwórz nawias ostrokątny 64 minus str kropka length otwórz nawias okrągły zamknij nawias okrągły średnik plus plus i zamknij nawias okrągły otwórz nawias klamrowy. Linia 9. System kropka out kropka print otwórz nawias okrągły cudzysłów 0 cudzysłów zamknij nawias okrągły średnik. Linia 10. zamknij nawias klamrowy. Linia 12. System kropka out kropka print otwórz nawias okrągły str zamknij nawias okrągły średnik. Linia 13. System kropka out kropka print otwórz nawias okrągły cudzysłów lewy ukośnik n cudzysłów zamknij nawias okrągły średnik. Linia 14. zamknij nawias klamrowy.

Sprawdźmy zatem, za pomocą poniższego kodu, jak wygląda reprezentacja bitowa liczby 1.3:

Linia 1. double zmiennoprzecinkowa znak równości 1 kropka 3 średnik. Linia 2. wypiszBinarnaReprezentacje otwórz nawias okrągły zmiennoprzecinkowa zamknij nawias okrągły średnik.

W wyniku uruchomienia powyższych linijek w programie, na ekranie wypisany zostanie poniższy ciąg znaków:

Linia 1. 0011111111110100110011001100110011001100110011001100110011001101.

Wiemy już, że pierwszy bit jest bitem znaku. Kolejne 11 bitów to bity wykładnika, a ostatnie 52 bity to mantysa.

W języku Java występuje też klasa BigDecimal, która może nam posłużyć w celu wypisania rzeczywistej wartości przechowywanej w zmiennej typu double. Poniższa funkcja wypisze dla nas tę wartość.

Linia 1. public static void wypiszDokladnaWartosc otwórz nawias okrągły double zmiennoprzecinkowa zamknij nawias okrągły otwórz nawias klamrowy. Linia 2. BigDecimal duzaDokladnosc znak równości new BigDecimal otwórz nawias okrągły zmiennoprzecinkowa zamknij nawias okrągły średnik. Linia 3. System kropka out kropka println otwórz nawias okrągły duzaDokladnosc zamknij nawias okrągły średnik. Linia 4. zamknij nawias klamrowy.

Wykorzystajmy ją, aby wypisać dokładniejszą wartość reprezentacji bitowej liczby 1.3:

Linia 1. double zmiennoprzecinkowa znak równości 1 kropka 3 średnik. Linia 2. wypiszDokladnaWartosc otwórz nawias okrągły zmiennoprzecinkowa zamknij nawias okrągły średnik.

Wynik uruchomienia powyższego kodu:

Linia 1. 1 kropka 3000000000000000444089209850062616169452667236328125.

Widzimy zatem, że nie jesteśmy w stanie w dokładny sposób przechowywać liczby 1.3 w postaci zgodnej z IEEE 754. W tym systemie liczba ta jest niewymierna, a jej mantysa musiałaby wyglądać następująco:

Linia 1. 110 otwórz nawias okrągły 1001 zamknij nawias okrągły.

Liczby zapisane w nawiasie są w okresie. Na mantysę przypadają tylko 52 bity, jest zatem ograniczona dokładność takich liczb.

Niedokładność reprezentacji IEEE 754

Najważniejszą zasadą związaną z unikaniem błędów numerycznych jest zachowywanie odpowiedniej ostrożności podczas wykonywania algorytmów iteracyjnych. Przyjrzyjmy się poniższemu błędnie skonstruowanemu programowi:

Linia 1. double zmiennoprzecinkowa znak równości 1 kropka 3 średnik. Linia 3. int i znak równości 0 średnik. Linia 4. while otwórz nawias okrągły zmiennoprzecinkowa wykrzyknik znak równości 2 kropka 0 zamknij nawias okrągły otwórz nawias klamrowy. Linia 5. zmiennoprzecinkowa plus znak równości 0 kropka 1 średnik. Linia 6. plus plus i średnik. Linia 7. zamknij nawias klamrowy. Linia 9. System kropka out kropka println otwórz nawias okrągły i zamknij nawias okrągły średnik.

Przedstawiony algorytm ma za zadanie dodawać do zmiennej o nazwie zmiennoprzecinkowa wartość 0.1 do momentu, gdy wartość zmiennej osiągnie 2.0. Według prostej logiki pętla powinna wykonać się 7 razy, a program wypisze 7 na ekranie. Co jednak stanie się, gdy rzeczywiście uruchomimy program? Będzie on wykonywał się w nieskończoność. Aby poznać przyczynę tego problemu, przyjrzyjmy się reprezentacji bitowej oraz dokładniejszej wartości liczb przechowywanych w programie.

Liczba 2.0 jest reprezentowana następująco:

Linia 1. 0100000000000000000000000000000000000000000000000000000000000000. Linia 2. 2.

Zmienna zmiennoprzecinkowa po wykonaniu 7 iteracji prezentuje się następująco:

Linia 1. 0100000000000000000000000000000000000000000000000000000000000001. Linia 2. 2 kropka 000000000000000444089209850062616169452667236328125.

Liczba 2.0 zatem posiada swoją dokładną reprezentację w standardzie IEEE 754. W przypadku wartości, którą przechowuje zmienna zmiennoprzecinkowa, tak nie jest. Wartość jest bardzo zbliżona do wartości 2. Wynika to z faktu, iż liczba 1.3 jest zapisana w pamięci komputera z pewną niedokładnością, której istnienie zostało przez nas pominięte. Z tego względu należy rozważnie dobierać warunek końca algorytmu iteracyjnego i w odpowiedni sposób zabezpieczyć program przed błędami wynikającymi z niedokładności liczb w standardzie IEEE 754. Zabronione jest stosowanie warunku stop, który będzie określony jako osiągnięcie konkretnej wartości.

Aby poradzić sobie z tym problemem, jest zalecane wprowadzenie pewnego rodzaju tolerancji do interesującego nas wyniku. Zamiast sprawdzać, czy została osiągnięta określona wartość, możemy sprawdzić, czy mieści się ona w granicach tolerancji. W przypadku naszego algorytmu może to wyglądać w następujący sposób:

Linia 1. double zmiennoprzecinkowa znak równości 1 kropka 3 średnik. Linia 3. int i znak równości 0 średnik. Linia 4. double epsilon znak równości 0 kropka 01 średnik. Linia 5. while otwórz nawias okrągły Math kropka abs otwórz nawias okrągły 2 kropka 0 minus zmiennoprzecinkowa zamknij nawias okrągły zamknij nawias ostrokątny epsilon zamknij nawias okrągły otwórz nawias klamrowy. Linia 6. zmiennoprzecinkowa plus znak równości 0 kropka 1 średnik. Linia 7. plus plus i średnik. Linia 8. zamknij nawias klamrowy. Linia 10. System kropka out kropka println otwórz nawias okrągły i zamknij nawias okrągły średnik.

Zauważ, że w obecnej sytuacji interesująca nas wartość zmiennej będzie mieściła się w przedziale ( 2.0 epsilon,   2.0 +epsilon). Zdefiniowany tak warunek to nic innego jak określenie maksymalnej wartości, jaką może przyjąć błąd bezwzględny.

Błąd bezwzględny i względny

Aby poradzić sobie z niedokładnościami obliczeniowymi, możemy wykorzystać pojęcie błędu bezwzględnego i względnego.

Błąd bezwzględny definiujemy następująco:

Δ x = | x x z |

gdzie:

x – wartość rzeczywista,

x z – wartość zmierzona.

Błąd względny zaś możemy zdefiniować, wykorzystując błąd bezwzględny:

δ = Δ x x = | x x z | x

Oba pojęcia możemy wykorzystać w celu przerwania procedur iteracyjnych. W przypadku gdy osiągniemy pewien z góry określony poziom błędu, przerywamy wykonywanie pętli.

Pokazaliśmy już przykład przerywania pętli z wykorzystaniem błędu bezwzględnego. Aby pokazać przykład błędu względnego, wprowadźmy pewną sumę szeregu matematycznego:

π 2 6 = n = 1 1 n 2 = 1 1 2 + 1 2 2   + . . .

Suma ta jest nieskończona, dlatego możemy co najwyżej przybliżyć jej wartość. Czy jesteśmy w stanie stwierdzić, ile elementów ciągu musimy zsumować, aby oszacować wartości powyższej sumy na poziomie błędu względnego 0.5%. Poniższy program przeprowadza sumowanie do momentu, gdy osiągniemy zadaną dokładność. Po tym procesie wypisuje wszystkie przydatne informacje.

Linia 1. prawy ukośnik prawy ukośnik błąd względny. Linia 2. double epsilon znak równości 0 kropka 005 średnik. Linia 3. int n znak równości 1 średnik. Linia 4. double wartoscObliczona znak równości 0 kropka 0 średnik. Linia 5. double wartoscOczekiwana znak równości Math kropka PI asterysk Math kropka PI prawy ukośnik 6 średnik. Linia 6. while otwórz nawias okrągły Math kropka abs otwórz nawias okrągły wartoscOczekiwana minus wartoscObliczona zamknij nawias okrągły prawy ukośnik wartoscOczekiwana zamknij nawias ostrokątny epsilon zamknij nawias okrągły otwórz nawias klamrowy. Linia 7. wartoscObliczona plus znak równości 1 kropka 0 prawy ukośnik otwórz nawias okrągły n asterysk n zamknij nawias okrągły średnik. Linia 8. plus plus n średnik. Linia 9. zamknij nawias klamrowy. Linia 11. System kropka out kropka println otwórz nawias okrągły cudzysłów Wartosc oczekiwana dwukropek cudzysłów plus wartoscOczekiwana zamknij nawias okrągły średnik. Linia 12. System kropka out kropka println otwórz nawias okrągły cudzysłów Wartosc obliczona dwukropek cudzysłów plus wartoscObliczona zamknij nawias okrągły średnik. Linia 13. System kropka out kropka println otwórz nawias okrągły cudzysłów Blad bezwzgledny dwukropek cudzysłów plus Math kropka abs otwórz nawias okrągły wartoscOczekiwana minus wartoscObliczona zamknij nawias okrągły zamknij nawias okrągły średnik. Linia 14. System kropka out kropka println otwórz nawias okrągły cudzysłów Blad wzgledny dwukropek cudzysłów plus Math kropka abs otwórz nawias okrągły wartoscOczekiwana minus wartoscObliczona zamknij nawias okrągły prawy ukośnik wartoscOczekiwana zamknij nawias okrągły średnik. Linia 15. System kropka out kropka println otwórz nawias okrągły cudzysłów Liczba iteracji dwukropek cudzysłów plus otwórz nawias okrągły n minus 1 zamknij nawias okrągły zamknij nawias okrągły średnik.

Tak wykonany program wypisze na ekran:

Linia 1. Wartosc oczekiwana dwukropek 1 kropka 6449340668482264. Linia 2. Wartosc obliczona dwukropek 1 kropka 6367708468736315. Linia 3. Blad bezwzgledny dwukropek 0 kropka 008163219974594904. Linia 4. Blad wzgledny dwukropek 0 kropka 004962642660952381. Linia 5. Liczba iteracji dwukropek 122.

Widzimy, że błąd względny osiągnął wartość mniejszą niż 0.5% dopiero po zsumowaniu 122 wyrazów.

Słownik

typ referencyjny
typ referencyjny

typ zmiennej, która jest przechowywana w konkretnym bloku w pamięci; adres tej zmiennej jest przechowywany w programie w zmiennej referencyjnej; w języku Java zarządzanie pamięcią odbywa się w sposób automatyczny, więc jedyną rzeczą, o której musimy pamiętać, to fakt, że modyfikacja typu referencyjnego (np. w funkcji) zawsze zmodyfikuje oryginał