Tvoření vlastních typů
Doteď jsme psali funkce, které pracovaly se standardními typy -- skaláry jako čísla či Booly; uspořádané k-tice nebo seznamy. To nám asi stačí k tomu, abychom naprogramovali cokoliv. Uznejme však, kdo to má pak číst? Pracovalo by se nám lépe, kdybychom si mohli definovat vlastní typy. Potom funkce budou vědět, s čím pracují a ohlídají nám, že pracují s tím správným.
Nejzákladnější způsob, jak si pořídit nové typy, je vytvoření aliasu za již
existující typ. Ten vytvoříme konstrukcí type Alias = For
. Nový typ musí
začínat velkým písmenem.
Typové aliasy můžeme i parametrizovat přidáním typové proměnné k aliasu, kterou pak lze použít uvnitř aliasovaného typu.
-- Řetězec je seznam Charů type String = [Char] type Indexed a = (Integer, a)
Jakmile si vytvoříme typový alias, můžeme jej použít v typové signatuře funkce. Tyto aliasy však nejsou typy v pravém smyslu slova. Chovají se opravdu jen jako aliasy a mají hlavně dokumentační hodnotu.
Typ String
je takto definovaný i ve standardní knihovně a jazyk má pro tento
typ (respektive pro typ [Char]
) speciální typ zápisu, díky kterému můžeme
řetězec zapsat mezi uvozovkami stejně, jako v jiných jazycích. Tento zápis je
použit i pro vypisování tohoto typu.
-- Funkce převádějící malá písmena na velká toUpper :: Char -> Char uppercase :: String -> String uppercase = map toUpper -- Identická funkce id :: a -> a id x = x listToString :: [Char] -> String -- Tato definice je v pořádku, protože [Char] a String je to samé listToString = id enumerate :: [a] -> [Indexed a] enumerate = zip [0..] > :t "ahoj" "ahoj" :: [Char] > uppercase "ahoj" "AHOJ" > :t uppercase "ahoj" uppercase "ahoj" :: String > :t reverse $ uppercase "ahoj" reverse $ uppercase "ahoj" :: [Char]
Abstraktní datové typy
Je na čase ukázat, jak se v Haskellu tvoří pravé nové typy a jak se s nimi pracuje. Takto zavedené typy se nazývají abstraktní datové, protože definujeme jejich sémantiku z pohledu uživatele, tedy jakých hodnot mohou nabývat. Nic pak neříkáme o tom, jak vypadají uvnitř konkrétně.
Začněme tedy definicí jednoduchého ADT, které prostě nabývá různých hodnot, řekněme třeba barvy. Chceme rozlišovat sedm různých barev duhy. V Haskellu tuto definici vytvoříme následovně:
data Colour = Red | Orange | Yellow | Green | Blue | Indigo | Violet deriving (Show, Eq)
Řádek deriving (Show, Eq)
říká, že náš nový typ lze vypsat jako výstup
vyhodnocení a lze jej porovnávat rovností. K tomu se vrátíme později.
V definici typu jsme použili slova začínající velkým písmenem jak u názvu typu, tak u hodnot. Tyto hodnoty jsou pak konstruktory, což jsou speciální funkce vracející (konstruující) hodnotu daného typu. Typy a konstruktory pochází z jiného kontextu, takže můžeme použít stejný název pro typ i konstruktor.
Všimněme si, že například Bool
je korektní ADT: může nabývat hodnoty True
nebo False
. Nijak nezatěžujeme programátora tím, zda se jedná o nějaké
jednobitové číslo nabývající 1 pro hodnotu True
. Podobně i Int32
si můžeme
představit jako ADT, které nabývá hodnot 0, 1, -1, a tak dále pro všechna
třicetidvoubitová čísla.
data Bool = True | False data Int32 = -2147483648 | -2147483647 | ... | -1 | 0 | 1 | ... | 2147483647
Nyní, protože máme definovaný náš typ, můžeme s ním pracovat jako s každým jiným typem a využívat Pattern matching.
-- Je barva teplá? warm :: Colour -> Bool warm Red = True warm Orange = True warm Yellow = True warm _ = False cold = not . warm > Blue Blue > warm Red True > cold Blue True > :t Green Green :: Colour > :t Colour error: Data constructor not in scope: Colour
Cvičení: Ukažte, jak byste naprogramovali funkci colourHue :: Colour ->
Int
, která vrací odstínovou složku popisu
HSV.
Zatím tedy umíme tvořit typy, které mohou nabývat právě jedné konkrétní hodnoty. Co kdybychom však chtěli mít složený typ, který třeba v sobě obsahuje i hodnoty jiných typů?
Vytvořme si třeba typ jméno, který odpovídá křestnímu jménu a přijmení. Pomiňme teď fakt, že lidi mohou mít více křestních jmen, nebo žádné přijmení a další takové podivnosti.
data Name = Name String String deriving (Show, Eq) -- Funkce vracející křestní jméno firstName :: Name -> String firstName (Name f _) = f -- Funkce vracející přijmení surname :: Name -> String surname (Name _ s) = s nameStr :: Name -> String nameStr (Name f s) = f ++ " " ++ s > firstName (Name "Jan" "Novák") "Jan" > nameStr (Name "Jan" "Novák") "Jan Novák" > :t Name Name :: String -> String -> Name
Zde můžeme vidět, že konstruktor je nyní funkce o dvou parametrech a je stejného názvu, jako samotný typ. Protože kontexty názvu funkcí a názvu typů jsou odlišné, tak nám to prošlo.
Cvičení: Napište funkci createNames :: [String] -> [String] -> [Name]
,
která vytvoří všechny kombinace jmen z daných křestních jmen a příjmení.
Velmi často je náš účel pro vytvoření nového typu to, abychom si vytvořili složenou strukturu a uměli přistupovat ke každé její složce. To přesně děláme pro případ typu pro jména. Pokud navíc máme těch složek mnoho a chceme přistupovat jen k některé, bude celkem otrava psát spoustu podtržítek nebo strávíme spoustu času vytvářením přistupových funkcí.
Proto přesně pro tento způsob užívání typu jako struktury máme k dispozici record syntaxi, kde každou složku rovnou pojmenováváme, a tím automaticky vytvoříme i přístupové funkce. Navíc budeme moct i v Pattern matchingu požádat jen malou část složek.
data Gender = Male | Female | Unknown deriving (Show) data Person = Person { nameP :: Name, age :: Int, gender :: Gender, } deriving (Show, Eq) adult :: Person -> Bool adult p = (age p) >= 18 -- Přístupové funkce máme automaticky senior :: Person -> Bool senior (Person { age = y }) = y >= 65 -- Můžeme částečně rozbalit strukturu youngChild :: Person -> Bool youngChild (Person _ y _) = y < 6 -- Původní syntaxe stále existuje rename :: Person -> Name -> Person rename p n = p { nameP = n } -- Můžeme částečně přepsat strukturu title :: Person -> String title p = case gender p of Male -> "Mr. " Female -> "Ms. " Unknown -> "" > :t Person Person :: Name -> Int -> Gender -> Person > rename $ Person (Name "Jan" "Novák") 30 Male $ Name "Petr" "Novák" Person { nameP = Name "Petr" "Novák", age = 30, gender = Male }
Konstrukce case ... of
je podobná strážím, která umožňuje volit větev výpočtu
podle toho, jakou strukturu má její vstup, jenž je libovolný výraz. Výběr větve
pak probíhá Pattern matchingem podobně, jako u výběru varianty funkce.
Cvičení: Uvažte datum (den, měsíc a rok) a ukažte, jak byste jej reprezentovali jako typ v Haskellu.
Obdobně, jako u aliasů, lze i ADT parametrizovat. Navíc, každá hodnota může být jinak složená, takže není problem kombinovat samostatné a složené hodnoty. Tak můžeme třeba vytvořit vlastní uspořádanou dvojici. Dokonce máme možnost reprezentovat něco, co může nabývat dvou různých typů.
data Pair a b = Pair a b deriving (Eq, Show) data Either a b = Left a | Right b deriving (Eq, Show) first :: Pair a b -> a first (Pair a _) = a second :: Pair a b -> b second (Pair _ b) = b swapPair :: Pair a b -> Pair b a swapPair (Pair a b) = Pair b a isLeft :: Either a b -> Bool isRight :: Either a b -> Bool isLeft (Left _) = True isLeft (Right _) = False isRight = not . isLeft > :t Pair Pair :: a -> b -> Pair a b > :t Pair True Pair True :: b -> Pair Bool b > :t Left Left :: a -> Either a b > :t Right Right :: b -> Either a b
Jako poznámku si dovolíme to, že ADT může tež označovat algebraické datové
typy, protože každý ADT můžeme technicky reprezentovat jako algebraickou
strukturu užívající prázdný typ ()
, jiné existující typy, Pair
a Either
.
type WeirdBool = Either () () -- True = Left (), False = Right () type WeirdPerson = Pair (Pair Name Int) (Either (Either () ()) ())
Typy mohou být též rekurzivní, tedy že jako součást složené hodnoty bude jiná hodnota stejného typu. Tak třeba můžeme utvořit seznamy.
data List a = Empty | Cons a (List a) deriving (Show, Eq) my_null :: List a -> Bool my_null Empty = True my_null (Cons _ _) = False my_map :: (a -> b) -> List a -> List b my_map _ Empty = Empty my_map f (Cons x xs) = Cons (f x) (my_map f xs)
Cvičení: Ukažte, že List a
je ekvivalentní typu [a]
. Pak definujte
take
, filter
, zip
,foldr
a libovolné další funkce pracující se seznamy
pro List a
.
Cvičení: Naprogramujte funkce fromStdList :: [a] -> List a
a toStdList
:: List a -> [a]
.
Vlastnosti typů a typové třídy
Často se nám stává, že mnoho naších typů splňuje společné vlastnosti. Příklad takových vlastností je to, že typy jsou porovnatelné, nebo číselné. Tyto vlastnosti můžeme formalizovat užitím typové třídy. Pozor, nejde o třídy v běžném smyslu procedurálních jazyků, mnohem blíže jde o role nebo interface.
Součástí typové třídy jsou funkce, které každý typ patřící do této třídy musí splňovat. Navíc, pokud některé funkce vyplývají z jiných, můžeme je rovnou definovat. Zkusme si tedy nadefinovat vlastní typovou třídu pro objekty, u kterých umíme porovnávat rovnost.
class MyEq a where eq, neq :: a -> a -> Bool neq x y = not (eq x y)
Máme tedy nadefinovanou typovou třídu. Teď bychom chtěli říct, že některé typy jsou součástí této typové třídy. Současně řekneme, jak spočítat funkce dané touto třídou. Pak tyto funkce můžeme bezpečně použít pro typy, které jsou součástí této třídy.
-- Tvary: Čtverec nebo obdélník data Shape = Square Int | Rectangle Int Int instance MyEq Shape where eq (Square a) (Square b) = a == b eq (Rectangle a b) (Rectangle c d) = (a == c) && (b == d) eq (Square a) (Rectangle b c) = (a == b) && (a == c) eq (r@(Rectangle _ _)) (s@(Square _)) = eq s r > eq (Square 3) (Square 3) True > eq (Rectangle 5 5) (Square 5) True
Typové třídy též můžeme využít k dalšímu důležitému účelu, a to omezení typů dle typové třídy. Můžeme tak třeba utvořit funkce, pro kterou vstup musí splňovat určité vlastnosti. S tímto užitím už jsme se mnohokrát setkali.
-- Je prvek uvnitř seznamu? myElem :: (MyEq a) => a -> [a] -> Bool myElem _ [] = False myElem y (x:xs) | eq y x = True | otherwise = myElem y xs > myElem (Square 1) [Square 2, Rectangle 3 1, Rectangle 1 1] True
Pokud potřebujeme, aby byl typ součástí více typových tříd, oddělíme tyto třídy
čárkou před =>
.
Omezení na typovou třídu můžeme použít, kdekoliv bereme typové proměnné. Pokud omezíme parametr u typové třídy, vytvoříme si typovou podtřídu.
Pokud naopak užijeme omezení při instanciování typové třídy, omezíme tím parametry samotného typu, které mohou být instancí třídy. Pak můžeme mít různé parametrizace typu, kde některé jsou a jiné naopak nejsou ve třídě.
instance (MyEq a) => MyEq List a where eq Empty Empty = True eq (Cons a as) (Cons b bs) = (eq a b) && (eq as bs) eq _ _ = False > eq (Cons (Square 1) Empty) (Cons (Rectangle 1 1) Empty) True > eq (Cons True (Cons False Empty)) (Cons True Empty) error: • No instance for (MyEq Bool) arising from a use of ‘eq’ class (MyEq a) => MyOrd a where lt, gt, leq, geq :: a -> a -> Bool my_max, my_min :: a -> a -> a leq a b = (eq a b) || (lt a b) gt a b = lt b a geq a b = leq b a
Cvičení: Vezměte typ Shape
a definujte pro něj lt
, které porovnává
obsahy.
Výhoda definice podtříd je, že všechna omezení při definici podtřídy se
kaskádují. Speciálně, kdykoliv pak přidáme omezení podtřídy, automaticky se
přidá i omezení nadtřídy. Tudíž, pokud chceme porovnávání rovností i
nerovností, nemusíme psát (MyEq a, MyOrd a) =>
, ale stačí MyOrd a =>
.
Cvičení: Naprogramujte funkci sort :: (MyOrd a) => List a -> List a
,
který setřídí seznam porovnatelných prvků.
Omezení na typové třídy lze přidat i při definici ADT, ale důrazně to není doporučeno. Když totiž tohle uděláme, tak všechny funkce, které budou chtít s tímto ADT pracovat, budou muset toto omezení splňovat taky, i když toto omezení nikdy nepoužijí.
Derivovatelné třídy
Ve standardní knihovně existuje několik základních typových tříd, které lze
použít v konstrukci deriving
. Pojďme si ukázat, o které jde a jak se
derivují.
Show
je třída typů, které lze vypsat na výstup. Definuje jedinou funkcishow :: a -> String
. Derivace pak funguje tak, že nejprve vypíše konstruktor, a pak rekurzivně vypíše všechny jeho parametry, oddělené mezerou.Read
je komplementární třída kShow
, která naopak definuje funkciread :: String -> a
. I zde se nejprve přečte konstruktor a následně rekurzivně jeho parametry oddělené mezerou.Eq
definuje operátory(==)
a(/=)
. Derivace pak kontroluje, že jde o stejný konstruktor a rekurzivně porovná parametry.Ord
je podtřídaEq
a definuje porovnávací operátory(<)
,(>)
,(<=)
,(>=)
,min
max
. Různé konstruktory se porovnávají v pořadí zleva doprava a pro stejné konstruktory se pak porovnávají rekurzivně jejich parametry. Tudíž prodata D = A | B | C
bude platitA < C
aB > A
.Enum
je třída typů, které lze vypsat za sebou. Tato třída poskytuje funkcesucc
apred
dávající následující a předchozí prvek, funkcetoEnum
afromEnum
převádějící výčet mezi číslem a funkce vracející aritmetické posloupnosti. Derivace očekává pouze výčet prvků, které zleva doprava očísluje 0 až n.Bounded
je třída typů, které mají horní a dolní mezminBound
amaxBound
. Derivace funguje pouze na výčtu prvků, kdy jsou meze dané prvním nebo posledním prvkem; nebo na typu s jediným konstruktorem, kdy se aplikuje rekurzivně.
Cvičení: Uvažme typ pro datum z dřívějška. Pokud mu přidáne deriving Ord
,
bude správně porovnávat, které z dat bylo dříve?
Cvičení: Znovu uvažme datum. Může být součástí typových tříd Enum
nebo
Bounded
? Pokud ano, jak by se měl chovat vzhledem k těmto třídám?
Pokud se stane, že některý z parametrů není součástí typové třídy, tak derivace selže a tato konkrétní parametrizace typu nebude součástí typové třídy.
U funkcí, které pracují s typovými třídami se často může stát, že existuje více
typů splňující danou třídu, a pak je nejednoznačné, který typ chceme. To se
třeba stává u funkce read
nebo minBound
. Pak musíme tento typ upřesnit
užitím čtyřtečky.
Na závěr si ukažme takový velmi užitečný typ Maybe
, který bude reprezentovat
null
ovatelný typ z běžných jazyků.
data Maybe a = Nothing | Just a deriving (Show, Read, Eq, Ord)
Jak můžeme tento typ použít? Uvažme třeba funkci head
. Ta pro prázdné seznamy
vyhodí chybu, protože hlava pro něj není definovaná. Pomocí Maybe
ale můžeme
udělat variantu, která nevrátí nic, pokud je seznam prázdný.
headSafe :: [a] -> Maybe a headSafe [] = Nothing headSafe (x:_) = Just x > headSafe [1,3,5] Just 1 > headSafe [] Nothing
Cvičení: Ukažte další příklad funkcí, kde se může vyplatit vracet Maybe
.
Aby se nám s Maybe
pracovalo lépe, můžeme si definovat obecné funkce:
-- Vytáhni hodnotu z Maybe fromJust :: Maybe a -> a fromJust (Just x) = x fromJust Nothing = error "Tried to call fromJust on Nothing" -- Vem hodnotu z Maybe, nebo jinak vrať něco jiného orElse :: Maybe a -> a -> a orElse (Just x) _ = x orElse Nothing y = y -- Pokud existuje hodnota v Maybe, něco s ní proveď, jinak nic nevrať andThen :: Maybe a -> (a -> Maybe b) -> Maybe b andThen Nothing _ = Nothing andThen (Just x) f = f x headDefault l d = (headSafe l) `orElse` d > headDefault [5] 0 5 > headDefault [] 0 0 > (headSafe [1,2,3]) `andThen` (\x -> Just 2*x) Just 2 > (headSafe []) `andThen` (\x -> Just 2*x) Nothing
Cvičení: Naprogramujte funkci indexOf :: Eq a => a -> [a] -> Maybe
Integer
, která vezme prvek a vrátí index jeho prvního výskytu. Pokud se v
seznamu nevyskytuje, nevrátíme nic.
Cvičení: Naprogramujte funkci maybeZip :: [a] -> [b] -> [(Maybe a, Maybe
b)]
, která se chová jako zip
pro stejně dlouhé seznamy a pro různě dlouhé
seznamy pak po dojití na konec kratšího seznamu bude vracet na daném místě
Nothing
.
Cvičení: Naprogramujte funkci takeExactly :: Integer -> [a] -> Maybe [a]
,
která vrátí prvních k prvků seznamů pouze, pokud je seznam dost dlouhý.
Cvičení: Naprogramujte funkci takeDefault :: Integer -> a -> [a] -> [a]
,
která v případě, že dřív dojdou prvky seznamu, vrátí vychozí prvek.