Osnovna ideja funktorjev in monad je, da lahko "golo" oziroma čisto vrednost zapakiramo v kontejner oziroma ji dodamo kontekst, ki določa, kako s to vrednostjo računamo.

  • Tip Maybe vrednosti doda kontekst, ki pove, če vrednost obstaja (Just 42) ali ne (Nothing).
  • Seznam je zbirka vrednosti: funkcija, ki vrne seznam, lahko vrne nič, eno ali mnogo vrednosti.

V Haskellu lahko iz nekega tipa naredimo monado tako, da zanj implementiramo določene operacije. Tu si bomo ogledali monadi Input in Output, ki čistim vrednostim dodata kanal za branje oziroma pisanje.

Monada Output

Izhod (angl. output) je računski učinek, ki omogoča pošiljanje podatkov na izhodni kanal (datoteka, zaslon, printer). Izhod lahko uporabljamo tudi v programu kot "akumulator", v katerega dodajamo rezultate z operacijo write.

Podatkovni tip, ki predstavlja to idejo je

data Output o a = Out { result :: a, output :: o }
                  deriving Show

Tip Output o a je zapis s poljem result tipa a in poljem output tipa o, ki predstavljata rezultat nekega izračuna in pripadajoč izhod. Vrednost tega tipa ustvarimo s konstruktorjem Out:

Out 42 "foo"
=> Out {result = 42, output = "foo"}

Tu smo izpis predstavili z nizom. Če želimo tip Output uporabljati kot monado, moramo zanj implementirati določene operacije. Te so definirane z razredi tipov (angl. typeclasses) Functor, Applicative in Monad.

Naloga: write

Definirajte funkcijo write, ki sprejme niz x in ga "izpiše" tako, da vrne zapis tipa Output. Rezultat naj bo enota (), izhod pa kar x. Primer:

write "foo"
=> Out {output = "foo", result = ()}

write [42]
=> Out {output = [42], result = ()}

Functor (Output o)

Razred tipov Functor izgleda tako:

class Functor f where
    fmap :: (a -> b) -> f a -> f b

Če želimo, da je tip f (v našem primeru bo to Output o) funktor, moramo implementirati funkcijo fmap. Ta uporabi funkcijo, ki slika iz a v b, da preslika funktor z vsebino tipa a v funktor z vsebino tipa b. Lahko bi rekli tudi, da fmap uporabi funkcijo, ki slika iz a v b za to, da preslika a s kontekstom (izhodom) v b s kontekstom (izhodom), ozirma zapakiran a v zapakiran b.

instance Functor (Output o) where
    -- fmap :: (a -> b) -> Output o a -> Output o b
    fmap f (Out x o) = Out (f x) o

Applicative (Output o)

Razred tipov Applicative definira funkcijo pure in operator <*>:

pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b

Funkcija pure vzame navadno vrednost in iz nje zgradi vrednost našega tipa. Pri definiciji si pomagamo z omejitvijo Monoid o, ki določa, da mora tip o vsebovati prazno vrednost mempty (npr. prazen niz ali seznam) in funkcijo mappend, ki združi dve vrednosti v novo vrednost (npr. stakne dva niza ali seznama).

instance Monoid o => Applicative (Output o) where
    -- pure :: a -> Output o a
    pure x = Out x mempty

Definirati moramo še operator <*>, ki pove, kako evalviramo funkcijo z dodanim izhodom na argumentu z dodanim izhodom.

-- (<*>) :: Output o (a -> b) -> (Output o a) -> Output o b
Out f o <*> Out x o' = Out (f x) (mappend o o')

Monad (Output o)

Monada je Applicative, ki vsebuje še funkcijo return in operator >>=:

return :: a -> m a
(>>=) :: m a -> (a -> m b) -> m b

Funkcije return nam ni treba definirati, saj dela isto kot pure. Operator >>= pa sprejme a z izhodom in funkcijo, ki iz a slika v b z izhodom, ter vrne b z izhodom. Implementiramo jo tako, da odpakiramo prvi argument in na njem poženemo podano funkcijo:

instance Monoid o => Monad (Output o) where
    -- (>>=) :: Output o a -> (a -> Output o b) -> Output o b
    Out x o >>= f = let (Out y o') = f x in Out y (mappend o o')

Uporaba

Z definirano monado Output lahko med računanjem vrednosti pišemo na izhodni kanal:

primer = do write "Hello,"
            x <- return 10
            write " world"
            y <- return (x + 20)
            write "!"
            return (x * y)
output primer
=> "Hello, world!"

Naloga: isPrime

Definirajte funkcijo isPrime, ki preveri, ali je dano število praštevilo. Namig: pomagate si lahko s funkcijo all p s, ki vrne True natanko tedaj, ko predikat p vrne True za vse elemente seznama s. Primer:

all (>2) [3,4,5]
=> True

S pomočjo funkcije isPrime in monade Output lahko "izpišemo" seznam praštevil med 1 in n:

primes :: Int -> [Int]
primes n = output (loop 2)
    where loop k | k >= n     = return ()
                 | otherwise  = do if isPrime k then write [k] else return ()
                                   loop (k + 1)

Monada Input

Vhod (angl. input) je računski učinek, ki omogoča branje podatkov z vhodnega kanala (datoteka, tipkovnica, povezava TCP, …). Vhodni kanal bomo implementirali s seznamom vrednosti, iz katerega ob vsakem branju odstranimo in vrnemo prvi element.

Uporabili bomo podatkovni tip Input, ki predstavlja izračun, ki je (lahko) odvisen od vhoda:

data Input i a = In { run :: [i] -> (a, [i]) }

Tip Input je zapis z enim samim poljem – funkcijo run, ki sprejme vhod (seznam vrednosti tipa i) in vrne rezultat tipa a ter preostanek vhoda (če nismo prebrali nobenega podatka, vrne kar originalni vhod). Definirajmo consume, ki prebere prvo vrednost z vhoda in jo vrne skupaj s preostankom vhoda:

consume :: Input i i
consume = In { run = (\input -> (head input, tail input)) }

Opomba. Ko definiramo vrednost nekega zapisa, nam polj ni treba poimenovati, ampak jih lahko kar naštejemo:

consume = In (\input -> (head input, tail input))

Sam consume ni funkcija, ampak vrednost tipa Input, ki vsebuje funkcijo v polju run. Uporabimo jo tako:

run consume ["a", "b", "c"]  -- (run consume) nam vrne polje run iz zapisa consume
=> ("a",["b","c"])

Če želimo tip Input uporabljati kot monado, moramo zanj implementirati določene operacije. Te so definirane z razredi tipov (angl. typeclasses) Functor, Applicative in Monad.

Functor (Input i)

Za tip Input i definirajmo funkcijo fmap:

instance Functor (Input i) where
    fmap f (In xrun) = In (\input -> let (x, input') = xrun input in (f x, input'))

To nas spomni na implementacijo map za tokove v OCaml. Funkcija fmap sama ne izračuna ničesar, temveč le definira funkcijo run, ki bo (kasneje) na danem vhodu input pognala xrun, da dobi dejansko vrednost x in nov vhod input', nato pa evalvirala funkcijo f na x ter vrnila (f x, input').

Vse operacije, ki vrnejo vrednost tipa Input, definiramo na ta način – vrednosti tega tipa vsebujejo funkcijo run, ki jo šele kasneje evalviramo na dejanskem vhodu.

Applicative (Input i)

Razred tipov Applicative definira funkcijo pure in operator <*>:

pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b

Funkcija pure vzame navadno vrednost in iz nje zgradi vrednost našega tipa tako. Vrednost tipa Input zgradimo tako, da definiramo funkcijo run, ki pri danem vhodu vrne vrednost in nespremenjen vhod:

instance Applicative (Input i) where
    pure x = In (\input -> (x, input))

Operator <*> je podoben fmap, le da je tudi funkcija a -> b zapakirana v funktor f. Zato run najprej poženemo na prvem in nato še na drugem argumentu operatorja <*>. Pri obeh korakih se lahko spremeni tudi vhod:

(In frun) <*> (In xrun) = In (\input -> let (f, input') = frun input
                                            (x, input'') = xrun input'
                                        in (f x, input''))

Monad (Input i)

Na koncu definirajmo še operator >>= (bind), ki sprejme vrednost tipa a (odvisen od vhoda) in funkcijo, ki iz a slika v b (odvisen od vhoda).

instance Monad (Input i) where
    (In arun) >>= f = In (\input -> let (a, input') = arun input
                                    in run (f a) input')

Uporaba

Oglejmo si primer uporabe monade Input:

primer = do x <- consume
            y <- consume
            return ("First input: " ++ x ++ "  Second input: " ++ y)

Vrednost primer je monada Input, ki prebere dva niza z vhoda in vrne sestavljen niz. Poženemo jo tako kot consume:

run primer ["prvi niz", "drugi niz", "preostanek"]
=> ("First input: prvi niz  Second input: drugi niz",["preostanek"])

Kaj se zgodi, če v vhodnem kanalu ni dovolj podatkov?

Kombiniran vhod/izhod

Ko imamo monadi za vhodni in izhodni kanal, bi ju želeli tudi kombinirati:

do write "What is your name? "
   name <- consume
   write "Hello, " ++ name

Tega v Haskellu ne moremo narediti brez dodatnega dela (lahko pa uporabimo vgrajeno monado IO, ki podpira vhod in izhod). Na predavanjih boste spoznali jezik Eff, ki računske učinke obravnava drugače.

Zadnja sprememba: sreda, 27. maj 2020, 10.49