Kiedy w językach obiektowych potrzebujemy struktury, opisującej jakieś dane, tworzymy po prostu klasę. W Haskellu mamy zaś… algebraiczne typy danych. Ale dlaczego algebraiczne? Czy kryje się tutaj jakaś czyhająca na nas komplikacja? Przyjrzyjmy się tej sprawie i rozwikłajmy zagadkę haskellowych ADT (ang. Algebraic Data Types).
Spis treści
Dlaczego algebraiczne?
Mając do dyspozycji garść podstawowych typów danych, takich jak Bool
, Char
czy Int
, możemy przystąpić do konstruowania własnych typów. Dajmy na to, taki oto typ, przechowujący imię, nazwisko i wiek osoby:
1 |
data Person = Person String String Int |
Posiada on jeden konstruktor – Person
– zdefiniowany po prawej stronie znaku równości. Konstruktor ten przyjmuje trzy parametry. Typ ten moglibyśmy jednak opisać nieco inaczej, używając kilku konstruktorów, np.:
1 |
data Person = Man String String Int | Woman String String Int |
Na podstawie tych dwóch przykładów możemy już wyjaśnić sobie na czym polega owa algebraiczność haskellowych typów danych. Mamy tu do czynienia z dwoma rodzajami typów:
- iloczynami (product types) – określanymi przez konstruktor i następujące po nim parametry
- sumami (sum types) – składającymi się z co najmniej dwóch alternatywnych konstruktorów, oddzielonych od siebie znakami
|
Warto przy tym zauważyc, że sumy mogą zawierać w sobie typy iloczynowe – tak jak ma to miejsce w drugim z naszych przykładów.
Parametry z nazwami
Jak na razie nie wygląda to zbyt czytelnie. Co z tego, że mamy zdefiniowany nasz algebraiczny typ danych, skoro opisujemy go jedynie przez podanie typów parametrów, a nie ich nazw? Skąd będziemy później wiedzieć który z parametrów String
przechowuje imię, a który nazwisko?
Zanim przejdziemy do możliwych rozwiązań, zerknijmy jeszcze jak wygląda tworzenie nowych instancji naszego typu (tego składającego się z jednego konstruktora):
1 |
somePerson = Person "John" "Smith" 35 |
Jednym ze sposobów na pozbawione nazw parametry jest skorzystanie z tzw. rekordów:
1 |
data Person = Person {name :: String, secondName :: String, age :: Int} |
Generowanie instancji takich typów może odbywać się albo tak jak poprzednio, albo też wraz z podaniem nazw parametrów:
1 |
somePerson = Person {name="John", secondName="Smith", age=35} |
Czyżby zatem Haskell oferował równie intuicyjne złożone typy danych jak wiele innych języków programowania? Otóż nie do końca. Po pierwsze, do tak zdefiniowanych pól nie odnosimy się, pisząc somePerson.name
lub somePerson->name
, ale po prostu wywołując funkcje:
1 2 3 4 |
Prelude> name somePerson "John" Prelude> secondName somePerson "Smith" |
Można by powiedzieć „ok, to tylko kwestia przyzwyczajenia”. Można, gdyby nie fakt, iż sprawa nieco się komplikuje, kiedy do tego samego pliku z haskellowym kodem dorzucimy jeszcze np. taki ATD:
1 |
data Company = Company {name :: String, owner :: Person} |
Kompilacja niestety nie powiedzie się, a naszym oczom ukaże się błąd: Multiple declarations of ‘name'
. Nie można po prostu zadeklarować dwóch funkcji o tej samej nazwie. Rozwiązań tej sytuacji jest kilka:
Modularyzacja
Podział kodu źródłowego na mniejsze fragmenty, znajdujące się w osobnych plikach, generalnie jest dobrym pomysłem. Stosując taką taktykę, unikniemy próby deklaracji kilku funkcji o tej samej nazwie we wspólnej przestrzenii nazw. Następnie, korzystając z import qualified
(czyli importowania modułu w taki sposób, aby wszystkie jego funkcje trzeba było poprzedzać nazwą modułu i następującą po niej kropką) będziemy mogli odnosić się do obu funkcji, pisząc, w zależności od kontekstu Person.name
lub Company.name
. Oto zawartość poszczgólnych plików:
1 2 |
module Person where data Person = Person {name :: String, secondName :: String, age :: Int} |
1 2 3 |
module Company where import Person data Company = Company {name :: String, owner :: Person} |
1 2 3 4 5 6 7 |
import qualified Company import qualified Person main = let somePerson = Person.Person "John" "Smith" 35 someCompany = Company.Company {Company.name = "IT Company", Company.owner = somePerson} in print $ Person.name somePerson ++ " is the owner of " ++ Company.name someCompany |
Klasy typów
Innym rozwiązaniem na „przeładowanie” funkcji jest wykorzystanie klas typów (ang. typeclasses). O samych klasach typów porozmawiamy sobie w jednym z kolejnych wpisów, więc nie przejmuj się, jeśli poniższe fragmenty kodu nie będą dla Ciebie do końca jasne. Aktualnie wystarczy nam wiedza, że jest to coś na podobieństwo znanych z programowania obiektowego interfejsów.
Pomysł polega na tym, że wewnątrz definicji rekordów będziemy używać unikalnych nazw parametrów, natomiast dzięki dodatkowej klasie zapewnimy również dostęp do nich przy pomocy funkcji o nieunikalnej nazwie:
1 2 3 4 5 6 7 8 9 10 |
class Named a where name :: a -> String data Person = Person {personName :: String, secondName :: String, age :: Int} instance Named Person where name person = personName person data Company = Company {companyName :: String, owner :: Person} instance Named Company where name company = companyName company |
Podobne podejście możemy zastosować również zupełnie rezygnując z rekordów, na rzecz zwykłych ATD:
1 2 3 4 5 6 7 8 9 10 |
class Named a where name :: a -> String data Person = Person String String Int instance Named Person where name (Person personName _ _) = personName data Company = Company String Person instance Named Company where name (Company companyName _) = companyName |
Rozszerzenia, biblioteki
I w tym miejscu dochodzimy do podejrzenia, że rezygnacja z użycia rekordów może rzeczywiście jest dobrym pomysłem. Problem z kolizją funkcji dostępowych jest tylko jednym z wielu. Kłopotliwe są również chociażby identycznie nazwane parametry dla różnych konstruktorów tego samego typu
Zdaniem wielu użytkowników Haskella, rekordy są aktualnie jednym z jego słabych punktów. Możliwe, że problem zostanie wreszcie naprawiony w którymś z kolejnych standardów tego języka. Do tego czasu, skazani jesteśmy na stosowanie alternatywnych rozwiązań. Wśród nich znajdziemy zarówno dodatkowe moduły, jak i rozszerzenia języka (language extensions). Poniżej kilka przydatnych linków dla zainteresowanych tematem: