Problemy przy zamianie string na number w JavaScript

Po dłuższej przerwie w blogowaniu czas powrócić… a na tapetę dzisiaj weźmiemy problem, z którym czasami spotykam się szczególnie wśród osób zaczynających dopiero naukę JS/HTML, a mianowicie z kwestią konwertowania ciągów znakowych do typu number. Omówimy kilka metod na wykonanie takiej zamiany ze wskazaniem istotnych różnic między nimi.

Konwersja ciągów znakowych do typu number jest dość częstą operacją, szczególnie jeśli korzystamy w aplikacji z bardziej rozbudowanych formularzy, w których prosimy użytkownika o wprowadzanie różnych liczb. W wielu poradnikach i przykładach w internecie bardzo często widnieje metoda parseInt, co jednak nie zawsze daje faktycznie oczekiwane rezultaty. W tym artykule omówimy aż jedenaście sposobów na przekonwertowanie ciągu string do typu number.

No to zaczynamy zabawę… 🙂

Sposób zapisu liczby w JavaScript

W języku JavaScript liczby można zapisywać na kilka sposobów. Najczęściej stosowany jest zapis dziesiętny, np. 1, 2.5 itp. Za prawidłowy uznaje się również zapis .5 równoważny liczbie 0.5 jak również 5. jako 5.

W niektórych przypadkach używane są także zapisy w systemie szesnastkowym, np. FF czyli dziesiętnie liczba 255. W zapisie tym dysponujemy cyframi od 0 do 9 oraz literami A do F, przy czym mogą to być zarówno małe jak i wielkie litery. Ten zapis pewnie większość osób zna chociażby z pracy ze stylami CSS.

Ostatni zapis, któremu dzisiaj się przyjrzymy to zapis w notacji wykładniczej, czyli np. 2e5 co oznacza 2 * 10 do potęgi 5, czyli 2 * 100000 = 200000. W zapisie tym należy jednak uważać, ponieważ nie każda liczba przed znakiem „e” zapisana z kropką będzie oznaczać końcowo liczbę całkowitą. Na przykład:

2.5e1;   //25
2.505e1; //25.05

O tym czy mamy do czynienia z liczbą całkowitą czy zmiennoprzecinkową decyduje bowiem ilość cyfr przed znakiem „e” i jednocześnie wykładnik, czyli liczba znajdująca się z prawej strony „e”.

W tym artykule skupimy się na tych trzech formach zapisu liczb, które są wykorzystywane najczęściej. Zapis wykładniczy w większości tradycyjnych aplikacji raczej jest rzadko spotykany, ale jest to format dość powszechnie używany np. w obliczeniach inżynierskich.

Pole <input> o typie number

Na początku musimy zastanowić się skąd będą pochodzić dane do przetworzenia i w jakim będą one formacie. Dzisiaj skupimy się na danych w formie ciągów znakowych string, które otrzymujemy np. z formularzy na stronach www.

Tradycyjne pole <input type="text"> jak się można domyślać zawiera wartość znakową. Pole umożliwia wpisanie nie tylko liczb ale praktycznie dowolnych znaków Unicode, chyba, że świadomie to ograniczymy nasłuchując odpowiednich zdarzeń.

Nasuwa się więc pomysł, aby wykorzystać pole o type number, czyli <input type="number">. Jest to jak najbardziej słuszna decyzja, jednakże należy zdawać sobie sprawę z kilku niuansów tego rozwiązania.

Po pierwsze trzeba pamiętać, że nadal dane zapisane w value takiego pola są typu string, a nie number jak mógłby sugerować typ pola. Ponad to w przypadku środowiska nieobsługującego typu number pole zostanie wyświetlone i obsłużone jak tradycyjne pole typu text.

Tym problemem jednak nie będziemy się zajmować. Pozostaje pytanie co właściwie umożliwia pole typu number.

Poniżej pokazałem kilka przykładowych wartości wpisanych do pola number i wartości, jaki zostaną zwrócone po odczytaniu właściwości value tego pola:

1.5    ->  "1.5"
1,5    ->  "1.5" (otrzymamy zapis z kropką)

.5     ->  ".5"
,5     ->  ".5" (tutaj również dostajemy kropkę)

2e3    ->  "2e3"
2.5e3  ->  "2.5e3"
.5e3   ->  ".5e3"

ale...
,5e3   ->  "" (błąd)
2,5e3  ->  "" (błąd)

Zauważ ciekawą rzecz, otóż dla liczb dziesiętnych dopuszczalny jest przecinek w roli separatora. Użycie i przecinka i kropki nie przejdzie walidacji i zwróci pusty ciąg znakowy. Pewien problem jednak pojawia się gdy chcemy dopuścić zapis wykładniczy. W tym momencie przecinek nie jest dopuszczalny.

Ponad to pole typu number nie pozwala wprowadzać liczb w zapisie szesnastkowym, co czasami również może ograniczać część zastosowań.

Jeśli jednak pracujemy tylko z liczbami dziesiętnymi to jak najbardziej pole takie jest dobrym rozwiązaniem, ale… no właśnie… musimy pamiętać jeszcze o jednym drobnym szczególe. Otóż domyślnie wartość step dla pola number wynosi 1, co uniemożliwi nam przejście natywnej walidacji dla liczb zmiennoprzecinkowych.

Jednym z rozwiązań jest ustalenie dokładnego kroku, np. step="0.01" lub ustawienie step="any". W pierwszym przypadku kontrolki będą zwiększać jednak wartości o 0.01 co może być czasami uciążliwe, natomiast w drugiej sytuacji zmiana będzie co 1. Wybór rozwiązania zależy więc od konkretnej sytuacji.

Przykładowe dane testowe

Do testów wykorzystamy tablicę zawierającą różne liczby w formie ciągów znakowych string oraz kilka elementów, które nie są poprawnymi liczbami. Pozwoli to pokazać istotne różnice w działaniu różnych metod.

const values = [
    '1', '1.', '1.0', '1.2', '1.5', '1.6', '1.60',
    '-1', '-1.2', '-1.5',
    '0.5', '.5', '0', '0.0',
    'ff', 'FF', '-FF', 'FFY',
    '2e5', '-2e5', '2.3e1', '.5e2',
    '___', '__5', '__5.5', '5__', '5.5__',
];

Dla potrzeb tego wpisu stworzyłem przykład na CODEPEN, gdzie zestawiłem wszystkie wyniki w formie jednej tabeli, co jednocześnie umożliwia łatwe samodzielne testowanie wszystkich metod (wystarczy odpowiednio modyfikować tablicę values).

Konstruktor Number i operator „+”

Konstruktor Number i operator „+” w zasadzie dają taki sam rezultat, co widać na tabeli, do której link jest powyżej (link codepen).

Dla liczb w zapisie dziesiętnym wszystkie wyniki są zgodne z oczekiwanymi, czyli zachowane są również zapisy zmiennoprzecinkowe. Zwróć uwagę, że przekazanie do konstruktora Number wartości wskazującej na zapis wykładniczy spowoduje przedstawienie tej liczby w normalnym zapisie dziesiętnym.

Sposób ten jest dość często wykorzystywany i w wielu sytuacjach jest to bezpieczne i w pełni przewidywalne rozwiązanie. Nie sprawdzi się jednak w przypadku zapisów innych niż dziesiętne – np. dla zapisu szesnastkowego otrzymujemy NaN (Not a Number).

Podobnie wartość NaN dostaniemy przy próbie przekazania ciągu, który po za cyframi dziesiętnymi zawiera również inne znaki, np. znak „_”. Zachowanie jest tutaj podobne jak przy liczbach szesnastkowych. Konstruktor Number i operator „+” muszą więc otrzymać string zawierający wyłącznie cyfry arabskie 0-9 i ewentualny separator w postaci znaku kropki – przecinek nie jest dozwolony (dla Number('2,5') otrzymamy również NaN).

Konstruktor ten możemy wykorzystać np. w metodzie Array.prototype.map do szybkiego przekonwertowania tablicy ciągów znakowych na tablicę liczbową number:

const arr = ['1.5', '0.3', '5.6'];

arr.map(Number);  //[1.5, 0.3, 5.6]
arr.map(n => +n); //[1.5, 0.3, 5.6]

Funkcja parseFloat

Działanie metody parseFloat jest w dużym stopniu analogiczne do wywołania konstruktora Number. Zwróć jednak uwagę na dwa ostatnie przypadki, czyli ciągi znakowe "5__" oraz "5.5__". W tych przypadkach metoda parseFloat potrafi wyciągnąć z początku string liczby, które da się przekonwertować do typu number i zignoruje dalsze znaki (za ostatnimi piątkami).

Daje więc to nieco większy zakres dopuszczalnych ciągów niż metoda z konstruktorem Number czy operatorem „+”. Trzeba jednak w tym momencie zastanowić się, czy na pewno dwa powyższe ciągi uznajemy za poprawne? Jeśli byłyby to dane wprowadzane do formularza, gdzie użytkownik wyraźnie miałby podać liczbę to moim zdaniem tego typu próby powinny zakończyć się i odrzuceniem takiej wartości jako błędnej. Zabezpiecza nas przed tym pole input o typie number, ale pamiętajmy, że nie zawsze możemy być pewni uruchomienia się walidacji HTML5.

Z metodą parseFloat związany jest jeszcze pewien problem dokładności liczb w JavaScript. Spójrz na poniższy przykład:

parseFloat('2.00000000000005');   //2.00000000000005
parseFloat('2.000000000000005');  //2.000000000000005
parseFloat('2.0000000000000005'); //2.0000000000000004

Zwróć uwagę na penie nie do końca oczekiwany rezultat w trzecim przypadku. Nie będziemy teraz omawiać dokładności liczb w JS, problem ten można by rozwiązać np. z odpowiednim wcześniejszym użyciem metody toFixed albo obrobić string odpowiednim regexp, ale do tematu tego może powrócimy kiedy indziej.

Metoda Math.trunc vs parseInt()

No dobrze, a teraz załóżmy, że interesują nas tylko liczby całkowite, a dokładniej mówiąc tylko część całkowita z liczby, czyli np. dla liczby 12.6 chcemy pozyskać liczbę 12, stanowiącą będącą liczbą integer.

Pierwsza myśl to pewnie użycie funkcji parseInt. I tutaj mała uwaga – obecnie (jeśli dobrze pamiętam to od wersji ECMAScript 5) funkcja ta domyślnie przyjmuje podstawę konwersji jako 10 (czyli system dziesiętny). Myślę, że dzisiaj już nie musimy się martwić o starsze środowiska, nie obsługujące ES5, ale mimo wszystko preferuję jawne podawanie podstawy konwersji.

Dodatkowo obiekt Math posiada metodę trunc(), która również służy do wyciągania części całkowitej z liczby. Między tymi metodami jest jednak pewna istotna różnica.

Otóż metoda parseInt pobiera wartość typu string (dla innego typu zostanie wywołana niejawnie metoda toString) i próbuje wyciągnąć z tego stringa liczbę stanowiącą liczbę typu integer. Dlatego wywołana na ciągu ".5" zwraca NaN, ponieważ nie znajduje na początku ciągu żadnej liczby.

Zwróć jednak uwagę, że metoda Math.trunc prawidłowo zwraca w tym wypadku zero. Co więcej, spójrz na rezultat wywołania obu metoda dla ciągów przedstawiających liczby w zapisie wykładniczym.

Funkcja parseInt z ciągu "2e5" zwraca tylko cyfrę 2, ponieważ potem znajduje się znak, nie będący żadną cyfrą (litera „e”). Z kolej metoda Math.trunc zwraca nam liczbę w zapisie 200000 co jest wynikiem prawidłowym.

Skąd więc ta różnica? Otóż metoda Math.trunc, jak i inne metody obiektu Math, powinny otrzymać jako argument wartość typu number. Jeśli będzie to inna wartość, np. string jak w naszych przypadkach, to zostanie na tej wartości wywołany konstruktor Number. W tym momencie spójrz jakie wartości otrzymaliśmy po wywołaniu konstruktora Number i zobaczysz, że to właśnie z tych liczb metoda trunc wyciągnęła część całkowitą.

Zanim jednak wpadniemy w euforię i całkowicie wyprzemy z głowy funkcję parseInt należy przeanalizować jeszcze dwa ostatnie przypadki, czyli ciągi "5__""5.5__". Funkcja parseInt pozwoliła wyciągnąć z takich ciągów liczbę całkowitą odcinając (w pewnym uproszczeniu) dalsze, błędne znaki. Metoda trunc zwróci jednak NaN, ponieważ taką wartość otrzyma po wywołaniu konstruktora Number.

Wybór metody powinien więc zależeć od rodzaju danych wejściowych i oczekiwanych rezultatów dla konkretnych typów wartości. Jeśli chcemy być pewni, że zawsze otrzymamy wartość typu number to pewnym rozwiązaniem może być użycie operatorów bitowych „~~n” lub „n>>0”, które omówimy pod koniec artykułu. Trzeba jednak pamiętać, że operatory binarne w JS pracują prawidłowo tylko dla liczb 32-bitowych, a zakres tzw. bezpiecznych liczb w JS to zakres między Number.MIN_SAFE_INTEGER i Number.MAX_SAFE_INTEGER czyli wykraczający poza 32-bity.

Zwróć również uwagę, że przekonwertowanie ciągu zawierającego zapis HEX do postaci binarnej możliwe jest wyłącznie przy użyciu metody parseInt ze wskazaniem podstawy jako 16. Uważaj jednak ze zbyt pochopnym stosowaniem tego zapisu – spójrz jakie wyniki daje to dla pozostałych wartości.

Metody Math.round i Math.ceil

Działanie metod round i ceil należy rozpatrywać pamiętając o tym co przed chwilą pisaliśmy o niejawnym wywoływaniu konstruktora Number jeśli argumentem dla tych metod będzie typ inny niż number.

Metoda Math.round dokonuje zaokrąglenia liczby zgodnie z ogólnie przyjętymi zadadami matematyczymi, czyli liczba dziesiętna od 0.0 do 0.4 zostanie uznana za 0, natomiast od 0.5 do 1.0 będzie uznana za 1.

Z kolei metoda Math.ceil dokonuje zaokrąglenia zawsze w górę. Nie wolno jej jednak mylić z metodą trunc, której zadaniem było wyciągnięcie części całkowitej. Metoda Math.ceil dla liczby 0.1 da wynik 1.0, czyli najbliższe zaokrąglenie do liczby całkowitej.

Dla ciągów, na których konstruktor Number zwróci NaN, metody obiektu Math również zwrotnie dadzą wartość NaN.

Uwaga na Math.floor vs operatory binarne

Często spotykaną metodą jest również Math.floor, szczegółnie w różnego rodzaju algorytmach matematyczno-fizycznych. Tutaj również nastąpi niejawne wywołanie konstruktora Number, stąd dla części wyników wartości NaN.

Dla prawidłowych liczb (prawidłowych wg konstruktora Number) zostanie zwrócona najbliższa liczba całkowita mniejsza od liczby n lub jej równa (jeśli część dziesiętna wynosi zero).

Patrząc na wykonaną przeze mnie tabelę może wydawać się, że działa ona tak samo jak metoda trunc, czyli po prostu wyciąga część całkowitą. Zwróć jednak szczególną uwagę co się dzieje w przypadku liczb ujemnych!

Dla liczby -1.2 metoda Math.trunc zwróci 1, czyli część całkowitą, natomiast metoda Math.floor zwróci najbliższą mniejszą liczbę, czyli -2. Jeśli przewidujemy pracę z liczbami ujemnymi to musimy na takie zachowania zwracać szczególną uwagę.

Przyjrzymy się teraz dwóm ostatnim kolumnom, czyli operatorom bitowym. W naszych przypadkach oba operatory działają identycznie. Nie będę tutaj omawiał dokładnie zasad operacji bitowych, zainteresowanych tą tematyką odsyłam do innego mojego artykułu Operatory bitowe nie takie straszne.

Stosując operatory binarne mamy pewność, że zawsze otrzymamy w wyniku wartość typu number, jednakże musimy pamiętać o bardzo ważnej kwestii – operatory bitowe w JS pracują prawidłowo tylko dla liczb 32-bitowych! W JavaScript możemy jednak posługiwać się większymi liczbami, których bezpieczny zakres określają wartości:

Number.MAX_SAFE_INTEGER === Math.pow(2, 53)-1;  //true
Number.MIN_SAFE_INTEGER === Math.pow(-2, 53)+1; //true

Ponad to zauważ, że operatory bitowe dają inne wyniki dla liczb ujemnych w stosunku do metody Math.floor. Jeśli poruszamy się wyłącznie w zakresie 32-bitowych liczb dodatnich to możemy użyć zapisu w operatorami binarnymi. Jeśli jednak nie mamy pewności co do zakresu liczb (np. w aplikacjach inżynierskich czy innych wykonujących złożone obliczenia na dużych liczbach) lepiej zrezygnować z ich użycia na rzecz odpowiednich metod np. obiektu Math.

Napiszmy więc jedną, uniwersalną funkcję konwertującą…

Ok, skoro omówiliśmy już parę aspektów związanych z zamianą liczb to możemy spróbować napisać jedną uniwersalną funkcję, która pozwoli nam przekonwertować dowolny z naszych ciągów znakowych na typ number.

Funkcja taka mogłaby wyglądać tak:

function convertStringToNumber(str) {
    return false;
}

Jeśli w tym miejscu zastanawiasz się skąd takie rozwiązanie, to spójrz na problem z innej strony, i przeanalizujmy kilka wybranych problemów:

  • mając ciąg „2e3” jak rozróżnić, czy mamy do czynienia z zapisem szesnastkowym (gdzie „e” to dozwolony znak reprezentujący dziesiętnie liczbę 14) od zapisu wykładniczego…?
  • mając liczbę „1.6” skąd wiedzieć, czy interesuje nas cała liczba, a może część całkowita, a może zaokrąglenie itp.?
  • mając zapis „5__” skąd mamy wiedzieć, czy powinniśmy to uznać za liczbę poprawną czy błędną?

Takich problemów jest więcej i za chwilę okazałoby się, że nasza funkcja miała by tak wiele parametrów, że jej użycie byłoby praktycznie bezsensowne i znacznie wygodniejsze jest korzystanie z odpowiednio wybranej metody z omówionych w artykule.

Podsumowanie

To mój pierwszy wpis po dłuższej przerwie i początkowo miał być krótkim opisem kilku metoda, a wyszedł z tego całkiem spory artykuł 🙂 Mam jednak nadzieję, że kilku osobom się przyda ponieważ co jakiś czas dostaję pytania szczególnie od osób początkujących w JS związane właśnie z problemami konwersji stringów do number.

Moim zdaniem nie ma sensu usilne poszukiwanie najlepszego rozwiązania i po prostu trzeba pamiętać o pewnych niuansach różnych funkcji, dobierając metodę do konkretnego przypadku. Mam nadzieję, że pomocna będzie przedstawiona przeze mnie tabela (codepen).

Jeśli znasz jeszcze inne ciekawe przypadki związane z konwersją string->number to zapraszam do dyskusji w komentarzach 🙂