Haskell - State, IO, record syntaxe, zipper
V dnešních cvičeních se nám hodí několik importů:
import Data.Char
import Control.Exception
import Network.HTTP
Říkali jsme si, že funkce v Haskellu jsou čisté, tj. jejich hodnoty závisí jen na jejich vstupních parametrech. To je sice pěkné a dobře se s tím pracuje, ale občas to je nepraktické. Představme si třeba, že chceme v programu pracovat se zásobníkem čísel. Můžeme si ho nadefinovat jako
type Zasobnik = [Int]
Pro práci se zásobníkem potom používáme typicky funkce jako push
a pop
. push
přidává hodnotu na zásobník a pop
vrací hodnotu z vrcholu zásobníku (a odebere ji). Pro náš zásobník je jednoduché je napsat.
push::Int -> Zasobnik -> Zasobnik
push h zas = h:zas
pop::Zasobnik -> (Int, Zasobnik)
pop (z:zas) = (z, zas)
Jak bychom teď napsali funkci, která odebere dvě hodnoty z vrcholu zásobníku, sečte je, a vrátí nový zásobník?
zasobnikTest::Zasobnik->(Int, Zasobnik)
zasobnikTest z = (a+b, nz)
where
(a, z1) = pop z
(b, z2) = pop z1
nz = push (a+b) z2
Není to těžké, ale je to celkem ukecané a nepřehledné, musíme se sami starat o předávání zásobníku mezi funkcemi. Přitom by bylo tak pěkné, kdybychom mohli používat třeba do
notaci a zásobníku si vůbec nevšímat. Taková věc je v Haskellu možná a implementuje ji State
monáda. Ta má dva typové parametry, typ stavu (s
) a typ návratové hodnoty funkce (a
). Zajímavé je, že uvnitř monády tentokrát nemáme samotné hodnoty těchto dvou typů, ale funkci, která vezme stav a spočítá návratovou hodnotu a nový stav. Musí to tak být, jinak bychom se museli o stav starat pořád sami a předávat si ho. Celá myšlenka této monády je v tom, že budeme postupně skládat funkce. Na začátku zadáme jako parametr počáteční stav a dál se o něj funkce budou starat samy a budou si ho i samy předávat (pomoci >>=
).
data Stav s a = Stav {runState :: s -> (a, s)}
V definici Stav
si můžete všimnout použití tzv. record syntaxe, ta vlastně pojmenovává funkci uloženou uvnitř Stav
a zároveň vytváří funkci runState::Stav s a -> s -> (a, s)
, která nám vrátí funkci obsaženou ve Stav
. Funkce pop
a push
potom můžeme přepsat tak, aby pracovaly se stavem, který obsahuje náš zásobník. Potřebujeme tedy, aby vracely něco typu Stav Zásobník b
, kde b
je libovolný návratový typ funkce. U popS
tento typ je Int
(první hodnota ze zásobníku), u pushS
použijeme jako typ ()
, kterému se také říká unit. Je to typ, který může obsahovat jen jednu hodnotu a to ()
. Odpovídá trochu typu void v jiných programovacích jazycích (NoneType
v Pythonu). (Pozor v Haskellu je i typ Void
, který dělá něco úplně jiného – je pro funkce, které nikdy nic nevrátí.)
popS::Stav Zasobnik Int
popS = Stav $ \(s:ss) -> (s,ss)
popS
tedy zabalí do Stav
funkci, která vezme stav, z něj odebere první prvek ten nastaví jako výslednou hodnotu a vrátí zásobník bez prvního prvku.
pushS::Int -> Stav Zasobnik ()
pushS x = Stav $ \ss -> ((),x:ss)
Na pushS
vidíme, že funkce pro práci se stavem mohou mít i parametry. V tomto případě pushS
dostane Int
, ten přidá na začátek zásobníku a vrátí ()
. Není totiž asi žádná rozumná hodnota, kterou by pushS
jinak mohla vrátit, tato návratová hodnota nás navíc nebude zajímat. Pojďme ze Stav
udělat monádu: samotná monáda musí mít jen jeden typový parametr, implementovat jako monádu tedy budeme (Stav s)
a ne jen samotný Stav
.
První věc, kterou potřebujeme je napsat instanci pro Functor (Stav s)
, tzn. potřebujeme napsat funkci fmap::(a -> b) -> Stav s a -> Stav s b
, měla by to tedy být funkce, která vezme funkci a->b
, která stav úplně ignoruje, a aplikuje ji ve Stav s
. Jediná rozumná implementace takové funkce bude vzít návratovou hodnotu ze Stav s a
, na ní aplikovat funkci a vytvořit tak Stav s b
. Samotný vnitřní stav by se neměl změnit.
instance Functor (Stav s) where
-- fmap::(a -> b) -> Stav s a -> Stav s b
fmap f x = Stav $ \s -> let (v, ns) = runState x s
in (f v, ns)
Na implementaci je možná vhodné podívat se podrobněji. runState x
vytáhne funkci ze stavu x
(druhého parametru fmap
) a aplikuje ji na stav s
(parametr nové funkce, kterou definujeme), tím získá výsledek a stav, na výsledek potom aplikuje f
(první parametr fmap
) a stav jen zkopíruje.
Jako další krok potřebujeme napsat instanci Applicative
pro Stav s
, tedy funkci pure::a -> Stav s a
, která co nejjednodušeji zabalí hodnotu do Stav s
, a operátor (<*>)::Stav s (a->b) -> Stav s a -> Stav s b
.
Funkce pure
je jednoduchá, když dostane hodnotu, tak vytvoří funkci, která jen zkopíruje stav a jako návratovou hodnotu nastaví hodnotu ze vstupu.
Operátor (<*>)
je o něco komplikovanější. Napřed spustí runState
na stav, který obsahuje funkci (a->b)
, potom spustí runState
i na druhý parametr, tím dostane výsledek typu a
a další nový stav. Na výsledek aplikuje funkci, která byla výsledkem prvního stavu a vrátí nejnovější stav.
instance Applicative (Stav s) where
-- pure:: a -> Stav s a
pure x = Stav $ \s -> (x, s)
-- (<*>)::Stav s (a->b) -> Stav s a -> Stav s b
sab <*> sa = Stav $ \s -> let (ab, ns) = runState sab s
(a, ns2) = runState sa ns
in (ab a, ns2)
Konečně můžeme napsat instanci Monad (Stav s)
, stačí už nám jen dodefinovat operátor (>>=)::Stav s a -> (a -> Stav s b) -> Stav s b
, který vlastně řetězí dvě funkce se stavem za sebou. Napřed tedy spustí runState
na svou levou stranu, a na výsledný stav aplikuje funkci, kterou má na pravé straně. Tím dostane nový stav a zavolá na něj runState
.
instance Monad (Stav s) where
--(>>=)::Stav s a -> (a -> Stav s b) -> Stav s b
h >>= f = Stav $ \s -> let (a, newState) = runState h s
g = f a
in runState g newState
Nyní můžeme naší testovací funkci přepsat jednoduše pomoci do
notace, je vidět, že kód je mnohem přehlednější a snadnější na pochopení, navíc se nemusíme o stav starat sami.
zasobnikTestS::Stav Zasobnik Int
zasobnikTestS = do
a <- popS
b <- popS
pushS (a+b)
return (a+b)
Pomoci State
monády můžeme třeba implementovat i práci s náhodnými čísly. Napíšeme si jednoduchý lineární kongruenční generátor náhodných čísel.
type Rand t = Stav Int t
rand::Int -> Rand Int
rand max = Stav $ \s -> let x = (s*1664525+1013904223) `mod` 2^32
in (x `mod` max, x)
nahoda::Int -> Int -> Rand [Int]
nahoda 1 max = fmap pure $ rand max
nahoda n max = (:) <$> rand max <*> nahoda (n-1) max
seed::Int -> Rand ()
seed n = Stav $ \_ -> ((), n)
Jedna z nejdůležitějších monád v Haskellu je IO
. V této monádě žije všechno, co chce nějakým způsobem komunikovat s vnějším světem. Můžete si to představovat třeba tak, že ta monáda se stará o to, aby si jednotlivé funkce mezi sebou předávaly nějaký stav. A ten stav je v tomto případě celý vnější svět (vnější z pohledu programu). Díky tomu potom funkce mohou měnit tento stav (výstup), případně z toho stavu něco zjišťovat (vstup). Tím jsme dosáhli toho, že se nám podařilo propojit svět funkci bez vedlejších efektů s požadavkem na to, mít vstupy a výstupy.
V Haskellu je běžné, že se vstupy a výstupy oddělují od vlastního provádění programu. Monáda IO
by vám tedy neměla nikdy prolézt celým programem až k typům pomocných funkci někde hluboko v kódu. Naopak, IO
by ve svém typu měly mít jen funkce, které vstup a výstup nutné potřebují dělat. Podle toho, že nějaká funkce má ve svém typu IO poznáte, že může mít nějaké vedlejší efekty.
Podívejte se třeba na funkci getLine
, její typ je IO String
. To znamená, že tahle funkce udělá nějaké vstupy a výstupy a vrátí nakonec String
. Díky tomu, že tento String
má před sebou ještě IO vidíme, že může být jiný při každém volání této funkce - funkce tedy má vedlejší efekty.
Už jsme viděli i funkci putStrLn
, ta má typ String -> IO ()
, to znamená, že vezme String
, udělá nějaký vstup a výstup a nic nevrací (tj. jen mění okolní svět).
My samozřejmě víme přesně, co tyhle funkce dělají – getLine
načte jednu řádku že vstupu, putStrLn
vypíše String
na standardní výstup.
Napište teď program, který se zeptá uživatele na nějaký text a potom ho postupně po řádkách načítá a vypisuje na konzoli velkými písmeny. (Ve skutečnosti ho načte celý najednou, ale buffer je nastavený defaultně po řádkách, takže to vždy po konci řádky zpracuje.) Pokud byste chtěli načíst jen jednu řádku, můžete použít getLine
. Jeden znak se načte pomocí getChar
. Program ukončete pomocí Ctrl+C.
zakric :: IO ()
zakric = do
putStrLn "Zadej text: "
text <- getContents
let output = naVelka text
putStrLn output
naVelka :: [Char] -> [Char]
naVelka = map (toUpper)
Všimněte si několika věci: zakric
má typ IO ()
, tj. je to funkce, která má nějaké vedlejší efekty a nakonec nic nevrací; naVelka
je čistá funkce, která ve svém typu žádné IO
nemá, taky by mít neměla, nedělá žádné vstupy a výstupy a nakonec je jí úplně jedno, kde ten String
, se kterým pracuje, vezme; v do
blocích se za let
nepíše in
. Pokud chceme v do
bloku pojmenovat výstup z čisté funkce použijeme let
, pokud z nějaké IO akce použijeme <-
.
Načtení vstupu, jeho zpracování a výstup jsou tak běžné činnosti, že pro ně dokonce existuje hotová funkce, jmenuje se interact
a pomoci ní můžeme přepsat předchozí příklad takhle:
iZakric :: IO ()
iZakric = do
putStrLn "Zadej text: "
interact naVelka
Tím, že se nám podařilo oddělit vstup a výstup od jejich zpracování se nám zároveň podařilo to, že samotné funkce, které vstupy a výstupy zpracovávají, vůbec nemusí vědět (ani nevi), odkud se data vzala. Mohla se klidně vzít i z nějakého souboru.
Můžeme si třeba podobným způsobem napsat program, který načte ze souboru čísla a na výstup vypíše jejich součet.
secti :: String -> IO ()
secti vstup = do
vstup <- readFile vstup
let vystup = zpracujSoucet vstup
putStrLn vystup
zpracujSoucet :: String -> String
zpracujSoucet v = show $ sum $ map (\x -> (read x)::Integer) $ lines v
Načtení celého souboru najednou, jak je uděláno v příkladu výše, se bát nemusíte. Pokud ho zpracováváte rozumně, Haskell ho celý najednou v paměti nikdy mít nebude. Jakmile zjistí, že některá data už nepotřebuje, zahodí je. Zpracování v příkladu úplně rozumné není, sum
totiž používá foldl
a ten potřebuje mít napřed celý seznam čísel. Pokud bychom místo foldl
v sum
použili foldl'
, potom by Haskell celý soubor zpracoval v konstantní paměti.
Pokud soubor neexistuje, Haskell vyhodí výjimku a pokud ji nikdo neodchytí, tak spadne. Haskell na výjimky reagovat umí, není s tím problém. My se tím moc zabývat nebudeme, jen si povíme o jedné funkci: handle
sectiExceptions vstup = handle ((\_ -> putStrLn "Soubor se nepodarilo otevrit")::IOException -> IO ()) $ do
vstup <- readFile vstup
let vystup = zpracujSoucet vstup
putStrLn vystup
Podobným způsobem můžeme číst i data ze sítě (potřebujeme modul Network.HTTP
). Příklad funguje jen pro ASCII soubory, zkoušejte třeba s http://www.google.com/robots.txt
.
stahni url fileName = do
response <- simpleHTTP $ getRequest url
print response
let body = fmap rspBody response
case body of
Left _ -> putStrLn "error"
Right content -> writeFile fileName content
V předchozím příkladu jste zároveň viděli typické použití typu Either a b = Left a | Right b
. V případě chyby se její popis uloží do Left
, pokud se chyba nestane, je výsledek v Right
. Jedná se vlastně o rozšíření typu Maybe
(a funguje podobně, navíc je to taky monáda).
ghc
umí Haskell i kompilovat, k tomu je důležité, abychom měli metodu main::IO ()
. Typ IO ()
říká, že ta metoda pracuje se vstupy a výstupy (obecně s IO monádou) a nic nevrací. V modulu System.Environment
je metoda getArgs :: IO [String]
, která vrací seznam parametrů z příkazové řádky (bez názvu programu, ten můžete dostat pomocí getProgName :: IO String
).
Typický main
obsahuje načtení vstupu (případně i parametrů) a jejich předání čistým funkcím, které už IO
nepoužívají. Nakonec se zase výsledky těchto čistých funkci vypíšou (typicky zase přímo v main
je volání vypisovacích funkcí).
V Haskellu vstup a výstup funguje striktně (tj. ne líně), ale streamovaně. To znamená, že data programem v Haskellu protečou, a pokud nejsou všechna najednou třeba, v paměti se neuloží. Striktnost vstupu a výstupu je důležitá jinak by výsledek programu mohl záležet na pořadí vyhodnocení (např. dvakrát getLine
za sebou by vracel jiné výsledky, kdyby se druhý vyhodnotil dříve než první).
Defaultní chování ghc
při kompilování je nepoužívání optimalizací. Pokud chcete optimalizace zapnout, stačí mu dát parametr -O
, případně -O1
(znamená to samé). -O2
může použít i “nebezpečné” optimalizace, které mohou při troše smůly vést naopak ke zpomalení kódu. Dokumentace ke ghc
tvrdí, že -O2
málokdy vytvoří rychlejší kód než -O
.
Jak velké je zrychlení? Pokud např. použijeme kód, který jsme ukazovali výše (součet hodnot ze souboru) na sečtení miliónu čísel, zjistíme, že bez optimalizací to trvá cca 5.5 sekundy, s nimi jen 4.5 sekundy. Navíc s optimalizacemi vystačíme s menším zásobníkem (default je 8MB), bez nich ho potřebujeme zvýšit.
Ještě větší rozdíl uvidíme, když ho spustíme na prvních 100M čísel. Verze, která používá -O
doběhne za 433 sekund a po celou dobu zabírá cca 3MB paměti. Oproti tomu verze bez optimalizaci sežere 1GB paměti a pak spadne (můžeme ji dát paměti více, ale stejně to nepomůže). Rozdíl je v tom, že GHC optimalizuje striktnost výpočtu a je schopné detekovat, že sum
(což je vlastně foldl (+) 0
) se dá vyhodnotit v konstantním prostoru (v zásadě z foldl
udělá foldl'
a navíc se postará o to, aby se soubor nenačítal celý, ale jen kousky, které jsou zrovna potřeba).
Předešlá diskuze není rozhodně sofistikovaný benchmark, má spíš ukázat, že se vyplatí Haskellovské programy kompilovat a pokud máte nějaký problém, je vhodné zapnout optimalizace.
Zkusme si ještě nakonec naimplementovat simulátor nedeterministického Turingova stroje. Ukážeme si na tom několik málo technik, které jsme ještě neviděli a procvičíme si práci s nedeterminismem.
První co budeme potřebovat je nějaký způsob, jak reprezentovat pohyb hlavy na páskách.
data Smer = L | R | N
Dále budeme potřebovat nějakou přechodovou funkci.
data Fce a b = Fce [((a,b),(a,b,Smer))]
A potom samotný Turingův stroj. Tady si všimněte definice pásky: jsou tam dva seznamy a jedna hodnota. Hodnota určuje symbol pod hlavou TS. Levý seznam je páska vlevo od hlavy (uložena pozpátku, takže na začátku seznamu je symbol, který je hned vlevo od hlavy) a v pravém seznamu je páska napravo od hlavy.
data TS a b = TS {
paska :: ([a],a,[a]) -- obousmerne nekonecna paska TS
, stav :: b -- aktualni stav TS
, fce :: Fce a b -- prechodova fce
, koncoveStavy :: [b] -- seznam koncovych stavu
, pos :: Int -- aktualni pozice od zacatku (pro vypis)
, minPos :: Int -- nejlevejsi pozice (pro vypis)
, maxPos :: Int -- nejpravejsi pozice (pro vypis)
}
Použili jsme tzv. record syntaxi, která nám kromě definice dat zároveň definuje i funkce, které jednotlivé položky vrací. Takže např. zavolání paska ts
, kde ts
je nějaký TS nám vrátí jeho pásku.
Ještě se nám hodí funkce, která vrátí symbol pod hlavou TS. Všimněte si triku s where
, který nám umožňuje udělat pattern matching v těle funkce.
hlava :: TS a b -> a
hlava ts = a where (_,a,_) = paska ts
Funkce, která “pěkně” vypíše TS. Tentokrát jsme místo triku s where
použili podobný trik s let
.
showTS:: (Show a, Show b) => TS a b -> String
showTS ts = let
(xs,h,ys) = paska ts
b = stav ts
right = (maxPos ts) - (pos ts)
left = (pos ts) - (minPos ts)
in
show (reverse $ take left xs) ++ show h ++ show (take right ys) ++ "-" ++ show b
Funkce, která vrací všechny možnosti přechodové funkce aplikovatelné v daném stavu s daným symbolem pod hlavou.
findAll :: (Eq a, Eq b) => (a,b) -> Fce a b -> [(a,b,Smer)]
findAll (a,b) (Fce f) = [(x,y,s) | ((a1,b1),(x,y,s)) <- f, a==a1, b==b1]
Teď je potřeba naimplementovat jeden krok TS. Funkce je sice dlouhá, ale není moc těžká. Většina práce a triků se děje až v case na konci.
step :: (Eq a,Eq b) => TS a b -> [TS a b]
step ts = do -- vyuzijeme nedeterminismu list monady
let f = fce ts -- prechodova fce
let ((x:xs), p, (y:ys)) = paska ts -- paska a symbol pod hlavou
let s = stav ts -- stav
let kroky = findAll (hlava ts, stav ts) f -- mozne prechody
let ps = pos ts -- aktualni pozice na pasce
let minP = minPos ts -- nejlevejsi navstsivena pozice
let maxP = maxPos ts -- nejpravejsi navstivena pozice
(np, ns, d) <- kroky -- jeden mozny krok
case d of -- posun a zmena stavu podle kroku
L -> return ts {paska = (xs,x,np:y:ys), stav = ns, pos = ps - 1, minPos = min minP (ps - 1)}
R -> return ts {paska = (np:x:xs,y,ys), stav = ns, pos = ps + 1, maxPos = max maxP (ps + 1)}
N -> return ts {paska = (x:xs,np,y:ys), stav = ns}
Všimněte si posledních třech řádek. Record syntaxe nám umožňuje vytvořit upravenou kopii TS, aniž bychom museli opisovat zbytek informací, které se nemění. To se hodí, nemusíme totiž přepisovat všechny funkce, když se rozhodneme přidat do TS nějakou další informaci.
Nakonec napíšeme samotnou simulaci. Ta už je snadná. Stroje, které jsou v nějakém koncovém stavu už se dál nesimulují. Na ostatní se aplikuje funkce step
. Funkce opět využívá nedeterminismu list
monády.
sim::(Eq a, Eq b) => TS a b -> Int -> [TS a b]
sim ts 0 = return ts
sim ts maxS = do
stroj <- step ts
if (stav stroj) `elem` (koncoveStavy stroj) then
return stroj
else
sim stroj (maxS - 1)
Způsob, jakým jsme reprezentovali pásku TS se nazývá zipper. Umožňuje nám přístup v konstantním čase na jedno konkrétní místo v datové struktuře (v tomhle případě v seznamu). Kromě seznamu se dá použít i na stromu (tam je třeba si pamatovat, na jaké straně byl daný kousek přilepen). Můžete si ho představit tak, že strom (seznam) vezmete za to místo, kam chcete mít rychlý přístup a zvednete ho do vzduchu, co spadne dolů na jednu stranu je jedna polovina zipperu, co spadne na druhou je jeho druhá polovina (u TS jsme si navíc pamatovali prvek, za který jsme ho drželi).