sobota 4. října 2014

Jak zpracovávat chyby?

V programu může běžně nastat nějaká chyba, kterou bychom měli zpracovat. Výjimky nejsou jediná možnost, jak to řešit. Co víc, výjimky nemusejí být vždy tou nejlepší možností.

Dost totiž záleží na stylu programování. Nicméně dnes se styly programování často mísí, takže to není tak jednoznačné. Většinou dnes uplatníme od každého přístupu něco.

Imperativní přístup

V imperativním kódu budou výjimky nejspíše správná cesta. Pokud volám funkci (proceduru), která má něco provést, ale nevrací žádný výsledek nebo mě její výsledek nemusí zajímat, je dost velké riziko, že zapomenu zkontrolovat výsledek. Dopady mohou být někdy fatální. Pokud se nepodaří změnit adresář, mohu vymazat třeba úplně jiná data. Pokud se nepodaří zkopírovat data, může dojít k jejich ztrátě. Pokud se nepodaří volání setuid, program může běžet dál s vyšším oprávněním, jako to bylo v případě rageagainstthecage. V takovýchto případech je lepší program nechat spadnout než dělat, že se nic špatného nestalo.

Bylo by fajn, kdyby se programátor již při kompilaci dozvěděl, že něco zapomněl ošetřit. V Javě jsou k tomuto účelu checked exceptions. Používají se v situacích, kdy si programátor nemůže být jist, že operace proběhne bez chyby. Typicky jde o I/O. Naopak třeba u dělení by bylo otravné pokaždé muset kontrolovat, jestli nedošlo k ArithmeticException, ale zase programátor má šanci různými způsoby zajistit, aby nedělil nulou. Uznávám, že okolo checked exceptions je jistá kontroverze, a že nejspíš kvůli tomu je nemá moc jazyků. Nalezení hranice mezi checked a unchecked mi kupodivu v praxi většinou (ne vždy) nepřišlo jako až takový problém, ale třeba podpora v lambda funkcích je docela peklo. Dobře se to projevuje v Javě 8. Zkuste schválně upravit kód urlStringList.map((url) -> new java.net.URL(url)) do funkční podoby.

Funkcionální přístup

Mám dvě zprávy, jednu špatnou a druhou dobrou.

Špatná zpráva je, že v čistě funkcionálních jazycích není chytání výjimek zrovna běžná záležitost. Například v Haskellu se snad nedají výjimky chytat mimo I/O monády. Důvodů pro to může být více, třeba určité narušení čistoty vzhledem k línému vyhodnocování. Je tedy celkem OK vyhodit výjimku třeba u dělení nulou, což mohl programátor snadno ošetřit různými způsoby. Na druhou stranu je méně vhodné házet výjimku třeba u neexistujícího klíče mapy.

Dobrá zpráva je, že funkcionální jazyky přicházejí s něčím v jistých ohledech lepším, co by mohlo nahradit checked exceptions. Pokud výraz nemění stav, určitě nás bude zajímat jeho návratová hodnota. Jinak je zbytečný. (Výjimkou může být snad jen sleep.) V návratové hodnotě bude tedy buď výsledek, nebo chyba. Když chce programátor číst hodnotu, musí zároveň ošetřit i chybu. Podstatné je, že by nemělo jít o uspořádanou dvojici (errorCode, value), protože tady je velmi snadné přečíst pouze value, i pokud došlo k chybě. Spíše by mělo jít o typ Either[ErrorType, ReturnValueType]. V případě úspěchu se vrátí Right(value), v případě chyby se vrátí Left(errorDescription).

Možná to vypadá strašně komplikovaně, ale není. Funkcionální jazyky mívají pattern matching, který to usnadní. Ukážu příklad. Dejme tomu, že budeme mít celočíselné dělení safeDivision, které skončí chybou nejen v případě dělení nulou, ale i v případě nepřesného výsledku. Tedy safeDivision(9, 3) vrátí Right(3), ale safeDivision(9, 2) vrátí Left(InaccurateResult) a safeDivision(9, 0) vrátí Left(DivisionByZero). Budeme psát funkci, která má prezentovat výsledek uživateli. Její tělo může vypadat třeba takto:

safeDivision(numerator, denominator) match {
 case Right(result) => s"$numerator/$denominator = $result"
 case Left(error) => "Can't divide"
}

Nebo můžeme vypsat i konkrétní chybu:

safeDivision(numerator, denominator) match {
 case Right(result) => s"$numerator/$denominator = $result"
 case Left(InaccurateResult) => "Can't divide accurately"
 case Left(DivisionByZero) => "Can't divide by zero"
}

Daly by se vymýšlet i složitější příklady, kdy bychom napsali nějaký výraz pro prvek JSONu (například json.a.b.c.d.as[String]) a na konci bychom zjistili buď hodnotu, nebo srozumitelnou chybovou hlášku (např. "a.b.c je null"). Toto by se přes výjimky dělalo obtížně.

Nabízí se otázka, kdy ve funkcionálním programování použít výjimky a kdy návratové hodnoty. Výhoda výjimek je, že nezaplevelují kód, pokud ta chyba nemůže nastat, například u foo/(1+x*x) nenastane dělení nulou (pokud je vyřešeno číselné přetečení). Jejich nevýhoda je, že se na jejich zpracování snadno zapomene a že se hůře zpracovávají. Někdy se osvědčilo nabídnout dvě funkce, kdy jedna je optimistická (předpokládá bezchybný průběh, jinak hodí výjimku) a druhá pesimistická (předpokládá, že může nastat chyba, a vrátí Either nebo něco podobného). To může být užitečné třeba u mapy (slovníku), kdy záleží na použití, co se více hodí.

Který použít?

Rozmýšlíte se, jestli použít funkcionální přístup, nebo imperativní? Nenechte se zmást jazykem. Máme imperativní jazyky s funkcionálními prvky (Ruby, Java, PHP), máme čistě funkcionální jazyky s I/O monádami (Haskell) a máme nečisté funkcionální jazyky (Scala, LISP). Hranice jsou někdy diskutabilní, záleží dost na kultuře. Co tedy s tím?

Pokud by chyba v dobře napsaném programu neměla nastat, pak budou nejspíš nejlepší výjimky. Nutit programátora ošetřovat chybu, která nemůže nastat, těžko povede k něčemu dobrému. V lepším případě ji sám konvertuje na výjimku, v horším případě ji nějak bude ignorovat.

Funkcionální přístup se dobře hodí u výrazů, které nemají žádný side effect. Tam těžko zapomenu na kontrolu návratové hodnoty. Zbývá pouze otázka, zda zvolený jazyk nabízí vhodné prostředky pro tento přístup.

Diskutabilní bude použít funkcionální přístup, pokud sice mám side effect, ale vracím nějakou zajímavou návratovou hodnotu.

Pokud je ale volání čistě o tom, abych udělal nějaký side effect (změna adresáře, setuid, ...), potom je dost riskantní se spoléhat na ověření návratové hodnoty. Jsme čistě imperativní, výjimka je tedy skoro jasná volba, pokud to jazyk umožňuje. Diskutovat lze možná o tom, jestli má jít o checked exception, nebo unchecked exception.