Operacje na liczbach – zaokrąglanie i precyzja wyświetlania

W wielu aplikacjach zachodzi konieczność pracy z wartościami numerycznymi i przekazywania zwrotnych wyników w określonym formacie. Omówimy więc najważniejsze kwestie związane z zaokrąglaniem liczb i dokładnością ich wyświetlania.

W artykule omówimy kilka podstawowych metod używanych w operacjach matematycznych jak:

  • parseInt,
  • Number.prototype.toFixed,
  • Number.prototype.toPrecision,
  • Math.round,
  • Math.floor,
  • Math.ceil

Każda z ww. metod wykonuje nieco inne operacje i zwraca wartości różnych typów na co należy uważać podczas wykonywania różnych działań matematycznych.

Zaokrąglenie liczby do dwóch miejsc po przecinku

Zaokrąglanie liczb i określanie ich dokładności nie zawsze jest do końca oczywistą i prostą sprawą. Mamy trzy podstawowe metody obiektu globalnego Math, tj. round, floor i ceil ale należy pamiętać, że metody te zwracają tylko część całkowitą z liczby, zatem nie sprawdzą się (w sposób bezpośredni) do uzyskania dokładności do 2 (lub innej dowolnej liczby) miejsc po przecinku.

Przypadek z koniecznością uzyskania dokładności do dwóch miejsc po przecinku występuje wbrew pozorom dość często, np. podczas operacji na liczbach reprezentujących kwoty pieniężne – grosz (czyli 0.01) jest przecież najmniejszą jednostką walutową.

Nie wchodzimy tutaj w zaawansowane operacje walutowe (gdzie stosuje się większą dokładność cząstkową kwot) – załóżmy np. że piszemy prosty kalkulator obliczający cenę brutto na podstawie ceny netto podanej w formularzu na stronie internetowej. Zacznijmy od metody toFixed obiektu Number:

var netto= '11.55', vat = 1.23, brutto;

brutto = netto * vat; //14.2065
typeof brutto; //'number'

brutto = (netto * vat).toFixed(2); //'14.21'
typeof brutto; //'string'

brutto = Number((netto * vat).toFixed(2)); //14.21
typeof brutto; //'number'

brutto = +((netto * vat).toFixed(2)); //14.21
typeof brutto; //'number'

Na początku zmienną netto zadeklarowano z wartością 14.55 jako string. Zrobiłem to celowo, gdyż w przypadku pobierania wartości z formularzy html otrzymujemy dane właśnie jako ciągi znakowe String. W naszym przypadku nie ma to większego znaczenia, gdyż zarówno na zmiennej netto jak i vat wykonujemy wyłącznie operację mnożenia. W tej sytuacji nastąpi niejawna konwersja netto to typu Number (w dużym uproszczeniu można powiedzieć, że zostanie na niej wywołana metoda parseFloat).

Przeanalizujmy więc kolejne fragmenty naszego kodu. Na początku próbowaliśmy po prostu wykonać zwykłą operację mnożeniu netto * vat. W wyniku otrzymaliśmy wartość liczbową ale przedstawioną z aż czterema cyframi po przecinku. Co więcej, takie wywołanie będzie zwracać liczby o różnej dokładności w zależności od otrzymanych  wyników (zostaną usunięte po prostu wszystkie końcowe zera).

No dobrze, zatem w kolejnym przypadku zdecydowaliśmy się użyć metody toFixed. Wynik został prawidłowo zaokrąglony do oczekiwanych dwóch miejsc po przecinku (co określa się jako parametr metody) lecz zwróć uwagę, że wynikowa wartość jest ciągiem znakowym typu String. Metoda toFixed jest funkcją obiektu globalnego Number, więc musi zostać wywołana na wartości numerycznej. W naszym przypadku jak wspomniałem wcześniej nastąpiła niejawna konwersja netto do typu number, dlatego wynikiem mnożenia również jest typ number.

Załóżmy jednak, że otrzymany wynik zapisany w zmiennej brutto będzie nam potrzebny do wykonania kolejnych operacji matematycznych i należy go przechowywać jako typ numeryczny.

Możemy zatem, co pokazano w kolejnych liniach kodu, użyć jawnej konwersji wyniku na typ numeryczny poprzez zastosowanie konstruktora Number(). Można użyć również formy skróconej zamieniając Number na znak plusa „+” (osobiście preferuję właśnie taką krótszą formę).

Jak widać metoda toFixed() dobrze rozwiązuje nasz problem. Jeśli metodzie nie przekażemy żadnego parametru to zostanie ona wywołana jako toFixed(0), czyli zwróci liczbę zaokrągloną do części całkowitej (podobnie jak Math.round).

Czasami w internecie można również spotkać metodę zaokrąglania bazującą na funkcji Math.round (kod poniżej). Zwraca ona od razu wartość typu numerycznego lecz osobiście uważam, że do określania dokładności wyświetlania lepiej stosować metodę toFixed, a metodę Math.round pozostawić do wyciągania części całkowitej z liczby.

function round2(n){
   return Math.round(n*100)/100;
}

round2(14.54); //14.54
round2(14.5467); //14.55

Uważaj na metodę toPrecision()

Nazwa tej metody mogłaby sugerować, że jej zadaniem jest określenie dokładności liczby. Owa precyzja nie dotyczy jednak zaokrąglenia, lecz ilości cyfr w liczbie, przy czym nie ma znaczenia czy jest to liczba całkowita czy zmiennoprzecinkowa.

(14.2065).toPrecision(2); //'14'
(14.2065).toPrecision(3); //'14.2'
(14.2065).toPrecision(4); //'14.21'
(144.2065).toPrecision(4); //'144.2'

(14.2065).toPrecision(); //'14.2065'
(14.2065).toPrecision(10); //'14.20650000'

Metoda toPrecision, podobnie jak toFixed zwraca wartość typu String i można na niej stosować dodatkowe operacje, np.  wywołanie konstruktora Number.

Przeanalizuj jednak dokładnie co się dzieje w kolejnych wywołaniach. Jeśli do metody toPrecision przekażemy parametr numeryczny, to będzie on oznaczał ilość cyfr jaka zostanie użyta do przedstawienia danej liczby.

Jeśli parametr jest mniejszy niż ilość cyfr w naszej liczbie to nastąpi zaokrąglenie na zasadach matematycznych (czyli od 0.5 zaokrąglamy w górę). Jeśli jednak przekazany parametr będzie większy to w zwrotnym ciągu znakowym pojawią się dodatkowe dopełniające zera. Wywołanie metody toPrecision bez podania parametru wejściowego zwróci po prostu liczbą w formie ciągu znakowego (niejawnie zostanie tutaj wywołana metoda toString() na zmiennej typu Number).

Stosując metodę toPrecision należy uważać na jeszcze jeden drobny problem. W pewnych sytuacjach metoda potrafi zwrócić liczbę zapisaną w notacji naukowej. Pamiętajmy jednak, że wynik jest wartością typu String. Spójrzmy na poniższy przykład ilustrujący wyraźnie omawiany problem:

(50.3).toPrecision(2); //'50'
(502.3).toPrecision(3); //'502'
(502.3).toPrecision(2); //'5.0e+2' czyli 5*10^2=500
(502.3).toPrecision(1); //'5e+2' czyli 5*10^2=500

Jeśli na otrzymanym wyniku wywołamy metodę parseFloat lub użyjemy konstruktora Number to otrzymamy poprawną wartość typu Number. Uważaj jednak jeśli planujesz zwrócić użytkownikowi bezpośredni wynik bazujący na metodzie toPrecision, gdyż większość użytkowników internetu nie zna zasad tworzenia liczb w notacji naukowej i może nawet uznać taką wartość za błędną.

Jeśli planujesz użyć w aplikacji metody toPrecision to dobrze zastanów się w jakim celu to robisz i napisz odpowiednio dokładne testy.

Zaokrąglenie z dokładnością do 0.5

Metoda toFixed dobrze radzi sobie z zaokrąglaniem do określonej liczby cyfr po przecinku, lecz stosuje tutaj matematyczne reguły zaokrąglenia liczb, czyli 5.4 zostanie zaokrąglone do 5, natomiast 5.6 do 6.

Zdarza się, że zależy nam na nieco innej formie zaokrąglenia, np. z zaokrągleniem do jednego miejsca po przecinku, ale z dokładnością do 0.5. W takim przypadku metoda toFixed nie jest najlepszym rozwiązaniem. Przyjrzyjmy się więc metodzie round obiektu Math:

function round(n) {
    return Math.round(n*2)/2;
}

round(5.2); //5
round(5.3); //5.5
round(5.4); //5.5
round(5.5); //5.5
round(5.6); //5.5
round(5.7); //5.5
round(5.8); //6

round(5.74); //5.5
round(5.75); //6

round(5.744); //5.5
round(5.745); //6

Jak widać w wielu przypadkach w programowaniu wcale nie trzeba szukać zaawansowanych funkcji i wystarczy zastosowanie prostych szczutek i algorytmów matematycznych.

W naszym przykładzie użyto metody round obiektu Math, która zwraca liczbę zaokrągloną do części całkowitej, dlatego naszą liczbę n musimy wcześniej pomnożyć razy 2, wyciągnąć zaokrągloną część całkowitą i dopiero tę wartość podzielić przez 2. Jak wiemy z podstaw matematyki każda liczba całkowita podzielona przez dwa da w wyniku albo liczbę całkowitą, albo liczbę z dokładnością do 0.5 (dla liczb nieparzystych). Nasza metoda działa prawidłowo również na liczbach o większej dokładności niż do 1 czy 2 miejsc po przecinku.

Metoda round zwraca wartość typu number więc nie musimy stosować w tym wypadku szczutek z jawną konwersją na typ Number.

Wyciąganie części całkowitej z liczby zmiennoprzecinkowej

Ok, zaokrąglanie liczb mamy załatwione (dla większości prostych przypadków). Przejdźmy do drugiego zagadnienia – wyciągania części całkowitej z liczby. W JavaScript istnieje tylko jeden typu numeryczny – typ Number i przechowuje on liczby zarówno całkowite jak i zmiennoprzecinkowe.

Mamy do dyspozycji kilka metod przeznaczonych do operowania na części całkowitej liczby. Zacznijmy od metody parseInt:

parseInt('11.5'); //11
parseInt('11.5',10); //11
parseInt('11.5',16); //17=1*16^0+1*16^1
parseInt('11.5',8); //9=1*8^0+1*8^1

parseInt('x'); //NaN
parseInt(null); //NaN

parseInt(55.5); //55

Wywołując metodę parseInt warto pamiętać, że pobiera ona również drugi, opcjonalny parametr który określa system liczbowy w jakim ma być prezentowana dana wartość. Jeśli go nie podamy to od ECMAScript 5 jest przyjmowana domyślna podstawa 10 (w starszych środowiskach nie było podstawy i wartość ‚055’ zostałaby wykryta jako ciąg znakowy reprezentujący liczbę w zapisie ósemkowym).

Jeśli do metody parseInt przekażemy wartość, która nie może zostać przekonwertowana do typu Number to zwrócona zostanie wartość specjalna NaN.

Metoda parseInt oczekuje pierwszego parametru w formie ciągu znakowego String i drugiego (opcjonalnego) jako typu Number. Metoda wykona się jednak prawidłowo również wówczas, gdy przekażemy pierwszy parametr jako typ Number. O ile takie wywołanie funkcji parseInt ma sens to już podanie drugiego parametru jako String wydaje się niezbyt użyteczne. Może się to jednak zdarzyć gdy umożliwimy dynamiczną zmianę tego parametru bez dopilnowania typu przekazywanej wartości – metoda wykona się prawidłowo, ale zalecam unikania takich sytuacji.

Metoda parseInt wyciąga bezpośrednio część całkowitą, bez analizowania wartości znajdujących się po przecinku. Jeśli potrzebujemy części całkowitej ale zaokrąglonej względem pierwszej cyfry po przecinku to możemy teoretycznie użyć metody toFixed() lub toFixed(0). Otrzymamy jednak wartość typu String, którą musimy poddać dodatkowej konwersji do typu Number.

Ponad to co w przypadku, gdy interesuje nas część całkowita ale zaokrąglona zawsze „w górę”, np. dla liczby 55.2 chcemy uzyskać wynikowo wartość 56. W tym miejscu z pomocą przychodzą metody obiektu globalnego Math, które z zasady powinno się wywoływać na wartości typu Number, choć wykonają się również na wartości String (nastąpi niejawna konwersja do liczby zmiennoprzecinkowej w podobny sposób jak działa parseFloat).

Math.round(55.2); //55
Math.round(55.5); //56

Math.ceil(55.2); //56
Math.ceil(55.5); //56

Math.floor(55.2); //55
Math.floor(55.6); //55
~~(55.2); //55
~~(55.6); //55

Math.floor(-55.6); //-56
~~(-55.6); //55

Osobiście uważam, że podczas wykonywania operacji matematycznych lepiej jest stosować metody obiektu Math gdyż jasno wskazuje się wtedy wybraną metodę zaokrąglenia i wyciągania części całkowitej.

Jeśli operujemy wyłącznie na liczbach dodatnich to zapis Math.floor można zastąpić skróconym zapisem z wykorzystaniem podwójnego operatora bitowego NOT (sposób ten omówimy przy okazji opisywania operatorów bitowych).

Niezależnie od zastosowanej metody zawsze zwracaj uwagę na typ wynikowej wartości – String lub Number. Ponad to z dużą ostrożnością i rozwagą korzystaj z metody toPrecision, a już na pewno nie stosuj jej w celu zaokrąglania liczb do określonej ilości cyfr po przecinku.