Typeclasses - FP Ladder 02
Wpis ten jest dość długi, ale zależy mi, żeby był (względnie) kompletnym wstępem do idei typeclass w Scali. Nie jest to koncept łatwy, ale jest niezbędny do wejścia powyżej poziomu podstawowego w Scali.
Polimorfizm jest jedną z podstawowych technik programowania w językach wysokiego poziomu. Jest bardzo popularny w językach obiektowych - i słusznie. Będąc wierny jedynej słusznej drodze (JSD), czyli programowaniu funkcyjnemu, sądzę, że polimorfizm o smaku ad-hoc pozwala pisać w sposób naturalny kod, który jest bardziej modularny oraz uniwersalny.
Czym jest typ, klasa, klasa typów?
Zacznijmy od rozplątanie pojęć, które bywają używane zamiennie w kontekście tematu typeclass. Na końcu tej części powinno być jasne, czym ów twór jest.
- Zbiór - w kontekście matematycznym - chyba najtrudniejsze do zdefiniowiania pojęcie. Jest to fundamentalne pojęcie teorii mnogości(Mnogość to inaczej zbiór, więc to po prostu teoria zbiorów). Fundamentalne oznacza tu, że jest to pojęcie tak pierwotne, że jest częściowo przyjmowane na zasadzie - “czym jest zbiór, każdy widzi” (to oczywiście zależy od podejścia do teorii mnogości jakie przyjmiemy, ale to wykracza poza nasze potrzeby). Intuicyjne jest to pewna kolekcja arbitralnie wybranych przez nas elementów.
Ważniejsze są cechy zbioru - dany element może należeć do zbioru lub też nie, ale nie może przynależeć do niego dwukrotnie. Zbiór jest jednoznacznie wyznaczony przez jego elementy. To tylko tyle i aż tyle. - Typ - w ujęciu programistycznym - znane nam dobrze
Int
,String
,List[T]
, czy też stworzone przez nasclass Animal
,trait Money
,object Earth
. Jest to cecha danych, która mówi kompilatorowi w jaki sposób chcemy użyć danych i na jakie operacje powinien nam na nich pozwolić. Zwróćmy uwagę na analogię pomiędzy zbiorem a typem, posłużmy się do tego funkcją:1
def isDivisableBy3(x: Int): Boolean = x % 3 == 0
Funkcja ta sprawdza, czy przekazany
Int
jest podzielny przez3
. Informuje nas o to zwracając odpowiednią wartośćtrue
lubfalse
. Spójrzmy na to jednak z małą matematyczną lupą. Zdefiniowaliśmy funkcję, która przypisuje elementom zbioruInt
któryś z elementów zbioruBoolean
. Tak więc wartościtrue
,false
należą do zbioruBoolean
- stąd też ich typ. Analogicznie ze zbioremInt
, będącego skończonym podzbiorem liczb całkowitych. - Klasa - w ujęciu matematycznym - Pojęcie jest używane w matematyce, gdy mamy do czynienia z wielością (celowo nie zbiorem), który jest zbyt liczny i odrobinę zbyt zaskakujący, żeby go badać przy pomocy narzędzi teorii mnogości. Na nasze potrzeby wystarczy intuicja, mówiąca, że klasa to grupa obiektów, która jest określona przez pewną wspólną własność.
- Klasa - w ujęciu programistycznym - liczę na to, że czytelnik jest zaznajomiony z tym pojęciem. Ewentualną dygresją, którą warto dodać, że jest to pewne narzędzie, które służy nam do modelowania domeny problemu.
- Klasa typów - typeclass - Jest to połączenie konceptu klasy matematycznej z typem programistycznym. Więc mówiąc typeclass mamy na myśli pewną grupę typów, które mają jakąś wspólną własność. W praktyce przez wspólną własność zazwyczaj rozumiemy określoną na tych typach funkcję.
Przykładem, który weźmiemy sobię pod lupę jest serializacja danych do formatu JSON. Naszym bardzo (a nawet bardzo, bardzo - nie bieżcie go za wzór przy modelowaniu czegokolwiek) uproszczonym punktem początkowym będzie
1
2
3
4
5
6
7
case class Account(id: String, balance: BigDecimal) {
def toJsonString: String = ??? // we do not care about implementation
}
case class Dog(name: String, breed: String, weight: Int) {
def toJsonString: String = ??? // we do not care about implementation
}
Bez żadnego naciągactwa możemy powiedzieć, że chcielibyśmy wprowadzić pewną klasę typów, która pozwoli nam mówić o klasach, które możemy serializować do formatu JSON.
Seperacja zachowania od danych
Zanim jednak wypłyniemy na wzburzone morza typeclass o modularności. Jestem zwolennikiem seperacji zachowania danych od ich definicji. Programiści (w tym ja!) często - przypadkiem - doprowadzają często do splątania tych dwóch rzeczy, a to w efekcie zmniejsza modularność naszego kodu. Zmniejszenie modularności powoduje, że nasz kod jest ciężej używać w różnych, niezależnych od siebie miejscach, ciężej go testować i ogólnie zwiększa stopień “kaszanowatości” rozwiązania. Wyobraźmy sobie nie najlepiej zamodelowaną klasę:
1
2
3
4
5
6
7
case class Account(id: String, balance: BigDecimal) {
def toJsonString: String = ??? // we do not care about implementation
def closeAccount: ClosingResult = ???
def buyDog(dog: Dog, money: BigDecimal): Dog = ???
}
Mamy do czynienia tutaj z pomieszaniem z poplątaniem. Moduł naszej aplikacji odpowiedzialny za zamykanie i autoryzacje kont teraz jest pośrednio zależny od buyDog
- jeżeli zmienia się definicja tej funkcji, wszyscy użytkownicy tej klasy muszą zostać o tym poinformowani. W mojej ocenie co jest jeszcze gorsze dajemy możliwości kupowania psów modułowi autoryzacyjnemy oraz zamykania konta modułowi odpowiedzialnymi za sprzedaż piesków! Pozwól danym być danymi, nie zmuszaj ich do niewolniczej pracy.
Podejście to jest oczywiście efektem dobrych praktyk programistycznych (single responsibility principle, seperation of concerns i innych).
Co to nam mówi o “serializowalnym” Account
i Dog
? Nie powinniśmy plątać definicji serializacji i definicji danych.
Do rzeczy - jak ten typeclass wygląda w Scali?
Żeby zdefiniować klasę typów, które będą mogły być serializowane do JSON możemy zacząć od definicji trait
:
1
2
3
trait JsonEncodable[A] {
def toJsonString(a: A): String
}
A cóż to za parametr A
? Naszą klasą typów jest JsonEncodable
natomiast z punktu widzenia języka programowania będziemy musieli w jakiś sposób pokazać, że dany typ przynależy do klasy JsonEncodable
- odbędzie się to przez implementację JsonEncodable
:
1
2
3
4
5
6
object Account {
implicit val enc: JsonEncodable[Account] = new JsonEncodable {
override def toJsonString(account: Account): String = ??? // we do not care about implementation.
}
}
Teraz żeby zserializować obiekt Account możemy napisać:
1
2
val account: Account = Account("id", 100)
val jsonString: String = Account.enc.toJsonString(account)
Możemy pozwolić sobie również tworzenie generycznych funkcji:
1
def genericToJsonString[A](a: A, ev: JsonEncodable[A]) = ev.toJsonString(a)
Po co to wszystko?
Pierwszą zaletą jest fakt odseperowania zachowania od definicji danych. Kolejną jest przekazanie części pracy kompilatorowi, funkcja genericToJsonString[A]
wymaga zaimplementowanego ev: JsonEncodable[A]
(ev
jest skrótem od evidence).
Drugą jest możliwość deklarowania różnych implementacji dla kompletnie różnych typów, które same w sobie nie muszą być ze sobą w żaden sposób związane (musi istnieć odpowiednia implementacja JsonEncodable
dla typu A
) - nazywamy to ad-hoc polymorphism. Zwróć uwagę na fakt, że w żaden sposób nie narzuciliśmy ograniczeń na typ A
.
Trzecią zaletą możliwość rozszerzanie funkcjonalności typów bez dostępu do ich kodu źrodłowego (JsonEncodable[Account]
możemy zaimplementować bez dostępu do kodu źrodłowego Account
).
No strasznie to brzydkie
Faktycznie zapis def genericToJsonString[A](a: A, ev: JsonEncodable[A]) = ev.toJsonString(a)
wygląda dość ociężale. Scala jednak pozwala na kilka uproszczeń.
1
def genericToJsonString[A](a: A)(implicit ev: JsonEncodable[A]) = ev.toJsonString(a)
Wtedy serializacja typu Account
będzie uproszczona - tak długo jak w zasięgu kompilatora będzie implicit val enc: JsonEncodable[A]
. Obecnie zdeklarowaliśmy go w companion object Account
, więc zawsze gdy zaimportujemy Account
pojawi się nasz encoder, co pozwoli nam napisać:
1
2
val account: Account = Account("id", 100)
val jsonString: String = genericToJsonString(account)
Kolejnym uproszczeniem jest następujący zapis:
1
def genericToJsonString[A: JsonEncodable](a: A) = JsonEncodable[A].toJsonString(a)
Żeby jednak kompilator był zadowolony (zadowolony kompilator to sprawa istotna) musimy stworzyć companion object do JsonEncodable
:
1
2
3
object JsonEncodable {
def apply[A](implicit ev: JsonEncodable[A]): JsonEncodable[A] = ev
}
To pozwoli nam również na zapis:
1
2
val account: Account = Account("id", 100)
val jsonString: String = genericToJsonString(account)
Alterantywnie bez deklaracji companion object możemy pokusić się o takie sformułowanie:
1
def genericToJsonString[A: JsonEncodable](a: A) = implicitly[JsonEncodable[A]].toJsonString(a)
Można (i należy) dalej “upiększać” przy pomocy interface syntax lub interface object, o których warto przeczytać tu
Podsumowanie implementacji
Żeby zaimplementować typeclass musimy zrobić przynajmniej poniższe dwie rzeczy:
- Zdefiniować typeclass-ę jako generyczny (sparametryzowany przynajmniej przez jeden typ)
trait
, na przykładJsonEncodable[A]
. - Przynajmniej jedną implementację powyższej typeclass-y - w naszym wypadku była to
implicit val enc: JsonEncodable[Account] = ...
.
Podsumowanie
Mam nadzieję, że ten artykuł przybliżył Wam koncept typeclass-y. Jest to tylko wstęp, więc nie miej sobie za złe, jeżeli nie od razu widzisz zastosowania tej techniki. Postaram się to zmienić w kolejnych wpisach. Jest to jednak niezbędna widza, jeżeli chce się wejść na odrobinę wyższy poziom w Scali niż użycie map
i filter
.