Napisałem ten krótki referat, trochę w formie żartu, w jaki sposób możemy uzyskać obiekty w Haskellu. Haskell jest znany z tego, że daje łatwo wyrazić pewne problemy przez konstrukcje językowe, które są dla niego unikalne, a później często stają się inspiracją dla innych języków. Teraz inspiracją dla Haskella są obiekty.
Rozważmy sobie klasę typów IsTelegraph
pozwalającą na wysyłanie i odbieranie pewnych danych
za pomocą wejścia/wyjścia.
class IsTelegraph a where
tgSend :: a -> String -> IO ()
tgReceive :: a -> (String -> IO ()) -> IO ()
Mam nadzieję, że opis tych funkcji jest jasny.
Zdefiniujmy sobie parę struktur danych i instancji IsTelegraph
.
data ConsoleTg = ConsoleTg
instance IsTelegraph ConsoleTg where
tgSend _ = putStrLn
tgReceive _ k = getLine >>= k
data FileTg = FileTg FilePath
instance IsTelegraph FileTg where
tgSend (FileTg fp) = appendFile fp
tgReceive (FileTg fp) k = readFile fp >>= k
(Dla FileTg
funkcja tgReceive
nie jest idealna, ale you got the point).
Oczywiście taka konstrukcja nic nie wnosi, ponieważ i tak musimy znać typ,
żeby móc uruchomić tgSend
albo tgReceive
, więc jest to zwykły Haskellowy kod.
Sytuacja nabiera kolorytu, jeśli dorzucimy tzw. existential types.
{-# LANGUAGE ExistentialQuantification #-}
data Telegraph = forall a. IsTelegraph a => Telegraph a
send :: Telegraph -> String -> IO ()
send (Telegraph a) msg = tgSend a msg
receive :: Telegraph -> (String -> IO ()) -> IO ()
receive (Telegraph a) k = tgReceive a k
Napiszmy również tzw. sprytne konstruktory.
toConsole :: IO Telegraph
toConsole = pure (Telegraph ConsoleTg)
toFile :: FilePath -> IO Telegraph
toFile fp = pure (Telegraph (FileTg fp))
Mając taki repertuar funkcji, możemy w pełni emulować obiekty. Zauważ, że korzystając z send
i receive
nie musimy
nic znać na temat a
, ponieważ powiedzieliśmy, że istnieje pewien typ a
, który ma funkcje z IsTelegraph
, na których możemy
działać. Ponadto, możemy wyeksportować IsTelegraph (..)
, Telegraph
i akompaniujące mu funkcje, kompletnie ukrywając fakt,
że korzystamy z typów ConsoleTg
oraz FileTg
.
main
= do stdout <- toConsole
file <- toFile "some/path/to/file.txt"
send stdOut "Sending to console"
send file "Sending to file"
receive stdOut putStrLn
receive file putStrLn
Pytacie pewnie, jak zrealizować mutowalny stan? Otóż jest to dość łatwa sprawa. Możemy po prostu zdefiniować
odpowiednik ConsoleTg
, bądź FileTg
, żeby zawierał w sobie pole typu IO (IORef t)
, gdzie t
to wartość,
którą chcemy mutować. Wtedy możemy osiągnąć pełną obiektową nirwanę.
Dziedziczenie można zdefiniować różnie. Możemy tworzyć struktury w następujący sposób:
import Data.Char
newtype UpperCaseTg a = UCT a
instance IsTelegraph a => IsTelegraph (UpperCaseTg a) where
tgSend (UCT a) msg = tgSend a (map toUpper msg)
tgReceive (UCT a) k = tgReceive a (k . map toUpper)
Skorzystanie z instancji IsTelegraph
jest analogiczne do korzystania z metod za pomocą super
w Javie.
Również nic nie przeszkadza nam, by kompletnie nadpisać zachowanie, nie korzystając z super
.
Zauważmy, że ta konstrukcja jest bardzo elastyczna, zasadniczo przypomina wzorzec Dekorator.
Jak wobec tego kontrolować dziedziczenie pól? Możemy wyeksportować poszczególne ukrywane struktury do osobnych
modułów i eksportować wyłącznie wybrane pola. Mamy wtedy wybór między public
a private
i możemy to w taki sposób,
mimo rozwlekłości, kontrolować.
Jedyną wadą tego rozwiązania jest fakt, że nie da się (bądź jeszcze nikt nie wpadł na pomysł) wyrazić poziomu protected
.
(Chociaż, tak szczerze, czy jest ono koniecznie potrzebne?)