Jak Number w JavaScripcie i float w Pythonie przechowują liczby
Większość języków statycznie typowanych, jak Java czy C, ma różne typy danych dla liczb.
Na przykład jeśli musisz przechować liczbę całkowitą z zakresu [-128;127], możesz użyć byte w Javie i char w C — oba zajmują tylko 1 bajt.
Jeśli musisz przechować większą całkowitą, możesz użyć typów int lub long, które zajmują odpowiednio 4 i 8 bajtów.
Są też oddzielne typy do przechowywania liczb z częścią ułamkową — float, który zajmuje 4 bajty, i double z 8 bajtami. Zwykle nazywa się je formatem zmiennoprzecinkowym, a dalej zobaczymy, skąd wzięła się ta nazwa.
JavaScript i Python nie idą tym schematem. JavaScript przez długi czas miał tylko jeden ogólny typ Number, który obsługiwał każdą wartość liczbową — całkowite i ułamkowe na równi — a BigInt doszedł później jako oddzielny typ całkowitych o dowolnej precyzji.
Odpowiednikami w Pythonie są int dla całkowitych o dowolnej precyzji i float dla wartości ułamkowych.
Być może widziałeś już to dziwne zachowanie — wpisz 0.1 + 0.2 do konsoli JavaScriptu i w odpowiedzi dostaniesz 0.30000000000000004, a nie 0.3. Na ten temat istnieje niezliczona liczba wątków na StackOverflow, zgłoszeń na GitHubie i wpisów blogowych, i niemal wszystkie zrzucają winę na JavaScript. Ale ta sama odpowiedź wraca w Pythonie, Javie, C i każdym innym języku, który używa IEEE-754 binary64 jako typu zmiennoprzecinkowego — 64-bitowego formatu podwójnej precyzji, leżącego pod zarówno Number JavaScriptu, jak i float Pythona.
Ta sama mechanika pod spodem przejawia się w innych zachowaniach, które wyglądają jak błędy języka, ale nimi nie są — dwie duże, ale nie aż tak duże liczby całkowite okazują się równe, albo wartość nie jest równa sama sobie:
> 9007199254740992 === 9007199254740993
true
> NaN === NaN
falseTe same wyrażenia w Pythonie — te same odpowiedzi, co do bitu:
>>> 9007199254740992.0 == 9007199254740993.0
True
>>> float('nan') == float('nan')
FalseW tym artykule przejdziemy przez to, jak te 64 bity naprawdę działają według IEEE-754, przeprowadzimy te same eksperymenty w obu językach, by zobaczyć, że dają identyczne układy bitów, i ustalimy, które zachowania pochodzą od floata, a które od języka owiniętego wokół niego.
Jakie typy liczbowe udostępnia każdy z języków
Przed otwarciem bitów warto rozłożyć to, co każdy język daje na powierzchni. Oba mają oddzielne typy dla liczb całkowitych i ułamkowych, o różnych nazwach i różnych domyślnych ustawieniach:
| Język | Rodzaj | Typ | Szczegóły |
|---|---|---|---|
| Python | Całkowita (bez części dziesiętnej, dokładna) | int | Dowolna precyzja. CPython powiększa magazyn w miarę potrzeby, więc 2**1000 po prostu działa, a wynik jest dokładny. |
| Zmiennoprzecinkowa (64-bitowy double IEEE-754) | float | Ten sam format, którego rozbieraniu poświęcona jest reszta artykułu. Literał 0.5 w skrypcie Pythona to 8 bajtów. | |
| JavaScript | Całkowita (bez części dziesiętnej, dokładna) | BigInt | Liczba całkowita o dowolnej precyzji, dodana później jako oddzielny typ. Zapisywana z sufiksem n (np. 5n). Najbliższy odpowiednik pythonowego int. |
| Zmiennoprzecinkowa (64-bitowy double IEEE-754) | Number | Ten sam format co float w Pythonie. JavaScript używa go i dla całkowitych, i dla ułamkowych; domyślnie nie ma oddzielnego typu całkowitego. |
Każdy z języków domyślnie wybiera też inny typ, gdy napiszesz literał liczbowy. Python domyślnie bierze int i przechodzi na float tylko wtedy, gdy w literale pojawi się kropka dziesiętna:
>>> type(5)
<class 'int'>
>>> type(5.2)
<class 'float'>
>>> type(5 + 0.0) # int + float promuje się do float
<class 'float'>JavaScript domyślnie robi odwrotnie — każdy literał liczbowy jest typu Number (czyli zmiennoprzecinkowego), o ile nie oznaczysz go n, by zrobić z niego BigInt. typeof raportuje 'number' zarówno dla całkowitych, jak i ułamkowych:
> typeof 5
'number'
> typeof 1.5
'number'
> typeof 5n
'bigint'A gdy już masz BigInt, nie możesz mieszać go z Number w arytmetyce — silnik odmawia, zamiast po cichu konwertować:
> 5n + 1
TypeError: Cannot mix BigInt and other types, use explicit conversions
> 5n + BigInt(1)
6n
> Number(5n) + 1 // odwrotny kierunek też działa, kosztem precyzji
6Podział na dokładne całkowite i domyślne zmiennoprzecinkowe jest w obu językach z grubsza symetryczny, ale domyślne ustawienia są odwrócone:
w Pythonie musisz świadomie wybrać float, pisząc kropkę dziesiętną; w JavaScripcie musisz świadomie zrezygnować z floata, pisząc n dla BigInt.
Wszystko, o czym będziemy mówić dalej, żyje po stronie floata — float w Pythonie, Number w JavaScripcie i ten sam format IEEE-754 binary64 pod nimi obydwoma.
Reprezentowanie liczb w notacji naukowej
Zanim zaczniemy mówić o zmiennym przecinku i standardzie IEEE754, musimy najpierw przyjrzeć się temu, co znaczy przedstawić liczbę w notacji naukowej. Prawdopodobnie widziałeś wartości jak (liczba Avogadra) albo (postać notacji e, często używana w kodzie) — zwykle reprezentują one wielkości zbyt duże lub zbyt małe, by wypisywać je cyfra po cyfrze, i dokładnie ten problem rozwiązuje notacja naukowa.
W postaci ogólnej liczbę w notacji naukowej można przedstawić tak:
Mantysa (significand) pokazuje cyfry znaczące liczby — nazywa się ją też często significand albo precyzją.
Zera nie są uważane za znaczące; one tylko trzymają miejsce.
Podstawa określa bazę systemu liczbowego, czyli 10 dla systemu dziesiętnego i 2 dla dwójkowego.
Wykładnik definiuje, o ile miejsc trzeba przesunąć przecinek w lewo lub w prawo, by otrzymać pierwotną liczbę.
Każdą liczbę można przedstawić w notacji naukowej. Na przykład liczbę 7 w systemie dziesiętnym i dwójkowym można przedstawić tak:
Wykładnik 0 po prostu pokazuje nam, że żadne dodatkowe operacje nie są potrzebne, by otrzymać pierwotną liczbę. Spójrzmy na inny przykład — liczbę 0.00000022. Cyframi znaczącymi są tu 22, więc usuńmy zera:
Linia z w środku wygląda jak operacja jałowa — i o to właśnie chodzi, to zapełniacz. Już w następnej linii zastępujemy równoważnym wyrażeniem , które nadal jest , ale w postaci, którą da się rozdzielić. Następnie wchłania osiem przesunięć wiodących zer w (zamieniając ją w całkowitą ), a pozostałe staje się nowym wykładnikiem. Te dwa czynniki są wzajemnie odwrotne, więc wartość całego wyrażenia się nie zmienia — po prostu przestawiliśmy je w potrzebną nam postać.
Powyższe obliczenie pokazuje, dlaczego wykładnik podstawy maleje, gdy przecinek przesuwa się w prawo. Wykonując mnożenie, doprowadziliśmy więc naszą pierwotną liczbę do postaci z samymi cyframi znaczącymi:
Ponieważ użyliśmy mnożenia przez 8, musieliśmy skompensować to dzieleniem — i stąd bierze się ujemny wykładnik 8. Ten sam proces, tylko tym razem z dzieleniem, by uzyskać cyfry znaczące, można wykonać na liczbie 22300000:
Tym razem przecinek przesunął się w lewo, więc wykładnik wzrósł. Jak widzisz, notacja naukowa to sposób na łatwą pracę z bardzo dużymi lub bardzo małymi liczbami. W zależności od wykładnika mantysa może reprezentować liczbę całkowitą albo liczbę z częścią ułamkową. Przy powrocie do pierwotnej liczby ujemny wykładnik wymaga przesunięcia przecinka w lewo. Dodatni wykładnik wymaga przesunięcia w prawo i zwykle oznacza duże liczby całkowite.
Ważne jest też zrozumienie, czym jest postać znormalizowana liczby. Liczba jest znormalizowana, gdy zapisana jest w notacji naukowej z jedną niezerową cyfrą dziesiętną przed przecinkiem. Jeśli więc weźmiemy nasze pierwotne liczby i przedstawimy je w postaci znormalizowanej, będą miały następujące reprezentacje:
Przedstawienie liczb w postaci znormalizowanej umożliwia łatwe porównywanie ich rzędem wielkości.
Jak się zapewne domyślasz, liczby dwójkowe będą zawsze miały przed przecinkiem 1.
Ponieważ system dwójkowy ma tylko dwie cyfry — 0 i 1 — a normalizacja wymaga, by cyfra wiodąca była niezerowa, cyfra przed przecinkiem w znormalizowanej postaci dwójkowej to zawsze 1.
Notację naukową można traktować jako zmiennoprzecinkową reprezentację liczby. Termin „zmienny przecinek” odnosi się do tego, że przecinek liczby może „płynąć” — można go umieścić gdziekolwiek względem cyfr znaczących liczby. A jak już wiemy, pierwotne położenie wskazuje wykładnik.
Zmienny przecinek według IEEE754
Standard IEEE dla arytmetyki zmiennoprzecinkowej (IEEE 754) definiuje wiele rzeczy związanych z arytmetyką zmiennoprzecinkową, ale do celów naszej eksploracji interesuje nas tylko to, jak liczby są przechowywane, zaokrąglane i dodawane. Napisałem bardzo szczegółowy artykuł wyjaśniający, jak zaokrąglać liczby dwójkowe. Zaokrąglanie jest częstą operacją i zachodzi wtedy, gdy wybrany format nie daje dość bitów, by przechować liczbę. To ważny temat, więc dobrze opanuj jego mechanikę. A teraz przyjrzyjmy się, jak przechowywane są liczby. Wszystkie dalsze przykłady będą dotyczyć głównie liczb w systemie dwójkowym.
Jak liczby są przechowywane
Standard definiuje dwa formaty używane najczęściej — pojedynczej i podwójnej precyzji. Różnią się liczbą zajmowanych bitów, a w konsekwencji zakresem liczb, jaki każdy format może przechować. Podejście do przełożenia liczby z notacji naukowej na postać IEEE754 jest takie samo dla wszystkich formatów, różni się tylko liczba bitów przydzielonych na mantysę (cyfry znaczące) i wykładnik.
Zmienny przecinek IEEE754 przydziela bity na przechowanie znaku liczby, jej mantysy (cyfr znaczących) i wykładnika. Oto jak rozkłada te bity w formacie podwójnej precyzji (64 bity na liczbę), używanym zarówno przez Number JavaScriptu, jak i float Pythona:
63 62 52 51 0 ← pozycja bitu
0 00000000000 0000000000000000000000000000000000000000000000000000
│ │ │
sign(1) exponent(11) mantissa(52) (significand)Bit znaku dostaje 1 bit, wykładnik — 11 bitów, a 52 bity przydzielone są mantysie (significand). Oto tabela pokazująca liczbę bitów przydzielonych w każdym formacie:
| Nazwa | Bitów łącznie | Wykładnik | Mantysa |
|---|---|---|---|
| Pojedyncza precyzja | 32 | 8 | 23 |
| Podwójna precyzja | 64 | 11 | 52 |
Wykładnik przechowywany jest w formacie kodu z przesunięciem. Napisałem szczegółowy artykuł wyjaśniający ten format i jego różnice względem kodu uzupełnień do dwóch. Poświęć proszę trochę czasu na zrozumienie tego tematu, bo będę z niego korzystać przy przekładaniu liczb na format zmiennoprzecinkowy.
Przykłady przechowywania liczb całkowitych
Przed rozbiorem konkretnych przypadków bit po bicie, oto konkretny obraz od początku do końca dla liczby całkowitej 7 (111 dwójkowo) — pierwotne bity, znormalizowana postać naukowa i końcowy 64-bitowy układ IEEE-754:
111 → 1.11 × 2^2 → 0 10000000001 11 0...0
^^^ ^ ^^ ^ ^ ^^^^^^^^^^^ ^^ ^^^^^
│ │ │ │ │ │ │ └── 50 zer wypełniających (mantysa ma łącznie 52 bity)
│ │ │ │ │ │ └── przechowana mantysa: „11" (bity po wiodącej 1)
│ │ │ │ │ └── przechowany wykładnik: 2 + 1023 (przesunięcie) = 1025 = 10000000001
│ │ │ │ └── bit znaku: 0 (dodatnia)
│ │ │ └── wykładnik: o ile przesunąć przecinek z powrotem
│ │ └── mantysa (bity po niejawnej wiodącej 1)
│ └── niejawna wiodąca 1 (nieprzechowywana)
└── pierwotne bity liczby całkowitejPrzejdźmy teraz krok po kroku i zobaczmy, jak według tego samego schematu przechowywane są liczby całkowite 1 i 3.
Liczba 1 we wszystkich systemach liczbowych zapisywana jest jako 1, więc żadna konwersja nie jest potrzebna. W postaci naukowej można ją zapisać jako:
Mamy tu mantysę 1 i wykładnik 0. Na tej podstawie możesz założyć, że liczba jest reprezentowana zmiennoprzecinkowo tak:
63 62 52 51 0 ← pozycja bitu
0 00000000000 0000000000000000000000000000000000000000000000000001
│ │ │
sign exponent mantissa (significand)Sprawdźmy, czy tak naprawdę jest. Niestety ani JavaScript, ani Python nie ma wbudowanej funkcji do wypisywania surowych bitów przechowanego floata. Ale łatwo taką napisać w każdym z tych języków. Oto pomocnicza funkcja w JavaScripcie, która sama radzi sobie z kolejnością bajtów Twojego komputera — zapisuje float do Float64Array, patrzy na leżącą pod spodem pamięć jako na bajty i przechodzi je od tyłu, składając łańcuch bitów:
function to64bitFloat(number) {
var f = new Float64Array(1);
f[0] = number;
var view = new Uint8Array(f.buffer);
var i, result = "";
for (i = view.length - 1; i >= 0; i--) {
var bits = view[i].toString(2);
if (bits.length < 8) {
bits = new Array(8 - bits.length).fill('0').join("") + bits;
}
result += bits;
}
return result;
}Odpowiednik w Pythonie to jedna linia z modułem struct — spakuj float jako 8 bajtów big-endian (>d), potem rozpakuj te same bajty jako 64-bitową liczbę całkowitą bez znaku (>Q) i sformatuj tę liczbę jako 64-znakowy łańcuch dwójkowy:
import struct
def to64bit_float(x):
return f"{struct.unpack('>Q', struct.pack('>d', x))[0]:064b}"Obie dają ten sam 64-znakowy łańcuch dwójkowy dla tego samego wejścia — to64bitFloat(1) w JavaScripcie i to64bit_float(1.0) w Pythonie dają identyczne bity. Dalej będziemy używać ich wymiennie.
Zatem korzystając z którejkolwiek z nich, można zobaczyć, że liczba 1 przechowywana jest tak:
63 62 52 51 0 ← pozycja bitu
0 01111111111 0000000000000000000000000000000000000000000000000000
│ │ │
sign exponent mantissa (significand)To całkowicie odbiega od założeń powyżej. W mantysie nie mamy żadnych cyfr, a w wykładniku pojawiły się jedynki. Zobaczmy teraz, dlaczego tak jest.
Kluczowy wgląd polega na tym, że IEEE-754 nie przechowuje liczby wprost — najpierw przekształca ją w znormalizowaną postać naukową (jedna niezerowa cyfra przed przecinkiem, reszta po), a potem przechowuje te części.
A jak ustaliliśmy wcześniej, cyfra wiodąca przed przecinkiem w znormalizowanej postaci dwójkowej to zawsze 1 — więc format nie zawraca sobie głowy jej przechowywaniem.
To sztuczka ukrytego bitu: niejawna 1 jest dopisywana z powrotem przez sprzęt za każdym razem, gdy wartość jest czytana, co daje nam dodatkowy bit precyzji praktycznie za darmo.
Konkretnie dla liczby 1 postacią znormalizowaną jest 1.0 × 2^0 — po przecinku nie ma żadnych cyfr, a cyfra przed nim (niejawna 1) nie jest przechowywana.
Mantysie nie zostaje więc nic do zapisania, dlatego jest w całości z zer.
Zobaczmy teraz, skąd w wykładniku wzięły się jedynki. Wspomniałem wcześniej, że wykładnik przechowywany jest w kodzie z przesunięciem. Jeśli obliczymy przesunięcie:
widzimy, że to dokładnie to, co mamy w reprezentacji. Czyli w kodzie z przesunięciem wartość tam przechowana to w rzeczywistości 0. Jeśli nie jest jasne, jak przesunięcie daje nam 0, przeczytaj mój artykuł o kodzie z przesunięciem.
Skorzystajmy z tego, czego się wyżej dowiedzieliśmy, i spróbujmy przedstawić w postaci zmiennoprzecinkowej liczbę 3. Dwójkowo zapisywana jest jako 11. Jeśli nie pamiętasz dlaczego, sprawdź mój bardzo szczegółowy artykuł o algorytmach konwersji dziesiętno-dwójkowej. A po normalizacji liczba 3 ma taką postać (liczby dwójkowo):
Po przecinku mamy tylko jedną cyfrę 1, która będzie przechowana w mantysie. Jak wyjaśniono wyżej, pierwsza cyfra przed przecinkiem nie jest przechowywana. Normalizacja dała nam też wykładnik 1.
Obliczmy, jak jest on reprezentowany w kodzie z przesunięciem, i wtedy będziemy mieć wszystkie potrzebne informacje:
Jedna rzecz do zapamiętania o mantysie: cyfry przechowywane są dokładnie w tej kolejności, w jakiej stoją w postaci naukowej — od lewej do prawej od przecinka. Mając to na uwadze, złóżmy wszystkie liczby w reprezentację zmiennoprzecinkową:
63 62 52 51 0 ← pozycja bitu
0 10000000000 1000000000000000000000000000000000000000000000000000
│ │ │
sign exponent mantissa (significand)Jeśli uruchomisz którąkolwiek z powyższych funkcji — to64bitFloat(3) w JavaScripcie albo to64bit_float(3.0) w Pythonie — zobaczysz, że uzyskaliśmy poprawną reprezentację.
Uwaga o kolejności bitów
Być może zauważyłeś, że bity mantysy są wyrównane do lewej — dla 3 dostaliśmy 1000…000, a dla 7 (który przerabialiśmy wcześniej) dostalibyśmy 1100…000. Może to wydawać się błędne, bo te same liczby przechowane jako zwykłe 8-bitowe całkowite to 00000011 i 00000111 — jedynki po prawej. Te dwa formaty nie są odbiciem lustrzanym; po prostu przywiązują bity do różnych punktów odniesienia.
- Przechowywanie całkowitych: pozycje bitów reprezentują potęgi rosnące od prawej. Bity liczb całkowitych skupiają się po prawej, bo tam mieszkają najmniejsze wielkości ().
- Mantysa zmiennoprzecinkowa: bity stoją po przecinku w postaci znormalizowanej i reprezentują potęgi malejące od przecinka. Skrajnie lewy przechowany bit to , następny i tak dalej. Zatem mantysa dla przechowuje
11od lewej do prawej, kładąc pierwszą1na , a drugą na — wyrównanie do lewej, bo tam mieszkają największe wielkości ułamkowe.
Analogia dziesiętna pokazuje to samo: całkowita 123 jest wyrównana do prawej (3 to rząd jedności). Ułamek 0.123 jest wyrównany do lewej (1 to rząd części dziesiątych). Oba nadal traktują lewą stronę jako najbardziej znaczącą — po prostu mierzą od różnych punktów odniesienia (prawa krawędź kontra przecinek).
Dlaczego 0.1+0.2 nie równa się 0.3
Teraz, gdy wiemy, jak przechowywane są liczby, zobaczmy, co dzieje się w tym często cytowanym przykładzie. Krótkie wyjaśnienie sprowadza się do tego, które ułamki da się w ogóle dokładnie przedstawić dwójkowo.
Tylko ułamki o mianowniku będącym potęgą dwójki dają się skończenie przedstawić w postaci dwójkowej. Ponieważ mianowniki 0.1 (1/10) i 0.2 (1/5) nie są potęgami dwójki, tych liczb nie da się skończenie przedstawić w formacie dwójkowym. Aby przechować je jako liczbę zmiennoprzecinkową IEEE-754, trzeba je zaokrąglić do dostępnej liczby bitów mantysy — 10 bitów dla połowicznej precyzji, 23 bity dla pojedynczej lub 52 bity dla podwójnej. W zależności od tego, ile bitów precyzji jest dostępnych, zmiennoprzecinkowe przybliżenia 0.1 i 0.2 mogą być nieco mniejsze lub większe od odpowiadających im reprezentacji dziesiętnych, ale nigdy równe. Właśnie dlatego nigdy nie dostaniesz 0.1 + 0.2 == 0.3.
Niektórym programistom takie wyjaśnienie może wystarczyć, ale najlepszym sposobem, by zobaczyć, co dzieje się pod spodem, jest samemu wykonać wszystkie obliczenia, które robi komputer. Właśnie to teraz zrobię.
Reprezentowanie 0.1 i 0.2 w formacie zmiennoprzecinkowym
Spójrzmy na układ bitów 0.1 w postaci zmiennoprzecinkowej. Pierwszą rzeczą, którą musimy zrobić, jest przekonwertowanie 0.1 na postać dwójkową. Da się to zrobić algorytmem mnożenia przez 2. Jego mechanikę wyjaśniam w artykule o algorytmach konwersji dziesiętno-dwójkowej. Jeśli przekonwertujemy 0.1 na postać dwójkową, dostaniemy ułamek nieskończony:
0.1 · 2 = 0.2 0.0...
0.2 · 2 = 0.4 0.00...
0.4 · 2 = 0.8 0.000...
0.8 · 2 = 1.6 0.0001...
0.6 · 2 = 1.2 0.00011...
0.2 · 2 = 0.4 0.000110...Następnym krokiem jest przedstawienie tej liczby w znormalizowanej notacji naukowej:
Ponieważ mantysa może mieć tylko 52 bity, musimy zaokrąglić naszą nieskończoną liczbę do 52 bitów po przecinku.
Zgodnie z regułami zaokrąglania zdefiniowanymi przez standard IEEE-754 i wyjaśnionymi w moim artykule o zaokrąglaniu liczb dwójkowych musimy zaokrąglić liczbę w górę do:
Ostatnią rzeczą jest obliczenie reprezentacji wykładnika w kodzie z przesunięciem:
A umieszczona w reprezentacji zmiennoprzecinkowej liczba 0.1 ma następujący układ bitów:
63 62 52 51 0 ← pozycja bitu
0 01111111011 1001100110011001100110011001100110011001100110011010
│ │ │
sign exponent mantissa (significand)Zachęcam Cię do samodzielnego obliczenia zmiennoprzecinkowej reprezentacji 0.2. Powinieneś skończyć z następującymi reprezentacjami w notacji naukowej i dwójkowo:
63 62 52 51 0 ← pozycja bitu
0 01111111100 1001100110011001100110011001100110011001100110011010
│ │ │
sign exponent mantissa (significand)Obliczanie wyniku 0.1 + 0.2
Jeśli złożymy liczby z powrotem z ich reprezentacji zmiennoprzecinkowej do postaci naukowej, mamy tak:
Aby dodawać liczby, muszą mieć równe wykładniki. Reguła mówi, że musimy dostosować liczbę o mniejszym wykładniku do tej o większym. Dostosujmy więc wykładnik -4 pierwszej liczby do wykładnika -3, jak u drugiej:
Teraz możemy dodać liczby:
0.1100110011001100110011001100110011001100110011001101
+ 1.1001100110011001100110011001100110011001100110011010
─────────────────────────────────────────────────────────
10.0110011001100110011001100110011001100110011001100111Wynik obliczenia przechowywany jest w formacie zmiennoprzecinkowym, więc musimy go znormalizować, w razie potrzeby zaokrąglić i obliczyć wykładnik w kodzie z przesunięciem.
Znormalizowana liczba wypada dokładnie w środku między opcjami zaokrąglenia, więc stosujemy regułę rozstrzygania remisu i zaokrąglamy do parzystej. Daje to następującą liczbę wynikową w znormalizowanej postaci naukowej:
A przekonwertowana na format zmiennoprzecinkowy do przechowania ma następujący układ bitów:
63 62 52 51 0 ← pozycja bitu
0 01111111101 0011001100110011001100110011001100110011001100110100
│ │ │
sign exponent mantissa (significand)To dokładnie ten układ bitów, który zostaje przechowany, gdy wykonasz instrukcję 0.1+0.2.
Aby go uzyskać, komputer musi zaokrąglić trzy razy — po jednym razie dla każdej liczby i trzeci raz dla ich sumy. Gdy przechowywane jest po prostu 0.3, komputer zaokrągla tylko raz. Te operacje zaokrąglania prowadzą do różnych układów bitów przechowywanych dla 0.1+0.2 i dla samodzielnego 0.3.
Gdy język porównuje 0.1+0.2 z 0.3 — przez === w JavaScripcie, == w Pythonie — porównywane są właśnie te układy bitów, a skoro się różnią, zwracany wynik to false. Gdyby istniały takie formaty, w których nawet po zaokrągleniu układy bitów byłyby równe, porównanie dałoby true niezależnie od tego, że 0.1 i 0.2 nie są skończenie reprezentowalne dwójkowo.
Spróbuj sprawdzić bity liczby 0.3 za pomocą pokazanych wyżej funkcji — to64bitFloat(0.3) w JavaScripcie albo to64bit_float(0.3) w Pythonie. Układ będzie inny niż ten, który obliczyliśmy powyżej dla wyniku 0.1+0.2.
Aby odzyskać rzeczywistą wartość dziesiętną, którą te bity reprezentują, weź dwójkową postać naukową (niejawna 1, po niej 52 bity mantysy, pomnożone przez 2 do potęgi prawdziwego wykładnika), przesuń przecinek, wchłaniając wykładnik, i przekonwertuj powstały ułamek dwójkowy na dziesiętny. Wykonanie tej arytmetyki dla 0.1 + 0.2 daje 0.3000000000000000444089209850062616169452667236328125. Wykonanie jej dla 0.3 daje 0.299999999999999988897769753748434595763683319091796875. Są bliskie, ale nie równe — i właśnie dlatego porównanie zwraca false.
Gdzie języki się rozchodzą: opakowania ponad tym samym floatem
Sam float jest wspólny, ale języki nie zgadzają się w każdej operacji, która używa floata. Te rozejścia są decyzjami polityki na warstwie języka, nie różnicami w leżącej pod spodem arytmetyce. Kilka wartych poznania:
Dzielenie przez zero. JavaScript zwraca Infinity.
> 1 / 0
Infinity
> -1 / 0
-InfinityPython rzuca ZeroDivisionError.
>>> 1 / 0
Traceback (most recent call last):
...
ZeroDivisionError: division by zeroAby dostać Infinity w Pythonie, prosisz o to jawnie: math.inf albo float('inf'). Format zmiennoprzecinkowy ma całkiem dobre kodowanie nieskończoności (zobaczymy je niżej) — Python po prostu decyduje, żeby nie produkować jej z dosłownego 1/0.
Modulo liczb ujemnych. % w JavaScripcie idzie za znakiem dzielnej.
> -7 % 3
-1% w Pythonie idzie za znakiem dzielnika.
>>> -7 % 3
2Zaokrąglanie połówek. Math.round w JavaScripcie zaokrągla połówkę w górę (w stronę ).
> Math.round(2.5)
3
> Math.round(3.5)
4Wbudowany round w Pythonie używa „zaokrąglania bankowego” — remisy do parzystej — co odpowiada domyślnemu zachowaniu IEEE-754 i regule, której używa wewnętrznie sam FPU.
>>> round(2.5)
2
>>> round(3.5)
4Domyślne wyświetlanie dziesiętne. JavaScript pokazuje dokładnie tyle cyfr, by dało się wrócić do tego samego floata. repr() w Pythonie robi to samo. Oba ukrywają końcówkę …44089… przy 0.1 + 0.2, dopóki nie poprosisz o większą precyzję przez .toFixed(20) albo specyfikator formatu f"{x:.20f}".
Reguła praktyczna: gdy dwa języki nie zgadzają się co do liczby, podejrzewaj opakowanie, nie float. Te 64 bity pod spodem są takie same.
Granica — w obu językach
Przechowywanie liczb całkowitych we floacie natrafia na twardy limit. Mantysa mieści 52 bity plus niejawną wiodącą 1, czyli 53 bity precyzji. Zatem każda liczba całkowita do może być przechowana dokładnie. Dalej bitów mantysy nie wystarcza, by zakodować każdą całkowitą — i format musi niektóre pominąć.
JavaScript udostępnia tę granicę jako stałą:
> Number.MAX_SAFE_INTEGER
9007199254740991 // 2^53 - 1
> 2 ** 53
9007199254740992
> 2 ** 53 + 1
9007199254740992 // a nie 9007199254740993!
> 9007199254740992 === 9007199254740993
trueWyrażenie 2 ** 53 + 1 nie daje 9007199254740993. Format nie potrafi przedstawić tej wartości, więc zaokrągla do najbliższej reprezentowalnej — czyli 9007199254740992.
Warto być precyzyjnym co do tego, co MAX_SAFE_INTEGER naprawdę oznacza: to nie jest największa liczba całkowita, jaką JavaScript może pomieścić. JavaScript potrafi przechować znacznie większe wartości — Number.MAX_VALUE dociąga aż do 1.7976931348623157e+308, największego skończonego double’a. MAX_SAFE_INTEGER w rzeczywistości oznacza największą całkowitą N, dla której zarówno N, jak i N + 1 są dokładnie reprezentowalne. Dalej zaczynają pojawiać się luki: MAX_SAFE_INTEGER + 3 (czyli 9007199254740994) jest w porządku, ale MAX_SAFE_INTEGER + 2 (czyli 9007199254740993) to pierwsza całkowita, której format nie umie przedstawić — wpisz ją w konsolę i dostaniesz z powrotem 9007199254740992, po cichu zaokrąglone w dół o 1. Gdy wykładnik rośnie dalej, luki się rozszerzają, więc gdy zbliżysz się do MAX_VALUE, kolejne reprezentowalne wartości mogą być od siebie oddalone o ogromne odległości.
Jeśli naprawdę potrzebujesz dokładnej arytmetyki całkowitej za MAX_SAFE_INTEGER w JavaScripcie, to właśnie do tego jest BigInt — ma dowolną precyzję, więc granica znika całkowicie:
> 9007199254740993n
9007199254740993n // BigInt, zostaje dokładny — sufiks n ma znaczenie
> 9007199254740993n === 9007199254740992n
false // różne wartości, w odróżnieniu od Number
> 2n ** 53n + 1n
9007199254740993n // każda operacja arytmetyczna na BigInt zostaje dokładna
> 2n ** 1000n // i nie ma górnego limitu
107150860718626732094842504906000181056140481170553360744375038837035105112493612249319837881569585812759467291755314682518714528569231404359845775746985748039345677748242309854210746050623711418779541821530464749835819412673987675591655439460770629145711964776865421676604298316526243868372056680693100nTa sama granica dokładnego przechowywania całkowitych we floacie istnieje też w Pythonie, ale jest niewidoczna, dopóki nie przejdziesz z int na float.
Tak jak BigInt w JavaScripcie, int w Pythonie ma dowolną precyzję, więc arytmetyka całkowita jest dokładna:
>>> 2**53 + 1
9007199254740993 # dokładnie, bo obie strony są int
>>> 2**53 + 1 == 2**53
FalseAle w chwili, gdy rzutujesz na float, wchodzi ten sam 64-bitowy format i ta sama granica:
>>> 2.0**53 + 1.0
9007199254740992.0
>>> 2.0**53 + 1 == 2.0**53
True
>>> float(9007199254740993) == float(9007199254740992)
TrueTe same bity, ta sama granica. JavaScript udostępnia ją jako stałą najwyższego poziomu, bo każdy zwykły literał liczbowy w JavaScripcie jest floatem — BigInt doszedł później jako wyjście awaryjne, ale wymaga jawnego sufiksu n, by się na niego zdecydować — więc język oznacza granicę nazwą, żeby Cię ostrzec. Python odwraca domyślne ustawienia: goły literał całkowity jest już int-em o dowolnej precyzji, więc granica jest niewidoczna, dopóki jawnie nie rzutujesz na float. Dlatego MAX_SAFE_INTEGER nie pojawia się w bibliotece standardowej Pythona — w kodzie operującym tylko na int nigdy nie ma znaczenia.
Dlaczego dokładnie ?
Spójrz na układ bitów liczby 9007199254740991 (czyli ). To liczba całkowita, której reprezentacja dwójkowa to pięćdziesiąt trzy 1 z rzędu, znormalizowana jako
co całkowicie wypełnia mantysę jedynkami. Aby przechować następną liczbę całkowitą — — dodajemy 1, co przetacza przeniesienie przez wszystkie 52 bity mantysy (każda 1 przeskakuje na 0), zostawiając nas z
Ponowna normalizacja przesuwa przecinek o jedno miejsce w lewo i zwiększa wykładnik o jeden, dając mantysę z samych zer:
Dla następnej całkowitej, , potrzebowalibyśmy ustawionego bitu mantysy na pozycji 53 — ale mantysa ma tylko 52 bity szerokości. Nie ma miejsca. Format po cichu zaokrągla do najbliższej reprezentowalnej wartości (albo , albo , zależnie od rozstrzygania remisu; dla +1 wypada na ).
Mechaniczna przyczyna, dla której tak się dzieje: przy wykładniku 52 mantysa dokładnie obejmuje każdą liczbę całkowitą do . Aby przechować cokolwiek większego, musimy podnieść wykładnik — ale podniesienie go do 53 znaczy, że przecinek przesuwa się o 53 miejsca w prawo, podczas gdy mantysa nadal daje nam tylko 52 przechowane bity, więc 53. pozycja bitowa jest zawsze niejawnie 0. Przy wykładniku 54 format dopisuje dwa zera, przy 55 trzy, i tak dalej.
Aby zobaczyć to konkretnie, spójrz na pozycje bitowe kilku pierwszych liczb całkowitych za . Każda ma 54 bity szerokości (od bitu 53 do bitu 0), ale format ma miejsce tylko na 53 z nich — niejawną wiodącą 1 plus 52 przechowane bity mantysy.
Najmniej znaczący bit (skrajnie prawy, bit 0 — nazwijmy go LSB) nie ma miejsca, więc format fizycznie nie może postawić tam 1. Wynikają z tego dwie konsekwencje:
- W tym zakresie reprezentowalne są tylko całkowite z LSB=
0(czyli parzyste). - Sąsiedzi są od siebie o
2, więc przyrost+1zaokrągla z powrotem do tej samej wartości — potrzebujesz co najmniej+2, by wylądować na następnej reprezentowalnej całkowitej. (A+1jest dokładnie w połowie drogi między dwoma sąsiadami, więc reguła remisu do parzystej wybiera tego, którego mantysa kończy się na0.)
2^53 = 9007199254740992 = 1 0000…0000 0 ← LSB=0 (parz.) ✓ reprezentowalna
2^53 + 1 = 9007199254740993 = 1 0000…0000 1 ← LSB=1 (nieparz.) ✗ brak miejsca na LSB
2^53 + 2 = 9007199254740994 = 1 0000…0001 0 ← LSB=0 (parz.) ✓ reprezentowalna
2^53 + 3 = 9007199254740995 = 1 0000…0001 1 ← LSB=1 (nieparz.) ✗ brak miejsca na LSB
↑ └─── 52 ───┘ ↑
│ mantissa │
│ bits │
│ └── LSB nie ma slotu w mantysie
│ → zawsze 0 → całkowita zawsze parzysta
└── niejawna wiodąca 1 (nieprzechowywana)Wniosek jest zaskakujący: każda liczba całkowita większa od MAX_SAFE_INTEGER kończy się co najmniej jednym niejawnym zerem, więc żadnej nieparzystej liczby całkowitej powyżej MAX_SAFE_INTEGER nie da się w ogóle przedstawić — tylko parzyste.
Wspinaj się po wykładniku dalej i reprezentowalne będą tylko wielokrotności 4, potem wielokrotności 8, potem 16 — luka podwaja się na każdym kroku. Możesz to obejrzeć wprost w którymkolwiek REPL-u:
2^53 → 9007199254740992
2^53 + 1 == 2^53 → true (luka tutaj 2, +1 się zapada)
2^53 + 2 > 2^53 → true (+2 to następna reprezentowalna)
2^54 + 2 == 2^54 → true (luka 4, nawet +2 się zapada)
2^54 + 4 > 2^54 → true (+4 to następna)
2^55 + 4 == 2^55 → true (luka teraz 8)
2^55 + 8 > 2^55 → true (+8 to następna)Każdy krok podwaja lukę. Gdy dojdziesz do największego skończonego double’a (Number.MAX_VALUE albo sys.float_info.max, oba około ), kolejne reprezentowalne wartości będą od siebie oddalone o ogromne odległości.
Pułapka pętli for
Zachowanie z rozszerzającymi się lukami, które właśnie przeszliśmy, ma słynną praktyczną konsekwencję — pętlę, która wygląda, jakby powinna się zakończyć, ale nigdy tego nie robi. Weźmy najprostszą możliwą pętlę „zwiększaj, dopóki odwrotność nie będzie zerem”, napisaną w obu językach.
Jeśli uruchomimy ją w JavaScripcie:
for (let i = 1; 1/i > 0; i++) {
console.log("Count is: " + i);
}albo w Pythonie:
i = 1.0
while 1/i > 0:
print(f"Count is: {i}")
i += 1Pętla nigdy się nie zatrzymuje. To zachowanie jest wspólne dla obu języków, bo oba liczniki są 64-bitowymi floatami podlegającymi granicy bezpiecznych całkowitych. Warunek 1/i > 0 jest prawdziwy dla każdego skończonego dodatniego i, więc żeby pętla się zatrzymała, licznik musiałby dojść do Infinity. Ale gdy i dochodzi do , i + 1 zaokrągla się z powrotem do — luka do następnej reprezentowalnej wartości to 2, a +1 wypada w środku i zaokrągla w dół. Licznik zostaje na 9007199254740992 na zawsze, 1/i pozostaje maleńką liczbą dodatnią, a pętla kręci się w miejscu.
Możesz pomyśleć, że zwiększanie o 2 ominie ten problem — i przez jakiś czas tak jest. Z i += 2 licznik przeskakuje i dalej kroczy po reprezentowalnych parzystych (2^53, 2^53 + 2, 2^53 + 4, …). Ale gdy dojdzie do , luka podwaja się do 4, więc i + 2 zaokrągla z powrotem do i i pętla znów zostaje unieruchomiona. Przejście na i += 4 tylko odkłada pułapkę do , gdzie luka staje się 8, i tak dalej. Fundamentalny problem nie leży w +1 — chodzi o to, że przy wspinaniu się w górę kolejne liczby całkowite przestają być reprezentowalne, a każdy licznik o stałym kroku w końcu wpadnie w lukę większą niż jego krok.
Sedno: ta pułapka jest własnością Number / float, nie języka. Może na nią trafić każdy język, który używa double’a IEEE-754 jako typu licznika.
NaN i Infinity
Format rezerwuje dwie wartości wykładnika na specjalne kodowania:
- Wykładnik z samych zer jest zarezerwowany dla
±0(gdy mantysa też jest zerowa) i dla liczb zdenormalizowanych (gdy mantysa jest niezerowa — wartości zbyt małe, by przedstawić je w postaci znormalizowanej). - Wykładnik z samych jedynek jest zarezerwowany dla
±Infinity(mantysa zerowa) i NaN (mantysa niezerowa).
Dlatego typeof NaN w JavaScripcie to 'number', a type(float('nan')) w Pythonie to <class 'float'> — NaN jest całkowicie poprawnym układem bitów formatu zmiennoprzecinkowego, tylko z wykładnikiem z samych jedynek.
Układ bitów dodatniej nieskończoności to
0 11111111111 0000000000000000000000000000000000000000000000000000A typowy NaN wygląda tak
0 11111111111 1000000000000000000000000000000000000000000000000000Widzimy, że każda niezerowa mantysa w parze z wykładnikiem z samych jedynek koduje NaN — co znaczy, że odrębnych układów bitów NaN jest całkiem dużo, a nie tylko jeden.
Reguła IEEE-754 mówi, że NaN jest różny od wszystkiego, włącznie z samym sobą. Oba języki tego przestrzegają. Oto JavaScript:
> NaN === NaN
false
> NaN > 0
false
> NaN < 0
false
> Number.isNaN(NaN)
trueI to samo zachowanie w Pythonie:
>>> import math
>>> float('nan') == float('nan')
False
>>> float('nan') > 0
False
>>> float('nan') < 0
False
>>> math.isnan(float('nan'))
TrueOba języki dostarczają dedykowane isNaN / math.isnan, bo — zgodnie z regułą IEEE-754 — x == x to zły sposób wykrywania NaN: zwraca false dla NaN i true dla wszystkiego innego, ale jest to raczej sztuczka na odwrotnej logice niż czyste API. (Właściwie x !== x jest częstą idiomą w starszym kodzie JS, właśnie dlatego, że jednoznacznie identyfikuje NaN.)
Infinity podlega zwykłym regułom porównywania — jest większa od każdej liczby skończonej, mniejsza od siebie samej tylko w sensie równości — i można mieszać ją w arytmetyce bez zaskoczeń (Infinity + 1 to Infinity, 1 / Infinity to 0, Infinity - Infinity to NaN).
> Infinity > 1e308
true
> Infinity + 1 === Infinity
true
> Infinity - Infinity
NaNI to samo zachowanie w Pythonie:
>>> math.inf > 1e308
True
>>> math.inf + 1 == math.inf
True
>>> math.inf - math.inf
nanCo warto zabrać ze sobą
- Niemal każdy nowoczesny język używa tych samych 64 bitów dla liczb ułamkowych.
NumberJavaScriptu,floatPythona,doubleJavy,doubleC — wszystko to IEEE-754 binary64. Ten sam układ, ta sama arytmetyka, te same dziwactwa. Różnice między językami żyją w opakowaniu, nie we floacie. 0.1 + 0.2 != 0.3jest własnością formatu. Dzieje się tak, bo0.1i0.2nie są skończone dwójkowo i kumulują trzy błędy zaokrąglenia podczas dodawania; przechowanie0.3wprost kumuluje tylko jeden. Układy bitów się różnią.- Granica „bezpiecznej liczby całkowitej” leży na . Za nią nie wszystkie wartości całkowite dają się przedstawić, a sąsiednie całkowite zapadają się na tego samego floata. JavaScript udostępnia ją jako
Number.MAX_SAFE_INTEGER, bo nie ma innego typu całkowitego; użytkownicy Pythona widzą ją tylko po rzutowaniu nafloat. - NaN i Infinity to prawdziwe układy bitów, nie wyjątki. Żyją wewnątrz formatu, a zarezerwowany dla nich jest wykładnik z samych jedynek. Reguła „NaN jest różny od wszystkiego, włącznie z samym sobą” pochodzi z IEEE-754, nie jest wyborem języka.
Znając te cztery rzeczy, potrafisz przewidzieć, co zrobi float w każdym języku, który go używa — czyli, w praktyce, we wszystkich.
Głębsze zanurzenie w to, jak cztery podstawowe operacje (dodawanie, odejmowanie, mnożenie, dzielenie) faktycznie przekształcają bity — zarówno dla liczb całkowitych w uzupełnieniu do dwóch, jak i dla liczb IEEE-754 — znajdziesz w artykule Jak działa arytmetyka dwójkowa: liczby całkowite w uzupełnieniu do dwóch i liczby IEEE-754.