Václav Končický

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í.

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 nullovatelný 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.