poniedziałek, 21 listopada 2011

UML a Haskell

Dzisiaj chciałbym poruszyć temat projektowania aplikacji haskellowych przy użyciu UMLa. Zajmę się tylko diagramem klas, gdyż akurat ostatnio próbowałem się z nim zmierzyć (w kontekście aplikacji haskellowych). Aby jednak zacząć w ogóle mówić o diagramie klas musiałbym pokrótce omówić typy danych jakie występują w Haskellu oraz ich podobieństwa do typów używanych w językach imperatywnych orientowanych obiektowo jak np. C++ czy Java.

W Haskellu rozróżniamy głównie 2 słowa kluczowe służące do tworzenia typów: class oraz data (istnieje również type, który tworzy tylko alias do typu oraz newtype, który jest raczej zabiegiem optymalizacyjnym). Oczywiście w językach funkcyjnych również funkcja jest typem danych. Omówię teraz z grubsza wyżej wymienione słowa kluczowe. Zapisy tłumaczył będę imperatywnie, tzn. używając słów takich jak chociażby interfejs, które oczywiście nie występują w Haskellu.

data

Zacznę o słówka data, ponieważ jest ono mniej abstrakcyjne od class. Typy tworzone przez właśnie to słowo odpowiadają klasą znanym z języków obiektowych. Wygląda to mniej więcej tak:

data Foo = Foo { fooname :: String, foofunction :: Int -> String }

Tłumacząc to na język imperatywny powiedzielibyśmy, że Foo jest klasą, która zawiera atrybut o nazwie fooname typu String oraz metodę o nazwie foofunction, która przyjmuje obiekt typu Int jako argument i zwraca obiekt typu String. Jak zatem tworzymy instancję klasy? Ano tak:

fooInstance :: Foo
fooInstance = Foo "foo" (\a -> "foo: " ++ (show a))

Pierwsza różnica jaką widać w stosunku do języków imperatywnych to fakt, że możemy przyjąć funkcję jako argument konstruktora. Oczywiście w językach typu C-podobnych moglibyśmy wykorzystać w tym celu wskaźnik do funkcji bądź delegaty. Rozważmy zatem nieco bardziej wyrafinowany typ:

data Foo2 = Foo2 { fooname :: String, foofunction :: a -> String }

Zmieniła się definicja foofunction. Moglibyśmy przeczytać ją następująco: foofunction jest funkcją, która przyjmuje argument dowolnego typu i zwraca obiekt typu String. Aby wyrazić taką funkcję w imperatywnych językach potrzebny jest jakiś mechanizm template'ów. OK, a co powiedzie na taki typ:

data Foo3 = Foo3 { fooname :: String, foofunction :: (Show a) => a -> String }

Teraz foofunction jest funkcją, która przyjmuje argument dowolnego typu, który implementuje interfejs Show i zwraca obiekt typu String. W Javie po prostu funkcja ta przyjmowałaby jako argument ten interfejs a raczej "obiekt interfejsu". No dobrze, a co w takim przypadku:

data Foo3 = Foo3 { fooname :: String, foofunction :: (Show a, Ord a) => a -> String }

W tym przypadku foofunction jest funkcją, która przyjmuje argument dowolnego typu, który implementuje interfejs Show oraz interfejs Ord i zwraca obiekt typu String. Teraz jest znacznie ciężej wyrazić to w Javie. Trzeba by utworzyć osobny typ (klasę), który implementuje te interfejsy i użyć go jako argument funkcji.

Typy tworzone przez data mogą być również parametryzowane:

data Foo3 b = FooB { fooid :: (Show b) => b, foofunction :: (Show a, Ord a) => a -> String }

Obiekt tworzylibyśmy w ten sposób:
fooInstance :: Foo3 Int
fooInstance = FooB 3 (\a -> "foo: " ++ (show a))

Natomiast obiekt takiej klasy przyjmowalibyśmy w funkcji np. w taki sposób:
fooFun :: Foo3 b -> b
fooFun foo = fooid foo

fooFunSpec :: Foo3 Int -> Int
fooFunSpec = fooid foo

Pierwsza funkcja jest polimorficzna czyli zadziała dla każdego typu Foo3, a druga tylko dla typu Foo3 Int.

class

Klasy w Haskellu nie są tym samym co klasy w Javie czy w C++. Przypominają one raczej interfejsy znane z Javy tylko znacznie lepsze. Prosty przykład:

class Show a where
    show :: a -> String

Jak widać jest to prosty interfejs z metodą show, która przyjmuje argument typu a i zwraca obiekt typu String. Jest to tylko deklaracja typu funkcji bez jej ciała. Jak widać również tutaj w przypadku języków imperatywnych trzeba by zastosować mechanizm template'ów. Jak zatem implementuje się ten interfejs? A tak:
instance Show Foo where
    show _ = "Foo"

W tym momencie na dowolnym obiekcie klasy Foo możemy wywołać funkcję show i zawsze w wyniku otrzymamy napis Foo. Pominę może aspekt, że metody/atrybuty w interfejsach mogą przyjmować domyślne definicje co różni je od interfejsów w Javie.

Podstawy za nami więc pora na bardziej skomplikowany typ:
class (Ord a) => Show a where
    show :: a -> String

Ten zapis powoduje, że implementować interfejs Show mogą klasy, które implementują już interfejs Ord. Czy da się coś takiego wyrazić w językach imperatywnych? Ja nie znam żadnej konstrukcji ale też żadnym mistrzem w C++ czy Javie nie jestem więc jak ktoś wie jak można to zrobić to proszę niech napisze w komentarzu.

To nie jedyne różnice. Pokażmy zatem coś znacznie ciekawszego:
class Eq e => Collection c e | c -> e where
    ...

Zapis po kresce oznacza, że typ e jest jednoznacznie określony przez typ c. Co to oznacza? Oznacza to tyle, że jak istniała by instancja powiedzmy Collection Array Int to nie może istnieć inna klasa implementująca ten interfejs taka, że c będzie typu Array, a e NIE będzie typu Int.

Nie dodałem tu jeszcze, że interfejsy mogą implementować typy polimorficzne takie jak chociażby lista elementów dowolnego typu: [a]. Czyli taki zapis:
instance Collection [a] a where
    ...

w tym przypadku oznacza, że "[a] a" jest kolekcją i żadna inna kolekcja "zaczynająca się" od [a] istnieć nie może. Oznacza on również, że "[Int] Int" jest kolekcją oraz że "[Char] Char" jest kolekcją itd. Tak na marginesie to widać tu jeszcze jedną rzecz, której nie da się (chyba) wyrazić w wymienionych językach imperatywnych, tzn. "[a] a" - jak pierwszy typ będzie powiedzmy [Int] to drugi musi być Int (ponieważ a jest typu Int).

UML

Powróćmy zatem do UMLa. O ile typy tworzone za pomocą słówka data i relacje między nimi da się jakoś na siłę wyrazić na diagramie klas o tyle już w przypadku słówka class nie za bardzo. No bo jak ma z diagramu wynikać, że np. klasa (słówko data) "Article a" ma być taka, że a implementuje interfejs (słówko class) Foo, a do tego interfejs Foo mogą implementować tylko te klasy, które implementują jeszcze interfejs Show oraz interfejs Ord? Nie mówiąc już o funkcjach istniejących poza interfejsami/klasami.

Czego zatem użyć zamiast diagramu klas skoro się nie nadaje? W UMLu nie ma moim zdaniem odpowiedniego diagramu. Pewien człowiek napisał propozycję diagramów do projektowania funkcyjnego w swoim doktoracie: link. Większość ludzi jednak uważa, że w przypadku nie zbyt dużych programów wystarczy pisać program w odpowiedni sposób, który Haskell akurat wymusza. Jaki to konkretnie sposób? Odsyłam do książki "Real World Haskell". W przypadku większych aplikacji polecam użyć jakiegoś prostego programu do tworzenia diagramów chociażby jak CmapTools. Tyle, że sami musimy ustalić sobie jak ma być prezentowana klasa, jak interfejs a jak funkcja itd. Niestety kodu nam to nie wygeneruje ale aplikację uda się z powodzeniem zaprojektować.

1 komentarz:

  1. Nie wspomniałem o template'ach w Haskellu, które są znacznie potężniejsze od tych w innych językach. Jak widać mechanizm szablonów z innych języków jest tutaj dostępny od razu. Natomiast haskellowy mechanizm template'ów umożliwia np. utworzenie funkcji która przyjmuje liczbę argumentów i typ dla funkcji którą ma utworzyć.

    Gdyby nasza funkcja do tworzenia funkcji nazywała się funC i chcielibyśmy utworzyć funkcję która przyjmuje dokładnie 7 Intów to wywołalibyśmy funC 7 Int. To tak teoretycznie bo o tym jak dokładnie wygląda zapis template'ów w Haskellu to może innym razem...

    OdpowiedzUsuń