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.

4 komentáře:

  1. "Výjimkou může být snad jen sleep." - sleep má side-effect, mění stav - času.

    OdpovědětVymazat
  2. Omlouvám se za opožděnou reakci, trochu mi to zapadlo.

    Dostáváme se do filozofické debaty. Side effects a referenční transparentnost je v praxi nutné brát s vhodnou abstrakcí (např. ignorovat čas, ignorovat dumpy z debuggeru, ignorovat teplo vygenerované procesorem, mít neomezenou paměť), jinak nám vyjde, že všude máme side effects nebo referenční netransparentnost…

    Sleep čas nemění, pouze trvá nějakou dobu. Ale každá funkce reálně trvá nějakou dobu, byť možná není tak přesně vymezená. Pokud bychom to považovali za side effect, bude mít každá funkce side effect.

    Sleep patří do kategorie funkcí, které čekají na nějaký stav. Nemá side effect (nedovedu odjinud zjistit, jestli byla volána). Pravda, trošku se mi rozšiřuje spektrum funkcí, kde má smysl nemít side effect ani návratovou hodnotu (mohu čekat i pode něčeho jiného než času). Článek bych mohl trošku upřesnit, ale jádro sdělení to měnit nebude.

    OdpovědětVymazat
    Odpovědi
    1. Referenční transparentnost lze dobře pochopit na úvaze, zda jde výpočet nahradit výsledkem. Pokud toto nejde, jedná se o side-effect.
      S tou abstrakcí máš samozřejmě pravdu. Ale přesto, zatímco dumpy v debuggeru nejsou součástí logiky, čas u sleep je součástí logiky.
      Tož tak,

      Vymazat
    2. Referenční transparentnost znamená, že referenci na výpočet mohu transparentně nahradit novým výpočtem. Odtud je IMHO ten název. Takže souhlas s první větou.

      Ovšem referenčně transparentní ≠ bez side effectů. Referenční transparentnost může rozbít i jakékoliv čtení
      mutable stavu. Máme tedy případy, kdy funkce bez side effectů není referenčně transparentní, třeba získání aktuálního času nebo velikosti souboru.

      Funkce sleep je poněkud speciální. Z aktuálního vlákna její výsledek by šel brát jako side effect z určité úrovně abstrakce (a v případě třeba sčítání ten side effect zanedbáme). Má ale speciální vlastnost – není přímo pozorovatelný z jiného vlákna ani procesu. (Ale tady by šlo zase debatovat o side effectech nad lokálníma proměnnýma…)

      Vymazat