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 Future22
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 Executora, 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 Monady, 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 Monady jest jest IO
final class IO[A](val interpret: () => A)
object IO {
def apply[A](a: =>A): IO[A] = new IO(() => a)
implicit val monad: Monad[IO] = new Monad[IO] {
def point[A](a: =>A): IO[A] = IO(a)
def bind[A, B](fa: IO[A])(f: A => IO[B]): IO[B] = IO(f(fa.interpret()).interpret())
}
}
Metoda .interpret wywoływana jest tylko raz, na wejściu do naszej aplikacji:
def main(args: Array[String]): Unit = program.interpret()
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 Monady, 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 Listy. 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 Monady):
scala> val hello = IO { println("hello") }
scala> Apply[IO].forever(hello).interpret()
hello
hello
hello
...
hello
java.lang.StackOverflowError
at java.io.FileOutputStream.write(FileOutputStream.java:326)
at ...
at monadio.IO$$anon$1.$anonfun$bind$1(monadio.scala:18)
at monadio.IO$$anon$1.$anonfun$bind$1(monadio.scala:18)
at ...
Scalaz definiuje typeklasę BindRec, którą mogą implementować Monady niezagrażające przeładowywaniem stosu (stack safe).
Wymaga ona zachowywania stałego rozmiaru stosu przy rekurencyjnych wywołaniach bind:
@typeclass trait BindRec[F[_]] extends Bind[F] {
def tailrecM[A, B](f: A => F[A \/ B])(a: A): F[B]
override def forever[A, B](fa: F[A]): F[B] = ...
}
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:
sealed abstract class Free[S[_], A]
object Free {
private final case class Return[S[_], A](a: A) extends Free[S, A]
private final case class Suspend[S[_], A](a: S[A]) extends Free[S, A]
private final case class Gosub[S[_], A0, B](
a: Free[S, A0],
f: A0 => Free[S, B]
) extends Free[S, B] { type A = A0 }
...
}
ADT Free to naturalna reprezentacja metod z interfejsu Monad:
-
Returnreprezentuje.point -
Gosubreprezentuje.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ę Monady
object Free {
type Trampoline[A] = Free[() => ?, A]
implicit val trampoline: Monad[Trampoline] with BindRec[Trampoline] =
new Monad[Trampoline] with BindRec[Trampoline] {
def point[A](a: =>A): Trampoline[A] = Return(a)
def bind[A, B](fa: Trampoline[A])(f: A => Trampoline[B]): Trampoline[B] =
Gosub(fa, f)
def tailrecM[A, B](f: A => Trampoline[A \/ B])(a: A): Trampoline[B] =
bind(f(a)) {
case -\/(a) => tailrecM(f)(a)
case \/-(b) => point(b)
}
}
...
}
.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):
object Trampoline {
def done[A](a: A): Trampoline[A] = Return(a)
def delay[A](a: =>A): Trampoline[A] = suspend(done(a))
def suspend[A](a: =>Trampoline[A]): Trampoline[A] = unit >> a
private val unit: Trampoline[Unit] = Suspend(() => done(()))
}
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 DLista
W poprzednim rozdziale przedstawiliśmy typ danych DList jako
final case class DList[A](f: IList[A] => IList[A]) {
def toIList: IList[A] = f(IList.empty)
def ++(as: DList[A]): DList[A] = DList(xs => f(as.f(xs)))
...
}
Jednak w rzeczywistości jego implementacja przypomina bardziej:
final case class DList[A](f: IList[A] => Trampoline[IList[A]]) {
def toIList: IList[A] = f(IList.empty).run
def ++(as: =>DList[A]): DList[A] = DList(xs => suspend(as.f(xs) >>= f))
...
}
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:
final class IO[A](val tramp: Trampoline[A]) {
def unsafePerformIO(): A = tramp.run
}
object IO {
def apply[A](a: =>A): IO[A] = new IO(Trampoline.delay(a))
implicit val Monad: Monad[IO] with BindRec[IO] =
new Monad[IO] with BindRec[IO] {
def point[A](a: =>A): IO[A] = IO(a)
def bind[A, B](fa: IO[A])(f: A => IO[B]): IO[B] =
new IO(fa.tramp >>= (a => f(a).tramp))
def tailrecM[A, B](f: A => IO[A \/ B])(a: A): IO[B] = ...
}
}
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:
scala> val hello = IO { println("hello") }
scala> Apply[IO].forever(hello).unsafePerformIO()
hello
hello
hello
...
hello
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
Monady oraz Hoist(a więc i MonadTrans):
@typeclass trait MonadTrans[T[_[_], _]] {
def liftM[F[_]: Monad, A](a: F[A]): T[F, A]
}
@typeclass trait Hoist[F[_[_], _]] extends MonadTrans[F] {
def hoist[M[_]: Monad, N[_]](f: M ~> N): F[M, ?] ~> F[N, ?]
}
.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ą.purezMonady - z
F[A], używającliftMzMonadTrans
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ę.
final case class MaybeT[F[_], A](run: F[Maybe[A]])
object MaybeT {
def just[F[_]: Applicative, A](v: =>A): MaybeT[F, A] =
MaybeT(Maybe.just(v).pure[F])
def empty[F[_]: Applicative, A]: MaybeT[F, A] =
MaybeT(Maybe.empty.pure[F])
...
}
dostarcza MonadPlus
implicit def monad[F[_]: Monad] = new MonadPlus[MaybeT[F, ?]] {
def point[A](a: =>A): MaybeT[F, A] = MaybeT.just(a)
def bind[A, B](fa: MaybeT[F, A])(f: A => MaybeT[F, B]): MaybeT[F, B] =
MaybeT(fa.run >>= (_.cata(f(_).run, Maybe.empty.pure[F])))
def empty[A]: MaybeT[F, A] = MaybeT.empty
def plus[A](a: MaybeT[F, A], b: =>MaybeT[F, A]): MaybeT[F, A] = a orElse b
}
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 Stringa, który może, lub nie, wskazywać na konkretnego użytkownika. Oto nasza algebra:
trait Twitter[F[_]] {
def getUser(name: String): F[Maybe[User]]
def getStars(user: User): F[Int]
}
def T[F[_]](implicit t: Twitter[F]): Twitter[F] = t
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:
def stars[F[_]: Monad: Twitter](name: String): F[Maybe[Int]] = for {
maybeUser <- T.getUser(name)
maybeStars <- maybeUser.traverse(T.getStars)
} yield maybeStars
Jednak mając do dyspozycji MonadPlus, możemy wessać Maybe do F[_] za pomocą .orEmpty i skupić się na ważniejszych rzeczach:
def stars[F[_]: MonadPlus: Twitter](name: String): F[Int] = for {
user <- T.getUser(name) >>= (_.orEmpty[F])
stars <- T.getStars(user)
} yield stars
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 Monady), albo użyć MaybeT wprost w zwracanym typie,
za cenę nieco większej ilości kodu:
def stars[F[_]: Monad: Twitter](name: String): MaybeT[F, Int] = for {
user <- MaybeT(T.getUser(name))
stars <- T.getStars(user).liftM[MaybeT]
} yield stars
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]
final case class EitherT[F[_], A, B](run: F[A \/ B])
object EitherT {
def either[F[_]: Applicative, A, B](d: A \/ B): EitherT[F, A, B] = ...
def leftT[F[_]: Functor, A, B](fa: F[A]): EitherT[F, A, B] = ...
def rightT[F[_]: Functor, A, B](fb: F[B]): EitherT[F, A, B] = ...
def pureLeft[F[_]: Applicative, A, B](a: A): EitherT[F, A, B] = ...
def pure[F[_]: Applicative, A, B](b: B): EitherT[F, A, B] = ...
...
}
z instancją MonadError
@typeclass trait MonadError[F[_], E] extends Monad[F] {
def raiseError[A](e: E): F[A]
def handleError[A](fa: F[A])(f: E => F[A]): F[A]
}
.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:
implicit final class MonadErrorOps[F[_], E, A](self: F[A])(implicit val F: MonadError[F, E])\
{
def attempt: F[E \/ A] = ...
def recover(f: E => A): F[A] = ...
def emap[B](f: A => E \/ B): F[B] = ...
}
.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:
implicit def monad[F[_]: Monad, E] = new MonadError[EitherT[F, E, ?], E] {
def monad[F[_]: Monad, E] = new MonadError[EitherT[F, E, ?], E] {
def bind[A, B](fa: EitherT[F, E, A])
(f: A => EitherT[F, E, B]): EitherT[F, E, B] =
EitherT(fa.run >>= (_.fold(_.left[B].pure[F], b => f(b).run)))
def point[A](a: =>A): EitherT[F, E, A] = EitherT.pure(a)
def raiseError[A](e: E): EitherT[F, E, A] = EitherT.pureLeft(e)
def handleError[A](fa: EitherT[F, E, A])
(f: E => EitherT[F, E, A]): EitherT[F, E, A] =
EitherT(fa.run >>= {
case -\/(e) => f(e).run
case right => right.pure[F]
})
}
Nie powinno też dziwić, że możemy przepisać przykład z MonadPlus, używając MonadError i dostarczając informacje
o błędzie:
def stars[F[_]: Twitter](name: String)
(implicit F: MonadError[F, String]): F[Int] = for {
user <- T.getUser(name) >>= (_.orError(s"user '$name' not found")(F))
stars <- T.getStars(user)
} yield stars
gdzie .orError to funkcja pomocnicza zdefiniowana na Maybe.
sealed abstract class Maybe[A] {
...
def orError[F[_], E](e: E)(implicit F: MonadError[F, E]): F[A] =
cata(F.point(_), F.raiseError(e))
}
Wersja używająca EitherT bezpośrednio:
def stars[F[_]: Monad: Twitter](name: String): EitherT[F, String, Int] = for {
user <- EitherT(T.getUser(name).map(_ \/> s"user '$name' not found"))
stars <- EitherT.rightT(T.getStars(user))
} yield stars
Najprostszą instancją MonadError jest \/, idealny typ do testowania logiki biznesowej wymagającej
tej typeklasy. Na przykład:
final class MockTwitter extends Twitter[String \/ ?] {
def getUser(name: String): String \/ Maybe[User] =
if (name.contains(" ")) Maybe.empty.right
else if (name === "wobble") "connection error".left
else User(name).just.right
def getStars(user: User): String \/ Int =
if (user.name.startsWith("w")) 10.right
else "stars have been replaced by hearts".left
}
Nasz test dla .stars może pokryć takie warianty:
scala> stars("wibble")
\/-(10)
scala> stars("wobble")
-\/(connection error)
scala> stars("i'm a fish")
-\/(user 'i'm a fish' not found)
scala> stars("fommil")
-\/(stars have been replaced by hearts)
Po raz kolejny możemy skupić się na testowaniu logiki biznesowej bez zbędnych dystrakcji.
Możemy w końcu wrócić do naszego JsonClienta z Rozdziału 4.3
trait JsonClient[F[_]] {
def get[A: JsDecoder](
uri: String Refined Url,
headers: IList[(String, String)]
): F[A]
...
}
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
object JsonClient {
sealed abstract class Error
final case class ServerError(status: Int) extends Error
final case class DecodingError(message: String) extends Error
}
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 Stringiem 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:
final case class Meta(fqn: String, file: String, line: Int)
object Meta {
implicit def gen(implicit fqn: sourcecode.FullName,
file: sourcecode.File,
line: sourcecode.Line): Meta =
new Meta(fqn.value, file.value, line.value)
}
final case class Err(msg: String)(implicit val meta: Meta)
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ść:
scala> println(Err("hello world").meta)
Meta(com.acme,<console>,10)
scala> println(Err("hello world").meta)
Meta(com.acme,<console>,11)
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:
scala> println(Err("hello world")(Meta("com.acme", "<console>", 10)).meta)
Meta(com.acme,<console>,10)
scala> println(Err("hello world")(Meta("com.acme", "<console>", 11)).meta)
Meta(com.acme,<console>,11)
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.
type ReaderT[F[_], A, B] = Kleisli[F, A, B]
final case class Kleisli[F[_], A, B](run: A => F[B]) {
def dimap[C, D](f: C => A, g: B => D)(implicit F: Functor[F]): Kleisli[F, C, D] =
Kleisli(c => run(f(c)).map(g))
def >=>[C](k: Kleisli[F, B, C])(implicit F: Bind[F]): Kleisli[F, A, C] = ...
def >==>[C](k: B => F[C])(implicit F: Bind[F]): Kleisli[F, A, C] = this >=> Kleisli(k)
...
}
object Kleisli {
implicit def kleisliFn[F[_], A, B](k: Kleisli[F, A, B]): A => F[B] = k.run
...
}
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:
trait ConfigReader[F[_]] {
def token: F[RefreshToken]
}
Tym samym odkryliśmy typeklasę MonadReader, związaną z ReaderT, gdzie .ask jest tym samym
co nasza metoda .token, a S to RefreshToken:
@typeclass trait MonadReader[F[_], S] extends Monad[F] {
def ask: F[S]
def local[A](f: S => S)(fa: F[A]): F[A]
}
wraz z implementacją
implicit def monad[F[_]: Monad, R] = new MonadReader[Kleisli[F, R, ?], R] {
def point[A](a: =>A): Kleisli[F, R, A] = Kleisli(_ => F.point(a))
def bind[A, B](fa: Kleisli[F, R, A])(f: A => Kleisli[F, R, B]) =
Kleisli(a => Monad[F].bind(fa.run(a))(f))
def ask: Kleisli[F, R, R] = Kleisli(_.pure[F])
def local[A](f: R => R)(fa: Kleisli[F, R, A]): Kleisli[F, R, A] =
Kleisli(f andThen fa.run)
}
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:
def bearer(refresh: RefreshToken)(implicit F: Monad[F]): F[BearerToken] =
for { ...
a następnie zamienić parametr refresh we fragment Monady
def bearer(implicit F: MonadReader[F, RefreshToken]): F[BearerToken] =
for {
refresh <- F.ask
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 MonadReaderze jest .local
def local[A](f: S => S)(fa: F[A]): F[A]
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ę:
def traced[A](fa: F[A])(implicit F: MonadReader[F, IList[Meta]]): F[A] =
F.local(Meta.gen :: _)(fa)
i używać jej do opakowywania funkcji, które wymagają takiego kontekstu
def foo: F[Foo] = traced(getBar) >>= barToFoo
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
def bearer(implicit F: Monad[F]): ReaderT[F, RefreshToken, BearerToken] =
ReaderT( token => for {
...
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.
final case class WriterT[F[_], W, A](run: F[(W, A)])
object WriterT {
def put[F[_]: Functor, W, A](value: F[A])(w: W): WriterT[F, W, A] = ...
def putWith[F[_]: Functor, W, A](value: F[A])(w: A => W): WriterT[F, W, A] = ...
...
}
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!
@typeclass trait MonadTell[F[_], W] extends Monad[F] {
def writer[A](w: W, v: A): F[A]
def tell(w: W): F[Unit] = ...
def :++>[A](fa: F[A])(w: =>W): F[A] = ...
def :++>>[A](fa: F[A])(f: A => W): F[A] = ...
}
@typeclass trait MonadListen[F[_], W] extends MonadTell[F, W] {
def listen[A](fa: F[A]): F[(A, W)]
def written[A](fa: F[A]): F[W] = ...
}
MonadTell służy do spisywania dziennika a MonadListen do jego odtwarzania.
Ich implementacja dla WriterT wygląda następująco:
implicit def monad[F[_]: Monad, W: Monoid] = new MonadListen[WriterT[F, W, ?], W] {
def point[A](a: =>A) = WriterT((Monoid[W].zero, a).point)
def bind[A, B](fa: WriterT[F, W, A])(f: A => WriterT[F, W, B]) = WriterT(
fa.run >>= { case (wa, a) => f(a).run.map { case (wb, b) => (wa |+| wb, b) } })
def writer[A](w: W, v: A) = WriterT((w -> v).point)
def listen[A](fa: WriterT[F, W, A]) = WriterT(
fa.run.map { case (w, a) => (w, (a, w)) })
}
Oczywistym zastosowaniem MonadTell jest logowanie lub zbieranie danych audytowych. Raz jeszcze używając
Meta możemy wyobrazić sobie taki log
sealed trait Log
final case class Debug(msg: String)(implicit m: Meta) extends Log
final case class Info(msg: String)(implicit m: Meta) extends Log
final case class Warning(msg: String)(implicit m: Meta) extends Log
i użyć Dequeue[Log] jako naszego dziennika. Tym razem zmodyfikujemy metodę authenticate z części kodu
odpowiedzialnej za obsługę OAuth2.
def debug(msg: String)(implicit m: Meta): Dequeue[Log] = Dequeue(Debug(msg))
def authenticate: F[CodeToken] =
for {
callback <- user.start :++> debug("started the webserver")
params = AuthRequest(callback, config.scope, config.clientId)
url = config.auth.withQuery(params.toUrlQuery)
_ <- user.open(url) :++> debug(s"user visiting $url")
code <- user.stop :++> debug("stopped the webserver")
} yield code
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).
type Writer[W, A] = WriterT[Id, W, A]
object WriterT {
def writer[W, A](v: (W, A)): Writer[W, A] = WriterT[Id, W, A](v)
def tell[W](w: W): Writer[W, Unit] = WriterT((w, ()))
...
}
final implicit class WriterOps[A](self: A) {
def set[W](w: W): Writer[W, A] = WriterT(w -> self)
def tell: Writer[A, Unit] = WriterT.tell(self)
}
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:
@typeclass trait MonadState[F[_], S] extends Monad[F] {
def put(s: S): F[Unit]
def get: F[S]
def modify(f: S => S): F[Unit] = get >>= (s => put(f(s)))
...
}
Struktura StateT jest zaimplementowana nieco inaczej niż transformatory, które widzieliśmy do tej pory,
nie jest case klasą, lecz ADT z dwoma wariantami:
sealed abstract class StateT[F[_], S, A]
object StateT {
def apply[F[_], S, A](f: S => F[(S, A)]): StateT[F, S, A] = Point(f)
private final case class Point[F[_], S, A](
run: S => F[(S, A)]
) extends StateT[F, S, A]
private final case class FlatMap[F[_], S, A, B](
a: StateT[F, S, A],
f: (S, A) => StateT[F, S, B]
) extends StateT[F, S, B]
...
}
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:
sealed abstract class StateT[F[_], S, A] {
def run(initial: S)(implicit F: Monad[F]): F[(S, A)] = this match {
case Point(f) => f(initial)
case FlatMap(Point(f), g) =>
f(initial) >>= { case (s, x) => g(s, x).run(s) }
case FlatMap(FlatMap(f, g), h) =>
FlatMap(f, (s, x) => FlatMap(g(s, x), h)).run(initial)
}
...
}
StateT ze swoim ADT w trywialny sposób implementuje MonadState
implicit def monad[F[_]: Applicative, S] = new MonadState[StateT[F, S, ?], S] {
def point[A](a: =>A) = Point(s => (s, a).point[F])
def bind[A, B](fa: StateT[F, S, A])(f: A => StateT[F, S, B]) =
FlatMap(fa, (_, a: A) => f(a))
def get = Point(s => (s, s).point[F])
def put(s: S) = Point(_ => (s, ()).point[F])
}
.pure otrzymał swoją kopię .stateT w obiekcie towarzyszącym:
object StateT {
def stateT[F[_]: Applicative, S, A](a: A): StateT[F, S, A] = ...
...
}
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:
type State[a] = StateT[Id, a]
object State {
def apply[S, A](f: S => (S, A)): State[S, A] = StateT[Id, S, A](f)
def state[S, A](a: A): State[S, A] = State((_, a))
def get[S]: State[S, S] = State(s => (s, s))
def put[S](s: S): State[S, Unit] = State(_ => (s, ()))
def modify[S](f: S => S): State[S, Unit] = ...
...
}
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.
class Mutable(state: WorldView) {
var started, stopped: Int = 0
implicit val drone: Drone[Id] = new Drone[Id] { ... }
implicit val machines: Machines[Id] = new Machines[Id] { ... }
val program = new DynAgentsModule[Id]
}
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:
final case class WorldView(
backlog: Int,
agents: Int,
managed: NonEmptyList[MachineNode],
alive: Map[MachineNode, Epoch],
pending: Map[MachineNode, Epoch],
time: Epoch
)
Skoro piszemy symulację świata na potrzeby naszych testów, to możemy zdefiniować typ danych przechwytujący pełen jego obraz
final case class World(
backlog: Int,
agents: Int,
managed: NonEmptyList[MachineNode],
alive: Map[MachineNode, Epoch],
started: Set[MachineNode],
stopped: Set[MachineNode],
time: Epoch
)
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:
import State.{ get, modify }
object StateImpl {
type F[a] = State[World, a]
private val D = new Drone[F] {
def getBacklog: F[Int] = get.map(_.backlog)
def getAgents: F[Int] = get.map(_.agents)
}
private val M = new Machines[F] {
def getAlive: F[Map[MachineNode, Epoch]] = get.map(_.alive)
def getManaged: F[NonEmptyList[MachineNode]] = get.map(_.managed)
def getTime: F[Epoch] = get.map(_.time)
def start(node: MachineNode): F[Unit] =
modify(w => w.copy(started = w.started + node))
def stop(node: MachineNode): F[Unit] =
modify(w => w.copy(stopped = w.stopped + node))
}
val program = new DynAgentsModule[F](D, M)
}
a my możemy przepisać nasze testy tak, aby zachowywały konwencję:
-
world1to stan świata przed uruchomieniem programu -
view1to widok świata z punktu widzenia aplikacji -
world2to stan świata po uruchomieniu programu -
view2to widok świata z punktu widzenia aplikacji po uruchomieniu programu
Przykład:
it should "request agents when needed" in {
val world1 = World(5, 0, managed, Map(), Set(), Set(), time1)
val view1 = WorldView(5, 0, managed, Map(), Map(), time1)
val (world2, view2) = StateImpl.program.act(view1).run(world1)
view2.shouldBe(view1.copy(pending = Map(node1 -> time1)))
world2.stopped.shouldBe(world1.stopped)
world2.started.shouldBe(Set(node1))
}
Moglibyśmy spojrzeć na naszą nieskończoną pętlę logiki biznesowej
state = initial()
while True:
state = update(state)
state = act(state)
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.
type StateT[F[_], S, A] = IndexedStateT[F, S, S, A]
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:
sealed abstract class IndexedStateT[F[_], -S1, S2, A] {
def run(initial: S1)(implicit F: Bind[F]): F[(S2, A)] = ...
...
}
object IndexedStateT {
def apply[F[_], S1, S2, A](
f: S1 => F[(S2, A)]
): IndexedStateT[F, S1, S2, A] = Wrap(f)
private final case class Wrap[F[_], S1, S2, A](
run: S1 => F[(S2, A)]
) extends IndexedStateT[F, S1, S2, A]
private final case class FlatMap[F[_], S1, S2, S3, A, B](
a: IndexedStateT[F, S1, S2, A],
f: (S2, A) => IndexedStateT[F, S2, S3, B]
) extends IndexedStateT[F, S1, S3, B]
...
}
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:
trait Cache[F[_]] {
def read(k: Int): F[Maybe[String]]
def lock: F[Unit]
def update(k: Int, v: String): F[Unit]
def commit: F[Unit]
}
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
sealed abstract class Status
final case class Ready() extends Status
final case class Locked(on: ISet[Int]) extends Status
final case class Updated(values: Int ==>> String) extends Status
i odświeżenia naszej algebry
trait Cache[M[_]] {
type F[in, out, a] = IndexedStateT[M, in, out, a]
def read(k: Int): F[Ready, Ready, Maybe[String]]
def readLocked(k: Int): F[Locked, Locked, Maybe[String]]
def readUncommitted(k: Int): F[Updated, Updated, Maybe[String]]
def lock: F[Ready, Locked, Unit]
def update(k: Int, v: String): F[Locked, Updated, Unit]
def commit: F[Updated, Ready, Unit]
}
co spowoduje, że próba wywołania .update bez wcześniejszego .lock spowoduje błąd kompilacji
for {
a1 <- C.read(13)
_ <- C.update(13, "wibble")
_ <- C.commit
} yield a1
[error] found : IndexedStateT[M,Locked,Ready,Maybe[String]]
[error] required: IndexedStateT[M,Ready,?,?]
[error] _ <- C.update(13, "wibble")
[error] ^
pozwalając nam konstruować funkcje, które mogą być komponowane dzięki wyrażaniu swojego stanu explicite
def wibbleise[M[_]: Monad](C: Cache[M]): F[Ready, Ready, String] =
for {
_ <- C.lock
a1 <- C.readLocked(13)
a2 = a1.cata(_ + "'", "wibble")
_ <- C.update(13, a2)
_ <- C.commit
} yield a2
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.
sealed abstract class IndexedReaderWriterStateT[F[_], -R, W, -S1, S2, A] {
def run(r: R, s: S1)(implicit F: Monad[F]): F[(W, A, S2)] = ...
...
}
object IndexedReaderWriterStateT {
def apply[F[_], R, W, S1, S2, A](f: (R, S1) => F[(W, A, S2)]) = ...
}
type ReaderWriterStateT[F[_], -R, W, S, A] = IndexedReaderWriterStateT[F, R, W, S, S, A]
object ReaderWriterStateT {
def apply[F[_], R, W, S, A](f: (R, S) => F[(W, A, S)]) = ...
}
Do dyspozycji mamy skróty, bo trzeba przyznać, że te typy są tak długie, że wyglądają jak część API J2EE.
type IRWST[F[_], -R, W, -S1, S2, A] = IndexedReaderWriterStateT[F, R, W, S1, S2, A]
val IRWST = IndexedReaderWriterStateT
type RWST[F[_], -R, W, S, A] = ReaderWriterStateT[F, R, W, S, A]
val RWST = ReaderWriterStateT
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ć.
final case class TheseT[F[_], A, B](run: F[A \&/ B])
object TheseT {
def `this`[F[_]: Functor, A, B](a: F[A]): TheseT[F, A, B] = ...
def that[F[_]: Functor, A, B](b: F[B]): TheseT[F, A, B] = ...
def both[F[_]: Functor, A, B](ab: F[(A, B)]): TheseT[F, A, B] = ...
implicit def monad[F[_]: Monad, A: Semigroup] = new Monad[TheseT[F, A, ?]] {
def bind[B, C](fa: TheseT[F, A, B])(f: B => TheseT[F, A, C]) =
TheseT(fa.run >>= {
case This(a) => a.wrapThis[C].point[F]
case That(b) => f(b).run
case Both(a, b) =>
f(b).run.map {
case This(a_) => (a |+| a_).wrapThis[C]
case That(c_) => Both(a, c_)
case Both(a_, c_) => Both(a |+| a_, c_)
}
})
def point[B](b: =>B) = TheseT(b.wrapThat.point[F])
}
}
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:
def foo[I, A](input: I)(next: A => Unit): Unit = next(doSomeStuff(input))
Możemy sprawić, że ten kod stanie się czysty, wprowadzając kontekst F[_]
def foo[F[_], I, A](input: I)(next: A => F[Unit]): F[Unit]
i zwracając funkcję dla danego wejścia
def foo[F[_], I, A](input: I): (A => F[Unit]) => F[Unit]
ContT to opakowanie dla takiej właśnie sygnatury, z dodatkiem instancji typeklasy Monad
final case class ContT[F[_], B, A](_run: (A => F[B]) => F[B]) {
def run(f: A => F[B]): F[B] = _run(f)
}
object IndexedContT {
implicit def monad[F[_], B] = new Monad[ContT[F, B, ?]] {
def point[A](a: =>A) = ContT(_(a))
def bind[A, C](fa: ContT[F, B, A])(f: A => ContT[F, B, C]) =
ContT(c_fb => fa.run(a => f(a).run(c_fb)))
}
}
i wygodnej składni do tworzenia ContT z monadycznej wartości:
implicit class ContTOps[F[_]: Monad, A](self: F[A]) {
def cps[B]: ContT[F, B, A] = ContT(a_fb => self >>= a_fb)
}
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ół:
final case class A0()
final case class A1()
final case class A2()
final case class A3()
final case class A4()
def bar0(a4: A4): IO[A0] = ...
def bar2(a1: A1): IO[A2] = ...
def bar3(a2: A2): IO[A3] = ...
def bar4(a3: A3): IO[A4] = ...
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:
def simple(a: A1): IO[A0] = bar2(a) >>= bar3 >>= bar4 >>= bar0
Możemy wynieść .simple do postaci kontynuacji, używając .cps i odrobiny boilerplate’u:
def foo1(a: A1): ContT[IO, A0, A2] = bar2(a).cps
def foo2(a: A2): ContT[IO, A0, A3] = bar3(a).cps
def foo3(a: A3): ContT[IO, A0, A4] = bar4(a).cps
def flow(a: A1): IO[A0] = (foo1(a) >>= foo2 >>= foo3).run(bar0)
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:
def foo2(a: A2): ContT[IO, A0, A3] = ContT { next =>
for {
a3 <- bar3(a)
a0 <- next(a3)
} yield process(a0)
}
Nie jesteśmy ograniczeni do .mapowania wartości. Możemy również wywołać .bind i zamienić
liniowy przepływ w pełnoprawny graf!
def elsewhere: ContT[IO, A0, A4] = ???
def foo2(a: A2): ContT[IO, A0, A3] = ContT { next =>
for {
a3 <- bar3(a)
a0 <- next(a3)
a0_ <- if (check(a0)) a0.pure[IO]
else elsewhere.run(bar0)
} yield a0_
}
Możemy też zostać przy oryginalnym przepływie i ponowić wszystkie dalsze operacje.
def foo2(a: A2): ContT[IO, A0, A3] = ContT { next =>
for {
a3 <- bar3(a)
a0 <- next(a3)
a0_ <- if (check(a0)) a0.pure[IO]
else next(a3)
} yield a0_
}
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:
def foo2(a: A2): ContT[IO, A0, A3] = bar3(a).ensuring(cleanup).cps
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ć Monady.
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)
final case class IndexedContsT[W[_], F[_], C, B, A](_run: W[A => F[B]] => F[C])
type IndexedContT[f[_], c, b, a] = IndexedContsT[Id, f, c, b, a]
type ContT[f[_], b, a] = IndexedContsT[Id, f, b, b, a]
type ContsT[w[_], f[_], b, a] = IndexedContsT[w, f, b, b, a]
type Cont[b, a] = IndexedContsT[Id, Id, b, b, a]
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
type Ctx[A] = StateT[EitherT[IO, E, ?], S, A]
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
Monadoznacza, ż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ą wStateTiEitherT, 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.
StateTjest szczególnie kosztowny, ale nawetEitherTmoż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ę
trait Lookup[F[_]] {
def look: F[Int]
}
i typy danych
final case class Problem(bad: Int)
final case class Table(last: Int)
które chcielibyśmy użyć w naszej logice biznesowej:
def foo[F[_]](L: Lookup[F])(
implicit
E: MonadError[F, Problem],
S: MonadState[F, Table]
): F[Int] = for {
old <- S.get
i <- L.look
_ <- if (i === old.last) E.raiseError(Problem(i))
else ().pure[F]
} yield i
Pierwszy problem: nasz kod się nie kompiluje.
[error] value flatMap is not a member of type parameter F[Table]
[error] old <- S.get
[error] ^
Istnieją pewne taktyczne rozwiązania tego problemu. Najbardziej oczywistym jest przyjęcie parametrów wprost
def foo1[F[_]: Monad](
L: Lookup[F],
E: MonadError[F, Problem],
S: MonadState[F, Table]
): F[Int] = ...
i wymaganie jedynie Monady 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.
@inline final def shadow[A, B, C](a: A, b: B)(f: (A, B) => C): C = f(a, b)
def foo2a[F[_]: Monad](L: Lookup[F])(
implicit
E: MonadError[F, Problem],
S: MonadState[F, Table]
): F[Int] = shadow(E, S) { (E, S) => ...
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.
@inline final def shadow[A, B](a: A)(f: A => B): B = f(a)
...
def foo2b[F[_]](L: Lookup[F])(
implicit
E: MonadError[F, Problem],
S: MonadState[F, Table]
): F[Int] = shadow(E) { E => ...
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
trait MonadErrorState[F[_], E, S] {
implicit def E: MonadError[F, E]
implicit def S: MonadState[F, S]
}
i którą automatycznie wyderywujemy z dostępnych instancji MonadError i MonadState.
object MonadErrorState {
implicit def create[F[_], E, S](
implicit
E0: MonadError[F, E],
S0: MonadState[F, S]
) = new MonadErrorState[F, E, S] {
def E: MonadError[F, E] = E0
def S: MonadState[F, S] = S0
}
}
Teraz, gdy potrzebujemy S lub E mamy do nich dostęp poprzez F.S i F.E
def foo3a[F[_]: Monad](L: Lookup[F])(
implicit F: MonadErrorState[F, Problem, Table]
): F[Int] =
for {
old <- F.S.get
i <- L.look
_ <- if (i === old.last) F.E.raiseError(Problem(i))
else ().pure[F]
} yield i
I tak jak w drugim podejściu, możemy wybrać jedną z Monad jako niejawną w naszym bloku, importując ją
def foo3b[F[_]](L: Lookup[F])(
implicit F: MonadErrorState[F, Problem, Table]
): F[Int] = {
import F.E
...
}
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
object LookupRandom extends Lookup[IO] {
def look: IO[Int] = IO { util.Random.nextInt }
}
ale chcielibyśmy, aby nasz kontekst wyglądał tak
type Ctx[A] = StateT[EitherT[IO, Problem, ?], Table, A]
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]
final class MonadOps[F[_]: Monad, A](fa: F[A]) {
def liftM[G[_[_], _]: MonadTrans]: 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
type Ctx0[F[_], A] = StateT[EitherT[F, Problem, ?], Table, A]
type Ctx1[F[_], A] = EitherT[F, Problem, A]
type Ctx2[F[_], A] = StateT[F, Table, A]
to możemy abstrahować ponad MonadTrans, aby wynieść Lookup[F] do dowolnego Lookup[G[F, ?]]
tak długo jak G to transformator monad:
def liftM[F[_]: Monad, G[_[_], _]: MonadTrans](f: Lookup[F]) =
new Lookup[G[F, ?]] {
def look: G[F, Int] = f.look.liftM[G]
}
Możemy więc opakować algebrę kolejno dla EitherT i StateT
val wrap1 = Lookup.liftM[IO, Ctx1](LookupRandom)
val wrap2: Lookup[Ctx] = Lookup.liftM[EitherT[IO, Problem, ?], Ctx2](wrap1)
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:
@typeclass trait MonadIO[F[_]] extends Monad[F] {
def liftIO[A](ioa: IO[A]): F[A]
}
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ę:
def liftIO[F[_]: MonadIO](io: Lookup[IO]) = new Lookup[F] {
def look: F[Int] = io.look.liftIO[F]
}
val L: Lookup[Ctx] = Lookup.liftIO(LookupRandom)
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:
sealed abstract class Free[S[_], A] {
def mapSuspension[T[_]](f: S ~> T): Free[T, A] = ...
def foldMap[M[_]: Monad](f: S ~> M): M[A] = ...
...
}
object Free {
implicit def monad[S[_], A]: Monad[Free[S, A]] = ...
private final case class Suspend[S[_], A](a: S[A]) extends Free[S, A]
private final case class Return[S[_], A](a: A) extends Free[S, A]
private final case class Gosub[S[_], A0, B](
a: Free[S, A0],
f: A0 => Free[S, B]
) extends Free[S, B] { type A = A0 }
def liftF[S[_], A](value: S[A]): Free[S, A] = Suspend(value)
...
}
-
Suspendreprezentuje program, który nie został jeszcze zinterpretowany -
Returnto.pure -
Gosubto.bind
Instancja Free[S, A] może być wygenerowana za darmo dla dowolnej algebry S. Aby zobaczyć to wprost,
rozważmy naszą algebrę Machines
trait Machines[F[_]] {
def getTime: F[Epoch]
def getManaged: F[NonEmptyList[MachineNode]]
def getAlive: F[Map[MachineNode, Epoch]]
def start(node: MachineNode): F[Unit]
def stop(node: MachineNode): F[Unit]
}
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ę:
object Machines {
sealed abstract class Ast[A]
final case class GetTime() extends Ast[Epoch]
final case class GetManaged() extends Ast[NonEmptyList[MachineNode]]
final case class GetAlive() extends Ast[Map[MachineNode, Epoch]]
final case class Start(node: MachineNode) extends Ast[Unit]
final case class Stop(node: MachineNode) extends Ast[Unit]
...
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:
...
def liftF = new Machines[Free[Ast, ?]] {
def getTime = Free.liftF(GetTime())
def getManaged = Free.liftF(GetManaged())
def getAlive = Free.liftF(GetAlive())
def start(node: MachineNode) = Free.liftF(Start(node))
def stop(node: MachineNode) = Free.liftF(Stop(node))
}
}
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
def program[F[_]: Monad](M: Machines[F]): F[Unit] = ...
val interpreter: Machines.Ast ~> IO = ...
val app: IO[Unit] = program[Free[Machines.Ast, ?]](Machines.liftF)
.foldMap(interpreter)
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ą.
def interpreter[F[_]](f: Machines[F]): Ast ~> F = λ[Ast ~> F] {
case GetTime() => f.getTime
case GetManaged() => f.getManaged
case GetAlive() => f.getAlive
case Start(node) => f.start(node)
case Stop(node) => f.stop(node)
}
Ale nasza logika biznesowa potrzebuje więcej niż tylko algebry Machines. Oprócz niej
potrzebna jest też algebra Drones, zdefiniowana jako:
trait Drone[F[_]] {
def getBacklog: F[Int]
def getAgents: F[Int]
}
object Drone {
sealed abstract class Ast[A]
...
def liftF = ...
def interpreter = ...
}
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:
final case class Coproduct[F[_], G[_], A](run: F[A] \/ G[A])
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.
type :<:[F[_], G[_]] = Inject[F, G]
sealed abstract class Inject[F[_], G[_]] {
def inj[A](fa: F[A]): G[A]
def prj[A](ga: G[A]): Option[F[A]]
}
object Inject {
implicit def left[F[_], G[_]]: F :<: Coproduct[F, G, ?]] = ...
...
}
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:
def liftF[F[_]](implicit I: Ast :<: F) = new Machines[Free[F, ?]] {
def getTime = Free.liftF(I.inj(GetTime()))
def getManaged = Free.liftF(I.inj(GetManaged()))
def getAlive = Free.liftF(I.inj(GetAlive()))
def start(node: MachineNode) = Free.liftF(I.inj(Start(node)))
def stop(node: MachineNode) = Free.liftF(I.inj(Stop(node)))
}
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ą
def program[F[_]: Monad](M: Machines[F], D: Drone[F]): F[Unit] = ...
oraz gotowe implementacja algebr Machines i Drone, z użyciem których możemy stworzyć interpretery:
val MachinesIO: Machines[IO] = ...
val DroneIO: Drone[IO] = ...
val M: Machines.Ast ~> IO = Machines.interpreter(MachinesIO)
val D: Drone.Ast ~> IO = Drone.interpreter(DroneIO)
i połączyć je w większy zbiór instrukcji używając pomocniczych metod z obiektu towarzyszącego
NaturalTransformation
object NaturalTransformation {
def or[F[_], G[_], H[_]](fg: F ~> G, hg: H ~> G): Coproduct[F, H, ?] ~> G = ...
...
}
type Ast[a] = Coproduct[Machines.Ast, Drone.Ast, a]
val interpreter: Ast ~> IO = NaturalTransformation.or(M, D)
aby następnie użyć ich do wyprodukowania IO
val app: IO[Unit] = program[Free[Ast, ?]](Machines.liftF, Drone.liftF)
.foldMap(interpreter)
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:
val M: Machines.Ast ~> Id = stub[Map[MachineNode, Epoch]] {
case Machines.GetAlive() => Map.empty
}
val D: Drone.Ast ~> Id = stub[Int] {
case Drone.GetBacklog() => 1
}
które posłużą nam do testowania naszego programu.
program[Free[Ast, ?]](Machines.liftF, Drone.liftF)
.foldMap(or(M, D))
.shouldBe(1)
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:
val Monitor = λ[Demo.Ast ~> Demo.Ast](
_.run match {
case \/-(m @ Drone.GetBacklog()) =>
JmxAbstractFactoryBeanSingletonProviderUtilImpl.count("backlog")
Coproduct.rightc(m)
case other =>
Coproduct(other)
}
)
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ą
.mapSuspension(Monitor).foldMap(interpreter)
lub połączyć transformacje naturalne wywołując pojedyncze
.foldMap(Monitor.andThen(interpreter))
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
#c0ffeeprzy 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:
val monkey = λ[Machines.Ast ~> Free[Machines.Ast, ?]] {
case Machines.Stop(MachineNode("#c0ffee")) => Free.pure(())
case other => Free.liftF(other)
}
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:
type S = Set[MachineNode]
val M: Machines.Ast ~> State[S, ?] = Mocker.stub[Unit] {
case Machines.Stop(node) => State.modify[S](_ + node)
}
Machines
.liftF[Machines.Ast]
.stop(MachineNode("#c0ffee"))
.foldMap(monkey)
.foldMap(M)
.exec(Set.empty)
.shouldBe(Set.empty)
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:
sealed abstract class FreeAp[S[_], A] {
def hoist[G[_]](f: S ~> G): FreeAp[G,A] = ...
def foldMap[G[_]: Applicative](f: S ~> G): G[A] = ...
def monadic: Free[S, A] = ...
def analyze[M:Monoid](f: F ~> λ[α => M]): M = ...
...
}
object FreeAp {
implicit def applicative[S[_], A]: Applicative[FreeAp[S, A]] = ...
private final case class Pure[S[_], A](a: A) extends FreeAp[S, A]
private final case class Ap[S[_], A, B](
value: () => S[B],
function: () => FreeAp[S, B => A]
) extends FreeAp[S, A]
def pure[S[_], A](a: A): FreeAp[S, A] = Pure(a)
def lift[S[_], A](x: =>S[A]): FreeAp[S, A] = ...
...
}
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…
def liftA[F[_]](implicit I: Ast :<: F) = new Machines[FreeAp[F, ?]] {
def getTime = FreeAp.lift(I.inj(GetTime()))
...
}
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
final class DynAgentsModule[F[_]: Applicative](D: Drone[F], M: Machines[F])
extends DynAgents[F] {
def act(world: WorldView): F[WorldView] = ...
...
}
Zacznijmy od stworzenia boilerplate’u lift dla nowej algebry Batch
trait Batch[F[_]] {
def start(nodes: NonEmptyList[MachineNode]): F[Unit]
}
object Batch {
sealed abstract class Ast[A]
final case class Start(nodes: NonEmptyList[MachineNode]) extends Ast[Unit]
def liftA[F[_]](implicit I: Ast :<: F) = new Batch[FreeAp[F, ?]] {
def start(nodes: NonEmptyList[MachineNode]) = FreeAp.lift(I.inj(Start(nodes)))
}
}
oraz instancji DynAgentsModule używając FreeAp jako kontekstu
type Orig[a] = Coproduct[Machines.Ast, Drone.Ast, a]
val world: WorldView = ...
val program = new DynAgentsModule(Drone.liftA[Orig], Machines.liftA[Orig])
val freeap = program.act(world)
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:
sealed abstract class FreeAp[S[_], A] {
...
def analyze[M: Monoid](f: S ~> λ[α => M]): M =
foldMap(λ[S ~> Const[M, ?]](x => Const(f(x)))).getConst
}
Używamy transformacji naturalnej i .analyze, aby zebrać wszystkie węzły, które powinny zostać wystartowane
val gather = λ[Orig ~> λ[α => IList[MachineNode]]] {
case Coproduct(-\/(Machines.Start(node))) => IList.single(node)
case _ => IList.empty
}
val gathered: IList[MachineNode] = freeap.analyze(gather)
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.
type Extended[a] = Coproduct[Batch.Ast, Orig, a]
def batch(nodes: IList[MachineNode]): FreeAp[Extended, Unit] =
nodes.toNel match {
case None => FreeAp.pure(())
case Some(nodes) => FreeAp.lift(Coproduct.leftc(Batch.Start(nodes)))
}
Musimy również pozbyć się wszystkich wywołań Machines.Start, co możemy osiągnąć używając jeszcze jednej transformacji naturalnej
val nostart = λ[Orig ~> FreeAp[Extended, ?]] {
case Coproduct(-\/(Machines.Start(_))) => FreeAp.pure(())
case other => FreeAp.lift(Coproduct.rightc(other))
}
Mamy teraz dwa programy, które musimy połączyć. Przypomnijmy sobie operator *> z Apply
val patched = batch(gathered) *> freeap.foldMap(nostart)
i złóżmy to wszystko razem jako jedną metodę
def optimise[A](orig: FreeAp[Orig, A]): FreeAp[Extended, A] =
(batch(orig.analyze(gather)) *> orig.foldMap(nostart))
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.
sealed abstract class Coyoneda[F[_], A] {
def run(implicit F: Functor[F]): F[A] = ...
def trans[G[_]](f: F ~> G): Coyoneda[G, A] = ...
...
}
object Coyoneda {
implicit def functor[F[_], A]: Functor[Coyoneda[F, A]] = ...
private final case class Map[F[_], A, B](fa: F[A], f: A => B) extends Coyoneda[F, A]
def apply[F[_], A, B](sa: F[A])(f: A => B) = Map[F, A, B](sa, f)
def lift[F[_], A](sa: F[A]) = Map[F, A, A](sa, identity)
...
}
Również w wersji kontrawariantnej:
sealed abstract class ContravariantCoyoneda[F[_], A] {
def run(implicit F: Contravariant[F]): F[A] = ...
def trans[G[_]](f: F ~> G): ContravariantCoyoneda[G, A] = ...
...
}
object ContravariantCoyoneda {
implicit def contravariant[F[_], A]: Contravariant[ContravariantCoyoneda[F, A]] = ...
private final case class Contramap[F[_], A, B](fa: F[A], f: B => A)
extends ContravariantCoyoneda[F, A]
def apply[F[_], A, B](sa: F[A])(f: B => A) = Contramap[F, A, B](sa, f)
def lift[F[_], A](sa: F[A]) = Contramap[F, A, A](sa, identity)
...
}
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ę
Functora lub ContravariantFunctora).
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ć:
def liftCoyo[F[_]](implicit I: Ast :<: F) = new Machines[Coyoneda[F, ?]] {
def getTime = Coyoneda.lift(I.inj(GetTime()))
...
}
def liftCocoyo[F[_]](implicit I: Ast :<: F) = new Machines[ContravariantCoyoneda[F, ?]] {
def getTime = ContravariantCoyoneda.lift(I.inj(GetTime()))
...
}
Optymalizacją, którą możemy zastosować to fuzja wywołań .map (map fusion), co pozwala nam przepisać
xs.map(a).map(b).map(c)
na
xs.map(x => c(b(a(x))))
unikając pośrednich reprezentacji. Jeśli, na przykład, xs to Lista 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:
object MonadState {
sealed abstract class Ast[S, A]
final case class Get[S]() extends Ast[S, S]
final case class Put[S](s: S) extends Ast[S, Unit]
def liftF[F[_], S](implicit I: Ast[S, ?] :<: F) =
new MonadState[Free[F, ?], S] with BindRec[Free[F, ?]] {
def get = Free.liftF(I.inj(Get[S]()))
def put(s: S) = Free.liftF(I.inj(Put[S](s)))
val delegate = Free.freeMonad[F]
def point[A](a: =>A) = delegate.point(a)
...
}
...
}
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:
-
.mapna kolekcji efektów, zwracając pojedynczy efekt. Można to osiągnąć za pomocą metody.traverse, która deleguje do.apply2danego 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.
@typeclass trait Bind[F[_]] extends Apply[F] {
...
override def apply2[A, B, C](fa: =>F[A], fb: =>F[B])(f: (A, B) => C): F[C] =
bind(fa)(a => map(fb)(b => f(a, b)))
...
}
Innymi słowy, Monadom 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
object Applicative {
type Par[F[_]] = Applicative[λ[α => F[α] @@ Tags.Parallel]]
...
}
Programy monadyczne mogą więc żądać niejawnego Par jako dodatku do ich Monad
def foo[F[_]: Monad: Applicative.Par]: F[Unit] = ...
Metody Traverse ze Scalaz wspierają równoległe wykonanie:
implicit class TraverseSyntax[F[_], A](self: F[A]) {
...
def parTraverse[G[_], B](f: A => G[B])(
implicit F: Traverse[F], G: Applicative.Par[G]
): G[F[B]] = Tag.unwrap(F.traverse(self)(a => Tag(f(a))))
}
Jeśli w zakresie dostępna jest niejawna instancja Applictive.Par[IO], to możemy wybrać między
sekwencyjną i równoległa trawersacją:
val input: IList[String] = ...
def network(in: String): IO[Int] = ...
input.traverse(network): IO[IList[Int]] // one at a time
input.parTraverse(network): IO[IList[Int]] // all in parallel
Podobnie możemy wywołać .parApply lub .parTupled po tym, jak użyliśmy operatorów krzyku
val fa: IO[String] = ...
val fb: IO[String] = ...
val fc: IO[String] = ...
(fa |@| fb).parTupled: IO[(String, String)]
(fa |@| fb |@| fc).parApply { case (a, b, c) => a + b + c }: IO[String]
Warto zaznaczyć, że kiedy mamy do czynienia z programami opartymi o Applicative, takimi jak
def foo[F[_]: Applicative]: F[Unit] = ...
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.
def foo[F[_]: Applicative: Applicative.Par]: F[Unit] = ...
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
final class MyIO[A](val io: IO[A]) extends AnyVal
i dostarczamy naszą własną implementację Monady, która uruchamia .apply2 równolegle, poprzez oddelegowanie
do instancji dla typu @@ Parallel
object MyIO {
implicit val monad: Monad[MyIO] = new Monad[MyIO] {
override def apply2[A, B, C](fa: MyIO[A], fb: MyIO[B])(f: (A, B) => C): MyIO[C] =
Applicative[IO.Par].apply2(fa.io, fb.io)(f)
...
}
}
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:
object IO {
...
type Par[a] = IO[a] @@ Parallel
implicit val ParApplicative = new Applicative[Par] {
override def apply2[A, B, C](fa: =>Par[A], fb: =>Par[B])(f: (A, B) => C): Par[C] =
Tag(
IO {
val forked = Future { Tag.unwrap(fa).interpret() }
val b = Tag.unwrap(fb).interpret()
val a = Await.result(forked, Duration.Inf)
f(a, b)
}
)
}
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ść:
import IO.ParApplicative
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.
sealed abstract class IO[E, A] { ... }
object IO {
private final class FlatMap ... extends IO[E, A]
private final class Point ... extends IO[E, A]
private final class Strict ... extends IO[E, A]
private final class SyncEffect ... extends IO[E, A]
private final class Fail ... extends IO[E, A]
private final class AsyncEffect ... extends IO[E, A]
...
}
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:
type Task[A] = IO[Throwable, A]
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:
object IO {
// eager evaluation of an existing value
def now[E, A](a: A): IO[E, A] = ...
// lazy evaluation of a pure calculation
def point[E, A](a: =>A): IO[E, A] = ...
// lazy evaluation of a side-effecting, yet Total, code block
def sync[E, A](effect: =>A): IO[E, A] = ...
// lazy evaluation of a side-effecting code block that may fail
def syncThrowable[A](effect: =>A): IO[Throwable, A] = ...
// create a failed IO
def fail[E, A](error: E): IO[E, A] = ...
// asynchronously sleeps for a specific period of time
def sleep[E](duration: Duration): IO[E, Unit] = ...
...
}
wraz z wygodnymi konstruktorami dla Taska:
object Task {
def apply[A](effect: =>A): Task[A] = IO.syncThrowable(effect)
def now[A](effect: A): Task[A] = IO.now(effect)
def fail[A](error: Throwable): Task[A] = IO.fail(error)
def fromFuture[E, A](io: Task[Future[A]])(ec: ExecutionContext): Task[A] = ...
}
Najczęściej używanym wariantem, szczególnie przy pracy z istniejącym kodem, jest Task.apply oraz Task.fromFuture:
val fa: Task[Future[String]] = Task { ... impure code here ... }
Task.fromFuture(fa)(ExecutionContext.global): Task[String]
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
trait SafeApp extends RTS {
sealed trait ExitStatus
object ExitStatus {
case class ExitNow(code: Int) extends ExitStatus
case class ExitWhenDone(code: Int, timeout: Duration) extends ExitStatus
case object DoNotExit extends ExitStatus
}
def run(args: List[String]): IO[Void, ExitStatus]
final def main(args0: Array[String]): Unit = ... calls 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:
sealed abstract class IO[E, A] {
// retries an action N times, until success
def retryN(n: Int): IO[E, A] = ...
// ... with exponential backoff
def retryBackoff(n: Int, factor: Double, duration: Duration): IO[E, A] = ...
// repeats an action with a pause between invocations, until it fails
def repeat[B](interval: Duration): IO[E, B] = ...
// cancel the action if it does not complete within the timeframe
def timeout(duration: Duration): IO[E, Maybe[A]] = ...
// runs `release` on success or failure.
// Note that IO[Void, Unit] cannot fail.
def bracket[B](release: A => IO[Void, Unit])(use: A => IO[E, B]): IO[E, B] = ...
// alternative syntax for bracket
def ensuring(finalizer: IO[Void, Unit]): IO[E, A] =
// ignore failure and success, e.g. to ignore the result of a cleanup action
def ignore: IO[Void, Unit] = ...
// runs two effects in parallel
def par[B](that: IO[E, B]): IO[E, (A, B)] = ...
...
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:
...
// terminate whatever actions are running with the given throwable.
// bracket / ensuring is honoured.
def terminate[E, A](t: Throwable): IO[E, A] = ...
// runs two effects in parallel, return the winner and terminate the loser
def race(that: IO[E, A]): IO[E, A] = ...
// ignores terminations
def uninterruptibly: IO[E, A] = ...
...
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:
...
def fork[E2]: IO[E2, Fiber[E, A]] = ...
def supervised(error: Throwable): IO[E, A] = ...
...
Kiedy mamy do dyspozycji włókno możemy je włączyć z powrotem do IO (.join) lub przerwać
wykonywaną pracę (.interrupt).
trait Fiber[E, A] {
def join: IO[E, A]
def interrupt[E2](t: Throwable): IO[E2, Unit]
}
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.
final class BadData(data: Data) extends Throwable with NoStackTrace
for {
fiber1 <- analysis(data).fork
fiber2 <- validate(data).fork
valid <- fiber2.join
_ <- if (!valid) fiber1.interrupt(BadData(data))
else IO.unit
result <- fiber1.join
} yield result
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.
final class Promise[E, A] private (ref: AtomicReference[State[E, A]]) {
def complete[E2](a: A): IO[E2, Boolean] = ...
def error[E2](e: E): IO[E2, Boolean] = ...
def get: IO[E, A] = ...
// interrupts all listeners
def interrupt[E2](t: Throwable): IO[E2, Boolean] = ...
}
object Promise {
def make[E, A]: IO[E, Promise[E, A]] = ...
}
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ą.
final class IORef[A] private (ref: AtomicReference[A]) {
def read[E]: IO[E, A] = ...
// write with immediate consistency guarantees
def write[E](a: A): IO[E, Unit] = ...
// write with eventual consistency guarantees
def writeLater[E](a: A): IO[E, Unit] = ...
// return true if an immediate write succeeded, false if not (and abort)
def tryWrite[E](a: A): IO[E, Boolean] = ...
// atomic primitives for updating the value
def compareAndSet[E](prev: A, next: A): IO[E, Boolean] = ...
def modify[E](f: A => A): IO[E, A] = ...
def modifyFold[E, B](f: A => (B, A)): IO[E, B] = ...
}
object IORef {
def apply[E, A](a: A): IO[E, IORef[A]] = ...
}
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
final class StateTask[A](val io: Task[A]) extends AnyVal
object StateTask {
def create[S](initial: S): Task[MonadState[StateTask, S]] =
for {
ref <- IORef(initial)
} yield
new MonadState[StateTask, S] {
override def get = new StateTask(ref.read)
override def put(s: S) = new StateTask(ref.write(s))
...
}
}
Możemy wykorzystać tak zoptymalizowaną implementację MonadState w SafeApp, gdy nasz .program
wymaga instancji tej typeklasy:
object FastState extends SafeApp {
def program[F[_]](implicit F: MonadState[F, Int]): F[ExitStatus] = ...
def run(@unused args: List[String]): IO[Void, ExitStatus] =
for {
stateMonad <- StateTask.create(10)
output <- program(stateMonad).io
} yield output
}
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:
trait MonadIO[M[_], E] {
def liftIO[A](io: IO[E, A])(implicit M: Monad[M]): M[A]
}
wraz z drobną różnicą w boilerplacie kompaniującym naszej algebrze, uwzględniają dodatkowe E:
trait Lookup[F[_]] {
def look: F[Int]
}
object Lookup {
def liftIO[F[_]: Monad, E](io: Lookup[IO[E, ?]])(implicit M: MonadIO[F, E]) =
new Lookup[F] {
def look: F[Int] = M.liftIO(io.look)
}
...
}
7.8 Podsumowanie
- Typ
Futurejest 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
Freepozwala nam analizować, optymalizować i łatwo testować nasze programy. -
IOumożliwia implementowanie algebr jako efektów wykonywanych na świecie zewnętrznym. -
IOmoże wykonywać efekty równolegle i jest wysoce wydajnym fundamentem dla dowolnej aplikacji.