Zobrazují se příspěvky se štítkemfunkcionální programování. Zobrazit všechny příspěvky
Zobrazují se příspěvky se štítkemfunkcionální programování. Zobrazit všechny příspěvky

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.

pátek 4. ledna 2013

Jaké je vlastně funkcionální programování? (debata k ĚĽŠČŘ)

Jiří Knesl napsal plugin ĚĽŠČŘ, Jakub Vrána mu jej zkritizoval a Jiří Knesl zareagoval na Jakubovu kritiku tím, že mu šlo hlavně o vyzkoušení funkcionálního programování. Jakub Vrána se ale podivuje, co je na tom tak elegantního. I já se tedy do diskuze zapojím, byť se zpožděním. Napřed ale trošku odbočím.

Způsoby vyhodnocování

Nechci tu mít moc teorie, tak jen ve stručnosti:

Striktní vyhodnocování

Toto určitě všichni znáte, protože to využívají snad všechny mainstreamové jazyky. Vyhodnotí se prostě vše (nepočítáme-li optimalizace jako &&), i pokud to nebude nikdy potřeba. To je právě důvod, proč v Javascriptu výraz [3, decodeURIComponent("aa=%gg"), 8, 9][3] nevrátí hodnotu 9, ale skončí výjimkou.

Vyhodnocování on-demand

Druhá možnost je provést vyhodnocování teprve ve chvíli, kdy je to potřeba. Sem patřì normální a líné vyhodnocování, rozdíly mezi nimi teď nejsou podstatné. Pokud se v Haskellu pokusíme vyhodnotit [3, 1 `div` 0, 8, 9] !! 3, dostaneme 9, ačkoli celočíselné dělení nulou končí v Haskellu výjimkou. Funguje to díky tomu, že výraz 1 `div` 0 není potřeba vyhodnocovat. Pokud by se vyhodnocoval, vznikla by v Haskellu výjimka kvůli dělení nulou.

Asi je zřejmé, proč v mainstreamových (aspoň trošku imperativních) jazycích na toto nenarazíte: jakmile byste začali ve výrazech používat side effects, nastala by pravá magie. Těžko by se ovlivňovalo pořadí vyhodnocování (nebo to, zda by se něco vůbec vyhodnotilo) a chování programu by se začalo nepředvídatelně měnit.

Funkcionální klasika: linked list

Ve funkcionálním programování je spojový seznam velmi oblíbený. Ne, že by nešlo používat např. klasické pole, ale klasický funkcionální spojový seznam je dobré znát. Seznam je buď prázdný (v Haskellu []), nebo se skládá z jednoho prvku a reference na zbytek seznamu (v Haskellu head:tail). Tak je seznam definován rekurzivně a lze některé suffixy seznamu použí ve více různých seznamech. Například budeme mít dva seznamy o sto prvcích, ale budou se lišit vždy jen v prvním prvku, takže do paměti uložíme jen 101 prvků. Na funkčnost to nebude mí vliv, protože tyto seznamy jsou immutable. U vyhodnocování on-demand je navíc možné mít nekonečné seznamy. Součet nekonečné řady tak sice nespočítáte (aspoň ne přesně, sum (takeWhile (<epsilon) someList), tedy součet členů menších než epsilon u klesající řady, fungovat bude), ale třeba seznam všech prvočísel, ze kterého se na konec ve skutečnosti vyhodnotí jen prvních n z nich, není problém.

Jak je to s funkcemi map a filter

A už se dostávám ke článku, na který jsem reagoval. Funkce map a filter (popř. další, jako třeba reduceLeft, reduceRight, take, drop, takeWhile, dropWhile...) skutečně nejsou vše, o čem je funkcionální programování. Programovat bez nich skutečně lze. Ale není to ono. Až tak se nedivím, že se Jakub Vrána pod článkem podivil, kde je ta elegance.

Jak jsou tyto funkce (map, reduce, ...) ve skutečnosti implementovány?

Teď budu trošku kritizovat. A to článek nejen Jiřího Knesla, ale i samotné funkcionální programování. Jde mi o to, že funkce typické pro funkcionální programování není až tak snadné napsat správně. Ukážeme si to na funkci map. Nejdřív si odbydeme vyhodnocování on-demand (které zkritizuju za chvilku). Tam není nutné řešit tail call optimization, protože díky způsobu vyhodnocování nenastane rekurze v té podobě, v jaké ji známe ze striktního vyhodnocování. Můžeme tedy namapovat první prvek a rekurzivně namapovat zbytek:

map f (x:xs) = (f x) : (map f xs)

Funkce tedy vezme funkci f a seznam (x:xs). Zápis (x:xs) ve skutečnosti znamená, že parametr je neprázdný seznam a ten je rozdělen na první prvek (x) a zbytek (xs). Zbývá dořešit prázdný seznam, ten se namapuje na prázdný seznam:

map f [] = []

Jenže takto to funguje dobře jen u vyhodnocování on-demand. U striktního vyhodnocování získáme s dlouhými seznamy mnoho rekurzí a potřebujeme velký stack. Nepomůže ani tail call optimization, protože poslední operace není rekurze, ale vytvoření seznamu (:). Takto tedy ne.

Pak se nám nabízí použít akumulátor, což přesně udělal Jiří Knesl. Určitou nevýhodou je, že seznam procházíme od začátku, ale přidáváme též na začátek. Výsledný seznam tedy dostaneme v opačném pořadí:

map f list = map0 f list []
map0 f [] acc = acc
map0 f (x:xs) acc = map0 f xs ((f x):acc) -- chyba, obracíme pořadí.

Můžeme na to zavolat i funkci reverse (tu jde naštěstí napsat snadno) a dostaneme dokonce i dobrou asymptotickou složitost (konstantní na stack, lineární na heap a na čas), ale je to velmi neelegantní. O paměťové lokalitě nemluvě.

Trošku po svém si s tím poradila Scala (funkcionální jazyk se striktním vyhodnocováním). Funkce map je u třídy List efektivní, elegantní, čitelná (byť se znalostí pokročilejších konstrukcí), ale bohužel ne funkcionální. Autoři byli pragmatici, ne fanatici.

Původně jsem sem chtěl zkopírovat i implementaci pro důkaz, že to je fakt imperativně. Třída List ale nechává implementaci metody map na traitu TraversableLike, který to implementuje obecněji. Vysvětlovat tu implementaci lidem, kteří jazyk Scala nepotkali, by ale bylo nad rámec tohoto článku.

Snadný paralelismus?

Funkcionální programování k paralelismu přímo vybízí. Například ve Scale stačí do funkcionálního kódu přidat na pár míst par a hned je paralelní. (No dobře, má to svá úskalí, ale o tom třeba jindy.) U striktního vyhodnocování to funguje celkem dobře, pokud se u funkcí jako je map vzdáme části elegance. Menší problém je formát seznamu, spojový seznam není příliš vhodný u mnoha krátkých výpočtů.

Horší situace je u vyhodnocování on-demand. Co všechno má být vyhodnoceno paralelně? Pokud se výpočet něčeho odloží na později, nemusí se to vykonávat paralelně. Pokud se má vyhodnotit vše, bude se to vyhodnocovat striktně. Mimochodem, na tento problém jsem narazil ve Scale u funkce mapValues, která se kupodivu nechovala striktně. Místo toho, aby výpočet proběhl celý paralelně, se podstatná část odložila na později a spočítala se až při výpisu. Samozřejmě sériově.

Tail call optimization

A ještě jedna věc, která se sice netýká výhradně funkcionálního programování, ale – protože se tam rekurze prostě používá často – stojí za zmínku. Navíc chci v tomto reagovat i na Jiřího Knesla. Myšlenka je, že pokud je nějaké volání funkce posledním příkazem, lze uvolnit místo na stacku již při volání. Typicky se toho využívá při rekurzi. Z koncové rekurze kompilátor může udělat (mírně zjednodušeno) skok na začátek funkce. Program s rekurzí se tak může přeložit úplně stejně, jako kdyby měl cyklus. Teorie je to pěkná, Jiří Knesl se na to i odvolává při obhajobě svého skriptu. Jak je to ale doopravdy?

Hledal jsem Tail call optimization v Javascriptových enginech (na kterých bdou záviset i optimalizace Livescriptu), ale moc jsem o tom nenašel, nejvýše plány. Možná jsem jen špatně hledal, ale spíš bych se na to nespoléhal.

Ona to vlastně ani není ekvivalentní úprava. Pokud čteme stack trace (např. z výjimky), dostáváme po optimalizaci jiné údaje. To nemusí vadit, pokud o tom víme. U Javy je ale snaha zpětným nekompatibilitám (třeba i teoretickým) bránit, takže se tato optimalizace sice zvažuje, ale volání metody musí být (podle jednoho návrhu) uvozeno speciální instrukcí. Nevím o tom, že by dnes něco takového bylo v nějaké production-ready JVM podporováno. Podporovat to může kompilátor (a kompilátor pro Scalu to skutečně podporuje), ale možnosti jsou omezené. Optimalizovat takto rekurzi známou v době překladu lze relativně snadno (stačí do parametrů přiřadit nové hodnoty a goto 0, jen je potřeba řešit, aby korektně fungovalo např. i prohození parametrů), ale jiné tail calls moc dobře nejdou, protože JVM nemá instrukce pro takto low-level práci se zásobníkem. A v případě Scaly doporučuji @tailrec, aby kompilátor nezradil. U Livescriptu by teoreticky mohlo být něco podobného, překladač má asi podobné možnosti.

Závěr

Ano, funkcionální programování nabízí řadu výhod – eleganci, použitelné vyhodnocování on-demand, snadný paralelismus a další. Nemůžte ale od toho chtít všechno současně. Mám sice rád líný čistě funkcionální Haskell, ale v praxi píšu spíš v ne úplně čistě funkcionální Scale.

Pure functional programming is like anarchism: everything is stateless. Although I believe that reducing the state is often useful, I am not sure if total elimination of state is a good idea.