Václav Končický

Stavíme Origami

Origami je Japonské umění, které spočívá v překládání a ohýbání papíru, které vyústí ve všemožné tvary. To si může zkusit každý. Stačí jen kousky papíru správné velikosti a návod, jak pro aktuálně poskládaný papír pokračovat dále. Pak již každý zvládne poskládat z papíru třeba čepici, loďku, ptáčka nebo také dvanáctistěn, jenž můžete každý najít v posluchárně S3 na Malé Straně.

Jistě se ptáte, proč se zmiňujeme o Origami, když je očividně řeč o programování v Haskellu. Představte si nyní, že náš papír reprezentují seznamy. Dobře, ale jak vypadá skládání seznamů?

Mějme v ruce nějaký aktuální stav. Pak jedno složení v seznamu vypadá tak, že vezmeme prvek v seznamu, ten podle návodu zkombinujeme s aktuálním stavem, čímž dostaneme jiný stav a přesuneme se na další prvek. Návod reprezentujeme libovolnou funkci odpovídajícího typu. Jakmile nám dojdou prvky, máme hotové mistrovské dílo, v našem případě konečný stav.

Všimněme si, že většina rekurzivních funkcí, které pracují se seznamem, dělá právě nějaký druh skládání seznamu. Na prázdném seznamu se zastaví a jako výsledek vrátí nějakou transformaci hlavy a rekurzivního výsledku na zbytek seznamu.

Existují dva způsoby jak postupovat. Můžeme se buď nejprve zarekurzit až ke konci seznamu, kde začneme zpracovávat počáteční stav, který postupně při návratu z rekurze upravujeme. Též naopak můžeme začít zpracovávání již od prvního prvku a stav budeme udržovat v akumulátoru. První způsob zachytíme funkcí foldr, ten druhý pomocí foldl.

foldr :: (a -> b -> b) -> b -> [a] -> b
foldr _ start []     = start
foldr f start (x:xs) = f x $ foldr f start xs

foldl :: (b -> a -> b) -> b -> [a] -> b
foldl _ acc []     = acc
foldl f acc (x:xs) = foldl f (f acc x) xs

Nyní tyto funkce použijeme k tomu, abychom sečetli všechny prvky v seznamu. Počáteční stav bude nula a návodová funkce bude prostě sčítání.

> foldr (+) 0 [1..5]
15

> foldl (+) 0 [1..5]
15

Který fold použít?

Obě varianty foldu nám vrátily v případě sčítání stejný výsledek. Kdy je však která varianta ta správná? Podívejme se nejprve, na jaký výraz se foldy převedou:

> foldl (∘) v [a, b, c, d, e]
(((((v ∘ a) ∘ b) ∘ c) ∘ d) ∘ e)

> foldr (∘) v [a, b, c, d, e]
(a ∘ (b ∘ (c ∘ (d ∘ (e ∘ v)))))

Oba výrazy tedy mají vzhledem k operaci stejné pořadí prvků seznamu. Podstatně se změnilo uzávorkování. Zatímco foldl asociuje aplikace zleva, foldr to dělá zprava. Proto se též liší signatury návodových funkcí.

Co to znamená pro naši rekurzi? Nejprve předpokládejme, že nemáme líné vyhodnocování, tudíž se vyhodnocení provede od nejhlubší závorky. Uvažme příklad se sčítáním:

> foldl (+) 0 [1..5]
 ((((0 + 1) + 2) + 3) + 4) + 5
 ((( 1      + 2) + 3) + 4) + 5
 ((  3           + 3) + 4) + 5
 (   6                + 4) + 5
    10                     + 5
    15

> foldr (+) 0 [1..5]
 1 + (2 + (3 + (4 + (5 + 0))))
 1 + (2 + (3 + (4 +      5 )))
 1 + (2 + (3 +           9  ))
 1 + (2 +               12   )
 1 +                    14
 15

Pro foldl, abychom mohli začít redukovat, nám stačí první prvek seznamu, vyhodnocení odpovídá používání akumulátoru a dostáváme příjemně efektivní koncovou rekurzi. Naopak foldr musí nejprve dojít ke konci seznamu, a pak jej redukovat "pozpátku", použití zásobníku se v tomto případě nevyhneme a vyhodnocení bude potřebovat lineární prostor. Z tohoto důvodu se foldl často říká levý fold a foldr pravý fold.

Nyní se však do hry dostane líné vyhodnocování. Podívejme se nejprve, jak se změní vyhodnocení levého foldu. Zde se najednou stane to, že protože hodnotu budeme chtít potenciálně až "později", Haskell si zapamatuje celý uzávorkovaný výraz. Potom si jiná část programu řekne o výsledek, což bude vnější závorka. Ta ale obsahuje striktní (+), tudíž její vyhodnocení vynutí vyhodnocení vnitřní závorky. Takto pokračujeme, dokud se nedostaneme do nejhlubší závorky, kterou můžeme vyhodnotit a pak se vrátit zpět.

Ve výsledku dostaneme stejný výraz, ale najednou místo konstantní paměti najednou budeme potřebovat lineární. To není hezké. O co víc, pokud seznam samotný je líný a vytváří se postupně v konstantním čase, zhoršili jsme si paměťovou složitost extrémně.

Z tohoto důvodu nám Haskell poskytuje striktní variantu levého foldu, nalezitelnou jako funkci foldl'. Ta opravdu dělá to, že průběžně udržuje akumulátor a běží v konstantním prostoru.

Dobře, a co foldr? Zde nám líné vyhodnocování naopak pomůže! Zapišme si explicitněji průběh rekurze našeho sčítacího příkladu.

> foldr (+) 0 [1, 2, 3, 4, 5]
 1 + (foldr (+) 0 [2, 3, 4, 5])

To je pořádný rozdíl. Dokud nepotřebujeme výsledek, udělali jsme jen jeden krok a zatím jsme vůbec nevyhodnotili zbytek seznamu. To je velmi dobrá zpráva, protože to může vést k pouze částečnému vyhodnocení seznamu. Samozřejmě, v případě sčítání se při dotaz na výsledek stejně projde celý seznam, ale zkusme nyní něco jiného.

> let f x xs = (2*x) : xs
  in take 2 $ foldr f [] [1..]
 take 2 $ (1 * 2) : (foldr f [] [2..])
 take 2 $ (1 * 2) : (2 * 2) : (foldr f [] [3..])
 (1 * 2) : (2 * 2) : []
 [2,4]

Konstrukce let a = b in c je ekvivalentní c where a = b. Narozdíl od where, což je speciální syntaxe při definici funkce, je let .. in výraz a lze jej použít kdekoliv.

Vidíme, že pravý fold umí pracovat i s nekonečnými seznamy a v případech, kdy je vše líné, se opravdu vyhodnotí jen ta část, která nás zajímá.

Co si z tohoto odnést? Pokud opravdu musíme projít celý seznam a chceme levý fold, vždy je lepší místo toho uvážit jeho striktní variantu foldl'. Naopak, pokud chceme mít funkci, která umí pracovat s nekonečnými seznamy, chceme použít pravý fold.

Cvičení: Pomocí foldu naprogramujte funkci product :: [Integer] -> Integer vracející součin všech čísel seznamu. Který fold použijete?

Cvičení: Naprogramujte funkci length :: [a] -> Integer vracející délku seznamu.

Cvičení: Naprogramujte funkci sumSq :: [Integer] -> Integer, která vrací součet druhých mocnin prvků seznamu.

Cvičení: Naprogramujte funkce and a or typů [Bool] -> Bool, které vyhodnocují n-ární logický and nebo or. Jak se budou chovat na nekonečném seznamu?


Podívejme se blíže na univerzalitu foldů. Dříve jsme si naprogramovali funkci map. Když pohlédneme na její definici, celkem přímočaře v rekurzi vidíme pravý fold. Návodová funkce odpovídá aplikaci f na hlavu, kterou připojí ke zbytku seznamu. Zbytek seznamu pak tvoří stav.

map f = foldr (\x xs -> (f x) : xs) []

> map (*2) [1,2,4]
 foldr (\x xs -> (x*2) : xs) [] [1,2,4]
 (1*2) : foldr (...) [] [2,4]
 (1*2) : (2*2) : foldr (...) [] [4]
 (1*2) : (2*2) : (4*2) : foldr (...) [] []
 (1*2) : (2*2) : (4*2) : []
 [2,4,8]

To bylo celkem jednoduché. Co třeba taková funkce takeWhile? Návodová funkce zde vždy zkontroluje predikát. Pokud platí, vrátí prvek a zbylý stav, jinak vrátí prázdný seznam. Podobným způsobem můžeme napsat i filter, kde ale dle návodu vrátíme zbytek vždy.

takeWhile f = foldr (check f) []
    where
        check f x xs = if (f x) then x : xs else []

filter f = foldr (check f) []
    where
        check f x xs = if (f x) then x : xs else xs

Konstrukce if p then t else f je výraz, který zkontroluje pred a vrátí příslušnou větev. Jedná se tedy o ekvivalent stráží, který lze ale použít kdekoliv. Narozdíl od běžného procedurálního if je else větev povinná, něco vrátit musíme.

Cvičení: Naprogramujte funkci reverse :: [a] -> [a], která obrací konečný seznam.

Cvičení: Naprogramujte funkci deleteAll :: Eq a => a -> [a] -> [a], která odsraní všechny výskyty daného prvku ze seznamu.

Cvičení: Naprogramujte funkci replace :: Eq a => a -> a -> [a] -> [a], beroucí dvojici x, y a nahrazující všechny výskyty x v seznamu za y.

V typové signatuře Eq a značí, že typ a lze porovnávat rovností. Obdobně, Ord a popisuje porovnatelné prvky pomocí < a =.

Dobře, tato řešení byla vcelku krotká, rekurze je celkem přímočará a vždy se zastavuje až na konci seznamu. Zkusme nyní něco složitějšího. Vezměme si funkci take, která vrací prvních k prvků. Jak ji napsat?

Trochu pomůžeme našemu foldu zvenku. Nejprve si k prvkům přidáme jejich pořadí a pak provedeme fold na této dvojici, který postupně z dvojice zanechá pouze původní prvek. Jakmile se dopočítáme k pořadí, které má být poslední, skončíme vracením prázdného seznamu.

-- očíslovat prvky od 0
enumerate :: [a] -> [(Integer, a)]
enumerate = zip [0..]

take :: Integer -> [a] -> [a]
take n list = foldr step [] (enumerate list)
    where
        step (pos, x) xs
            | pos == n  = []
            | otherwise = x : xs

Stavy nechť jsou funkce

Kromě pravého foldu jsme zde též využili funkci zip. To se skoro zdá, že jsme prohráli. Nezoufejme, i zip umíme napsat foldem. Nejprve se podívejme na tuto magickou definici.

zip xs ys = foldr guide start xs ys
    where
        start ys = []
        guide x f []     = []
        guide x f (y:ys) = (x, y) : (f ys)

Jak funguje? Počátek je typu [b] -> [c], tudíž návodová funkce je typu [a] -> ([b] -> [c]) -> [b] -> [c]. Z toho vyplývá, že celý foldr start guide xs vrací typ [b] -> [c]. No a protože vracíme dvojici, tak c = (a, b).

Tudíž, jak postupně procházíme seznam xs, vytváříme mnohanásobně složenou funkci, kterou pak aplikujeme na správnou část ys.

> zip [1,2,3]
 \(y:ys) -> (1,y) : ((foldr (...) [2,3]) ys)
 \(y:ys) -> (1,y) : \(y:ys) -> (2,y) : ((foldr (...) [3]) ys)
 \(y:ys) -> (1,y) : \(y:ys) -> (2,y) : \(y:ys) -> (3,y) : ((foldr (...) []) ys)
 \(y:ys) -> (1,y) : \(y:ys) -> (2,y) : \(y:ys) -> (3,y) : ((\y -> []) ys)

> zip [1,2,3] [3,2,1,0]
 \(y:ys) -> (1,y) : \(y:ys) -> (2,y) : \(y:ys) -> (3,y) : ((\y -> []) ys) [3,2,1,0]
 (1,3) : \(y:ys) -> (2,y) : \(y:ys) -> (3,y) : ((\y -> []) ys) [2,1,0]
 (1,3) : (2,2) : \(y:ys) -> (3,y) : ((\y -> []) ys) [1,0]
 (1,3) : (2,2) : (3,1) : ((\y -> []) ys) [0]
 (1,3) : (2,2) : (3,1) : []

Cvičení: Naprogramujte zipWith pomocí pravého foldu.

Stejnou myšlenkou můžeme rovnou naprogramovat take přímo, bez použití zipu. Stav budeme reprezentovat funkcí, která bude brát číslo a vrátí seznam. Konec seznamu nahradíme funkcí, která vždy vrací prázdný seznam. Pak v každém kroku tuto funkci správně složíme.

take :: Integer -> [a] -> [a]
take n xs = foldr guide start xs n
    where
        start ys = []
        guide x f 0     = []
        guide x f n = x : f (n-1)

Struktura návodové funkce se nápadně podobá funkci v zipu, jen jsme rovnou za y dosadili správné číslo.

Když se podíváme na naše foldy blíže, tak jsme sice z čisté rekurze došli k pravému foldu, ale trochu jsme podváděli. Rekurzi jsme si totiž schovali do naší návodové, avšak tentokrát již nerekurzivní, funkce. Fold pak rozepíše explicitně tolik kroků původní rekurze, kolik je potřeba. Tohle je mimochodem dosti podobné způsobu, jak v lambda kalkulu tvořit rekurzivní funkce.

Cvičení: Naprogramujte funkci delete :: Eq a => a -> [a] -> [a], která odstraní první výskyt prvku x ze seznamu, pokud v něm je.

Cvičení: Naprogramujte funkci replaceFirst :: Eq a => a -> a -> [a] -> [a], která nahradí první výskyt prvku x na y, pokud x v seznamu je.

Nakonec ukážeme, že foldr je až tak univerzální, že lze pomocí něj napsat foldl. Znovu použijeme trik s tím, že stav bude funkce. Stejným způsobem můžeme pomocí pravého foldu nadefinovat i jeho striktní variantu. Použijeme totiž operátor $! definovaný jako striktní aplikace na správném místě.

foldl f a list = foldr (\b g x -> g (f x b)) id list a

foldl' f a list = foldr (\b g x -> g $! (f x b)) id list a

Varianty foldů

Ne vždy má nějaká funkce význam pro prázdný seznam nebo není snadné nadefinovat počáteční prvek. Proto si přidáme upravené verze foldů, které jako počáteční prvek použijí první prvek seznamu.

foldr1 :: (a -> a -> a) -> [a] -> a
foldr1 f [] = error "foldr1 on empty list"
foldr1 f (x:xs) = foldr f x xs

foldl1' :: (a -> a -> a) -> [a] -> a
foldl1' f [] = error "foldl1' on empty list"
foldl1' f (x:xs) = foldl' f x xs

Na rozdíl od běžného foldu nyní je stav vždy stejného typu, jako prvky seznamu, takže tyto foldy jsou slabší.

Cvičení: Naprogramujte minimum a maximum typů Ord a => [a] -> a, které pro neprázdné seznamy porovnatelných prvků vrací jejich minimum nebo maximum. Můžete použít funkce min a max typů Ord a => a -> a -> a.

Cvičení: Dává smysl naprogramovat součet nebo součin pomocí jedničkové varianty foldu? Proč ano a proč ne?

Cvičení: Naprogramujte funkci scalarProduct, která spočítá skalární součin dvou vektorů.

Cvičení: Naprogramujte funkci sorted :: Ord a => [a] -> Bool, která kontroluje, že seznam odpovídá seřazené posloupnosti.

Dále se někdy může hodit si zapamatovat všechny mezivýsledky, které při skládání dostaneme. Takové funkce najdeme pod názvem scan. Obdobně, podle typu foldu máme typ scanu.

scanr :: (a -> b -> b) -> b -> [a] -> [b]
scanr _ start []     = [start]
scanr f start (x:xs) = f x q : qs
    where
        (qs@(q:_)) = scanr f start xs

scanr1 :: (a -> a -> a) -> [a] -> [a]
scanr1 _ []     = []
scanr1 _ [x]    = [x]
scanr1 f (x:xs) = f x q : qs
    where
        (qs@(q:_)) = scanr1 f xs

scanl :: (b -> a -> b) -> b -> [a] -> [b]
scanl' :: (b -> a -> b) -> b -> [a] -> [b]
scanl1 :: (a -> a -> a) -> [a] -> [a]
scanl1' :: (a -> a -> a) -> [a] -> [a]

Operátor @ je užitečný operátor použitelný v Pattern Matchingu, který způsobí, že levou stranu rozbalí do pravé strany. Tudíž p@(a,b) odpovídá dvojici p, která se skládá z prvků a a b.

Cvičení: Naprogramujte funkci prefixSum, která vrací prefixové součty na seznamu.

Cvičení: Pomocí scanu na [0,0..] naprogramujte funkci arithSeq :: Integer -> [Integer], která pro k vrací aritmetickou posloupnost [0, k, 2k, ...].

Cvičení: Pomocí scanu na [1,1..] naprogramujte funkci geomSeq :: Integer -> [Integer], která pro k vrací geometrickou posloupnost [1, k, k², ...].


V Haskellu je podstatně lepší psát funkce jako skládání základních funkcí jako map, filter, fold, než přímou rekurzí. Přímá rekurze je totiž srovnatelná s asemblérem a používání goto, zatímco základní funkce představují vyšší konstrukce jako if, while. Obvykle pak programátor udělá méně chyb a lépe to udává pravý úmysl programátora, co vlastně chtěl udělat. Nesmíme to ale přehnat, viz zip.

Podívejme se ještě, jak snadno můžeme díky línému vyhodnocování definovat Fibonacciho čísla.

fib :: [Integer]
fib = 0 : 1 : zipWith (+) fib $ tail fib

Máme rekurzivní definici, která odkazuje sama na sebe. Ale díky línému vyhodnocování budeme vždy znát dostatečně dlouhý prefix fib na to, aby zipWith (+) uměl spočítat následující číslo.

Nakonec si dovolíme poznámku k efektivitě. Haskell umí na pravém foldu dělat optimalizace, kdy se vyhne vytváření meziseznamů, takže programování pomocí foldů dává smysl.