Václav Končický

Seznamy v Haskellu

Seznamy jsou v Haskellu poměrně důležitá struktura. Uvidíme, že se seznamy můžeme dělat mnoho užitečných operací. Nejprve si ukažme, jak si takový seznam můžeme pořídit.

Na rozdíl od seznamů v Prologu mají tyto seznamy určený typ. Tohoto typu pak musí být všechny položky seznamu.

Protože seznamy jsou spojové, můžeme použít nejzákladnější syntaxi pro seznamy, a to prostě zapsat seznam přímo pomocí operátoru (:) a konce seznamu []. Tato syntaxe lze též použít v Pattern Matchingu k tomu, abychom si seznam částečně rozbalili.

Tento zápis je samozřejmě otravný, tak můžeme seznam zapsat výčtem jeho prvků odděleným čárkami.

seznam1 = 3 : 1 : 4 : 1 : 5 : 9 : 2 : []
seznam2 = [3,1,4,1,5,9,2]

Další možností je vytvořit si jako seznam nějakou aritmetickou posloupnost. Pokud napíšeme [a..b], vytvoříme si tím seznam [a, a+1, ..., b]. Pokud bychom chtěli mít aritmetickou posloupnost s jiným krokem k, můžeme zapsat [a,a+k..b]. Pokud nespecifikujeme b, vytvoříme si nekonečný seznam.

> [1..10]
[1,2,3,4,5,6,7,8,9,10]

> [2,4..9]
[2,4,6,8]

> [0..]
-- vypisování donekonečna

Abychom mohli rozumně pracovat s nekonečným seznamem, můžeme si napsat funkci, která vrátí prvních k prvků. Líné vyhodnocování je zde zásadní, protože nebudeme zbytek seznamu potřebovat.

take :: Integer -> [a] -> [a]
take 0 _      = []
take _ []     = []
take k (x:xs)
  | k > 0     = x : (take (k-1) xs)
  | otherwise = error "Negative amount of elements???"

> take 3 [1..10]
[1,2,3]

> take 5 [2,4..]
[2,4,6,8,10]

> take 3 [0,0..]
[0,0,0]

Cvičení: Napište funkci drop :: Integer -> [a] -> [a], která naopak prvních k prvků zahodí.

Cvičení: Napište funkci takeWhile :: (a -> Bool) -> [a] -> [a]), která vrací nejdelší prefix seznamu takový, že pro všechny prvky toho platí podmínka f.

Cvičení: Napište funkci span :: (a -> Bool) -> [a] -> ([a],[a]), která bere predikát p a rozdělí seznam na prefix splňující p a zbytek seznamu, ve kterém první prvek již nesplňuje p.

Cvičení: Napište funkci group :: (a -> Bool) -> [a] -> [[a]], která vezme seznam a vrátí seznam souvislých podposloupností stejných prvků.

> drop 3 [1..5]
[4,5]

> takeWhile (<3) [1,2,3,2,1]
[1,2]

> span (<3) [1,2,3,2,1]
([1,2],[3,2,1])

> group [1,1,2,3,2,2,1,1,1]
[[1,1],[2],[3],[2,2],[1,1,1]]

Pokud bychom z nějakého důvodu nemohli nebo nechtěli použít Pattern matching k rozbalení seznamu, můžeme místo toho použít funkce head a tail, které respektive vrací hlavu a zbytek seznamu.

head (x:_) = x
tail (_:l) = l

> head (tail [1,3,4])
3

Cvičení: Napište funkci last :: [a] -> a, která pro konečný seznam vrátí jeho poslední prvek.

Též můžeme dva seznamy spojovat, k tomu máme k dispozici operátor (++) :: [a] -> [a] -> [a]. Časová složitost této funkce je lineární v délce prvního seznamu: musíme dojít jeho konec, než k němu můžeme připojit ten druhý. Díky línému vyhodnocování však nevadí, když je druhý seznam nekonečný.

> take 5 ([5,7,3] ++ [10..])
[5,7,3,10,11]

Filtrujeme, mapujeme, zipujeme...

Myšlenka nad prácí se seznamy ve funkcionálním programování spočívá v tom, že máme k dispozici obecné funkce, které pomocí jiných funkcí transformují seznamy. Velká část práce se seznamy lze pak provést skládáním těchto funkcí.

Jedna taková základní funkce je map, jenž aplikuje funkci na každý prvek seznamu. Tato funkce může změnit typ výsledného seznamu.

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

> map (\x -> x * 2) [1..10]
[2,4,6,8,10,12,14,16,18,20]

> map (2*) [1..10]
[2,4,6,8,10,12,14,16,18,20]

> map (\x -> x `mod` 3 == 0) [1..10]
[False,False,True,False,False,True,False,False,True,False]

Cvičení: Napište funkci mocniny :: Integer -> [Integer], která vrátí nekonečný seznam mocnin celého čísla k.

Pokračujme dál. Nyní mějme nějakou funkci reprezentující predikát, tudíž vracející Bool. Pak máme nějaký seznam a chceme z něj zachovat pouze ty prvky, pro které predikát platí. Tuto operaci zachytíme funkcí filter.

filter :: (a -> Bool) -> [a] -> [a]
filter _ []   = []
filter p (x:xs)
  | p x       = x : ys
  | otherwise = ys
    where
        ys = filter p xs

Zde jsme použili syntaktickou konstrukci where, která umožňuje vytvořit novou proměnnou a pak specifikovat, co tato proměnná reprezentuje. Tím se můžeme vyhnout kopírování kódu. Podobně jako stráže, i tato konstrukce musí být odsazená.

Filtrování na rozdíl od takeWhile neumí pracovat s nekonečnými seznamy. Stále však díky línému vyhodnocování můžeme omezit rozsah filtrování.

> filter (<4) [3,1,4,1,5,9,2]
[3,1,1,2]

> filter (<10) [1..]
-- nikdy neskončí

> take 3 (filter (\x -> x `mod` 10 == 0) [1..])
[10,20,30]

Cvičení: Napište funkci slozene :: Integer -> Bool, která o přirozeném čísle řekne, zda je složené.

Cvičení: Napište funkci prvocisla :: [Integer], která vrátí seznam prvočísel. Může se hodit funkce not obracející výsledek Bool.

Tato funkce nám stačí k tomu, abychom mohli napsat třídění QuickSortem. Stačí, abychom vyfiltrovali prvky menší, než pivot a větší, než pivot, do dvou různých seznamů. Pak tyto dva seznamy prostě spojíme s pivotem ve správném pořadí.

sort :: Ord a => [a] -> [a] -- a je porovnatelný typ
sort []    = []
sort (p:l) = (sort lt) ++ p:(sort geq)
    where
        lt = filter (<p) l
        geq = filter (>=p) l

Cvičení: Vytvořte funkci toOrderedSet, která vezme seznam a vrátí seřazený seznam jeho unikátních prvků. Může se vám hodit group.

Podívejme se ještě na funkci zip. Pro její význam si představme, co dělá zip v realitě. V řeči seznamů to znamená, že máme dva seznamy a chceme z nich sestavit seznam dvojic. Délka seznamu dvojic bude odpovídat tomu kratšímu ze seznamů.

zip :: [a] -> [b] -> [(a,b)]
zip [] _ = []
zip _ [] = []
zip (x:xs) (y:ys) = (x,y) : zip xs ys

> zip [1..3] ['a','b','c']
[(1,'a'),(2,'b'),(3,'c')]

> zip [1..] ['a','b','c']
[(1,'a'),(2,'b'),(3,'c')]

Cvičení: Napište funkci unzip :: [(a,b)] -> ([a], [b]) dělající pravý opak funkce zip.

Často se však stává, že chceme dva seznamy spojit nějakou operací, třeba je sečíst po složkách. K tomuto účelu si napíšeme funkci zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]. Protože již máme všechny části skládačky k dispozici, stačí je poskládat dohromady.

-- Převod funkce dvou parametrů na funkci beroucí jednu dvojici
uncurry :: (a -> b -> c) -> (a, b) -> c
uncurry f (x,y) = f x y

zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
zipWith f as bs = map (uncurry f) (zip as bs)

> zipWith (+) [1..3] [10,20..]
[11,22,33]

Funkce uncurry se tak jmenuje, protože z víceparametrové funkce přes currying děláme "víceparametrovou" funkci, která potřebuje všechny parametry najednou (jako u procedurálních jazyků) jako jednu dvojici.

Cvičení: Napište funkci curry, která z funkce beroucí dvojici udělá funkci beroucí dva parametry.

Cvičení: Napište zipWith pomocí přímé rekurze.

Zaplatíme si za možnost nepoužití závorek

Aplikace funkce má vysokou prioritu. To je nám často na škodu, protože pak musíme psát závorky, pokud chceme skládat výrazy. Kdyby tak jen existovala možnost, jak se psaní mnoha závorek vyhnout...

Představme si operátor $. Co dělá? Podívejme se na jeho definici: f $ x = f x. Tedy $ je alternativní zápis aplikace. Může se zdát, že je zbytečný. Jeho užitečnost však spočívá ve velmi malé prioritě (menší, než běžná aplikace). Tudíž si "zaplatíme" to, že pak nemusíme používat závorky.

Ukažme si nyní nějaké příklady toho, jak může být $ užitečný. Postupně si ukážeme různé funkce bez a následně s tímto operátorem, abychom ten rozdíl viděli.

> take 5 ([5,7,3] ++ [10..])
[5,7,3,10,11]

> take 5 $ [5,7,3] ++ [10..]
[5,7,3,10,11]

> take 5 (map (*2) (tail [0..10]))
[2,4,6,8,10]

> take 5 $ map (*2) $ tail [0..10]
[2,4,6,8,10]

> head (tail (tail (tail (tail [1..10]))))
5

> head $ tail $ tail $ tail $ tail [1..10]
5

> map (\x -> negate (abs x)) [-3,-1,1,3]
[-3,-1,-1,-3]

> map (\x -> negate $ abs x) [-3,-1,1,3]
[-3,-1,-1,-3]

Jak vidíme, $ umí snadno nahradit závorky od dané chvíle až do konce výrazu. Není to však příme nahrazení závorek, protože je to stále alternativní zápis za aplikaci. Pozor na nesprávná použití:

> map ((+) 2) (tail [1..5])
[4,5,6,7]

> map ((+) 2) $ tail [1..5]
[4,5,6,7]

> map $ ((+) 2) $ tail [1..5]
error:
    • Couldn't match expected type ‘a -> b’
          with actual type ‘[Integer]’
    • In the second argument of ‘($)’, namely ‘(+) 2 $ tail [1 .. 5]’

> map $ ((+) 2) (tail [1..5])
error:
    • Couldn't match expected type ‘a -> b’
          with actual type ‘[Integer]’
    • Possible cause: ‘(+)’ is applied to too many arguments
      In the second argument of ‘($)’, namely ‘((+) 2) (tail [1 .. 5])’

Skládání funkcí

V Haskellu můžeme též funkce skládat v matematickém významu. Máme funkce f a g, a chceme, aby platilo f.g(x) = f(g(x)). Takové skládání můžeme zapsat přímočaře v Haskellu, dokonce existuje jako operátor (.).

(.) :: (b -> c) -> (a -> b) -> a -> c
f . g = \x -> f (g x)

Je důležité si všimnout, že tento operátor je asociativní zprava a vyhodnocování funkcí probíhá zprava doleva.

Z definice skládání vyplývá, že kdykoliv máme výraz tvaru f (g x), můžeme jej nahradit výrazem (f . g) x. Tím dostaneme krásný bonus. Skládání nyní nemusíme psát jako lambdu s explicitním parametrem. Ukažme si to znovu na příkladech:

> map (\x -> negate (abs x)) [-3,-1,1,3]
[-3,-1,-1,-3]

> map (\x -> (negate . abs) x) [-3,-1,1,3]
[-3,-1,-1,-3]

> map (negate . abs) [-3,-1,1,3]
[-3,-1,-1,-3]

Zde jsme jako argument mapu použili složení negate a abs. Původně jsme museli vytvořit novou lambdu reprezentující toto složení. Použitím (.) jsme se však dostali na výraz \x -> f x, což je prostě f.

Pomocí skládání funkcí pak můžeme alternativně zapsat zipWith.

zipWith f as bs = (map . uncurry) f $ zip as bs

Abychom pochopili, proč toto funguje, podívejme taktéž se postupně na typy.

(.) :: (b -> c) -> (a -> b) -> a -> c
map :: (u -> v) -> [u] -> [v]
-- b -> c | b = (u -> v), c = ([u] -> [v])
uncurry :: (k -> l -> m) -> ((k, l) -> m)
-- a -> b | a = (k -> l -> m), b = ((k, l) -> m)
-- u = (k, l), v = m

(map.uncurry) :: a -> c
(map.uncurry) :: (k -> l -> m) -> ([u] -> [v])
(map.uncurry) :: (k -> l -> m) -> ([(k, l)] -> [m])

Skládáním jsme vytvořili novou funkci, která očekává funkci o dvou parametrech a seznam dvojic. Funkci dáváme jako argument zipWith a seznam dvojic pak doplníme přes zip as bs.

Cvičení: Zkuste napsat toOrderedSet tak, že využívá jen skládání a částečnou aplikaci, tedy nemá žádné lambdy ani explicitní parametry.

Množinový zápis seznamu

Seznamy též můžeme tvořit pomocí množinového zápisu z jiných seznamů.

Mějme zápis tvaru {f(x) | x ∈ a, g(x)}. Tento zápis říká, že chceme všechny f(x) takové, že x je prvkem seznamu a a platí g(x). To je v Haskellu ekvivalentní zápisu [f x | x <- a, (g x) = 1].

Výhodou tohoto zápisu je, že můžeme specifikovat více proměnných z různých jiných seznamů. Takovým zápisem dostaneme kartézský součin.

> [2*x | x <- [1..10]]
[2,4,6,8,10,12,14,16,18,20]

> [x + y | x <- [10,20,40], y <- [5,7]]
[15,17,25,27,45,47]

> [(x, y) | x <- [2..10], y <- [3,5], x `mod` y == 0]
[(3,3),(5,5),(6,3),(9,3),(10,5)]

Pokud používáme prvky pouze z jednoho zdrojového seznamu, je tento zápis v podstatě ekvivalentní složení funkcí map a filter.

Cvičení: Napište funkci vracející pro přirozené číslo k seznam všech pytagorejských trojic x, y, z: x² + y² = z², kde x ≤ y ≤ z ≤ k.