IEEE 754, avagy mit ért a JavaScript a Number alatt?
A legtöbben egy típusos nyelv segítségével ismerkedtünk meg a programozással, tanultunk a számítógépes számábrázolásról, és a „fejünkbe verték”, hogy óriási különbség van az egész és a lebegőpontos számok között. De a JavaScript csak egyféle számot ismer… Hogyan tekintsünk rá? Pontosan mit ért alatta? Mi a leggyakoribb számokkal kapcsolatos hiba, amit a programozók elkövetnek? Ezekre a kérdésekre keresem a választ a teljesség igényével.
Az alábbi dolgozat Farkas Máté az októberi budapest.js találkozón elhangzott előadásának sajtó alá rendezett változata. Az előadásfóliák letölthetők.
Ma már nem kell ahhoz számítástechnika szakosnak lenni, hogy középiskolában az ifjú titánt megismertessék a számámbrázolás rejtelmeivel. Matematika órán tanultunk számrendszerekről, majd néhány évvel később számtech órán vettük a bináris számrendszert, és a nem negatív egészeket oda-vissza kellett alakítgatni. Majd elmondták, hogy a számítógép ismeri a negatív számokat is, melyeket az úgynevezett kettes komplemens képzéssel tudunk előállítani: bitenkénti negált, majd hozzáadunk egyet. Így a pozítív számoknál megismert összeadással (egy bit túlcsordulással) -x + x == 0
lett. Nagyon kényelmes, nagyon szuper.
Farkas Máté előadás közben (fotó: Török Gábor)
Aztán jöttek a lebegőpontos számok, melyekről megtanultuk, hogy a számítógép s × m × 2^e
, úgynevezett normál alakban tárolja őket. Mindez nem volt újdonság, hiszen a normálalakról már matematika órán is hallottunk: num = s × m × 10^e
, ahol s az előjel (±1), m a mantissza, ami vagy 0, vagy egy 1 ≤ m < 10
valós szám, és e a kitevő. Szuper, ez ismerősen cseng, mindent értünk. (Nektek is feltűnt anno, hogy nincsenek negatív valós számok?) Megtanultuk azt is, hogy az a baj a lebegőpontos számokkal, hogy a mantissza csak véges helyiértéket tud tárolni, ezért a kettes számrendszerben végtelen tört számokat (mint az 1/3 a tízes számrendszerben) csak közelíteni tudja, ezért az egyik legelső dolog volt, amit álmunkból felrázva is tudnunk kellett:
Lebegőpontos számokat soha nem vizsgálunk egyenlőséggel!
Hoznék is rá két példát, éppen a JavaScriptből:
0.1 + 0.2 == 0.30000000000000004 != 0.3
0.6 + 0.7 == 1.2999999999999998 != 1.3
A bátrabbak/figyelmetlenebbek ennek ellenére mégis megtették néha, és egész értéket tartalmazó lebegőpontos számokra mintha mégis működött volna… Ez viszont feladta a leckét a filozofikusabb beállítottságú csoporttársaknak, hiszen például a + 1,2 × 10^1
(=12) és a + 1,2 × 10^0
(1,2) csak a kitevőben tér el, a mantissza pontosan ugyanaz. Miért van, hogy az egyiket a számítógép pontosan, a másikat csak közelítőleg tudja tárolni?!
Felismerve az anomáliát említve lettek a fixpontos racionális számok, valamint a BCD kódolás, ahol a tízes számrendszer számjegyei vannak kódolva bináris formában (1 tizedes jegy 4 bit, de lehet tömöríteni is), és elmondták, hogy ezt használják például a bank rendszerekben… Tegye fel a kezét, aki találkozott vele élőben.
No, de térjünk át egy másik problémára: a 0 körül van egy nagy lyuk, amit nem tudunk lefedni a számokkal – legyen e a lehető legkisebb kitevő, azaz nézzük a két legkisebb pozitív számot és a 0-t:
1,00…001 × 10^e
1,00…000 × 10^e
0,00…000 × 10^e
Az első két esetben a sokadik tizedesjegyen van eltérés, míg a 0 körül több nagyságrenddel… Nem jó ez így.
Száz szónak is egy a vége, megtanultuk és idővel rutinná vált, hogy minden esetben a feladatnak megfelelően válasszuk ki a felhasználni kívánt szám típusát és méretét. Egyeseket az élet viharai sodortak el, mások lassú folyón csorogtak le a gyengén típusos script nyelvek tengerének szabadsággal teli világába. Szerencsére a jól megszokott rutinon nem sokat kellett változtatni: nem kellett megmondani előre, hogy egy változó számot vagy stringet fog tartalmazni, és ugyanígy megmaradt a többféle szám tárolási lehetőség implicit vagy explicit konverzióval.
Például a PHP-ban platform függő méretű (32/64 bit) egészeket használhatunk, amik szükség szerint automatikusan lebegőpontos számmá alakulnak. A Perl három különböző módon tudja tárolni a számokat: natív egész, natív lebegőpontos és string (!) alakban. Ruby esetén szintén három lehetőségünk van: Float, BigNum, FixNum, a különféle matematikai műveletek eredménye itt is a felhasznált változók típusától függ. Pythonban négyféle típus van: int, long, float, complex. (Hasonlóan a Ruby BigNumjához, a long itt tetszőleges méretű egész számot tárolhat – természetesen jóval lassabban tud vele dolgozni, mint a natív egészekkel, de legalább nem fog túlcsordulás miatt elhasalni a programunk.)
Azonban a JavaScript csak egyféle számot ismer!
Ez pedig majdnem pontosan megegyezik az IEEE 754 által specifikált formátummal, ami alapvetően egy 64 bites, lebegőpontos szám, amellyel annak rendje s módja szerint 2^64 - 2^53 + 3
különböző számot tudunk reprezentálni. Kezdeném a +3-mal: van három darab kitűntetett elemünk: az Infinity
(∞, plussz végtelen), a -Infinity
(-∞, mínusz végtelen), valamint a NaN
(Not a Number), ami egy szám, annak ellenére, hogy nem az, ráadásul semmivel (így önmagával) sem egyenlő. (Itt van egy kis eltérés az IEEE 754-től, ugyanis ott sokféle NaN-t megengednek, azonban a JavaScriptben csak egy ilyen van, ez az oka, hogy nem tudunk 2^64 különböző értéket tárolni.)
A többi szám véges, ezeknek a fele pozitív, a fele negatív. Helyesen fogalmaztam, ugyanis van +0 és -0 is, és annak ellenére, hogy pontosan megegyeznek (+0 === -0
), meg lehet különböztetni őket. A véges számok túlnyomó többsége (2^64-2^54 darab) normál alakban van tárolva: s × m × 2^e
, ahol s = ±1
, 2^52 ≤ m < 2^53
egész, -1074 ≤ e ≤ 971
egész, a maradék (2^53-2 darab) szám pedig denormalizált alakban van: s = ±1
, m < 2^52
egész, e = -1074
.
Megoldódott a rejtély: a mantissza egy jó nagy egész szám (és a kitevő mondja meg, hogy a végén levő sok 0 közül hány darabbal ne foglalkozzunk), félrevezettek minket, amikor a normál alakot a matematika órán tanultakra építve mondták el. A JavaScriptben valódi egészként viselkedik minden -2^53 < i < 2^53
egész szám: amennyiben nem végzünk olyan műveleteket, amik kivezetnek az egészek köréből, bátran vizsgálhatjuk őket egyenlőséggel. Ne tegyük. (A teljességhez hozzá tartozik, hogy a bit műveletek 32 bites egészekre vonatkoznak, a „felesleges” részek ilyenkor figyelmen kívül lesznek hagyva.)
Az IEEE 754 egyéb szabályokat is rögzít, mint például kivételkezelés és kerekítés. Kivételkezelésre példa a 0-val való osztás, vagy hogy 0 * ∞ → NaN
(ahogy a matematika tanulmányaink alapján el is vártuk volna). Kerekítési szabályokra a fentebb említettek miatt van szükség (például mert egy szép tizedes tört végtelen bináris tört lehet), de vannak humorosabb példák is:
9999999999999999 === 10000000000000000;
Infinity === 999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999;
(A leggyakrabban bejelentett JavaScript „bug” épp a kezdők számára fura szám kezeléssel kapcsolatos. Az ECMAScript 5-ös szabványba megpróbálták beépíteni az IEEE 754r-et, de végül nem sikerült. Ajánlom elolvasni jwz bejegyzésére érkező kommenteket, illetve Brendan Eich is kifejtette a véleményét.)
Oké, hogy ezeket nagyon jól tudjuk, eszerint – konyhanyelven fogalmazva – azért a JavaScriptben is vannak egészek és lebegőpontos számok. De mi az, amire nagyon tudatosan oda kell figyelnünk, ha számokkal van dolgunk?
Számokat sohasem vizsgálunk kizárólag egyenlőséggel!
Túl konzervatív, túl szögletes vagyok? Az a baj, hogy bármikor előfordulhat olyan eset, amire a programozó nem számít. Ciklus vagy bármi más léptetéssel járó művelet esetén igenis vizsgáljunk egyenlőtlenséget, bármennyire logikátlannak tűnik elsőre. Két példát hoznék: volt egy DOS-os autós lövöldözős játék, meg kellett nyerni a versenyt, de ha valaki leelőzött, nem feltétlenül kellett gyorsabbnak lenned. Lőszerből korlátozott mennyiségű lehetett az autóban, azonban az elmentett játékállásban a megfelelő helyen 0xFFFF-re cserélve az adatot -1 darab lőszer lett az eredmény, amit aztán az ember kénye-kedvére használhatott, sohasem fogyott el (jutott el a 0-ig). Tény és való, ez nagyon durva beavatkozás egy program lelkivilágába, de a másik eset humánusabb: itt egy anyahajóval kellett akciózni, amiről ki lehetett küldeni kétéltűeket vagy repülőket. Az anyahajónak megvolt a kapacitása, hogy például egy rakétából max 15-öt tud raktározni. Nem kellett mást tenni, mint néhányat bepakolni egy repülőbe és kiküldeni, majd feltölteni a készleteket a szigetről (15-re, többre nem lehetett), visszahívni a repülőt, amiről automatikusan visszakerültek a fel nem használt rakéták a raktárba. 16-17 raktáron levő készletről pedig már nyugodtan rendelhettem, amennyi jól esett, sosem érte el a maximumot…
Meglátásom szerint egy esetben teljesen igazolt a csak egyenlőség-es vizsgálat: amikor a számokat állapotok leírására használjuk. (Más kérdés, ha 0-10 darab lőszer lehet, ezeket is tekinthetem állapotoknak…) Meg kell vizsgálni az ilyen eseteket, de alapvetően nem szabad vakon megbíznunk a saját kódunkban sem. Szerintem.
■
Jó cikk!
Én animációk kapcsán futottam bele ebbe a problémába. Mondjuk ha ki akarod számolni
x=vx*t
alapján, hogy milyen pozícióban van egy mozgó elem, akkor komoly meglepetések érhetnek, ha a sebesség nem egész szám.