34. Když nula není jen nula: dělení podle IEEE 754

Ilustrační obrázek k článku: Když nula není jen nula: dělení podle IEEE 754
Dělení nulou nemusí být chybou – v IEEE 754 je to normální operace. Proč si PHP 8 vybral jinou cestu a jak v CombiScriptu obnovíme matematickou konzistenci mezi PHP a JavaScriptem.

V jednom z minulých článků jsme řešili dilema: má funkce nm_ASin(2) vyhodit výjimku – protože sinus nikdy nedosahuje hodnoty 2 – nebo tiše vrátit NaN? Na první pohled jde o detail implementace. Ve skutečnosti však otevírá zásadní otázku, která přesahuje konkrétní funkci i jazyk. A to:

Přístup k aritmetickým chybám

V teorii programovacích jazyků existují dva protipóly v přístupu k aritmetickým chybám. Nejviditelnějším a zároveň nejproblematičtějším příkladem těchto chyb je dělení nulou. Nejde přitom o akademickou drobnost, ale o velmi praktickou otázku. Co má program udělat v případě zadání chybného výpočtu?

Oba přístupy mají svůj smysl. Jeden programátora chrání, druhý mu věří. Jeden je rychlý, druhý pečlivý. Někdy chceme, aby případná neošetřená chyba "zapadla", třeba když se při chybě nevykreslí jeden z tísíce polygonů, tak se to přehlédne. Jindy chceme při chybě program okamžitě zastavit, fakt nechceme, aby se frézka CNC stroje při jízdě do nekonečna urazila o dorazy.

JavaScript a PHP

JavaScript je v tomto ohledu dlouhodobě čitelný a konzistentní. Dodržuje "volný" IEEE 754 standart bez výjimek. Dělení nulou nikdy nevyhodí výjimku, vždy vrátí matematicky odpovídající hodnotu a pokračuje dál. Math.asin(2) je NaN, -5 / 0 je -Infinity.

PHP si prošlo několika obdobími. Na počátku bylo období temna - kdy ještě ve verzi PHP 5 vracelo jako výsledek dělení nulou false. Vrátit false jako výsledek matematické operace (např. 1/0) je z pohledu typové stability katastrofa. Navíc false se při dalším výpočtu často převedlo na 0, což chybu jen dál maskovalo a vršilo. Autoři se to snažili alespoň ošetřit chytrým vylepšením - systémem warningů.

Zlaté období přišlo s verzí PHP 7. Matematika se začala chovat dle IEEE 754 (dělení vracelo vždy číslo ), zdálo se že i systém warningů bude dodělán, uživatelé si ho zažili a opravdu na vývoji zapínali a na produkci vypínali a kdo věděl co dělá mohl použít operátor @. Byl to promyšlený kompromis mezi volným chováním a praktičností.

Tento model ale skončil s PHP 8. Dnes dělení nulou vyhodí výjimku DivisionByZeroError a okamžitě přeruší tok programu. Podle mého názoru je to koncepční krok špatným směrem. Výsledkem je nesystémovost, kdy například asin(2) proběhne naprosto tiše dokonce i bez notice (systém warningů dodělán nebyl), ale 5 / 0 se snaží program ukončit. Matematicky mi to nedává smysl. Architektonicky už vůbec ne.

Cíl CombiScriptu: stejné chování, stejný výsledek

Ovšem otázka je jaký typ chování zvolit? Všechny funkce budu mít pod kontrolou, mohu se rozhodnout buď pro striktní nebo pro volné chování či to dokonce řídit nějakým globálním přepínačem (vzpomeňme třeba na fortranské CALL IEEE_SET_HALTING_MODE.

Po delším uvažovaní, kdy beru v úvahu složitost kṕdu, rychlost výsledných funkcí, přehlednost pro uživatele a mnohé další faktory se pro nm_Moduly rozhoduji:

Přijímám IEEE 754

Tedy programy v CombiScriptu používající nm_Funkce budou počítat stejně jako programy v JavaScriptu.

Zároveň si ale nezavírám cestu a neříkám, že někdy nevytvořím třeba nějakou numerickou striktní knihovnu (ns_), která ovšem bude vyhazovat výjimku při jakémkoliv chybném vstupu. Rozhodně to nemíním dělat nějak "částečně".

nm_Div

Takže logicky zavádím vlastní funkci nm_Div. Neznamená to, že bychom museli přepisovat každý výraz typu $x / 5. Jakmile, ale dělíme proměnnou v které by 0 mohla být, měli bychom místo operátoru / použít právě funkci nm_Div().

Na první pohled se může zdát, že naprogramování je triviální. Stačí přece otestovat, zda je dělitel nula, a v tom případě vrátit nekonečno. Jenže IEEE 754 skrývá detail, který nám to dost zkomplikuje: Existuje kladná i záporná nula.

A znaménko nuly má přímý vliv na znaménko výsledného nekonečna. A protože PHP výjimka nám výsledek "sežrala" musíme tyto dvě různé nuly detekovat.

Detekce Signed Zero: Problém neviditelného bitu

Takže klíčem k implementaci korektního dělení je rozlišení mezi kladnou nulou (+0.0) a zápornou nulou (−0.0). Pro testovací účely tyto hodnoty (snad ve všech verzích od PHP 5 můžeme získat třeba jako 1/1E400)

V PHP nám nepomůže žádné běžné porovnání. Operátory <, >, == ani === mezi +0.0 a -0.0 nerozlišují. Potřebujeme tedy metodu, která ignoruje sémantiku operátoru a kladnou a zápornou nulu odliší jinak. Existuje vůbec nějaká taková metoda? Zapřemýšlejte, vzpomenete si na nějakou?

  1. <?php
  2. $Pl = 1/1E200/1E200; //Kladné podtečení t.j. +0.0
  3. $Mi = -1/1E200/1E200; //Záporné podtečení t.j. -0.0
  4. echo ($Pl===$Mi) ? "TRUE" : "FALSE"; echo '<BR>';

V PHP znám několik způsobů, jak rozlišit +0.0 od −0.0: var_dump() přetypování na řetězec (string), serializaci přes json_encode a přímý pohled do bitové reprezentace double pomocí pack().

  1. <?php
  2. $Pl = 1/1E200/1E200; //Kladné podtečení t.j. +0.0
  3. $Mi = -1/1E200/1E200; //Záporné podtečení t.j. -0.0
  4. echo 'var_dump(+0.0) => '; var_dump($Pl); echo '<BR>';
  5. echo 'var_dump(-0.0) => '; var_dump($Mi); echo '<BR>';
  6. echo '(string) +0.0 => ' . (string) $Pl .'<BR>';
  7. echo '(string) -0.0 => ' . (string) $Mi .'<BR>';
  8. echo 'json_encode(+0.0) => ' . json_encode($Pl) .'<BR>';
  9. echo 'json_encode(-0.0) => ' . json_encode($Mi) .'<BR>';

Var_dump by znamenalo odchytávat výstup, u přetypování ani json_encode si nejsem jistý chováním v budoucích verzích. Proto volím pack(). To prostě dává přímý přístup k bitu, který ve standardu IEEE 754 definuje znaménko.

Dynamická past na bity

Přiznejme si to upřímně: málokdo z nás nosí v hlavě přesnou bitovou mapu 64bitového double floatu. A i kdybychom věděli, že znaménko je teoreticky 63. bit, v praxi nás čeká peklo v podobě endianity (pořadí bajtů v paměti). Na procesoru Intel bude ten bit jinde než na ARMu.

Místo listování v manuálech procesorů jsem na to šel selským rozumem. Vím, že +1.0 a -1.0 se v IEEE 754 liší právě a jen v jednom znaménkovém bitu. Jejich binární XORovou operací získáme masku, která nám přesně označí pozici znaménka bez ohledu na hardware. Tuto masku použijeme (binární AND) na dodané číslo a získáme binárně buď samé nuly, anebo někde jedničku. Porovnáme se samými nulami stejné délky a máme výsledek.

A když už takovou funkci budeme psát tak ji napíše Combiscriptově, tj. pro oba jazyky. JS také nedovede pomocí operátorů rozlišit +0.0 od -0.0, ale můžeme si pomoci právě dělením, kdy získáme +/- Infinity a to už snadno porovnáme.

Tak vznikne funkce nm_IsIEEE754Positive:

  1. function nm_IsIEEE754Positive($num)
  2. {
  3. if (!nm_IsNumber($num)) throw new cs_Error('nm_IsIEEE754Positive-$num-NotNumber');
  4. if ($num > 0) return true;
  5. if ($num < 0) return false;
  6. 0&&0<!--$_;/*
  7. return (1 / $num === Infinity);
  8. /*/
  9. if (is_int($num)) $num = $num * 1.0;
  10. static $Sign = null, $Zeros = null;
  11. if ($Sign === null)
  12. {
  13. $Plus = pack('d', +1.0);
  14. $Minus = pack('d', -1.0);
  15. $Sign = $Plus ^ $Minus;
  16. $Zeros = str_repeat("\x00", strlen($Sign));
  17. }
  18. $BinTest = pack('d', $num);
  19. $BinRes = $Sign & $BinTest;
  20. return $Zeros === $BinRes;
  21. //*/
  22. }

Masku $Sign i binární nuly $Zeros jsem si pro zrychlení nacachoval do statické proměnné.

Funkci, lze ještě výrazně zjednodušit, přijdete na to jak?

Neprůstřelné dělení: nm_Div

S funkčním detektorem polarity implementuji nm_Div. Tato funkce musí být imunní vůči PHP výjimkám a musí striktně dodržovat IEEE 754 pravidla:
  1. function nm_Div($num1, $num2)
  2. {
  3. if (!nm_IsNumber($num1)) throw new cs_Error(...);
  4. if (!nm_IsNumber($num2)) throw new cs_Error(...);
  5. 0&&0<!--$_;/*
  6. return $num1 / $num2;
  7. /*/
  8. if (is_nan($num1) || is_nan($num2)) return NAN;
  9. if ($num1 == 0 && $num2 == 0) return NAN;
  10. if ($num2 != 0) return $num1 / $num2;
  11. return (nm_IsIEEE754Positive($num1) XOR nm_IsIEEE754Positive($num2)) ? -INF : INF;
  12. //*/
  13. }

Závěr: Nebojte se ohýbat realitu

Tento souboj s dělením nulou a znaménky je jen špičkou ledovce. Často slýcháme, že se musíme smířit s tím, jak je jazyk navržen. S tím jak se chovají jeho matematické operace, pole či objekty.

Není to pravda.

Tvůrci jazyků nám dali základní rámec, ale to neznamená, že jsme jeho vězni. Programování není o pasivním přijímání limitů, ale o vytváření vlastních pravidel tam, kde ta původní nedávají smysl. Někdy v příštích dílech si ukážeme, třeba jak lze podobně "ohnout" pole přes Proxy v JS a ArrayAccess v PHP, abychom dosáhli naprosto shodné funkčnosti.
Předchozí