Spis treści
- O niniejszej książce
- Nota lewa autorskiego
- Podziękowania
- Nota tłumacza
- Aspekty praktyczne
- 1. Wprowadzenie
-
2. Konstrukcja
for
- 3. Projektowanie aplikacji
- 4. Dane i funkcjonalności
- 5. Typeklasy ze Scalaz
- 6. Typy danych ze Scalaz
- 7. Zaawansowane Monady
- 8. Derywacja typeklas
- 9. Złożenie aplikacji
- Skrót Typeklas
- Haskell
- Licencje
- Notatki
“Miłość jest mądra, nienawiść jest głupia. W tym świecie, który staje się coraz bardziej połączony wewnętrznie, musimy nauczyć się tolerować się wzajemnie. Musimy nauczyć się znosić to, że niektórzy ludzie mówią rzeczy, które nam się nie podobają. Tylko w ten sposób możemy żyć razem. Ale jeśli mamy razem żyć, ale nie umierać, musimy nauczyć się dobroci i tolerancji, które są absolutnie niezbędne do przetrwania ludzi na tej planecie.”
― Bertrand Russell
O niniejszej książce
Książka ta jest kierowana do zwykłego programisty Scali, prawdopodobnie z doświadczeniem Javowym, który zaciekawiony jest paradygmatem Programowania Funkcyjnego. Każdy koncept, który przedstawiamy, uzasadniany jest praktycznym przykładem, a cała książka zwieńczona jest stworzeniem w pełni funkcjonalnej aplikacji webowej.
Niniejsza książka używa Scalaz 7.2, najpopularniejszej, najstabilniejszej, najbardziej pryncypialnej1 i najbardziej kompleksowej biblioteki do Programowania Funkcyjnego w Scali.
Książka ta została napisana z myślą o czytaniu jej od deski do deski, w zaprezentowanej kolejności i z przerwami na odpoczynek między rozdziałami. Początkowe rozdziały zachęcają do programowania w sposób, który w później zdyskredytujemy: podobnie jak uczymy się teorii grawitacji Newtona jako dzieci, aby później przejść do Riemanna / Einsteina / Maxwella, jeśli zechcemy studiować fizykę.
Komputer nie jest niezbędny, aby podążać za treścią, ale zachęcamy do zgłębienia kodu źródłowego Scalaz. Niektóre z bardziej skomplikowanych fragmentów kodu są dostępne wraz ze źródłami tej książki, a ci, którzy żądni są praktycznych ćwiczeń, powinni spróbować (zre)implementować Scalaz (oraz naszą przykładową aplikację) używając częściowych opisów, które zaprezentujemy.
Jako kolejny krok polecamy również Czerwoną Książkę2. Pokazuje ona jak stworzyć bibliotekę wspierającą programowanie funkcyjne, taką jak na przykład Scalaz, od zupełnych podstaw.
Nota lewa autorskiego3
Ta książka jest Wolna4 i podąża za filozofią Wolnego Oprogramowania: możesz używać jej, jak tylko chcesz, źródła są dostępne, możesz ją redystrybuować oraz dystrybuować swoją własną wersję. Oznacza to że możesz ja wydrukować, fotokopiować, wysyłać mailem, uploadować na strony internetowe, zmieniać, tłumaczyć, pobierać opłaty, remiksować, usuwać kawałki i malować po dowolnych fragmentach.
Ta książka jest objęta Lewem autorskim: jeśli ją zmienisz i udostępnisz swoją własną wersję, musisz również przekazać wspomniane uprawnienia swoim odbiorcom.
Książka ta używa licencji Creative Commons Attribution ShareAlike 4.0 International (CC BY-SA 4.0).
Wszystkie oryginalne fragmenty kodu są osobno licencjonowane na podstawie CC0, co oznacza, że możesz używać ich bez żadnych ograniczeń. Fragmenty ze Scalaz i pokrewnych bibliotek zachowują swoje licencje, powtórzone w pełni w załączniku.
Przykładowa aplikacja drone-dynamic-agents
udostępniona jest na zasadach GPLv3,
a jedynie fragmenty zawarte w książce dostępne są bez ograniczeń.
Podziękowania
Dla Diego Estebana Alonso Blasa, Raúla Raja Martíneza i Petera Neyensa z 47 degrees, Rúnara Bjarnasona, Tonego Morrisa, Johna de Goes i Edwarda Kmetta za ich pomoc w tłumaczeniu zasad FP. Kenji Yoshidzie i Jasonowi Zauggowi za stworzenie pierwszej wersji Scalaz, oraz Paulowi Chiusano / Milesowi Sabinowi za naprawienie krytycznego błędu w kompilatorze Scali (SI-2712).
Dziękuje też czytelnikom, którzy doradzali przy pierwszych szkicach tej książki.
Niektóre materiały były szczególnie pomocne dla mojego własnego zrozumienia konceptów opisanych w tej książce. Podziękowania dla Juana Manuela Serrano za All Roads Lead to Lambda, Pere’a Villega za On Free Monads, Dicka Walla oraz Josha Sueretha za For: What is it Good For?, Erika Bakkera za Options in Futures, how to unsuck them, Noela Markhama za ADTs for the Win!, Sukanta Hajra za Classy Monad Transformers, Luki Jacobowitz za Optimizing Tagless Final, Vincenta Marqueza za Index your State, Gabriela Gonzaleza za The Continuation Monad, oraz Yi Lin Wei / Zainab Ali za ich tutoriale w trakcie spotkań Hack The Tower.
Pomocne dusze, które cierpliwe tłumaczyły mi kolejne koncepty: Merlin Göttlinger, Edmund Noble, Fabio Labella, Adelbert Chang, Michael Pilquist, Paul Snively, Daniel Spiewak, Stephen Compall, Brian McKenna, Ryan Delucchi, Pedro Rodriguez, Emily Pillmore, Aaron Vargo, Tomas Mikula, Jean-Baptiste Giraudeau, Itamar Ravid, Ross A. Baker, Alexander Konovalov, Harrison Houghton, Alexandre Archambault, Christopher Davenport, Jose Cardona, Isaac Elliott.
Nota tłumacza
Angielski stał się de facto uniwersalnym językiem programistów, a zarówno dostępność, jak i konsumpcja treści technicznych w innych językach jest bardzo niewielka. Niesie to ze sobą implikacje również i dla tej książki oraz jej tłumaczenia. Przy tłumaczeniu staraliśmy się brać pod uwagę, że wszelkie kroki po przeczytaniu tej książki, takie jak wyszukiwanie dodatkowych materiałów, zadawanie pytań lub zwykła komunikacja z innymi programistami, najprawdopodobniej odbywać się będą z użyciem języka angielskiego. Dlatego też staraliśmy się używać tłumaczeń jak najbardziej zbliżonych do wersji oryginalnych, co poskutkowało tym, że duża ich część może brzmieć śmiesznie, dziwnie lub nawet absurdalnie. W wielu miejscach używamy form potocznych i słowotwórstwa, które nie znalazłyby miejsca w poważnej literaturze technicznej. Co więcej, w momencie, gdy wprowadzamy tłumaczenie, dla którego odtworzenie wersji oryginalnej może być nieoczywiste, podajemy tę wersję wprost, w nawiasach bądź przypisach.
Pamiętajmy również, że jest to tłumaczenie amatorskie, motywowane jedynie chęcią popularyzacji Scali i programowania funkcyjnego, a nie zyskiem, dlatego też w wielu miejscach może ono odbiegać od profesjonalnych standardów. Z kolei użycie liczby mnogiej w tej nocie i późniejszych przypisach, wynika z kolei jedynie z megalomanii tłumacza, a nie z faktu, że była to praca zespołu profesjonalistów.
Chcielibyśmy również uprzedzić, że część tłumaczeń może nie pokrywać się (z różnych powodów) z tłumaczeniami tych samych terminów wprowadzonymi przez innych autorów. Wynika to z 2 rzeczy. Po pierwsze, jak wspomniano wyżej, wierzymy, że to wersje oryginalne są najistotniejsze, a polskie tłumaczenie jest tutaj jedynie pomocą obniżającą próg wejścia. Po drugie, jak również wspomniano powyżej, jest to tłumaczenie realizowane minimalnym wysiłkiem i bez zasobów potrzebnych na analizę wcześniejszych prac i tłumaczeń. Mimo to mamy nadzieję, że lektura sprawi wam przyjemność.
Aspekty praktyczne
Aby skonfigurować projekt używający bibliotek prezentowanych w tej książce, użyjemy aktualnej wersji Scali wraz
z opcjami specyficznymi dla Programowania Funkcyjnego. Oto build.sbt
:
Aby fragmenty kodu były krótkie, ominiemy sekcje import
ów i jeśli nie zaznaczono inaczej,
to należy przyjąć, że wszystkie fragmenty zawierają:
1. Wprowadzenie
To normalne, że do nowego paradygmatu podchodzimy sceptycznie. Aby zarysować nieco drogę, jaką już przeszliśmy i zmiany, jakie udało nam się zaakceptować w JVMie, zacznijmy od szybkiej powtórki z ostatnich 20 lat tej platformy.
Java 1.2 wprowadziła Collections API, pozwalające nam pisać metody mogące abstrahować nad5 mutowalnymi kolekcjami. Była to zmiana pomocna w pisaniu algorytmów ogólnego przeznaczenia, która stała się podwaliną dla wielu systemów i bibliotek.
Niestety, API to miało jeden problem, zmuszało nas do rzutowania w czasie wykonania (runtime casting):
W odpowiedzi na ten problem deweloperzy definiowali obiekty domenowe, które przyjmowały formę
CollcetionOfThing
, czyli były kolekcjami konkretnych typów, z ich własnym silnie typowanym interfejsem,
a Collection API stało się jedynie szczegółem implementacyjnym.
W 2005 Java 5 wprowadziła typy generyczne (generics), pozwalające nam definiować Collection<Thing>
,
abstrahując nad konkretną kolekcją oraz typem jej elementów. Typy generyczne po raz kolejny zmieniły sposób,
w jaki pisaliśmy kod w Javie.
Autor Javowego kompilatora typów generycznych, Martin Odersky, niedługo później stworzył Scalę, język z silniejszym systemem typów, niemutowalnymi kolekcjami oraz wielokrotnym dziedziczeniem. Wprowadziło to fuzję pomiędzy programowaniem obiektowym (OOP) oraz programowaniem funkcyjnym (FP).
Dla większości programistów FP oznacza używanie niemutowalnych struktur danych tak często jak to możliwe,
ale mutowalny stan jest nadal złem koniecznym, które musi być wyizolowane i zarządzane, np. przy użyciu aktorów z Akki
lub
klas używających synchronized
. Ten styl FP skutkuje prostszymi programami, które łatwiej zrównoleglić i rozproszyć,
stawiając zdecydowany krok naprzód względem Javy. Jest to jednak niewielka część zalet i korzyści płynących z programowa funkcyjnego,
które odkryjemy w tej książce.
Scala wprowadza również typ Future
, sprawiając, że pisanie aplikacji asynchronicznych staje się dużo łatwiejsze.
Jednak gdy tylko Future
pojawi się w typie zwracanym z funkcji, wszystko musi zostać przepisane i dostosowane,
wliczając testy, które teraz narażone są na arbitralne timeouty6.
Mamy więc problem podobny to tego z Javy 1.0: brakuje nam możliwości abstrahowania nad wykonaniem programu, tak samo, jak brakowało nam możliwość abstrahowania nad używanymi kolekcjami.
1.1 Abstrahowanie nad wykonaniem
Wyobraźmy sobie, że chcemy komunikować się z użytkownikiem poprzez wiersz poleceń. Możemy czytać (.read
),
to co użytkownik napisał i pisać (.write
) wiadomości, które będzie mógł przeczytać.
Jak możemy napisać generyczny kod, który zrobi coś tak prostego, jak powtórzenie (echo
) wiadomości
wpisanej przez użytkownika w sposób synchroniczny bądź asynchroniczny w zależności od naszego środowiska uruchomieniowego?
Moglibyśmy napisać wersję synchroniczną i owinąć ją typem Future
, ale zmusiłoby nas to do zdecydowania, jakiej puli wątków
powinniśmy użyć. Alternatywnie moglibyśmy zawołać Await.result
na Future
i wprowadzić tym samym blokowanie wątku.
W obu przypadkach sprowadza się to do napisanie dużej ilości boilerplate’u i utrzymywania dwóch różnych API, które nie są
w żaden sposób zunifikowane.
Możemy też rozwiązać ten problem, podobnie jak w Javie 1.2, używając wspólnego interfejsu bazującego na typach wyższego rodzaju (higher kinded types, HKT) dostępnych w Scali.
Chcielibyśmy zdefiniować Terminal
dla dowolnego konstruktora typu C[_]
. Poprzez zdefiniowanie Now
jako równoznacznego ze swoim parametrem (analogicznie do Id
), możemy zaimplementować ten wspólny interfejs dla terminali
synchronicznych i asynchronicznych:
Niestety nie wiemy nic o C
i nic nie jesteśmy w stanie zrobić z C[String]
.
Potrzebujemy środowiska wykonania (execution environment), które pozwoli nam zawołać metodę zwracającą C[T]
a później
zrobić coś z T
, np. zawołać kolejną metodę z interfejsu Terminal
. Potrzebujemy również możliwości owinięcia prostej wartości
typem C[_]
. Poniższa sygnatura dobrze spełnia nasze wymagania:
pozwalając nam na napisanie:
Możemy teraz współdzielić implementację echo
pomiędzy synchroniczną i asynchroniczną wersją naszego programu.
Możemy napisać sztuczną (mock) implementację Terminal[Now]
i użyć jej w naszych testach beż zagrożenia ze strony timeoutów.
Implementacje Execution[Now]
oraz Execution[Future]
mogą być reużywane przez generyczne metody takie jak echo
.
Ale kod implementujący echo
jest okropny!
Używając mechanizmu implicit class
w Scali, możemy dodać metody do C
.
Nazwijmy te metody flatMap
i map
z powodów, które staną się jasne już niedługo. Każda z metod przyjmuje
implicit Execution[C]
, ale oprócz tego są to dokładnie takie same flatMap
i map
, jakie znamy z typów Seq
, Option
czy Future
.
Możemy teraz zdradzić, dlaczego użyliśmy flatMap
jako nazwy metody: pozwala nam to używać for comprehension, czyli
lepszej składni dla zagnieżdżonych wywołań flatMap
i map
.
Nasze Execution
ma taką samą sygnaturę jak trait w Scalaz zwany Monad
,
z ta różnicą, że chain
to bind
and create
to pure
. Mówimy, że C
jest monadyczne (monadic),
gdy dostępna jest niejawna (implicit) instancja Monad[C]
. Dodatkowo Scalaz definiuje również alias typu Id
.
Podsumowując: jeśli piszemy metody operujące na typach monadycznych, wówczas możemy
pisać sekwencyjny kod, który abstrahuje nad swoim środowiskiem wykonania. Pokazaliśmy jak zrobić to dla
synchronicznego i asynchronicznego wykonania programu, ale tej samej techniki możemy użyć dla innych kontekstów, np.
statycznie deklarowanych błędów (gdzie C[_]
stanie się Either[Error, _]
), zarządzania dostępem do ulotnego stanu aplikacji (volatile state),
wykonywania operacji wejścia/wyjścia albo audytowalnej sesji.
1.2 Programowanie czysto funkcyjne7
Programowanie Funkcyjne to akt tworzenia programów przy użyciu czystych funkcji (pure functions). Czyste funkcje mają trzy właściwości:
- Totalność: zwracają wartość dla każdego możliwego argumentu (total)
- Deterministyczność: za każdym razem zwracają tę samą wartość dla tego samego argumentu (deterministic)
- Niewinność: brak (bezpośrednich) interakcji ze światem lub wewnętrznym stanem programu (inculpable)
Razem te właściwości dają nam bezprecedensową zdolność do rozumowania o naszym kodzie. Na przykład, walidacja wejścia jest łatwiejsza do wyizolowania z totalnością, caching jest możliwy, gdy funkcje są deterministyczne, a interagowanie ze światem jest łatwiejsze do kontrolowania i testowania, gdy funkcje są niewinne.
Rzeczy, które łamią te właściwości, nazywamy efektami ubocznymi (side effects): bezpośredni dostęp lub zmiana
mutowalnego stanu aplikacji (np. var
wewnątrz klasy), komunikowanie się z zewnętrznymi zasobami (np. plikami lub siecią) lub
rzucanie i łapanie wyjątków.
Piszemy czyste funkcje przez unikanie wyjątków i komunikowanie się ze światem jedynie poprzez bezpieczny kontekst wywołania F[_]
.
W poprzedniej sekcji abstrahowaliśmy nad wykonaniem programu i definiowaliśmy echo[Id]
oraz echo[Future]
.
Możemy oczekiwać, że wywołanie jakiegokolwiek echo
nie spowoduje żadnych efektów ubocznych, ponieważ jest to czysta funkcja.
Jednak jeśli używamy Future
lub Id
jako kontekstu wykonania, nasza aplikacja zacznie nasłuchiwać na standardowym strumieniu wejścia (stdin):
Zaburzyliśmy czystość i tym samym nie piszemy już kodu funkcyjnego: futureEcho
jest rezultatem wykonania echo
jeden raz.
Future
łączy definicję programu z jego interpretacją (uruchomieniem). Tym samym, trudnym staje się rozumowanie o
aplikacjach zbudowanych przy użyciu Future
.
Możemy zdefiniować prosty i bezpieczny kontekst wykonania F[_]
który leniwie wykonuje thunk8. IO
jest zwyczajną strukturą danych, która zawiera kawałek (potencjalnie)
nieczystego kodu, ale go nie wykonuje. Możemy zaimplementować Terminal[IO]
i wywołać echo[IO]
aby dostać z powrotem wartość
Taka wartość delayed
może być reużywana, gdyż jest to tylko definicja pracy, która musi zostać wykonana. Możemy
przemapować String
i tworzyć kolejne programy, podobnie jak mapowalibyśmy Future
. IO
pozwala nam zachować szczerość
co do tego, że zależymy od pewnych interakcji ze światem zewnętrznym, ale nie pozbawia nas dostępu do wyniku tej interakcji.
Nieczysty kod wewnątrz IO
jest ewaluowany, kiedy wywołamy .interpret()
na zwróconej wartości. Wywołanie to jest oczywiście
również nieczystą akcją
Aplikacja złożona z programów opartych o IO
jest interpretowana jedynie raz, wewnątrz metody main
, która jest
również zwana końcem świata (the end of the world).
W następnych rozdziałach rozszerzymy wprowadzone tutaj koncepcje i pokażemy jak pisać utrzymywalne czyste funkcje, które rozwiązują stawiane przed nimi problemy.
2. Konstrukcja for
for comprehension
to idealna abstrakcja do pisania funkcyjnych programów komunikujących się ze światem.
Ponieważ będziemy używać jej bardzo często, poświęcimy chwilę na przypomnienie sobie zasad jej działania, a
przy okazji zobaczymy, jak Scalaz może pomóc nam pisać czystszy kod.
Ten rozdział nie skupia się na programowaniu czysto funkcyjnym, a techniki w nim opisane znajdą zastosowanie również w zwykłych aplikacjach.
2.1 Wzbogacona składnia
Konstrukcja for
w Scali jest prostą regułę przepisania (rewrite rule), zwaną również cukrem składniowym (syntactic sugar),
i nie wnosi żadnych dodatkowych informacji do naszego programu.
Aby zobaczyć, co tak naprawdę robi for
, użyjemy funkcji show
i reify
dostępnych w REPLu. Dzięki nim możemy
wypisać kod w formie, jaką przyjmuje po inferencji typów (type inference).
Widzimy dużo szumu spowodowanego dodatkowymi wzbogaceniami (np. +
jest przepisany jako $plus
itp.).
Dla zwiększenia zwięzłości w dalszych przykładach pominiemy wywołania show
oraz reify
, kiedy linia rozpoczyna się
od reify>
. Dodatkowo generowany kod poddamy ręcznemu oczyszczeniu, aby nie rozpraszać czytelnika.
Zasadą kciuka jest, że każdy <-
(zwany generatorem) jest równoznaczny z zagnieżdżonym wywołaniem flatMap
,
z wyjątkiem ostatniego, który jest wywołaniem funkcji map
, do której przekazane zostaje ciało bloku yield
.
2.1.1 Przypisania
Możemy bezpośrednio przypisywać wartości do zmiennych za pomocą wyrażeń typu ij = i + j
(słowo kluczowe val
nie jest wymagane).
Wywołanie map
na b
wprowadza zmienną ij
, która jest flat-mapowana razem z j
, a na końcu
wołane jest ostateczne map
wykorzystujące kod z bloku yield
.
Niestety nie możemy deklarować przypisań przed użyciem generatora. Funkcjonalność ta została zasugerowana, ale nie została jeszcze zaimplementowana: https://github.com/scala/bug/issues/907
Możemy obejść to ograniczenie poprzez zadeklarowanie zmiennej poza konstrukcją for
lub poprzez stworzenie Option
z pierwotnej wartości
2.1.2 Filtrowanie
Możemy umieścić wyrażenie if
za generatorem, aby ograniczyć wartości za pomocą predykatu
Starsze wersje Scali używały metody filter
, ale ponieważ Traversable.filter
tworzy nową kolekcję dla każdego predykatu,
wprowadzono metodę withFilter
jako bardziej wydajną alternatywę. Musimy uważać, aby przypadkowo nie wywołać withFilter
, podając informację
co do oczekiwanego typu, którą kompilator interpretuje jako pattern matching.
Podobnie jak w przypadku przypisywania wartości do zmiennych, generatory mogą używać pattern matchingu po swojej lewej stronie. W przeciwieństwie
do przypisań (które rzucają MatchError
w przypadku niepowodzenia), generatory są filtrowane i nie rzucają wyjątków
w czasie wykonania. Niestety dzieje się to kosztem podwójnego zaaplikowania wzoru.
2.1.3 For Each
Jeśli nie użyjemy słowa yield
, kompilator użyje metody foreach
zamiast flatMap
, co ma sens
jedynie w obecności efektów ubocznych.
2.1.4 Podsumowanie
Pełen zbiór metod używanych przez konstrukcję for
nie ma jednego wspólnego interfejsu, a każde użycie jest
niezależnie kompilowane. Gdyby taki interfejs istniał, wyglądałby mniej więcej tak:
Jeśli kontekst (C[_]
) konstrukcji for
nie dostarcza swoich własnych metod map
i flatMap
to nie wszystko jeszcze stracone. Jeśli dostępna jest niejawna instancja typu scalaz.Bind[C]
to dostarczy ona potrzebne metody map
oraz flatMap
.
2.2 Obsługa błędów
Jak dotąd patrzyliśmy jedynie na reguły przepisywania, nie natomiast na to, co dzieje się wewnątrz metod map
i flatMap
.
Zastanówmy się co dzieje się, kiedy kontekst for
zadecyduje, że nie może kontynuować działania.
W przykładzie bazującym na typie Option
blok yield
wywoływany jest jedynie kiedy wartości wszystkich zmiennych
i, j, k
są zdefiniowane.
Jeśli którakolwiek ze zmiennych a, b, c
przyjmie wartość None
, konstrukcja for
zrobi zwarcie9 i zwróci None
, nie mówiąc
nam co poszło nie tak.
Jeśli użyjemy typu Either
, wtedy Left
powodować będzie zwarcie konstrukcji for
z dodatkową informacją, którą niesie w sobie.
Rozwiązanie to jest zdecydowanie lepsze w przypadku raportowania błędów niż użycie typu Option
:
Na koniec spójrzmy co stanie się z typem Future
, który zawiedzie
Wartość Future
, która wypisuje wiadomość do terminala, nie jest nigdy tworzona, ponieważ,
tak jak w przypadku Option
i Either
, konstrukcja for
zwiera obwód i zakańcza przetwarzanie.
Zwieranie obwodu w przypadku odejścia od oczekiwanej ścieżki przetwarzania jest ważnym i często spotykanym rozwiązaniem.
Pamiętajmy, że konstrukcja for
nie jest w stanie obsłużyć uprzątnięcia zasobów (resource cleanup), a więc nie ma możliwości wyrażenia zachowania
podobnego do try
/finally
. Rozwiązanie to jest dobre, gdyż jasno deklaruje, że to kontekst
(który zazwyczaj jest Monad
ą, jak zobaczymy później), a nie logika biznesowa, jest odpowiedzialny za obsługę błędów
i uprzątnięcie zasobów.
2.3 Sztuczki
Chociaż łatwo jest przepisać prosty kod sekwencyjny przy pomocy konstrukcji for
,
czasami chcielibyśmy zrobić coś, co w praktyce wymaga mentalnych fikołków. Ten rozdział zbiera
niektóre praktyczne przykłady i pokazuje jak sobie z nimi radzić.
2.3.1 Wyjście awaryjne
Powiedzmy, że wywołujemy metodę, która zwraca typ Option
. Jeśli wywołanie to się nie powiedzie,
chcielibyśmy użyć innej metody (i tak dalej i tak dalej), np. gdy używamy cache’a.
Jeśli chcemy zrobić to samo poprzez asynchroniczną wersję tego samego API
musimy uważać, aby nie spowodować zbędnych obliczeń, ponieważ
uruchomi oba zapytania. Możemy użyć pattern matchingu na pierwszym rezultacie, ale typ się nie zgadza
Musimy stworzyć Future
ze zmiennej cache
.
Future.successful
tworzy nową wartość typu Future
, podobnie jak konstruktor typu Option
lub
List
.
2.3.2 Wczesne wyjście
Powiedzmy, że znamy warunek, który pozwala nam szybciej zakończyć obliczenia z poprawną wartością.
Jeśli chcemy zakończyć je szybciej z błędem, standardowym sposobem na zrobienie tego w OOP10 jest rzucenie wyjątku
co można zapisać asynchronicznie jako
Lecz jeśli chcemy zakończyć obliczenia z poprawną wartością, prosty kod synchroniczny:
przekłada się na zagnieżdżone konstrukcje for
, gdy tylko nasze zależności staną się asynchroniczne:
2.4 Łączenie kontekstów
Kontekst, którego używamy wewnątrz konstrukcji for
, musi być niezmienny: nie możemy mieszać wielu różnych typów jak
na przykład Future
i Option
.
Nie ma nic, co pozwoliłoby nam mieszać dowolne dwa konteksty wewnątrz konstrukcji for
, ponieważ nie da się zdefiniować znaczenia takiej operacji.
Jednak gdy mamy do czynienia z konkretnymi kontekstami intencja jest zazwyczaj oczywista, tymczasem kompilator nadal nie przyjmuje naszego kodu.
Chcielibyśmy, aby konstrukcja for
zajęła się zewnętrznym kontekstem i pozwoliła nam
skupić się modyfikacji wartości wewnątrz instancji typu Option
.
Ukrywaniem zewnętrznego kontekstu zajmują się tzw. transformatory monad (monad transformers),
a Scalaz dostarcza nam implementacje tychże dla typów Option
i Either
, nazywające się
odpowiednio OptionT
oraz EitherT
.
Kontekst zewnętrzny może być dowolnym kontekstem, który sam w sobie kompatybilny jest z konstrukcją for
,
musi jedynie pozostać niezmienny.
Tworzymy instancję OptionT
z każdego wywołania metody, zmieniając tym samym kontekst z Future[Option[_]]
na OptionT[Future, _]
.
.run
pozwala nam wrócić do oryginalnego kontekstu
Transformatory monad pozwalają nam mieszać wywołania funkcji zwracających Future[Option[_]]
z
funkcjami zwracającymi po prostu Future
poprzez metodę .liftM[OptionT]
(pochodzącą ze scalaz):
Dodatkowo możemy mieszać wartości typu Option
poprzez wywołanie
Future.successful
(lub .pure[Future]
), a następnie OptionT
.
Znów zrobił się mały bałagan, ale i tak jest lepiej niż gdybyśmy ręcznie pisali zagnieżdżone
wywołania metod flatMap
oraz map
. Możemy nieco uprzątnąć za pomocą DSLa który obsłuży
wszystkie wymagane konwersje
w połączeniu z operatorem |>
, który aplikuje funkcje podaną po prawej stronie na argumencie
podanym z lewej strony, możemy wizualnie oddzielić logikę od transformacji.
To podejście działa również dla Either
i innych typów danych, ale w ich przypadku metody pomocnicze są bardziej skomplikowane
i wymagają dodatkowy parametrów. Scalaz dostarcza wiele transformatorów
monad dla typów, które definiuje, więc zawsze
warto sprawdzić, czy ten, którego potrzebujemy jest dostępny.
3. Projektowanie aplikacji
W tym rozdziale napiszemy logikę biznesową oraz testy dla czysto funkcyjnej aplikacji serwerowej.
Kod źródłowy tej aplikacji dostępny jest wraz ze źródłami tej książki w katalogu example
.
Nie mniej lepiej nie zagłębiać się w niego, zanim nie dotrzemy do ostatniego rozdziału, gdyż
wraz z poznawaniem technik FP będziemy go istotnie zmieniać.
3.1 Specyfikacja
Nasza aplikacja będzie zarządzać farmą serwerów, tworzoną na bazie zapotrzebowania i operującą z możliwie niskim budżetem. Będzie ona nasłuchiwać wiadomości od serwera CI Drone i uruchamiać agenty (maszyny robocze) używając Google Container Engine (GKE), tak aby zaspokoić potrzeby kolejki zadań.
Drone otrzymuje pracę do wykonania kiedy kontrybutor zgłasza pull request w obsługiwanym projekcie na githubie. Drone przydziela pracę swoim agentom, gdzie każdy z nich przetwarza jedno zadanie w danym momencie.
Zadaniem naszej aplikacji jest zagwarantować, że zawsze jest dość agentów, aby wykonać potrzebną pracę, jednocześnie dbając, aby ich liczba nie przekroczyła określonej granicy i minimalizując całkowite koszta. Aby tego dokonać potrzebna będzie liczba elementów w kolejce i liczba dostępnych agentów.
Google potrafi tworzyć węzły (nodes), każdy z nich może być gospodarzem dla wielu agentów równocześnie. Agent podczas startu rejestruje się w serwerze, który od tej pory kontroluje jego cykl życia (wliczając cykliczne weryfikowanie czy agent jest nadal aktywny).
GKE pobiera opłatę za każdą minutę działania węzła, zaokrąglając czas do najbliższej godziny. Aby osiągnąć maksymalną efektywność, nie możemy po prostu tworzyć nowych węzłów dla każdego zadania. Zamiast tego powinniśmy reużywać wcześniej stworzone węzły i utrzymywać je do 58 minuty ich działania.
Nasza aplikacja musi być w stanie uruchamiać i zatrzymywać węzły, sprawdzać ich status (np. czas działania, aktywność) oraz wiedzieć, jaki jest aktualny czas wg GKE.
Dodatkowo, nie jest dostępne żadne API, które pozwoliłoby rozmawiać bezpośrednio z danym agentem, tak więc nie wiemy, czy aktualnie wykonuje on jakąś pracę dla serwera. Jeśli przypadkowo zatrzymamy agenta w czasie wykonywania pracy, jest to niewygodne, gdyż wymaga ludzkiej interakcji i ponownego rozpoczęcia zadania.
Kontrybutorzy mogą ręcznie dodawać agentów do farmy, tak więc liczba agentów i węzłów może być różna. Nie musimy dodawać węzłów, jeśli dostępni są wolni agenci.
W przypadku awarii powinniśmy zawsze wybierać najtańszą opcję.
Zarówno Drone, jak i GKE udostępniają JSONowe REST API zabezpieczone OAuth 2.0.
3.2 Interfejsy i algebry
Spróbujmy teraz skodyfikować diagram architektury z poprzedniego rozdziału. Po pierwsze powinniśmy zdefiniować prosty typ danych do przechowywania znacznika czasu z dokładnością do milisekund. Niestety typ taki nie jest dostępny ani w bibliotece standardowej Javy ani Scali.
W FP algebra zajmuje miejsce interfejsu z Javy lub zbioru poprawnych wiadomości obsługiwanych przez aktora z Akki. W tej właśnie warstwie definiujemy wszystkie operacje naszego systemu, które prowadzą do komunikacji ze światem zewnętrznym a tym samym do efektów ubocznych.
Istnieje ścisła więź między algebrami a logiką biznesową. Często przechodzić będziemy przez kolejne iteracje, w których próbujemy zamodelować nasz problem, następnie implementujemy rozwiązanie, tylko po to, aby przekonać się, że nasz model i zrozumienie problemu wcale nie było tak kompletne, jak nam się wydawało.
Użyliśmy typu NonEmptyList
, który można łatwo utworzyć, wywołując metodę .toNel
na standardowej liście, co zwraca nam
Option[NonEmptyList]
. Poza tym wszystko powinno być jasne.
3.3 Logika biznesowa
Teraz przyszedł czas na napisanie logiki biznesowej, która definiuje zachowanie naszej aplikacji. Na razie rozpatrywać będziemy tylko szczęśliwy scenariusz (happy path).
Potrzebujemy klasy WorldView
, która przechowywać będzie całość naszej wiedzy o świecie.
Gdybyśmy projektowali naszą aplikację przy użyciu Akki, WorldView
najprawdopodobniej
zostałby zaimplementowany jako var
wewnątrz stanowego aktora.
WorldView
agreguje wartości zwracane przez wszystkie metody ze wcześniej zdefiniowanych algebr
oraz dodaje pole pending
, aby umożliwić śledzenie nieobsłużonych jeszcze żądań.
Teraz prawie gotowi jesteśmy, aby zacząć pisać naszą logikę biznesową, ale musimy zadeklarować, że zależy ona
od algebr Drone
in Machines
.
Możemy zacząć od interfejsu dla naszej logiki
i zaimplementować go za pomocą modułu. Moduł zależy wyłącznie od innych modułów, algebr i czystych funkcji oraz
potrafi abstrahować nad F
. Jeśli implementacja algebraicznego interfejsu zależy od konkretnego typu, np. IO
,
nazywamy ją interpreterem.
Ograniczenie kontekstu (context bound) poprzez typ Monad
oznacza, że F
jest monadyczne, pozwalając nam tym samym na używanie
metod map
, pure
, i oczywiście, flatmap
wewnątrz konstrukcji for
.
Mamy dostęp do algebr Drone
i Machines
poprzez D
i M
. Używanie pojedynczych wielkich liter jest popularną konwencją
dla implementacji algebr i typeklas.
Nasza logika biznesowa działać będzie wewnątrz nieskończonej pętli, która może być zapisana jako pseudokod:
3.3.1 initial
Wewnątrz metody initial
wywołujemy wszystkie zewnętrzne serwisy, a wyniki tych wywołań zapisujemy wewnątrz
instancji WorldView
. Pole pending
domyślnie pozostaje puste.
Przypomnij sobie, jak w Rozdziale 1 mówiliśmy, że flatMap
(używany wraz z generatorem <-
)
pozwala nam operować na wartościach dostępnych w czasie wykonania. Kiedy zwracamy F[_]
to tak naprawdę
zwracamy kolejny program, który zostanie zinterpretowany w czasie wykonania. Na takim programie wywołujemy flatMap
.
Tak właśnie możemy sekwencyjnie łączyć kod, który powoduje efekty uboczne, jednocześnie mogąc używać zupełnie czystej
(pozbawionej tychże efektów) implementacji w czasie testowania. FP może być przez to widziane jako Ekstremalne Mockowanie.
3.3.2 update
Metoda update
powinna wywołać initial
, aby odświeżyć nasz obraz świata, zachowując znane akcje, które oczekują na wywołanie (pole pending
).
Jeśli węzeł zmienił swój stan, usuwamy go z listy oczekujących, a jeśli akcja trwa dłużej niż 10 minut, to zakładamy, że zakończyła się porażką i zapominamy, że ją zainicjowaliśmy.
Konkretne funkcje takie jak .symdiff
nie wymagają testowych interpreterów, ponieważ mają bezpośrednio wyrażone zarówno
wejście, jak i wyjście. Możemy przenieść je do samodzielnego, bezstanowego obiektu, który można
testować w izolacji i testować jedynie publiczne metody modułu.
3.3.3 act
Metoda act
jest nieco bardziej skomplikowana, więc dla zwiększenia czytelności podzielimy ją na dwie części:
wykrywanie akcji, które należy wykonać oraz wykonywanie tychże akcji. To uproszczenie sprawia, że możemy wykonać tylko
jedną akcję per wywołanie, ale jest to całkiem rozsądne, biorąc pod uwagę, że dzięki temu możemy lepiej kontrolować wykonywane akcje
oraz wywoływać act
tak długo aż nie pozostanie żadna akcja do wykonania.
Wykrywanie konkretnych scenariuszy dzieje się poprzez ekstraktory bazujące na WorldView
, co w praktyce jest
po prostu bardziej ekspresywną formą warunków if
/ else
.
Musimy dodać agentów do farmy, jeśli praca gromadzi się w kolejce oraz nie ma żadnych agentów, aktywnych węzłów ani akcji oczekujących na wykonanie. Jako wynik zwracamy węzeł, który chcielibyśmy uruchomić.
Jeśli kolejka jest pusta, powinniśmy zatrzymać wszystkie nieaktywne (niewykonujące żadnych zadań) węzły. Pamiętając, że Google zawsze pobiera opłatę za pełne godziny, wyłączamy węzły jedynie w 58 minucie ich działania. Wynikiem jest lista węzłów do zatrzymania.
Jako zabezpieczenie finansowe zakładamy, że żaden węzeł nie może żyć dłużej niż 5 godzin.
Gdy już zdefiniowaliśmy scenariusze, które nas interesują, możemy przejść do implementacji metody act
.
Gdy chcemy aby, węzeł został uruchomiony lub zatrzymany, dodajemy go do listy pending
wraz z zapisem
czasu, w którym tę akcję zaplanowaliśmy.
Ponieważ NeedsAgent
i Stale
nie pokrywają wszystkich możliwych sytuacji, musimy również zdefiniować
zachowanie domyślne, które nie robi nic. Przypomnijmy z Rozdziału 2: .pure
tworzy (monadyczny) kontekst używany
wewnątrz for
z prostej wartości.
foldLeftM
działa podobnie do foldLeft
, z tą różnicą, że przyjmowana funkcja może zwracać wartość opakowaną w kontekst.
W naszym przypadku każda iteracja zwraca F[WorldView]
. M
w nazwie jest skrótem od Monadic. Niedługo dowiemy się
więcej o tego typu wyniesionych (lifted) funkcjach, które zachowują się tak, jak byśmy oczekiwali, ale przyjmują
funkcje zwracające wartości monadyczne zamiast zwykłych wartości.
3.4 Testy jednostkowe
Podejście funkcyjne do pisania aplikacji jest marzeniem projektanta: można skupić się na logice biznesowej,pozostawiając implementacji algebr pozostałym członkom zespołu.
Nasza aplikacja bardzo silnie zależy od upływu czasu oraz zewnętrznych webserwisów. Gdyby była to tradycyjna aplikacja napisania w duchu OOP, stworzylibyśmy mocki dla wszystkich wywołań lub testowych aktorów dla wysyłanych wiadomości. Mockowanie w FP jest równoznaczne z dostarczeniem alternatywnej implementacji algebr, od których zależymy. Algebry izolują części systemu, które muszą zostać zamockowane, czyli po prostu inaczej interpretowane w kontekście testów jednostkowych.
Zaczniemy od przygotowania danych testowych:
Implementujemy algebry poprzez rozszerzenie interfejsów Drone
i Machines
podając konkretny kontekst monadyczny,
który w najprostszym przypadku to po prostu Id
.
Nasza “mockowa” implementacja zwyczajnie odtwarza wcześniej przygotowany WorldView
.
Stan naszego systemu został wyizolowany, więc możemy użyć var
do jego przechowywania:
Kiedy piszemy testy jednostkowe (używając FlatSpec
z biblioteki Scalatest), tworzymy instancje Mutable
i importujemy wszystkie jej pola i metody.
Nasze drone
i machines
używają Id
jako kontekstu wykonania, więc interpretacja naszego programu
zwraca Id[WoldView]
, na którym bez żadnych problemów możemy wykonywać asercje.
W tym trywialnym scenariuszu sprawdzamy, czy initial
zwraca tę sama wartość, której użyliśmy
w naszej statycznej implementacji:
Możemy też stworzyć bardziej skomplikowane testy dla metod update
i act
,
które pomogą nam znaleźć błędy i dopracować wymagania:
Przejście przez pełen komplet testów byłby dość nudny, poniższe testy można łatwo zaimplementować,używając tego samego podejścia:
- nie proś o nowych agentów, gdy kolejka oczekujących jest niepusta
- nie wyłączaj agentów, jeśli węzły są zbyt młode
- wyłącz agenty, gdy backlog jest pusty a węzły wkrótce wygenerują nowe koszta
- nie wyłączaj agentów, gdy obecne są oczekujące akcje
- wyłącz agenty, gdy są zbyt stare, a backlog jest pusty
- wyłącz agenty, nawet jeśli wykonują prace, jeśli są zbyt starzy
- zignoruj nieodpowiadające oczekujące akcje podczas aktualizacji
Wszystkie te testy są synchroniczne i działają na wątku uruchamiającym testy oraz mogą być uruchamiane równolegle. Gdybyśmy zaprojektowali nasze testy z użyciem Akki, narażone byłyby na arbitralne timeouty, a błędy ukryte byłyby w logach.
Ciężko jest przecenić zwiększenie produktywności wynikające z prostych testów logiki biznesowej. Weź pod uwagę, że 90% czasu programisty podczas interakcji z klientem poświęcone jest na ulepszanie, aktualizowanie i poprawianie tych właśnie reguł. Wszystko inne to tylko szczegóły implementacyjne.
3.5 Przetwarzanie równoległe
Aplikacja, którą stworzyliśmy, uruchamia każdą z algebraicznych metod sekwencyjnie, mimo tego, że jest kilka oczywistych miejsc, w których praca może być wykonywana równolegle.
3.5.1 initial
W naszej definicji metody initial
moglibyśmy zarządać wszystkich informacji równocześnie, zamiast wykonywać tylko jedno
zapytanie na raz.
W przeciwieństwie do metody flatMap
, która działa sekwencyjnie, Scalaz dostarcza składnie Apply
przewidzianą do operacji równoległych:
możemy również użyć notacji infiksowej (infix notation):
Jeśli każda z operacji równoległych zwraca ten sam kontekst, możemy wywołać funkcję w momencie, gdy wszystkie zwrócą wynik.
Przepiszmy initial
tak, aby skorzystać z tej możliwości:
3.5.2 act
W aktualnej implementacji act
zatrzymujemy każdy z węzłów sekwencyjnie, czekając na wynik i kontynuując pracę,
dopiero gdy operacja się zakończy. Moglibyśmy jednak zatrzymać wszystkie węzły równolegle i na koniec zaktualizować
nasz obraz świata.
Wadą tego rozwiązania jest fakt, że błąd w którejkolwiek akcji spowoduje zwarcie, zanim zdążymy zaktualizować pole
pending
. Wydaje się to być rozsądnym kompromisem, gdyż nasza metoda update
poradzi sobie z sytuacją, w której
węzeł niespodziewanie się zatrzyma.
Potrzebujemy metody, która operuje na typie NonEmptyList
i pozwoli nam przemap
ować każdy element na
F[MachineNode]
i zwróci F[NonEmptyList[MachineNode]]
. Metoda ta nazywa się traverse
, a gdy na jej rezultacie
wywołamy flatMap
, otrzymamy wartość typu NonEmptyList[MachineNode]
z którą możemy sobie poradzić w prosty sposób:
Co więcej, wygląda na to, że wersja równoległa jest łatwiejsza do zrozumienia niż wersja sekwencyjna.
3.6 Podsumowanie
- Algebry definiują interfejsy między systemami
- Moduły implementują algebry, używając innych algebr
-
Interpretery to konkretne implementacje algebr dla określonego
F[_]
- Interpretery testowe mogą zamienić części systemu wywołujące efekty uboczne, dając nam wysokie pokrycie testami.
4. Dane i funkcjonalności
Przychodząc ze świata obiektowego, jesteśmy przyzwyczajeni do myślenia o danych i funkcjonalnościach jako jednym: hierarchie klas zawierają metody, a traity mogą wymagać obecności konkretnych pól (danych).
Polimorfizm obiektu w czasie wykonania, bazujący na relacji “jest” (is a), wymaga od klas, aby dziedziczyły po wspólnym interfejsie. Rozwiązanie to może wywołać spory bałagan, gdy tylko ilość naszego kodu zacznie się istotnie zwiększać. Proste struktury danych zostają przysłonięte setkami linii kodu implementującego kolejne metody, traity, które wmiksowujemy do naszych klas, zaczynają cierpieć na problemy związane z kolejnością inicjalizacji, a testowanie i mockowanie ściśle powiązanych komponentów staje się katorgą.
FP podchodzi inaczej do tego problemu, rozdzielając definicje funkcjonalności i danych. W tym rozdziale poznamy podstawowe typy danych i zalety ograniczenia się do podzbioru funkcjonalności oferowanych przez Scalę. Odkryjemy również typeklasy jako sposób na osiągnięcie polimorfizmu już na etapie kompilacji: zaczniemy myśleć o strukturach danych w kategoriach relacji “ma” (has a) zamiast “jest”.
4.1 Dane
Podstawowymi materiałami używanymi do budowania typów danych są:
-
final case class
, czyli klasy znane również jako produkty (products) -
sealed abstract class
znane również jako koprodukty (coproducts) -
case object
oraz typy proste takie jakInt
,Double
,String
to wartości (values)12
z tym ograniczeniem, że nie mogą one mieć żadnych metod ani pól innych niż parametry konstruktora. Preferujemy
abstract class
nad trait
, aby zyskać lepszą kompatybilność binarną i nie zachęcać do wmiksowywania traitów.
Wspólna nazwa dla produktów, koproduktów i wartości to Algebraiczny Typ Danych13 (ADT).
Składamy typy danych analogicznie do algebry Boole’a opartej na operacjach AND
i XOR
(wykluczający OR
):
produkt zawiera wszystkie typy, z których się składa, a koprodukt jest jednym z nich. Na przykład
- produkt:
ABC = a AND b AND c
- koprodukt:
XYZ = x XOR y XOR z
co zapisane w Scali wygląda tak:
4.1.1 Rekursywne ADT
Kiedy ADT odnosi się to samego siebie, staje się Rekursywnym Algebraicznym Typem Danych.
scalaz.IList
, bezpieczna alternatywa dla typu List
z biblioteki standardowej, to rekursywne ADT, ponieważ
ICons
zawiera referencje do IList
.:
4.1.2 Funkcje w ADT
ADT mogą zawierać czyste funkcje
ale sprawia to, że stają się mniej oczywiste, niż mogłoby się wydawać, gdyż sposób, w jaki są wyrażone w JVMie,
nie jest idealny. Dla przykładu Serializable
, hashCode
, equals
i toString
nie zachowują się tak, jak
byśmy się tego spodziewali.
Niestety, Serializable
używany jest przez wiele frameworków mimo tego, że istnieje dużo lepszych alternatyw. Częstą
pułapką jest zapomnienie, że Serializable
może próbować zserializować całe domknięcie (closure) funkcji,
co może np. zabić produkcyjny serwer, na którym uruchomiona jest aplikacja. Podobnymi problemami obciążone są inne typy Javowe
takie jak na przykład Throwable
, który niesie w sobie referencje do arbitralnych obiektów.
Zbadamy dostępne alternatywy, gdy pochylimy się nad biblioteką Scalaz w następnym rozdziale. Kosztem tych alternatyw będzie poświęcenie interoperacyjności (interoperability) z częścią ekosystemu Javy i Scali.
4.1.3 Wyczerpywalność14
Istotne jest, że definiując typy danych, używamy konstrukcji sealed abstract class
, a nie abstract class
. Zapieczętowanie
(sealing) klasy oznacza, że wszystkie podtypy (subtypes) muszą być zdefiniowane w tym samym pliku, pozwalając tym samym
kompilatorowi na sprawdzanie, czy pattern matching jest wyczerpujący. Dodatkowo informacja ta może być wykorzystana przez makra,
które pomagają nam eliminować boilerplate.
Jak widzimy, kompilator jest w stanie pokazać deweloperowi, co zostało zepsute, gdy ten dodał nowy wariant do koproduktu
lub pominął już istniejący. Używamy tutaj flagi kompilatora -Xfatal-warnings
, bo w przeciwnym przypadku błąd ten jest jedynie ostrzeżeniem.
Jednakże kompilator nie jest w stanie wykonać koniecznych sprawdzeń, gdy klasa nie jest zapieczętowana lub gdy używamy dodatkowych ograniczeń (guards), np.:
Aby zachować bezpieczeństwo, nie używaj ograniczeń na zapieczętowanych typach.
Nowa flaga, -Xstrict-patmat-analysis
, została zaproponowana, aby dodatkowo
wzmocnić bezpieczeństwo pattern matchingu.
4.1.4 Alternatywne produkty i koprodukty
Inną formą wyrażenia produktu jest tupla (inaczej krotka, ang. tuple), która przypomina finalną case klasę pozbawioną etykiet.
(A.type, B, C)
jest równoznaczna z ABC
z wcześniejszego przykładu, ale do konstruowania ADT
lepiej jest używać klas, ponieważ brak nazw jest problematyczne w praktyce. Dodatkowo case klasy są zdecydowanie bardziej wydajne
przy operowaniu na wartościach typów prostych (primitive values).
Inną formą wyrażenia koproduktu jest zagnieżdżanie typu Either
, np.
co jest równoznaczne z zapieczętowaną klasą abstrakcyjną XYZ
. Aby uzyskać czystszą składnię do definiowania zagnieżdżonych
typów Either
, możemy zdefiniować alias typu zakończony dwukropkiem, co sprawi, że używając notacji infiksowej, będzie on wiązał
argument po prawej stronie jako pierwszy15.
Anonimowe koprodukty stają się przydatne, gdy nie jesteśmy w stanie umieścić wszystkich typów w jednym pliku.
Alternatywnym rozwiązaniem jest zdefiniowanie nowej zapieczętowanej klasy, której podtypy opakowują potrzebne nam typy.
Pattern matching na tych formach koproduktów jest dość mozolny, dlatego też w Dottym (kompilatorze Scali następnej generacji) dostępne są Unie (union types). Istnieją również biblioteki (oparte o makra), takie jak totalitarian czy iota, które dostarczają kolejne sposoby na wyrażanie koproduktów.
4.1.5 Przekazywanie informacji
Typy danych, oprócz pełnienia funkcji kontenerów na kluczowe informacje biznesowe, pozwalają nam również wyrażać ograniczenia dla tychże danych. Na przykład instancja typu
nigdy nie będzie pusta. Sprawia to, że scalaz.NonEmptyList
jest użytecznym typem danych mimo tego, że
zawiera dokładnie te same dane jak IList
.
Produkty często zawierają typy, które są dużo bardziej ogólne, niż ma to sens. W tradycyjnym podejściu obiektowym moglibyśmy obsłużyć taki przypadek poprzez walidację danych za pomocą asercji:
Zamiast tego, możemy użyć typu Either
i zwracać Right[Person]
dla poprawnych instancji, zapobiegając tym samym przed
propagacją niepoprawnych instancji. Zauważ, że konstruktor jest prywatny:
4.1.5.1 Rafinowane typy danych16
Prostym sposobem ograniczenie zbioru możliwych wartości ogólnego typu jest użycie biblioteki refined.
Aby zainstalować refined
, dodaj poniższą linię do pliku build.sbt
.
oraz poniższe importy do swojego kodu
Refined
pozwala nam zdefiniować klasę Person
, używając rafinacji ad-hoc, aby zapisać dokładne wymagania co do typu.
Rafinację taką wyrażamy jako typ A Refined B
.
Dostęp do oryginalnej wartości odbywa się poprzez .value
. Aby skonstruować instancje rafinowanego typu
w czasie działa programu, możemy użyć metody .refineV
, która zwróci nam Either
.
Jeśli dodamy poniższy import
możemy konstruować poprawne wartości z walidacją na etapie kompilacji:
Możemy również wyrażać bardziej skomplikowane wymagania, np. za pomocą gotowej reguły MaxSize
dostępnej
po dodaniu poniższych importów
Oto jak wyrażamy wymagania, aby String
był jednocześnie niepusty i nie dłuższy niż 10 znaków:
Łatwo jest zdefiniować własne ograniczenia, które nie są dostępne bezpośrednio w bibliotece. Na przykład
w naszej aplikacji drone-dynamic-agents
potrzebować będziemy sposobu, aby upewnić się, że String
jest wiadomością
zgodną z formatem application/x-www-form-urlencoded
. Stwórzmy więc taką regułę, używając wyrażeń regularnych:
4.1.6 Dzielenie się jest łatwe
Przez to, że ADT nie dostarczają żadnych funkcjonalności, mają minimalny zbiór zależności. Sprawia to, że dzielenie tychże typów z innymi deweloperami jest nad wyraz łatwe i używając prostego jeżyka modelowania danych, komunikacja oparta o kod, a nie specjalnie przygotowane dokumenty, staje się możliwa nawet wewnątrz zespołów interdyscyplinarnych (a więc składających się dodatkowo z np. administratorów baz danych, specjalistów od UI czy analityków biznesowych).
Dodatkowo łatwiejsze staje się tworzenie narzędzi, które pomogą w konsumowaniu i produkowaniu schematów danych dla innych języków danych albo łączeniu protokołów komunikacji.
4.1.7 Wyliczanie złożoności
Złożoność typu danych to liczba możliwych do stworzenia wartości tego typu. Dobry typ danych ma najmniejszą możliwą złożoność, która pozwala mu przechować potrzebne informacje.
Typy proste mają z góry określoną złożoność:
-
Unit
ma dokładnie jedną wartość (dlatego nazywa się “jednostką”) -
Boolean
ma dwie wartości -
Int
ma 4,294,967,295 wartości -
String
ma efektywnie nieskończenie wiele wartości
Aby policzyć złożoność produktu, wystarczy pomnożyć złożoności jego składowych.
-
(Boolean, Boolean)
ma 4 wartości (2*2
) -
(Boolean, Boolean, Boolean)
ma 6 wartości (2*2*2
)
Aby policzyć złożoność koproduktu, sumujemy złożoności poszczególnych wariantów.
-
(Boolean |: Boolean)
ma 4 wartości (2+2
) -
(Boolean |: Boolean |: Boolean)
ma 6 wartości (2+2+2
)
Aby określić złożoność ADT sparametryzowanego typem, mnożymy każdą z części przez złożoność parametru:
-
Option[Boolean]
ma 3 wartości,Some[Boolean]
iNone
(2+1
)
W FP funkcje są totalne i muszą zwracać wartość dla każdego wejścia, bez żadnych wyjątków, a zmniejszanie złożoności wejścia i wyjścia jest najlepszą drogą do osiągnięcia totalności. Jako zasadę kciuka przyjąć można, że funkcja jest źle zaprojektowana, jeśli złożoność jej wyjścia jest większa niż złożoność produktu jej wejść: w takim przypadku staje się ona źródłem entropii.
Złożoność funkcji totalnej jest liczbą możliwych funkcji, które pasują do danej sygnatury typu, a więc innymi słowy, złożoność wyjścia do potęgi równej złożoności wejścia.
-
Unit => Boolean
ma złożoność 2 -
Boolean => Boolean
ma złożoność 4 -
Option[Boolean] => Option[Boolean]
ma złożoność 27 -
Boolean => Int
to zaledwie trylion kombinacji -
Int => Boolean
ma złożoność tak wielką, że gdyby każdej implementacji przypisać liczbę, to każda z tych liczb wymagałaby 4 gigabajtów pamięci, aby ją zapisać
W praktyce Int => Boolean
będzie zazwyczaj czymś tak trywialnym, jak sprawdzenie parzystości lub rzadkie (sparse)
wyrażenie zbioru bitów (BitSet
). Funkcja taka w ADT powinna być raczej zastąpiona koproduktem istotnych funkcji.
Gdy nasza złożoność to “nieskończoność na wejściu, nieskończoność na wyjściu” powinniśmy wprowadzić bardziej restrykcyjne typy
i walidacje wejścia, na przykład używając konstrukcji Refined
wspomnianej w poprzedniej sekcji.
Zdolność do wyliczania złożoności sygnatury typu ma jeszcze jedno praktyczne zastosowanie: możemy odszukać prostsze sygnatury przy pomocy matematyki na poziomie szkoły średniej! Aby przejść od sygnatury do jej złożoności, po prostu zamień
-
Either[A, B]
naa + b
-
(A, B)
naa * b
-
A => B
nab ^ a
a następnie poprzestawiaj i zamień z powrotem. Dla przykładu powiedzmy, że zaprojektowaliśmy framework oparty na callbackach i dotarliśmy do miejsca, w którym potrzebujemy takiej sygnatury:
Możemy ją przekonwertować i przetransformować
a następnie zamienić z powrotem, aby otrzymać
która jest zdecydowanie prostsza: wystarczy, że użytkownik dostarczy nam Either[A, B] => C
.
Ta sama metoda może być użyta aby udowodnić, że
jest równoznaczna z
co znane jest jako currying lub rozwijanie funkcji.
4.1.8 Preferuj koprodukty nad produkty
Archetypowym problemem, który pojawia się bardzo często, są wzajemnie wykluczające się parametry konfiguracyjne
a
, b
i c
. Produkt (a: Boolean, b: Boolean, c: Boolean)
ma złożoność równą 8, podczas gdy złożoność koproduktu
to zaledwie 3. Lepiej jest zamodelować opisany scenariusz jako koprodukt niż pozwolić na wyrażenie pięciu zupełnie nieprawidłowych przypadków.
Złożoność typu danych wpływa również na testowanie kodu na nim opartego i praktycznie niemożliwym jest przetestowanie wszystkich możliwych wejść do funkcji. Całkiem łatwo jest jednak przetestować próbkę wartości za pomocą biblioteki do testowania właściwości17 Scalacheck. Jeśli prawdopodobieństwo poprawności losowej próbki danych jest niskie, jest to znak, że dane są niepoprawnie zamodelowane.
4.1.9 Optymalizacja
Dużą zaletą używania jedynie podzbioru języka Scala do definiowania typów danych jest to, że narzędzia mogą optymalizować bytecode potrzebny do reprezentacji tychże.
Na przykład, możemy spakować pola typu Boolean
i Option
do tablicy bajtów, cache’ować wartości,
memoizować hashCode
, optymalizować equals
, używać wyrażeń @switch
przy pattern matchingu i wiele, wiele więcej.
Optymalizacje te nie mogą być zastosowane do hierarchii klas w stylu OOP, które to mogą przechowywać wewnętrzny stan, rzucać wyjątki lub dostarczać doraźne implementacje metod.
4.2 Funkcjonalności
Czyste funkcje są najczęściej definiowane jako metody wewnątrz obiektu (definicji typu object
).
Jednakże, używanie obiektów może być nieco niezręczne, gdyż wymaga od programisty czytania kodu od wewnątrz do zewnątrz -
zamiast od lewej do prawej. Dodatkowo, funkcje z obiektu zawłaszczają przestrzeń nazw. Jeśli chcielibyśmy zdefiniować
funkcje sin(t: T)
gdzieś indziej, napotkalibyśmy błędy niejednoznacznych referencji (ambigous reference). Jest
to ten sam problem, który spotykamy w Javie, gdy wybieramy między metodami statycznymi i tymi definiowanymi w klasie.
Korzystając z konstrukcji implicit class
(znanej również jako extension methodology lub syntax) i odrobiny boilerplate’u
możemy uzyskać znaną nam składnię:
Często dobrze jest pominąć definiowanie obiektu i od razu sięgnąć po klasę niejawną (implicit class
), ograniczając boilerplate do minimum:
4.2.1 Funkcje polimorficzne
Bardziej popularnym rodzajem funkcji są funkcje polimorficzne, które żyją wewnątrz typeklas18. Typeklasa to trait, który:
- nie ma wewnętrznego stanu
- ma parametr typu
- ma przynajmniej jedną metodą abstrakcyjną (kombinator prymitywny (primitive combinator))
- może mieć metody uogólnione (kombinatory pochodne (derived combinators))
- może rozszerzać inne typeklasy
Dla każdego typu może istnieć tylko jedna instancja typeklasy, a właściwość ta nazywa się koherencją lub spójnością typeklas (typeclass coherence). Typeklasy mogą na pierwszy rzut oka wydawać się bardzo podobne do algebraicznych interfejsów, różnią się jednak tym, że algebry nie muszą zachowywać spójności.
Typeklasy używane się m.in. w bibliotece standardowej Scali. Przyjrzymy się uproszczonej wersji scala.math.Numeric
,
aby zademonstrować zasadę działania tej konstrukcji:
Możemy zaobserwować wszystkie kluczowe cechy typeklasy w praktyce:
- nie ma wewnętrznego stanu
-
Ordering
iNumeric
mają parametr typuT
-
Ordering
definiuje abstrakcyjną metodącompare
, aNumeric
metodyplus
,times
,negate
izero
-
Ordering
definiuje uogólnionelt
igt
bazujące nacompare
,Numeric
robi to samo zabs
, bazując nalt
,negate
orazzero
-
Numeric
rozszerzaOrdering
Możemy teraz napisać funkcję dla typów, które “posiadają” instancję typeklasy Numeric
:
Nie zależymy już od hierarchii klas w stylu OOP! Oznacza to, że wejście do naszej funkcji nie musi być instancją typu
Numeric
, co jest niezwykle ważne, kiedy chcemy zapewnić wsparcie dla klas zewnętrznych, których definicji nie jesteśmy
w stanie zmienić.
Inną zaletą typeklas jest to, że dane wiązane są z funkcjonalnościami na etapie kompilacji, a nie za pomocą dynamicznej dyspozycji (dynamic dispatch) w czasie działania programu, jak ma to miejsce w OOP.
Dla przykładu, tam, gdzie klasa List
może mieć tylko jedną implementację danej metody, używając typeklas, możemy
używać różnych implementacji zależnie od typu elementów zawartych wewnątrz. Tym samym wykonujemy część pracy
w czasie kompilacji, zamiast zostawiać ją do czasu wykonania.
4.2.2 Składnia
Składnia użyta to zapisania signOfTheTimes
jest nieco niezgrabna, ale jest kilka rzeczy, które możemy poprawić.
Użytkownicy chcieliby, aby nasza metoda używała wiązania kontekstu (context bounds), ponieważ wtedy
sygnaturę można przeczytać wprost jako: “przyjmuje T
, dla którego istnieje Numeric
”
Niestety, teraz musielibyśmy wszędzie używać implicitly[Numeric[T]]
. Możemy pomóc sobie, definiując
metodę pomocniczą w obiekcie towarzyszącym typeklasy
aby uzyskać dostęp do jej instancji w bardziej zwięzły sposób:
Nadal jednak jest to, dla nas, implementatorów, problem. Zmuszeni jesteśmy używać czytanej od wewnątrz do zewnątrz składni
metod statycznych zamiast czytanej od lewej do prawej składni tradycyjnej. Możemy sobie z tym poradzić poprzez
definicję obiektu ops
wewnątrz obiektu towarzyszącego typeklasy:
Zauważ, że zapis -x
rozwijany jest przez kompilator do x.unary_-
, dlatego też definiujemy rozszerzającą metodę (extension method)
unary_-
. Możemy teraz zapisać naszą funkcję w sposób zdecydowanie czystszy:
Dobra wiadomość jest taka, że nie musimy pisać całego tego boilerplatu własnoręcznie, ponieważ
Simulacrum dostarcza makro anotacje @typeclass
, która automatycznie
generuje dla nas metodę apply
i obiekt ops
. Dodatkowo pozwala nam nawet zdefiniować alternatywne (zazwyczaj symboliczne)
nazwy dla metod. Całość:
Kiedy używamy operatora symbolicznego, możemy czytać (nazywać) go jak odpowiadającą mu metodę. Np. <
przeczytamy
jako “less then”, a nie “left angle bracket”.
4.2.3 Instancje
Instancje typu Numeric
(które są również instancjami Ordering
) są definiowane jako implicit val
i rozszerzają
typeklasę, mogąc tym samym dostarczać bardziej optymalne implementacje generycznych metod:
Mimo że używamy tutaj +
, *
, unary_-
, <
i >
, które zdefiniowane są też przy użyciu @ops
(i mogłyby spowodować
nieskończoną pętlę wywołań), są one również zdefiniowane bezpośrednio dla typu Double
. Metody klasy są używane w
pierwszej kolejności, a dopiero w przypadku ich braku kompilator szuka metod rozszerzających. W rzeczywistości kompilator Scali
obsługuje wywołania tych metod w specjalny sposób i zamienia je bezpośrednio na instrukcje kodu bajtowego, odpowiednio dadd
, dmul
, dcmpl
i dcmpg
.
Możemy również zaimplementować Numeric
dla Javowego BigDecimal
(unikaj scala.BigDecimal
,
jest całkowicie zepsuty).
Możemy też zdefiniować nasz własny typ danych do reprezentowania liczb zespolonych:
Tym samym uzyskamy Numeric[Complex[T]]
, jeśli istnieje Numeric[T]
. Instancje te zależą od typu T
, a więc definiujemy
je jako def
, a nie val
.
Uważny czytelnik zauważy, że abs
jest czymś zupełnie innym, niż oczekiwałby matematyk. Poprawna wartość zwracana
z tej metody powinna być typu T
, a nie Complex[T]
.
scala.math.Numeric
stara się robić zbyt wiele i nie generalizuje ponad liczby rzeczywiste. Pokazuje nam to, że
małe, dobrze zdefiniowane typeklasy są często lepsze niż monolityczne kolekcje szczegółowych funkcjonalności.
4.2.4 Niejawne rozstrzyganie19
Wielokrotnie używaliśmy wartości niejawnych: ten rozdział ma na celu doprecyzować, czym one są i jak tak naprawdę działają.
O parametrach niejawnych (implicit parameters) mówimy, gdy metoda żąda, aby unikalna instancja określonego typu znajdowała się w niejawnym zakresie (implicit scope) wywołującego. Może do tego używać specjalnej składni ograniczeń kontekstu lub deklarowac je wprost. Parametry niejawne są więc dobrym sposobem na przekazywanie konfiguracji poprzez warstwy naszej aplikacji.
W tym przykładzie foo
wymaga aby dostępne były instancje typeklas Numeric
i Typeable
dla A, oraz instancja typu
Handler
, który przyjmuje dwa parametry typu.
Konwersje niejawne pojawiają się, gdy używamy implicit def
. Jednym z użyć niejawnych konwersji jest stosowanie
metod rozszerzających. Kiedy kompilator próbuje odnaleźć metodę, która ma zostać wywołana na obiekcie, przegląda metody zdefiniowane
w klasie tego obiektu, a następnie wszystkie klasy, po których ona dziedziczy (reguła podobna do tych znanych
z Javy). Jeśli takie poszukiwanie się nie powiedzie, kompilator zaczyna przeglądać zakres niejawny w poszukiwaniu
konwersji do innych typów, a następnie na nich szuka wspomnianej metody.
Inny przykładem użycia niejawnych konwersji jest derywacja typeklas (typeclass derivation). W poprzednim rozdziale
napisaliśmy metodę niejawną, która tworzyła instancję Numeric[Complex[T]]
jeśli dostępna była instancja Numeric[T]
.
Możemy w ten sposób łączyć wiele niejawnych metod (również rekurencyjnie), dochodząc tym samym do metody zwanej
“typeful programming”, która pozwala nam wykonywać obliczenia na etapie kompilacji, a nie w czasie działania programu.
Część, która łączy niejawne parametry (odbiorców) z niejawnymi konwersjami i wartościami (dostawcami), nazywa się niejawnym rozstrzyganiem.
Najpierw w poszukiwaniu wartości niejawnych przeszukiwany jest standardowy zakres zmiennych, wg kolejności:
- zakres lokalny, wliczając lokalne importy (np. ciało bloku lub metody)
- zakres zewnętrzny, wliczając lokalne importy (np. ciało klasy)
- przodkowie (np. ciało klasy, po której dziedziczymy)
- aktualny obiekt pakietu (package object)
- obiekty pakietów nadrzędnych (kiedy używamy zagnieżdżonych pakietów)
- importy globalne zdefiniowane w pliku
Jeśli nie uda się odnaleźć pasującej metody, przeszukiwany jest zakres specjalny, składający się z: wnętrza obiektu towarzyszącego danego typu, jego obiektu pakietu, obiektów pakietów zewnętrznych (jeśli jest zagnieżdżony)), a w przypadku porażki to samo powtarzane jest dla typów, po których nasza klasa dziedziczy. Operacje te wykonywane są kolejno dla:
- typu zadanego parametru
- oczekiwanego typu parametru
- parametru typu (jeśli istnieje)
Jeśli w tej samej fazie poszukiwań znalezione zostaną dwie pasujące wartości niejawne, zgłaszany jest błąd niejednoznaczności ambigous implicit error.
Wartości niejawne często definiowane są wewnątrz traitów, które następnie rozszerzane są przez obiekty. Praktyka ta podyktowana jest próbą kontrolowania priorytetów wg których kompilator dobiera pasującą wartość, unikając jednocześnie błędów niejednoznaczności.
Specyfikacja języka Scala jest dość nieprecyzyjna, jeśli chodzi o przypadki skrajne, co sprawia, że aktualna implementacja
kompilatora staje się standardem. Są pewne reguły, którymi będziemy się kierować w tej książce, jak na przykład
używanie implicit val
zamiast implicit object
, mimo że ta druga opcja jest bardziej zwięzła. Kaprys
kompilatora sprawia, że wartości definiowane jako implicit object
wewnątrz obiektu towarzyszącego są traktowane inaczej niż te definiowane za pomocą implicit val
.
Niejawne rozstrzyganie zawodzi, kiedy typeklasy tworzą hierarchię tak jak w przypadku klas Ordering
i Numeric
.
Jeśli napiszemy funkcję, która przyjmuje niejawny parametr typu Ordering
i zawołamy ją z typem prymitywnym, który
posiada instancję Numeric
zdefiniowaną w obiekcie towarzyszącym typu Numeric
, kompilator rzeczonej instancji nie znajdzie.
Niejawne rozstrzyganie staje się prawdziwą loterią, gdy w grze pojawią się aliasy typu,
które zmieniają kształt parametru. Dla przykładu, parametr niejawny używający aliasu type Values[A] = List[Option[A]]
prawdopodobnie nie zostanie połączony z niejawną wartością zdefiniowaną dla typu List[Option[A]]
ponieważ kształt
zmienia się z kolekcji kolekcji elementów typu A
na kolekcję elementów typu A
.
4.3 Modelowanie OAuth2
Zakończymy ten rozdział praktycznym przykładem modelowania danych i derywacji typeklas połączonych z projektowaniem algebr i modułów, o którym mówiliśmy w poprzednim rozdziale.
W naszej aplikacje drone-dynamic-agents
chcielibyśmy komunikować się z serwerem Drone i Google Cloud używając JSONa poprzez
REST API. Obie usługi używają OAuth2 do uwierzytelniania użytkowników. Istnieje
wiele interpretacji OAuth2, ale my skupimy się na tej, która działa z Google Cloud (wersja współpracująca z Drone
jest jeszcze prostsza).
4.3.1 Opis
Każda aplikacja komunikująca się z Google Cloud musi mieć skonfigurowany Klucz Kliencki OAuth 2.0 (OAuth 2.0 Client Key) poprzez
co da nam dostęp do ID Klienta (Client ID) oraz Sekretu Klienta (Client secret).
Aplikacja może wtedy uzyskać jednorazowy kod poprzez sprawienie, aby użytkownik wykonał Prośbę o Autoryzację (Authorization Request) w swojej przeglądarce (tak, naprawdę, w swojej przeglądarce). Musimy więc wyświetlić poniższą stronę:
Kod dostarczony zostanie pod {CALLBACK_URI}
w postaci żądania GET
. Aby go odebrać, musimy posiadać serwer http
słuchający na interfejsie localhost
.
Gdy zdobędziemy kod, możemy wykonać Żądanie o Token Dostępu (Access Token Request):
na które odpowiedzią będzie dokument JSON
Tokeny posiadacza (Bearer tokens) zazwyczaj wygasają po godzinie i mogą być odświeżone poprzez wykonanie kolejnego żądania http z użyciem tokenu odświeżającego (refresh token):
na który odpowiedzią jest
Wszystkie żądania do serwera powinny zwierać nagłówek
z podstawioną rzeczywistą wartością BEARER_TOKEN
.
Google wygasza wszystkie tokeny oprócz najnowszych 50, a więc czas odświeżania to tylko wskazówka. Tokeny odświeżające trwają pomiędzy sesjami i mogą być wygaszone ręcznie przez użytkownika. Tak więc możemy mieć jednorazową aplikację do pobierania tokenu odświeżającego, który następnie umieścimy w konfiguracji drugiej aplikacji.
Drone nie implementuje endpointu /auth
ani tokenów odświeżających, a jedynie dostarcza BEARER_TOKEN
poprzez
interfejs użytkownika.
4.3.2 Dane
Pierwszym krokiem będzie zamodelowanie danych potrzebnych do implementacji OAuth2. Tworzymy więc ADT z dokładnie takimi
samymi polami jak te wymagane przez serwer OAuth2. Użyjemy typów String
i Long
dla zwięzłości, ale moglibyśmy użyć typów
rafinowanych, gdyby wyciekały one do naszego modelu biznesowego.
4.3.3 Funkcjonalność
Musimy przetransformować klasy zdefiniowane w poprzedniej sekcji do JSONa, URLi i formy znanej z żądań HTTP POST. Ponieważ aby to osiągnąć, niezbędny jest polimorfizm, potrzebować będziemy typeklas.
jsonformat
to prosta biblioteka do
pracy z JSONem, którą poznamy dokładniej w jednym z następnych rozdziałów. Została ona stworzona, stawiając na
pierwszym miejscu pryncypia programowania funkcyjnego i czytelność kodu. Składa się ona z AST do opisu JSONa
oraz typeklas do jego kodowania i dekodowania:
Potrzebować będziemy instancji JsDecoder[AccessResponse]
i JsDecoder[RefreshResponse]
. Możemy je zbudować,
używają, funkcji pomocniczej:
Umieścimy je w obiektach towarzyszących naszych typów danych, aby zawsze znajdowały się w zakresie niejawnym:
Możemy teraz sparsować ciąg znaków do typu AccessResponse
lub RefreshResponse
Musimy stworzyć nasze własne typeklasy do kodowania danych w postaci URLi i żądań POST. Poniżej widzimy całkiem rozsądny design:
Musimy zapewnić instancje dla typów podstawowych:
Używamy Refined.unsafeApply
, kiedy jesteśmy pewni, że zawartość stringa jest już poprawnie zakodowana i możemy
pominąć standardową weryfikację.
ilist
jest przykładem prostej derywacji typeklasy, w sposób podobny do tego, którego użyliśmy przy Numeric[Complex]
.
Metoda .intercalate
to bardziej ogólna wersja .mkString
.
W rozdziale poświęconym Derywacji typeklas pokażemy jak stworzyć instancję UrlQueryWriter
automatycznie oraz jak
oczyścić nieco kod, który już napisaliśmy. Na razie jednak napiszmy boilerplate dla typów, których chcemy używać:
4.3.4 Moduł
Tym samym zakończyliśmy modelowanie danych i funkcjonalności niezbędnych do implementacji protokołu OAuth2. Przypomnij sobie z poprzedniego rozdziału, że komponenty, które interagują ze światem zewnętrznym definiujemy jako algebry, a logikę biznesową jako moduły, aby pozwolić na gruntowne jej przetestowanie.
Definiujemy algebry, na których bazujemy oraz używamy ograniczeń kontekstu, aby pokazać, że nasze odpowiedzi muszą posiadać
instancję JsDecoder
, a żądania UrlEncodedWriter
:
Zauważ, że definiujemy jedynie szczęśliwy scenariusz w API klasy JsonClient
. Obsługą błędów zajmiemy się w jednym
z kolejnych rozdziałów.
Pozyskanie CodeToken
z serwera Google OAuth2
wymaga
- wystartowania serwera HTTP na lokalnej maszynie i odczytanie numeru portu, na którym nasłuchuje.
- otworzenia strony internetowej w przeglądarce użytkownika, tak aby mógł zalogować się do usług Google swoimi danymi i uwierzytelnić aplikacje
- przechwycenia kodu, poinformowania użytkownika o następnych krokach i zamknięcia serwera HTTP.
Możemy zamodelować to jako trzy metody wewnątrz algebry UserInteraction
.
Ujęte w ten sposób wydaje się to niemal proste.
Potrzebujemy również algebry pozwalającej abstrahować nam nad lokalnym czasem systemu
oraz typu danych, którego użyjemy w implementacji logiki odświeżania tokenów
Możemy teraz napisać nasz moduł klienta OAuth2:
4.4 Podsumowanie
-
algebraiczne typy danych (ADT) są definiowane jako produkty (
final case class
) i koprodukty (sealed abstract class
). - Typy
Refined
egzekwują ograniczenia na zbiorze możliwych wartości. - konkretne funkcje mogą być definiowane wewnątrz klas niejawnych (
implicit class
), aby zachować kierunek czytania od lewej do prawej. - funkcje polimorficzne definiowane są wewnątrz typeklas. Funkcjonalności zapewniane są poprzez ograniczenia kontekstu wyrażające relacje “ma”, a nie hierarchie klas wyrażające relacje “jest”.
- anotacja
@simulacrum.typeclass
generuje obiekt.ops
wewnątrz obiektu towarzyszącego, zapewniając wygodniejszą składnię dla metod typeklasy. - derywacja typeklas to kompozycja typeklas odbywająca się w czasie kompilacji.
5. Typeklasy ze Scalaz
W tym rozdziale przejdziemy przez niemal wszystkie typeklasy zdefiniowane w scalaz-core
. Nie wszystkie z nich
znajdują zastosowanie w drone-dynamic-agents
, więc czasami będziemy używać samodzielnych przykładów.
Napotkać można krytykę w stosunku do konwencji nazewniczych stosowanych w Scalaz i programowaniu funkcyjnym w
ogólności. Większość nazw podąża za konwencjami wprowadzonymi w Haskellu, który bazował z kolei na dziale matematyki
zwanym Teorią kategorii. Możesz śmiało użyć aliasów typów, jeśli uważasz, że rzeczowniki pochodzące od
głównej funkcjonalności są łatwiejsze do zapamiętania (np. Mappable
, Pureable
, FlatMappable
).
Zanim wprowadzimy hierarchię typeklas, popatrzmy na cztery metody, które są najistotniejsze z punktu widzenia kontroli przepływu. Metod tych używać będziemy w większości typowych aplikacji funkcyjnych:
Typeklasa | Metoda | Z | Mając dane | Do |
---|---|---|---|---|
Functor |
map |
F[A] |
A => B |
F[B] |
Applicative |
pure |
A |
F[A] |
|
Monad |
flatMap |
F[A] |
A => F[B] |
F[B] |
Traverse |
sequence |
F[G[A]] |
G[F[A]] |
Wiemy, że operacje zwracające F[_]
mogą być wykonywane sekwencyjnie wewnątrz konstrukcji for
przy użyciu
metody .flatMap
, która zdefiniowana jest wewnątrz Monad[F]
. Możemy myśleć o F[A]
jak o kontenerze na
pewien efekt, którego rezultatem jest wartość typu A
. .flatMap
pozwala nam wygenerować nowe efekty F[B]
na podstawie rezultatów wykonania wcześniejszych efektów.
Oczywiście nie wszystkie konstruktory typu F[_]
wiążą się z efektami ubocznymi, nawet jeśli mają instancję
Monad[F]
, często są to po prostu struktury danych. Używając najmniej konkretnej (czyli najbardziej ogólnej) abstrakcji
możemy w łatwy sposób współdzielić kod operujący na typach List
, Either
, Future
i wielu innych.
Jeśli jedyne czego potrzebujemy to przetransformować wynik F[_]
, wystarczy, że użyjemy metody map
, definiowanej w
typeklasie Functor
. W rozdziale 3 uruchamialiśmy efekty równolegle, tworząc produkt i mapując go. W Programowaniu
Funkcyjnym obliczenia wykonywane równolegle są uznawane za słabsze niż te wykonywane sekwencyjnie.
Pomiędzy Monad
ą i Functor
em leży Applicative
, która definiuje metodę pure
pozwalającą nam wynosić (lift)
wartości do postaci efektów lub tworzyć struktury danych z pojedynczych wartości.
.sequence
jest użyteczna, gdy chcemy poprzestawiać konstruktory typów. Gdy mamy F[G[_]]
a potrzebujemy G[F[_]]
,
np. zamiast List[Future[Int]]
potrzebujemy Future[List[Int]]
, wtedy właśnie użyjemy .sequence
.
5.1 Plan
Ten rozdział jest dłuższy niż zazwyczaj i wypełniony po brzegi informacjami. Przejście przez niego w wielu podejściach jest czymś zupełnie normalnym, a zapamiętanie wszystkiego wymagałoby supermocy. Potraktuj go raczej jako miejsce, do którego możesz wracać, gdy będziesz potrzebował więcej informacji.
Pominięte zostały typeklasy, które rozszerzają typ Monad
, gdyż zasłużyły na swój własny rozdział.
Scalaz używa generacji kodu, ale nie za pomocą simulacrum. Niemniej, dla zwięzłości prezentujemy przykłady bazujące na
anotacji @typeclass
. Równoznaczna składanie dostępna jest, gdy zaimportujemy scalaz._
i Scalaz._
, a jej
implementacja znajduje się w pakiecie scalaz.syntax
w kodzie źródłowym scalaz.
5.2 Rzeczy złączalne20
Semigroup
(półgrupa) może być zdefiniowana dla danego typu, jeśli możemy połączyć ze sobą dwie jego wartości. Operacja ta
musi być łączna (associative), co oznacza, że kolejność zagnieżdżonych operacji nie powinna mieć znaczenia, np:
Monoid
jest półgrupą z elementem zerowym (zero, zwanym również elementem pustym (empty), tożsamym (identity) lub neutralnym).
Połączenie zero
z dowolną inną wartością a
powinno zwracać to samo niezmienione a
.
Prawdopodobnie przywołaliśmy tym samym wspomnienie typu Numeric
z Rozdziału 4. Istnieją implementacje typeklasy
Monoid
dla wszystkich prymitywnych typów liczbowych, ale koncepcja rzeczy złączalnych jest użyteczna również
dla typów innych niż te liczbowe.
Band
(pas) dodaje prawo, gwarantujące, że append
wywołane na dwóch takich samych elementach jest
idempotentne, tzn. zwraca tę samą wartość. Przykładem są typy, które mają tylko jedną wartość, takie jak Unit
,
kresy górne (least upper bound), lub zbiory (Set
). Band
nie wnosi żadnych dodatkowych metod, ale użytkownicy
mogą wykorzystać dodatkowe gwarancje do optymalizacji wydajności.
Jako realistyczny przykład dla Monoid
u, rozważmy system transakcyjny, który posiada ogromną bazę reużywalnych
wzorów transakcji. Wypełnianie wartości domyślnych dla nowej transakcji wymaga wybrania i połączenia wielu
wzorów, z zasadą “ostatni wygrywa”, jeśli dwa wzory posiadają wartości dla tego samego pola. “Wybieranie” jest
wykonywane dla nas przez osobny system, a naszym zadaniem jest jedynie połączyć wzory według kolejności.
Stworzymy prosty schemat, aby zobrazować zasadę działania, pamiętając przy tym, że prawdziwy system oparty byłby na dużo bardziej skomplikowanym ADT.
Jeśli chcemy napisać metodę, która przyjmuje parametr templates: List[TradeTemplate]
, wystarczy, że zawołamy
i gotowe!
Jednak aby móc zawołać zero
lub |+|
musimy mieć dostęp do instancji Monoid[TradeTemplate]
. Chociaż
w ostatnim rozdziale zobaczymy jak wyderywować taką instancję w sposób generyczny, na razie stworzymy ją ręcznie:
Jednak nie jest to do końca to, czego byśmy chcieli, gdyż Monoid[Option[A]]
łączy ze sobą wartości wewnętrzne, np.
podczas gdy my chcielibyśmy zachowania “ostatni wygrywa”. Możemy więc nadpisać domyślną instancję Monoid[Option[A]]
naszą własną:
Wszystko kompiluje się poprawnie, więc wypróbujmy nasze dzieło…
Jedyne co musieliśmy zrobić to zdefiniować jeden mały kawałek logiki biznesowej, a całą resztę
zrobił za nas Monoid
!
Zauważ, że listy płatności są ze sobą łączone. Dzieje się tak, ponieważ domyślny Monoid[List]
zachowuje się ten właśnie sposób.
Gdyby wymagania biznesowe były inne, wystarczyłoby dostarczyć inną instancję Monoid[List[LocalDate]]
. Przypomnij sobie
z Rozdziału 4, że dzięki polimorfizmowi czasu kompilacji możemy zmieniać zachowanie append
dla List[E]
w zależności
od E
, a nie tylko od implementacji List
.
5.3 Rzeczy obiektowe
W rozdziale o Danych i funkcjonalnościach powiedzieliśmy, że sposób, w jaki JVM rozumie równość nie działa dla wielu typów,
które możemy umieścić wewnątrz ADT. Problem ten wynika z faktu, że JVM był projektowany dla języka Java, a equals
zdefiniowane jest w klasie java.lang.Object
, nie ma więc możliwości, aby equals
usunąć ani zagwarantować, że jest
explicite zaimplementowany.
Niemniej, w FP wolimy używać typeklas do wyrażania polimorficznych zachowań i pojęcie równości również może zostać w ten sposób wyrażone.
===
(potrójne równa się, triple equals) jest bezpieczniejszy względem typów (typesafe) niż ==
(podwójne równa się,
double equals), ponieważ użycie go wymaga, aby po obu stronach porównania znajdowały się instancje dokładnie tego samego typu.
W ten sposób możemy zapobiec wielu częstym błędom.
equal
ma te same wymagania jak Object.equals
-
przemienność (commutative):
f1 === f2
implikujef2 === f1
-
zwrotność (reflexive):
f === f
-
przechodniość (transitive):
f1 === f2 && f2 === f3
implikujef1 === f3
Poprzez odrzucenie uniwersalnego Object.equals
, gdy konstruujemy ADT, nie bierzemy za pewnik, że wiemy jak
porównywać instancje danego typu. Jeśli instancja Equal
nie będzie dostępna, nasz kod się nie skompiluje.
Kontynuując praktykę odrzucania zaszłości z Javy, zamiast mówić, że dane są instancją java.lang.Comparable
,
powiemy, że mają instancję typeklasy Order
:
Order
implementuje .equal
, wykorzystując nową metodę prostą .order
. Kiedy typeklasa implementuje
kombinator prymitywny rodzica za pomocą kombinatora pochodnego, musimy dodać domniemane prawo podstawiania
(implied law of substitution). Jeśli instancja Order
ma nadpisać .equal
z powodów wydajnościowych, musi ona zachowywać
się dokładnie tak samo jak oryginał.
Rzeczy, które definiują porządek, mogą również być dyskretne, pozwalając nam na przechodzenie do poprzedników i następników:
EphemeralStream
omówimy w następnym rozdziale, na razie wystarczy nam wiedzieć, że jest to potencjalnie nieskończona
struktura danych, która unika problemów z przetrzymywaniem pamięci obecnych w klasie Stream
z biblioteki standardowej.
Podobnie do Object.equals
, koncepcja metody .toString
dostępnej w każdej klasie ma sens jedynie w Javie.
Chcielibyśmy wymusić możliwość konwersji do ciągu znaków w czasie kompilacji i dokładnie to robi typeklasa Show
:
Lepiej poznamy klasę Cord
w rozdziale poświęconym typom danych, teraz jedyne co musimy wiedzieć, to to że
Cord
jest wydajną strukturą danych służącą do przechowywania i manipulowania instancjami typu String
.
5.4 Rzeczy mapowalne
Skupmy się teraz na rzeczach, które możemy w jakiś sposób przemapowywać (map over) lub trawersować (traverse):
5.4.1 Funktor
Jedyną metodą abstrakcyjną jest map
i musi się ona komponować (składać, compose), tzn. mapowanie za pomocą f
, a następnie g
musi dawać ten sam wyniki jak mapowanie z użyciem złożenia tych funkcji (f ∘ g
).
Metoda map
nie może też wykonywać żadnych zmian, jeśli przekazana do niej funkcja to identity
(czyli x => x
)
Functor
definiuje kilka pomocniczych metod wokół map
, które mogą być zoptymalizowane przez konkretne instancje.
Dokumentacja została celowo pominięta, aby zachęcić do samodzielnego odgadnięcia co te metody robią, zanim spojrzymy na ich
implementację. Poświęć chwilę na przestudiowanie samych sygnatur, zanim ruszysz dalej:
-
void
przyjmuje instancjęF[A]
i zawsze zwracaF[Unit]
, a więc gubi wszelkie przechowywane wartości, ale zachowuje strukturę. -
fproduct
przyjmuje takie same argumenty jakmap
, ale zwracaF[(A, B)]
, a więc łączy wynik operacji z wcześniejszą zawartością. Operacja ta przydaje się, gdy chcemy zachować argumenty przekazane do funkcji. -
fpair
powiela elementA
do postaciF[(A, A)]
-
strengthL
łączy zawartośćF[B]
ze stałą typuA
po lewej stronie. -
strengthR
łączy zawartośćF[A]
ze stałą typuB
po prawej stronie. -
lift
przyjmuje funkcjęA => B
i zwracaF[A] => F[B]
. Innymi słowy, przyjmuje funkcję, która operuje na zawartościF[A]
i zwraca funkcję, która operuje naF[A]
bezpośrednio. -
mapply
to łamigłówka. Powiedzmy, że mamyF[_]
z funkcjąA => B
w środku oraz wartośćA
, w rezultacie możemy otrzymaćF[B]
. Sygnatura wygląda podobnie dopure
, ale wymaga od wołającego dostarczeniaF[A => B]
.
fpair
, strengthL
i strengthR
wyglądają całkiem bezużytecznie, ale przydają się, gdy chcemy zachować pewne informacje,
które w innym wypadku zostałyby utracone.
Functor
ma też specjalną składnię:
.as
i >|
to metody pozwalające na zastąpienie wyniku przez przekazaną stałą.
W naszej przykładowej aplikacji wprowadziliśmy jeden brzydki hak, definiując metody start
i stop
tak,
aby zwracały swoje własne wejście:
Pozwala nam to opisywać logikę biznesową w bardzo zwięzły sposób, np.
albo
Ale hak ten wprowadza zbędną komplikację do implementacji. Lepiej będzie, gdy pozwolimy naszej algebrze zwracać
F[Unit]
a następnie użyjemy as
:
oraz
5.4.2 Foldable
Technicznie rzecz biorąc, Foldable
przydaje się dla struktur danych, przez które możemy przejść
a na koniec wyprodukować wartość podsumowującą. Jednak stwierdzenie to nie oddaje pełnej natury tej
“jednotypeklasowej armii”, która jest w stanie dostarczyć większość tego, co spodziewalibyśmy
się znaleźć w Collections API.
Do omówienia mamy tyle metod, że musimy je sobie podzielić. Zacznijmy od metod abstrakcyjnych:
Instancja Foldable
musi zaimplementować jedynie foldMap
i foldRight
, aby uzyskać pełną funkcjonalność
tej typeklasy, aczkolwiek poszczególne metody są często optymalizowane dla konkretnych struktur danych.
.foldMap
ma alternatywną nazwę do celów marketingowych: MapReduce. Mając do dyspozycji F[A]
,
funkcję z A
na B
i sposób na łączenie B
(dostarczony przez Monoid
wraz z elementem zerowym),
możemy wyprodukować “podsumowującą” wartość typu B
. Kolejność operacji nie jest narzucana, co pozwala
na wykonywanie ich równolegle.
.foldRight
nie wymaga, aby jej parametry miały instancję Monoid
u, co oznacza, że musimy podać
wartość zerową z
oraz sposób łączenia elementów z wartością podsumowująca. Kierunek przechodzenia jest
zdefiniowany jako od prawej do lewej, co sprawia, że operacje nie mogą być zrównoleglone.
foldLeft
trawersuje elementy od lewej do prawej. Metoda ta może być zaimplementowana za pomocą foldMap
, ale
większość instancji woli dostarczyć osobną implementację dla tak podstawowej operacji. Ponieważ z reguły implementacje
tej metody są ogonowo rekursywne (tail recursive), nie ma tutaj parametrów przekazywanych przez nazwę.
Jedyny prawem obowiązującym Foldable
jest to, że foldLeft
i foldRight
powinny być spójne z foldMap
dla
operacji monoidalnych, np. dodawanie na koniec listy dla foldLeft
i dodawanie na początek dla foldRight
.
Niemniej foldLeft
i foldRight
nie muszą być zgodne ze sobą nawzajem: w rzeczywistości często produkują
odwrotne rezultaty.
Najprostszą rzeczą, którą możemy zrobić z foldMap
to użyć funkcji identity
i uzyskać tym samym fold
(naturalną sumę elementów monoidalnych), z dodatkowymi wariantami pozwalającymi dobrać odpowiednią metodę
zależnie od kryteriów wydajnościowych:
Gdy uczyliśmy się o Monoid
zie, napisaliśmy:
Teraz wiemy już, że było to niemądre i powinniśmy zrobić tak:
.fold
nie zadziała na klasie List
z biblioteki standardowej, ponieważ ta definiuje już metodę o nazwie
fold
, która robi coś podobnego na swój własny sposób.
Osobliwie nazywająca się metoda intercalate
wstawia konkretną instancję typu A
pomiędzy każde dwa elementy przed wykonaniem fold.
i jest tym samym uogólnioną wersją mkString
:
foldLeft
pozwala na dostęp do konkretnego elementu poprzez jego indeks oraz daje nam kilka innych, blisko związanych
metod:
Scalaz jest biblioteką czystą, składającą się wyłącznie z funkcji totalnych. Tam, gdzie List.apply
wyrzuca wyjątek,
Foldable.index
zwraca Option[A]
oraz pozwala użyć wygodnego indexOr
, który zwraca A
, bazując na wartości domyślnej.
.element
podobny jest do .contains
z biblioteki standardowej, ale używa Equal
zamiast niejasno zdefiniowanego
pojęcia równości pochodzącego z JVMa.
Metody te naprawdę wyglądają jak API kolekcji. No i oczywiście każdy obiekt mający instancje Foldable
może być
przekonwertowany na listę:
Istnieją również konwersje do innych typów danych zarówno z biblioteki standardowej, jak i Scalaz, takie jak:
.toSet
, .toVector
, .toStream
, .to[T <: TraversableLike]
, .toIList
itd.
Dostępne są również przydatne metody do weryfikacji predykatów
filterLength
zlicza elementy, które spełniają predykat, all
i any
zwracają true
, jeśli wszystkie (lub jakikolwiek)
elementy spełniają predykat i mogą zakończyć działanie bez sprawdzania wszystkich elementów.
Możemy też podzielić F[A]
na części bazując na kluczu B
za pomocą metody splitBy
na przykład
Zwróć uwagę, że otrzymaliśmy dwa elementy zaindeksowane za pomocą 'b'
.
splitByRelation
pozwala uniknąć dostarczania instancji Equal
, ale za to wymaga podania operatora porównującego.
splitWith
dzieli elementy na grupy, które spełniają i nie spełniają predykatu. selectSplit
wybiera grupy elementów,
które spełniają predykat, a pozostałe odrzuca. To jedna z tych rzadkich sytuacji gdzie dwie metody mają tę samą sygnaturę,
ale działają inaczej.
findLeft
i findRight
pozwalając znaleźć pierwszy element (od lewej lub prawej), który spełnia predykat.
Dalej korzystając z Equal
i Order
dostajemy metody distinct
, które zwracają elementy unikalne.
distinct
jest zaimplementowany w sposób bardziej wydajny niż distinctE
, ponieważ może bazować na kolejności,
a dzięki niej odszukiwać elementy unikalne w sposób podobny do tego, w jaki działa algorytm quicksort. Dzięki temu
jest zdecydowanie szybszy niż List.distinct
. Struktury danych (takie jak zbiory) mogą implementować distinct
w
swoich Foldable
bez dodatkowego wysiłku.
distinctBy
pozwala na grupowanie, bazując na rezultacie wywołania podanej funkcji na każdym z oryginalnych elementów.
Przykładowe użycie: grupowanie imion ze względu na pierwszą literę słowa.
Możemy wykorzystać Order
również do odszukiwania elementów minimalnych i maksymalnych (lub obu ekstremów), wliczając w to
warianty używające Of
, lub By
aby najpierw przemapować elementy do innego typu lub użyć innego typu do samego porównania.
Możemy na przykład zapytać o to, który element typu String
jest maksimum ze względu (By
) na swoją długość lub jaka jest maksymalna
długość elementów (Of
).
Podsumowuje to kluczowe funkcjonalności Foldable
. Cokolwiek spodziewalibyśmy się zobaczyć
w API kolekcji, jest już prawdopodobnie dostępna dzięki Foldable
, a jeśli nie jest, to prawdopodobnie być powinno.
Na koniec spojrzymy na kilka wariacji metod, które widzieliśmy już wcześniej. Zacznijmy od tych, które przyjmują
instancję typu Semigroup
zamiast Monoid
:
zwracając tym samym Option
, aby móc obsłużyć puste struktury danych (Semigroup
nie definiuje elementu zerowego).
Typeklasa Foldable1
zawiera dużo więcej wariantów bazujących na Semigroup
ie (wszystkie z sufiksem 1
)
i używanie jej ma sens dla struktur, które nigdy nie są puste, nie wymagając definiowania pełnego Monoid
u dla elementów.
Co ważne, istnieją również warianty pracujące w oparciu o typy monadyczne. Używaliśmy już foldLeftM
, kiedy po raz
pierwszy pisaliśmy logikę biznesową naszej aplikacji. Teraz wiemy, że pochodzi ona z Foldable
:
5.4.3 Traverse
Traverse
to skrzyżowanie Functor
a z Foldable
.
Na początku rozdziału pokazaliśmy, jak ważne są metody traverse
i sequence
, gdy chcemy odwrócić kolejność konstruktorów typu
(np. z List[Future[_]]
na Future[List[_]]
).
W Foldable
nie mogliśmy założyć, że reverse
jest pojęciem uniwersalnym, ale sprawa wygląda zupełnie inaczej, gdy mamy do
dyspozycji Traverse
.
Możemy też zip
ować ze sobą dwie rzeczy, które mają instancję Traverse
, dostając None
, gdy jedna ze stron
nie ma już więcej elementów. Specjalnym wariantem tej operacji jest dodanie indeksów do każdego elementu za pomocą
indexed
.
zipWithL
i zipWithR
pozwalają połączyć elementy w nowy typ i od razu stworzyć F[C]
.
mapAccumL
i mapAccumR
to standardowe map
połączone z akumulatorem. Jeśli nawyki z Javy każą nam sięgnąć po zmienna typu var
i używać jej wewnątrz map
, to najprawdopodobniej powinniśmy użyć mapAccumL
.
Powiedzmy, że mamy listę słów i chcielibyśmy ukryć te, które już wcześniej widzieliśmy. Chcemy, aby algorytm działał również dla nieskończonych strumieni danych, a więc kolekcja może być przetworzona jedynie raz.
Na koniec Traverse1
, podobnie jak Foldable1
, dostarcza warianty wspomnianych metod dla struktur danych, które nigdy nie są
puste, przyjmując słabszą Semigroup
ę zamiast Monoid
u i Apply
zamiast Applicative
. Przypomnijmy, że
Semigroup
nie musi dostarczać .empty
, a Apply
nie wymaga .point
.
5.4.4 Align
Align
służy do łączenia i wyrównywania wszystkiego, co ma instancję typu Functor
. Zanim spojrzymy
na Align
, poznajmy typ danych \&/
(wymawiany jako te, these lub hurray!),
A więc jest to wyrażenie alternatywy łącznej OR
: A
lub B
, lub A
i B
jednocześnie.
alignWith
przyjmuje funkcję z albo A
, albo B
(albo obu) na C
i zwraca wyniesioną funkcję z tupli F[A]
i F[B]
na F[C]
. align
konstruuje \&/
z dwóch F[_]
.
merge
pozwala nam połączyć dwie instancje F[A]
tak długo, jak jesteśmy w stanie dostarczyć instancję Semigroup[A]
.
Dla przykładu, Semigroup[Map[K,V]]]
deleguje logikę do Semigroup[V]
, łącząc wartości dla tych samych kluczy, a w
konsekwencji sprawiając, że Map[K, List[A]]
zachowuje się jak multimapa:
a Map[K, Int]
po prostu sumuje wartości.
.pad
i .padWith
służą do częściowego łącznie struktur danych, które mogą nie mieć wymaganych wartości po jednej ze stron.
Dla przykładu, jeśli chcielibyśmy zagregować niezależne głosy i zachować informację skąd one pochodziły:
Istnieją też wygodne warianty align
, które używają struktury \&/
i które powinny być jasne po przeczytaniu sygnatur. Przykłady:
Zauważ, że warianty A
i B
używają alternatywy łącznej, a This
i That
są wykluczające,
zwracając None
, gdy wartość istnieje po obu stronach lub nie istnieje po wskazanej stronie.
5.5 Wariancja
Musimy wrócić na moment do Functor
a i omówić jego przodka, którego wcześniej zignorowaliśmy
InvariantFunctor
, znany również jako funktor wykładniczy, definiuje metodę xmap
, która pozwala zamienić
F[A]
w F[B]
jeśli przekażemy do niej funkcje z A
na B
i z B
na A
.
Functor
to skrócona nazwa na to, co powinno nazywać się funktorem kowariantnym.
Podobnie Contravariant
to tak naprawdę funktor kontrawariantny.
Functor
implementuje metodę xmap
za pomocą map
i ignoruje funkcję z B
na A
. Contravariant
z kolei
implementuje ją z użyciem contramap
i ignoruje funkcję z A
na B
:
Co istotne, określenia kowariantny, kontrawariantny i inwariantny, mimo że związane na poziomie
teoretycznym, nie przekładają się bezpośrednio na znaną ze Scali wariancję typów (czyli modyfikatory +
i -
umieszczane przy parametrach typów). Inwariancja oznacza tutaj, że możliwym jest przetłumaczenie zawartości
F[A]
do F[B]
. Używając identity
, możemy zobaczyć, że A
może być w bezpieczny sposób zrzutowane (w górę lub w dół)
do B
, zależnie od wariancji funktora.
.map
może być rozumiana poprzez swój kontrakt: “jeśli dasz mi F
dla A
i sposób na zamianę A
w B
, wtedy dam ci F
dla B
”.
Podobnie, .contramap
mówi, że: “jeśli dasz mi F
dla A
i sposób na zamianę B
w A
, wtedy dam ci F
dla B
”.
Rozważymy następujący przykład: w naszej aplikacji wprowadzamy typy domenowe Alpha
, Beta
i Gamma
, aby zabezpieczyć się
przed pomieszaniem liczb w kalkulacjach finansowych:
ale sprawia to, że nie mamy żadnych instancji typeklas dla tych nowych typów. Jeśli chcielibyśmy użyć takich
wartości w JSONie, musielibyśmy dostarczyć JsEncoder
i JsDecoder
.
Jednakże, JsEncoder
ma instancję typeklasy Contravariant
a JsDecoder
typeklasy Functor
, a więc możemy
wyderywować potrzebne nam instancje, spełniając kontrakt:
- “jeśli dasz mi
JsDecoder
dlaDouble
i sposób na zamianęDouble
wAlpha
, wtedy dam ciJsDecoder
dlaAlpha
”. - “jeśli dasz mi
JsEncoder
dlaDouble
i sposób na zamianęAlpha
wDouble
, wtedy dam ciJsEncoder
dlaAlpha
”.
Metody w klasie mogą ustawić swoje parametry typu w pozycji kontrawariantnej (parametry metody) lub
w pozycji kowariantnej (typ zwracany). Jeśli typeklasa łączy pozycje kowariantne i kontrawariantne może oznaczać to, że
ma instancję typeklasy InvariantFunctor
, ale nie Functor
ani Contrawariant
.
5.6 Apply i Bind
Potraktuj tę część jako rozgrzewkę przed typami Applicative
i Monad
5.6.1 Apply
Apply
rozszerza typeklasę Functor
poprzez dodanie metody ap
, która jest podobna do map
w tym, że aplikuje otrzymaną funkcje na wartościach.
Jednak w przypadku ap
funkcja jest opakowana w ten sam kontekst co wartości, które są do niej przekazywane.
Warto poświęcić chwilę na zastanowienie się co to znaczy, że prosta struktura danych, taka jak Option[A]
, posiada
następującą implementację .ap
Aby zaimplementować .ap
, musimy najpierw wydostać funkcję ff: A => B
z f: Option[A => B]
, a następnie
możemy przemapować fa
z jej użyciem. Ekstrakcja funkcji z kontekstu to ważna funkcjonalność, którą przynosi Apply
.
Pozwala tym samym na łączenie wielu funkcji wewnątrz jednego kontekstu.
Wracając do Apply
, znajdziemy tam rodzinę funkcji applyX
, która pozwala nam łączyć równoległe obliczenia, a następnie
mapować ich połączone wyniki:
Potraktuj .apply2
jako obietnicę: “jeśli dasz mi F
z A
i F
z B
oraz sposób na połączenie A
i B
w C
, wtedy
mogę dać ci F
z C
”. Istnieje wiele zastosowań dla tej obietnicy, a 2 najważniejsze to:
- tworzenie typeklas dla produktu
C
z jego składnikówA
iB
- wykonywanie efektów równolegle, jak w przypadku algebr dla drone i google, które stworzyliśmy w Rozdziale 3, a następnie łączenie ich wyników.
W rzeczy samej, Apply
jest na tyle użyteczne, że ma swoją własną składnię:
której użyliśmy w Rozdziale 3:
Operatory <*
i *>
(prawy i lewy ptak) oferują wygodny sposób na zignorowanie wyniku jednego z dwóch równoległych
efektów.
Niestety, mimo wygody, którą daje operator |@\
, jest z nim jeden problem: dla każdego kolejnego efektu alokowany jest
nowy obiekt typu ApplicativeBuilder
. Gdy prędkość obliczeń ograniczona jest przez operacje I/O nie ma to znaczenia.
Jednak gdy wykonujesz obliczenia w całości na CPU, lepiej jest użyć krotnego wynoszenia (lifting with arity), które nie
produkuje żadnych obiektów pośrednich:
na przykład:
Możemy też zawołać applyX
bezpośrednio:
Mimo tego, że Apply
używany jest najczęściej z efektami, działa równie dobrze ze strukturami danych. Rozważ przepisanie
jako
Gdy chcemy jedynie połączyć wyniki w tuple, istnieją metody, które służą dokładnie do tego:
Dostępne są też uogólnione wersje ap
dla więcej niż dwóch parametrów:
razem z wariantami .lift
, które przyjmują zwykłe funkcje i wynoszą je do kontekstu F[_]
, uogólniając Functor.lift
oraz .apF
, częściowo zaaplikowana wersja ap
A na koniec .forever
który powtarza efekt w nieskończoność bez zatrzymywania się. Przy jej użyciu instancja Apply
musi być zabezpieczona prze
przepełnieniem stosu (stack-safe), w przeciwnym wypadku wywołanie spowoduje StackOverflowError
.
5.6.2 Bind
Bind
wprowadza metodę .bind
, która jest synonimiczna do .flatMap
i pozwala na mapowanie efektów/struktur danych
z użyciem funkcji zwracających nowy efekt/strukturę danych bez wprowadzania dodatkowych zagnieżdżeń.
Metoda .join
może wydawać się znajoma tym, którzy używali .flatten
z biblioteki standardowej. Przyjmuje ona
zagnieżdżone konteksty i łączy je w jeden.
Wprowadzone zostały kombinatory pochodne dla .ap
i .apply2
, aby zapewnić spójność z .bind
. Zobaczymy później, że
to wymaganie niesie ze sobą konsekwencje dla potencjalnego zrównoleglania obliczeń.
mproduct
przypomina Functor.fproduct
i paruje wejście i wyjście funkcji wewnątrz F
.
ifM
to sposób na tworzenie warunkowych struktur danych lub efektów:
ifM
i ap
są zoptymalizowane do cachowania i reużywania gałezi kodu. Porównajmy je z dłuższą wersją
która produkuje nowe List(0)
i List(1, 1)
za każdym razem, gdy dana gałąź jest wywoływana.
Bind
wprowadza też specjalne operatory:
Używając >>
odrzucamy wejście do bind
, a używając >>!
odrzucamy wyjście`
5.7 Aplikatywy i monady
Z punkty widzenia oferowanych funkcjonalności, Applicative
to Apply
z dodaną metodą pure
, a Monad
rozszerza Applicative
, dodając Bind
.
Pod wieloma względami Applicative
i Monad
są zwieńczeniem wszystkiego, co do tej pory widzieliśmy w tym rozdziale.
.pure
(lub .point
- alias powszechnie używany przy strukturach danych) pozwala nam na tworzenie efektów lub
struktur danych z pojedynczych wartości.
Instancje Applicative
muszę spełniać prawa gwarantujące spójność metod:
-
Tożsamość (Identity):
fa <*> pure(identity) == fa
(gdziefa
toF[A]
) - zaaplikowaniepure(identity)
nic nie zmienia -
Homomorfizm (Homomorphism):
pure(a) <*> pure(ab) === pure(ab(a))
, (gdzieab
to funkcjaA => B
) - zaaplikowanie funkcji osadzonej w kontekścieF
za pomocąpure
na wartości potraktowanej w ten sam sposób jest równoznaczne z wywołaniem tej funkcji na wspomnianej wartości i wywołaniempure
na rezultacie. -
Zamiana (Interchange):
pure(a) <*> fab === fab <*> pure(f => f(a))
, (gdziefab
toF[A => B]
) -pure
jest tożsama lewo- i prawostronnie -
Mappy:
map(fa)(f) === fa <*> pure(f)
Monad
dodaje następujące prawa
-
Tożsamość lewostronna (Left Identity):
pure(a).bind(f) === f(a)
-
Tożsamość prawostronna (Right Identity):
a.bind(pure(_)) === a
-
Łączność (Associativity):
fa.bind(f).bind(g) === fa.bind(a => f(a).bind(g))
gdziefa
toF[A]
,f
toA => F[B]
, ag
toB => F[C]
.
Łączność mówi nam, że połączone wywołania bind
muszą być zgodne z wywołaniami zagnieżdżonymi. Jednakże,
nie oznacza to, że możemy zamieniać kolejność wywołań - to gwarantowałaby przemienność (commutativity).
Dla przykładu, pamiętając, że flatMap
to alias na bind
, nie możemy zamienić
na
start
i stop
są nieprzemienne, ponieważ uruchomienie, a następnie zatrzymanie węzła jest czymś innym
niż zatrzymanie i uruchomienie.
Nie mniej, zarówno start
, jak i stop
są przemienne same ze sobą samym, a więc możemy zamienić
na
Obie formy są równoznaczne w tym konkretnym przypadku, ale nie w ogólności. Robimy tutaj dużo założeń co do Google Container API, ale wydaje się to być rozsądnych wyjściem.
Okazuje się, że w konsekwencji powyższych praw Monad
a musi być przemienna, jeśli chcemy pozwolić na równoległe
działanie metod applyX
. W Rozdziale 3 oszukaliśmy uruchamiając efekty w ten sposób
ponieważ wiedzieliśmy, że są one ze sobą przemienne. Kiedy w dalszych rozdziałach zajmiemy się interpretacją naszej aplikacji, dostarczymy dowód na przemienność operacji lub pozwolimy na uruchomienie ich sekwencyjnie.
Subtelności sposobów radzenia sobie z porządkowaniem efektów, i tym, czym te efekty tak naprawdę są, zasługują na osobny rozdział. Porozmawiamy o nich przy Zaawansowanych monadach.
5.8 Dziel i rządź
Divide
to kontrawariantny odpowiednik Apply
divide
mówi nam, że jeśli potrafimy podzielić C
na A
i B
oraz mamy do dyspozycji F[A]
i F[B]
to możemy stworzyć F[C]
. Stąd też dziel i rządź.
Jest to świetny sposób na generowanie instancji kowariantnych typeklas dla typów będących produktami poprzez
podzielenie tychże produktów na części. Scalaz oferuje instancje Divide[Equal]
, spróbujmy więc stworzyć Equal
dla nowego typu Foo
.
Podążając za Apply
, Divide
również dostarcza zwięzłą składnię dla tupli
Ogólnie rzecz biorąc, jeśli typeklasa, oprócz instancji Contravariant
, jest w stanie dostarczyć również Divide
,
to znaczy, że jesteśmy w stanie wyderywować jej instancje dla dowolnej case klasy. Sprawa wygląda analogicznie dla
typeklas kowariantnych z instancją Apply
. Zgłębimy ten temat w rozdziale poświęconym Derywacji typeklas.
Divisible
to odpowiednik Applicative
dla rodziny Contravariant
. Wprowadzana ona metodę .conquer
, odpowiednik .pure
:
.conquer
pozwala na tworzenie trywialnych implementacji, w których parametr typu jest ignorowany. Takie instancje
nazywane są ogólnie kwantyfikowanymi (universally quantified). Na przykład, Divisible[Equal].conquer[INil[String]]
tworzy
instancję Equal
, która zawsze zwraca true
.
5.9 Plus
Plus
to Semigroup
a dla konstruktorów typu a PlusEmpty
to odpowiednik Monoid
u (obowiązują ich nawet te same prawa).
Nowością jest typeklasa IsEmpty
, która pozwala na sprawdzenie czy F[A]
jest puste:
Pozornie może się wydawać, że <+>
zachowuje się tak samo, jak |+|
:
Najlepiej jest przyjąć, że <+>
operuje jedynie na F[_]
nigdy nie patrząc na zawartość.
Przyjęła się konwencja, że Plus
ignoruje porażki i wybiera “pierwszego zwycięzcę”. Dzięki temu
<+>
może być używany jako mechanizm szybkiego wyjścia oraz obsługi porażek przez fallbacki.
Na przykład, jeśli chcielibyśmy pominąć obiekty None
wewnątrz NonEmptyList[Option[Int]]
i wybrać pierwszego
zwycięzcę (Some
), możemy użyć <+>
w połączeniu z Foldable1.foldRight1
:
Teraz, gdy znamy już Plus
, okazuje się, że wcale nie musieliśmy zaburzać koherencji typeklas w sekcji o Rzeczach złączalnych
(definiując lokalną instancję Monoid[Option[A]]
). Naszym celem było “wybranie ostatniego zwycięzcy”,
co jest tożsame z wybranie pierwszego po odwróceniu kolejności elementów. Zwróć uwagę na użycie Interceptora TIE z
ccy
i otc
w odwróconej kolejności.
Applicative
i Monad
mają wyspecjalizowaną wersję PlusEmpty
.unite
pozwala nam zwinąć strukturę danych, używając PlusEmpty[F].monoid
zamiast Monoidu
zdefiniowanego dla
typu wewnętrznego. Dla List[Either[String, Int]]
oznacza to, że instancje Left[String]
zamieniane są na .empty
,
a następnie wszytko jest złączane. Jest to wygodny sposób na pozbycie się błędów:
withFilter
pozwala nam na użycie konstrukcji for
, którą opisywaliśmy w Rozdziale 2. Można nawet powiedzieć, że
Scala ma wbudowane wsparcie nie tylko dla Monad
, ale i MonadPlus
!
Wracając na moment do Foldable
, możemy odkryć kilka metod, których wcześniej nie omawialiśmy:
msuml
wykonuje fold
, używając Monoidu
z PlusEmpty[G]
, a collapse
używa foldRight
w kombinacji
z instancją PlusEmpty
typu docelowego:
5.10 Samotne wilki
Niektóre z typeklas w Scalaz są w pełni samodzielne i nie należą do ogólnej hierarchii.
5.10.1 Zippy
Metoda kluczowa tutaj to zip
. Jest to słabsza wersja Divide.tuple2
. Jeśli dostępny jest Functor[F]
to
.zipWith
może zachowywać się jak Apply.apply2
. Używając ap
, możemy nawet stworzyć pełnoprawne Apply[F]
z
instancji Zip[F]
i Functor[F]
.
.apzip
przyjmuje F[A]
i wyniesioną funkcję F[A] => F[B]
produkując F[(A, B)]
, podobnie do Functor.fproduct
.
Bazą jest unzip
dzielący F[(A,B)]
na F[A]
i F[B]
, a firsts
i seconds
pozwalają na wybranie
jednej z części. Co ważne, unzip
jest odwrotnością zip
.
Metody od unzip3
do unzip7
to aplikacje unzip
pozwalające zmniejszyć ilość boilerplatu. Na przykład,
jeśli dostaniemy garść zagnieżdżonych tupli to Unzip[Id]
jest wygodnym sposobem na ich wypłaszczenie:
W skrócie, Zip
i Unzip
są słabszymi wersjami Divide
i Apply
dostarczającymi użyteczne funkcjonalności
bez zobowiązywania F
do składania zbyt wielu obietnic.
5.10.2 Optional
Optional
to uogólnienie struktur danych, które mogą opcjonalnie zawierać jakąś wartość, np. Option
lub Either
.
Przypomnijmy, że \/
(dysjunkcja) ze Scalaz jest ulepszoną wersją scala.Either
. Poznamy też Maybe
- ulepszoną wersję
scala.Option
.
Powyższe metody powinny wydawać się znajome, może z wyjątkiem pextract
, która pozwala F[_]
na zwrócenie
przechowywanej wartości lub specyficznego dla implementacji F[B]
. Na przykład Optional[Option].pextract
zwróci
nam Option[Nothing] \/ A
, czyli None \/ A
.
Scalaz daje nam operator trenarny dla wszystkich typów mających swoją instancję Optional
.
Przykład:
5.11 Ko-rzeczy
Ko-rzecz zazwyczaj ma sygnaturę przeciwną do tego, co robi rzecz, ale nie musi koniecznie być jej odwrotnością. Aby podkreślić relacje między rzeczą i ko-rzeczą, wszędzie gdzie to możliwe zawrzemy obie sygnatury.
5.11.1 Cobind
cobind
(znany również jako coflatmap
) przyjmuje funkcję F[A] => B
, która operuje na F[A]
, a nie jego elementach.
Ale nie zawsze będzie to pełne fa
, często jest to substruktura stworzona przez metodę cojoin
(znaną również jako
coflatten
), która rozwija strukturę danych.
Przekonywające przykłady użycia Cobind
są rzadkie, jednak kiedy spojrzymy na tabele permutacji metod typeklasy Functor
,
ciężko jest uzasadnić, czemu niektóre metody miałyby być ważniejsze od innych.
method | parameter |
---|---|
map |
A => B |
contramap |
B => A |
xmap |
(A => B, B => A) |
ap |
F[A => B] |
bind |
A => F[B] |
cobind |
F[A] => B |
5.11.2 Comonad
.copoint
(znany też jako .copure
) wydostaje element z kontekstu. Efekty z reguły nie posiadają instancji
tej typeklasy, gdyż na przykład interpretacja IO[A]
do A
zaburza transparentność referencyjną.
Dla struktur danych jednakże może to być na przykład wygodny sposób na pokazanie wszystkich elementów wraz z ich sąsiadami.
Rozważmy strukturę sąsiedztwa (Hood
), która zawiera pewien element (focus
) oraz elementy na
lewo i prawo od niego (lefts
i rights
).
lefts
i right
powinny być uporządkowane od najbliższego do najdalszego elementu względem elementu środkowego focus
,
tak abyśmy mogli przekonwertować taką strukturę do IList
za pomocą poniższej implementacji
Możemy zaimplementować metody do poruszania się w lewo (previous
) i w prawo (next
)
Wprowadzając metodę more
, jesteśmy w stanie obliczyć wszystkie możliwe do osiągnięcia pozycje (positions
) w danym Hood
.
Możemy teraz stworzyć Comonad[Hood]
cojoin
daje nam Hood[Hood[IList]]
zawierające wszystkie możliwe sąsiedztwa w naszej początkowej liście
Okazuje się, że cojoin
to tak naprawdę positions
! A więc możemy nadpisać ją, używając bezpośredniej
(a przez to wydajniejszej) implementacji
Comonad
generalizuje koncepcję sąsiedztwa dla arbitralnych struktur danych. Hood
jest przykładem zippera
(brak związku z Zip
). Scalaz definiuje typ danych Zipper
dla strumieni (jednowymiarowych nieskończonych struktur danych),
które omówimy w następnym rozdziale.
Jednym z zastosowanie zippera jest automat komórkowy (cellular automata), który wylicza wartość każdej komórki w następnej generacji na podstawie aktualnych wartości sąsiadów tej komórki.
5.11.3 Cozip
Mimo że nazwa tej typeklasy brzmi Cozip
, lepiej jest spojrzeć na jej symetrię względem metody unzip
.
Tam, gdzie unzip
zamienia F[_]
zawierające produkt (tuple) na produkt zawierający F[_]
, tam
cozip
zamienia F[_]
zawierające koprodukty (dysjunkcje) na koprodukt zawierający F[_]
.
5.12 Bi-rzeczy
Czasem mamy do czynienia z typami, które przyjmują dwa parametry typu i chcielibyśmy przemap
ować obie jego
strony. Możemy na przykład śledzić błędy po lewej stronie Either
i chcieć przetransformować
wiadomości z tychże błędów.
Typeklasy Functor
/ Foldable
/ Traverse
mają swoich krewnych, którzy pozwalają nam mapować obie strony wspieranych typów.
Mimo że sygnatury metod są dość rozwlekłe, to są to niemal dokładnie te same metody, które znamy
z typeklas Functor
, Foldable
i Traverse
, z tą różnicą, że przyjmują dwie funkcje zamiast jednej.
Czasami funkcje te muszą zwracać ten sam typ, aby wyniki można było połączyć za pomocą Monoid
u lub Semigroup
y.
Dodatkowo możemy wrócić na chwile do MonadPlus
(czyli Monad
y z metodami filterWith
i unite
), aby zobaczyć,
że potrafi ona rozdzielać (separate
) zawartość Monad
y, jeśli tylko jej typ ma instancję Bifoldable
.
Jest to bardzo przydatny mechanizm, kiedy mamy do czynienia z kolekcją bi-rzeczy i chcemy podzielić ją
na kolekcję A
i kolekcję B
.
5.13 Podsumowanie
Dużo tego! Właśnie odkryliśmy standardową bibliotekę polimorficznych funkcjonalności. Ale patrząc na to z innej perspektywy: w Collections API z biblioteki standardowej Scali jest więcej traitów niż typeklas w Scalaz.
To całkiem normalne, jeśli twoja czysto funkcyjna aplikacja korzysta jedynie z małej części omówionych typeklas, a większość funkcjonalności czerpie z typeklas i algebr domenowych. Nawet jeśli twoje domenowe typeklasy są tylko wyspecjalizowanymi odpowiednikami tych zdefiniowanych w Scalaz, to jest zupełnie ok, aby zrefaktorować je później.
Aby ułatwić nieco sprawę, dołączyliśmy cheat-sheet wszystkich typeklas i ich głównych metod w załączniku. Jest on zainspirowany przez Scalaz Cheatsheet Adama Rosiena.
Aby pomóc jeszcze bardziej, Valentin Kasas pokazuję jak połączyć N
rzeczy
6. Typy danych ze Scalaz
Kto nie kocha porządnej struktury danych? Odpowiedź brzmi nikt, a struktury danych są super!
W tym rozdziale poznamy typy danych przypominające kolekcje oraz takie, które wzbogacają Scalę o dodatkowe możliwości i zwiększają bezpieczeństwo typów.
Podstawowym powodem, dla którego używamy wielu różnych typów kolekcji, jest wydajność. Wektor i lista mogą zrobić to samo, ale ich charakterystyki wydajnościowe są inne: wektor oferuje dostęp do losowego elementu w czasie stałym, podczas gdy lista musi zostać w czasie tej operacji przetrawersowana.
Wszystkie kolekcje, które tutaj zaprezentujemy, są trwałe (persistent): jeśli dodamy lub usuniemy element, nadal możemy używać poprzedniej, niezmienionej wersji. Współdzielenie strukturalne (structural sharing) jest kluczowe dla wydajności trwałych struktur danych, gdyż bez tego musiałyby one być tworzone od nowa przy każdej operacji.
W przeciwieństwie do kolekcji z bibliotek standardowych Javy i Scali, w Scalaz typy danych nie tworzą hierarchii, a przez to są dużo prostsze do zrozumienia. Polimorfizm jest zapewniany przez zoptymalizowane instancje typeklas, które poznaliśmy w poprzednim rozdziale. Sprawia to, że zmiana implementacji podyktowana zwiększeniem wydajności, lub dostarczenie własnej, jest dużo prostsze.
6.1 Wariancja typów
Wiele z typów danych zdefiniowanych w Scalaz jest inwariantna. Dla przykładu IList[A]
nie jest podtypem
IList[B]
nawet jeśli A <: B
.
6.1.1 Kowariancja
Problem z kowariantnymi parametrami typu, takimi jak A
w class List[+A]
, jest taki, że List[A]
jest podtypem
(a więc dziedziczy po) List[Any]
i bardzo łatwo jest przez przypadek zgubić informacje o typach.
Zauważ, że druga lista jest typu List[Char]
i kompilator niezbyt pomocnie wyinferował Any
jako
Najmniejszą Górną Granicę (Least Upper Bound, LUB). Porównajmy to z IList
, która wymaga bezpośredniego wywołania
.widen[Any]
, aby pozwolić na ten haniebny uczynek:
Podobnie, gdy kompilator inferuje typ z dopiskiem with Product with Serializable
to najprawdopodobniej
miało miejsce przypadkowe rozszerzenie typu spowodowane kowariancją.
Niestety, musimy uważać, nawet gdy konstruujemy typy inwariantne, ponieważ obliczenie LUB wykonywane jest również dla parametrów typu:
Podobny problem powodowany jest przez typ Nothing
, który jest podtypem wszystkich innych typów, wliczając w to
ADT, klasy finalne, typy prymitywne oraz null
.
Nie istnieją jednak wartości typu Nothing
. Funkcje, które przyjmują Nothing
jako parametr, nie mogą zostać uruchomione, a
funkcje, które zwracają ten typ, nigdy nie zwrócą rezultatu. Typ Nothing
został wprowadzony, aby umożliwić używanie
kowariantnych parametrów typu, ale w konsekwencji umożliwił pisanie kodu, który nie może być uruchomiony, często przez przypadek.
W Scalaz uważamy, że kowariantne parametry typu wcale nie są potrzebne, ograniczając się tym samym do praktycznego
kodu, który może zostać uruchomiony.
6.1.2 Sprzeciwwariancja
Z drugiej strony, parametry kontrawariantne takie jak A
w trait Thing[-A]
mogą ujawnić niszczycielskie
błędy w kompilatorze. Spójrzmy na to, co Paul Phillips (były
członek zespołu pracującego nad scalac
) nazywa contrarivariance:
Tak jak byśmy oczekiwali, kompilator odnalazł najdokładniejsze dopasowanie metod do argumentów. Sprawa komplikuje się jednak gdy użyjemy wartości niejawnych
Niejawne rozstrzyganie odwraca definicje “najbardziej dokładnego dopasowania” dla typów kontrawariantnych, czyniąc je tym samym kompletnie bezużytecznymi do reprezentacji typeklas i czegokolwiek co wymaga polimorficznych funkcjonalności. Zachowanie to zostało poprawione w Dottym.
6.1.3 Ograniczenia podtypów
scala.Option
ma metodę .flatten
, która konwertuje Option[Option[B]]
na Option[B]
.
Niestety kompilator Scali nie pozwala nam na poprawne zapisanie sygnatury tej metody.
Rozważmy poniższą implementację, która pozornie wydaje się poprawna:
A
wprowadzone w definicji .flatten
przysłania A
wprowadzone w definicji klasy. Tak więc jest to równoznaczne z
czyli nie do końca jest tym, czego chcieliśmy.
Jako obejście tego problemu wprowadzono klasy <:<
i =:=
wraz z niejawnymi metodami, które zawsze tworzą
instancje dla poprawnych typów.
=:=
może być użyty do wymuszenia, aby dwa parametry typu były dokładnie takie same. <:<
służy do wyrażenia
relacji podtypowania, pozwalając tym samym na implementację .flatten
jako
Scalaz definiuje ulepszone wersje <:<
i =:=
: Liskov (z aliasem <=<
) oraz Leibniz (===
).
Poza dostarczeniem przydatnych metod i niejawnych konwersji, <=<
i ===
są bardziej pryncypialne niż ich odpowiedniki
z biblioteki standardowej.
6.2 Ewaluacja
Java to język o ścisłej (strict) ewaluacji: wszystkie parametry przekazane do metody muszą zostać wyewaluowane do
wartości, zanim metoda zostanie uruchomiona. Scala wprowadza pojęcie parametrów przekazywanych przez nazwę (by-name)
za pomocą składni a: =>A
. Takie parametry opakowywane są w zero-argumentową funkcję, która jest wywoływana za każdym razem, gdy odnosimy
się do a
. Widzieliśmy tego typu parametry wielokrotnie, gdy omawialiśmy typeklasy.
Scala pozwala również na ewaluacje wartości na żądanie za pomocą słowa kluczowego lazy
: obliczenia są wykonywane najwyżej raz
produkując wartość przy pierwszym użyciu. Niestety Scala nie wspiera ewaluacji na żądanie dla parametrów metod.
Scalaz formalizuje te trzy strategie ewaluacji za pomocą ADT
Najsłabszą formą ewaluacji jest Name
, która nie daje żadnych gwarancji obliczeniowych. Następna jest Need
gwarantująca
ewaluację najwyżej raz (at most once). Value
jest obliczana przed utworzeniem, gwarantując tym samym ewaluację
dokładnie raz (exactly once).
Gdybyśmy chcieli być pedantyczni, moglibyśmy wrócić do wszystkich typeklas, które poznaliśmy do tej pory i zamienić przyjmowane
parametry w ich metodach na Name
, Need
i Value
. Zamiast tego możemy też po prostu założyć, że normalne parametry
mogą być zawsze opakowane w Value
, a te przekazywane przez nazwę w Name
.
Gdy piszemy czyste programy, możemy śmiało zamienić dowolne Name
na Need
lub Value
, i vice versa, bez zmieniania
poprawności programu. To jest właśnie esencja transparencji referencyjnej: zdolność do zamiany obliczeń na wartość wynikową
lub wartości na obliczenia potrzebne do jej uzyskania.
W programowaniu funkcyjnym prawie zawsze potrzebujemy Value
lub Need
(znane też jako parametry ścisłe i leniwe), ale nie mamy
zbyt wiele pożytku z Name
. Ponieważ na poziomie języka nie mamy bezpośredniego wsparcia dla leniwych parametrów, metody często
przyjmują wartości przez nazwę, a następnie konwertują je do Need
, zwiększając tym samym wydajność.
Name
dostarcza instancje poniższych typeklas:
Monad
Comonad
Traverse1
Align
-
Zip
/Unzip
/Cozip
6.3 Memoizacja
Scalaz potrafi memoizować funkcje za pomocą typu Memo
, który nie daje żadnych gwarancji co do ewaluacji z powodu
dużej gamy różniących się implementacji:
Metoda memo
pozwala nam na tworzenie własnych implementacji Memo
. nilMemo
nie memoizuje w ogóle, a więc funkcja wykonywana
jest za każdym wywołaniem. Pozostałe implementacje przechwytują wywołania funkcji i cache’ują wynik przy użyciu kolekcji z
biblioteki standardowej.
Aby wykorzystać Memo
, wystarczy, że opakujemy naszą funkcję z użyciem wybranej implementacji, a następnie używać będziemy
zwróconej nam funkcji zamiast tej oryginalnej:
Jeśli funkcja przyjmuje więcej niż jeden argument, musimy wywołać na niej tupled
, konwertując ją tym samym
do jednoargumentowej funkcji przyjmującej tuple.
Memo
jest traktowany w specjalny sposób i typowe reguły czystości są nieco osłabione przy jego implementacji.
Aby nosić miano czystego wystarczy, aby wykonanie K => V
było referencyjnie transparentne. Przy implementacji możemy
używać mutowalnych struktur danych lub wykonywać operacje I/O, np., aby uzyskać LRU lub rozproszony cache bez deklarowania efektów
w sygnaturze typu. Inne funkcyjne języki programowania udostępniają automatyczną memoizację zarządzaną przez środowisko
uruchomieniowe, Memo
to nasz sposób na dodanie podobnej funkcjonalności do JVMa, niestety jedynie jako “opt-in”.
6.4 Tagowanie
W podrozdziale wprowadzającym Monoid
stworzyliśmy Monoid[TradeTemplate]
jednocześnie uświadamiając sobie, że
domyślna instancja Monoid[Option[A]]
ze Scalaz nie robi tego, czego byśmy od niej oczekiwali. Nie jest to jednak przeoczenie
ze strony Scalaz: często będziemy napotykali sytuację, w której dany typ danych może mieć wiele poprawnych implementacji
danej typeklasy, a ta domyślna nie robi tego, czego byśmy chcieli lub w ogóle nie jest zdefiniowana.
Najprostszym przykładem jest Monoid[Boolean]
(koniunkcja &&
vs alternatywa ||
) lub Monoid[Int]
(mnożenie vs dodawanie).
Aby zaimplementować Monoid[TradeTemplate]
musieliśmy albo zaburzyć spójność typeklas, albo użyć innej typeklasy niż Monoid
.
scalaz.Tag
został zaprojektowany jako rozwiązanie tego problemu, ale bez sprowadzania ograniczeń, które napotkaliśmy.
Definicja jest dość pokrzywiona, ale składnia dostarczana użytkownikowi jest bardzo przejrzysta. Oto w jaki sposób możemy
oszukać kompilator i zdefiniować typ A @@ T
, który zostanie uproszczony do A
w czasie wykonania programu:
Kilka użytecznych tagów znajdziemy w obiekcie Tags
:
First
i Last
służą do wyboru między instancjami Monoidu
, które wybierają odpowiednio pierwszy lub ostatni
operand. Za pomocą Multiplication
możemy zmienić zachowanie Monoid
u dla typów liczbowych z dodawania na mnożenie.
Disjunction
i Conjunction
pozwalają wybrać między &&
i ||
dla typu Boolean
.
W naszym przykładzie definiującym TradeTemplate
, zamiast Option[Currency]
mogliśmy użyć Option[Currency] @@ Tags.Last
.
W rzeczywistości jest to przypadek tak częsty, że mogliśmy użyć wbudowanego aliasu LastOption
i tym samym sprawić, że implementacja Monoid[TradeTemplate]
będzie znacznie czystsza.
Tworzymy wartości typu LastOption
poprzez zaaplikowanie Tag
na instancji Option
. W tym wypadku wołamy
Tag(None)
.
W rozdziale o derywacji typeklas pójdziemy o jeden krok dalej i stworzymy monoid
automatycznie.
Kuszącym może wydać się pomysł użycia Tag
ów do oznaczania danych na potrzeby walidacji (np. String @@ PersonName
),
ale należy oprzeć się tym pokusom, gdyż za takim oznaczeniem nie stoją żadne weryfikacje wartości używanych w czasie wykonania.
Tag
powinien być używany tylko do selekcji typeklas, a do ograniczania możliwych wartości dużo lepiej jest użyć
biblioteki Refined
, którą poznaliśmy w Rozdziale 4.
6.5 Transformacje naturalne
Funkcja z jednego typu w drugi zapisywana jest w Scali jako A => B
, ale jest to tylko syntax sugar dla
typu Function1[A, B]
. Scalaz dostarcza podobny mechanizm w formie F ~> G
dla funkcji z konstruktora typu
F[_]
do G[_]
.
Intancje typu F ~> G
nazywamy transformacjami naturalnymi (natural transformation) i mówimy, że są one
uniwersalnie kwantyfikowane, ponieważ nie ma dla nich znaczenia zawartość F[_]
.
Przykładem transformacji naturalnej jest funkcja, która konwertuje IList
na List
Lub, bardziej zwięźle, korzystając ze składni udostępnianej przez kind-projector
:
Jednak w codziennej pracy zdecydowanie częściej będziemy używać transformacji naturalnych do konwersji między
algebrami. Możemy, na przykład, chcieć zaimplementować naszą algebrę Machines
, służącą do komunikacji z Google Container
Engine, za pomocą gotowej, zewnętrznej algebry BigMachines
. Zamiast zmieniać naszą logikę biznesową i wszystkie testy,
tak aby używały nowej algebry, możemy spróbować napisać transformację naturalną BigMachines ~> Machines
.
Powrócimy do tego pomysłu w rozdziale o Zaawansowanych Monadach.
6.6 Isomorphism
Czasami mamy do czynienia z dwoma typami, które tak naprawdę są dokładnie tym samym. Powoduje to problemy z kompatybilnością, ponieważ kompilator tego nie wie. Najczęściej takie sytuacje mają miejsce, gdy chcemy użyć zewnętrznych bibliotek, które definiują coś, co już mamy w naszym kodzie.
W takich właśnie okolicznościach z pomocą przychodzi Isomorphism
, który definiuje relację równoznaczności
między dwoma typami. Ma on 3 warianty dla typów o różnym kształcie:
Aliasy typów IsoSet
, IsoFunctor
i IsoBiFunctor
pokrywają najczęstsze przypadki: zwykłe funkcje,
transformacje naturalne i binaturalne. Funkcje pomocnicze pozwalają nam generować instancje Iso
z gotowych
funkcji lub transformacji, ale często łatwiej jest użyć do tego klas Template
. Na przykład:
Jeśli wprowadzimy izomorfizm, możemy wygenerować wiele standardowych typeklas. Dla przykładu
pozwala nam wyderywować Semigroup[F]
dla typu F
, jeśli mamy F <=> G
oraz Semigroup[G]
.
Niemal wszystkie typeklasy w hierarchii mają wariant dla typów izomorficznych. Jeśli złapiemy się na
kopiowaniu implementacji danej typeklasy, warto rozważyć zdefiniowanie Isomorphism
u.
6.7 Kontenery
6.7.1 Maybe
Widzieliśmy już Maybe
, Scalazowe ulepszenie scala.Option
. Jest to ulepszenie dzięki swojej inwariancji oraz braku
jakichkolwiek nieczystych metod, taki jak Option.get
, które mogą rzucać wyjątki.
Zazwyczaj typ ten używany jest do reprezentacji rzeczy, które mogą być nieobecne, bez podawania żadnej przyczyny ani wyjaśnienia dla tej nieobecności.
.empty
i .just
są lepsze niż tworzenie Just
i Maybe
bezpośrednio, ponieważ zwracają Maybe
, pomagając tym samym
w inferencji typów. Takie podejście często nazywane jest zwracaniem typu sumy (sum type), a więc mimo posiadania
wielu implementacji zapieczętowanego traita (sealed trait) nigdy nie używamy konkretnych podtypów w sygnaturach metod.
Pomocnicza klasa niejawna pozwala nam zawołać .just
na dowolnej wartości i uzyskać Maybe
.
Maybe
posiada instancje wszystkich poniższych typeklas
Align
Traverse
-
MonadPlus
/IsEmpty
Cobind
-
Cozip
/Zip
/Unzip
Optional
oraz deleguje implementację poniższych do instancji dla typu A
-
Monoid
/Band
-
Equal
/Order
/Show
Dodatkowo, Maybe
oferuje funkcjonalności niedostępne w żadnej typeklasie
.cata
to zwięźlejsza alternatywa dla .map(f).getOrElse(b)
dostępna również pod postacią |
jeśli f
to identity
(co jest równoznaczne z .getOrElse
).
.toLeft
i .toRight
oraz ich aliasy symbolicznie tworzą dysjunkcje (opisane w następnym podrozdziale), przyjmując
jako parametr wartość używaną w przypadku napotkania Empty
.
.orZero
używa instancji typeklasy Monoid
do zdobycia wartości domyślnej.
.orEmpty
używa ApplicativePlus
, aby stworzyć jednoelementowy lub pusty kontener. Pamiętajmy, że podobną funkcjonalność
dla kolekcji udostępnia nam .to
pochodzące z Foldable
.
6.7.2 Either
Typ ulepszający scala.Either
w Scalaz jest symbolem, ale przyjęło się nazywać go either lub Disjunction
.
z odpowiednią składnią
pozwalającą na łatwe tworzenie wartości. Zauważ, że te metody przyjmują typ drugiej strony jako parametr. A więc
jeśli chcesz stworzyć String \/ Int
mając Int
, wołając .right
, musisz przekazać String
.
Symboliczna natura \/
sprawia, że dobrze się go czyta, gdy użyty jest jako infiks. Pamiętaj, że typy symboliczne w Scali
wiążą argumenty od lewej, a więc zagnieżdżone \/
muszą być ujęte w nawiasy.
\/
posiada prawostronne (co oznacza, że flatMap
wykonywany jest na \/-
) instancje typeklas:
-
Monad
/MonadError
-
Traverse
/Bitraverse
Plus
Optional
Cozip
oraz zależne od zawartości
-
Equal
/Order
-
Semigroup
/Monoid
/Band
Dodatkowo dostajemy kilka niestandardowych metod
.fold
przypomina Maybe.cata
i wymaga, aby obie strony zostały przemapowane do tego samego typu.
.swap
zamienia strony miejscami, lewa na prawo, prawa na lewo.
Alias |
na getOrElse
jest podobny do tego w Maybe
. Dostajemy też |||
jako alias na orElse
.
+++
pozwala na łączenie dysjunkcji, priorytetyzując te, które wypełnione są po lewej stronie.
-
right(v1) +++ right(v2)
givesright(v1 |+| v2)
-
right(v1) +++ left (v2)
givesleft (v2)
-
left (v1) +++ right(v2)
givesleft (v1)
-
left (v1) +++ left (v2)
givesleft (v1 |+| v2)
.toEither
zapewnia kompatybilność z biblioteką standardową.
Połączenie :?>>
i <<?:
pozwala w wygodny sposób zignorować zawartość \/
wybierając jednocześnie nową wartość
zależnie od jego typu.
6.7.3 Validation
Na pierwszy rzut oka typ Validation
(zaliasowany jako \?/
czyli szczęśliwy Elvis) wydaje się być klonem
Disjunction
:
Z pomocną składnią
Jednak sama struktura danych to nie wszystko. Validation
celowo nie posiada instancji Monad
,
ograniczając się do:
Applicative
-
Traverse
/Bitraverse
Cozip
Plus
Optional
oraz zależnych od zawartości:
-
Equal
/Order
Show
-
Semigroup
/Monoid
Dużą zaletą ograniczenia się do Applicative
jest to, że Validation
używany jest wyraźnie w sytuacjach,
w których chcemy zebrać wszystkie napotkane problemy, natomiast Disjunction
zatrzymuje się przy pierwszym i ignoruje
pozostałe. Aby wesprzeć akumulacje błędów, mamy do dyspozycji ValidationNel
, czyli Validation
z NonEmptyList[E]
po stronie błędów.
Rozważmy wykonanie walidacji danych pochodzących od użytkownika za pomocą Disjunction
i flatMap
:
Jeśli użyjemy |@|
nadal dostaniemy tylko pierwszy błąd. Wynika to z faktu, że Disjunction
jest Monad
ą, a jego metody
.applyX
muszą być spójne z .flatMap
i nie mogą zakładać, że operacje mogą być wykonywane poza kolejnością.
Porównajmy to z:
Tym razem dostaliśmy z powrotem wszystkie napotkane błędy!
Validation
ma wiele metod analogicznych do tych w Disjunction
, takich jak .fold
, .swap
i +++
, plus
kilka ekstra:
.append
(z aliasem +|+
) ma taką samą sygnaturę jak +++
, ale preferuje wariant success
-
failure(v1) +|+ failure(v2)
zwracafailure(v1 |+| v2)
-
failure(v1) +|+ success(v2)
zwracasuccess(v2)
-
success(v1) +|+ failure(v2)
zwracasuccess(v1)
-
success(v1) +|+ success(v2)
zwracasuccess(v1 |+| v2)
.disjunction
konwertuje Validated[A, B]
do A \/ B
. Dysjunkcja ma lustrzane metody .validation
i .validationNel
,
pozwalając tym samym na łatwe przełączanie się między sekwencyjnym i równoległym zbieraniem błędów.
\/
i Validation
są bardziej wydajnymi alternatywami dla wyjątków typu checked do walidacji wejścia, unikającymi
zbierania śladu stosu (stacktrace). Wymagają one też od użytkownika obsłużenia potencjalnych błędów, sprawiając tym samym,
że tworzone systemy są bardziej niezawodne.
6.7.4 These
Napotkaliśmy These
, strukturę danych wyrażającą logiczne LUB, kiedy poznawaliśmy Align
.
z metodami upraszczającymi konstrukcję
These
ma instancje typeklas
Monad
Bitraverse
Traverse
Cobind
oraz zależnie od zawartości
-
Semigroup
/Monoid
/Band
-
Equal
/Order
Show
These
(\&/
) ma też wiele metod, których oczekiwalibyśmy od Disjunction
(\/
) i Validation
(\?/
)
.append
ma 9 możliwych ułożeń i dane nigdy nie są tracone, ponieważ This
i That
mogą być zawsze
zamienione w Both
.
.flatMap
jest prawostronna (Both
i That
), przyjmując Semigroup
y dla strony lewej (This
), tak aby
móc połączyć zawartości, zamiast je porzucać. Metoda &&&
jest pomocna, gdy chcemy połączyć dwie instancje
\&/
, tworząc tuple z prawej strony i porzucając tę stronę zupełnie, jeśli nie jest wypełniona w obu instancjach.
Mimo że zwracanie typu \&/
z naszych funkcji jest kuszące, to jego nadużywanie to antywzorzec.
Głównym powodem dla używania \&/
jest łączenie i dzielenie potencjalnie nieskończonych strumieni danych w
skończonej pamięci. Dlatego też dostajemy do dyspozycji kilka przydatnych funkcji do operowania na EphemeralStream
(zaliasowanym tutaj, aby zmieścić się w jednej linii) lub czymkolwiek z instancją MonadPlus
6.7.5 Either wyższego rodzaju
Typ danych Coproduct
(nie mylić z bardziej ogólnym pojęciem koproduktu w ADT) opakowuje Disjunction
dla konstruktorów typu:
Instancje typeklas po prostu delegują do instancji zdefiniowanych dla F[_]
i G[_]
.
Najpopularniejszym przypadkiem, w którym zastosowanie znajduje Coproduct
, to sytuacja, gdy chcemy
stworzyć anonimowy koprodukt wielu ADT.
6.7.6 Nie tak szybko
Wbudowane w Scalę tuple oraz podstawowe typy danych takie jak Maybe
lub Disjunction
są ewaluowane zachłannie
(eagerly-evaluated).
Dla wygody zdefiniowane zostały warianty leniwe, mające instancje oczekiwanych typeklas:
Wnikliwy czytelnik zauważy, że przedrostek Lazy
jest nie do końca poprawny, a nazwy tych typów danych
prawdopodobnie powinny brzmieć: ByNameTupleX
, ByNameOption
i ByNameEither
.
6.7.7 Const
Const
, zawdzięczający nazwę angielskiemu constant, jest opakowaniem na wartość typu A
, razem
z nieużywanym parametrem typu B
.
Const
dostarcza instancję Applicative[Const[A, ?]]
jeśli tylko dostępny jest Monoid[A]
:
Najważniejszą własnością tej instancji jest to, że ignoruje parametr B
, łącząc wartości typu A
, które napotka.
Wracając do naszej aplikacji drone-dynamic-agents
, powinniśmy najpierw zrefaktorować plik logic.scala
, tak aby używał
Applicative
zamiast Monad
. Poprzednią implementację stworzyliśmy, zanim jeszcze dowiedzieliśmy się, czym jest
Applicative
. Teraz wiemy jak zrobić to lepiej:
Skoro nasza logika biznesowa wymaga teraz jedynie Applicative
, możemy zaimplementować nasz mock F[a]
jako
Const[String, a]
. W każdym z przypadków zwracamy nazwę funkcji, która została wywołana:
Z taką interpretacją naszego programu możemy zweryfikować metody, które są używane:
Alternatywnie, moglibyśmy zliczyć ilość wywołań za pomocą Const[Int, ?]
lub IMap[String, Int]
.
W tym teście zrobiliśmy krok dalej poza tradycyjne testowanie z użyciem Mocków. Const
pozwolił nam
sprawdzić, co zostało wywołane bez dostarczania faktycznej implementacji. Podejście takie jest użyteczne,
kiedy specyfikacja wymaga od nas, abyśmy wykonali konkretne wywołania w odpowiedzi na dane wejście.
Dodatkowo, osiągnęliśmy to, zachowując pełną zgodność typów.
Idąc dalej tym tokiem myślenia, powiedzmy, że chcielibyśmy monitorować (w środowisku produkcyjnym) węzły,
które zatrzymywane są w metodzie act
. Możemy stworzyć implementacje Drone
i Machines
używając Const
i
zawołać je z naszej opakowanej wersji act
Możemy to zrobić, ponieważ monitor
jest czysty i uruchomienie go nie produkuje żadnych efektów ubocznych.
Poniższy fragment uruchamia program z ConstImpl
, ekstrahując wszystkie wywołania do Machines.stop
i zwracając
wszystkie zatrzymane węzły razem WoldView
.
Użyliśmy Const
, aby zrobić coś, co przypomina niegdyś popularne w Javie Programowanie Aspektowe.
Na bazie naszej logiki biznesowej zaimplementowaliśmy monitoring, nie komplikując tej logiki w żaden sposób.
A będzie jeszcze lepiej. Moglibyśmy uruchomić ConstImpl
w środowisku produkcyjnym, aby zebrać informacje
o tym, co ma zostać zatrzymane, a następnie dostarczyć zoptymalizowaną implementację korzystającą
ze specyficznych dla implementacji wywołań batchowych.
Cichym bohaterem tej opowieści jest Applicative
, a Const
pozwala nam pokazać, co jest dzięki niemu możliwe.
Jeśli musielibyśmy zmienić nasz program tak, aby wymagał Monad
y, nie moglibyśmy wtedy użyć Const
, a zamiast tego zmuszeni
bylibyśmy do napisania pełnoprawnych mocków, aby zweryfikować jakie funkcje zostały wywołane dla danych argumentów.
Reguła Najmniejszej Mocy (Rule of Least Power) wymaga od nas, abyśmy używali Applicative
zamiast Monad
, kiedy tylko możemy.
6.8 Kolekcje
W przeciwieństwie do Collections API z biblioteki standardowej, Scalaz opisuje zachowanie kolekcji
za pomocą hierarchii typeklas, np. Foldable
, Traverse
, Monoid
. Co pozostaje do przeanalizowania, to
konkretne struktury danych, ich charakterystyki wydajnościowe i wyspecjalizowane metody.
Ten podrozdział wnika w szczegóły implementacyjne każdego typu danych. Nie musimy zapamiętać wszystkiego, celem jest zrozumieć, jak działa każda ze struktur jedynie w ogólności.
Ponieważ wszystkie kolekcje dostarczają instancje mniej więcej tych samych typeklas, nie będziemy ich powtarzać. W większości przypadków jest to pewna wariacja poniższej listy.
Monoid
-
Traverse
/Foldable
-
MonadPlus
/IsEmpty
-
Cobind
/Comonad
-
Zip
/Unzip
Align
-
Equal
/Order
Show
Struktury danych, które nigdy nie są puste dostarczają również
-
Traverse1
/Foldable1
oraz Semigroup
zamiast Monoid
i Plus
zamiast IsEmpty
.
6.8.1 Listy
Używaliśmy IList[A]
i NonEmptyList[A]
tyle razy, że powinny już być nam znajome.
Reprezentują on ideę klasycznej, jedno-połączeniowej listy:
Główną zaletą IList
nad List
jest brak niebezpiecznych metod, takich jak .head
(jest ona niebezpieczna, gdyż wyrzuca wyjątek
w przypadku pustej kolekcji).
Dodatkowo, IList
jest dużo prostsza, gdyż nie jest częścią hierarchii, oraz zużywa zdecydowanie mniej
pamięci. Ponadto, List
z biblioteki standardowej ma przerażająca implementację, używającą var
, aby obejść
problemy wydajnościowe:
Tworzenie instancji List
wymaga ostrożnej i powolnej, synchronizacji wątków, aby zapewnić
bezpieczne publikowanie. IList
nie ma żadnych tego typu wymagań, a więc może przegonić List
pod względem wydajności.
6.8.2 EphemeralStream
Stream
z biblioteki standardowej jest leniwą wersją List
y, ale obarczoną wyciekami pamięci i niebezpiecznymi
metodami. EphemeralStream
nie przetrzymuje referencji do wyliczonych wartości, łagodząc problemy z przetrzymywaniem
pamięci. Jednocześnie pozbawiony jest niebezpiecznych metod, tak, jak Ilist
.
.cons
, .unfold
i .iterate
to mechanizmy do tworzenia strumieni. ##::
(a więc i cons
) umieszcza nowy element na
początku EStream
u przekazanego przez nazwę. .unfold
służy do tworzenia skończonych (lecz potencjalnie nieskończonych)
strumieni poprzez ciągłe wywoływanie funkcji f
zwracającej następną wartość oraz wejście do swojego kolejnego wywołania.
.iterate
tworzy nieskończony strumień za pomocą funkcji f
wywoływanej na poprzednim jego elemencie.
EStream
może pojawiać się w wyrażeniach pattern matchingu z użyciem symbolu ##::
.
Mimo że EStream
rozwiązuje problem przetrzymywania pamięci, nadal możemy ucierpieć z powodu
powolnych wycieków pamięci, jeśli żywa referencja wskazuje na czoło nieskończonego strumienia.
Problemy tej natury oraz potrzeba komponowania strumieni wywołujących efekty uboczne są powodem, dla którego
istnieje biblioteka fs2.
6.8.3 CorecursiveList
Korekursja (corecursion) ma miejsce, gdy zaczynamy ze stanu bazowego i deterministycznie produkujemy kolejne stany
przejściowe, tak jak miało to miejsce w metodzie EphemeralStream.unfold
, którą niedawno omawialiśmy:
Jest to działanie odwrotne do rekursji, która rozbija dane do stanu bazowego i kończy działanie.
CorecursiveList
to struktura danych wyrażająca EphemeralStream.unfold
i będąca alternatywą dla EStream
, która
może być wydajniejsza w niektórych przypadkach:
Korekursja jest przydatna, gdy implementujemy Comonad.cojoin
, jak w naszym przykładzie z Hood
.
CorecursiveList
to dobry sposób na wyrażenie nieliniowych równań rekurencyjnych, jak te używane w
biologicznych modelach populacji, systemach kontroli, makroekonomii i modelach bankowości inwestycyjnej.
6.8.4 ImmutableArray
Czyli proste opakowanie na mutowalną tablicę (Array
) z biblioteki standardowej, ze specjalizacją
dla typów prymitywnych:
Typ Array
jest bezkonkurencyjny, jeśli chodzi prędkość odczytu oraz wielkość stosu. Jednak
nie występuje tutaj w ogóle współdzielenie strukturalne, więc niemutowalne tablice używane są zwykle tylko, gdy
ich zawartość nie ulega zmianie lub jako sposób na bezpieczne owinięcie danych pochodzących z zastanych
części systemu.
6.8.5 Dequeue
Dequeue
(wymawiana jak talia kart - “deck”) to połączona lista, która pozwala na dodawanie i odczytywanie
elementów z przodu (cons
) lub tyłu (snoc
) w stałym czasie. Usuwania elementów z obu końców jest
stałe statystycznie.
Implementacja bazuje na dwóch listach, jednej dla danych początkowych, drugiej dla końcowych.
Rozważmy instancję przechowującą symbole a0, a1, a2, a3, a4, a5, a6
która może być zobrazowana jako
Zauważ, że lista przechowująca back
jest w odwróconej kolejności.
Odczyt snoc
(element końcowy) to proste spojrzenie na back.head
. Dodanie elementu na koniec Dequeue
oznacza
dodanie go na początek back
i stworzenie nowego FullDequeue
(co zwiększy backSize
o jeden). Prawie
cała oryginalna struktura jest współdzielona. Porównaj to z dodaniem nowego elementu na koniec IList
, co wymaga
stworzenia na nowo całej struktury.
frontSize
i backSize
są używane do zbalansowywania front
i back
, tak, aby zawsze były podobnych rozmiarów.
Balansowanie oznacza, że niektóre operacje mogą być wolniejsze od innych (np. gdy cała struktura musi być przebudowana),
ale ponieważ dzieje się to okazjonalnie możemy ten koszt uśrednić i powiedzieć, że jest stały.
6.8.6 DList
Zwykłe listy mają kiepską wydajność, gdy duże listy są ze sobą łączone. Rozważmy koszt wykonania poniższej operacji:
Tworzonych jest 6 list pośrednich, przechodząc i przebudowując każdą z list trzy krotnie (oprócz gs
, która jest współdzielona
na wszystkich etapach).
DList
(od difference list, listy różnic) jest bardziej wydajnym rozwiązaniem dla tego scenariusza. Zamiast
wykonywać obliczenia na każdym z etapów, wynik reprezentowany jest jako IList[A] => IList[A]
.
Odpowiednikiem naszych obliczeń jest (symbole muszą zostać stworzone za pomocą DList.fromIList
)
gdzie praca podzielona jest na prawostronne (czyli szybkie) złączenia
wykorzystując szybki konstruktor na IList
.
Jak zawsze, nie ma nic za darmo. Występuje tu narzut związany z alokacją pamięci, który może spowolnić nasz kod, jeśli ten i tak zakładał prawostronne złączenia. Największe przyspieszenie uzyskamy, gdy operacje są lewostronne, np.:
Lista różnic cierpi z powodu kiepskiego marketingu. Najprawdopodobniej znalazłaby się w bibliotece standardowej, gdyby
tylko nazywała się ListBuilderFactory
.
6.8.7 ISet
Struktury drzewiaste są doskonałe do przechowywania uporządkowanych danych, tak aby każdy węzeł binarny przechowywał elementy od niego mniejsze w jednej gałęzi, a większe w drugiej. Jednak naiwna implementacja takiej struktury może w łatwy sposób stać się niesymetryczna, gdyż symetria zależeć będzie od kolejności dodawanie elementów. Utrzymywanie perfekcyjnie zbalansowanego drzewa jest możliwe, ale jednocześnie niewiarygodnie nieefektywne, ponieważ każde wstawienie elementu do drzewa powodowałoby jego pełne przebudowanie.
ISet
to implementacja drzewa z ograniczoną równowagą (bounded balance), co oznacza, że jest ono zrównoważone
w przybliżeniu, używając size
każdej gałęzi do równoważenia węzła.
ISet
wymaga, aby A
miało instancję typeklasy Order
oraz musi ona pozostawać taka sama pomiędzy wywołaniami,
gdyż inaczej zaburzone zostaną wewnętrzne założenia, prowadząc tym samym do uszkodzenia danych. Innymi słowy,
zakładamy spójność typeklas, a więc dla dowolnego A
istnieje tylko jedna instancja Order[A]
.
ADT ISet
u niestety pozwala na wyrażenie niepoprawnych drzew. Staramy się pisać ADT tak, aby
w pełni opisywały to, co jest i nie jest możliwe poprzez restrykcję typów, ale nie zawsze jest to możliwe.
Zamiast tego Tip
i Bin
są prywatne, powstrzymując użytkowników przed przypadkowym niepoprawnych drzew.
.insert
jest jedynym sposobem na konstrukcję drzew, definiując tym samym to, jak wygląda poprawna jego forma.
Wewnętrzne metody .balanceL
i .balanceR
są swoimi lustrzanymi odbiciami, a więc przestudiujemy jedynie
.balanceL
, która jest wywoływana, gdy dodawana wartość jest mniejsza niż aktualny węzeł. Jest ona również
wołana przez metodę .delete
.
Równoważenie wymaga, abyśmy sklasyfikowali scenariusze, które mogą się zdarzyć. Przejdziemy przez nie kolejno,
wizualizując (y, left, right)
po lewej stronie i wersją zbalansowaną (znaną tez jako drzewo obrócone,
rotated tree) po prawej.
- wypełnione koła obrazują
Tip
- trzy kolumny to
left | value | right
pochodzące zBin
- diamenty wizualizują dowolny
ISet
Pierwszy scenariusz jest trywialny i zachodzi, gdy obie strony to Tip
y. Nigdy nie napotkamy tego scenariusza, wykonując
.insert
, ale może on wystąpić przy .delete
Drugi przypadek ma miejsce, kiedy lewa strona to Bin
zawierający jedynie Tip
. Nie musimy nic równoważyć, dodajemy
jedynie oczywiste połączenie:
Przy trzecim scenariuszu zaczyna robić się interesująco: lewa strona to Bin
zawierający
Bin
po swojej prawej stronie.
Ale co z dwoma diamentami poniżej lrx
? Czy nie utraciliśmy właśnie informacji? Nie, nie utraciliśmy, ponieważ
możemy wnioskować (na podstawie równoważenia wielkości), że są one zawsze puste (Tip
). Nie istnieje żadna reguła
w naszych scenariuszach, która pozwala na wyprodukowanie drzewa, w którym którykolwiek z tych węzłów to Bin
.
Czwarty przypadek jest przeciwieństwem trzeciego.
W scenariuszu piątym mamy do czynienia z pełnymi drzewami po obu stronach left
i musimy oprzeć
decyzję o dalszych krokach na ich wielkości.
Dla pierwszej gałęzi, 2*ll.size > lr.size
a dla drugiej, 2*ll.size <= lr.size
Szósty przypadek wprowadza drzewo po prawej stronie. Gdy left
jest puste, tworzymy oczywiste połączenie.
Taka sytuacja nigdy nie pojawia się w wyniku .insert
, ponieważ left
jest zawsze pełne:
Ostatni scenariusz zachodzi, gdy mamy pełne drzewa po obu stronach. Jeśli left
jest mniejszy niż
trzykrotność right
, możemy po prostu stworzyć nowy Bin
.
Jednak gdy ten warunek nie jest spełniony i left
jest większy od right
więcej niż trzykrotnie, musimy
zrównoważyć drzewa jak w przypadku piątym.
Tym samym doszliśmy do końca analizy metody .insert
i tego, jak tworzony jest ISet
. Nie powinno dziwić, że
Foldable
jest zaimplementowany w oparciu o przeszukiwanie w-głąb. Metody takie jak .minimum
i .maximum
są optymalne, gdyż struktura danych bazuje na uporządkowaniu elementów.
Warto zaznaczyć, że niektóre metody nie mogą być zaimplementowane tak wydajnie, jak byśmy chcieli. Rozważmy
sygnaturę Foldable.element
Oczywistą implementacją .element
jest użyć przeszukiwania (prawie) binarnego ISet.contains
.
Jednak nie jest to możliwe, gdyż .element
dostarcza Equal
, a .contains
wymaga instancji Order
.
Z tego samego powodu ISet
nie jest w stanie dostarczyć instancji typeklasy Functor
, co w praktyce okazuje się
sensownym ograniczeniem: wykonanie .map
powodowałoby przebudowanie całej struktury. Rozsądnie jest przekonwertować
nasz zbiór do innego typu danych, na przykład IList
, wykonać .map
i przekonwertować wynik z powrotem. W konsekwencji
nie jesteśmy w stanie uzyskać Traverse[ISet]
ani Applicative[ISet]
.
6.8.8 IMap
Wygląda znajomo? W rzeczy samej, IMap
(alias na operator prędkości światła ==>>
) to kolejne równoważone
drzewo, z tą różnicą, że każdy węzeł zawiera dodatkowe pole value: B
, pozwalając na przechowywanie par klucz/wartość.
Instancja Order
wymagana jest jedynie dla typu klucza A
, a dodatkowo dostajemy zestaw przydatnych metod do aktualizowania
wpisów
6.8.9 StrictTree
i Tree
Zarówno StrictTree
, jak i Tree
to implementacje Rose Tree, drzewiastej struktury danych z nieograniczoną
ilością gałęzi w każdym węźle. Niestety, z powodów historycznych, zbudowane na bazie kolekcji z biblioteki standardowej…
Tree
to leniwa (by-need) wersja StrictTree
z wygodnymi konstruktorami
Użytkownik Rose Tree powinien sam zadbać o balansowanie drzewa, co jest odpowiednie, gdy chcemy wyrazić hierarchiczną wiedzę domenową jako strukturę danych. Dla przykładu, w sztucznej inteligencji Rose Tree może być użyte w algorytmach klastrowania do organizacji danych w hierarchie coraz bardziej podobnych rzeczy. Możliwe jest również wyrażenie dokumentów XML jako Rose Tree.
Pracując z danymi hierarchicznymi, dobrze jest rozważyć tę strukturę danych, zanim stworzymy swoją własną.
6.8.10 FingerTree
Finger tree (drzewo palczaste) to uogólniona sekwencja z zamortyzowanym stałym czasem dostępu do elementów i logarytmicznym
złączaniem. A
to typ elementów, a V
na razie zignorujemy:
Przedstawmy FingerTree
jako kropki, Finger
jako prostokąty, a Node
jako prostokąty wewnątrz prostokątów:
Dodanie elementy na początek FingerTree
za pomocą +:
jest wydajne, ponieważ Deep
po prostu dodaje nowy element
do swojego lewego (left
) palca. Jeśli palec to Four
, to przebudowujemy spine
, tak, aby przyjął 3 z tych
elementów jako Node3
. Dodawanie na koniec (:+
) odbywa się tak samo, ale w odwrotnej kolejności.
Złączanie za pomocą |+|
lub <++>
jest bardziej wydajne niż dodawanie po jednym elemencie, ponieważ instancje Deep
mogą zachować swoje zewnętrzne gałęzie, przebudowując jedynie spine
.
Do tej pory ignorowaliśmy V
. Ukryliśmy też niejawny parametr obecny we wszystkich wariantach tego ADT: implicit measurer: Reducer[A, V]
.
Reducer
to rozszerzenie typeklasy Monoid
pozwalające na dodanie pojedynczych elementów do M
Na przykład Reducer[A, IList[A]]
może zapewnić wydajną implementację .cons
6.8.10.1 IndSeq
Jeśli jako V
użyjemy Int
, dostaniemy sekwencje zindeksowaną, gdzie miarą jest wielkość, pozwalając nam
na wyszukiwanie elementu po indeksie poprzez porównywanie indeksu z rozmiarem każdej gałezi w drzewie:
6.8.10.2 OrdSeq
Inną odmianą FingerTree
jest sekwencja uporządkowana, gdzie miarą jest największa wartość w danej gałęzi:
OrdSeq
nie posiada instancji żadnych typeklas, co sprawia, że przydatna jest tylko do stopniowego budowania
uporządkowanej sekwencji (zawierającej duplikaty). Jeśli zajdzie taka potrzeba, możemy zawsze skorzystać z bazowego FingerTree
.
6.8.10.3 Cord
Najpopularniejszym użyciem FingerTree
jest przechowanie tymczasowej reprezentacji String
ów w instancjach Show
.
Budowanie pojedynczego String
a może być tysiąckrotnie szybsze niż domyślna implementacja .toString
dla case class
,
która tworzy nowy ciąg znaków dla każdej warstwy w ADT.
Dla przykładu, instancja Cord[String]
zwraca Three
ze stringiem pośrodku i cudzysłowami po obu stronach
Sprawiając, że String
wygląda tak jak w kodzie źródłowym
6.8.11 Kolejka Priorytetowa Heap
Kolejka priorytetowa to struktura danych, która pozwala na szybkie wstawianie uporządkowanych elementów (zezwalając na duplikaty) oraz szybki dostęp do najmniejszego elementu (czyli takiego z najwyższym priorytetem). Nie jest wymagane, aby elementy inne niż minimalny były przechowywane wg porządku. Naiwna implementacja mogłaby wyglądać tak:
Taki push
jest bardzo szybki (O(1)
), ale reorder
, a zatem i pop
bazują na metodzie IList.sorted
, której
złożoność to O(n log n)
.
Scalaz implementuje kolejkę priorytetową za pomocą struktury drzewiastej, gdzie każdy węzeł ma wartość
mniejszą niż jego dzieci. Heap
pozwala na szybkie wstawianie (insert
), złączanie (union
), sprawdzanie
wielkości (size
), zdejmowanie (pop
) i podglądanie (minimum0
) najmniejszego elementu.
Heap
zaimplementowany jest za pomocą Rose Tree
z wartościami typu Ranked
, gdzie rank
to głębokość
subdrzewa, pozwalająca na balansowanie całej struktury. Samodzielnie zarządzamy drzewem, tak aby minimum
było zawsze na jego szczycie. Zaletą takiej reprezentacji jest to, że minimum0
jest darmowe:
Dodając nowy element, porównujemy go z aktualnym minimum i podmieniamy je jeśli nowa wartość jest mniejsza:
Dodawanie wartości, które nie są minimum, skutkuje nieuporządkowanymi gałęziami drzewa. Kiedy napotkamy dwa, lub więcej, poddrzewa tej samej rangi, optymistycznie dodajemy minimum na początek:
Uniknięcie pełnego uporządkowania drzewa sprawia, że insert
jest bardzo szybki (O(1)
), a więc
producenci wartości nie są karani. Jednak konsumenci muszą ponieść koszt tej decyzji, gdyż
złożoność uncons
to O(log n)
, z racji tego, że musimy odszukać nowe minimum i przebudować drzewo.
Nadal jednak jest to implementacja szybsza od naiwnej.
union
również odracza porządkowanie, pozwalając na wykonanie ze złożonością O(1)
.
Jeśli domyślna instancja Order[Foo]
nie wyraża w poprawny sposób priorytetów, których potrzebujemy, możemy
użyć Tag
u i dostarczyć własną instancję Order[Foo @@ Custom]
dla Heap[Foo @@ Custom]
.
6.8.12 Diev
- interwały dyskretne
Możemy wydajnie wyrazić zbiór liczb całkowitych 6, 9, 2, 13, 8, 14, 10, 7, 5 jako serię domkniętych przedziałów:
[2, 2], [5, 10], [13, 14]
. Diev
to wydajna implementacja tej metody dla dowolnego A
, dla którego istnieje
Enum[A]
. Tym wydajniejsza im gęstsza jego zawartość.
Kiedy aktualizujemy Diev
, sąsiednie przedziały są łączone i porządkowane, tak, że dla każdego zbioru wartości
istnieje unikalna reprezentacja.
Świetnym zastosowaniem dla tej struktury są przedziały czasu. Na przykład w TradeTemplate
z wcześniejszego rozdziału.
Jeśli okaże się, że lista payments
jest bardzo gęsta, możemy zamienić ją na Diev
dla zwiększenia wydajności,
nie wpływając jednocześnie na logikę biznesową, gdyż polega ona na typeklasie Monoid
, a nie metodach specyficznych
dla List
y. Musimy tylko dostarczyć instancję Enum[LocalDate]
.
6.8.13 OneAnd
Przypomnij sobie Foldable
, czyli odpowiednik API kolekcji ze Scalaz, oraz Foldable1
, czyli jego wersję dla
niepustych kolekcji. Do tej pory widzieliśmy instancję Foldable1
jedynie dla NonEmptyList
. Okazuje się, że
OneAnd
to prosta struktura danych, która potrafi opakować dowolną kolekcję i zamienić ją w Foldable1
:
Typ NonEmptyList[A]
mógłby być aliasem na OneAnd[IList, A]
. W podobny sposób możemy stworzyć niepuste
wersje Stream
, DList
i Tree
. Jednak może to zaburzyć gwarancje co do uporządkowania i unikalności elementów:
OneAnd[ISet, A]
to nie tyle niepusty ISet
, a raczej ISet
z zagwarantowanym pierwszym elementem, który może
również znajdować się w tym zbiorze.
6.9 Podsumowanie
W tym rozdziale przejrzeliśmy typy danych, jakie Scalaz ma do zaoferowania.
Nie musimy zapamiętać wszystkiego. Niech każda z sekcji zasadzi ziarno pewnej koncepcji, które może o sobie przypomnieć, gdy będziemy szukać rozwiązania dla konkretnego problemu.
Świat funkcyjnych struktur danych jest aktywnie badany i rozwijany. Publikacje naukowe na ten temat ukazują się regularnie, pokazując nowe podejścia do znanych problemów. Implementacja nowych struktur bazujących na literaturze to dobry sposób na swój własny wkład do ekosystemu Scalaz.
7. Zaawansowane Monady
Znajomość Zaawansowanych Monad to element obowiązkowy, aby móc nazwać się doświadczonym programistą funkcyjnym.
Jednocześnie jesteśmy deweloperami, którzy nieustannie pragną prostego życia, a więc i nasza definicja “zaawansowania”
jest raczej skromna. Dla porównania: scala.concurrent.Future
jest strukturą dużo bardziej skomplikowaną niż jakakolwiek
z prezentowanych w tym rozdziale Monad
.
7.1 Always in motion is the Future
22
Największym problemem z Future
jest to, że rozpoczyna obliczenia w momencie stworzenia, tym samym łącząc definiowanie
programu z jego interpretacją (czyli np. uruchomieniem).
Future
jest też nie najlepszym wyborem ze względów wydajnościowych: za każdym razem, gdy wywołujemy .flatMap
,
domknięcie jest przekazywane do Executor
a, wywołując planowanie wątków i zmiany kontekstu. Nie jest niczym
nadzwyczajnym, aby 50% mocy obliczeniowej CPU wykorzystywane było na te właśnie operacje zamiast rzeczywistej pracy.
W efekcie program zrównoleglony za pomocą Future
może okazać się wolniejszy od swojego sekwencyjnego odpowiednika.
Zachłanna ewaluacja w połączeniu z odwołaniami do executora sprawia, że niemożliwe jest określenie kiedy
zadanie się rozpoczęło, kiedy się zakończyło ani jakie pod-zadania zostały rozpoczęte. Zatem nie powinno nas dziwić,
że “rozwiązania” do monitorowania wydajności frameworków opartych o Future
są solidnym źródłem utrzymania
dla nowoczesnych odpowiedników sprzedawców “cudownych remediów”.
Co więcej, Future.flatMap
wymaga niejawnego przekazania parametru typu ExecutionContext
, zmuszając
użytkownika do myślenia o logice biznesowej i semantyce uruchomienia w tym samym momencie.
7.2 Efekty i efekty uboczne
Jeśli nie możemy wywołać metod z efektami ubocznymi w naszej logice biznesowej ani w Future
(ani w Id
, Either
,
Const
itd.), to kiedy możemy je wywołać? Odpowiedź brzmi: wewnątrz Monad
y, która opóźnia wykonanie
do czasu interpretacji w punkcie wejścia do aplikacji. W takiej sytuacji możemy odnosić się do operacji I/O i mutacji
jako efektów, które widoczne są wprost w systemie typów, odwrotnie niż ukryte i nieoczywiste efekty uboczne.
Najprostszą implementacją takiej Monad
y jest jest IO
Metoda .interpret
wywoływana jest tylko raz, na wejściu do naszej aplikacji:
Niemniej, taka prosta implementacja niesie ze sobą dwa duże problemy:
- może spowodować przepełnienie stosu
- nie umożliwia równoległego wykonywania obliczeń
Oba te problemy zostaną przez nas przezwyciężone w tym rozdziale. Jednocześnie musimy pamiętać,
że niezależnie jak skomplikowana jest wewnętrzna implementacja Monad
y, zasady tutaj opisane zachowują moc:
modularyzujemy definicję programu i jego wykonanie, tak aby móc wyrazić efekty w sygnaturach typów, sprawiając tym samym,
że rozumowanie o nich staje się możliwe, a reużycie kodu łatwiejsze.
7.3 Bezpieczeństwo stosu
Na JVMie każde wywołanie metody dodaje wpis do stosu wywołań aktualnego wątku (Thread
), tak jakbyśmy
dopisywali element na początek List
y. Kiedy metoda kończy działanie, wierzchni wpis jest usuwany. Maksymalna długość
stosu wywołań determinowana jest przez flagę -Xss
ustawianą przy uruchomieniu polecenia java
. Wywołania
ogonowo-rekursywne są wykrywane przez kompilator Scali i nie dodają wpisów do stosu. Kiedy przekroczymy dozwolony
limit poprzez zawołanie zbyt wielu metod, napotkamy StackOverflowError
.
Niestety, każde zagnieżdżone wywołanie na naszym IO
, jak np. .flatMap
, dodaje kolejne wywołania do stosu.
Najprostszym sposobem, aby zaobserwować to zjawisko, jest powtórzenie akcji w nieskończoność i sprawdzenie, czy
taki program przeżyje dłużej niż kilka sekund. Możemy użyć metody .forever
pochodzącej z Apply
(rodzica Monad
y):
Scalaz definiuje typeklasę BindRec
, którą mogą implementować Monad
y niezagrażające przeładowywaniem stosu (stack safe).
Wymaga ona zachowywania stałego rozmiaru stosu przy rekurencyjnych wywołaniach bind
:
Nie dla wszystkich programów potrzebujemy jej instancji, ale jest ona istotna dla Monad
ogólnego przeznaczenia.
Aby osiągnąć wspomniane bezpieczeństwo, należy zamienić wywołania metod na referencje do ADT, czyli monadę Free
:
ADT Free
to naturalna reprezentacja metod z interfejsu Monad
:
-
Return
reprezentuje.point
-
Gosub
reprezentuje.bind
/.flatMap
Kiedy ADT odwzorowuje argumenty powiązanej funkcji, nazywamy to kodowaniem Churcha (Church encodnig).
Typ Free
to skrót od FreeMonad
i nazywa się tak, ponieważ pozwala uzyskać za darmo monadę dla dowolnego S[_]
. Moglibyśmy
na przykład wskazać algebrę Drone
lub Machines
z rozdziału 3 i wygenerować struktury danych reprezentujące nasz program, które stałyby się naszym S[_]
.
Zastanowimy się jak może nam się to przydać pod koniec tego rozdziału.
7.3.1 Trampoline
Free
jest typem bardziej ogólnym, niż tego w tym momencie potrzebujemy. Ustawiając algebrę S[_]
na () => ?
, czyli odroczone wykonanie
znane jako thunk, otrzymamy typ Trampoline
, który pozwoli nam zaimplementować bezpieczną instancję Monad
y
.tailrecM
pochodząca z BindRec
uruchamia .bind
tak długo, aż otrzymamy B
. Mimo że technicznie rzecz biorąc,
nie jest to implementacja, którą spełnia wymagania anotacji @tailrec
, to zachowuje stałą wielkość stosu, ponieważ
każde wywołanie zwraca obiekt ze sterty (heap), a rekurencja zostaje odroczona.
Dostępne są funkcje ułatwiające tworzenie Trampoline
zarówno zachłannie (.done
), jak i przez nazwę (.delay
).
Możemy też stworzyć instancję Trampoline
, przekazując inną jej instancję poprzez nazwę (.suspend
):
Kiedy widzimy w naszym kodzie Trampoline[A]
możemy w głowie podstawić w jej miejsce A
, ponieważ
jej jedyną funkcją jest sprawienie, że nie przeładujemy stosu. Możemy uzyskać A
, interpretując Free
za pomocą .run
.
7.3.2 Przykład: bezpieczna DList
a
W poprzednim rozdziale przedstawiliśmy typ danych DList
jako
Jednak w rzeczywistości jego implementacja przypomina bardziej:
Zamiast aplikować zagnieżdżone wywołanie f
, używamy odraczającej Trampoline
, którą interpretujemy za pomocą .run
,
kiedy zajdzie taka potrzeba, np. w toIList
. Zmiany są minimalne, ale w efekcie otrzymaliśmy bezpieczną wersję DList
,
która nie przepełni stosu niezależnie od tego, jak duże będą przetwarzane listy.
7.3.3 Bezpieczne IO
Używając Trampoline
, możemy w podobny sposób zabezpieczyć nasze IO
:
Interpreter unsafePerformIO()
specjalnie został nazwany w tak odstraszający sposób, aby zniechęcić
do używania go poza punktem wejścia naszej aplikacji.
Tym razem nie zobaczymy StackOverflowError
:
Używanie Trampoline
zazwyczaj wiąże się ze spadkiem wydajności w porównaniu do używania zwykłej referencji.
Okazuje się, że Free
nie jest tak do końca za darmo.
7.4 Biblioteka Transformatorów Monad
Transformatory monad to struktury danych, które opakowują pewną wartość i dostarczają monadyczny efekt.
W Rozdziale 2 używaliśmy OptionT
, aby móc użyć wartości typu F[Option[A]]
w konstrukcji for
, tak jakby była to
po prostu instancja F[A]
. Uzyskaliśmy w ten sposób efekt opcjonalnej wartości. Aby osiągnąć ten sam rezultat,
moglibyśmy też użyć MonadPlus
.
Poniższy zbiór typów danych często nazywany jest Biblioteką Transformatorów Monad (Monad Transformer Library, MTL). W tym podrozdziale opiszemy każdy z nich, zwracając uwagę na, to do czego mogą być przydatne i jak są zbudowane.
E | Wnętrze | Transformator | Typeklasa |
---|---|---|---|
opcjonalność | F[Maybe[A]] |
MaybeT |
MonadPlus |
błędy | F[E \/ A] |
EitherT |
MonadError |
odczyt wartości w czasie wykonania | A => F[B] |
ReaderT |
MonadReader |
dziennik/wielozadaniowość | F[(W, A)] |
WriterT |
MonadTell |
zmieniający się stan | S => F[(S, A)] |
StateT |
MonadState |
zachowaj spokój i idź dalej | F[E \&/ A] |
TheseT |
|
kontrola przepływu | (A => F[B]) => F[B] |
ContT |
7.4.1 MonadTrans
Każdy transformator ma ogólny kształt T[F[_], A]
, dostarczając instancje co najmniej
Monad
y oraz Hoist
(a więc i MonadTrans
):
.liftM
pozwala nam stworzyć instancję transformatora na podstawie F[A]
, na przykład, aby zbudować
OptionT[IO, String]
, wystarczy wywołać .liftM[OptionT]
na IO[String]
.
.hoist
wyraża tę samą koncepcję, ale dla transformacji naturalnych.
W ogólności istnieją trzy sposoby na uzyskanie transformatora:
- z instancji typu wewnętrznego, używając konstruktora
- z pojedynczej instancji
A
, używają.pure
zMonad
y - z
F[A]
, używającliftM
zMonadTrans
Z racji tego, jak działa inferencja typów w Scali, często oznacza to, że dość skomplikowana sygnatura typu musi być zapisania explicite. Transformatory dostarczają nam trochę wygodniejsze konstruktory w swoich obiektach towarzyszących jako obejście tego problemu.
7.4.2 MaybeT
OptionT
, MaybeT
i LazyOptionT
są zaimplementowane w podobny sposób, zapewniając opcjonalność
poprzez odpowiednio Option
, Maybe
i LazyOption
. Skupimy się na MaybeT
, aby uniknąć powtarzania się.
dostarcza MonadPlus
Powyższa implementacja może wydawać się skomplikowana, ale w rzeczywistości jedyne co tutaj się dzieje,
to delegacja operacji do Monad[F]
i opakowywanie wyniku w MaybeT
.
Sam klej i taśma.
Z tą monadą możemy pisać logikę obsługującą opcjonalność wewnątrz kontekstu F[_]
.
Alternatywnie musielibyśmy wszędzie umieszczać Option
lub Maybe
.
Wyobraźmy sobie, że odpytujemy portal społecznościowy, chcąc zliczyć liczbę gwiazdek danego użytkownika,
zaczynając od String
a, który może, lub nie, wskazywać na konkretnego użytkownika. Oto nasza algebra:
Musimy wywołać getUser
a wynik przekazać do getStars
. Jeśli użyjemy typeklasy Monad
,
będzie to dość skomplikowane, gdyż musimy obsłużyć przypadek Empty
:
Jednak mając do dyspozycji MonadPlus
, możemy wessać Maybe
do F[_]
za pomocą .orEmpty
i skupić się na ważniejszych rzeczach:
Jednakże zwiększenie wymagań co do naszego kontekstu na typeklasę MonadPlus
może spowodować problemy na późniejszym etapie, jeśli nie będzie ona dostępna.
Rozwiązaniem jest zmiana kontekstu na MaybeT[F, ?]
(co automatycznie daje nam
instancję MonadPlus
dla dowolnej Monad
y), albo użyć MaybeT
wprost w zwracanym typie,
za cenę nieco większej ilości kodu:
Każdy zespół musi sam wybrać między tymi opcjami, na bazie tego jakich interpreterów planują używać dla swoich programów.
7.4.3 EitherT
Wartość opcjonalna to tak naprawdę szczególny przypadek wartości, która może być błędem,
ale nic o tym błędzie nie wiemy. EitherT
(i jego leniwy wariant LazyEitherT
) pozwalają nam
użyć wartości dowolnego typu do wyrażenia błędu, który powie nam, co poszło nie tak w naszych
obliczeniach.
W praktyce EitherT
to opakowana wartość typu F[A \/ B]
z instancją MonadError
.raiseError
i .handleError
są samoopisującymi się odpowiednikami throw
i catch
, które znamy
z pracy z wyjątkami.
MonadError
dostarcza również dodatkową składnię do rozwiązywania popularnych problemów:
.attempt
przenosi błędy z kontekstu do wartości.
.recover
służy do zamiany błędów na wartości dla wszystkich przypadków, w przeciwieństwie do
.handleError
, która pozwala nam zwrócić F[A]
, czyli tym samym częściowo obsłużyć błędy.
.emap
, czyli either map, pozwala zaaplikować transformację, która sama w sobie może się nie udać.
MonadError
dla EitherT
wygląda następująco:
Nie powinno też dziwić, że możemy przepisać przykład z MonadPlus
, używając MonadError
i dostarczając informacje
o błędzie:
gdzie .orError
to funkcja pomocnicza zdefiniowana na Maybe
.
Wersja używająca EitherT
bezpośrednio:
Najprostszą instancją MonadError
jest \/
, idealny typ do testowania logiki biznesowej wymagającej
tej typeklasy. Na przykład:
Nasz test dla .stars
może pokryć takie warianty:
Po raz kolejny możemy skupić się na testowaniu logiki biznesowej bez zbędnych dystrakcji.
Możemy w końcu wrócić do naszego JsonClient
a z Rozdziału 4.3
gdzie w API zawarliśmy jedynie szczęśliwą ścieżkę wykonania. Jeśli nasz interpreter dla tej algebry
działa jedynie dla F
mających instancję MonadError
musimy zdefiniować jakiego rodzaju błędy mogą się pojawić.
I faktycznie, jeśli zdecydujemy się interpretować EitherT[IO, JsonClient.Error, ?]
, to możemy mieć dwie warstwy błędów
które pokrywają odpowiednio problemy ze statusem odpowiedzi serwera oraz z naszym modelem dekodowanych obiektów.
7.4.3.1 Wybieranie typu błędu
Społeczność jest podzielona co do najlepszej strategii wyrażania błędów za pomocą E
w MonadError
.
Jedna szkoła mówi, że powinniśmy wybrać jakiś ogólny typ, np. String
. Druga twierdzi, że każda aplikacja powinna mieć ADT
wyrażające błędy, aby każdy z nich mógł być raportowany i obsługiwany inaczej. Gang niepryncypialny woli używać Throwable
dla maksymalnej
kompatybilności z JVMem.
Wprowadzenie wspomnianego ADT niesie za sobą dwa problemy:
- dodawanie nowych błędów jest niewygodne, a jeden z plików musi stać się monolitycznym repozytorium błędów, agregując ADT z pojedynczych podsystemów.
- niezależnie jak drobnoziarniste będą błędy, ich obsługa jest zazwyczaj taka sama: zaloguj i spróbuj ponownie albo przerwij przetwarzanie. Nie potrzebujemy do tego ADT.
ADT niesie ze sobą wartość, jeśli każdy wariant pozwala na inną strategię obsługi.
Kompromisem między ADT i String
iem jest format pośredni, jak np. JSON, który jest rozumiany przez większość
bibliotek odpowiedzialnych za logowanie i monitoring.
Brak stacktrace’a może znacznie utrudnić zlokalizowanie fragmentu kodu odpowiedzialnego za zgłoszenie danego błędu.
Możemy rozwiązać ten problem, używając biblioteki sourcecode
autorstwa Li Haoyi:
Mimo że Err
jest referencyjnie transparentny, jego niejawna konstrukcja już nie.
Dwa wywołania Meta.gen
wyprodukują różne wartości, ponieważ ich umieszczenie w kodzie
wpływa na zwracaną wartość:
Aby zrozumieć, czemu tak się dzieje, musimy zdać sobie sprawę, że metody sourcecode.*
to tak
naprawdę makra, które generują dla nas kod. Jeśli napiszemy tę samą logikę wprost, to
wszystko stanie się jasne:
Zgadza się, zawarliśmy pakt z diabłem pod postacią makr, ale jeśli mielibyśmy tworzyć obiekty
Meta
ręcznie to nasz kod zdezaktualizowywałby się szybciej niż nasza dokumentacja.
7.4.4 ReaderT
Monada ReaderT
opakowuje A => F[B]
pozwalając programowi F[B]
zależeć od wartości A
znanej dopiero w czasie wykonania.
Dla tych zaznajomionych ze wstrzykiwaniem zależności (dependency injection), jest to funkcyjny odpowiednik
anotacji @Inject
znanej ze Springa lub Guice’a, tyle że bez dodatku XMLa czy refleksji.
ReaderT
jest w rzeczywistości jedynie aliasem do bardziej ogólnego typu danych
nazwanego na cześć matematyka Henryka Kleisli.
Niejawna konwersja widoczna w obiekcie towarzyszącym pozwala nam używać Kleisli
tam, gdzie spodziewamy się funkcji,
w efekcie czego możemy przekazywać instancje tego typu jako parametr do .bind
lub >>=
.
Najpopularniejszym zastosowaniem ReaderT
jest dostarczanie informacji ze środowiska do naszego programu.
W drone-dynamic-agents
potrzebujemy dostępu do tokenu odświeżającego OAuth 2.0 dla naszego użytkownika, aby
móc połączyć się z serwerem Google’a. Oczywistym wydaje się odczytanie RefreshTokens
z dysku przy starcie aplikacji i
dodanie parametru RefreshToken
do każdej metody. Okazuje się, że jest to problem na tyle częsty, że Martin Odersky
zaproponował nowy mechanizm funkcji niejawnych,
które mogłyby nam tutaj pomóc.
Lepszym rozwiązaniem jest zdefiniowanie algebry, która dostarczy potrzebnej nam konfiguracji, np:
Tym samym odkryliśmy typeklasę MonadReader
, związaną z ReaderT
, gdzie .ask
jest tym samym
co nasza metoda .token
, a S
to RefreshToken
:
wraz z implementacją
Prawa obowiązujące MonadReader
zastrzegają, że S
nie może zmieniać się między wywołaniami, a więc
ask >> ask === ask
. W naszym przypadku oznacza to, że konfiguracja jest czytana raz. Jeśli
później zdecydujemy, że chcielibyśmy przeładowywać konfigurację za każdym razem, gdy jest potrzebna,
to możemy ponownie wprowadzić typ ConfigReader
, który nie ma takich ograniczeń.
W naszej implementacji OAuth 2.0 możemy zacząć od przeniesienia parametru Monad
do metod:
a następnie zamienić parametr refresh
we fragment Monad
y
W ten sposób możemy przenieść dowolny parametr do MonadReader
, co niesie największą wartość
dla wywołań, które tylko przekazują tę wartość z zewnątrz.
Drugą metodą w MonadReader
ze jest .local
Możemy zmodyfikować S
i uruchomić fa
wewnątrz takiego kontekstu. Przykładowym zastosowaniem .local
może być generowanie “śladów stosu”, które mają sens dla naszej domeny, pozwalając nam tym samym
na zagnieżdżone logowanie! Polegając na typie Meta
z poprzedniego rozdziału, możemy zdefiniować
poniższą funkcję:
i używać jej do opakowywania funkcji, które wymagają takiego kontekstu
automatycznie przekazując dalej wszystko, co nie jest oznaczone wprost. Plugin do kompilatora albo makro mogłoby działać odwrotnie, śledząc wszystko automatycznie.
Jeśli wywołamy .ask
, zobaczymy dokładne ślady prowadzące do naszego wywołania, bez detali implementacyjnych
związanych z kodem bajtowym. Tym samym otrzymaliśmy referencyjnie transparenty ślad stosu!
Ostrożny programista mógłby chcieć w pewnym momencie przyciąć IList[Meta]
aby uniknąć odpowiednika
przepełnienia stosu. Tym samym bardziej odpowiednią strukturą danych byłaby Dequeue
.
.local
może być użyte również do śledzenie informacji kontekstowych, które są bezpośrednio związane
z aktualnie wykonywanym zadaniem, jak na przykład liczba spacji potrzebnych do wcięcia linii, gdy wyświetlamy
format przyjazny dla ludzi, zwiększając tę liczbę o dwa, gdy zwiększamy zagnieżdżenie.
W końcu, kiedy nie możemy zażądać instancji MonadReader
, ponieważ nasza aplikacja nie umie takowej
dostarczyć, możemy zawsze zwrócić ReaderT
Jeśli wywołujący otrzyma ReaderT
i ma pod ręką parametr token
, to wystarczy, że wywoła
access.run(token)
, aby otrzymać F[BearerToken]
.
Faktycznie, biorąc pod uwagę fakt, że nie mamy zbyt wiele wywołujących, powinniśmy wrócić do tradycyjnych
parametrów funkcji. MonadReader
ma na najwięcej zastosowań, gdy:
- możemy chcieć w przyszłości przerefactorować kod, aby konfiguracja była przeładowywana
- wartość nie jest używana przez metody pośredniczące (intermediate callers)
- chcemy lokalnie zmienić jakąś zmienną
Dotty może zatrzymać funkcje niejawne dla siebie, my już mamy wszystko, czego nam trzeba: ReaderT
i MonadReader
.
7.4.5 WriterT
Odwrotnością czytania jest pisanie, a transformator monad WriterT
służy właśnie do tego.
Opakowywany typ to F[(W, A)]
, a nasz dziennik jest akumulowany wewnątrz W
.
Mamy do dyspozycji nie jedną, a dwie powiązane monady: MonadTell
i MonadListen
!
MonadTell
służy do spisywania dziennika a MonadListen
do jego odtwarzania.
Ich implementacja dla WriterT
wygląda następująco:
Oczywistym zastosowaniem MonadTell
jest logowanie lub zbieranie danych audytowych. Raz jeszcze używając
Meta
możemy wyobrazić sobie taki log
i użyć Dequeue[Log]
jako naszego dziennika. Tym razem zmodyfikujemy metodę authenticate
z części kodu
odpowiedzialnej za obsługę OAuth2.
Moglibyśmy nawet połączyć to podejście ze śledzeniem opartym o ReaderT
, aby uzyskać ustrukturalizowany log zdarzeń.
Dziennik może zostać odzyskany za pomocą .written
, a następnie dowolnie modyfikowany.
Jednak istnieje silny argument za tym, aby logowanie otrzymało swoją własną algebrę. Poziom logowania jest często potrzebny w momencie stworzenia wiadomości dla celów wydajnościowych, a ponadto logowanie często konfigurowane i zarządzane jest na poziomie całej aplikacji, a nie pojedynczych komponentów.
Parametr W
w WriterT
posiada Monoid
, pozwalając nam tym samym na wszelkiego rodzaju monoidyczne operacje,
które będą działy się równolegle do naszego głównego programu. Możemy na przykład zliczać, ile razy coś się wydarzyło,
budować opis obliczeń lub tworzyć TradeTemplate
dla nowej transakcji, gdy ją wyceniamy.
Popularną specjalizacją WriterT
jest użycie go z monadą Id
, sprawiając, że leżąca pod spodem wartość run
to prosta tupla (W, A)
.
W taki sposób możemy nieść dodatkową monoidyczną kalkulację obok dowolnej wartości bez kontekstu F[_]
.
W skrócie WriterT
i MonadTell
to sposoby na multizadaniowość w stylu FP.
7.4.6 StateT
StateT
pozwala nam włożyć (.put
), wyciągnąć (.get
) i zmodyfikować (.modify
) wartość zarządzaną przez monadyczny kontekst.
Jest to czysto funkcyjny zamiennik dla var
.
Jeśli mielibyśmy napisać nieczystą metodę korzystającą z mutowalnego stanu przechowywanego wewnątrz var
, jej sygnatura
mogłaby przyjąć postać () => F[A]
, a ona sama zwracałaby inną wartość przy każdym wywołaniu, zaburzając w ten sposób transparentność
referencyjną. W czystym FP taka funkcja przyjmuje stan jako wejście i produkuje i zwraca zmodyfikowany stan jako wyjście.
Dlatego też StateT
opakowuje S => F[(S,A)]
.
Powiązana monada to MonadState
:
Struktura StateT
jest zaimplementowana nieco inaczej niż transformatory, które widzieliśmy do tej pory,
nie jest case klasą, lecz ADT z dwoma wariantami:
które są wyspecjalizowaną formą Trampoline
, dając nam bezpieczeństwo stosu, kiedy chcemy odwołać się
do leżącej pod spodem struktury za pomocą .run
:
StateT
ze swoim ADT w trywialny sposób implementuje MonadState
.pure
otrzymał swoją kopię .stateT
w obiekcie towarzyszącym:
a MonadTrans.liftM
jak zawsze dostarcza nam konstruktor F[A] => StateT[F, S, A]
.
Popularną odmianą StateT
jest F = Id
. W takim wypadku opakowywany typ to S => (S, A)
.
Scalaz definiuje alias typu State
i wygodne funkcje to interakcji z nim, które udają MonadState
:
Wróćmy na chwilę do testów logiki biznesowej z drone-dynamic-agents
. W Rozdziale 3 stworzyliśmy testowy interpreter Mutable
,
który przechowywał liczbę wystartowanych i zatrzymanych węzłów w var
.
Teraz wiemy już jak napisać dużo lepszy interpreter, używając State
. Przy okazji skorzystamy z możliwości
zwiększenia dokładności naszej symulacji. Przypomnijmy nasz kluczowy obiekt przechowujący obraz świata:
Skoro piszemy symulację świata na potrzeby naszych testów, to możemy zdefiniować typ danych przechwytujący pełen jego obraz
Główną różnicą jest to, że mamy do dyspozycji zmienne started
i stopped
. Nasz interpreter może być budowany na bazie
State[World, a]
, pozwalając nam na weryfikacje tego, jak wygląda zarówno World
, jak i WorldView
po wykonaniu logiki biznesowej.
Interpretery udające zewnętrzne usługi Drone i Google będą wyglądać tak:
a my możemy przepisać nasze testy tak, aby zachowywały konwencję:
-
world1
to stan świata przed uruchomieniem programu -
view1
to widok świata z punktu widzenia aplikacji -
world2
to stan świata po uruchomieniu programu -
view2
to widok świata z punktu widzenia aplikacji po uruchomieniu programu
Przykład:
Moglibyśmy spojrzeć na naszą nieskończoną pętlę logiki biznesowej
i użyć w niej StateT
do zarządzania stanem. Niestety, w ten sposób naruszylibyśmy Regułę Najmniejszej Mocy, wymagając
MonadState
zamiast aktualnie wymaganego Applicative
. Tak więc całkowicie rozsądne jest zarządzanie ręczne
i przekazywanie stanu do update
i act
.
7.4.7 IndexedStateT
Kod, który widzieliśmy do tej pory nie pochodzi ze Scalaz. Tak naprawdę StateT
jest zaimplementowany tam
jako alias typu wskazujący na kolejny typ: IndexedStateT
.
Implementacja IndexedStateT
jest bardzo podobna do tej, którą widzieliśmy do tej pory, z dodatkiem
jednego parametru typu, który pozwala na to, by stan wejściowy S1
był inny niż stan wyjściowy S2
:
IndexedStateT
nie posiada instancji MonadState
kiedy S1 != S2
, więc w takiej sytuacji musi nas zadowolić
instancja samego Monad
.
Poniższy przykład został zaadaptowany z prezentacji Vincentego Maqrqueza Index your State.
Wyobraź sobie, że musimy zaprojektować algebraiczny interfejs dla dostępu do wartości typu String
za pomocą klucza typu Int
. Załóżmy, że jedna z implementacji będzie opierała się na komunikacji sieciowej,
a kolejność wywołań jest kluczowa. Nasze pierwsze podejście mogłoby wyglądać tak:
produkując błędy w czasie wykonania, jeśli .update
lub .commit
zostaną wywołane bez wcześniejszego użycia
.lock
. Alternatywą mógłby być skomplikowany DSL, którego nikt nie umiałby użyć bez zaglądania do dokumentacji.
Zamiast tego użyjemy IndexedStateT
, aby wymusić odpowiedni stan na wywołującym. Zacznijmy od możliwych stanów
wyrażonych jako ADT
i odświeżenia naszej algebry
co spowoduje, że próba wywołania .update
bez wcześniejszego .lock
spowoduje błąd kompilacji
pozwalając nam konstruować funkcje, które mogą być komponowane dzięki wyrażaniu swojego stanu explicite
7.4.8 IndexedReaderWriterStateT
Ci, którzy chcieli kombinacji ReaderT
, WriterT
i InedexedStateT
nie będą zawiedzeni.
Transformator IndexedReaderWriterStateT
opakowuje (R, S1) => F[(W, A, S2)]
gdzie R
to Reader
, W
służy do monoidycznych zapisów, a S
do indeksowanych aktualizacji stanu.
Do dyspozycji mamy skróty, bo trzeba przyznać, że te typy są tak długie, że wyglądają jak część API J2EE.
IRWST
to bardziej wydajny odpowiednik ręcznie skonstruowanego stosu transformatorów
ReaderT[WriterT[IndexedStateT[F, ...], ...], ...]
.
7.4.9 TheseT
TheseT
pozwala nam wybrać czy błędy mają zakończyć obliczenia, czy też mają być zakumulowane
w przypadku częściowego sukcesu. Stąd zachowaj spokój i idź dalej (keep calm and carry on).
Opakowywany typ danych to F[A \&/ B]
, gdzie A
to typ błędów, z wymaganą instancją Semigroup
,
jeśli chcemy je akumulować.
Nie istnieje żadna specjalna monada dla TheseT
ponad Monad
. Jeśli chcemy zakończyć
obliczenia, zwracamy This
, ale akumulujemy błędy, zwracając wartość Both
, która zawiera także
poprawnie obliczoną część wyniku.
Możemy spojrzeć na TheseT
z innej strony: A
nie musi być wcale błędem. Podobnie jak w przypadku
WriterT
, A
może być nośnikiem dla innej wartości, którą obliczamy wraz z B
. TheseT
pozwala
zatrzymać się, gdy coś specyficznego dla A
tego od nas wymaga. Jak wtedy, gdy Charlie Bucket
wyrzucił swoją czekoladę (B
), jak tylko odnalazł Złoty Kupon (A
).
7.4.10 ContT
Styl Przekazywania Kontynuacji (CPS, Continuation Passing Style) to styl programowania, w którym funkcje nigdy nie zwracają wartości, a zamiast tego kontynuują następne obliczenia. CPS jest popularny w JavaScripcie i Lispie, pozwalając na wykonywanie nieblokującego IO za pomocą callbacków, gdy dane stają się dostępne. Bezpośrednie przełożenie tego wzorca na nieczystą Scalę wygląda mniej więcej tak:
Możemy sprawić, że ten kod stanie się czysty, wprowadzając kontekst F[_]
i zwracając funkcję dla danego wejścia
ContT
to opakowanie dla takiej właśnie sygnatury, z dodatkiem instancji typeklasy Monad
i wygodnej składni do tworzenia ContT
z monadycznej wartości:
Jednak proste użycie callbacków nie wnosi nic do programowania czysto funkcyjnego, ponieważ
poznaliśmy już sposób na sekwencyjne łączenie nieblokujących, potencjalnie rozproszonych
obliczeń: Monad
ę. Aby zobaczyć dlaczego kontynuacje są użyteczne, musimy rozważyć
bardziej złożony przykład ze sztywnymi ograniczeniami projektowymi.
7.4.10.1 Kontrola przepływu
Załóżmy, że podzieliliśmy naszą aplikację na moduły, które mogą wykonywać operacje I/O, a każdy z nich rozwijany jest przez osobny zespół:
Naszym celem jest wyprodukować A0
na podstawie otrzymanego A1
. Tam, gdzie JavaScript lub Lisp
sięgnęliby po kontynuacje (ponieważ IO może blokować), my możemy po prostu połączyć funkcje:
Możemy wynieść .simple
do postaci kontynuacji, używając .cps
i odrobiny boilerplate’u:
Co zyskaliśmy? Po pierwsze, warto zauważyć, że przepływ kontroli w tej aplikacji biegnie od lewej do prawej strony.
Co, gdy jako autorzy foo2
chcielibyśmy zmodyfikować wartość a0
, którą otrzymujemy z prawej strony?
W praktyce oznacza to podzielenie foo2
na foo2a
i foo2b
Dodajmy ograniczenie, że nie możemy zmodyfikować tego, jak zdefiniowane są metody flow
i bar0
.
Może na przykład pochodzą one z frameworka lub biblioteki, których używamy.
Nie jesteśmy w stanie przeprocesować a0
poprzez modyfikację żadnej z pozostałych metod barX
,
jednak używając ContT
, możemy zaimplementować metodę foo2
tak, aby mogła wykonać obliczenia
na podstawie wyniku kontynuacji next
:
Na przykład w taki sposób:
Nie jesteśmy ograniczeni do .map
owania wartości. Możemy również wywołać .bind
i zamienić
liniowy przepływ w pełnoprawny graf!
Możemy też zostać przy oryginalnym przepływie i ponowić wszystkie dalsze operacje.
W tym wypadku ponawiamy operacje tylko raz, a nie w nieskończoność, np. upewniamy się, czy na pewno powinniśmy wykonać jakąś potencjalnie niebezpieczną operację.
W końcu możemy też wykonać akcje specyficzne dla kontekstu ContT
, czyli w tym wypadku IO
,
który pozwala nam na obsługę błędów i uprzątnięcie zasobów:
7.4.10.2 Kiedy zamówić spaghetti
Nie przez przypadek nasze diagramy wyglądają jak spaghetti. Tak właśnie dzieje się, kiedy
zaczynamy bawić się przepływem kontroli. Wszystkie mechanizmy, które omówiliśmy w tym podrozdziale,
można w łatwy sposób zaimplementować bezpośrednio, gdy możemy zmodyfikować flow
, a więc
nie musimy używać ContT
.
Jednak jeśli projektowalibyśmy framework, to system pluginów opartych na ConT
i pozwalający użytkownikom
kontrolować przepływ byłby czymś wartym rozważenia. Czasem użytkownik po prostu chce spaghetti.
Gdyby kompilator Scali był napisany z użyciem CPS, to kolejne fazy kompilacji mogłyby komunikować się ze sobą w prosty i regularny sposób. Plugin kompilatora mógłby wykonywać akcje, bazując na wyinferowanym typie wyrażenia pochodzącym z późniejszej fazy kompilacji. Podobnie kontynuacje byłyby dobrym API dla narzędzie do budowania projektów (build tool) albo edytora tekstu.
Pułapką ConT
jest fakt, że nie jest to struktura bezpieczna od przepełnienia stosu, a więc
nie nadaje się do tworzenia programów, które nigdy się nie kończą.
7.4.10.3 Great, kid. Don’t get ContT
.
Bardziej złożony wariant ContT
znany jako IndexedContT
opakowuje (A => F[B] => F[C])
.
Nowy parametr typu C
definiuje typ zwracany przez cały program, który może być inny, niż
ten przekazywany między komponentami. Jednak jeśli B
nie jest równe C
to nie jesteśmy w stanie
zdefiniować Monad
y.
Korzystając z okazji do zgeneralizowania kodu tak, jak to tylko możliwe, zarówno typ IndexedContT
, jak i ConT
są
w praktyce zaimplementowane jako aliasy na jeszcze bardziej ogólną strukturę IndexedContsT
(zwróć uwagę
na dodatkowe s
przed T
)
w której W[_]
posiada instancję Comonad
. Dla wspomnianych aliasów istnieją obiekty towarzyszące
z pomocnymi konstruktorami.
Wprawdzie pięć parametrów typów to raczej przesada, ale takie przegeneralizowanie pozostaje spójne ze specyfiką kontynuacji.
7.4.11 Stosy transformatorów i niejednoznaczne parametry niejawne
Czas podsumować naszą wycieczkę wśród transformatorów monad zdefiniowanych w Scalaz.
Kiedy wiele transformatorów jest składanych ze sobą, nazywamy to stosem transformatorów (transformer stack),
i mimo że jest to dość rozwlekłe, to można odczytać dostarczane w ten sposób funkcjonalności. Jeśli skonstruujemy
kontekst F[_]
taki jak
wiemy, że dodajemy obsługę błędów typu E
(istnieje MonadError[Ctx. E]
) i stan S
(istnieje MonadState[Ctx, S]
).
Niestety istnieją pewne praktyczne przeciwności co do stosowania takich stosów i towarzyszących im instancji typeklas:
- Wiele niejawnych instancji
Monad
oznacza, że kompilator nie może odnaleźć odpowiedniej składni dla kontekstu - Monady nie komponują się w sposób ogólny, co oznacza, że kolejność zagnieżdżania ma znaczenie
- Wszystkie interpretery muszą obsługiwać ten wspólny kontekst. Dla przykładu: jeśli mamy implementację pewnej algebry,
która używa
IO
, to i tak musimy opakować ją wStateT
iEitherT
, mimo że nie będą one używane w interpreterze. - Każda warstwa niesie ze sobą koszt wydajnościowy. Niektóre transformatory są gorsze niż inne, np.
StateT
jest szczególnie kosztowny, ale nawetEitherT
może sprawiać problemy z alokacją pamięci dla aplikacji o dużej przepustowości.
Porozmawiajmy o obejściach tych problemów.
7.4.11.1 Brak składni
Powiedzmy, że mamy algebrę
i typy danych
które chcielibyśmy użyć w naszej logice biznesowej:
Pierwszy problem: nasz kod się nie kompiluje.
Istnieją pewne taktyczne rozwiązania tego problemu. Najbardziej oczywistym jest przyjęcie parametrów wprost
i wymaganie jedynie Monad
y niejawnie poprzez ograniczenie kontekstu. Jednak oznacza to, że musimy ręcznie
przekazać MonadError
i MonadState
, kiedy wywołujemy foo1
oraz gdy wołamy inne metody, które wymagają
parametrów niejawnych.
Drugim rozwiązaniem jest pozostawienie parametrów jako implicit
i użycie przesłaniania nazw tak by wszystkie
stały się jawne. Pozwala to innym wywoływać nas, używając niejawnego rozstrzygania, ale my nadal musimy
przekazywać parametry wprost, gdy są potrzebne.
Moglibyśmy też przesłonić tylko jedną z Monad
, pozostawiając drugą tak, by mogła być użyta do dostarczenia
nam potrzebnej składni i gdy wywołujemy inne metody.
Trzecia opcja, która niesie ze sobą wyższy koszt początkowy, to stworzenie własnej typeklasy, która będzie przechowywać referencje do dwóch pozostałych, których potrzebujemy
i którą automatycznie wyderywujemy z dostępnych instancji MonadError
i MonadState
.
Teraz, gdy potrzebujemy S
lub E
mamy do nich dostęp poprzez F.S
i F.E
I tak jak w drugim podejściu, możemy wybrać jedną z Monad
jako niejawną w naszym bloku, importując ją
7.4.11.2 Komponowanie transformatorów
EitherT[StateT[...], ...]
posiada instancję MonadError
, ale nie MonadState
, natomiast StateT[EitherT[...], ...]
daje nam obie.
Rozwiązaniem jest przestudiowanie reguł niejawnej derywacji transformatorów zawartych w obiektach towarzyszących, aby upewnić się, że najbardziej zewnętrzny z nich dostarcza wszystkie instancje, których potrzebujemy.
Zasada kciuka: im bardziej skomplikowany transformator, tym bliżej wierzchu stosu powinien być umieszczony. W tym rozdziale prezentowaliśmy je z rosnącym poziomem skomplikowania, co powinno ułatwić aplikację tej zasady.
7.4.11.3 Wynoszenie interpreterów
Kontynuując ten sam przykład, załóżmy, że nasza algebra Lookup
ma interpreter oparty o IO
ale chcielibyśmy, aby nasz kontekst wyglądał tak
aby móc używać MonadError
i MonadState
. Oznacza to, że musimy opakować LookupRandom
tak, aby mógł operować na Ctx
.
Najpierw użyjemy metody .liftM
z MonadTrans
, która wynosi F[A]
do postaci G[F, A]
Ważne jest, aby pamiętać, że parametr typu przekazywany do .liftM
sam ma dwa parametry,
pierwszy o kształcie _[_]
i drugi _
. Jeśli stworzymy odpowiednie aliasy
to możemy abstrahować ponad MonadTrans
, aby wynieść Lookup[F]
do dowolnego Lookup[G[F, ?]]
tak długo jak G
to transformator monad:
Możemy więc opakować algebrę kolejno dla EitherT
i StateT
Innym sposobem osiągnięcia tego samego rezultatu w pojedynczym kroku jest użycie typeklasy MonadIO
,
która pozwala nam wynieść IO
do stosu transformatorów:
i posiada instancje dla wszystkich popularnych kombinacji transformatorów.
Boilerplate potrzebny, by wynieść interpreter oparty o IO
do dowolnego kontekstu posiadającego
instancję MonadIO
to dwie linie kodu (dla definicji interpretera), plus jedna linia dla każdego elementu algebry
i jedna linia wywołująca konwersję:
7.4.11.4 Wydajność
Największym problemem Transformatorów Monad jest ich narzut wydajnościowy. EitherT
ma dość mały narzut,
gdzie każde wywołanie .flatMap
generuje tylko garść obiektów, ale nawet to może wpłynąć na aplikacje
wymagające wysokiej przepustowości, gdzie każda alokacja ma znaczenie. Inne transformatory, takie jak
StateT
, dodają trampolinę do każdego wywołania, a ContT
przechowuje cały łańcuch wywołań w pamięci.
Jeśli wydajność staje się problemem, jedynym rozwiązaniem jest zrezygnować z Transformatorów Monad,
a przynajmniej ich struktur danych. Dużą zaletą typeklas opartych o Monad
, takich jak np. MonadState
,
jest fakt, że możemy stworzyć zoptymalizowany kontekst F[_]
, który będzie dostarczał wspomniane typeklasy.
Zobaczymy jak stworzyć optymalne F[_]
w następnych dwóch rozdziałach, kiedy bliżej przyjrzymy się poznanym już
strukturom Free
i IO
.
7.5 Darmowy lunch
Nasza branża pragnie bezpiecznych, wysokopoziomowych języków, które pozwalają na zwiększenie wydajności deweloperów kosztem zmniejszonej wydajności kodu.
Kompilator Just In Time (JIT) na JVMie działa tak dobrze, że proste funkcje mogą działać porównywalnie szybko co ich odpowiedniki w C lub C++, ignorując koszt garbage collectora. Jednak JIT wykonuje jedynie optymalizacje niskopoziomowe: predykcję gałęzi (branch prediction), inlinowanie metod, rozwijanie pętli itd.
JIT nie zastosuje optymalizacji do naszej logiki biznesowej, jak na przykład łączenie wywołań sieciowych lub uruchamianie niezależnych zadań równolegle. Deweloper jest odpowiedzialny za tworzenie logiki biznesowej i jej optymalizacje, co efektywnie obniża czytelność i zwiększa koszt utrzymania kodu. Dobrze by było, gdyby wydajność i optymalizacja były problememami zupełnie niezależnymi.
Jeśli mielibyśmy do dyspozycji struktury danych, które opisują naszą logikę biznesową w kontekście wysokopoziomowych konceptów, a nie instrukcji maszynowych, moglibyśmy łatwo wykonać wysokopoziomowe optymalizacje. Takie struktury danych są zazwyczaj nazywane Darmowymi strukturami (Free structures) i mogą być generowane dla elementów naszych algebraicznych interfejsów dostarczając na za darmo instancje pewnych typeklas. Przykładowo, instancje Free Applicative mogą być wygenerowane i użyte do połączenia lub deduplikacji kosztownych operacji sieciowych.
W tym rozdziale zobaczymy jak tworzyć takie darmowe struktury i jak ich używać.
7.5.1 Free
(Monad
)
Zasadniczo, monada opisuje sekwencyjny program, gdzie każdy krok zależy od poprzedniego. Ograniczeni więc jesteśmy do modyfikacji, które wiedzą jedynie to, co już uruchomiliśmy i jaki krok uruchomimy jako następny.
Przypomnijmy, Free
to struktura danych reprezentująca Monad
ę zdefiniowana jako trzy warianty:
-
Suspend
reprezentuje program, który nie został jeszcze zinterpretowany -
Return
to.pure
-
Gosub
to.bind
Instancja Free[S, A]
może być wygenerowana za darmo dla dowolnej algebry S
. Aby zobaczyć to wprost,
rozważmy naszą algebrę Machines
Zdefiniujmy Free
dla algebry Machines
poprzez stworzenie ADT
odpowiadającego jej elementom. Każdy typ danych ma te same parametry wejściowe, jest sparametryzowany
typem zwracanym i ma taką samą nazwę:
Takie ADT jest Abstrakcyjnym drzewem składniowym (AST, Abstract Syntax Tree), ponieważ każdy element reprezentuje obliczenia w naszym programie.
Następnie zdefiniujmy .liftF
, implementację Machines
dla kontekstu Free[Ast, ?]
. Każda metoda
deleguje implementację do Free.liftF
tworząc Suspend
:
Kiedy skonstruowaliśmy program sparametryzowany z użyciem Free
, to aby go uruchomić, musimy przekazać
interpreter (transformację naturalną Ast ~> M
) do metody .foldMap
. Jeśli mielibyśmy interpreter,
który mapuje operacje do IO
, moglibyśmy stworzyć program IO[Unit]
z dostępnego AST
Dla kompletności zaimplementujmy interpreter, który deleguje operacje do ich bezpośredniej implementacji. Taki
interpreter może być użyteczny, jeśli reszta aplikacji również używa Free
jako kontekstu, a my akurat mamy implementację
algebry bazującą na IO
pod ręką.
Ale nasza logika biznesowa potrzebuje więcej niż tylko algebry Machines
. Oprócz niej
potrzebna jest też algebra Drones
, zdefiniowana jako:
Chcielibyśmy, aby nasze AST było kombinacją AST pochodzących z obu tych algebr. W Rozdziale
6 poznaliśmy Coproduct
, dysjunkcję wyższego rodzaju:
Możemy więc użyć kontekstu Free[Coproduct[Machines.Ast, Drone.Ast, ?], ?]
.
Moglibyśmy tworzyć instancję koproduktu ręcznie, ale utonęlibyśmy w morzu boilerplate’u, a później musielibyśmy robić to raz jeszcze, jeśli chcielibyśmy dodać trzecią algebrę.
Z pomocą przychodzi typeklasa scalaz.Inject
.
Niejawna derywacja wygeneruje instancję Inject
, kiedy będziemy ich potrzebować, pozwalając nam
przepisać metodę liftF
tak, aby działała dla dowolnej kombinacji AST:
W tym wypadku Ast :<: F
mówi nam, że Ast
jest częścią pełnego zbioru instrukcji F
.
Łącząc ze sobą wszystkie elementy, załóżmy, że mamy program, który abstrahuje ponad konkretną Monad
ą
oraz gotowe implementacja algebr Machines
i Drone
, z użyciem których możemy stworzyć interpretery:
i połączyć je w większy zbiór instrukcji używając pomocniczych metod z obiektu towarzyszącego
NaturalTransformation
aby następnie użyć ich do wyprodukowania IO
Tym samym zatoczyliśmy koło! Mogliśmy przecież od razu użyć IO
jako naszego kontekstu i uniknąć
Free
. Po co więc zadaliśmy sobie cały ten trud? Poniżej znajdziemy kilka przykładów, gdy Free
staje się użyteczne.
7.5.1.1 Testowanie: mocki i stuby
Może to zabrzmieć obłudnie, jeśli po napisaniu całego tego boilerplate’u powiemy, że Free
może
służyć do zmniejszenia jego ilości. Istnieje jednak pewna granica, za którą Ast
zaczyna
mieć sens: gdy mamy dużo testów, które wymagają stubowania implementacji.
Jeśli .Ast
i .liftF
zostały zdefiniowane dla danej algebry, możemy tworzyć interpretery częściowe:
które posłużą nam do testowania naszego program
u.
Używając częściowych funkcji zamiast totalnych, narażamy się na błędy w czasie wykonania. Wiele zespołów godzi się na ten kompromis w swoich testach jednostkowych, bo popełniony błąd i tak zostanie wykryty, gdy testy te nie zakończą się sukcesem.
Moglibyśmy osiągnąć to samo zachowanie, implementując wszystkie metody z użyciem ???
i nadpisując
te, których aktualnie potrzebujemy.
7.5.1.2 Monitoring
Aplikacje serwerowe są często monitorowane przez agenty, które manipulują bajtkodem aplikacji, wstrzykując profilery i wydobywając różnego rodzaju informacje o działaniu naszego kodu i jego wydajności.
Gdy naszym kontekstem jest Free
, nie musimy uciekać się do manipulacji bajtkodem. Zamiast tego
możemy zaimplementować interpreter, który będzie monitorować wykonywane operacje i raportować je z użyciem efektów ubocznych.
Rozważmy użycie takiego “agenta” o typie Ast ~> Ast
, który zapisuje inwokacje metod:
Moglibyśmy też wychwytywać wiadomości, które nas szczególnie interesują i logować, gdy się pojawią.
Możemy dołączyć Monitor
do naszej produkcyjnej aplikacji opartej na Free
za pomocą
lub połączyć transformacje naturalne wywołując pojedyncze
7.5.1.3 Monkey patching
Jako inżynierowie przywykliśmy już do próśb o dodanie dziwacznych zmian do kluczowej logiki aplikacji. Moglibyśmy chcieć wyrazić takie przypadki brzegowe jako wyjątki od reguły i obsługiwać je niezależnie od reszty aplikacji.
Wyobraźmy sobie, że otrzymaliśmy poniższą notatkę od działu księgowości
PILNE: Bob używa węzła
#c0ffee
przy sprawozdaniu rocznym. NIE ZATRZYMUJCIE TEJ MASZYNY!1!
Nie ma możliwości, aby wytłumaczyć Bobowi, że nie powinien używać naszych maszyn dla jego super ważnych zadań, tak więc musimy zhakować naszą logikę biznesową i wypuścić zmianę na środowisko produkcyjne tak szybko, jak to możliwe.
Nasza łatka (monkey patch) może być przetłumaczona na strukturę Free
, pozwalając nam zwrócić
wcześniej przygotowany wynik (Free.pure
) zamiast wykonywania standardowych operacji. Implementacja
to specjalna transformacja naturalna:
I gotowe. Zerknąć czy działa, wypchnąć na produkcję i ustawić alarm na przyszły tydzień, żeby usunąć ten fragment i odebrać Bobowi dostęp do naszych serwerów.
W testach możemy użyć State
, aby zapisywać wszystkie węzły, które zatrzymaliśmy:
Zaletą Free
w tej sytuacji jest pewność, że obsłużyliśmy wszystkie użycia, nie musząc
szukać ich w logice biznesowej. Jeśli kontekstem naszej aplikacji jest IO
, to moglibyśmy oczywiście
zaimplementować tę samą funkcjonalność w Machines[IO]
, ale używając Free
, możemy taką
zamianę wyizolować i przetestować bez dotykania istniejącego kodu i bez wiązania się z IO
.
7.5.2 FreeAp
(Applicative
)
Mimo tego, że ten rozdział nazywa się Zaawansowane Monady, to kluczowe jest, że
nie powinniśmy używać monad, dopóki naprawdę naprawdę nie musimy. W tym podrozdziale
zobaczymy, czemu FreeAp
(free applicative) jest lepszy od monady Free
.
FreeAp
zdefiniowany jest jako struktura danych reprezentujące metody ap
i pure
z typeklasy Applicative
:
Metody .hoist
i .foldMap
odpowiadają metodom .mapSuspension
i .foldMap
z Free
.
Możemy też wygenerować Free[S, A]
bezpośrednio z naszego FreeAp[S, A]
używając .monadic
,
co jest szczególnie przydatne, gdy chcemy włączyć małe programy oparte o FreeAp
do całego systemu
opartego o Free
.
Podobnie jak z Free
, musimy stworzyć FreeAp
dla naszego AST. Więcej boilerplate’u…
7.5.2.1 Grupowanie wywołań sieciowych
Rozpoczęliśmy ten rozdział wzniosłymi obietnicami dotyczącymi wydajności. Czas ich dotrzymać.
Aby zrozumieć, dlaczego powinniśmy zredukować ilość wywołań sieciowych, spójrzmy na ludzką wersję liczb latencji autorstwa Philipa Starka, bazującą na danych oryginalnie przygotowanych przez Petera Norviga.
Komputer | Ludzka Skala Czasowa | Ludzka Analogia |
---|---|---|
Odwołanie do pamięci L1 | 0,5 sek. | Uderzenie serca |
Mispredykcja gałęzi | 5 sek. | Ziewnięcie |
Odwołanie do pamięci L2 | 7 sek. | Długie ziewnięcie |
Zablokowanie/odblokowanie mutexa | 25 sek. | Przygotowanie herbaty |
Odwołanie do pamięci głównej | 100 sek. | Umycie zębów |
Skompresowanie 1 KB przez Zippy | 50 min. | Pipeline CI kompilatora Scali |
Przesłanie 2 KB przez sieć 1Gbps | 5,5 godz. | Pociąg z Londynu do Edynburga |
Losowy odczyt z dysku SSD | 1,7 dn. | Weekend |
Sekwencyjny odczyt 1 MB z pamięci | 2,9 dn. | Długi weekend |
Podróż po jednym datacenter | 5,8 dn. | Długie wakacje w USA |
Sekwencyjny odczyt 1 MB z dysku SSD | 11,6 dn. | Krótkie wakacje w Europie |
Przesunięcie głowicy dyskowej | 16,5 tyg. | Semestr akademicki |
Sekwencyjny odczyt 1 MB z dysku | 7,8 mies. | Pełnopłatny urlop macierzyński w Norwegii |
Wysłanie pakietu CA->Holandia->CA | 4,8 r. | Kadencja rządu |
Mimo że zarówno Free
, jak i FreeAp
niosą ze sobą narzut spowodowany alokacją pamięci (100 sekund na ludzkiej skali),
to za każdym razem, gdy uda nam się połączyć dwa żądania sieciowe w jedno zyskujemy prawie 5 lat.
Kiedy jesteśmy w kontekście Applicative
, możemy bezpiecznie optymalizować naszą aplikację bez zaburzania oczekiwań
co do oryginalnego programu i bez komplikowania logiki biznesowej.
Przypomnijmy, że nasza główna logika biznesowa wymaga, na szczęście, jedynie instancji Applicative
Zacznijmy od stworzenia boilerplate’u lift
dla nowej algebry Batch
oraz instancji DynAgentsModule
używając FreeAp
jako kontekstu
W Rozdziale 6 poznaliśmy typ danych Const
, który pozwala nam analizować wykonanie programu. Nie powinno
więc dziwić, że FreeAp.analyze
jest zaimplementowane z jego właśnie użyciem:
Używamy transformacji naturalnej i .analyze
, aby zebrać wszystkie węzły, które powinny zostać wystartowane
Następnym krokiem jest rozszerzenie zbioru instrukcji z Orig
do Extended
, tak by zawierał
Batch.Ast
, oraz napisanie programu z użyciem FreeAp
, który wystartuje wszystkie zebrane wcześniej węzły
pojedynczym żądaniem.
Musimy również pozbyć się wszystkich wywołań Machines.Start
, co możemy osiągnąć używając jeszcze jednej transformacji naturalnej
Mamy teraz dwa programy, które musimy połączyć. Przypomnijmy sobie operator *>
z Apply
i złóżmy to wszystko razem jako jedną metodę
I tyle! Teraz wystarczy użyć .optimise
razem z act
w głównej pętli naszego programu.
7.5.3 Coyoneda
(Functor
)
To “darmowa” (free) struktura danych zawdzięczająca swoją nazwę matematykowi Nobuo Yoneda. Pozwala nam ona wygenerować “za darmo” instancję
typeklasy Functor
dla dowolnej algebry S[_]
, tak długo, jak mamy w planie przetransformować ją do algebry, która taką instancję posiada.
Również w wersji kontrawariantnej:
API jest nieco prostsze niż Free
i FreeAp
, udostępniając transformację poprzez .trans
i możliwość pozbycia się struktury poprzez metodę .run
(która przyjmuje faktyczną implementację
Functor
a lub ContravariantFunctor
a).
Coyo i cocoyo są przydatne, gdy chcemy wywołać .map
lub .contramap
na typie, który
takich metod nie posiada, ale wiemy, że w końcu i tak przekonwertujemy go do innego typu,
pozbawionego tych ograniczeń, a na razie nie chcemy się z nim wiązać. Przykładowo możemy
stworzyć Coyoneda[ISet, ?]
, pamiętając, że ISet
nie posiada instancji typeklasy Functor
, aby
wywołać metody jej wymagające, a później przekonwertować taki obiekt do typu IList
.
Aby użyć coyo lub cocoyo do optymalizacji naszego programu, musimy dostarczyć oczekiwany boilerplate dla każdej algebry, której będziemy używać:
Optymalizacją, którą możemy zastosować to fuzja wywołań .map
(map fusion), co pozwala nam przepisać
na
unikając pośrednich reprezentacji. Jeśli, na przykład, xs
to List
a z tysiącem elementów,
to oszczędzamy dwa tysiące alokacji, wywołując .map
tylko raz.
Jednak dużo prościej jest po prostu ręcznie zmienić oryginalną funkcję albo poczekać
na scalaz-plugin
, który automatycznie wykona tego typu
optymalizacje.
7.5.4 Efekty rozszerzalne
Programy to tylko dane, a struktury typu free wyrażają to wprost, pozwalając nam na ich rearanżację i optymalizację.
Free
jest bardziej niezwykła, niż nam się wydaje: pozwala sekwencyjnie łączyć arbitralne algebry i typeklasy.
Na przykład, istnieje Free
dla MonadState
. Ast
i .liftF
są nieco bardziej skomplikowane niż zwykle, bo
muszą uwzględniać parametr typu S
oraz dziedziczenie po typie Monad
:
Daje nam to okazje do użycia zoptymalizowanego interpretera, który może na przykład przechowywać stan w atomowym
polu, zamiast budować zagnieżdżone trampoliny StateT
.
Możemy stworzyć Ast
i .liftF
dla niemal dowolnej algebry albo typeklasy. Jedyne ograniczenie jest takie, że
F[_]
nie może pojawiać się jako parametr w żadnej instrukcji, a więc algebra musi móc mieć instancję typeklasy Functor
.
Ograniczenie to wyklucza z użycia m.in. MonadError
i Monoid
.
Wraz z rozrostem AST programu obniża się wydajność interpretera, ponieważ instrukcja dopasowania (match)
ma złożoność liniową względem obsługiwanych wariantów. Alternatywą do scalaz.Coproduct
jest biblioteka iotaz,
która używa zoptymalizowanej struktury danych, aby wykonać tę operację ze złożonością O(1)
(używając liczb całkowitych
przydzielanych wariantom na etapie kompilacji).
Z przyczyn historycznych free AST dla algebry lub typeklasy jest nazywane Initial Encoding,
a bezpośrednia implementacja (np. z użyciem IO
) to Finally Tagless. Mimo że omówiliśmy
interesujące koncepty używając Free
, to ogólnie przyjęta jest opinia, że finally tagless
jest podejściem lepszym. Jednak aby użyć tego podejścia, musimy mieć do dyspozycji wydajny
typ efektu, który dostarczy nam wszystkie typeklasy, które omówiliśmy. Ponadto nadal
potrzebujemy sposobu na uruchomienie naszego kodu opartego o Applicative
w sposób równoległy.
I dokładnie tym się teraz zajmiemy.
7.6 Parallel
Są dwie operacje wywołujące efekty, które prawie zawsze chcemy wykonywać równolegle:
-
.map
na kolekcji efektów, zwracając pojedynczy efekt. Można to osiągnąć za pomocą metody.traverse
, która deleguje do.apply2
danego efektu. - uruchomienie danej liczby efektów z użyciem operatora krzyku
|@|
i połączenie ich wyników, również delegując do.apply2
.
W praktyce jednak żadna z tych operacji nie jest domyślnie wykonywana równolegle. Przyczyna
jest prosta: jeśli nasze F[_]
implementuje typeklasę Monad
, wtedy muszą być zachowane
jej prawa co do apply2
, tj.
Innymi słowy, Monad
om wprost zabrania się wykonywania efektów równolegle.
Jednak, gdy mamy F[_]
, które nie jest monadyczne, wtedy może ono implementować
.apply2
w sposób równoległy. Możemy użyć mechanizmu @@
(tagów), aby stworzyć instancję typeklasy Applicative
dla
F[_] @@ Parallel
, która dorobiła się własnego aliasu Applicative.Par
Programy monadyczne mogą więc żądać niejawnego Par
jako dodatku do ich Monad
Metody Traverse
ze Scalaz wspierają równoległe wykonanie:
Jeśli w zakresie dostępna jest niejawna instancja Applictive.Par[IO]
, to możemy wybrać między
sekwencyjną i równoległa trawersacją:
Podobnie możemy wywołać .parApply
lub .parTupled
po tym, jak użyliśmy operatorów krzyku
Warto zaznaczyć, że kiedy mamy do czynienia z programami opartymi o Applicative
, takimi jak
możemy używać F[A] @@ Parallel
jako kontekstu, sprawiając, że .traverse
i |@|
wykonywane będą równolegle.
Konwersja między zwykłą i równoległą wersją F[_]
musi być obsłużona ręcznie, co może być uciążliwe, więc
często łatwiej jest po prostu wymagać obu form Applicative
.
7.6.1 Łamiąc Prawo
Możemy przyjąć bardziej śmiałe podejście do zrównoleglania: zrezygnować z prawa, które każe
.apply2
wykonywać operacje sekwencyjnie dla Monad
. Jest to podejście mocno kontrowersyjne,
ale działa zadziwiająco dobrze dla większości rzeczywistych aplikacji. Musimy zacząć od prześledzenia kodu
w poszukiwaniu fragmentów, które mogłyby bazować na tym prawie, aby nie wprowadzić błędów. Później jest już tylko łatwiej.
Opakowujemy IO
i dostarczamy naszą własną implementację Monad
y, która uruchamia .apply2
równolegle, poprzez oddelegowanie
do instancji dla typu @@ Parallel
Od teraz możemy używać MyIO
zamiast IO
jako kontekstu naszej aplikacji i korzystać z automatycznego zrównoleglania.
Dla kompletności: naiwna i niewydajna implementacja Applicative.Par
dla naszego IO
mogłaby używać Future
:
a z powodu błędu w kompilatorze Scali, który powoduje, że wszystkie instancje dla typów @@
traktowane są jako instancje osierocone, musimy explicite zaimportować tę niejawną wartość:
W ostatniej sekcji tego rozdziału zobaczymy, jak naprawdę zaimplementowany jest typ IO
w Scalaz.
7.7 IO
IO
ze Scalaz jest najszybszą strukturą danych pozwalającą na programowanie asynchroniczne, jaką możemy znaleźć w ekosystemie Scali,
nawet do 50 razy szybsza niż Future
.23 Zaprojektowana została jako monada do obsługi efektów ogólnego przeznaczenia.
IO
ma dwa parametry typu, a tym samym posiada instancję typeklasy Bifunctor
, która pozwala
na zdefiniowanie błędów jako ADT specyficznego dla danej aplikacji. Niestety jesteśmy na JVMie oraz musimy
współpracować z istniejącymi bibliotekami, dlatego też zdefiniowany został pomocny alias, który używa
wyjątków jako typu błędów:
7.7.1 Tworzenie
Istnieje wiele sposobów na stworzenie IO
z zarówno zachłannych i leniwych, jak i bezpiecznych i niebezpiecznych bloków kodu:
wraz z wygodnymi konstruktorami dla Task
a:
Najczęściej używanym wariantem, szczególnie przy pracy z istniejącym kodem, jest Task.apply
oraz Task.fromFuture
:
Nie możemy przekazywać instancji Future
bezpośrednio, ponieważ obliczenia wewnątrz niej rozpoczynają się zachłannie w momencie
stworzenia, dlatego też tworzenie to musi odbyć się wewnątrz bezpiecznego bloku.
Zauważmy, że ExecutionContext
nie jest przekazywany niejawnie, odwrotnie niż przyjęta konwencja.
W Scalaz parametry niejawne zarezerwowane są dla typeklas, a ExecutionContext
to parametr konfiguracyjny,
a więc powinien być przekazany wprost.
7.7.2 Uruchamianie
Interpreter IO
nazywa się RTS
, od runtime system, ale jego implementacja wybiega poza zakres tej książki.
W zamian omówimy funkcjonalności, które nam udostępnia.
IO
to po prostu struktura danych, którą interpretujemy na końcu świata poprzez rozszerzenie SafeApp
i zaimplementowanie
metody .run
Jeśli integrujemy się z istniejącym systemem i nie mamy kontroli nad punktem wejścia do naszej aplikacji, możemy
rozszerzyć RTS
, zyskując dostęp do niebezpiecznych metod, których możemy użyć, aby wyewaluować IO
na wejściu do czysto funkcyjnej części kodu.
7.7.3 Funkcjonalności
IO
dostarcza instancje dla Bifunctor
, MonadError[E, ?]
, BindRec
, Plus
, MonadPlus
(jeśli E
formuje Monoid
) oraz
Applicative[IO.Par[E, ?]]
.
W dodatku do funkcjonalności pochodzących z typeklas dostajemy implementację kilku specyficznych metod:
Instancja IO
może być zakończona (terminated), co oznacza, że praca, która
była zaplanowana, zostanie odrzucona (nie jest to ani sukces, ani błąd). Narzędzia
do pracy z tym stanem to:
7.7.4 Fiber
IO
może przywołać włókna (fibers), czyli lekką abstrakcję ponad wątkami udostępnianymi przez JVM.
Możemy rozgałęziać (.fork
) IO
i nadzorować (.supervise
) niezakończone włókna, aby upewnić się, że
zostaną zakończone, kiedy IO
się wykona:
Kiedy mamy do dyspozycji włókno możemy je włączyć z powrotem do IO
(.join
) lub przerwać
wykonywaną pracę (.interrupt
).
Możemy używać włókien do osiągnięcia optymistycznej kontroli współbieżności. Wyobraźmy sobie, że mamy dane (data
),
które chcemy przeanalizować oraz zwalidować. Możemy optymistycznie rozpocząć analizę i przerwać ją, gdy walidacja,
która uruchomiona jest równolegle, zakończy się niepowodzeniem.
Innym zastosowaniem włókien jest sytuacja, gdy chcemy rozpocząć akcję i o niej zapomnieć, jak na przykład niskopriorytetowe logowanie zdarzeń przez sieć.
7.7.5 Promise
Obietnica (Promise
) reprezentuje asynchroniczną zmienną, która może być ustawiona dokładnie raz (poprzez .complete
lub .error
).
Następnie dowolna liczba odbiorców może odczytać taką zmienną, używając .get
.
Promise
nie jest zazwyczaj używany w kodzie aplikacyjnym. Jest to raczej element zaprojektowany do budowania
wyżejpoziomowych frameworków do obsługi współbieżności.
7.7.6 IORef
IORef
to odpowiednik mutowalnej zmiennej w świecie IO
.
Możemy odczytać jej wartość oraz dostajemy do dyspozycji szereg operacji do manipulacji nią.
IORef
to kolejny typ danych, który może nam dostarczyć wysokowydajną instancję MonadState
.
Spróbujmy stworzyć nowy typ wyspecjalizowany do obsługi Task
ów
Możemy wykorzystać tak zoptymalizowaną implementację MonadState
w SafeApp
, gdy nasz .program
wymaga instancji tej typeklasy:
Bardziej realistyczna aplikacja wymagałaby zdecydowanie większej liczby różnych typeklas i algebr jako wejścia.
7.7.6.1 MonadIO
Typ MonadIO
, który wcześniej widzieliśmy, został uproszczony poprzez ukrycie parametru E
. Prawdziwa jego
forma wygląda tak:
wraz z drobną różnicą w boilerplacie kompaniującym naszej algebrze, uwzględniają dodatkowe E
:
7.8 Podsumowanie
- Typ
Future
jest zepsuty, nie idź tą drogą. - Możemy zapanować nad bezpieczeństwem stosu za pomocą
Trampoline
. - Biblioteka Transformatorów Monad (MTL) abstrahuje nad popularnymi efektami za pomocą typeklas.
- Transformatory Monad dostarczają domyślnych implementacji dla typeklas z MTL.
- Struktura
Free
pozwala nam analizować, optymalizować i łatwo testować nasze programy. -
IO
umożliwia implementowanie algebr jako efektów wykonywanych na świecie zewnętrznym. -
IO
może wykonywać efekty równolegle i jest wysoce wydajnym fundamentem dla dowolnej aplikacji.
8. Derywacja typeklas
Typeklasy pozwalają na polimorfizm w naszym kodzie, ale aby z nich skorzystać potrzebujemy ich instancji dla naszych obiektów domenowych.
Derywacja typeklas to proces tworzenia nowych instancji na podstawie instancji już istniejących, i to właśnie nim zajmiemy się w tym rozdziale.
Istnieją cztery główne podejścia do tego zagadnienia:
- Ręcznie tworzone instancje dla każdego obiektu domenowego. Wykorzystanie tego podejścia na co dzień jest niewykonalne, gdyż skończylibyśmy z setkami linii czystego boilerplate’u dla każdej case klasy. Jego użyteczność ogranicza się więc jedynie do zastosowań edukacyjnych i doraźnych optymalizacji wydajnościowych.
- Abstrahowanie ponad typeklasami z użyciem isntiejących typeklas ze Scalaz. To podejście wykorzystywane jest przez
bibliotekę
scalaz-deriving
, która potrafi wygenerować zautomatyzowane testy oraz derywacje dla produktów i koproduktów. - Makra, z tym że napisanie makra dla każdej typeklasy wymaga doświadczonego dewelopera. Na szczęście Magnolia Jona Prettiego pozwala zastąpić ręcznie pisane makra prostym API, centralizując skomplikowane interakcje z kompilatorem.
- Pisanie generycznych programów, używając biblioteki Shapeless. Różne elementy opatrzone słowem
kluczowym
implicit
tworzą osobny język wewnątrz Scali, który może być wykorzystany do implementowania skomplikowanej logiki na poziomie typów.
W tym rozdziale przeanalizujemy typeklasy o rosnącym stopniu skomplikowania i ich derywacje. Zaczniemy od scalaz-deriving
jako
mechanizmu najbardziej pryncypialnego, powtarzając niektóre lekcje z Rozdziału 5 “Typeklasy ze Scalaz”. Następnie przejdziemy do
Magnolii, która jest najprostsza do użycia, a skończymy na Shapelessie, który jest najpotężniejszy i pozwala na derywacje
o skomplikowanej logice.
8.1 Uruchamianie przykładów
W tym rozdziale pokażemy jak zdefiniować derywacje pięciu konkretnych typeklas. Każda z nich pokazuje funkcjonalność, która może być uogólniona:
8.2 scalaz-deriving
Biblioteka scalaz-deriving
jest rozszerzeniem Scalaz i może być dodana do build.sbt
za pomocą
dostarczając nam nowe typeklasy, pokazane poniżej w relacji do kluczowych typeklas ze Scalaz:
Zanim przejdziemy dalej, szybka powtórka z kluczowych typeklas w Scalaz:
8.2.1 Nie powtarzaj się
Najprostszym sposobem za derywacje typeklasy jest użycie typeklas już istniejących.
Typeklasa Equal
posiada instancję Contravariant[Equal]
, która z kolei dostarcza metodę .contramap
:
Jako użytkownicy Equal
możemy wykorzystać .contramap
dla naszych jednoparametrowych typów danych.
Pamiętajmy, że instancje typeklas powinny trafić do obiektu towarzyszącego, aby znaleźć się w niejawnym zakresie:
Jednak nie wszystkie typeklasy mogą posiadać instancję typu Contravariant
. W szczególności typeklasy, których
parametry występują w pozycji kowariantnej, mogą w zamian dostarczać Functor
:
Możemy teraz wyderywować Default[Foo]
za pomocą
Jeśli parametry typeklasy występują zarówno w pozycji kowariantnej, jak i kontrawariantej, jak ma to miejsce
w przypadku Semigroup
, to typeklasa taka może dostarczać InvariantFunctor
i do jej derywacji użyjemy .xmap
:
W ogólności łatwiej jest użyć .xmap
zamiast .map
lub .contramap
:
8.2.2 MonadError
Zazwyczaj rzeczy, które wyciągają informacje z polimorficznej wartości, posiadają instancję Contravariant
,
a te, które zapisują do takiej wartości, definiują Functor
. Jednak bardzo często taki odczyt
może się nie powieść. Przykładowo, to, że mamy domyślny String
nie oznacza wcale, że możemy
bez problemu wyderywować z niego domyślny String Refined NonEmpty
.
skutkuje błędem kompilacji
Kompilator przypomniał nam to, czego dowiedzieliśmy się w Rozdziale 4.1, czyli że refineV
zwraca Either
.
Jako autorzy typeklasy Default
możemy postarać się troch bardziej niż Functor
i dostarczyć MonadError[Default, String]
:
Mamy teraz dostęp do .emap
i możemy wyderywować instancję dla naszego rafinowanego typu:
W praktyce możemy dostarczyć regułę dla wszystkich rafinowanych typów:
gdzie typ Validate
pochodzi z biblioteki refined
, a jego instancja wymagana jest przez refineV
.
Podobnie możemy użyć .emap
, aby wyderywować dekoder dla typu Int
z instancji dla typu Long
, chroniąc
się przed brakiem totalności .toInt
z biblioteki standardowej.
Jako autorzy Default
powinniśmy rozważyć API, w którym nie może dojść do błędu,
np. z użyciem takiej sygnatury
W takiej sytuacji nie bylibyśmy w stanie zdefiniować MonadError
, wymuszając, aby
instancje zawsze produkowały poprawną wartość. Poskutkowałoby to większą ilością boilerplate’u,
ale również zwiększonym bezpieczeństwem w czasie kompilacji. Pozostaniemy jednak przy typie
zwracanym String \/ A
, gdyż może służyć za bardziej ogólny przykład.
8.2.3 .fromIso
Wszystkie typeklasy ze Scalaz mają w swoim obiekcie towarzyszącym metodę o sygnaturze podobnej do:
Oznacza to, że jeśli mamy typ F
oraz sposób na jego konwersję do typu G
, który posiada instancję danej typeklasy,
to wystarczy zawołać .fromIso
, aby otrzymać instancję dla F
.
Dla przykładu, mając typ danych Bar
, możemy bez problemu zdefiniować izomorfizm do (String, Int)
a następnie wyderywować Equal[Bar]
, ponieważ istnieją już instancje Equal
dla tupli dowolnego kształtu
Mechanizm .fromIso
może też pomóc nam, jako autorom typeklas. Rozważmy Default
, której rdzeniem
jest sygnatura Unit => F[A]
. Tym samym metoda default
jest izomorficzna w stosunku do Kleisli[F. Unit, A]
,
czyli transformatora ReaderT
.
A skoro Kleisli
posiada MonadError
(jeśli tylko posiada go F
), to możemy wyderywować
MonadError[Default, String]
poprzez stworzenie izomorfizmu między Default
i Kleisli
:
Tym samym zyskaliśmy .map
, .xmap
i .emap
, których wcześniej używaliśmy, w praktyce za darmo.
8.2.4 Divisible
i Applicative
Aby wyderywować Equal
dla naszej dwuparametrowej case klasy użyliśmy instancji dostarczanej przez Scalaz
dla tupli. Ale skąd wzięła się ta instancja?
Bardziej specyficzną typeklasą niż Contravariant
jest Divisible
, a Equal
posiada jej instancję:
Bazując na divide2
, Dvisible
jest w stanie zbudować derywacje aż do divide22
, które następnie możemy zawołać
bezpośrednio dla naszych typów danych:
Odpowiednikiem dla parametrów typu w pozycji kowariantnej jest Applicative
:
Należy być jednak ostrożnym, aby nie zaburzyć praw rządzących Divisble
i Applicative
.
Szczególnie łatwo jest naruszyć prawo kompozycji, które mówi, że oba poniższe
wywołania muszą wyprodukować ten sam wynik
divide2(divide2(a1, a2)(dupe), a3)(dupe)
divide2(a1, divide2(a2, a3)(dupe))(dupe)
dla dowolnego dupe: A => (A, A)
. Dla Applicative
sprawa wygląda podobnie.
Rozważmy JsEncoder
i propozycję jej instancji Divisible
:
Z jednej strony prawa kompozycji, dla wejścia typu String
, otrzymujemy
a z drugiej
Moglibyśmy eksperymentować z różnymi wariacjami divide
, ale nigdy nie zaspokoilibyśmy
praw dla wszystkich możliwych wejść.
Dlatego też nie możemy dostarczyć Divisible[JsEncoder]
, gdyż złamalibyśmy matematyczne prawa rządzące tą typeklasą,
tym samym zaburzając wszystkie założenia, na bazie których użytkownicy Divisible
budują swój kod.
Aby pomóc z testowaniem tych praw, typeklasy ze Scalaz zawierają ich skodyfikowaną wersję. Możemy napisać zautomatyzowany test, przypominający nam, że złamaliśmy daną regułę:
Z drugiej strony, test podobnej typeklasy JsDecoder
pokazuje, że prawa Applicative
są przez nią zachowane
dla danych testowych
Jesteśmy teraz w stanie zaufać, przynajmniej do pewnego stopnia, że nasza wyderywowana instancja MonadError
przestrzega zasad.
Jednak udowodnienie, że taki test przechodzi dla konkretnego zbioru danych, nie udowadnia, że prawa są zachowane. Musimy jeszcze przeanalizować implementację i przekonać siebie samych, że prawa są raczej zachowane, a ponadto powinniśmy spróbować wskazać przypadki, w których mogłoby się to okazać nieprawdą.
Jednym ze sposobów generowania różnorodnych danych testowych jest użycie biblioteki scalacheck.
Dostarcza ona typeklasę Arbitrary
, która integruje się z większością frameworków testowych, pozwalając powtarzać
testy na bazie losowo wygenerowanych danych.
Biblioteka jsonFormat
dostarcza Arbitrary[JsValue]
(każdy powinien dostarczać Arbitrary
dla swoich ADT!) pozwalając nam
na skorzystanie z forAll
:
Taki test daje nam jeszcze większą pewność, że nasza typeklasa spełnia wszystkie prawa kompozycji
dla Applicative
. Sprawdzając wszystkie prawa dla Divisble
i MonadError
dostajemy też
dużo smoke testów zupełnie za darmo.
8.2.5 Decidable
i Alt
Tam, gdzie Divisble
i Applicative
pozwalają nam na derywacje typeklas dla produktów (w oparciu o tuple),
Decidable
i Alt
umożliwiają ją dla koproduktów (opartych o zagnieżdżone dysjunkcje):
Te cztery typeklasy mają symetryczne sygnatury:
Typeklasa | Metoda | Argumenty | Sygnatura | Typ zwracany |
---|---|---|---|---|
Applicative |
apply2 |
F[A1], F[A2] |
(A1, A2) => Z |
F[Z] |
Alt |
altly2 |
F[A1], F[A2] |
(A1 \/ A2) => Z |
F[Z] |
Divisible |
divide2 |
F[A1], F[A2] |
Z => (A1, A2) |
F[Z] |
Decidable |
choose2 |
F[A1], F[A2] |
Z => (A1 \/ A2) |
F[Z] |
wspierając odpowiednio kowariantne produkty, kowariantne koprodukty, kontrawariantne produkty i kontrawariantne koprodukty.
Możemy stworzyć instancję Decidable[Equal]
, która pozwoli na derywację Equal
dla dowolnego ADT!
Dla przykładowego ADT
gdzie produkty (Vader
i JarJar
) mają swoje instancje Equal
możemy wyderywować instancję dla całego ADT:
Typeklasy, która mają Applicative
, kwalifikują się również do Alt
. Jeśli chcemy użyć triku z Kleisli.iso
,
musimy rozszerzyć IsomorphismMonadError
i domiksować Alt
. Rozszerzmy więc naszą instancję MonadError[Default, String]
:
Pozwala nam to tym samym wyderywować Default[Darath]
Wróćmy do typeklas z scalaz-deriving
, gdzie inwariantnymi odpowiednikami Alt
i Decidable
są:
wspierając typeklasy z InvariantFunctor
em, jak np. Monoid
czy Semigroup
.
8.2.6 Arbitralna arność24 i @deriving
InvariantApplicative
i InvariantAlt
niosą ze sobą dwa problemy:
- wspierają jedynie produkty o 4 polach i koprodukty o 4 pozycjach.
- wprowadzają dużo boilerplate’u w obiektach towarzyszących.
W tym rozdziale rozwiążemy oba te problemy z użyciem dodatkowych typeklas ze scalaz-deriving
W praktyce cztery główne typeklasy Applicative
, Divisble
, Alt
i Decidable
zostały rozszerzone do
arbitralnej arności, używając biblioteki iotaz, stąd też sufiks z
.
Biblioteka ta definiuje trzy główne typy:
-
TList
, który opisuje ciąg typów dowolnej długości -
Prod[A <: TList]
dla produktów -
Cop[A <: TList]
dla koproduktów
Oto przykładowe reprezentacje oparte o TList
dla ADT Darath
z poprzedniego podrozdziału:
które mogą być zinstancjonizowane
Aby móc użyć API ze scalaz-deriving
potrzebujemy Isomorphism
pomiędzy naszym ADT i generyczną
reprezentacją z iotaz
. Generuje to sporo boilerplate’u, do które zaraz wrócimy
Teraz możemy już bez żadnych problemów zawołać API Deriving
dla Equal
, korzystając z tego,
że scalaz-deriving
dostarcza zoptymalizowaną instancję Deriving[Equal]
Aby móc zrobić to samo dla naszej typeklasy Default
, musimy zdefiniować dodatkową instancję Deriving[Default]
.
Na szczęście sprowadza się to jedynie do opakowania naszej instancji Alt
:
i wywołania z obiektów towarzyszących
Tym samym rozwiązaliśmy problem dowolnej liczby parametrów, ale wprowadziliśmy jeszcze więcej boilerplate’u.
Puenta jest taka, że anotacja @deriving
, pochodząca z deriving-plugin
, wygeneruje cały ten boilerplate za nas!
Wystarczy zaaplikować ją w korzeniu naszego ADT:
scalaz-deriving
zawiera również instancje dla typeklas Order
, Semigroup
i Monoid
.
Instancje dla Show
i Arbitrary
dostępne są po zainstalowaniu rozszerzeń scalaz-deriving-magnolia
oraz
scalaz-deriving-scalacheck
.
Nie ma za co!
8.2.7 Przykłady
Zakończymy naszą naukę scalaz-deriving
z w pełni działającymi implementacjami wszystkich
przykładowych typeklas. Jednak zanim do tego dojdziemy, musimy poznać jeszcze jeden typ danych:
/~\
a.k.a. wąż na drodze, który posłuży nam do przechowywania dwóch struktur wyższego rodzaju
sparametryzowanych tym samym typem:
Zazwyczaj będziemy używać tej struktury w kontekście Id /~\ TC
, gdzie TC
to nasz typeklasa,
wyrażając fakt, że mamy wartość oraz instancję typeklasy dla tej wartości.
W dodatku wszystkie metody w API Deriving
przyjmują niejawny parametr typu A PairedWith F[A]
, pozwalający
bibliotece iotaz
na wykonywanie .zip
, .traverse
i innych operacji na wartościach typu Prod
i Cop
.
Jako że nie używamy tych parametrów bezpośrednio, to możemy je na razie zignorować.
8.2.7.1 Equal
Podobnie jak przy Default
, moglibyśmy zdefiniować Decidable
o stałej arności i owinąć
w ExtendedInvariantAlt
(rozwiązanie najprostsze), ale zamiast tego zdefiniujemy Decidablez
dla
korzyści wydajnościowych. Dokonamy dwóch dodatkowych optymalizacji:
- wykonanie porównania referencji
.eq
przed zaaplikowaniemEqual.equal
, pozwalając na szybsze określenie równości dla tych samych wartości. - szybkie wyjście z
Foldable.all
, kiedy którekolwiek z porównań zwrócifalse
, tzn. jeśli pierwsze pola się nie zgadzają, to nie będziemy nawet wymagać instancjiEqual
dla pozostałych wartości
8.2.7.2 Default
Niestety, API iotaz
dla .traverse
(i analogicznej .coptraverse
) wymaga od nas zdefiniowania transformacji naturalnej,
co nawet w obecności kind-pojector
a jest niezbyt wygodne.
8.2.7.3 Semigroup
Nie da się zdefiniować Semigroup
y dla wszystkich koproduktów, ale da się to zrobić dla wszystkich
produktów. W tym celu użyjemy InvariantApplicative
o dowolnej arności, czyli InvariantApplicativez
:
8.2.7.4 JsEncoder
i JsDecoder
scalaz-deriving
nie pozwala na dostęp do nazw pól, więc nie jest możliwe zdefiniowanie enkoderów
i dekoderów z jej użyciem.
8.3 Magnolia
Magnolia jest biblioteką opierającą się o makra, która dostarcza schludne i dość proste API pomagające w derywowaniu typeklas. Instaluje się ją za pomocą wpisu w build.sbt
Jako autorzy typeklasy musimy zaimplementować poniższe pola
API Magnolii to:
wraz z pomocnikami
Typeklasa Monadic
widoczna w constructMonadic
jest automatycznie generowana za pomocą import mercator._
, jeśli nasz typ danych
posiada metody .map
i .flatMap
.
Nie ma sensu używać Magnolii do derywacji typeklas, które mogą być opisane poprzez Divisible
/Decidable
/Applicative
/Alt
,
gdyż te abstrakcje dają nam dodatkową strukturę i testy za darmo. Jednak Magnolia oferuje nam funkcjonalności, których
nie ma scalaz-deriving
: dostęp do nazw pól, nazw typów, anotacji i domyślnych wartości.
8.3.1 Przykład: JSON
Musimy zadać sobie kilka pytań odnośnie tego, jak chcemy serializować dane:
- Czy powinniśmy załączać pola o wartości
null
? - Czy dekodując powinniśmy traktować brakujące pola i pola o wartości
null
inaczej? - Jak zakodować nazwę koproduktu?
- Jak poradzić sobie z koproduktami, które nie są
JsObject
em?
Oto nasze odpowiedzi:
- nie załączamy pól o wartości
JsNull
- brakujące pola traktujemy tak samo, jak wartości
null
- użyjemy specjalnego pola
type
, aby rozróżnić koprodukty na podstawie ich nazw - wartości prymitywne umieścimy w specjalnym polu
xvalue
Pozwolimy też użytkownikowi dołączyć anotacje do koproduktów i pól produktów, aby dostosować te zachowania:
Na przykład:
Zacznijmy od enkodera, który obsługuje jedynie ustawienia domyślne:
Widzimy, w jak prosty sposób możemy posługiwać się nazwami pól oraz instancjami typeklas dla każdego z nich.
Teraz dodajmy wsparcie dla anotacji, aby obsłużyć preferencje użytkownika. Aby uniknąć sprawdzania anotacji za każdym kodowaniem, zapiszemy je w lokalnej tablicy. Mimo że dostęp do komórek tablicy nie jest totalny, to w praktyce mamy gwarancję, że indeksy zawsze będą się zgadzać. Wydajność zazwyczaj cierpi przy okazji walki specjalizacji z generalizacją.
Przy dekoderze skorzystamy z metody .constructMonadic
, która ma sygnaturę podobną do .traverse
Raz jeszcze dodajemy wsparcie dla preferencji użytkownika i domyślnych wartości pól wraz z paroma optymalizacjami:
Teraz musimy wywołać JsMagnoliaEncoder.gen
oraz JsMagnoliaDecoder.gen
z obiektów towarzyszących
naszych typów danych. Na przykład dla API Map Google:
Na szczęście anotacja @deriving
wspiera derywację z użyciem Magnolii! Jeśli autor typeklasy
dostarcza w swoim jarze plik deriving.conf
zawierający poniższe linie:
to deriving-macro
wywoła odpowiednie metody:
8.3.2 Derywacja w pełni automatyczna
Generowanie niejawnych instancji w obiektach towarzyszących jest techniką znaną
jako generacja semi-automatyczna (semi-auto), w porównaniu do generacji w pełni automatycznej (full-auto),
która ma miejsce, gdy metoda .gen
jest również niejawna.
W takim wypadku użytkownicy mogą zaimportować takie metody i zyskać magiczną derywację w punkcie użycia:
Może to brzmieć kusząco, gdyż wymaga najmniejszej ilości kodu, ale niesie ze sobą dwie pułapki:
- makro wykonywane jest przy każdym użyciu, a więc na przykład za każdym razem, gdy wywołamy
.toJson
. Spowalnia to kompilacje oraz prowadzi do stworzenia większej ilości obiektów, co może spowodować spadek wydajności w czasie wykonania. - wyderywowane mogą zostać rzeczy zupełnie niespodziewane.
Punkt pierwszy jest raczej oczywisty, ale nieprzewidziane derywacje manifestują się w formie subtelnych błędów. Pomyślmy co się wydarzy dla
jeśli zapomnimy dostarczyć niejawną instancję dla Option
. Moglibyśmy oczekiwać, że
Foo(Some("hello"))
przyjmie formę
ale zamiast tego otrzymamy
ponieważ Magnolia wyderywowała dla na nas enkoder dla typu Option
.
Chcielibyśmy, żeby kompilator informował nas o brakujących elementach, tak więc stanowczo odradzamy używanie derywacji w pełni automatycznej.
8.4 Shapeless
Biblioteka Shapeless jest niezmiennie najbardziej skomplikowaną biblioteką w ekosystemie Scali. Taka reputacja wynika z faktu, że implementuje ona niemal osoby język do programowania generycznego na poziomie typów i robi to za pomocą maksymalnego wykorzystania implicitów.
Nie jest to pomysł zupełnie obcy. W Scalaz staramy się ograniczyć używanie wartości niejawnych jedynie
do typeklas, ale czasem prosimy kompilator o dostarczenie różnego rodzaju dowodów co do wskazanych typów.
Przykładem mogą być relacje Liskov i Leibniz (<~<
i ===
) lub zdolność do wstrzyknięcia algebry free do koproduktu
algebr (Inject
).
Instalacja polega na dodaniu poniższego fragmentu do build.sbt
:
Rdzeniem biblioteki są typy danych HList
i Coproduct
które są generycznymi reprezentacjami odpowiednio produktów i koproduktów. sealed trait HNil
służy tylko naszej wygodzie, abyśmy nie musieli pisać HNil.type
.
Shapeless ma również kopię typu IsoSet
pod nazwą Generic
, która pozwala nam przechodzić
między ADT i jego generyczną reprezentacją:
Wiele z tych typów zawiera abstrakcyjny typ Repr
, a w swoich obiektach towarzyszących definiują
alias typu .Aux
, który pozwala go zobaczyć. Umożliwia to nam żądanie Generic[Foo]
bez podawania
generycznej reprezentacji, która będzie wygenerowana przez makro.
Istnieje również komplementarny typ LabelledGeneric
, który zawiera nazwy pól.
Zwróć uwagę, że wartość typu LabelledGeneric
jest taka sama jak Generic
. Nazwy pól istnieją
jedynie na poziomie typów i są wymazywane w czasie wykonania.
Nie musimy używać typu KeyTag
bezpośrednio, a zamiast tego możemy użyć aliasu:
Jeśli chcemy uzyskać dostęp do nazwy pola z FieldType[K, A]
, musimy poprosić o
niejawny dowód typu Witness.Aux[K]
, który dostarczy nam wartość K
w czasie wykonania.
Na pierwszy rzut oka to jest wszystko, co musimy wiedzieć, aby móc wyderywować instancję typeklasy z użyciem Shapelessa. Jednak z czasem wszystko się komplikuje, co zobaczymy przechodząc przez przykłady o rosnącym poziomie skomplikowania.
8.4.1 Przykład: Equal
Standardowym podejściem jest rozszerzenie typeklasy i umieszczenie jej derywacji w obiekcie towarzyszącym. W taki sposób znajduje się ona w niejawnym zakresie przeszukiwanym przez kompilator bez dopisywania dodatkowych importów.
Punktem wejścia do derywacji jest metoda .gen
, wymagająca dwóch parametrów typu: A
, dla którego derywujemy instancję
oraz R
, czyli jego generycznej reprezentacji. Następnie żądamy wartości Generic.Aux[A, R]
, która łączy A
z R
, oraz
instancji DerivedEqual
dla R
. Zacznijmy od takiej właśnie sygnatury i prostej implementacji:
Tym samym zredukowaliśmy problem do dostarczenia DerivedEqual[R]
, a więc instancji dla generycznej
reprezentacji A
. Najpierw rozważmy produkty, czyli sytuację gdzie R <: HList
. Chcielibyśmy
zaimplementować taką sygnaturę:
Gdy zostanie zaimplementowana, to kompilator będzie w stanie rekursywnie ją wywoływać, aż dotrze do końca listy.
W tym momencie będzie potrzebował instancji dla pustego HNil
:
A o to implementacja:
Dla koproduktów chcielibyśmy zaimplementować podobne sygnatury:
.cnil
nie zostanie nigdy zawołany dla typeklas takich jak Equal
, gdzie parametr typu występuje jedynie w
pozycji kontrawariantnej, ale kompilator tego nie wie, więc musimy dostarczyć jakąkolwiek jego implementację:
W przypadku koproduktów możemy porównywać jedynie instancje tego samego typu, czyli wtedy, gdy
mamy do czynienia z dwukrotnym Inl
lub Inr
.
Warto zaznaczyć, że nasze metody pokrywają się z konceptami conquer
(hnil
),
divide2
(hlist
) i alt2
(coproduct
)! Jedak nic nie zyskamy definiując Decidable
,
gdyż musielibyśmy zaczynać od zera pisząc testy dla tego kodu.
Przetestujmy więc go prostym ADT:
Dostarczamy odpowiednie instancje:
ale kod się nie kompiluje!
Witaj w Shapelessowym świecie błędów kompilacji!
Problem, który wcale nie jest jasno widoczny w komunikacie błędu, wynika z faktu, że kompilator
nie umie domyślić się, czym jest R
. Musimy więc dostarczyć mu ten parametr wprost:
lub użyć makra Generic
, które dostarczy kompilatorowi generyczną reprezentację
Powodem, dla którego to rozwiązanie działa, jest sygnatura metody .gen
która rozwijana jest do
Kompilator Scali rozwiązuje ograniczenia od lewej do prawej, a więc znajduje wiele różnych rozwiązań dla
DerivedEqual
, zanim ograniczy je z użyciem Generic.Aux[A, R]
. Innym rozwiązaniem jest nieużywanie ograniczeń kontekstu.
Tym samym nie potrzebujemy już implicit val generic
ani parametrów typu przekazywanych wprost i możemy
podłączyć @deriving
, dodając wpis w deriving.conf
(zakładając, że chcemy nadpisać implementację ze scalaz-deriving
).
i napisać
Ale zastąpienie wersji ze scalaz-deriving
oznacza, że zwiększy się czas kompilacji naszego projektu.
Wynika to z faktu, że kompilator musi rozwiązać N
niejawnych przeszukiwań dla każdego produktu o N
polach
lub koproduktu o N
wariantach, podczas gdy scalaz-deriving
i Magnolia
nie mają tego problemu.
Zauważ, że używając scalaz-deriving
lub Magnolii
wystarczy umieścić anotację @deriving
na korzeniu ADT,
a w przypadku Shapelessa musi się ona pojawić osobno przy każdym z wariantów.
Jednak taka implementacja nadal jest błędna: nie działa dla rekurencyjnych typów danych i informuje nas o tym w czasie wykonania. Przykład:
Dzieje się tak, ponieważ Equal[Tree]
zależy od Equal[Branch]
, które z kolei zależy od Equal[Tree]
.
Rekurencja i BUM! Rozwiązaniem jest załadować je leniwie, a nie zachłannie.
Zarówno scalaz-deriving
, jak i Magnolia obsługują ten przypadek automatycznie, lecz tutaj
leży to w gestii programisty.
Typy Cached
, Strict
i Lazy
, oparte o makra, zmieniają zachowanie kompilatora, pozwalając nam na osiągnięcie
potrzebnej leniwości. Generalną zasadą jest użycie Cached[Strict[_]]
w punkcie wejścia i Lazy[_]
w okolicach instancji dla typu H
.
W tym momencie najlepiej będzie, jeśli zupełnie zapomnimy o ograniczeniach kontekstu i typach SAM:
Przy okazji dokonaliśmy optymalizacji z użyciem quick
ze scalaz-deriving
.
Możemy teraz wywołać
bez wyjątków rzucanych w czasie wykonania.
8.4.2 Przykład: Default
Implementując derywację typeklasy z parametrem typu w pozycji kowariantnej, nie natkniemy się na szczęście
na żadne nowe pułapki. Tworzymy instancje dla HList
i Coproduct
, pamiętając, że musimy obsłużyć też
przypadek CNil
, gdyż odpowiada on sytuacji, w której żaden z wariantów nie był w stanie dostarczyć wartości.
Analogicznie do relacji pomiędzy Equal
i Decidable
, możemy zauważyć relację z Alt
w
.point
(hnil
), apply2
(.hcons
), i .altly2
(.ccons
).
Niewiele nowego moglibyśmy nauczyć się z Semigroup
, więc przejdziemy od razu do enkoderów i dekoderów.
8.4.3 Przykład: JsEncoder
Aby odtworzyć nasz enkoder oparty o Magnolię, musimy mieć dostęp do:
- nazw pól i klas
- anotacji odzwierciedlających preferencje użytkownika
- domyślnych wartości pól
Zacznijmy od wersji obsługującej jedynie nasze domyślne zachowania.
Aby uzyskać nazwy pól, użyjemy LabelledGeneric
zamiast Generic
, definiując typ
pierwszego elementu posłużymy się FieldType[K, H]
zamiast prostym H
, a wartość
typu Witness.Aux[K]
dostarczy nam nazwę pola w czasie wykonania.
Wszystkie nasze metody będą zwracać JsObject
, więc zamiast uogólniać te wartości do JsValue
możemy stworzyć wyspecjalizowaną typeklasę DerivedJsEncoder
o sygnaturze innej niż
ta w JsEncoder
.
Shapeless obiera ścieżkę wykonania na etapie kompilacji, bazując na obecności anotacji, co może prowadzić do bardziej wydajnego kodu kosztem jego powtarzania. Oznacza to, że liczba anotacji i ich podtypów, z którymi mamy do czynienia musi być rozsądnie mała, gdyż inaczej okaże się, że jesteśmy zmuszenie pisać 10x więcej kodu. Zamieńmy więc nasze trzy anotacje na jedną z trzema parametrami:
Każde użycie takiej anotacji wymaga od użytkownika podania wszystkich 3 parametrów, gdyż wartości domyślne nie są dostępne w konstruktorach anotacji. Możemy napisać własne destruktory, aby nie musieć modyfikować kodu, który napisaliśmy dla Magnolii.
Możemy zażądać Annotation[json, A]
dla case class
lub sealed trait
ów, aby zyskać dostęp do anotacji,
ale musimy stworzyć warianty hcons
i ccons
obsługujące oba przypadki, gdyż wartość taka nie zostanie wygenerowana,
gdy anotacja nie jest obecna. Tym samym musimy wprowadzić wartości niejawne o niższym priorytecie i za ich
pomocą obsłużyć brak anotacji.
Możemy też zażądać Annotations.Aux[json, A, J]
, aby otrzymać HList
ę anotacji json
dla typu A
.
Jednak tak samo musimy powtórzyć hcons
i ccons
dla przypadku, gdy anotacja nie jest obecna.
Aby wesprzeć tą jedną anotację, musimy napisać czterokrotnie więcej kodu!
Zacznijmy od przepisania derywacji JsEncoder
tak, aby obsługiwała kod bez jakichkolwiek anotacji.
Teraz kod, który użyje @json
, się nie skompiluje, co jest dobrym zabezpieczeniem.
Musimy dodać A
i J
do DerivedJsEncoder
i przeciągnąć je poprzez metodę .toJsObject
. Nasze
.hcons
i ccons
produkują instancje DerivedJsEncoder
z anotacja None.type
. Przeniesiemy je
do zakresu o niższym priorytecie, tak, abyśmy mogli obsłużyć Annotation[json, A]
w pierwszej kolejności.
Zauważ, że instancje dla J
pojawiają się przed R
. Jest to ważne, gdyż kompilator musi najpierw
określić typ J
, zanim będzie w stanie ustalić R
.
Teraz możemy dodać sygnatury dla sześciu nowych metod, które pokryją wszystkie możliwe warianty tego, gdzie może pojawić się anotacja. Zauważ, że wspieramy tylko jedną anotację w każdej pozycji, każda następna będzie po cichu zignorowana.
Powoli kończą nam się nazwy, więc arbitralnie dodamy Annotated
, gdy anotacja jest na typie A
i
Custom
, gdy jest ona umieszczona na polu:
Tak naprawdę wcale nie potrzebujemy .hconsAnnotated
ani .hconsAnnotatedCustom
, ponieważ
anotacja umieszczona na case klasie nie ma żadnego wpływu na logikę, ma ona jedynie sens w przypadku koproduktów w .cconsAnnotated
.
Tym samym możemy usunąć dwie metody.
.cconsAnnotated
i cconsAnnotatedCustom
mogą być zdefiniowane jako
oraz
Użycie metod .head
i .get
może być niepokojące, ale zauważmy, że anns
jest typu Some[json] :: J
, a więc obie
są totalne i zupełnie bezpieczne.
.hconsCustom
i cconsCustom
zapiszemy jako
oraz
Oczywiście, jest tutaj dużo boilerplate’u, ale jeśli przyjrzymy się bliżej, to zobaczymy, że każda z metod jest zaimplementowana tak wydajnie, jak to możliwe biorąc pod uwagę dostępne informacje, a ścieżki wykonania wybierane są w czasie kompilacji.
Ci z obsesją na punkcie wydajności mogą przerefaktorować ten kod, tak, aby wszystkie anotacje były
dostępne zawczasu, a nie wstrzykiwane przez metodę .toJsFields
. Dla absolutnej wydajności moglibyśmy
potraktować każdą customizację jako osobną anotację, ale tym samym po raz kolejny kilkukrotnie
zwiększylibyśmy ilość kodu, wydłużając jeszcze bardziej czas kompilacji dla naszych użytkowników.
Tego typu optymalizacje są poza zakresem tej książki, ale jak najbardziej są one nie tylko możliwe,
ale i implementowane w praktyce. Zdolność do przeniesienia pracy z czasu wykonania do czasu kompilacji
jest jedną z najbardziej pociągających rzeczy w programowaniu generycznym.
Dodatkowy haczyk, o którym musimy pamiętać, to to, że LabelledGeneric
nie jest kompatybilny ze
scalaz.@@
, ale na szczęście istnieje obejście tego problemu.
Powiedzmy, że chcielibyśmy w wydajny sposób zignorować tagi. Musimy więc dodać dodatkowe reguły derywacji:
W tym momencie powinniśmy móc wyderywować instancję JsDecoder
dla typów podobnych do naszego TradeTemplate
z Rozdziału 5
Jednak zamiast tego otrzymujemy błąd kompilacji:
Komunikat błędu jest, tak jak zawsze, niezbyt pomocny. Obejściem jest wprowadzenie dowodu dla H @@ Z
o niższym priorytecie, a następnie
ręczne wywołanie kodu, który kompilator powinien był znaleźć na samym początku:
Na szczęście musimy obsłużyć jedynie produkty, bo tylko one mogą być otagowane.
8.4.4 JsDecoder
Dekodowanie wygląda dokładnie tak, jak mogliśmy się tego spodziewać po poprzednich przykładach.
Możemy tworzyć instancje FieldType[K, H]
za pomocą funkcji pomocniczej field[K](h: H)
.
Chcąc obsłużyć jedynie zachowania domyślne, musimy napisać:
Dodanie obsługi preferencji użytkownika przebiega podobnie jak w przypadku DerivedJsEncoder
i jest równie mechaniczne,
więc zostawimy to jako ćwiczenie dla czytelnika.
Brakuje już tylko jednej rzeczy: obsługi domyślnych wartości w case klasach. Możemy zażądać odpowiedniej wartości, ale większym problemem jest to, że nie będziemy mogli używać tej samej logiki do derywacji instancji dla produktów i koproduktów, gdyż dla tych drugich taka wartość nigdy nie zostanie wygenerowana.
Rozwiązanie jest dość drastyczne: musimy podzielić nasz DerivedJsDecoder
na DerivedCoproductJsDecoder
i DerivedProductJsDecoder
. Skupimy się na tym drugim i jednocześnie użyjemy typu Map
dla szybszego dostępu do pól:
Możemy zażądać dowodu domyślnych wartości używając Default.Aux[A, D]
a następnie zduplikować wszystkie
metody tak, aby obsłużyć sytuacje, gdy są i nie są one zdefiniowane. Jednak Shapeless jest litościwy (choć raz)
i dostarcza Default.AsOptions.Aux[A, D]
, pozwalając nam obsłużyć je w czasie wykonania.
Musimy przenieść metody .hcons
i .hnil
do obiektu towarzyszącego nowej typeklasy, która
potrafi obsłużyć domyślne wartości
Niestety nie możemy już używać @deriving
dla produktów i koproduktów, gdyż w pliku deriving.conf
może być tylko jeden wpis
dla danej typeklasy.
No i nie zapomnijmy o wsparciu dla @@
.
8.4.5 Skomplikowane Derywacje
Shapeless pozwala na dużo więcej rodzajów derywacji, niż jest możliwe do osiągnięcia z użyciem
scalaz-deriving
lub Magnolii. Jako przykład takiego nieosiągalnego enkodera/dekodera może posłużyć
model XML z xmlformat
.
Znając naturę XMLa, sensownym wydaje się mieć osobne pary dekoderów i enkoderów dla XChildren
i XString
.
Z użyciem Shapelessa moglibyśmy je wyderywować implementując specjalną obsługę pól zależnie od typeklas jakie są dla nich dostępne oraz
od tego, czy jest to Option
, czy nie. Dodatkowo przy dekodowaniu moglibyśmy mieć różne strategie dekodowania
ciał elementów, które mogą być wieloczęściowe, zależnie czy nasz typ ma instancję Semigroup
, Monoid
czy też nie ma żadnej z nich.
8.4.6 Przykład: UrlQueryWriter
Nasza aplikacja drone-dynamic-agents
mogłaby skorzystać z derywacji dla typu UrlQueryWriter
, gdzie
każde pole kodowane jest za pomocą odpowiedniej instancji UrlEncodedWriter
, a koprodukty nie są wspierane:
Pytanie “Czy te 30 linii kodu jest faktycznie lepsze niż 8 linii dla dwóch ręcznie stworzonych instancji, których potrzebujemy?” jest całkowicie rozsądne, ale trzeba odpowiedzieć sobie na nie od nowa w każdym konkretnym przypadku.
Dla kompletności, derywacja UrlEncodedWriter
może być też zaimplementowana za pomocą Magnolii:
8.4.7 Ciemna Strona Derywacji
“Strzeż się w pełni automatycznej derywacji. Złość, strach, agresja; ciemną stroną derywacji są one. Łatwo wypływają, szybko dołączają do ciebie w walce. Gdy raz wstąpisz na ciemną ścieżkę, na zawsze zawładną twoim kompilatorem, a ciebie pochłoną.”
― starożytny mistrz Shapelessa
W dodatku do wszystkich ostrzeżeń względem w pełni automatycznej derywacji, wspomnianych dla Magnolii, Shapeless jest zdecydowanie gorszy. Taka derywacja z jego użyciem jest nie tylko najczęstszym źródłem powolnej kompilacji, ale również źródłem bolesnych błędów w kwestii koherencji typeklas.
Derywacja w pełni automatyczna ma miejsce wtedy, gdy def gen
jest opatrzona modyfikatorem implicit
, sprawiając,
że wywołanie przejdzie rekurencyjnie przez całe ADT. Z racji tego, jak działają niejawne zakresy,
zaimportowany implicit def
ma wyższy priorytet niż konkretne instancje w obiektach towarzyszących, co
powoduje dekoherencję typeklas. Rozważmy taką właśnie sytuację:
Spodziewalibyśmy się, że zakodowana forma Bar("hello")
będzie wyglądać tak:
ponieważ użyliśmy xderiving
dla Foo
. Ale zamiast tego możemy otrzymać
Sytuacja jest jeszcze gorsza, gdy taka niejawna derywacja jest dodana do obiektu towarzyszącego typeklasy, gdyż oznacza to, że jej instancje będą zawsze derywowane w punkcie użycia a użytkownik nie może wpłynąć na ten mechanizm.
Zasadniczo pisząc programy generyczne, należy przyjąć, że wartości niejawne mogą być ignorowane przez kompilator zależnie od zakresu, co oznacza, że tracimy bezpieczeństwo w czasie kompilacji, które było naszą główną motywacją do pisania tego typu programów!
Wszystko jest dużo prostsze po jasnej stronie, gdzie modyfikator implicit
jest używany jedynie dla
koherentnych, globalnie unikatowych instancji typeklas. Strach przed boilerplatem jest drogą na
ciemną stronę. Strach prowadzi do złości. Złość prowadzi do nienawiści. Nienawiść prowadzi do cierpienia.
8.5 Wydajność
Nie ma złotego środka w kwestii derywacji typeklas. Aspektem do rozważenia jest wydajność, zarówno w czasie kompilacji, jak i wykonania.
8.5.0.1 Czasy kompilacji
Kiedy mówimy o czasach kompilacji, to Shapeless zdecydowanie wychodzi przed szereg. Nie jest
niczym nadzwyczajnym, aby mały projekt przeszedł od jednej sekundy do jednej minuty czasu kompilacji.
Aby prześledzić przyczyny takich zachowań, możemy użyć pluginu scalac-profiling
który wyprodukuje raport mogący posłużyć do wygenerowania flame grafu.
Dla typowej derywacji opartej o Shapelessa dostajemy “skoczny” wykres:
Niemal cały czas jest poświęcony na niejawne rozstrzyganie. Wprawdzie obejmuje to też kompilacje
instancji tworzonych z użyciem scalaz-deriving
i Magnolii, ale to Shapeless dominuje.
A wszystko to, gdy wszystko działa. Jeśli zdarzy się problem z Shapelssową derywacją, to kompilator może się zaciąć w nieskończonej pętli i musi być zabity.
8.5.0.2 Wydajność w czasie wykonania
Kiedy mówimy o wydajności wykonania, odpowiedzią zawsze jest to zależy.
Zakładając, że logika derywacji została optymalnie zaimplementowana, to jedynym sposobem, aby dowiedzieć, która jest szybsza, jest eksperymentowanie.
Biblioteka jsonformat
używa Java Microbenchmark Harness (JMH)
na modelach pochodzących z API GeoJSONa, Google Maps i Twittera, które zostały skontrybuowane przez Andrity’ego Plokhotnyuka.
Dla każdego modelu mamy trzy testy:
- kodowanie
ADT
doJsValue
- pomyślne dekodowanie tego samego
JsValue
z powrotem do ADT - dekodowanie
JsValue
z błędnymi danymi
zaaplikowane do trzech implementacji:
- opartych o Magnolię
- opartych o Shapeless
- napisanych ręcznie
z odpowiadającymi optymalizacjami w każdej z nich. Wyniki prezentowane są w operacjach na sekundę (im więcej, tym lepiej) i pochodzą z wykonania na mocnej maszynie i jednym wątku:
Widzimy, że przodują implementacje ręczne, za którymi podąża Magnolia. Shapeless osiągnął od 30% do 70% wydajności ręcznie tworzonych instancji. Teraz spójrzmy na dekodowanie:
Tutaj walka o drugie miejsce między Magnolią i Shapelessem jest bardziej zażarta. W końcu test dekodujący niepoprawne dane:
Gdy już wydawało się, że widzimy wzór, okazało się, że zarówno Magnolia, jak i Shapeless wygrały w przypadku danych dla API GeoJSONa, ale ręczne instancje osiągnęły lepszy wyniki dla Google Maps i Twittera.
Chcielibyśmy dołączyć do porównania scalaz-deriving
, więc porównamy odpowiadające sobie implementacje
Equal
, przetestowane na dwóch wartościach, które mają tę samą zawartość (True
) i dwóch o różnej
zawartości (False
).
Tak jak można było się spodziewać, instancje stworzone ręcznie są daleko z przodu. Z kolei
Shapeless prawie zawsze wygrywa wśród automatycznych derywacji. Biblioteka scalaz-deriving
miała dobry start
z GeoJSON
, ale nie poradziła sobie w testach Google Maps i Twittera. Wyniki False
są niemal identyczne.
Wydajność wykonania scalaz-deriving
, Magnolii i Shapelessa jest zazwyczaj wystarczająca.
Bądźmy realistami, rzadko kiedy piszemy aplikacje, które muszą kodować do JSONa więcej niż 130 000
wartości na sekundę, na jednym wątku, na JVMie. Jeśli takie jest wymaganie, to może warto spojrzeć w stronę C i C++?
Mało prawdopodobne jest, żeby wyderywowane instancje stały się wąskim gardłem aplikacji. Jeśli jednak tak się stanie, to zawsze istnieje opcja ręcznych instancji, które są bardziej potężne, ale też tym samym bardziej niebezpieczne. Łatwo jest przy ich tworzeniu popełnić błędy, literówki, a nawet przypadkowo obniżyć wydajność.
Podsumowując, derywacje i antyczne makra nie są żadną konkurencją dla dobrych, własnoręcznie napisanych instancji!
8.6 Podsumowanie
Gdy musimy zdecydować jakiej technologii użyć do derywacji typeklas, pomocny może okazać się poniższy wykaz funkcjonalności:
Funkcjonalność | Scalaz | Magnolia | Shapeless | Manual |
---|---|---|---|---|
@deriving |
tak | tak | tak | |
Prawa | tak | |||
Szybka kompilacja | tak | tak | tak | |
Nazwy pól | tak | tak | ||
Anotacje | tak | częściowo | ||
Domyślne wartości | tak | z haczykami | ||
Skomplikowanie | boleśnie | |||
Wydajność | potrzymaj mi piwo |
Polecamy używanie scalaz-deriving
, gdy to tylko możliwe, Magnolii do enkoderów i dekoderów oraz gdy
wydajność jest bardzo istotna, a Shapelessa tam, gdzie derywacje są bardzo skomplikowane, a czasy kompilacji
nie mają dużego znaczenia.
Instancje pisane ręcznie pozostają zawsze pod ręką na specjalne okazje oraz gdy trzeba osiągnąć maksymalną wydajność. Jeśli je piszesz, to staraj się unikać literówek i błędów używając narzędzi do generacji kodu.
9. Złożenie aplikacji
Na zakończenie zaaplikujemy zdobytą wiedzę do naszej przykładowej aplikacji i zaimplementujemy klienta oraz serwer HTTP za pomocą czysto funkcyjnej biblioteki http4s.
Kod źródłowy drone-dynamic-agents
jest dostępny wraz z kodem źródłowym tej książki na https://github.com/fommil/fpmortals
w folderze examples
. Obecność przy komputerze w trakcie lektury tego rozdziału nie jest co prawda obowiązkowa,
ale wielu czytelników może zechcieć śledzić kod źródłowy wraz z tekstem tego rozdziału.
Niektóre części aplikacji pozostały niezaimplementowane i pozostawione jako ćwiczenie dla czytelnika. Więcej
instrukcji znajdziesz w README
.
9.1 Przegląd
Nasza główna aplikacja wymaga jedynie implementacji algebry DynAgents
.
Mamy już taką implementację w postaci DynAgentsModule
, ale wymaga ona implementacji algebr Drone
i Machines
,
które z kolei wymagają algebr JsonClient
, LocalClock
i Oauth2, itd., itd., itd.
Przydatnym bywa spojrzenie z lotu ptaka na wszystkie algebry, moduły i interpretery naszej aplikacji. Oto jak ułożony jest nasz kod źródłowy:
Sygnatury wszystkich algebr możemy podsumować jako
Zauważ, że niektóre sygnatury z poprzednich rozdziałów zostały przerefaktorowane tak, aby używały typów danych ze Scalaz, skoro już wiemy, że są lepsze od tych z biblioteki standardowej.
Definiowane typy danych to:
Oraz typeklasy:
Derywujemy przydatne typeklasy używając scalaz-deriving
oraz Magnolii. ConfigReader
pochodzi
z biblioteki pureconfig
i służy do odczytywania konfiguracji z plików HOCON.
Przeanalizujmy też, bez zaglądania do implementacji, jak kształtuje się graf zależności w DynAgentsModule
.
Dwa moduły implementują OAuth2JsonClient
, jeden używa algebry Refresh
dla usług Google’a, a drugi niewygasającego BearerToken
dla Drone
‘a.
Do tej pory widzieliśmy wymagania względem F
mówiące, że musimy dostarczyć Applicative[F]
, Monad[F]
oraz MonadState[F, BearerToken]
. Wszystkie te wymagania spełnia StateT[Task, BearerToken, ?]
co pozwala
nam uczynić ten typ kontekstem naszej aplikacji.
Jednak niektóre algebry mają interpretery używające bezpośrednio typu Task
:
Przypomnijmy, że nasze algebry mogą dostarczać liftM
w swoich obiektach towarzyszących (patrz rozdział
7.4 na temat Biblioteki Transformatorów Monad), co pozwala nam wynieść LocalClock[Task]
do pożądanego
StateT[Task, BearerToken, ?]
czyniąc wszystko idealnie spójnym.
Niestety to nie koniec. Sprawy komplikują się na następnej warstwie, gdyż JsonClient
posiada interpreter używający
innego kontekstu
Zauważ, że konstruktor BlazeJsonClient
zwraca Task[JsonClient[F]]
, a nie JsonClient[F]
.
Dzieje się tak, ponieważ stworzenie tego klient powoduje efekt w postaci utworzenia mutowalnej puli połączeń
zarządzanej wewnętrznie przez http4s.
Nie możemy zapomnieć o dostarczeniu RefreshToken
dla GoogleMachinesModule
. Moglibyśmy zrzucić to zadanie
na użytkownika, ale jesteśmy mili i dostarczamy osobną aplikację, która używając algebr Auth
i Access
rozwiązuje
ten problem. Implementacje AuthModule
i AccessModule
niosą ze sobą kolejne wymagania, ale na szczęście żadnych
zmian co do kontekstu F[_]
.
Interpreter algebry UserInteraction
jest najbardziej skomplikowanym elementem naszego
kodu. Startuje on serwer HTTP, prosi użytkownika o otworzenie strony w przeglądarce,
odbiera wywołanie zwrotne w serwerze i zwraca wynik, jednocześnie zakańczając pracę serwera
w bezpieczny sposób.
Zamiast używać StateT
do zarządzania tym stanem użyliśmy typu Promise
(pochodzącego z ioeffect
).
Powinniśmy zawsze używać Promise
(lub IORef
) zamiast StateT
, gdy piszemy interpreter oparty o
IO
, gdyż pozwala nam to opanować abstrakcje. Gdybyśmy użyli StateT
, to nie tylko miałoby to wpływ
na całą aplikacje, ale również powodowałoby wyciek lokalnego stanu do głównej aplikacji, która musiałaby
przejąc odpowiedzialność za dostarczenie inicjalnej wartości. W tym wypadku nie moglibyśmy użyć StateT
również dlatego, że potrzebujemy możliwości “czekania”, którą daje nam jedynie Promise
.
9.2 Main
Najbrzydsza część FP pojawia się, gdy musimy sprawić, by wszystkie monady się zgadzały. Najczęściej ma to miejsce
w punkcie wejściowym naszej aplikacji, czyli klasie Main
.
Przypomnijmy, nasza główna pętla wyglądała tak:
Dobra wiadomość jest taka, że teraz ten kod będzie wyglądał tak:
gdzie F
przechowuje stan świata w MonadState[F, WoldView]
. Możemy zamknąć ten fragment
w metodzie .step
i powtarzać ją w nieskończoność wywołując .step[F].forever[Unit]
.
W tym momencie mamy do wyboru dwa podejścia i oba omówimy. Pierwszym i jednocześnie najprostszym
jest skonstruowanie stosu monad kompatybilnego ze wszystkimi algebrami, a każda z nich musi definiować liftM
, aby
wynieść ją do większego stosu.
Kod, który chcemy napisać dla trybu jednorazowego uwierzytelnienia to:
gdzie .readConfig
i .putStrLn
to wywołania funkcji z bibliotek. Możemy potraktować je jako interpretery
oparte o Task
dla algebr odczytujących konfigurację i wypisująca ciąg znaków.
Ten kod jednak się nie kompiluje z dwóch powodów. Po pierwsze, musimy zdecydować, jak będzie wyglądał nasz
stos monad. Konstruktor BlazeJsonClient
zwraca Task
, ale JsonClient
wymaga MonadError[..., JsonClient.Error]
,
co można rozwiązać za pomocą EitherT
. Możemy więc skonstruować nasz stos dla całej konstrukcji for
jako
Niestety, oznacza to, że musimy wywołać .liftM
dla wszystkiego, co zwraca Task
,
dodając dość dużo boilerplate’u. Niestety metoda liftM
nie przyjmuje typów o kształcie
H[_]
tylko H[_[_]. _]
, więc musimy stworzyć alias, który pomoże kompilatorowi:
Możemy teraz wywołać .liftM[HT]
kiedy dostajemy Task
Ale nasz kod nadal się nie kompiluje. Tym razem dlatego, że clock
jest typu LocalClock[Task]
a AccessModule
wymaga LocalClock[H]
.
Dodajmy więc potrzebny boilerplate .liftM
do obiektu towarzyszącego LocalClock
i wynieśmy całą algebrę.
Wreszcie wszystko się kompiluje!
Drugie podejście do złożenia aplikacji jest bardziej złożone, ale niezbędne, gdy pojawiają się konflikty w stosie monad, tak jak w naszej głównej pętli. Jeśli przeanalizujemy wymagania, zobaczymy, że potrzebujemy poniższych instancji:
-
MonadError[F, JsonClient.Error]
wJsonClient
-
MonadState[F, BearerToken]
wOAuth2JsonClient
-
MonadState[F, WorldView]
w głównej pętli
Niestety, dwa wymagania na MonadState
są ze sobą sprzeczne. Moglibyśmy
skonstruować typ danych, który przechowuje cały stan aplikacji, ale byłaby to
cieknąca abstrakcja. Zamiast tego zagnieździmy konstrukcję for
i dostarczymy stan tam, gdzie jest potrzebny.
Musimy teraz przemyśleć trzy warstwy, które nazwiemy F
, G
i H
.
Złe wieści: liftM
obsługuje tylko jedną warstwę na raz. Jeśli mamy Task[A]
, a chcemy
uzyskać F[A]
to musimy przejść przez wszystkie kroki i wywołać ta.liftM[HT].liftM[GT].liftM[FT]
.
Podobnie, gdy wynosimy algebry, musimy zawołać liftM
wielokrotnie. Aby uzyskać Sleep[F]
, musimy napisać
a żeby dostać LocalClock[G]
robimy dwa wyniesienia
Główna aplikacja wygląda więc tak:
gdzie zewnętrzna pętla używa Task
, środkowa G
a wewnętrzna F
.
Wywołania .run(start)
oraz .eval(bearer)
dostarczają inicjalny stan dla części bazujących na StateT
.
.run
z kolei pokazuje błędy zgromadzone w EitherT
.
Na koniec wołamy te dwie aplikacji z naszej instancji SafeApp
i uruchamiamy ją!
Hurra!
9.3 Blaze
Server i klienta HTTP zaimplementujemy z użyciem zewnętrznej biblioteki http4s
. Interpretery
dla odpowiednich algebr dostaną w związku z tym prefiks Blaze, gdyż tak też nazywa się
właściwy komponent tej biblioteki.
Dodajmy więc poniższe zależności:
9.3.1 BlazeJsonClient
Będziemy potrzebować też kilku dodatkowych importów.
Moduł Client
może być podsumowany jako:
gdzie Request
i Response
to typy danych:
składające się z
EntityBody
jest aliasem na typ Stream
z biblioteki fs2
. Możemy rozumieć go jako
leniwy strumień danych wykonujący efekty, bazujący na wyciąganiu danych (pull-based). Zaimplementowany jest jako monada Free
z
dodatkowym łapaniem wyjątków i obsługą przerwań. Stream
przyjmuje dwa parametry typu: typ efektów i typ zawartości. Dodatkowo posiada
wewnątrz wydajną reprezentację pozwalającą na łączenie danych (batching), więc przykładowo, używając Stream[F, Byte]
tak naprawdę mamy do czynienia
z opakowaną tablicą Array[Byte]
, która przybywa do nas za pośrednictwem sieci.
Musimy przekonwertować nasze reprezentacje nagłówków i URLi na wersje wymagane przez http4s:
Obie nasze metody .get
i .post
muszą przekonwertować instancję Response
pochodząca z http4s
na typ A
.
Możemy wydzielić tę logikę do pojedynczej funkcji .handler
through(fs2.text.utf8Decode)
pozwala przekonwertować Stream[Task, Byte]
na Stream[Task, String]
.
compile.foldMonoid
interpretuje strumień z użyciem naszego Task
a i łączy wyniki przy pomocy
Monoid[String]
, zwracając Task[String]
.
Następnie parsujemy string do JSONa, a JsDecoder[A]
dostarcza potrzebny rezultat.
Oto nasza implementacja .get
:
Trzeba przyznać, że jest to w 100% łączenie istniejących kawałków. Konwertujemy nasze typy wejściowe
do http4s.Request
, wołamy .fetch
na kliencie przekazując nasz handler
, w odpowiedzi dostajemy
Task[Error \/ A]
. Musimy jednak zwrócić F[A]
, więc używamy MonadIO.liftIO
do stworzenia
F[Error \/ ]
, na którym z kolei wywołujemy emap
, umieszczając błąd wewnątrz F
.
Niestety, próba skompilowania tego kodu zakończy się porażką, a błąd będzie wyglądał mniej więcej tak:
Coś na temat zaginionego kota?
Dzieje się tak, gdyż http4s
używa innej biblioteki wspomagającej FP niż Scalaz. Na szczęście scalaz-ioeffect
dostarcza
warstwę dodającą kompatybilność z tą biblioteką, a projekt shims definiuje
niezauważalne (zazwyczaj) niejawne konwersje. Tak więc możemy sprawić, że nasz kod zacznie się kompilować dodając zależności
i importy
Implementacja .post
jest podobna, ale musimy jeszcze dostarczyć instancję
Na szczęście typeklasa EntityEncoder
pozwala nam łatwo ją wyderywować z istniejącego
enkodera dla typu String
Jedyną różnicą między .get
i .post
jest sposób, w jaki konstruujemy http4s.Request
Ostatnim fragmentem układanki jest konstruktor, w którym wywołujemy Http1Client
przekazując obiekt konfiguracyjny
9.3.2 BlazeUserInteraction
Musimy jeszcze uruchomić serwer HTTP, co jest dużo łatwiejsze, niż może się wydawać. Po pierwsze, importy
Następnie utwórzmy dsl
dla naszego typu efektów i zaimportujmy zawartość:
Teraz możemy używać dsla http4s do obsługi żądań HTTP. Zamiast opisywać wszystko co jest możliwe, zaimplementujemy po prostu pojedynczy endpoint, co przypomina każdy inny dsl do opisu HTTP.
Każde dopasowanie musi zwrócić Task[Response[Task]]
. W naszym przypadku chcemy wziąć code
i
ukończyć obietnicę ptoken
:
ale zdefiniowanie logiki nie wystarczy, musimy jeszcze uruchomić nasz serwer, co też zrobimy
używając BlazeBuilder
.
Przypisanie do portu 0
sprawia, że system operacyjny użyje tymczasowego portu, który możemy
odczytać z pola server.address
.
Nasza implementacja .start
i .stop
jest więc bardzo prosta
Uśpienie wątku na 1.second
jest niezbędne, aby uniknąć wyłączenia serwera, zanim odpowiedź trafi z powrotem
do przeglądarki. Z wydajnością współbieżności IO
nie ma żartów!
W końcu, aby utworzyć BlazeUserInteraction
potrzebujemy jedynie dwóch niezainicjalizowanych obietnic
Mogliśmy użyć IO[Void, ?]
, ale skoro reszta naszej aplikacji używa Task
(czyli IO[Throwable, ?]
), wywołujemy
.widenError
, aby nie wprowadzać zbędnego boilerplate’u.
9.4 Podziękowania
I to tyle! Gratulujemy dotarcia do końca podróży.
Jeśli w trakcie jej trwania nauczyłeś się czegoś, to proszę, powiedz o tym swoim znajomym. Ta książka nie ma działu marketingu, więc jest to jedyny sposób, w jaki potencjalni czytelnicy mogą się o niej dowiedzieć.
Aby zaangażować się w rozwój Scalaz, wystarczy dołączyć do pokoju na gitterze. Stamtąd możesz zadawać pytania, pomagać innym (teraz jesteś ekspertem!) i wspierać tworzenie kolejnych wersji biblioteki.
Skrót Typeklas
Typeklasa | Metoda | Z | Mając | Do |
---|---|---|---|---|
InvariantFunctor |
xmap |
F[A] |
A => B, B => A |
F[B] |
Contravariant |
contramap |
F[A] |
B => A |
F[B] |
Functor |
map |
F[A] |
A => B |
F[B] |
Apply |
ap / <*>
|
F[A] |
F[A => B] |
F[B] |
apply2 |
F[A], F[B] |
(A, B) => C |
F[C] |
|
Alt |
altly2 |
F[A], F[B] |
(A \/ B) => C |
F[C] |
Divide |
divide2 |
F[A], F[B] |
C => (A, B) |
F[C] |
Decidable |
choose2 |
F[A], F[B] |
C => (A \/ B) |
F[C] |
Bind |
bind / >>=
|
F[A] |
A => F[B] |
F[B] |
join |
F[F[A]] |
F[A] |
||
Cobind |
cobind |
F[A] |
F[A] => B |
F[B] |
cojoin |
F[A] |
F[F[A]] |
||
Applicative |
point |
A |
F[A] |
|
Divisible |
conquer |
F[A] |
||
Comonad |
copoint |
F[A] |
A |
|
Semigroup |
append |
A, A |
A |
|
Plus |
plus / <+>
|
F[A], F[A] |
F[A] |
|
MonadPlus |
withFilter |
F[A] |
A => Boolean |
F[A] |
Align |
align |
F[A], F[B] |
F[A \&/ B] |
|
merge |
F[A], F[A] |
F[A] |
||
Zip |
zip |
F[A], F[B] |
F[(A, B)] |
|
Unzip |
unzip |
F[(A, B)] |
(F[A], F[B]) |
|
Cozip |
cozip |
F[A \/ B] |
F[A] \/ F[B] |
|
Foldable |
foldMap |
F[A] |
A => B |
B |
foldMapM |
F[A] |
A => G[B] |
G[B] |
|
Traverse |
traverse |
F[A] |
A => G[B] |
G[F[B]] |
sequence |
F[G[A]] |
G[F[A]] |
||
Equal |
equal / ===
|
A, A |
Boolean |
|
Show |
shows |
A |
String |
|
Bifunctor |
bimap |
F[A, B] |
A => C, B => D |
F[C, D] |
leftMap |
F[A, B] |
A => C |
F[C, B] |
|
rightMap |
F[A, B] |
B => C |
F[A, C] |
|
Bifoldable |
bifoldMap |
F[A, B] |
A => C, B => C |
C |
(z MonadPlus ) |
separate |
F[G[A, B]] |
(F[A], F[B]) |
|
Bitraverse |
bitraverse |
F[A, B] |
A => G[C], B => G[D] |
G[F[C, D]] |
bisequence |
F[G[A], G[B]] |
G[F[A, B]] |
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:
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ę:
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
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ą
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]
.
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 dlaIO
w Scalaz, implementowana na poziomie środowiska uruchomieniowego
z honorowym miejscem dla
Haskell, podobnie jak Scala, pozwala na definiowanie aliasów typów. Alias i jego rozwinięta
forma mogą być używane zamiennie. Z powodów historycznych String
zdefiniowany jest alias na listę
Char
ów:
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.
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.
Bardziej wydajną alternatywą dla pojedynczych definicji danych jest użycie słowa kluczowego
newtype
, które nie niesie ze sobą żadnego narzutu wydajnościowego:
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:
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
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:
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ą więc równoznaczne:
Funkcja infiksowa może być rozwinięta z lewej bądź prawej strony, często zmieniając tym samym swoją semantykę:
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
:
Podkreślenie służy do ignorowania nieistotnych parametrów. Nazwy funkcji mogą występować w pozycji infiksowej również w definicjach:
Funkcje anonimowe (lambdy) definiujemy z pomocą odwrotnego ukośnika, co przypomina grecką literę λ. Poniższe definicje są równoznaczne:
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:
Jej implementacja
jest rozwijana przez kompilator do
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):
if
/ then
/ else
to słowa kluczowe do definiowania wyrażeń warunkowych:
Alternatywnie możemy użyć ograniczeń wariantów (case guards)
Dopasowania dla dowolnego wyrażenia definiujemy używając case ... of
Ograniczenia mogą być używane wewnątrz dopasowań. Możemy, na przykład, chcieć potraktować zera w specjalny sposób:
Na koniec dwie funkcje warte wspomnienia: ($)
i (.)
.
Obie są stylistycznymi alternatywami dla zagnieżdżonych nawiasów, a poniższe wywołania są równoznaczne:
tak jak i
Składanie funkcji za pomocą .
jest przez wielu preferowane ponad wielokrotne użycie $
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 =>
.
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
.
Gdy chcemy skorzystać z typeklasy w funkcji, deklarujemy to za pomocą =>
. Możemy na przykład
zdefiniować odpowiednik Apply.apply2
ze Scalaz.
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
:
Powyższy kod rozwijany jest do
gdzie >>=
to =<<
z parametrami zamienionymi miejscami
a return
to synonim do pure
.
W przeciwieństwie do Scali, nie musimy przypisywać pustych wartości ani używać yield
, gdy zwracamy ()
.
jest odpowiednikiem
Niemonadyczne wartości mogą być przypisywane z użyciem słowa kluczowego let
:
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:
Algebry
W Scali zarówno typeklasy, jak i algebry definiowane są za pomocą trait
ów. Typeklasy wstrzykiwane są
jako parametry niejawne a algebry przekazywane explicite. W Haskellu nie ma wsparcia dla algebr na poziomie języka
i wyrażane są jako zwyczajne dane!
Rozważmy prostą algebrę Console
ze wstępu. Możemy wyrazić ją jako rekord funkcji:
z logiką biznesową używającą Monad
y:
Produkcyjna implementacja Console
prawdopodobnie miałaby typ Console IO
. Funkcja
liftIO
ze Scalaz jest inspirowana haskellową funkcją o tej samej nazwie, która potrafi
wynieść Console IO
do stosu Zaawansowanych Monad.
Dwa dodatkowe rozszerzenia języka sprawiają że logika biznesowania stanie się jeszcze prostsza.
Pierwsze z nich, RecordWildCards
, pozwala na zaimportowanie wszystkich pól z rekordu za pomocą {..}
:
Drugie, NamedFieldPuns
, wymaga wskazania wszystkich pól explicite, co zwiększa ilość boilerplate’u, ale
sprawia, że kod jest łatwiejszy do przeczytania:
W Scali takie wyrażenie mogłoby zostać nazwane Finally Tagless, ale w Haskellu znane jest jako styl MTL. Bez wchodzenia w detale, przyjmijmy, że niektórzy deweloperzy Scali nie zrozumieli artykułu opisującego zalety wydajnościowe Uogólnionych ADT w Haskellu.
Alternatywą do stylu MTL są Rozszerzalne Efekty, znane również jako styl Monady Free.
Moduły
Kod źródłowy napisany w Haskellu układa się w hierarchiczne moduły, kod każdego z nich musi być zawarty z jednym pliku, a jego pierwsza linia określa jego nazwę:
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
:
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
.
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:
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ć:
Jeśli okaże się, że nazwy importowanych symboli kolidują ze sobą, to możemy rozwiązać ten problem używając importu kwalifikowanego
(qualified
) z opcjonalną listą importowanych symboli.
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:
Tym samym fringe
jest teraz dostępne jakoT.fringe
.
Alternatywnie, zamiast deklarować importowane symbole, możemy wybrać to, czego nie chcemy importować.
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:
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 naprawdę 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:
Natomiast poniższe nie są (mogą być dalej redukowane):
Następujące wyrażenia są w WHNF, ponieważ zewnętrzny kod nie może być zredukowany (mimo że części wewnętrzne mogą):
A te wyrażenia już w WHNF nie są:
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ą ($!)
Możemy też użyć wykrzyknika na parametrach konstruktorów danych:
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:
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.
Licencje
Niektóre części kodu źródłowego w tej książce zostały skopiowane z wolnego (free / libre) oprogramowania. Licencje tych projektów wymagają, aby poniższe licencje były rozprowadzane wraz ze wspomnianym kodem.
Licencja Scali
Licencja Scalaz
Notatki
1the most principled↩
2The Red Book↩
3Copyleft notice.↩
4Libre↩
5Jest to tłumaczenie angielskiego abstract over, które w języku polskim nie ma dobrego odpowiednika. Zwrot ten oznacza zrobienie czegoś bez brania pod uwagę szczegółów, np. “abstrahować nad mutowalnymi kolekcjami” oznacza używać ich ogólnej abstrakcji (interfejsu) zamiast konkretnych implementacji.↩
6Wielokrotnie w tej książce pojawią się spolszczone wyrażenia angielskie w miejscach w których nie ma dla nich dobrego polskiego odpowiednika. Uznaliśmy że dużo lepiej użyć wyrażenia które być może brzmi dziwnie, ale pozwala w łatwy sposób zrozumieć niesione znaczenie, niż wymyślać nową nazwę, której znaczenia trzeba się domyślać.↩
7Pure Functional Programming↩
8kawałek kodu. Jedno z wielu wyrażeń bez odpowiednika w języku polskim.↩
9Z angielskiego short-circuits - zakończenie przetwarzania bez wykonywania pozostałych instrukcji.↩
10Object Oriented Programming↩
11Be conservative in what you do, be liberal in what you accept from others↩
12Chodzi tutaj o wartości na poziomie typów (type level). Dla przykładu: produktem na poziomie wartości (value level),
jest nowa wartość złożona z wielu wartości, np. (1,2,3)
. Produktem na poziomie typów jest nowy typ złożony z wielu typów
(czyli wartości na poziomie typów), np. (Int, String, Int)
. Może to wydawać się zawiłe, ale nie ma potrzeby się tym przejmować.
Ważne jest, aby zrozumieć, że mamy 2 poziomy, na których możemy definiować byty: poziom typów i poziom wartości,
i że w tym wypadku mówimy o wartościach na poziomie typów.↩
13Algebraic Data Type↩
14Exhaustivity↩
15A więc String |: Int |: Double
rozumiany jest jako String |: (Int |: Double)
, a nie (String |: Int) |: Double
.↩
16Refined Data Types↩
17Property based testing.↩
18Ten potworek to spolszczona wersja słowa typeclass. Tłumaczenie tego terminu jako “klasa typu” jest rozwlekłe i odbiegające dość daleko od wersji angielskiej. Zdecydowaliśmy się więc pozostać przy wersji oryginalnej, dostosowując jedynie pisownie.↩
19Implicit resolution↩
20Appendable Things↩
22Mistrz Yoda do Luke’a Skywalkera.↩
23Biblioteki takie jak Monix, cats-effect i Scalaz nieustannie prześcigają się w optymalizacjach mających na celu zwiększenie wydajności, stąd ciężko jest określić kto jest aktualnym liderem.↩
24Liczba argumentów, arity↩