Haskell
Dokumentacja Scalaz często odwołuje się do bibliotek lub artykułów używających Haskella jako języka programowania. W tym krótkim rozdziale poznamy jego podstawy, tak, aby móc zrozumieć wspomniane materiały oraz móc uczestniczyć w Haskellowych prezentacjach na konferencjach o programowaniu funkcyjnym.
Dane
Haskell ma bardzo schludną składnię dla ADT. Oto tradycyjna lista:
data List a = Nil | Cons a (List a)
List jest konstruktorem typu, a to parametr typu, a | rozdziela konstruktory danych, które w tym wypadku to:
Nil czyli pusta lista oraz Cons czyli komórka listy. Cons przyjmuje dwa parametry, które są rozdzielone białym znakiem. Nie ma
tu przecinków ani nawiasów.
W Haskellu nie ma też podtypowania, więc nie ma czegoś takiego jak typ Nil
lub Cons, oba tworzą typ List.
Przybliżone tłumaczenie na Scalę:
sealed abstract class List[A]
object Nil {
def apply[A]: List[A] = ...
def unapply[A](as: List[A]): Option[Unit] = ...
}
object Cons {
def apply[A](head: A, tail: List[A]): List[A] = ...
def unapply[A](as: List[A]): Option[(A, List[A])] = ...
}
A więc, konstruktor typu to odpowiednik sealed abstract class a każdy z konstruktorów danych
to para .apply/.unapply. Warto zauważyć, że przy takim kodowaniu Scala nie jest w stanie sprawdzić
czy pattern matching jest wyczerpujący, i dlatego też nie jest ono używane w Scalaz.
Możemy też użyć infiksu, tworząc ładniejszą definicję z :. zamiast Cons
data List t = Nil | t :. List t
infixr 5 :.
Specyfikujemy tutaj fixity, które może przyjąć jedną z wartości infix, infixl lub infixr, które oznaczają odpowiednio
brak łączności, łączność lewostronną i prawostronną. Liczby od 0 do 9 określają priorytet operacji.
Możemy teraz stworzyć listę liczb całkowitych za pomocą
1 :. 2 :. Nil
Haskell posiada już definicję listy i jest ona na tyle ważna dla programowania funkcyjnego, że zasłużyła
na osobną składnię zdefiniowaną na poziomie języka: [a].
data [] a = [] | a : [a]
infixr 5 :
oraz wygodny wielowartościowy konstruktor [1, 2, 3] zamiast 1 : 2 : 3 : [].
Koniec końców nasze ADT musi przechowywać wartości prymitywne. Najpopularniejsze z nich to:
-
Char- znak unikodu -
Text- blok tekstu opartego o unikod -
Int- zależna od sprzętu liczba całkowita ze znakiem o stałej precyzji -
Word- liczba całkowita bez znaku z wariantami o stałym rozmiarzeWord8/Word16/Word32/Word64 -
Float/Double- liczby zmiennoprzecinkowe o pojedynczej i podwójnej precyzji, zgodne z IEEE -
Integer/Natural- liczba całkowita o arbitralnej precyzji. Ze znakiem oraz dodatnia. -
(,)- tuple o rozmiarach od 0 do 62 -
IO- inspiracja dlaIOw Scalaz, implementowana na poziomie środowiska uruchomieniowego
z honorowym miejscem dla
data Bool = True | False
data Maybe a = Nothing | Just a
data Either a b = Left a | Right b
data Ordering = LT | EQ | GT
Haskell, podobnie jak Scala, pozwala na definiowanie aliasów typów. Alias i jego rozwiniętą
forma mogą być używane zamiennie. Z powodów historycznych String zdefiniowany jest alias na listę
Charów
type String = [Char]
co jest reprezentacją bardzo niewydajną i dlatego właśnie powinniśmy używać typu Text.
Możemy też definiować nazwy pól w ADT używając składni rekordów, co oznacza umieszczenie konstruktorów danych w klamrach i dodanie do pól anotacji typów za podwójnym dwukropkiem aby określić ich typ.
-- raw ADT
data Resource = Human Int String
data Company = Company String [Resource]
-- with record syntax
data Resource = Human
{ serial :: Int
, humanName :: String
}
data Company = Company
{ companyName :: String
, employees :: [Resource]
}
Zauważ, że konstruktor danych Human i typ Resource nie muszą mieć tej samej nazwy.
Rekordy generują dla nas metody pozwalające na dostęp do pól i łatwe kopiowanie danych.
-- construct
adam = Human 0 Adam
-- field access
serial adam
-- copy
eve = adam { humanName = "Eve" }
Bardziej wydajną alternatywą dla pojedynczych definicji danych jest użycie słowa kluczowego
newtype, które nie niesie ze sobą żadnego narzutu wydajnościowego:
newtype Alpha = Alpha { underlying :: Double }
Jest to odpowiednik extends AnyVal, tyle że bez żadnych haczyków.
Funkcje
Mimo że nie jest to konieczne, to dobrą praktyką jest opatrywanie funkcji sygnaturami typu,
co wyrażane jest jako nazwa funkcji za którą podąża jej typ. Dla przykładu, oto foldl wyspecjalizowany
dla listy
foldl :: (b -> a -> b) -> b -> [a] -> b
Wszystkie funkcje w Haskellu są domyślnie rozwinięte (curried), parametry rozdzielone są `->, a ostatni typ to typ zwracany przez funkcję. Powyższy przykład to odpowiednik Scalowej sygnatury
def foldLeft[A, B](f: (B, A) => B)(b: B)(as: List[A]): B
Tyle, że:
- bez słów kluczowych
- bez deklaracji typów które wyprowadzamy
- bez nazywania parametrów
Wszystko to sprawia, że kod jest bardziej zwięzły.
Funkcje infiksowe definiowane są w nawiasach i wymagają określenia fixity:
(++) :: [a] -> [a] -> [a]
infixr 5 ++
Zwykłe funkcje mogą być wołane w pozycji infiksowej poprzez otoczenie nazwy apostrofami, a infiksowe możemy wywoływać tak jak normalne jeśli pozostawimy nawiasy wokół nazwy. Poniższe wywołania są równoznaczne:
a `foo` b
foo a b
Funkcja infiksowa może być rozwinięta z lewej bądź prawej strony, często dając różną semantykę:
invert = (1.0 /)
half = (/ 2.0)
Funkcje zazwyczaj pisane są tak, aby najbardziej ogólny parametr był na początku, co pozwala zmaksymalizować reużywalność domyślnej rozwiniętej formy.
Definicje funkcji mogą używać dopasowywania wzorców z jedną linią na wariant. W tym miejscu możemy
nazwać nasze parametry używając konstruktorów danych do ich wyekstrahowania, podobnie jak w Scalowej
klauzuli case:
fmap :: (a -> b) -> Maybe a -> Maybe b
fmap f (Just a) = Just (f a)
fmap _ Nothing = Nothing
Podkreślenie służy do ignorowania nieistotnych parametrów. Nazwy funkcji mogą występować w pozycji infiksowej również w definicjach:
(<+>) :: Maybe a -> Maybe a -> Maybe a
Just a <+> _ = Just a
Empty <+> Just a = Just a
Empty <+> Empty = Empty
Funkcje anonimowe (lambdy) definiujemy z pomocą odwrotnego ukośnika, co przypomina grecką literę λ. Poniższe definicje są równoznaczne:
(*)
(\a1 -> \a2 -> a1 * a2)
(\a1 a2 -> a1 * a2)
Funkcje używające dopasowań w Haskellu to tak naprawdę jedynie syntax sugar ponad zagnieżdżonymi lambdami. Rozważmy prostą funkcję, która tworzy tuple z 3 argumentów:
tuple :: a -> b -> c -> (a, b, c)
Jej implementacja
tuple a b c = (a, b, c)
jest rozwijana przez kompilator do
tuple = \a -> \b -> \c -> (a, b, c)
W ciele funkcji możemy tworzyć lokalne przypisania za pomocą klauzul let i where.
Poniższe definicje map dla listy są równoznaczne (apostrof jest poprawnym identyfikatorem):
map :: (a -> b) -> [a] -> [b]
-- explicit
map f as = foldr map' [] as
where map' a bs = f a : bs
-- terser, making use of currying
map f = foldr map' []
where map' a = (f a :)
-- let binding
map f = let map' a = (f a :)
in foldr map' []
-- actual implementation
map _ [] = []
map f (x : xs) = f x : map f xs
if / then / else to słowa kluczowe do definiowania wyrażeń warunkowych:
filter :: (a -> Bool) -> [a] -> [a]
filter _ [] = []
filter f (head : tail) = if f head
then head : filter f tail
else filter f tail
Alternatywnie możemy użyć ograniczeń wariantów (case guards)
filter f (head : tail) | f head = head : filter f tail
| otherwise = filter f tail
Dopasowania dla dowolnego wyrażenia definiujemy używają case ... of
unfoldr :: (a -> Maybe (b, a)) -> a -> [b]
unfoldr f b = case f b of
Just (b', a') -> b' : unfoldr f a'
Nothing -> []
Ograniczenia mogą być używane wewnątrz dopasowań. Możemy, na przykład, chcieć potraktować zera w specjalny sposób:
unfoldrInt :: (a -> Maybe (Int, a)) -> a -> [Int]
unfoldrInt f b = case f b of
Just (i, a') | i == 0 -> unfoldrInt f a'
| otherwise -> i : unfoldrInt f a'
Nothing -> []
Na koniec dwie funkcje warte wspomnienia: ($) i (.)
-- application operator
($) :: (a -> b) -> a -> b
infixr 0
-- function composition
(.) :: (b -> c) -> (a -> b) -> a -> c
infixr 9
Obie są stylistycznymi alternatywami dla zagnieżdżonych nawiasów.
Poniższe wywołania są równoznaczne
Just (f a)
Just $ f a
tak jak i
putStrLn (show (1 + 1))
putStrLn $ show $ 1 + 1
Składanie funkcji za pomocą . jest przez wielu preferowane ponad wielokrotne użycie $
(putStrLn . show) $ 1 + 1
Typeklasy
Aby zdefiniować typeklasę używamy słowa kluczowego class, za którym podąża jej nazwa oraz parametry typu, a wymagane
metody trafiają do klauzuli where. Jeśli między typeklasami istnieje zależność, jak np. w przypadku Applicative i
Functor, to może być ona wyrażona za pomocą notacji =>
class Functor f where
(<$>) :: (a -> b) -> f a -> f b
infixl 4 <$>
class Functor f => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b
infixl 4 <*>
class Applicative f => Monad f where
(=<<) :: (a -> f b) -> f a -> f b
infixr 1 =<<
Implementację danej typeklasy możemy dostarczyć za pomocą słowa kluczowego instance. Jeśli chcielibyśmy
powtórzyć sygnatury metod, co dodaje nieco czytelności, musimy włączyć rozszerzenie języka InstanceSigs.
{-# LANGUAGE InstanceSigs #-}
data List a = Nil | a :. List a
-- defined elsewhere
(++) :: List a -> List a -> List a
map :: (a -> b) -> List a -> List b
flatMap :: (a -> List b) -> List a -> List b
foldLeft :: (b -> a -> b) -> b -> List a -> b
instance Functor List where
(<$>) :: (a -> b) -> List a -> List b
f <$> as = map f as
instance Applicative List where
pure a = a :. Nil
Nil <*> _ = Nil
fs <*> as = foldLeft (++) Nil $ (<$> as) <$> fs
instance Monad List where
f =<< list = flatMap f list
Gdy chcemy skorzystać z typeklasy w funkcji, deklarujemy to za pomocą =>. Możemy na przykład
zdefiniować odpowiednik Apply.apply2 ze Scalaz
apply2 :: Applicative f => (a -> b -> c) -> f a -> f b -> f c
apply2 f fa fb = f <$> fa <*> fb
Skoro wprowadziliśmy już typeklasę Monad to jest to dobry moment na omówienie notacji do,
która była inspiracją dla Scalowej konstrukcji for:
do
a <- f
b <- g
c <- h
pure (a, b, c)
Powyższy kod rozwijany jest do
f >>= \a ->
g >>= \b ->
h >>= \c ->
pure (a, b, c)
gdzie >>= to =<< z parametrami zamienionymi miejscami
(>>=) :: Monad f => f a -> (a -> f b) -> f b
(>>=) = flip (=<<)
infixl 1 >>=
-- from the stdlib
flip :: (a -> b -> c) -> b -> a -> c
a return to synonim do pure.
W przeciwieństwie do Scali, nie musimy przypisywać pustych wartości ani używać yield gdy zwracamy ().
for {
_ <- putStr("hello")
_ <- putStr(" world")
} yield ()
jest odpowiednikiem
do putStr "hello"
putStr " world"
Niemonadyczne wartości mogą być przypisywane z użyciem słowa kluczowego let:
nameReturn :: IO String
nameReturn = do putStr "What is your first name? "
first <- getLine
putStr "And your last name? "
last <- getLine
let full = first ++ " " ++ last
putStrLn ("Pleased to meet you, " ++ full ++ "!")
pure full
Na koniec, Haskell pozwala na derywację typeklas za pomocą słowa kluczowego deriving, a mechanizm ten
był inspiracją dla poznanego przez nas @scalaz.deriving. Definiowanie zasad derywacji to dość zaawansowany
temat, ale sama derywacja dla ADT jest bardzo prosta:
data List a = Nil | a :. List a
deriving (Eq, Ord)
Algebras
In Scala, typeclasses and algebras are both defined as a trait interface.
Typeclasses are injected by the implicit feature and algebras are passed as
explicit parameters. There is no language-level support in Haskell for algebras:
they are just data!
Consider the simple Console algebra from the introduction. We can rewrite it
into Haskell as a record of functions:
data Console m = Console
{ println :: Text -> m ()
, readln :: m Text
}
with business logic using a Monad constraint
echo :: (Monad m) => Console m -> m ()
echo c = do line <- readln c
println c line
A production implementation of Console would likely have type Console IO.
The Scalaz liftIO function is inspired by a Haskell function of the same name
and can lift Console IO into any Advanced Monad stack.
Two additional language extensions make the business logic even cleaner. For
example, RecordWildCards allows us to import all the fields of a data type by
using {..}:
echo :: (Monad m) => Console m -> m ()
echo Console{..} = do line <- readln
println line
NamedFieldPuns requires each imported field to be listed explicitly, which is
more boilerplate but makes the code easier to read:
echo :: (Monad m) => Console m -> m ()
echo Console{readln, println} = do line <- readln
println line
Whereas in Scala this encoding may be called Finally Tagless, in Haskell it is known as MTL style. Without going into details, some Scala developers didn’t understand a research paper about the performance benefits of Generalised ADTs in Haskell.
An alternative to MTL style are Extensible Effects, also known as Free Monad style.
Moduły
Kod źródłowy napisany w Haskellu układa się w hierarchiczne moduły, a kod każdego z nich musi być zawarty z jednym pliku. Pierwsza linia w pliku określa jego nazwę
module Silly.Tree where
Kod organizowany jest według konwencji za pomocą katalogów, tak więc plik ten znajdzie się w Silly/Tree.hs.
Domyślnie wszystkie symbole w pliku są eksportowane, ale możemy kontrolować to zachowanie.
Dla przykładu wyeksportujmy typ Tree wraz z konstruktorami danych i funkcje fringe, a ominiemy
funkcję sapling:
module Silly.Tree (Tree(..), fringe) where
data Tree a = Leaf a | Branch (Tree a) (Tree a)
fringe :: Tree a -> [a]
fringe (Leaf x) = [x]
fringe (Branch left right) = fringe left ++ fringe right
sapling :: Tree String
sapling = Leaf ""
Co ciekawe, możemy eksportować symbole, które są zaimportowane z zewnątrz. Pozwala to autorom bibliotek spakować całe API do jednego modułu niezależnie od tego jak zostało zaimplementowane.
W innym pliku możemy zaimportować wcześniej zdefiniowane Silly.Tree.
import Silly.Tree
co jest równoznaczne ze Scalowym import silly.tree._. Jeśli chcielibyśmy ograniczyć symbole,
które są importowane to wystarczy wymienić je w nawiasach zaraz za nazwą importowanego modułu:
import Silly.Tree (Tree, fringe)
Tutaj importujemy jedynie kontruktor typu Tree (bez konstruktorów danych)
i funkcję fringe. Jeśli chcielibyśmy zaimportować wszystkie konstruktory danych możemy
użyć Tree(..). Jeśli potrzebujemy jedynie Branch to wystarczy to zadeklarować:
Here we only import the Tree type constructor (not the data constructors) and
the fringe function. If we want to import all the data constructors (and
pattern matchers) we can use Tree(..). If we only want to import the Branch
constructor we can list it explicitly:
import Silly.Tree (Tree(Branch), fringe)
Jeśli okaże się że nazwy importowanych symboli kolidują ze sobą możemy rozwiązać ten problem używając importu kwalifikowanego
(qualified) z opcjonalną listą importowanych symboli
import qualified Silly.Tree (fringe)
Teraz by wywołać fringe musimy posłużyć się identyfikatorem Silly.Tree.fringe zamiast zwykłego fringe. Podczas importowania
możemy też zmienić nazwę modułu
import qualified Silly.Tree as T
tym samym fringe jest teraz dostępne jakoT.fringe.
Alternatywnie, zamiast deklarować importowane symbole możemy wybrać to czego nie chcemy importować
import Silly.Tree hiding (fringe)
Domyślnie moduł Prelude jest niejawnie importowany, ale jeśli zaimportujemy go wprost, to tylko
nasza wersja będzie użyta. Możemy użyć tego triku aby ukryć niebezpieczne funkcje
import Prelude hiding ((!!), head)
Możemy też całkowicie się go pozbyć za pomocą rozszerzenia języka NoImplicitPrelude.
Ewaluacja
Haskell kompiluje się do kodu natywnego, nie ma więc maszyny wirtualnej, ale nadal jest garbage collector. Podstawową właściwością Haskellowego środowiska uruchomieniowego, jest to, że wszystkie parametry są domyślnie leniwie ewaluowane. Wyrażenia traktowane są jako “thunki”, czyli obietnice dostarczenia wartości gdy będzie ona potrzebna. Thunki są redukowane tylko gdy jest to absolutnie niezbędne do kontynuowania obliczeń.
Dużą zaletą leniwej ewaluacji jest to, że zdecydowanie trudniej jest przepełnić stos! Wadą jest nieuchronny narzut wydajnościowy, dlatego też Haskell pozwala nam przełączyć się na ścisłą ewaluację dla wybranych przez nas parametrów.
Nie jest też takie oczywiste co tak na prawdę oznacza ścisła ewaluacja. Określa się, że wyrażenie jest w słabej czołowej postaci normalnej (WHNF, weak head normal form) jeśli najbardziej zewnętrzne bloki nie mogą być bardziej zredukowane, oraz w postaci normalnej jeśli wyrażenie jest w pełni wyewaluowane. Domyślna strategia ewaluacji w Scali odpowiada właśnie postaci normalnej.
Dla przykładu, te wyrażenia są w postaci normalnej:
42
(2, "foo")
\x -> x + 1
natomiast poniższe nie są (mogą być dalej redukowane):
1 + 2 -- reduces to 3
(\x -> x + 1) 2 -- reduces to 3
"foo" ++ "bar" -- reduces to "foobar"
(1 + 1, "foo") -- reduces to (2, "foo")
Następujące wyrażenia są w WHNF ponieważ zewnętrzny kod nie może być zredukowany (mimo że części wewnętrzne mogą):
(1 + 1, "foo")
\x -> 2 + 2
'f' : ("oo" ++ "bar")
a te wyrażenia już w WHNF nie są
1 + 1 -- reduces to 2
(\x y -> x + y) 2 -- reduces to \y -> 2 + y
"foo" ++ "bar" -- reduces to "foobar"
Domyślną strategią ewaluacji jest niewykonywanie żadnych redukcji gdy wyrażenie przekazywane jest jako parametr.
Wsparcie na poziomie języka pozwala nam wymusić WHNF dla dowolnego wyrażenia za pomocą ($!)
-- evaluates `a` to WHNF, then calls the function with that value
($!) :: (a -> b) -> a -> b
infixr 0
Możemy też użyć wykrzyknika na parametrach konstruktorów danych:
data StrictList t = StrictNil | !t :. !(StrictList t)
data Employee = Employee
{ name :: !Text
, age :: !Int
}
Rozszerzenie języka StrictData sprawia, że wszystkie parametry danych w danym module są ściśle ewaluowane.
Kolejne rozszerzenie, BangPattern, pozwala na używanie ! na argumentach funkcji. Z kolei rozszerzenie
Strict zamienia wszystkie argumenty funkcji na ściśle ewaluowane.
W ekstremalnym przypadku możemy użyć ($!!) i typeklasy NFData do wymuszenia ewaluacji do postaci normalnej
class NFData a where
rnf :: a -> ()
($!!) :: (NFData a) => (a -> b) -> a -> b
jeśli tylko istnieje instancja tej typeklasy.
Kosztem ścisłej ewaluacji jest to, że Haskell zaczyna zachowywać się podobnie jak inne ścisłe języki i może wykonywać niepotrzebną pracę. Tym samym przełączanie się w ten tryb musi być wykonane z wielką uwagą i tylko gdy mamy do czynienia z mierzalnym wzrostem wydajności. Jeśli masz wątpliwości to lepiej zostać przy domyślnej leniwej ewaluacji.
Kolejne kroki
Haskell jest językiem często szybszym, bezpieczniejszym i prostszym niż Scala, który używany jest również
w biznesie. Rozważ kurs programowania funkcyjnego od data61, a ewentualne
pytania możesz zawsze zadać w pokoju #qfpl na freenode.net.
O to parę dodatkowych materiałów, które mogą być pomocne w nauce:
- Programming in Haskell nauczy nas języka od podstaw
- Parallel and Concurrent Programming in Haskell oraz What I Wish I Knew When Learning Haskell dostarczają wiedzy na poziomie zaawansowanym.
- Glasgow Haskell Compiler User Guide i HaskellWiki to z kolei same twarde fakty.
- Eta, czyli Haskell na JVMie.
Jeśli podoba Ci się Haskell i doceniasz wartość jaką może przynieść twojej firmie, to powiedz to swoim przełożonym! W ten sposób ci nieliczni managerowie, którzy zdecydują się na ten krok mogą przyciągnąć utalentowanych programistów funkcyjnych z miejsc, które nie były dość odważne, a wszyscy będą szczęśliwi.