Arytmetyka liczb wymiernych w języku C++ Monika Zagała Wydział Inżynierii Mechanicznej i Informatyki Kierunek Informatyka, Rok V m_zagala@o2.pl Streszczenie Poniższa praca przedstawia projekt oraz implementację nowego typu danych mzrational dla języka C++, służącego do prostych operacji arytmetycznych na liczbach wymiernych. Artykuł wykazuje jego słabe i mocne strony, na podstawie porównania z liczbami zmiennoprzecinkowymi (dostępnymi w standardzie języka), precyzji wyników otrzymanych dla prostych działań matematycznych. Ponadto, została zaproponowana arytmetyka mieszana między liczbami wymiernymi, a liczbami zmiennopozycyjnymi oraz omówione zostały problemy, jakie się z tym wiążą. 1 Wstęp Wraz, z pojawieniem się pierwszych maszyn liczących, czynności związane z pobieraniem i przetwarzaniem danych liczbowych, zostały zautomatyzowane. Do wykonywania działań arytmetycznych stosowany jest powszechnie typ zmiennopozycyjny. Niestety, w wielu przypadkach, obliczenia wykonywane przy jego pomocy, dają przybliżone rezultaty. Występujące błędy, są spowodowane min. brakiem skończonego rozwinięcia dziesiętnego, dla niektórych ułamków zwykłych [1]. Inną istotną sprawą jest kolejność wykonywania działań. Ma ona duży wpływ na precyzję otrzymywanych wyników [2]. Fakt, że reprezentacja liczb zmiennoprzecinkowych w pamięci komputera nie zawsze jest precyzyjna, nasuwa ideę zastosowania zamiennie danych, w postaci wymiernej. Dzięki temu, że są one przedstawiane za pomocą pary liczb: licznika i mianownika, uniknąć można np. błędów zaokrąglenia liczb, które towarzyszą postaci dziesiętnej implementacji. Ze względu na brak ogólnodostępnego typu liczb wymiernych dla języka C++ oraz przez wzgląd na jego duże zapotrzebowanie w wielu dziedzinach nauki i techniki, została zaprojektowana biblioteka zawierająca zestaw algorytmów i funkcji, umożliwiajacych wykonywanie operacji arytmetycznych, na liczbach reprezentowanych w postaci ułamków zwykłych. Praca zorganizowana jest w nastepujący sposób: W rozdziale drugim przedstawiona została reprezentacja liczb zmiennoprzecinkowych, wraz z charakterystyką najczęściej spotykanych błędów wystepujących w obliczeniach. Rozdział trzeci zawiera definicję typu mzrational oraz jego porównanie z typami zmiennopozycyjnymi, na podstawie prostych przykładów działań arytmetycznych. Rozdział czwarty określa zasady arytmetyki mieszanej opracowanego typu liczb wymiernych, z istniejącymi postaciami reprezentacji liczb rzeczywistych. 1
2 Reprezentacja liczb zmiennoprzecinkowych Liczba zmiennoprzecinkowa (ang. floating point number) służy do przedstawienia ograniczonego przedziału liczb rzeczywistych w pamięci komputera. Wszystkie założenia, związane z reprezentacją tego typu, zdefiniowane zostały przez standard IEEE 754 [3]. W praktyce stosowane są trzy metody wyświetlania liczb zmiennoprzecinkowych: dziesiętna, naukowa oraz inżynierska. Najbardziej popularnym zapisem jest notacja naukowa[4]. Stosując dynamiczne przesunięcie przecinka oraz używając potęgi podstawy do o- kreślenia jego rzeczywistego położenia, możemy reprezentować dowolne liczby za pomocą kilku cyfr [5]. Ogólny wzór wygląda następująco: z m M β z cc (1) gdzie : M mantysa liczby (ang. mantissa), C cecha (ang. exponent), β używana podstawa systemu liczbowego (ang. base), z m znak mantysy, z c znak cechy. Zarówno, dla mantysy, jak i wykładnika ilość cyfr jest z góry ustalona. Zatem, dana liczba jest reprezentowana, z pewną skończoną dokładnością i należy, do policzalnego zbioru wartości [2]. Przy obliczeniach, wykonywanych na liczbach zmiennopozycyjnych, można napotkać podstawowe rodzaje błedów : Błędy danych wejściowych występują wówczas, gdy dane liczbowe wprowadzone do pamięci, lub rejestrów maszyny cyfrowej, odbiegają od dokładnych ich wartości. Błędy reprezentacji problem isnieje, w przypadku reprezentacji wszystkich liczb niewymiernych np. Π, 3, liczb o nieskończonym rozwinięciu dziesiętnym np. 1/3, 1/6, 1/7 oraz dla ułamków dziesiętnych o nieskończonym rozwinięciu binarnym np. 0.1, 0.2. Nieuniknione jest wówczas zaokrąglenie. Błędy obcięcia powstają podczas obliczeń, na skutek zmniejszania liczby działań. Na przykład, podczas dodawania bardzo małej i bardzo dużej liczby, ze względu na ograniczoną reprezentację mantysy wyniku, jej przesunięcie względem tych samych cech, powoduje brak dodania liczb, a otrzymanym wynikiem będzie wartość liczby większej. Błędy zaokragleń pojawiają się podczas obliczeń, na skutek konieczności zaokrąglania wartości, ze wzgledu na ograniczoną długość słów binarnych. Błędy te można czasem zmniejszyć, ustalając umiejętnie sposób i kolejność wykonywania działań. Liczbę zmiennoprzecinkową można potraktować, jako sumę wartości dokładnej oraz poprawki do wartości liczby zmiennoprzecinkowej [3]: f = d + p (2) gdzie: f wartość zmiennoprzecinkowa; d wartość dokładna, którą reprezentuje liczba f ; p poprawka wartości d do wartości f, zwana również błędem zaokrąglenia (może przyjmować wartości dodatnie oraz ujemne). Dla przykładu, liczby: 2
float d1 = 123456.78 float d2 = 103.6003 są reprezentowane jako: f 1 = d1 + p1 i f 2 = d2 + p2, przy czym: f1 = 123456.781250, f2 = 103.600304 natomiast błędy zaokrąglenia wynoszą odpowiednio: p1 = 0.00125, p2 = -0.000004. Podczas dodawania dwóch liczb zmiennoprzecinkowych mamy do czynienia, z sumowaniem się błędów: f 1 + f 2 = d1 + p1 + d2 + p2 = (d1 + d2) + (p1 + p2) ; (3) }{{} błąd Jeżeli, poprawki: p1 i p2 mają przeciwne znaki, wówczas błąd może być nieco mniejszy. Teoretycznie, po podstawieniu do wzoru liczb otrzymamy: f = 123560.381554 d = 123560.3803 p = 0.001254 Wyniki otrzymane, przy użyciu kompilatora dla języka C++, różnią się od przedstawionych wyżej, gdyż dochodzą jeszcze błędy reprezentacji poszczególnych składników działań arytmetycznych oraz otrzymanego wyniku. Stąd, f = 123560.382812, natomiast poprawka p = 0.002512. Mnożenie dwóch liczb zmiennoprzecinkowych, przedstawia poniższe równanie: f 1 f 2 = (d1 + p1) (d2 + p2) = (d1 d2) + (d1 p2 + d2 p1 + p1 p2) ; (4) }{{} błąd Dodając, do wartości ujętej w nawias klamrowy (z wzoru (4) ), błędy numeryczne, wynikające z niedokładnej reprezentacji tych liczb, uzyskany błąd całkowity może być duży. Biorąc pod uwagę, że jest to jedynie pojedyncza operacja, warto zastanowić się, kiedy dokonywanie bardziej skomplikowanych operacji arytmetycznych ma w ogóle sens [2]. 3 Działania arytmetyczne na liczbach wymiernych Z poprzedniego rozdziału wynika, że typ zmiennopozycyjny niesie ze sobą wiele niedoskonałości. Można łatwo uzyskać bezużyteczne wyniki, czyli takie, które obarczone są bardzo dużym błędem. Zastosowanie większej precyzji liczb zmiennoprzecinkowych, jest jedną z metod osłabiającą ryzyko uzyskania niedokładnych wyników [2]. Jednak, w wielu laboratoriach naukowych, technicznych, czy przemysłowych, gdzie jakość obliczeń ma bardzo duże znaczenie, arytmetyka zmiennopozycyjna może okazać się zawodna. Fakt ten, przyczynił się do prac nad nowym typem danych zwanym ogólnie Rational. Głównym założeniem jest przedstawienie liczb rzeczywistych, wymiernych, za pomocą ułamków zwykłych. Licznik i mianownik są zapisywane w postaci liczb całkowitych, i dlatego podstawowe działania matematyczne wykonywane są z pełną precyzją. Na przykład dla języków takich jak: Java, czy Python istnieją odpowiedniki takiej biblioteki. 3
Na stronie internetowej Boost a [6] można znależć implementację typu rational dla języka C++, wraz z podstawowymi algorytmami i funkcjami. Brakuje jednak operandów dla arytmetyki mieszanej i możliwosci rzutowania typu zmiennopozycyjnego, na typ wymierny. Pakiet ten jest biblioteką "otwartą", wciąż opracowywaną. Na jego podstawie została zaprojektowana własna biblioteka mzrational, z operandami: dodawania, odejmowania, mnożenia i dzielenia, a także relacji porównania. Dodatkowo zostały przeciążone operatory typów zmiennoprzecinkowych oraz zdefionowana została ich konwersja do mzrational, wraz z funkcjami dla całej arytmetyki mieszanej. Na podstawie przykładowych dwóch liczb: a = 1223334444.5 i b = 2e-8 zostało dokonane porównanie operacji dodawania i mnożenia pomiędzy danymi typu mzrational, gdzie poszczególne składowe ułamka zwykłego zdefiniowane zostały jako long long int oraz liczbami zmiennoprzecinkowymi typu double. Otrzymane wyniki były następujace: operacja mzrational double + (61166722225000001/50000000) 1223334444.500000000000000000 (2446668889/100000000) 24.4666890000000356 dla wymiernej reprezentacji rezultat był prawidłowy, zarówno dla operacji dodawania oraz mnożenia. W przypadku liczb zapisanych w postaci dziesietnej, operacja dodawania dała wynik równy większemu czynnikowi, czyli wystapił typowy błąd obcięcia, charakterystyczny dla tego typu danych. Natomiast mnożenie zostało przeprowadzone precyzyjnie, z niewielkim błędem reprezentacji. Nasuwa się tutaj wniosek, że typ mzrational wykazuje zdecydowaną przewagę nad typem zmiennopozycyjnym, w operacjach dodawania (odejmowania) liczb skrajnie różnych. Porównanie arytmetyki, dla dwóch innych liczb: c = 45e12 oraz d = 5e-8, wykazało, że mnożenie wykonane zostało prawidłowo na liczbach mzrational, natomiast dodawanie zakończyło się błędem spowodowanym przekroczeniem najwyższej wartości liczby typu long long int. Podobnie, dla operacji mnożenia może wystąpić overflow (underflow), czyli tzw. bład nadmiaru (niedomiaru), szczególnie wtedy, gdy redukcja ułamków zwykłych jest niewykonalna. Zatem problem zachowania precyzji nie jest do końca rozwiązany. W tym konkretnym przypadku widoczna jest wyższość typów zmiennopozycyjnych typu double(long double). Przy zastosowaniu liczb typu float sprawa przedstawia się inaczej. Porównanie zakresów możliwych prezentowanych wyników wypada na korzyść reprezentacji mzrational. Dodatkowym atutem, reprezentacji liczb w postaci wymiernej, jest łatwość ich porównywania. Powszechnie wiadomo, że takie operacje na liczbach prezentowanych w postaci ułamków dziesiętnych nie są możliwe. Można jedynie sprawdzić, czy dana liczba zmiennopozycyjna mieści się w pewnym jej zakresie, otoczeniu [3]. Typ mzrational zapewnia nam operatory (<, >, ==,! =) dla tego typu relacji, zwracające odpowiednio true, jeżeli została ona spełniona, w przeciwnym razie false. Poniżej znajduje się fragment implementacji operatora mniejszości: template<typename Int> bool mzrational<int>::operator<( const mzrational<int>& less){ mzrational<int> l(*this); mzrational<int> r(less); 4
} if(l.num < 0 && r.num >= 0) return true; if(l.num >= 0 && r.num <= 0) return false; if(l.den == r.den) return l.num < r.num; l.normalize(); r.normalize(); Int gcd1 = gcd(l.num, r.num); Int gcd2 = gcd(r.den, l.den); return (l.num/gcd1) * (r.den/gcd2) < (l.den/gcd2) * (r.num/gcd1); Funkcja normalize() służy do redukcji ułamków zwykłych, natomiast gcd(), jako rezultat zwraca największy wspólny dzielnik. Zastosowanie operatora < wymaga zdefiniowania dwóch obiektów typu mzrational i porównaniu ich ze sobą. Ilustruje to poniższy przykład: int main(){ mzrational<long int> a(12, 78); mzrational<long int> b(34, 13); if(a < b){...} return 0; } Inną cechą typu mzrational, jest reprezentacja wyników w postaci zredukowanych ułamków zwykłych. Notacja dziesiętna jest zdecydowanie bardziej przyswajalna, od tego rodzaju prezentacji danych. Na przykład, liczba a = 1223334444.5 zostanie przedstawiona odpowiednio przez typ zmiennopozycyjny jako: 1223334444.500000000000000000 natomiast mzrational wyświetli się jako: (2446668889 / 2) Tę małą niedogodność rekompensuje możliwość zamiany typu z mzrational na dowolny typ zmiennoprzecinkowy. Trzeba się liczyć z tym, że w niektórych przypadkach, konwersja może przyczynić się, do utraty dokładności prezentowanej liczby. Porównanie typów: mzrational ze wszystkimi typami zmiennopozycyjnymi nie miało na celu wykazania, który z nich jest lepszy. Zarówno jedna, jak i druga reprezentacja niesie ze sobą wiele zalet i wad. Jednakże, wykazanie słabych i mocnych stron pomaga w dobraniu odpowiedniego typu, w zależności od wykonywanych operacji. 4 Definicja arytmetyki mieszanej Najważniejszym, a zarazem najtrudniejszym zagadnieniem jest arytmetyka liczb mieszanych, czyli określenie zasad działania na liczbach wymiernych typu mzrational, z liczbami zmiennopozycyjnymi w dowolnym formacie. Stosowanie zamiennie liczb zmiennopozycyjnych i wymiernych wymaga zdefiniowania operatorów rzutowania: operator float( ){...} operator double( ){...} operator long double( ){...} do zamiany typu mzrational, na jeden z powyższych typów zmiennopozycyjnych oraz zdefiniowania konwersji odwrotnej, czyli liczby rzeczywistej w dowolnej reprezentacji zmiennoprzecinkowej na liczbę mzrational: 5
template<typename Real> explicit mzrational(real x){...} Rzutowaniu ułamków, z postaci zwykłej na postać dziesietną, towarzyszy często utrata precyzji. Jest to związane przede wszystkim z błędami w reprezentacji zmiennoprzecinkowej. Odwrotna zamiana typów również nie pozwala uniknąć błędów. Przyczyny tego mogą być następujące. Po pierwsze, zamieniana liczba zmiennoprzecinkowa nie mieści się w granicach reprezentacji liczby wymiernej, wówczas konieczne jest obcięcie, bądź zaokrąglenie liczby do n cyfr (w jezyku C++, liczby typu long long int są zazwyczaj, co najwyżej 18 cyfrowe). Drugi rodzaj błędu, z jakim można się spotkać przy konwersji liczb rzeczywistych do typu mzrational, wynika z niedokładnej reprezentacji liczby zmiennoprzecinkowej. Ostatnim powodem utraty precyzji jest zamiana liczb niewymiernych na postać wymierną. Tego typu dane nigdy nie zostaną poprawnie przedstawione, co wynika z własności tych liczb [7]. Faktem jest, że nie każda zamiana typów spowoduje, że wartości liczbowe utracą swoją pierwotną dokładność. Jednak świadomość tego, kiedy i gdzie są popełniane błędy ułatwia określenie zasad dodawania, odejmowania, mnożenia i dzielenia liczb mieszanych, w taki sposób, by osiągnąć jak najwyższą prezyję otrzymywanych wyników. 5 Podsumowanie Artykuł przedstawia ogólną charakterystykę typu mzrational. Liczby prezentowane, jako ułamki zwykłe, poszerzają dotychczasowe możliwości, o wykonywanie precyzyjnego dodawania (a co za tym idzie, odejmowania) liczb, szczególnie o dużej rozbieżności wykładników. Ponadto, łatwość wykonywania porównań, takich jak: która z liczb jest większa, bądź: czy dwie liczby są równe, czy różne - to dodatkowy atut typu mzrational. Niestety, każdy reprezentacja danych, w pamięci komputera posiada pewne wady. Tak też jest w przypadku reprezentacji wymiernej implementacji. Świadczą o tym wyżej przedstawione przykłady. Dzięki poznaniu i zrozumieniu wszelkich ograniczeń, zastosowanie w konkretnych aplikacjach arytmetyki liczb wymiernych, staje się o wiele prostsze i bardziej efektywne. Literatura [1] W. Hebish, A. Szustalewicz, K. Tabisz, Wstęp do informatyki, 2002. [2] D. Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic, 1991. [3] K. Adamski, http://nr-k.namyslow.eu.org/, Liczby zmiennoprzecinkowe, 2003. [4] Wikipedia, http://pl.wikipedia.org/wiki/liczba_zmiennoprzecinkowa. [5] P. Furmański, Ś. Sobieski, Wstęp do Informatyki, wer. RCI, 2004. [6] Boost, http://www.boost.org/libs/rational. [7] T. Trajdos, Matematyka, wyd. VI, 1993. 6