Programowanie Funkcyjne dla Śmiertelników ze Scalaz
Programowanie Funkcyjne dla Śmiertelników ze Scalaz
Buy on Leanpub

Spis treści

“Miłość jest mądra, nienawiść jest głupia. W tym świecie, który staje się coraz bardziej połączony wewnętrznie, musimy nauczyć się tolerować się wzajemnie. Musimy nauczyć się znosić to, że niektórzy ludzie mówią rzeczy, które nam się nie podobają. Tylko w ten sposób możemy żyć razem. Ale jeśli mamy razem żyć, ale nie umierać, musimy nauczyć się dobroci i tolerancji, które są absolutnie niezbędne do przetrwania ludzi na tej planecie.”

― Bertrand Russell

O niniejszej książce

Książka ta jest kierowana do zwykłego programisty Scali, prawdopodobnie z doświadczeniem Javowym, który zaciekawiony jest paradygmatem Programowania Funkcyjnego. Każdy koncept, który przedstawiamy, uzasadniany jest praktycznym przykładem, a cała książka zwieńczona jest stworzeniem w pełni funkcjonalnej aplikacji webowej.

Niniejsza książka używa Scalaz 7.2, najpopularniejszej, najstabilniejszej, najbardziej pryncypialnej1 i najbardziej kompleksowej biblioteki do Programowania Funkcyjnego w Scali.

Książka ta została napisana z myślą o czytaniu jej od deski do deski, w zaprezentowanej kolejności i z przerwami na odpoczynek między rozdziałami. Początkowe rozdziały zachęcają do programowania w sposób, który w później zdyskredytujemy: podobnie jak uczymy się teorii grawitacji Newtona jako dzieci, aby później przejść do Riemanna / Einsteina / Maxwella, jeśli zechcemy studiować fizykę.

Komputer nie jest niezbędny, aby podążać za treścią, ale zachęcamy do zgłębienia kodu źródłowego Scalaz. Niektóre z bardziej skomplikowanych fragmentów kodu są dostępne wraz ze źródłami tej książki, a ci, którzy żądni są praktycznych ćwiczeń, powinni spróbować (zre)implementować Scalaz (oraz naszą przykładową aplikację) używając częściowych opisów, które zaprezentujemy.

Jako kolejny krok polecamy również Czerwoną Książkę2. Pokazuje ona jak stworzyć bibliotekę wspierającą programowanie funkcyjne, taką jak na przykład Scalaz, od zupełnych podstaw.

Nota lewa autorskiego3

Ta książka jest Wolna4 i podąża za filozofią Wolnego Oprogramowania: możesz używać jej, jak tylko chcesz, źródła są dostępne, możesz ją redystrybuować oraz dystrybuować swoją własną wersję. Oznacza to że możesz ja wydrukować, fotokopiować, wysyłać mailem, uploadować na strony internetowe, zmieniać, tłumaczyć, pobierać opłaty, remiksować, usuwać kawałki i malować po dowolnych fragmentach.

Ta książka jest objęta Lewem autorskim: jeśli ją zmienisz i udostępnisz swoją własną wersję, musisz również przekazać wspomniane uprawnienia swoim odbiorcom.

Książka ta używa licencji Creative Commons Attribution ShareAlike 4.0 International (CC BY-SA 4.0).

Wszystkie oryginalne fragmenty kodu są osobno licencjonowane na podstawie CC0, co oznacza, że możesz używać ich bez żadnych ograniczeń. Fragmenty ze Scalaz i pokrewnych bibliotek zachowują swoje licencje, powtórzone w pełni w załączniku.

Przykładowa aplikacja drone-dynamic-agents udostępniona jest na zasadach GPLv3, a jedynie fragmenty zawarte w książce dostępne są bez ograniczeń.

Podziękowania

Dla Diego Estebana Alonso Blasa, Raúla Raja Martíneza i Petera Neyensa z 47 degrees, Rúnara Bjarnasona, Tonego Morrisa, Johna de Goes i Edwarda Kmetta za ich pomoc w tłumaczeniu zasad FP. Kenji Yoshidzie i Jasonowi Zauggowi za stworzenie pierwszej wersji Scalaz, oraz Paulowi Chiusano / Milesowi Sabinowi za naprawienie krytycznego błędu w kompilatorze Scali (SI-2712).

Dziękuje też czytelnikom, którzy doradzali przy pierwszych szkicach tej książki.

Niektóre materiały były szczególnie pomocne dla mojego własnego zrozumienia konceptów opisanych w tej książce. Podziękowania dla Juana Manuela Serrano za All Roads Lead to Lambda, Pere’a Villega za On Free Monads, Dicka Walla oraz Josha Sueretha za For: What is it Good For?, Erika Bakkera za Options in Futures, how to unsuck them, Noela Markhama za ADTs for the Win!, Sukanta Hajra za Classy Monad Transformers, Luki Jacobowitz za Optimizing Tagless Final, Vincenta Marqueza za Index your State, Gabriela Gonzaleza za The Continuation Monad, oraz Yi Lin Wei / Zainab Ali za ich tutoriale w trakcie spotkań Hack The Tower.

Pomocne dusze, które cierpliwe tłumaczyły mi kolejne koncepty: Merlin Göttlinger, Edmund Noble, Fabio Labella, Adelbert Chang, Michael Pilquist, Paul Snively, Daniel Spiewak, Stephen Compall, Brian McKenna, Ryan Delucchi, Pedro Rodriguez, Emily Pillmore, Aaron Vargo, Tomas Mikula, Jean-Baptiste Giraudeau, Itamar Ravid, Ross A. Baker, Alexander Konovalov, Harrison Houghton, Alexandre Archambault, Christopher Davenport, Jose Cardona, Isaac Elliott.

Nota tłumacza

Angielski stał się de facto uniwersalnym językiem programistów, a zarówno dostępność, jak i konsumpcja treści technicznych w innych językach jest bardzo niewielka. Niesie to ze sobą implikacje również i dla tej książki oraz jej tłumaczenia. Przy tłumaczeniu staraliśmy się brać pod uwagę, że wszelkie kroki po przeczytaniu tej książki, takie jak wyszukiwanie dodatkowych materiałów, zadawanie pytań lub zwykła komunikacja z innymi programistami, najprawdopodobniej odbywać się będą z użyciem języka angielskiego. Dlatego też staraliśmy się używać tłumaczeń jak najbardziej zbliżonych do wersji oryginalnych, co poskutkowało tym, że duża ich część może brzmieć śmiesznie, dziwnie lub nawet absurdalnie. W wielu miejscach używamy form potocznych i słowotwórstwa, które nie znalazłyby miejsca w poważnej literaturze technicznej. Co więcej, w momencie, gdy wprowadzamy tłumaczenie, dla którego odtworzenie wersji oryginalnej może być nieoczywiste, podajemy tę wersję wprost, w nawiasach bądź przypisach.

Pamiętajmy również, że jest to tłumaczenie amatorskie, motywowane jedynie chęcią popularyzacji Scali i programowania funkcyjnego, a nie zyskiem, dlatego też w wielu miejscach może ono odbiegać od profesjonalnych standardów. Z kolei użycie liczby mnogiej w tej nocie i późniejszych przypisach, wynika z kolei jedynie z megalomanii tłumacza, a nie z faktu, że była to praca zespołu profesjonalistów.

Chcielibyśmy również uprzedzić, że część tłumaczeń może nie pokrywać się (z różnych powodów) z tłumaczeniami tych samych terminów wprowadzonymi przez innych autorów. Wynika to z 2 rzeczy. Po pierwsze, jak wspomniano wyżej, wierzymy, że to wersje oryginalne są najistotniejsze, a polskie tłumaczenie jest tutaj jedynie pomocą obniżającą próg wejścia. Po drugie, jak również wspomniano powyżej, jest to tłumaczenie realizowane minimalnym wysiłkiem i bez zasobów potrzebnych na analizę wcześniejszych prac i tłumaczeń. Mimo to mamy nadzieję, że lektura sprawi wam przyjemność.

Aspekty praktyczne

Aby skonfigurować projekt używający bibliotek prezentowanych w tej książce, użyjemy aktualnej wersji Scali wraz z opcjami specyficznymi dla Programowania Funkcyjnego. Oto build.sbt:

  scalaVersion in ThisBuild := "2.12.6"
  scalacOptions in ThisBuild ++= Seq(
    "-language:_",
    "-Ypartial-unification",
    "-Xfatal-warnings"
  )
  
  libraryDependencies ++= Seq(
    "com.github.mpilquist" %% "simulacrum"     % "0.13.0",
    "org.scalaz"           %% "scalaz-core"    % "7.2.26"
  )
  
  addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.9.7")
  addCompilerPlugin("org.scalamacros" % "paradise" % "2.1.1" cross CrossVersion.full)

Aby fragmenty kodu były krótkie, ominiemy sekcje importów i jeśli nie zaznaczono inaczej, to należy przyjąć, że wszystkie fragmenty zawierają:

  import scalaz._, Scalaz._
  import simulacrum._

1. Wprowadzenie

To normalne, że do nowego paradygmatu podchodzimy sceptycznie. Aby zarysować nieco drogę, jaką już przeszliśmy i zmiany, jakie udało nam się zaakceptować w JVMie, zacznijmy od szybkiej powtórki z ostatnich 20 lat tej platformy.

Java 1.2 wprowadziła Collections API, pozwalające nam pisać metody mogące abstrahować nad5 mutowalnymi kolekcjami. Była to zmiana pomocna w pisaniu algorytmów ogólnego przeznaczenia, która stała się podwaliną dla wielu systemów i bibliotek.

Niestety, API to miało jeden problem, zmuszało nas do rzutowania w czasie wykonania (runtime casting):

  public String first(Collection collection) {
    return (String)(collection.get(0));
  }

W odpowiedzi na ten problem deweloperzy definiowali obiekty domenowe, które przyjmowały formę CollcetionOfThing, czyli były kolekcjami konkretnych typów, z ich własnym silnie typowanym interfejsem, a Collection API stało się jedynie szczegółem implementacyjnym.

W 2005 Java 5 wprowadziła typy generyczne (generics), pozwalające nam definiować Collection<Thing>, abstrahując nad konkretną kolekcją oraz typem jej elementów. Typy generyczne po raz kolejny zmieniły sposób, w jaki pisaliśmy kod w Javie.

Autor Javowego kompilatora typów generycznych, Martin Odersky, niedługo później stworzył Scalę, język z silniejszym systemem typów, niemutowalnymi kolekcjami oraz wielokrotnym dziedziczeniem. Wprowadziło to fuzję pomiędzy programowaniem obiektowym (OOP) oraz programowaniem funkcyjnym (FP).

Dla większości programistów FP oznacza używanie niemutowalnych struktur danych tak często jak to możliwe, ale mutowalny stan jest nadal złem koniecznym, które musi być wyizolowane i zarządzane, np. przy użyciu aktorów z Akki lub klas używających synchronized. Ten styl FP skutkuje prostszymi programami, które łatwiej zrównoleglić i rozproszyć, stawiając zdecydowany krok naprzód względem Javy. Jest to jednak niewielka część zalet i korzyści płynących z programowa funkcyjnego, które odkryjemy w tej książce.

Scala wprowadza również typ Future, sprawiając, że pisanie aplikacji asynchronicznych staje się dużo łatwiejsze. Jednak gdy tylko Future pojawi się w typie zwracanym z funkcji, wszystko musi zostać przepisane i dostosowane, wliczając testy, które teraz narażone są na arbitralne timeouty6.

Mamy więc problem podobny to tego z Javy 1.0: brakuje nam możliwości abstrahowania nad wykonaniem programu, tak samo, jak brakowało nam możliwość abstrahowania nad używanymi kolekcjami.

1.1 Abstrahowanie nad wykonaniem

Wyobraźmy sobie, że chcemy komunikować się z użytkownikiem poprzez wiersz poleceń. Możemy czytać (.read), to co użytkownik napisał i pisać (.write) wiadomości, które będzie mógł przeczytać.

  trait TerminalSync {
    def read(): String
    def write(t: String): Unit
  }
  
  trait TerminalAsync {
    def read(): Future[String]
    def write(t: String): Future[Unit]
  }

Jak możemy napisać generyczny kod, który zrobi coś tak prostego, jak powtórzenie (echo) wiadomości wpisanej przez użytkownika w sposób synchroniczny bądź asynchroniczny w zależności od naszego środowiska uruchomieniowego?

Moglibyśmy napisać wersję synchroniczną i owinąć ją typem Future, ale zmusiłoby nas to do zdecydowania, jakiej puli wątków powinniśmy użyć. Alternatywnie moglibyśmy zawołać Await.result na Future i wprowadzić tym samym blokowanie wątku. W obu przypadkach sprowadza się to do napisanie dużej ilości boilerplate’u i utrzymywania dwóch różnych API, które nie są w żaden sposób zunifikowane.

Możemy też rozwiązać ten problem, podobnie jak w Javie 1.2, używając wspólnego interfejsu bazującego na typach wyższego rodzaju (higher kinded types, HKT) dostępnych w Scali.

Chcielibyśmy zdefiniować Terminal dla dowolnego konstruktora typu C[_]. Poprzez zdefiniowanie Now jako równoznacznego ze swoim parametrem (analogicznie do Id), możemy zaimplementować ten wspólny interfejs dla terminali synchronicznych i asynchronicznych:

  trait Terminal[C[_]] {
    def read: C[String]
    def write(t: String): C[Unit]
  }
  
  type Now[X] = X
  
  object TerminalSync extends Terminal[Now] {
    def read: String = ???
    def write(t: String): Unit = ???
  }
  
  object TerminalAsync extends Terminal[Future] {
    def read: Future[String] = ???
    def write(t: String): Future[Unit] = ???
  }

Niestety nie wiemy nic o C i nic nie jesteśmy w stanie zrobić z C[String]. Potrzebujemy środowiska wykonania (execution environment), które pozwoli nam zawołać metodę zwracającą C[T] a później zrobić coś z T, np. zawołać kolejną metodę z interfejsu Terminal. Potrzebujemy również możliwości owinięcia prostej wartości typem C[_]. Poniższa sygnatura dobrze spełnia nasze wymagania:

  trait Execution[C[_]] {
    def chain[A, B](c: C[A])(f: A => C[B]): C[B]
    def create[B](b: B): C[B]
  }

pozwalając nam na napisanie:

  def echo[C[_]](t: Terminal[C], e: Execution[C]): C[String] =
    e.chain(t.read) { in: String =>
      e.chain(t.write(in)) { _: Unit =>
        e.create(in)
      }
    }

Możemy teraz współdzielić implementację echo pomiędzy synchroniczną i asynchroniczną wersją naszego programu. Możemy napisać sztuczną (mock) implementację Terminal[Now] i użyć jej w naszych testach beż zagrożenia ze strony timeoutów.

Implementacje Execution[Now] oraz Execution[Future] mogą być reużywane przez generyczne metody takie jak echo.

Ale kod implementujący echo jest okropny!

Używając mechanizmu implicit class w Scali, możemy dodać metody do C. Nazwijmy te metody flatMap i map z powodów, które staną się jasne już niedługo. Każda z metod przyjmuje implicit Execution[C], ale oprócz tego są to dokładnie takie same flatMap i map, jakie znamy z typów Seq, Option czy Future.

  object Execution {
    implicit class Ops[A, C[_]](c: C[A]) {
      def flatMap[B](f: A => C[B])(implicit e: Execution[C]): C[B] =
            e.chain(c)(f)
      def map[B](f: A => B)(implicit e: Execution[C]): C[B] =
            e.chain(c)(f andThen e.create)
    }
  }
  
  def echo[C[_]](implicit t: Terminal[C], e: Execution[C]): C[String] =
    t.read.flatMap { in: String =>
      t.write(in).map { _: Unit =>
        in
      }
    }

Możemy teraz zdradzić, dlaczego użyliśmy flatMap jako nazwy metody: pozwala nam to używać for comprehension, czyli lepszej składni dla zagnieżdżonych wywołań flatMap i map.

  def echo[C[_]](implicit t: Terminal[C], e: Execution[C]): C[String] =
    for {
      in <- t.read
       _ <- t.write(in)
    } yield in

Nasze Execution ma taką samą sygnaturę jak trait w Scalaz zwany Monad, z ta różnicą, że chain to bind and create to pure. Mówimy, że C jest monadyczne (monadic), gdy dostępna jest niejawna (implicit) instancja Monad[C]. Dodatkowo Scalaz definiuje również alias typu Id.

Podsumowując: jeśli piszemy metody operujące na typach monadycznych, wówczas możemy pisać sekwencyjny kod, który abstrahuje nad swoim środowiskiem wykonania. Pokazaliśmy jak zrobić to dla synchronicznego i asynchronicznego wykonania programu, ale tej samej techniki możemy użyć dla innych kontekstów, np. statycznie deklarowanych błędów (gdzie C[_] stanie się Either[Error, _]), zarządzania dostępem do ulotnego stanu aplikacji (volatile state), wykonywania operacji wejścia/wyjścia albo audytowalnej sesji.

1.2 Programowanie czysto funkcyjne7

Programowanie Funkcyjne to akt tworzenia programów przy użyciu czystych funkcji (pure functions). Czyste funkcje mają trzy właściwości:

  • Totalność: zwracają wartość dla każdego możliwego argumentu (total)
  • Deterministyczność: za każdym razem zwracają tę samą wartość dla tego samego argumentu (deterministic)
  • Niewinność: brak (bezpośrednich) interakcji ze światem lub wewnętrznym stanem programu (inculpable)

Razem te właściwości dają nam bezprecedensową zdolność do rozumowania o naszym kodzie. Na przykład, walidacja wejścia jest łatwiejsza do wyizolowania z totalnością, caching jest możliwy, gdy funkcje są deterministyczne, a interagowanie ze światem jest łatwiejsze do kontrolowania i testowania, gdy funkcje są niewinne.

Rzeczy, które łamią te właściwości, nazywamy efektami ubocznymi (side effects): bezpośredni dostęp lub zmiana mutowalnego stanu aplikacji (np. var wewnątrz klasy), komunikowanie się z zewnętrznymi zasobami (np. plikami lub siecią) lub rzucanie i łapanie wyjątków.

Piszemy czyste funkcje przez unikanie wyjątków i komunikowanie się ze światem jedynie poprzez bezpieczny kontekst wywołania F[_].

W poprzedniej sekcji abstrahowaliśmy nad wykonaniem programu i definiowaliśmy echo[Id] oraz echo[Future]. Możemy oczekiwać, że wywołanie jakiegokolwiek echo nie spowoduje żadnych efektów ubocznych, ponieważ jest to czysta funkcja. Jednak jeśli używamy Future lub Id jako kontekstu wykonania, nasza aplikacja zacznie nasłuchiwać na standardowym strumieniu wejścia (stdin):

  val futureEcho: Future[String] = echo[Future]

Zaburzyliśmy czystość i tym samym nie piszemy już kodu funkcyjnego: futureEcho jest rezultatem wykonania echo jeden raz. Future łączy definicję programu z jego interpretacją (uruchomieniem). Tym samym, trudnym staje się rozumowanie o aplikacjach zbudowanych przy użyciu Future.

Możemy zdefiniować prosty i bezpieczny kontekst wykonania F[_]

  final class IO[A](val interpret: () => A) {
    def map[B](f: A => B): IO[B] = IO(f(interpret()))
    def flatMap[B](f: A => IO[B]): IO[B] = IO(f(interpret()).interpret())
  }
  object IO {
    def apply[A](a: =>A): IO[A] = new IO(() => a)
  }

który leniwie wykonuje thunk8. IO jest zwyczajną strukturą danych, która zawiera kawałek (potencjalnie) nieczystego kodu, ale go nie wykonuje. Możemy zaimplementować Terminal[IO]

  object TerminalIO extends Terminal[IO] {
    def read: IO[String]           = IO { io.StdIn.readLine }
    def write(t: String): IO[Unit] = IO { println(t) }
  }

i wywołać echo[IO] aby dostać z powrotem wartość

  val delayed: IO[String] = echo[IO]

Taka wartość delayed może być reużywana, gdyż jest to tylko definicja pracy, która musi zostać wykonana. Możemy przemapować String i tworzyć kolejne programy, podobnie jak mapowalibyśmy Future. IO pozwala nam zachować szczerość co do tego, że zależymy od pewnych interakcji ze światem zewnętrznym, ale nie pozbawia nas dostępu do wyniku tej interakcji.

Nieczysty kod wewnątrz IO jest ewaluowany, kiedy wywołamy .interpret() na zwróconej wartości. Wywołanie to jest oczywiście również nieczystą akcją

  delayed.interpret()

Aplikacja złożona z programów opartych o IO jest interpretowana jedynie raz, wewnątrz metody main, która jest również zwana końcem świata (the end of the world).

W następnych rozdziałach rozszerzymy wprowadzone tutaj koncepcje i pokażemy jak pisać utrzymywalne czyste funkcje, które rozwiązują stawiane przed nimi problemy.

2. Konstrukcja for

for comprehension to idealna abstrakcja do pisania funkcyjnych programów komunikujących się ze światem. Ponieważ będziemy używać jej bardzo często, poświęcimy chwilę na przypomnienie sobie zasad jej działania, a przy okazji zobaczymy, jak Scalaz może pomóc nam pisać czystszy kod.

Ten rozdział nie skupia się na programowaniu czysto funkcyjnym, a techniki w nim opisane znajdą zastosowanie również w zwykłych aplikacjach.

2.1 Wzbogacona składnia

Konstrukcja for w Scali jest prostą regułę przepisania (rewrite rule), zwaną również cukrem składniowym (syntactic sugar), i nie wnosi żadnych dodatkowych informacji do naszego programu.

Aby zobaczyć, co tak naprawdę robi for, użyjemy funkcji show i reify dostępnych w REPLu. Dzięki nim możemy wypisać kod w formie, jaką przyjmuje po inferencji typów (type inference).

  scala> import scala.reflect.runtime.universe._
  scala> val a, b, c = Option(1)
  scala> show { reify {
           for { i <- a ; j <- b ; k <- c } yield (i + j + k)
         } }
  
  res:
  $read.a.flatMap(
    ((i) => $read.b.flatMap(
      ((j) => $read.c.map(
        ((k) => i.$plus(j).$plus(k)))))))

Widzimy dużo szumu spowodowanego dodatkowymi wzbogaceniami (np. + jest przepisany jako $plus itp.). Dla zwiększenia zwięzłości w dalszych przykładach pominiemy wywołania show oraz reify, kiedy linia rozpoczyna się od reify>. Dodatkowo generowany kod poddamy ręcznemu oczyszczeniu, aby nie rozpraszać czytelnika.

  reify> for { i <- a ; j <- b ; k <- c } yield (i + j + k)
  
  a.flatMap {
    i => b.flatMap {
      j => c.map {
        k => i + j + k }}}

Zasadą kciuka jest, że każdy <- (zwany generatorem) jest równoznaczny z zagnieżdżonym wywołaniem flatMap, z wyjątkiem ostatniego, który jest wywołaniem funkcji map, do której przekazane zostaje ciało bloku yield.

2.1.1 Przypisania

Możemy bezpośrednio przypisywać wartości do zmiennych za pomocą wyrażeń typu ij = i + j (słowo kluczowe val nie jest wymagane).

  reify> for {
           i <- a
           j <- b
           ij = i + j
           k <- c
         } yield (ij + k)
  
  a.flatMap {
    i => b.map { j => (j, i + j) }.flatMap {
      case (j, ij) => c.map {
        k => ij + k }}}

Wywołanie map na b wprowadza zmienną ij, która jest flat-mapowana razem z j, a na końcu wołane jest ostateczne map wykorzystujące kod z bloku yield.

Niestety nie możemy deklarować przypisań przed użyciem generatora. Funkcjonalność ta została zasugerowana, ale nie została jeszcze zaimplementowana: https://github.com/scala/bug/issues/907

  scala> for {
           initial = getDefault
           i <- a
         } yield initial + i
  <console>:1: error: '<-' expected but '=' found.

Możemy obejść to ograniczenie poprzez zadeklarowanie zmiennej poza konstrukcją for

  scala> val initial = getDefault
  scala> for { i <- a } yield initial + i

lub poprzez stworzenie Option z pierwotnej wartości

  scala> for {
           initial <- Option(getDefault)
           i <- a
         } yield initial + i

2.1.2 Filtrowanie

Możemy umieścić wyrażenie if za generatorem, aby ograniczyć wartości za pomocą predykatu

  reify> for {
           i  <- a
           j  <- b
           if i > j
           k  <- c
         } yield (i + j + k)
  
  a.flatMap {
    i => b.withFilter {
      j => i > j }.flatMap {
        j => c.map {
          k => i + j + k }}}

Starsze wersje Scali używały metody filter, ale ponieważ Traversable.filter tworzy nową kolekcję dla każdego predykatu, wprowadzono metodę withFilter jako bardziej wydajną alternatywę. Musimy uważać, aby przypadkowo nie wywołać withFilter, podając informację co do oczekiwanego typu, którą kompilator interpretuje jako pattern matching.

  reify> for { i: Int <- a } yield i
  
  a.withFilter {
    case i: Int => true
    case _      => false
  }.map { case i: Int => i }

Podobnie jak w przypadku przypisywania wartości do zmiennych, generatory mogą używać pattern matchingu po swojej lewej stronie. W przeciwieństwie do przypisań (które rzucają MatchError w przypadku niepowodzenia), generatory są filtrowane i nie rzucają wyjątków w czasie wykonania. Niestety dzieje się to kosztem podwójnego zaaplikowania wzoru.

2.1.3 For Each

Jeśli nie użyjemy słowa yield, kompilator użyje metody foreach zamiast flatMap, co ma sens jedynie w obecności efektów ubocznych.

  reify> for { i <- a ; j <- b } println(s"$i $j")
  
  a.foreach { i => b.foreach { j => println(s"$i $j") } }

2.1.4 Podsumowanie

Pełen zbiór metod używanych przez konstrukcję for nie ma jednego wspólnego interfejsu, a każde użycie jest niezależnie kompilowane. Gdyby taki interfejs istniał, wyglądałby mniej więcej tak:

  trait ForComprehensible[C[_]] {
    def map[A, B](f: A => B): C[B]
    def flatMap[A, B](f: A => C[B]): C[B]
    def withFilter[A](p: A => Boolean): C[A]
    def foreach[A](f: A => Unit): Unit
  }

Jeśli kontekst (C[_]) konstrukcji for nie dostarcza swoich własnych metod map i flatMap to nie wszystko jeszcze stracone. Jeśli dostępna jest niejawna instancja typu scalaz.Bind[C] to dostarczy ona potrzebne metody map oraz flatMap.

2.2 Obsługa błędów

Jak dotąd patrzyliśmy jedynie na reguły przepisywania, nie natomiast na to, co dzieje się wewnątrz metod map i flatMap. Zastanówmy się co dzieje się, kiedy kontekst for zadecyduje, że nie może kontynuować działania.

W przykładzie bazującym na typie Option blok yield wywoływany jest jedynie kiedy wartości wszystkich zmiennych i, j, k są zdefiniowane.

  for {
    i <- a
    j <- b
    k <- c
  } yield (i + j + k)

Jeśli którakolwiek ze zmiennych a, b, c przyjmie wartość None, konstrukcja for zrobi zwarcie9 i zwróci None, nie mówiąc nam co poszło nie tak.

Jeśli użyjemy typu Either, wtedy Left powodować będzie zwarcie konstrukcji for z dodatkową informacją, którą niesie w sobie. Rozwiązanie to jest zdecydowanie lepsze w przypadku raportowania błędów niż użycie typu Option:

  scala> val a = Right(1)
  scala> val b = Right(2)
  scala> val c: Either[String, Int] = Left("sorry, no c")
  scala> for { i <- a ; j <- b ; k <- c } yield (i + j + k)
  
  Left(sorry, no c)

Na koniec spójrzmy co stanie się z typem Future, który zawiedzie

  scala> import scala.concurrent._
  scala> import ExecutionContext.Implicits.global
  scala> for {
           i <- Future.failed[Int](new Throwable)
           j <- Future { println("hello") ; 1 }
         } yield (i + j)
  scala> Await.result(f, duration.Duration.Inf)
  caught java.lang.Throwable

Wartość Future, która wypisuje wiadomość do terminala, nie jest nigdy tworzona, ponieważ, tak jak w przypadku Option i Either, konstrukcja for zwiera obwód i zakańcza przetwarzanie.

Zwieranie obwodu w przypadku odejścia od oczekiwanej ścieżki przetwarzania jest ważnym i często spotykanym rozwiązaniem. Pamiętajmy, że konstrukcja for nie jest w stanie obsłużyć uprzątnięcia zasobów (resource cleanup), a więc nie ma możliwości wyrażenia zachowania podobnego do try/finally. Rozwiązanie to jest dobre, gdyż jasno deklaruje, że to kontekst (który zazwyczaj jest Monadą, jak zobaczymy później), a nie logika biznesowa, jest odpowiedzialny za obsługę błędów i uprzątnięcie zasobów.

2.3 Sztuczki

Chociaż łatwo jest przepisać prosty kod sekwencyjny przy pomocy konstrukcji for, czasami chcielibyśmy zrobić coś, co w praktyce wymaga mentalnych fikołków. Ten rozdział zbiera niektóre praktyczne przykłady i pokazuje jak sobie z nimi radzić.

2.3.1 Wyjście awaryjne

Powiedzmy, że wywołujemy metodę, która zwraca typ Option. Jeśli wywołanie to się nie powiedzie, chcielibyśmy użyć innej metody (i tak dalej i tak dalej), np. gdy używamy cache’a.

  def getFromRedis(s: String): Option[String]
  def getFromSql(s: String): Option[String]
  
  getFromRedis(key) orElse getFromSql(key)

Jeśli chcemy zrobić to samo poprzez asynchroniczną wersję tego samego API

  def getFromRedis(s: String): Future[Option[String]]
  def getFromSql(s: String): Future[Option[String]]

musimy uważać, aby nie spowodować zbędnych obliczeń, ponieważ

  for {
    cache <- getFromRedis(key)
    sql   <- getFromSql(key)
  } yield cache orElse sql

uruchomi oba zapytania. Możemy użyć pattern matchingu na pierwszym rezultacie, ale typ się nie zgadza

  for {
    cache <- getFromRedis(key)
    res   <- cache match {
               case Some(_) => cache !!! wrong type !!!
               case None    => getFromSql(key)
             }
  } yield res

Musimy stworzyć Future ze zmiennej cache.

  for {
    cache <- getFromRedis(key)
    res   <- cache match {
               case Some(_) => Future.successful(cache)
               case None    => getFromSql(key)
             }
  } yield res

Future.successful tworzy nową wartość typu Future, podobnie jak konstruktor typu Option lub List.

2.3.2 Wczesne wyjście

Powiedzmy, że znamy warunek, który pozwala nam szybciej zakończyć obliczenia z poprawną wartością.

Jeśli chcemy zakończyć je szybciej z błędem, standardowym sposobem na zrobienie tego w OOP10 jest rzucenie wyjątku

  def getA: Int = ...
  
  val a = getA
  require(a > 0, s"$a must be positive")
  a * 10

co można zapisać asynchronicznie jako

  def getA: Future[Int] = ...
  def error(msg: String): Future[Nothing] =
    Future.failed(new RuntimeException(msg))
  
  for {
    a <- getA
    b <- if (a <= 0) error(s"$a must be positive")
         else Future.successful(a)
  } yield b * 10

Lecz jeśli chcemy zakończyć obliczenia z poprawną wartością, prosty kod synchroniczny:

  def getB: Int = ...
  
  val a = getA
  if (a <= 0) 0
  else a * getB

przekłada się na zagnieżdżone konstrukcje for, gdy tylko nasze zależności staną się asynchroniczne:

  def getB: Future[Int] = ...
  
  for {
    a <- getA
    c <- if (a <= 0) Future.successful(0)
         else for { b <- getB } yield a * b
  } yield c

2.4 Łączenie kontekstów

Kontekst, którego używamy wewnątrz konstrukcji for, musi być niezmienny: nie możemy mieszać wielu różnych typów jak na przykład Future i Option.

  scala> def option: Option[Int] = ...
  scala> def future: Future[Int] = ...
  scala> for {
           a <- option
           b <- future
         } yield a * b
  <console>:23: error: type mismatch;
   found   : Future[Int]
   required: Option[?]
           b <- future
                ^

Nie ma nic, co pozwoliłoby nam mieszać dowolne dwa konteksty wewnątrz konstrukcji for, ponieważ nie da się zdefiniować znaczenia takiej operacji.

Jednak gdy mamy do czynienia z konkretnymi kontekstami intencja jest zazwyczaj oczywista, tymczasem kompilator nadal nie przyjmuje naszego kodu.

  scala> def getA: Future[Option[Int]] = ...
  scala> def getB: Future[Option[Int]] = ...
  scala> for {
           a <- getA
           b <- getB
         } yield a * b
                   ^
  <console>:30: error: value * is not a member of Option[Int]

Chcielibyśmy, aby konstrukcja for zajęła się zewnętrznym kontekstem i pozwoliła nam skupić się modyfikacji wartości wewnątrz instancji typu Option. Ukrywaniem zewnętrznego kontekstu zajmują się tzw. transformatory monad (monad transformers), a Scalaz dostarcza nam implementacje tychże dla typów Option i Either, nazywające się odpowiednio OptionT oraz EitherT.

Kontekst zewnętrzny może być dowolnym kontekstem, który sam w sobie kompatybilny jest z konstrukcją for, musi jedynie pozostać niezmienny.

Tworzymy instancję OptionT z każdego wywołania metody, zmieniając tym samym kontekst z Future[Option[_]] na OptionT[Future, _].

  scala> val result = for {
           a <- OptionT(getA)
           b <- OptionT(getB)
         } yield a * b
  result: OptionT[Future, Int] = OptionT(Future(<not completed>))

.run pozwala nam wrócić do oryginalnego kontekstu

  scala> result.run
  res: Future[Option[Int]] = Future(<not completed>)

Transformatory monad pozwalają nam mieszać wywołania funkcji zwracających Future[Option[_]] z funkcjami zwracającymi po prostu Future poprzez metodę .liftM[OptionT] (pochodzącą ze scalaz):

  scala> def getC: Future[Int] = ...
  scala> val result = for {
           a <- OptionT(getA)
           b <- OptionT(getB)
           c <- getC.liftM[OptionT]
         } yield a * b / c
  result: OptionT[Future, Int] = OptionT(Future(<not completed>))

Dodatkowo możemy mieszać wartości typu Option poprzez wywołanie Future.successful (lub .pure[Future]), a następnie OptionT.

  scala> def getD: Option[Int] = ...
  scala> val result = for {
           a <- OptionT(getA)
           b <- OptionT(getB)
           c <- getC.liftM[OptionT]
           d <- OptionT(getD.pure[Future])
         } yield (a * b) / (c * d)
  result: OptionT[Future, Int] = OptionT(Future(<not completed>))

Znów zrobił się mały bałagan, ale i tak jest lepiej niż gdybyśmy ręcznie pisali zagnieżdżone wywołania metod flatMap oraz map. Możemy nieco uprzątnąć za pomocą DSLa który obsłuży wszystkie wymagane konwersje

  def liftFutureOption[A](f: Future[Option[A]]) = OptionT(f)
  def liftFuture[A](f: Future[A]) = f.liftM[OptionT]
  def liftOption[A](o: Option[A]) = OptionT(o.pure[Future])
  def lift[A](a: A)               = liftOption(Option(a))

w połączeniu z operatorem |>, który aplikuje funkcje podaną po prawej stronie na argumencie podanym z lewej strony, możemy wizualnie oddzielić logikę od transformacji.

  scala> val result = for {
           a <- getA       |> liftFutureOption
           b <- getB       |> liftFutureOption
           c <- getC       |> liftFuture
           d <- getD       |> liftOption
           e <- 10         |> lift
         } yield e * (a * b) / (c * d)
  result: OptionT[Future, Int] = OptionT(Future(<not completed>))

To podejście działa również dla Either i innych typów danych, ale w ich przypadku metody pomocnicze są bardziej skomplikowane i wymagają dodatkowy parametrów. Scalaz dostarcza wiele transformatorów monad dla typów, które definiuje, więc zawsze warto sprawdzić, czy ten, którego potrzebujemy jest dostępny.

3. Projektowanie aplikacji

W tym rozdziale napiszemy logikę biznesową oraz testy dla czysto funkcyjnej aplikacji serwerowej. Kod źródłowy tej aplikacji dostępny jest wraz ze źródłami tej książki w katalogu example. Nie mniej lepiej nie zagłębiać się w niego, zanim nie dotrzemy do ostatniego rozdziału, gdyż wraz z poznawaniem technik FP będziemy go istotnie zmieniać.

3.1 Specyfikacja

Nasza aplikacja będzie zarządzać farmą serwerów, tworzoną na bazie zapotrzebowania i operującą z możliwie niskim budżetem. Będzie ona nasłuchiwać wiadomości od serwera CI Drone i uruchamiać agenty (maszyny robocze) używając Google Container Engine (GKE), tak aby zaspokoić potrzeby kolejki zadań.

Drone otrzymuje pracę do wykonania kiedy kontrybutor zgłasza pull request w obsługiwanym projekcie na githubie. Drone przydziela pracę swoim agentom, gdzie każdy z nich przetwarza jedno zadanie w danym momencie.

Zadaniem naszej aplikacji jest zagwarantować, że zawsze jest dość agentów, aby wykonać potrzebną pracę, jednocześnie dbając, aby ich liczba nie przekroczyła określonej granicy i minimalizując całkowite koszta. Aby tego dokonać potrzebna będzie liczba elementów w kolejce i liczba dostępnych agentów.

Google potrafi tworzyć węzły (nodes), każdy z nich może być gospodarzem dla wielu agentów równocześnie. Agent podczas startu rejestruje się w serwerze, który od tej pory kontroluje jego cykl życia (wliczając cykliczne weryfikowanie czy agent jest nadal aktywny).

GKE pobiera opłatę za każdą minutę działania węzła, zaokrąglając czas do najbliższej godziny. Aby osiągnąć maksymalną efektywność, nie możemy po prostu tworzyć nowych węzłów dla każdego zadania. Zamiast tego powinniśmy reużywać wcześniej stworzone węzły i utrzymywać je do 58 minuty ich działania.

Nasza aplikacja musi być w stanie uruchamiać i zatrzymywać węzły, sprawdzać ich status (np. czas działania, aktywność) oraz wiedzieć, jaki jest aktualny czas wg GKE.

Dodatkowo, nie jest dostępne żadne API, które pozwoliłoby rozmawiać bezpośrednio z danym agentem, tak więc nie wiemy, czy aktualnie wykonuje on jakąś pracę dla serwera. Jeśli przypadkowo zatrzymamy agenta w czasie wykonywania pracy, jest to niewygodne, gdyż wymaga ludzkiej interakcji i ponownego rozpoczęcia zadania.

Kontrybutorzy mogą ręcznie dodawać agentów do farmy, tak więc liczba agentów i węzłów może być różna. Nie musimy dodawać węzłów, jeśli dostępni są wolni agenci.

W przypadku awarii powinniśmy zawsze wybierać najtańszą opcję.

Zarówno Drone, jak i GKE udostępniają JSONowe REST API zabezpieczone OAuth 2.0.

3.2 Interfejsy i algebry

Spróbujmy teraz skodyfikować diagram architektury z poprzedniego rozdziału. Po pierwsze powinniśmy zdefiniować prosty typ danych do przechowywania znacznika czasu z dokładnością do milisekund. Niestety typ taki nie jest dostępny ani w bibliotece standardowej Javy ani Scali.

  import scala.concurrent.duration._
  
  final case class Epoch(millis: Long) extends AnyVal {
    def +(d: FiniteDuration): Epoch = Epoch(millis + d.toMillis)
    def -(e: Epoch): FiniteDuration = (millis - e.millis).millis
  }

W FP algebra zajmuje miejsce interfejsu z Javy lub zbioru poprawnych wiadomości obsługiwanych przez aktora z Akki. W tej właśnie warstwie definiujemy wszystkie operacje naszego systemu, które prowadzą do komunikacji ze światem zewnętrznym a tym samym do efektów ubocznych.

Istnieje ścisła więź między algebrami a logiką biznesową. Często przechodzić będziemy przez kolejne iteracje, w których próbujemy zamodelować nasz problem, następnie implementujemy rozwiązanie, tylko po to, aby przekonać się, że nasz model i zrozumienie problemu wcale nie było tak kompletne, jak nam się wydawało.

  trait Drone[F[_]] {
    def getBacklog: F[Int]
    def getAgents: F[Int]
  }
  
  final case class MachineNode(id: String)
  trait Machines[F[_]] {
    def getTime: F[Epoch]
    def getManaged: F[NonEmptyList[MachineNode]]
    def getAlive: F[Map[MachineNode, Epoch]]
    def start(node: MachineNode): F[MachineNode]
    def stop(node: MachineNode): F[MachineNode]
  }

Użyliśmy typu NonEmptyList, który można łatwo utworzyć, wywołując metodę .toNel na standardowej liście, co zwraca nam Option[NonEmptyList]. Poza tym wszystko powinno być jasne.

3.3 Logika biznesowa

Teraz przyszedł czas na napisanie logiki biznesowej, która definiuje zachowanie naszej aplikacji. Na razie rozpatrywać będziemy tylko szczęśliwy scenariusz (happy path).

Potrzebujemy klasy WorldView, która przechowywać będzie całość naszej wiedzy o świecie. Gdybyśmy projektowali naszą aplikację przy użyciu Akki, WorldView najprawdopodobniej zostałby zaimplementowany jako var wewnątrz stanowego aktora.

WorldView agreguje wartości zwracane przez wszystkie metody ze wcześniej zdefiniowanych algebr oraz dodaje pole pending, aby umożliwić śledzenie nieobsłużonych jeszcze żądań.

  final case class WorldView(
    backlog: Int,
    agents: Int,
    managed: NonEmptyList[MachineNode],
    alive: Map[MachineNode, Epoch],
    pending: Map[MachineNode, Epoch],
    time: Epoch
  )

Teraz prawie gotowi jesteśmy, aby zacząć pisać naszą logikę biznesową, ale musimy zadeklarować, że zależy ona od algebr Drone in Machines.

Możemy zacząć od interfejsu dla naszej logiki

  trait DynAgents[F[_]] {
    def initial: F[WorldView]
    def update(old: WorldView): F[WorldView]
    def act(world: WorldView): F[WorldView]
  }

i zaimplementować go za pomocą modułu. Moduł zależy wyłącznie od innych modułów, algebr i czystych funkcji oraz potrafi abstrahować nad F. Jeśli implementacja algebraicznego interfejsu zależy od konkretnego typu, np. IO, nazywamy ją interpreterem.

  final class DynAgentsModule[F[_]: Monad](D: Drone[F], M: Machines[F])
    extends DynAgents[F] {

Ograniczenie kontekstu (context bound) poprzez typ Monad oznacza, że F jest monadyczne, pozwalając nam tym samym na używanie metod map, pure, i oczywiście, flatmap wewnątrz konstrukcji for.

Mamy dostęp do algebr Drone i Machines poprzez D i M. Używanie pojedynczych wielkich liter jest popularną konwencją dla implementacji algebr i typeklas.

Nasza logika biznesowa działać będzie wewnątrz nieskończonej pętli, która może być zapisana jako pseudokod:

  state = initial()
  while True:
    state = update(state)
    state = act(state)

3.3.1 initial

Wewnątrz metody initial wywołujemy wszystkie zewnętrzne serwisy, a wyniki tych wywołań zapisujemy wewnątrz instancji WorldView. Pole pending domyślnie pozostaje puste.

  def initial: F[WorldView] = for {
    db <- D.getBacklog
    da <- D.getAgents
    mm <- M.getManaged
    ma <- M.getAlive
    mt <- M.getTime
  } yield WorldView(db, da, mm, ma, Map.empty, mt)

Przypomnij sobie, jak w Rozdziale 1 mówiliśmy, że flatMap (używany wraz z generatorem <-) pozwala nam operować na wartościach dostępnych w czasie wykonania. Kiedy zwracamy F[_] to tak naprawdę zwracamy kolejny program, który zostanie zinterpretowany w czasie wykonania. Na takim programie wywołujemy flatMap. Tak właśnie możemy sekwencyjnie łączyć kod, który powoduje efekty uboczne, jednocześnie mogąc używać zupełnie czystej (pozbawionej tychże efektów) implementacji w czasie testowania. FP może być przez to widziane jako Ekstremalne Mockowanie.

3.3.2 update

Metoda update powinna wywołać initial, aby odświeżyć nasz obraz świata, zachowując znane akcje, które oczekują na wywołanie (pole pending).

Jeśli węzeł zmienił swój stan, usuwamy go z listy oczekujących, a jeśli akcja trwa dłużej niż 10 minut, to zakładamy, że zakończyła się porażką i zapominamy, że ją zainicjowaliśmy.

  def update(old: WorldView): F[WorldView] = for {
    snap <- initial
    changed = symdiff(old.alive.keySet, snap.alive.keySet)
    pending = (old.pending -- changed).filterNot {
      case (_, started) => (snap.time - started) >= 10.minutes
    }
    update = snap.copy(pending = pending)
  } yield update
  
  private def symdiff[T](a: Set[T], b: Set[T]): Set[T] =
    (a union b) -- (a intersect b)

Konkretne funkcje takie jak .symdiff nie wymagają testowych interpreterów, ponieważ mają bezpośrednio wyrażone zarówno wejście, jak i wyjście. Możemy przenieść je do samodzielnego, bezstanowego obiektu, który można testować w izolacji i testować jedynie publiczne metody modułu.

3.3.3 act

Metoda act jest nieco bardziej skomplikowana, więc dla zwiększenia czytelności podzielimy ją na dwie części: wykrywanie akcji, które należy wykonać oraz wykonywanie tychże akcji. To uproszczenie sprawia, że możemy wykonać tylko jedną akcję per wywołanie, ale jest to całkiem rozsądne, biorąc pod uwagę, że dzięki temu możemy lepiej kontrolować wykonywane akcje oraz wywoływać act tak długo aż nie pozostanie żadna akcja do wykonania.

Wykrywanie konkretnych scenariuszy dzieje się poprzez ekstraktory bazujące na WorldView, co w praktyce jest po prostu bardziej ekspresywną formą warunków if / else.

Musimy dodać agentów do farmy, jeśli praca gromadzi się w kolejce oraz nie ma żadnych agentów, aktywnych węzłów ani akcji oczekujących na wykonanie. Jako wynik zwracamy węzeł, który chcielibyśmy uruchomić.

  private object NeedsAgent {
    def unapply(world: WorldView): Option[MachineNode] = world match {
      case WorldView(backlog, 0, managed, alive, pending, _)
           if backlog > 0 && alive.isEmpty && pending.isEmpty
             => Option(managed.head)
      case _ => None
    }
  }

Jeśli kolejka jest pusta, powinniśmy zatrzymać wszystkie nieaktywne (niewykonujące żadnych zadań) węzły. Pamiętając, że Google zawsze pobiera opłatę za pełne godziny, wyłączamy węzły jedynie w 58 minucie ich działania. Wynikiem jest lista węzłów do zatrzymania.

Jako zabezpieczenie finansowe zakładamy, że żaden węzeł nie może żyć dłużej niż 5 godzin.

  private object Stale {
    def unapply(world: WorldView): Option[NonEmptyList[MachineNode]] = world match {
      case WorldView(backlog, _, _, alive, pending, time) if alive.nonEmpty =>
        (alive -- pending.keys).collect {
          case (n, started) if backlog == 0 && (time - started).toMinutes % 60 >= 58 => n
          case (n, started) if (time - started) >= 5.hours => n
        }.toList.toNel
  
      case _ => None
    }
  }

Gdy już zdefiniowaliśmy scenariusze, które nas interesują, możemy przejść do implementacji metody act. Gdy chcemy aby, węzeł został uruchomiony lub zatrzymany, dodajemy go do listy pending wraz z zapisem czasu, w którym tę akcję zaplanowaliśmy.

  def act(world: WorldView): F[WorldView] = world match {
    case NeedsAgent(node) =>
      for {
        _ <- M.start(node)
        update = world.copy(pending = Map(node -> world.time))
      } yield update
  
    case Stale(nodes) =>
      nodes.foldLeftM(world) { (world, n) =>
        for {
          _ <- M.stop(n)
          update = world.copy(pending = world.pending + (n -> world.time))
        } yield update
      }
  
    case _ => world.pure[F]
  }

Ponieważ NeedsAgent i Stale nie pokrywają wszystkich możliwych sytuacji, musimy również zdefiniować zachowanie domyślne, które nie robi nic. Przypomnijmy z Rozdziału 2: .pure tworzy (monadyczny) kontekst używany wewnątrz for z prostej wartości.

foldLeftM działa podobnie do foldLeft, z tą różnicą, że przyjmowana funkcja może zwracać wartość opakowaną w kontekst. W naszym przypadku każda iteracja zwraca F[WorldView]. M w nazwie jest skrótem od Monadic. Niedługo dowiemy się więcej o tego typu wyniesionych (lifted) funkcjach, które zachowują się tak, jak byśmy oczekiwali, ale przyjmują funkcje zwracające wartości monadyczne zamiast zwykłych wartości.

3.4 Testy jednostkowe

Podejście funkcyjne do pisania aplikacji jest marzeniem projektanta: można skupić się na logice biznesowej,pozostawiając implementacji algebr pozostałym członkom zespołu.

Nasza aplikacja bardzo silnie zależy od upływu czasu oraz zewnętrznych webserwisów. Gdyby była to tradycyjna aplikacja napisania w duchu OOP, stworzylibyśmy mocki dla wszystkich wywołań lub testowych aktorów dla wysyłanych wiadomości. Mockowanie w FP jest równoznaczne z dostarczeniem alternatywnej implementacji algebr, od których zależymy. Algebry izolują części systemu, które muszą zostać zamockowane, czyli po prostu inaczej interpretowane w kontekście testów jednostkowych.

Zaczniemy od przygotowania danych testowych:

  object Data {
    val node1   = MachineNode("1243d1af-828f-4ba3-9fc0-a19d86852b5a")
    val node2   = MachineNode("550c4943-229e-47b0-b6be-3d686c5f013f")
    val managed = NonEmptyList(node1, node2)
  
    val time1: Epoch = epoch"2017-03-03T18:07:00Z"
    val time2: Epoch = epoch"2017-03-03T18:59:00Z" // +52 mins
    val time3: Epoch = epoch"2017-03-03T19:06:00Z" // +59 mins
    val time4: Epoch = epoch"2017-03-03T23:07:00Z" // +5 hours
  
    val needsAgents = WorldView(5, 0, managed, Map.empty, Map.empty, time1)
  }
  import Data._

Implementujemy algebry poprzez rozszerzenie interfejsów Drone i Machines podając konkretny kontekst monadyczny, który w najprostszym przypadku to po prostu Id.

Nasza “mockowa” implementacja zwyczajnie odtwarza wcześniej przygotowany WorldView. Stan naszego systemu został wyizolowany, więc możemy użyć var do jego przechowywania:

  class Mutable(state: WorldView) {
    var started, stopped: Int = 0
  
    private val D: Drone[Id] = new Drone[Id] {
      def getBacklog: Int = state.backlog
      def getAgents: Int = state.agents
    }
  
    private val M: Machines[Id] = new Machines[Id] {
      def getAlive: Map[MachineNode, Epoch] = state.alive
      def getManaged: NonEmptyList[MachineNode] = state.managed
      def getTime: Epoch = state.time
      def start(node: MachineNode): MachineNode = { started += 1 ; node }
      def stop(node: MachineNode): MachineNode = { stopped += 1 ; node }
    }
  
    val program = new DynAgentsModule[Id](D, M)
  }

Kiedy piszemy testy jednostkowe (używając FlatSpec z biblioteki Scalatest), tworzymy instancje Mutable i importujemy wszystkie jej pola i metody.

Nasze drone i machines używają Id jako kontekstu wykonania, więc interpretacja naszego programu zwraca Id[WoldView], na którym bez żadnych problemów możemy wykonywać asercje.

W tym trywialnym scenariuszu sprawdzamy, czy initial zwraca tę sama wartość, której użyliśmy w naszej statycznej implementacji:

  "Business Logic" should "generate an initial world view" in {
    val mutable = new Mutable(needsAgents)
    import mutable._
  
    program.initial shouldBe needsAgents
  }

Możemy też stworzyć bardziej skomplikowane testy dla metod update i act, które pomogą nam znaleźć błędy i dopracować wymagania:

  it should "remove changed nodes from pending" in {
    val world = WorldView(0, 0, managed, Map(node1 -> time3), Map.empty, time3)
    val mutable = new Mutable(world)
    import mutable._
  
    val old = world.copy(alive = Map.empty,
                         pending = Map(node1 -> time2),
                         time = time2)
    program.update(old) shouldBe world
  }
  
  it should "request agents when needed" in {
    val mutable = new Mutable(needsAgents)
    import mutable._
  
    val expected = needsAgents.copy(
      pending = Map(node1 -> time1)
    )
  
    program.act(needsAgents) shouldBe expected
  
    mutable.stopped shouldBe 0
    mutable.started shouldBe 1
  }

Przejście przez pełen komplet testów byłby dość nudny, poniższe testy można łatwo zaimplementować,używając tego samego podejścia:

  • nie proś o nowych agentów, gdy kolejka oczekujących jest niepusta
  • nie wyłączaj agentów, jeśli węzły są zbyt młode
  • wyłącz agenty, gdy backlog jest pusty a węzły wkrótce wygenerują nowe koszta
  • nie wyłączaj agentów, gdy obecne są oczekujące akcje
  • wyłącz agenty, gdy są zbyt stare, a backlog jest pusty
  • wyłącz agenty, nawet jeśli wykonują prace, jeśli są zbyt starzy
  • zignoruj nieodpowiadające oczekujące akcje podczas aktualizacji

Wszystkie te testy są synchroniczne i działają na wątku uruchamiającym testy oraz mogą być uruchamiane równolegle. Gdybyśmy zaprojektowali nasze testy z użyciem Akki, narażone byłyby na arbitralne timeouty, a błędy ukryte byłyby w logach.

Ciężko jest przecenić zwiększenie produktywności wynikające z prostych testów logiki biznesowej. Weź pod uwagę, że 90% czasu programisty podczas interakcji z klientem poświęcone jest na ulepszanie, aktualizowanie i poprawianie tych właśnie reguł. Wszystko inne to tylko szczegóły implementacyjne.

3.5 Przetwarzanie równoległe

Aplikacja, którą stworzyliśmy, uruchamia każdą z algebraicznych metod sekwencyjnie, mimo tego, że jest kilka oczywistych miejsc, w których praca może być wykonywana równolegle.

3.5.1 initial

W naszej definicji metody initial moglibyśmy zarządać wszystkich informacji równocześnie, zamiast wykonywać tylko jedno zapytanie na raz.

W przeciwieństwie do metody flatMap, która działa sekwencyjnie, Scalaz dostarcza składnie Apply przewidzianą do operacji równoległych:

  ^^^^(D.getBacklog, D.getAgents, M.getManaged, M.getAlive, M.getTime)

możemy również użyć notacji infiksowej (infix notation):

  (D.getBacklog |@| D.getAgents |@| M.getManaged |@| M.getAlive |@| M.getTime)

Jeśli każda z operacji równoległych zwraca ten sam kontekst, możemy wywołać funkcję w momencie, gdy wszystkie zwrócą wynik. Przepiszmy initial tak, aby skorzystać z tej możliwości:

  def initial: F[WorldView] =
    ^^^^(D.getBacklog, D.getAgents, M.getManaged, M.getAlive, M.getTime) {
      case (db, da, mm, ma, mt) => WorldView(db, da, mm, ma, Map.empty, mt)
    }

3.5.2 act

W aktualnej implementacji act zatrzymujemy każdy z węzłów sekwencyjnie, czekając na wynik i kontynuując pracę, dopiero gdy operacja się zakończy. Moglibyśmy jednak zatrzymać wszystkie węzły równolegle i na koniec zaktualizować nasz obraz świata.

Wadą tego rozwiązania jest fakt, że błąd w którejkolwiek akcji spowoduje zwarcie, zanim zdążymy zaktualizować pole pending. Wydaje się to być rozsądnym kompromisem, gdyż nasza metoda update poradzi sobie z sytuacją, w której węzeł niespodziewanie się zatrzyma.

Potrzebujemy metody, która operuje na typie NonEmptyList i pozwoli nam przemapować każdy element na F[MachineNode] i zwróci F[NonEmptyList[MachineNode]]. Metoda ta nazywa się traverse, a gdy na jej rezultacie wywołamy flatMap, otrzymamy wartość typu NonEmptyList[MachineNode] z którą możemy sobie poradzić w prosty sposób:

  for {
    stopped <- nodes.traverse(M.stop)
    updates = stopped.map(_ -> world.time).toList.toMap
    update = world.copy(pending = world.pending ++ updates)
  } yield update

Co więcej, wygląda na to, że wersja równoległa jest łatwiejsza do zrozumienia niż wersja sekwencyjna.

3.6 Podsumowanie

  1. Algebry definiują interfejsy między systemami
  2. Moduły implementują algebry, używając innych algebr
  3. Interpretery to konkretne implementacje algebr dla określonego F[_]
  4. Interpretery testowe mogą zamienić części systemu wywołujące efekty uboczne, dając nam wysokie pokrycie testami.

4. Dane i funkcjonalności

Przychodząc ze świata obiektowego, jesteśmy przyzwyczajeni do myślenia o danych i funkcjonalnościach jako jednym: hierarchie klas zawierają metody, a traity mogą wymagać obecności konkretnych pól (danych).

Polimorfizm obiektu w czasie wykonania, bazujący na relacji “jest” (is a), wymaga od klas, aby dziedziczyły po wspólnym interfejsie. Rozwiązanie to może wywołać spory bałagan, gdy tylko ilość naszego kodu zacznie się istotnie zwiększać. Proste struktury danych zostają przysłonięte setkami linii kodu implementującego kolejne metody, traity, które wmiksowujemy do naszych klas, zaczynają cierpieć na problemy związane z kolejnością inicjalizacji, a testowanie i mockowanie ściśle powiązanych komponentów staje się katorgą.

FP podchodzi inaczej do tego problemu, rozdzielając definicje funkcjonalności i danych. W tym rozdziale poznamy podstawowe typy danych i zalety ograniczenia się do podzbioru funkcjonalności oferowanych przez Scalę. Odkryjemy również typeklasy jako sposób na osiągnięcie polimorfizmu już na etapie kompilacji: zaczniemy myśleć o strukturach danych w kategoriach relacji “ma” (has a) zamiast “jest”.

4.1 Dane

Podstawowymi materiałami używanymi do budowania typów danych są:

  • final case class, czyli klasy znane również jako produkty (products)
  • sealed abstract class znane również jako koprodukty (coproducts)
  • case object oraz typy proste takie jak Int, Double, String to wartości (values)12

z tym ograniczeniem, że nie mogą one mieć żadnych metod ani pól innych niż parametry konstruktora. Preferujemy abstract class nad trait, aby zyskać lepszą kompatybilność binarną i nie zachęcać do wmiksowywania traitów.

Wspólna nazwa dla produktów, koproduktów i wartości to Algebraiczny Typ Danych13 (ADT).

Składamy typy danych analogicznie do algebry Boole’a opartej na operacjach AND i XOR (wykluczający OR): produkt zawiera wszystkie typy, z których się składa, a koprodukt jest jednym z nich. Na przykład

  • produkt: ABC = a AND b AND c
  • koprodukt: XYZ = x XOR y XOR z

co zapisane w Scali wygląda tak:

  // values
  case object A
  type B = String
  type C = Int
  
  // product
  final case class ABC(a: A.type, b: B, c: C)
  
  // coproduct
  sealed abstract class XYZ
  case object X extends XYZ
  case object Y extends XYZ
  final case class Z(b: B) extends XYZ

4.1.1 Rekursywne ADT

Kiedy ADT odnosi się to samego siebie, staje się Rekursywnym Algebraicznym Typem Danych.

scalaz.IList, bezpieczna alternatywa dla typu List z biblioteki standardowej, to rekursywne ADT, ponieważ ICons zawiera referencje do IList.:

  sealed abstract class IList[A]
  final case class INil[A]() extends IList[A]
  final case class ICons[A](head: A, tail: IList[A]) extends IList[A]

4.1.2 Funkcje w ADT

ADT mogą zawierać czyste funkcje

  final case class UserConfiguration(accepts: Int => Boolean)

ale sprawia to, że stają się mniej oczywiste, niż mogłoby się wydawać, gdyż sposób, w jaki są wyrażone w JVMie, nie jest idealny. Dla przykładu Serializable, hashCode, equals i toString nie zachowują się tak, jak byśmy się tego spodziewali.

Niestety, Serializable używany jest przez wiele frameworków mimo tego, że istnieje dużo lepszych alternatyw. Częstą pułapką jest zapomnienie, że Serializable może próbować zserializować całe domknięcie (closure) funkcji, co może np. zabić produkcyjny serwer, na którym uruchomiona jest aplikacja. Podobnymi problemami obciążone są inne typy Javowe takie jak na przykład Throwable, który niesie w sobie referencje do arbitralnych obiektów.

Zbadamy dostępne alternatywy, gdy pochylimy się nad biblioteką Scalaz w następnym rozdziale. Kosztem tych alternatyw będzie poświęcenie interoperacyjności (interoperability) z częścią ekosystemu Javy i Scali.

4.1.3 Wyczerpywalność14

Istotne jest, że definiując typy danych, używamy konstrukcji sealed abstract class, a nie abstract class. Zapieczętowanie (sealing) klasy oznacza, że wszystkie podtypy (subtypes) muszą być zdefiniowane w tym samym pliku, pozwalając tym samym kompilatorowi na sprawdzanie, czy pattern matching jest wyczerpujący. Dodatkowo informacja ta może być wykorzystana przez makra, które pomagają nam eliminować boilerplate.

  scala> sealed abstract class Foo
         final case class Bar(flag: Boolean) extends Foo
         final case object Baz extends Foo
  
  scala> def thing(foo: Foo) = foo match {
           case Bar(_) => true
         }
  <console>:14: error: match may not be exhaustive.
  It would fail on the following input: Baz
         def thing(foo: Foo) = foo match {
                               ^

Jak widzimy, kompilator jest w stanie pokazać deweloperowi, co zostało zepsute, gdy ten dodał nowy wariant do koproduktu lub pominął już istniejący. Używamy tutaj flagi kompilatora -Xfatal-warnings, bo w przeciwnym przypadku błąd ten jest jedynie ostrzeżeniem.

Jednakże kompilator nie jest w stanie wykonać koniecznych sprawdzeń, gdy klasa nie jest zapieczętowana lub gdy używamy dodatkowych ograniczeń (guards), np.:

  scala> def thing(foo: Foo) = foo match {
           case Bar(flag) if flag => true
         }
  
  scala> thing(Baz)
  scala.MatchError: Baz (of class Baz$)
    at .thing(<console>:15)

Aby zachować bezpieczeństwo, nie używaj ograniczeń na zapieczętowanych typach.

Nowa flaga, -Xstrict-patmat-analysis, została zaproponowana, aby dodatkowo wzmocnić bezpieczeństwo pattern matchingu.

4.1.4 Alternatywne produkty i koprodukty

Inną formą wyrażenia produktu jest tupla (inaczej krotka, ang. tuple), która przypomina finalną case klasę pozbawioną etykiet.

(A.type, B, C) jest równoznaczna z ABC z wcześniejszego przykładu, ale do konstruowania ADT lepiej jest używać klas, ponieważ brak nazw jest problematyczne w praktyce. Dodatkowo case klasy są zdecydowanie bardziej wydajne przy operowaniu na wartościach typów prostych (primitive values).

Inną formą wyrażenia koproduktu jest zagnieżdżanie typu Either, np.

  Either[X.type, Either[Y.type, Z]]

co jest równoznaczne z zapieczętowaną klasą abstrakcyjną XYZ. Aby uzyskać czystszą składnię do definiowania zagnieżdżonych typów Either, możemy zdefiniować alias typu zakończony dwukropkiem, co sprawi, że używając notacji infiksowej, będzie on wiązał argument po prawej stronie jako pierwszy15.

  type |:[L,R] = Either[L, R]
  
  X.type |: Y.type |: Z

Anonimowe koprodukty stają się przydatne, gdy nie jesteśmy w stanie umieścić wszystkich typów w jednym pliku.

  type Accepted = String |: Long |: Boolean

Alternatywnym rozwiązaniem jest zdefiniowanie nowej zapieczętowanej klasy, której podtypy opakowują potrzebne nam typy.

  sealed abstract class Accepted
  final case class AcceptedString(value: String) extends Accepted
  final case class AcceptedLong(value: Long) extends Accepted
  final case class AcceptedBoolean(value: Boolean) extends Accepted

Pattern matching na tych formach koproduktów jest dość mozolny, dlatego też w Dottym (kompilatorze Scali następnej generacji) dostępne są Unie (union types). Istnieją również biblioteki (oparte o makra), takie jak totalitarian czy iota, które dostarczają kolejne sposoby na wyrażanie koproduktów.

4.1.5 Przekazywanie informacji

Typy danych, oprócz pełnienia funkcji kontenerów na kluczowe informacje biznesowe, pozwalają nam również wyrażać ograniczenia dla tychże danych. Na przykład instancja typu

  final case class NonEmptyList[A](head: A, tail: IList[A])

nigdy nie będzie pusta. Sprawia to, że scalaz.NonEmptyList jest użytecznym typem danych mimo tego, że zawiera dokładnie te same dane jak IList.

Produkty często zawierają typy, które są dużo bardziej ogólne, niż ma to sens. W tradycyjnym podejściu obiektowym moglibyśmy obsłużyć taki przypadek poprzez walidację danych za pomocą asercji:

  final case class Person(name: String, age: Int) {
    require(name.nonEmpty && age > 0) // breaks Totality, don't do this!
  }

Zamiast tego, możemy użyć typu Either i zwracać Right[Person] dla poprawnych instancji, zapobiegając tym samym przed propagacją niepoprawnych instancji. Zauważ, że konstruktor jest prywatny:

  final case class Person private(name: String, age: Int)
  object Person {
    def apply(name: String, age: Int): Either[String, Person] = {
      if (name.nonEmpty && age > 0) Right(new Person(name, age))
      else Left(s"bad input: $name, $age")
    }
  }
  
  def welcome(person: Person): String =
    s"${person.name} you look wonderful at ${person.age}!"
  
  for {
    person <- Person("", -1)
  } yield welcome(person)
4.1.5.1 Rafinowane typy danych16

Prostym sposobem ograniczenie zbioru możliwych wartości ogólnego typu jest użycie biblioteki refined. Aby zainstalować refined, dodaj poniższą linię do pliku build.sbt.

  libraryDependencies += "eu.timepit" %% "refined-scalaz" % "0.9.2"

oraz poniższe importy do swojego kodu

  import eu.timepit.refined
  import refined.api.Refined

Refined pozwala nam zdefiniować klasę Person, używając rafinacji ad-hoc, aby zapisać dokładne wymagania co do typu. Rafinację taką wyrażamy jako typ A Refined B.

  import refined.numeric.Positive
  import refined.collection.NonEmpty
  
  final case class Person(
    name: String Refined NonEmpty,
    age: Int Refined Positive
  )

Dostęp do oryginalnej wartości odbywa się poprzez .value. Aby skonstruować instancje rafinowanego typu w czasie działa programu, możemy użyć metody .refineV, która zwróci nam Either.

  scala> import refined.refineV
  scala> refineV[NonEmpty]("")
  Left(Predicate isEmpty() did not fail.)
  
  scala> refineV[NonEmpty]("Sam")
  Right(Sam)

Jeśli dodamy poniższy import

  import refined.auto._

możemy konstruować poprawne wartości z walidacją na etapie kompilacji:

  scala> val sam: String Refined NonEmpty = "Sam"
  Sam
  
  scala> val empty: String Refined NonEmpty = ""
  <console>:21: error: Predicate isEmpty() did not fail.

Możemy również wyrażać bardziej skomplikowane wymagania, np. za pomocą gotowej reguły MaxSize dostępnej po dodaniu poniższych importów

  import refined.W
  import refined.boolean.And
  import refined.collection.MaxSize

Oto jak wyrażamy wymagania, aby String był jednocześnie niepusty i nie dłuższy niż 10 znaków:

  type Name = NonEmpty And MaxSize[W.`10`.T]
  
  final case class Person(
    name: String Refined Name,
    age: Int Refined Positive
  )

Łatwo jest zdefiniować własne ograniczenia, które nie są dostępne bezpośrednio w bibliotece. Na przykład w naszej aplikacji drone-dynamic-agents potrzebować będziemy sposobu, aby upewnić się, że String jest wiadomością zgodną z formatem application/x-www-form-urlencoded. Stwórzmy więc taką regułę, używając wyrażeń regularnych:

  sealed abstract class UrlEncoded
  object UrlEncoded {
    private[this] val valid: Pattern =
      Pattern.compile("\\A(\\p{Alnum}++|[-.*_+=&]++|%\\p{XDigit}{2})*\\z")
  
    implicit def urlValidate: Validate.Plain[String, UrlEncoded] =
      Validate.fromPredicate(
        s => valid.matcher(s).find(),
        identity,
        new UrlEncoded {}
      )
  }

4.1.6 Dzielenie się jest łatwe

Przez to, że ADT nie dostarczają żadnych funkcjonalności, mają minimalny zbiór zależności. Sprawia to, że dzielenie tychże typów z innymi deweloperami jest nad wyraz łatwe i używając prostego jeżyka modelowania danych, komunikacja oparta o kod, a nie specjalnie przygotowane dokumenty, staje się możliwa nawet wewnątrz zespołów interdyscyplinarnych (a więc składających się dodatkowo z np. administratorów baz danych, specjalistów od UI czy analityków biznesowych).

Dodatkowo łatwiejsze staje się tworzenie narzędzi, które pomogą w konsumowaniu i produkowaniu schematów danych dla innych języków danych albo łączeniu protokołów komunikacji.

4.1.7 Wyliczanie złożoności

Złożoność typu danych to liczba możliwych do stworzenia wartości tego typu. Dobry typ danych ma najmniejszą możliwą złożoność, która pozwala mu przechować potrzebne informacje.

Typy proste mają z góry określoną złożoność:

  • Unit ma dokładnie jedną wartość (dlatego nazywa się “jednostką”)
  • Boolean ma dwie wartości
  • Int ma 4,294,967,295 wartości
  • String ma efektywnie nieskończenie wiele wartości

Aby policzyć złożoność produktu, wystarczy pomnożyć złożoności jego składowych.

  • (Boolean, Boolean) ma 4 wartości (2*2)
  • (Boolean, Boolean, Boolean) ma 6 wartości (2*2*2)

Aby policzyć złożoność koproduktu, sumujemy złożoności poszczególnych wariantów.

  • (Boolean |: Boolean) ma 4 wartości (2+2)
  • (Boolean |: Boolean |: Boolean) ma 6 wartości (2+2+2)

Aby określić złożoność ADT sparametryzowanego typem, mnożymy każdą z części przez złożoność parametru:

  • Option[Boolean] ma 3 wartości, Some[Boolean] i None (2+1)

W FP funkcje są totalne i muszą zwracać wartość dla każdego wejścia, bez żadnych wyjątków, a zmniejszanie złożoności wejścia i wyjścia jest najlepszą drogą do osiągnięcia totalności. Jako zasadę kciuka przyjąć można, że funkcja jest źle zaprojektowana, jeśli złożoność jej wyjścia jest większa niż złożoność produktu jej wejść: w takim przypadku staje się ona źródłem entropii.

Złożoność funkcji totalnej jest liczbą możliwych funkcji, które pasują do danej sygnatury typu, a więc innymi słowy, złożoność wyjścia do potęgi równej złożoności wejścia.

  • Unit => Boolean ma złożoność 2
  • Boolean => Boolean ma złożoność 4
  • Option[Boolean] => Option[Boolean] ma złożoność 27
  • Boolean => Int to zaledwie trylion kombinacji
  • Int => Boolean ma złożoność tak wielką, że gdyby każdej implementacji przypisać liczbę, to każda z tych liczb wymagałaby 4 gigabajtów pamięci, aby ją zapisać

W praktyce Int => Boolean będzie zazwyczaj czymś tak trywialnym, jak sprawdzenie parzystości lub rzadkie (sparse) wyrażenie zbioru bitów (BitSet). Funkcja taka w ADT powinna być raczej zastąpiona koproduktem istotnych funkcji.

Gdy nasza złożoność to “nieskończoność na wejściu, nieskończoność na wyjściu” powinniśmy wprowadzić bardziej restrykcyjne typy i walidacje wejścia, na przykład używając konstrukcji Refined wspomnianej w poprzedniej sekcji.

Zdolność do wyliczania złożoności sygnatury typu ma jeszcze jedno praktyczne zastosowanie: możemy odszukać prostsze sygnatury przy pomocy matematyki na poziomie szkoły średniej! Aby przejść od sygnatury do jej złożoności, po prostu zamień

  • Either[A, B] na a + b
  • (A, B) na a * b
  • A => B na b ^ a

a następnie poprzestawiaj i zamień z powrotem. Dla przykładu powiedzmy, że zaprojektowaliśmy framework oparty na callbackach i dotarliśmy do miejsca, w którym potrzebujemy takiej sygnatury:

  (A => C) => ((B => C) => C)

Możemy ją przekonwertować i przetransformować

  (c ^ (c ^ b)) ^ (c ^ a)
  = c ^ ((c ^ b) * (c ^ a))
  = c ^ (c ^ (a + b))

a następnie zamienić z powrotem, aby otrzymać

  (Either[A, B] => C) => C

która jest zdecydowanie prostsza: wystarczy, że użytkownik dostarczy nam Either[A, B] => C.

Ta sama metoda może być użyta aby udowodnić, że

  A => B => C

jest równoznaczna z

  (A, B) => C

co znane jest jako currying lub rozwijanie funkcji.

4.1.8 Preferuj koprodukty nad produkty

Archetypowym problemem, który pojawia się bardzo często, są wzajemnie wykluczające się parametry konfiguracyjne a, b i c. Produkt (a: Boolean, b: Boolean, c: Boolean) ma złożoność równą 8, podczas gdy złożoność koproduktu

  sealed abstract class Config
  object Config {
    case object A extends Config
    case object B extends Config
    case object C extends Config
  }

to zaledwie 3. Lepiej jest zamodelować opisany scenariusz jako koprodukt niż pozwolić na wyrażenie pięciu zupełnie nieprawidłowych przypadków.

Złożoność typu danych wpływa również na testowanie kodu na nim opartego i praktycznie niemożliwym jest przetestowanie wszystkich możliwych wejść do funkcji. Całkiem łatwo jest jednak przetestować próbkę wartości za pomocą biblioteki do testowania właściwości17 Scalacheck. Jeśli prawdopodobieństwo poprawności losowej próbki danych jest niskie, jest to znak, że dane są niepoprawnie zamodelowane.

4.1.9 Optymalizacja

Dużą zaletą używania jedynie podzbioru języka Scala do definiowania typów danych jest to, że narzędzia mogą optymalizować bytecode potrzebny do reprezentacji tychże.

Na przykład, możemy spakować pola typu Boolean i Option do tablicy bajtów, cache’ować wartości, memoizować hashCode, optymalizować equals, używać wyrażeń @switch przy pattern matchingu i wiele, wiele więcej.

Optymalizacje te nie mogą być zastosowane do hierarchii klas w stylu OOP, które to mogą przechowywać wewnętrzny stan, rzucać wyjątki lub dostarczać doraźne implementacje metod.

4.2 Funkcjonalności

Czyste funkcje są najczęściej definiowane jako metody wewnątrz obiektu (definicji typu object).

  package object math {
    def sin(x: Double): Double = java.lang.Math.sin(x)
    ...
  }
  
  math.sin(1.0)

Jednakże, używanie obiektów może być nieco niezręczne, gdyż wymaga od programisty czytania kodu od wewnątrz do zewnątrz - zamiast od lewej do prawej. Dodatkowo, funkcje z obiektu zawłaszczają przestrzeń nazw. Jeśli chcielibyśmy zdefiniować funkcje sin(t: T) gdzieś indziej, napotkalibyśmy błędy niejednoznacznych referencji (ambigous reference). Jest to ten sam problem, który spotykamy w Javie, gdy wybieramy między metodami statycznymi i tymi definiowanymi w klasie.

Korzystając z konstrukcji implicit class (znanej również jako extension methodology lub syntax) i odrobiny boilerplate’u możemy uzyskać znaną nam składnię:

  scala> implicit class DoubleOps(x: Double) {
           def sin: Double = math.sin(x)
         }
  
  scala> (1.0).sin
  res: Double = 0.8414709848078965

Często dobrze jest pominąć definiowanie obiektu i od razu sięgnąć po klasę niejawną (implicit class), ograniczając boilerplate do minimum:

  implicit class DoubleOps(x: Double) {
    def sin: Double = java.lang.Math.sin(x)
  }

4.2.1 Funkcje polimorficzne

Bardziej popularnym rodzajem funkcji są funkcje polimorficzne, które żyją wewnątrz typeklas18. Typeklasa to trait, który:

  • nie ma wewnętrznego stanu
  • ma parametr typu
  • ma przynajmniej jedną metodą abstrakcyjną (kombinator prymitywny (primitive combinator))
  • może mieć metody uogólnione (kombinatory pochodne (derived combinators))
  • może rozszerzać inne typeklasy

Dla każdego typu może istnieć tylko jedna instancja typeklasy, a właściwość ta nazywa się koherencją lub spójnością typeklas (typeclass coherence). Typeklasy mogą na pierwszy rzut oka wydawać się bardzo podobne do algebraicznych interfejsów, różnią się jednak tym, że algebry nie muszą zachowywać spójności.

Typeklasy używane się m.in. w bibliotece standardowej Scali. Przyjrzymy się uproszczonej wersji scala.math.Numeric, aby zademonstrować zasadę działania tej konstrukcji:

  trait Ordering[T] {
    def compare(x: T, y: T): Int
  
    def lt(x: T, y: T): Boolean = compare(x, y) < 0
    def gt(x: T, y: T): Boolean = compare(x, y) > 0
  }
  
  trait Numeric[T] extends Ordering[T] {
    def plus(x: T, y: T): T
    def times(x: T, y: T): T
    def negate(x: T): T
    def zero: T
  
    def abs(x: T): T = if (lt(x, zero)) negate(x) else x
  }

Możemy zaobserwować wszystkie kluczowe cechy typeklasy w praktyce:

  • nie ma wewnętrznego stanu
  • Ordering i Numeric mają parametr typu T
  • Ordering definiuje abstrakcyjną metodą compare, a Numeric metody plus, times, negate i zero
  • Ordering definiuje uogólnione lt i gt bazujące na compare, Numeric robi to samo z abs, bazując na lt, negate oraz zero
  • Numeric rozszerza Ordering

Możemy teraz napisać funkcję dla typów, które “posiadają” instancję typeklasy Numeric:

  def signOfTheTimes[T](t: T)(implicit N: Numeric[T]): T = {
    import N._
    times(negate(abs(t)), t)
  }

Nie zależymy już od hierarchii klas w stylu OOP! Oznacza to, że wejście do naszej funkcji nie musi być instancją typu Numeric, co jest niezwykle ważne, kiedy chcemy zapewnić wsparcie dla klas zewnętrznych, których definicji nie jesteśmy w stanie zmienić.

Inną zaletą typeklas jest to, że dane wiązane są z funkcjonalnościami na etapie kompilacji, a nie za pomocą dynamicznej dyspozycji (dynamic dispatch) w czasie działania programu, jak ma to miejsce w OOP.

Dla przykładu, tam, gdzie klasa List może mieć tylko jedną implementację danej metody, używając typeklas, możemy używać różnych implementacji zależnie od typu elementów zawartych wewnątrz. Tym samym wykonujemy część pracy w czasie kompilacji, zamiast zostawiać ją do czasu wykonania.

4.2.2 Składnia

Składnia użyta to zapisania signOfTheTimes jest nieco niezgrabna, ale jest kilka rzeczy, które możemy poprawić.

Użytkownicy chcieliby, aby nasza metoda używała wiązania kontekstu (context bounds), ponieważ wtedy sygnaturę można przeczytać wprost jako: “przyjmuje T, dla którego istnieje Numeric

  def signOfTheTimes[T: Numeric](t: T): T = ...

Niestety, teraz musielibyśmy wszędzie używać implicitly[Numeric[T]]. Możemy pomóc sobie, definiując metodę pomocniczą w obiekcie towarzyszącym typeklasy

  object Numeric {
    def apply[T](implicit numeric: Numeric[T]): Numeric[T] = numeric
  }

aby uzyskać dostęp do jej instancji w bardziej zwięzły sposób:

  def signOfTheTimes[T: Numeric](t: T): T = {
    val N = Numeric[T]
    import N._
    times(negate(abs(t)), t)
  }

Nadal jednak jest to, dla nas, implementatorów, problem. Zmuszeni jesteśmy używać czytanej od wewnątrz do zewnątrz składni metod statycznych zamiast czytanej od lewej do prawej składni tradycyjnej. Możemy sobie z tym poradzić poprzez definicję obiektu ops wewnątrz obiektu towarzyszącego typeklasy:

  object Numeric {
    def apply[T](implicit numeric: Numeric[T]): Numeric[T] = numeric
  
    object ops {
      implicit class NumericOps[T](t: T)(implicit N: Numeric[T]) {
        def +(o: T): T = N.plus(t, o)
        def *(o: T): T = N.times(t, o)
        def unary_-: T = N.negate(t)
        def abs: T = N.abs(t)
  
        // duplicated from Ordering.ops
        def <(o: T): T = N.lt(t, o)
        def >(o: T): T = N.gt(t, o)
      }
    }
  }

Zauważ, że zapis -x rozwijany jest przez kompilator do x.unary_-, dlatego też definiujemy rozszerzającą metodę (extension method) unary_-. Możemy teraz zapisać naszą funkcję w sposób zdecydowanie czystszy:

  import Numeric.ops._
  def signOfTheTimes[T: Numeric](t: T): T = -(t.abs) * t

Dobra wiadomość jest taka, że nie musimy pisać całego tego boilerplatu własnoręcznie, ponieważ Simulacrum dostarcza makro anotacje @typeclass, która automatycznie generuje dla nas metodę apply i obiekt ops. Dodatkowo pozwala nam nawet zdefiniować alternatywne (zazwyczaj symboliczne) nazwy dla metod. Całość:

  import simulacrum._
  
  @typeclass trait Ordering[T] {
    def compare(x: T, y: T): Int
    @op("<") def lt(x: T, y: T): Boolean = compare(x, y) < 0
    @op(">") def gt(x: T, y: T): Boolean = compare(x, y) > 0
  }
  
  @typeclass trait Numeric[T] extends Ordering[T] {
    @op("+") def plus(x: T, y: T): T
    @op("*") def times(x: T, y: T): T
    @op("unary_-") def negate(x: T): T
    def zero: T
    def abs(x: T): T = if (lt(x, zero)) negate(x) else x
  }
  
  import Numeric.ops._
  def signOfTheTimes[T: Numeric](t: T): T = -(t.abs) * t

Kiedy używamy operatora symbolicznego, możemy czytać (nazywać) go jak odpowiadającą mu metodę. Np. < przeczytamy jako “less then”, a nie “left angle bracket”.

4.2.3 Instancje

Instancje typu Numeric (które są również instancjami Ordering) są definiowane jako implicit val i rozszerzają typeklasę, mogąc tym samym dostarczać bardziej optymalne implementacje generycznych metod:

  implicit val NumericDouble: Numeric[Double] = new Numeric[Double] {
    def plus(x: Double, y: Double): Double = x + y
    def times(x: Double, y: Double): Double = x * y
    def negate(x: Double): Double = -x
    def zero: Double = 0.0
    def compare(x: Double, y: Double): Int = java.lang.Double.compare(x, y)
  
    // optimised
    override def lt(x: Double, y: Double): Boolean = x < y
    override def gt(x: Double, y: Double): Boolean = x > y
    override def abs(x: Double): Double = java.lang.Math.abs(x)
  }

Mimo że używamy tutaj +, *, unary_-, < i >, które zdefiniowane są też przy użyciu @ops (i mogłyby spowodować nieskończoną pętlę wywołań), są one również zdefiniowane bezpośrednio dla typu Double. Metody klasy są używane w pierwszej kolejności, a dopiero w przypadku ich braku kompilator szuka metod rozszerzających. W rzeczywistości kompilator Scali obsługuje wywołania tych metod w specjalny sposób i zamienia je bezpośrednio na instrukcje kodu bajtowego, odpowiednio dadd, dmul, dcmpl i dcmpg.

Możemy również zaimplementować Numeric dla Javowego BigDecimal (unikaj scala.BigDecimal, jest całkowicie zepsuty).

  import java.math.{ BigDecimal => BD }
  
  implicit val NumericBD: Numeric[BD] = new Numeric[BD] {
    def plus(x: BD, y: BD): BD = x.add(y)
    def times(x: BD, y: BD): BD = x.multiply(y)
    def negate(x: BD): BD = x.negate
    def zero: BD = BD.ZERO
    def compare(x: BD, y: BD): Int = x.compareTo(y)
  }

Możemy też zdefiniować nasz własny typ danych do reprezentowania liczb zespolonych:

  final case class Complex[T](r: T, i: T)

Tym samym uzyskamy Numeric[Complex[T]], jeśli istnieje Numeric[T]. Instancje te zależą od typu T, a więc definiujemy je jako def, a nie val.

  implicit def numericComplex[T: Numeric]: Numeric[Complex[T]] =
    new Numeric[Complex[T]] {
      type CT = Complex[T]
      def plus(x: CT, y: CT): CT = Complex(x.r + y.r, x.i + y.i)
      def times(x: CT, y: CT): CT =
        Complex(x.r * y.r + (-x.i * y.i), x.r * y.i + x.i * y.r)
      def negate(x: CT): CT = Complex(-x.r, -x.i)
      def zero: CT = Complex(Numeric[T].zero, Numeric[T].zero)
      def compare(x: CT, y: CT): Int = {
        val real = (Numeric[T].compare(x.r, y.r))
        if (real != 0) real
        else Numeric[T].compare(x.i, y.i)
      }
    }

Uważny czytelnik zauważy, że abs jest czymś zupełnie innym, niż oczekiwałby matematyk. Poprawna wartość zwracana z tej metody powinna być typu T, a nie Complex[T].

scala.math.Numeric stara się robić zbyt wiele i nie generalizuje ponad liczby rzeczywiste. Pokazuje nam to, że małe, dobrze zdefiniowane typeklasy są często lepsze niż monolityczne kolekcje szczegółowych funkcjonalności.

4.2.4 Niejawne rozstrzyganie19

Wielokrotnie używaliśmy wartości niejawnych: ten rozdział ma na celu doprecyzować, czym one są i jak tak naprawdę działają.

O parametrach niejawnych (implicit parameters) mówimy, gdy metoda żąda, aby unikalna instancja określonego typu znajdowała się w niejawnym zakresie (implicit scope) wywołującego. Może do tego używać specjalnej składni ograniczeń kontekstu lub deklarowac je wprost. Parametry niejawne są więc dobrym sposobem na przekazywanie konfiguracji poprzez warstwy naszej aplikacji.

W tym przykładzie foo wymaga aby dostępne były instancje typeklas Numeric i Typeable dla A, oraz instancja typu Handler, który przyjmuje dwa parametry typu.

  def foo[A: Numeric: Typeable](implicit A: Handler[String, A]) = ...

Konwersje niejawne pojawiają się, gdy używamy implicit def. Jednym z użyć niejawnych konwersji jest stosowanie metod rozszerzających. Kiedy kompilator próbuje odnaleźć metodę, która ma zostać wywołana na obiekcie, przegląda metody zdefiniowane w klasie tego obiektu, a następnie wszystkie klasy, po których ona dziedziczy (reguła podobna do tych znanych z Javy). Jeśli takie poszukiwanie się nie powiedzie, kompilator zaczyna przeglądać zakres niejawny w poszukiwaniu konwersji do innych typów, a następnie na nich szuka wspomnianej metody.

Inny przykładem użycia niejawnych konwersji jest derywacja typeklas (typeclass derivation). W poprzednim rozdziale napisaliśmy metodę niejawną, która tworzyła instancję Numeric[Complex[T]] jeśli dostępna była instancja Numeric[T]. Możemy w ten sposób łączyć wiele niejawnych metod (również rekurencyjnie), dochodząc tym samym do metody zwanej “typeful programming”, która pozwala nam wykonywać obliczenia na etapie kompilacji, a nie w czasie działania programu.

Część, która łączy niejawne parametry (odbiorców) z niejawnymi konwersjami i wartościami (dostawcami), nazywa się niejawnym rozstrzyganiem.

Najpierw w poszukiwaniu wartości niejawnych przeszukiwany jest standardowy zakres zmiennych, wg kolejności:

  • zakres lokalny, wliczając lokalne importy (np. ciało bloku lub metody)
  • zakres zewnętrzny, wliczając lokalne importy (np. ciało klasy)
  • przodkowie (np. ciało klasy, po której dziedziczymy)
  • aktualny obiekt pakietu (package object)
  • obiekty pakietów nadrzędnych (kiedy używamy zagnieżdżonych pakietów)
  • importy globalne zdefiniowane w pliku

Jeśli nie uda się odnaleźć pasującej metody, przeszukiwany jest zakres specjalny, składający się z: wnętrza obiektu towarzyszącego danego typu, jego obiektu pakietu, obiektów pakietów zewnętrznych (jeśli jest zagnieżdżony)), a w przypadku porażki to samo powtarzane jest dla typów, po których nasza klasa dziedziczy. Operacje te wykonywane są kolejno dla:

  • typu zadanego parametru
  • oczekiwanego typu parametru
  • parametru typu (jeśli istnieje)

Jeśli w tej samej fazie poszukiwań znalezione zostaną dwie pasujące wartości niejawne, zgłaszany jest błąd niejednoznaczności ambigous implicit error.

Wartości niejawne często definiowane są wewnątrz traitów, które następnie rozszerzane są przez obiekty. Praktyka ta podyktowana jest próbą kontrolowania priorytetów wg których kompilator dobiera pasującą wartość, unikając jednocześnie błędów niejednoznaczności.

Specyfikacja języka Scala jest dość nieprecyzyjna, jeśli chodzi o przypadki skrajne, co sprawia, że aktualna implementacja kompilatora staje się standardem. Są pewne reguły, którymi będziemy się kierować w tej książce, jak na przykład używanie implicit val zamiast implicit object, mimo że ta druga opcja jest bardziej zwięzła. Kaprys kompilatora sprawia, że wartości definiowane jako implicit object wewnątrz obiektu towarzyszącego są traktowane inaczej niż te definiowane za pomocą implicit val.

Niejawne rozstrzyganie zawodzi, kiedy typeklasy tworzą hierarchię tak jak w przypadku klas Ordering i Numeric. Jeśli napiszemy funkcję, która przyjmuje niejawny parametr typu Ordering i zawołamy ją z typem prymitywnym, który posiada instancję Numeric zdefiniowaną w obiekcie towarzyszącym typu Numeric, kompilator rzeczonej instancji nie znajdzie.

Niejawne rozstrzyganie staje się prawdziwą loterią, gdy w grze pojawią się aliasy typu, które zmieniają kształt parametru. Dla przykładu, parametr niejawny używający aliasu type Values[A] = List[Option[A]] prawdopodobnie nie zostanie połączony z niejawną wartością zdefiniowaną dla typu List[Option[A]] ponieważ kształt zmienia się z kolekcji kolekcji elementów typu A na kolekcję elementów typu A.

4.3 Modelowanie OAuth2

Zakończymy ten rozdział praktycznym przykładem modelowania danych i derywacji typeklas połączonych z projektowaniem algebr i modułów, o którym mówiliśmy w poprzednim rozdziale.

W naszej aplikacje drone-dynamic-agents chcielibyśmy komunikować się z serwerem Drone i Google Cloud używając JSONa poprzez REST API. Obie usługi używają OAuth2 do uwierzytelniania użytkowników. Istnieje wiele interpretacji OAuth2, ale my skupimy się na tej, która działa z Google Cloud (wersja współpracująca z Drone jest jeszcze prostsza).

4.3.1 Opis

Każda aplikacja komunikująca się z Google Cloud musi mieć skonfigurowany Klucz Kliencki OAuth 2.0 (OAuth 2.0 Client Key) poprzez

  https://console.developers.google.com/apis/credentials?project={PROJECT_ID}

co da nam dostęp do ID Klienta (Client ID) oraz Sekretu Klienta (Client secret).

Aplikacja może wtedy uzyskać jednorazowy kod poprzez sprawienie, aby użytkownik wykonał Prośbę o Autoryzację (Authorization Request) w swojej przeglądarce (tak, naprawdę, w swojej przeglądarce). Musimy więc wyświetlić poniższą stronę:

  https://accounts.google.com/o/oauth2/v2/auth?\
    redirect_uri={CALLBACK_URI}&\
    prompt=consent&\
    response_type=code&\
    scope={SCOPE}&\
    access_type=offline&\
    client_id={CLIENT_ID}

Kod dostarczony zostanie pod {CALLBACK_URI} w postaci żądania GET. Aby go odebrać, musimy posiadać serwer http słuchający na interfejsie localhost.

Gdy zdobędziemy kod, możemy wykonać Żądanie o Token Dostępu (Access Token Request):

  POST /oauth2/v4/token HTTP/1.1
  Host: www.googleapis.com
  Content-length: {CONTENT_LENGTH}
  content-type: application/x-www-form-urlencoded
  user-agent: google-oauth-playground
  code={CODE}&\
    redirect_uri={CALLBACK_URI}&\
    client_id={CLIENT_ID}&\
    client_secret={CLIENT_SECRET}&\
    scope={SCOPE}&\
    grant_type=authorization_code

na które odpowiedzią będzie dokument JSON

  {
    "access_token": "BEARER_TOKEN",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "REFRESH_TOKEN"
  }

Tokeny posiadacza (Bearer tokens) zazwyczaj wygasają po godzinie i mogą być odświeżone poprzez wykonanie kolejnego żądania http z użyciem tokenu odświeżającego (refresh token):

  POST /oauth2/v4/token HTTP/1.1
  Host: www.googleapis.com
  Content-length: {CONTENT_LENGTH}
  content-type: application/x-www-form-urlencoded
  user-agent: google-oauth-playground
  client_secret={CLIENT_SECRET}&
    grant_type=refresh_token&
    refresh_token={REFRESH_TOKEN}&
    client_id={CLIENT_ID}

na który odpowiedzią jest

  {
    "access_token": "BEARER_TOKEN",
    "token_type": "Bearer",
    "expires_in": 3600
  }

Wszystkie żądania do serwera powinny zwierać nagłówek

  Authorization: Bearer BEARER_TOKEN

z podstawioną rzeczywistą wartością BEARER_TOKEN.

Google wygasza wszystkie tokeny oprócz najnowszych 50, a więc czas odświeżania to tylko wskazówka. Tokeny odświeżające trwają pomiędzy sesjami i mogą być wygaszone ręcznie przez użytkownika. Tak więc możemy mieć jednorazową aplikację do pobierania tokenu odświeżającego, który następnie umieścimy w konfiguracji drugiej aplikacji.

Drone nie implementuje endpointu /auth ani tokenów odświeżających, a jedynie dostarcza BEARER_TOKEN poprzez interfejs użytkownika.

4.3.2 Dane

Pierwszym krokiem będzie zamodelowanie danych potrzebnych do implementacji OAuth2. Tworzymy więc ADT z dokładnie takimi samymi polami jak te wymagane przez serwer OAuth2. Użyjemy typów String i Long dla zwięzłości, ale moglibyśmy użyć typów rafinowanych, gdyby wyciekały one do naszego modelu biznesowego.

  import refined.api.Refined
  import refined.string.Url
  
  final case class AuthRequest(
    redirect_uri: String Refined Url,
    scope: String,
    client_id: String,
    prompt: String = "consent",
    response_type: String = "code",
    access_type: String = "offline"
  )
  final case class AccessRequest(
    code: String,
    redirect_uri: String Refined Url,
    client_id: String,
    client_secret: String,
    scope: String = "",
    grant_type: String = "authorization_code"
  )
  final case class AccessResponse(
    access_token: String,
    token_type: String,
    expires_in: Long,
    refresh_token: String
  )
  final case class RefreshRequest(
    client_secret: String,
    refresh_token: String,
    client_id: String,
    grant_type: String = "refresh_token"
  )
  final case class RefreshResponse(
    access_token: String,
    token_type: String,
    expires_in: Long
  )

4.3.3 Funkcjonalność

Musimy przetransformować klasy zdefiniowane w poprzedniej sekcji do JSONa, URLi i formy znanej z żądań HTTP POST. Ponieważ aby to osiągnąć, niezbędny jest polimorfizm, potrzebować będziemy typeklas.

jsonformat to prosta biblioteka do pracy z JSONem, którą poznamy dokładniej w jednym z następnych rozdziałów. Została ona stworzona, stawiając na pierwszym miejscu pryncypia programowania funkcyjnego i czytelność kodu. Składa się ona z AST do opisu JSONa oraz typeklas do jego kodowania i dekodowania:

  package jsonformat
  
  sealed abstract class JsValue
  final case object JsNull                                    extends JsValue
  final case class JsObject(fields: IList[(String, JsValue)]) extends JsValue
  final case class JsArray(elements: IList[JsValue])          extends JsValue
  final case class JsBoolean(value: Boolean)                  extends JsValue
  final case class JsString(value: String)                    extends JsValue
  final case class JsDouble(value: Double)                    extends JsValue
  final case class JsInteger(value: Long)                     extends JsValue
  
  @typeclass trait JsEncoder[A] {
    def toJson(obj: A): JsValue
  }
  
  @typeclass trait JsDecoder[A] {
    def fromJson(json: JsValue): String \/ A
  }

Potrzebować będziemy instancji JsDecoder[AccessResponse] i JsDecoder[RefreshResponse]. Możemy je zbudować, używają, funkcji pomocniczej:

  implicit class JsValueOps(j: JsValue) {
    def getAs[A: JsDecoder](key: String): String \/ A = ...
  }

Umieścimy je w obiektach towarzyszących naszych typów danych, aby zawsze znajdowały się w zakresie niejawnym:

  import jsonformat._, JsDecoder.ops._
  
  object AccessResponse {
    implicit val json: JsDecoder[AccessResponse] = j =>
      for {
        acc <- j.getAs[String]("access_token")
        tpe <- j.getAs[String]("token_type")
        exp <- j.getAs[Long]("expires_in")
        ref <- j.getAs[String]("refresh_token")
      } yield AccessResponse(acc, tpe, exp, ref)
  }
  
  object RefreshResponse {
    implicit val json: JsDecoder[RefreshResponse] = j =>
      for {
        acc <- j.getAs[String]("access_token")
        tpe <- j.getAs[String]("token_type")
        exp <- j.getAs[Long]("expires_in")
      } yield RefreshResponse(acc, tpe, exp)
  }

Możemy teraz sparsować ciąg znaków do typu AccessResponse lub RefreshResponse

  scala> import jsonformat._, JsDecoder.ops._
  scala> val json = JsParser("""
                       {
                         "access_token": "BEARER_TOKEN",
                         "token_type": "Bearer",
                         "expires_in": 3600,
                         "refresh_token": "REFRESH_TOKEN"
                       }
                       """)
  
  scala> json.map(_.as[AccessResponse])
  AccessResponse(BEARER_TOKEN,Bearer,3600,REFRESH_TOKEN)

Musimy stworzyć nasze własne typeklasy do kodowania danych w postaci URLi i żądań POST. Poniżej widzimy całkiem rozsądny design:

  // URL query key=value pairs, in un-encoded form.
  final case class UrlQuery(params: List[(String, String)])
  
  @typeclass trait UrlQueryWriter[A] {
    def toUrlQuery(a: A): UrlQuery
  }
  
  @typeclass trait UrlEncodedWriter[A] {
    def toUrlEncoded(a: A): String Refined UrlEncoded
  }

Musimy zapewnić instancje dla typów podstawowych:

  import java.net.URLEncoder
  
  object UrlEncodedWriter {
    implicit val encoded: UrlEncodedWriter[String Refined UrlEncoded] = identity
  
    implicit val string: UrlEncodedWriter[String] =
      (s => Refined.unsafeApply(URLEncoder.encode(s, "UTF-8")))
  
    implicit val url: UrlEncodedWriter[String Refined Url] =
      (s => s.value.toUrlEncoded)
  
    implicit val long: UrlEncodedWriter[Long] =
      (s => Refined.unsafeApply(s.toString))
  
    implicit def ilist[K: UrlEncodedWriter, V: UrlEncodedWriter]
      : UrlEncodedWriter[IList[(K, V)]] = { m =>
      val raw = m.map {
        case (k, v) => k.toUrlEncoded.value + "=" + v.toUrlEncoded.value
      }.intercalate("&")
      Refined.unsafeApply(raw) // by deduction
    }
  
  }

Używamy Refined.unsafeApply, kiedy jesteśmy pewni, że zawartość stringa jest już poprawnie zakodowana i możemy pominąć standardową weryfikację.

ilist jest przykładem prostej derywacji typeklasy, w sposób podobny do tego, którego użyliśmy przy Numeric[Complex]. Metoda .intercalate to bardziej ogólna wersja .mkString.

W rozdziale poświęconym Derywacji typeklas pokażemy jak stworzyć instancję UrlQueryWriter automatycznie oraz jak oczyścić nieco kod, który już napisaliśmy. Na razie jednak napiszmy boilerplate dla typów, których chcemy używać:

  import UrlEncodedWriter.ops._
  object AuthRequest {
    implicit val query: UrlQueryWriter[AuthRequest] = { a =>
      UrlQuery(List(
        ("redirect_uri"  -> a.redirect_uri.value),
        ("scope"         -> a.scope),
        ("client_id"     -> a.client_id),
        ("prompt"        -> a.prompt),
        ("response_type" -> a.response_type),
        ("access_type"   -> a.access_type))
    }
  }
  object AccessRequest {
    implicit val encoded: UrlEncodedWriter[AccessRequest] = { a =>
      IList(
        "code"          -> a.code.toUrlEncoded,
        "redirect_uri"  -> a.redirect_uri.toUrlEncoded,
        "client_id"     -> a.client_id.toUrlEncoded,
        "client_secret" -> a.client_secret.toUrlEncoded,
        "scope"         -> a.scope.toUrlEncoded,
        "grant_type"    -> a.grant_type.toUrlEncoded
      ).toUrlEncoded
    }
  }
  object RefreshRequest {
    implicit val encoded: UrlEncodedWriter[RefreshRequest] = { r =>
      IList(
        "client_secret" -> r.client_secret.toUrlEncoded,
        "refresh_token" -> r.refresh_token.toUrlEncoded,
        "client_id"     -> r.client_id.toUrlEncoded,
        "grant_type"    -> r.grant_type.toUrlEncoded
      ).toUrlEncoded
    }
  }

4.3.4 Moduł

Tym samym zakończyliśmy modelowanie danych i funkcjonalności niezbędnych do implementacji protokołu OAuth2. Przypomnij sobie z poprzedniego rozdziału, że komponenty, które interagują ze światem zewnętrznym definiujemy jako algebry, a logikę biznesową jako moduły, aby pozwolić na gruntowne jej przetestowanie.

Definiujemy algebry, na których bazujemy oraz używamy ograniczeń kontekstu, aby pokazać, że nasze odpowiedzi muszą posiadać instancję JsDecoder, a żądania UrlEncodedWriter:

  trait JsonClient[F[_]] {
    def get[A: JsDecoder](
      uri: String Refined Url,
      headers: IList[(String, String)]
    ): F[A]
  
    def post[P: UrlEncodedWriter, A: JsDecoder](
      uri: String Refined Url,
      payload: P,
      headers: IList[(String, String] = IList.empty
    ): F[A]
  }

Zauważ, że definiujemy jedynie szczęśliwy scenariusz w API klasy JsonClient. Obsługą błędów zajmiemy się w jednym z kolejnych rozdziałów.

Pozyskanie CodeToken z serwera Google OAuth2 wymaga

  1. wystartowania serwera HTTP na lokalnej maszynie i odczytanie numeru portu, na którym nasłuchuje.
  2. otworzenia strony internetowej w przeglądarce użytkownika, tak aby mógł zalogować się do usług Google swoimi danymi i uwierzytelnić aplikacje
  3. przechwycenia kodu, poinformowania użytkownika o następnych krokach i zamknięcia serwera HTTP.

Możemy zamodelować to jako trzy metody wewnątrz algebry UserInteraction.

  final case class CodeToken(token: String, redirect_uri: String Refined Url)
  
  trait UserInteraction[F[_]] {
    def start: F[String Refined Url]
    def open(uri: String Refined Url): F[Unit]
    def stop: F[CodeToken]
  }

Ujęte w ten sposób wydaje się to niemal proste.

Potrzebujemy również algebry pozwalającej abstrahować nam nad lokalnym czasem systemu

  trait LocalClock[F[_]] {
    def now: F[Epoch]
  }

oraz typu danych, którego użyjemy w implementacji logiki odświeżania tokenów

  final case class ServerConfig(
    auth: String Refined Url,
    access: String Refined Url,
    refresh: String Refined Url,
    scope: String,
    clientId: String,
    clientSecret: String
  )
  final case class RefreshToken(token: String)
  final case class BearerToken(token: String, expires: Epoch)

Możemy teraz napisać nasz moduł klienta OAuth2:

  import http.encoding.UrlQueryWriter.ops._
  
  class OAuth2Client[F[_]: Monad](
    config: ServerConfig
  )(
    user: UserInteraction[F],
    client: JsonClient[F],
    clock: LocalClock[F]
  ) {
    def authenticate: F[CodeToken] =
      for {
        callback <- user.start
        params   = AuthRequest(callback, config.scope, config.clientId)
        _        <- user.open(params.toUrlQuery.forUrl(config.auth))
        code     <- user.stop
      } yield code
  
    def access(code: CodeToken): F[(RefreshToken, BearerToken)] =
      for {
        request <- AccessRequest(code.token,
                                 code.redirect_uri,
                                 config.clientId,
                                 config.clientSecret).pure[F]
        msg     <- client.post[AccessRequest, AccessResponse](
                     config.access, request)
        time    <- clock.now
        expires = time + msg.expires_in.seconds
        refresh = RefreshToken(msg.refresh_token)
        bearer  = BearerToken(msg.access_token, expires)
      } yield (refresh, bearer)
  
    def bearer(refresh: RefreshToken): F[BearerToken] =
      for {
        request <- RefreshRequest(config.clientSecret,
                                  refresh.token,
                                  config.clientId).pure[F]
        msg     <- client.post[RefreshRequest, RefreshResponse](
                     config.refresh, request)
        time    <- clock.now
        expires = time + msg.expires_in.seconds
        bearer  = BearerToken(msg.access_token, expires)
      } yield bearer
  }

4.4 Podsumowanie

  • algebraiczne typy danych (ADT) są definiowane jako produkty (final case class) i koprodukty (sealed abstract class).
  • Typy Refined egzekwują ograniczenia na zbiorze możliwych wartości.
  • konkretne funkcje mogą być definiowane wewnątrz klas niejawnych (implicit class), aby zachować kierunek czytania od lewej do prawej.
  • funkcje polimorficzne definiowane są wewnątrz typeklas. Funkcjonalności zapewniane są poprzez ograniczenia kontekstu wyrażające relacje “ma”, a nie hierarchie klas wyrażające relacje “jest”.
  • anotacja @simulacrum.typeclass generuje obiekt .ops wewnątrz obiektu towarzyszącego, zapewniając wygodniejszą składnię dla metod typeklasy.
  • derywacja typeklas to kompozycja typeklas odbywająca się w czasie kompilacji.

5. Typeklasy ze Scalaz

W tym rozdziale przejdziemy przez niemal wszystkie typeklasy zdefiniowane w scalaz-core. Nie wszystkie z nich znajdują zastosowanie w drone-dynamic-agents, więc czasami będziemy używać samodzielnych przykładów.

Napotkać można krytykę w stosunku do konwencji nazewniczych stosowanych w Scalaz i programowaniu funkcyjnym w ogólności. Większość nazw podąża za konwencjami wprowadzonymi w Haskellu, który bazował z kolei na dziale matematyki zwanym Teorią kategorii. Możesz śmiało użyć aliasów typów, jeśli uważasz, że rzeczowniki pochodzące od głównej funkcjonalności są łatwiejsze do zapamiętania (np. Mappable, Pureable, FlatMappable).

Zanim wprowadzimy hierarchię typeklas, popatrzmy na cztery metody, które są najistotniejsze z punktu widzenia kontroli przepływu. Metod tych używać będziemy w większości typowych aplikacji funkcyjnych:

Typeklasa Metoda Z Mając dane Do
Functor map F[A] A => B F[B]
Applicative pure A   F[A]
Monad flatMap F[A] A => F[B] F[B]
Traverse sequence F[G[A]]   G[F[A]]

Wiemy, że operacje zwracające F[_] mogą być wykonywane sekwencyjnie wewnątrz konstrukcji for przy użyciu metody .flatMap, która zdefiniowana jest wewnątrz Monad[F]. Możemy myśleć o F[A] jak o kontenerze na pewien efekt, którego rezultatem jest wartość typu A. .flatMap pozwala nam wygenerować nowe efekty F[B] na podstawie rezultatów wykonania wcześniejszych efektów.

Oczywiście nie wszystkie konstruktory typu F[_] wiążą się z efektami ubocznymi, nawet jeśli mają instancję Monad[F], często są to po prostu struktury danych. Używając najmniej konkretnej (czyli najbardziej ogólnej) abstrakcji możemy w łatwy sposób współdzielić kod operujący na typach List, Either, Future i wielu innych.

Jeśli jedyne czego potrzebujemy to przetransformować wynik F[_], wystarczy, że użyjemy metody map, definiowanej w typeklasie Functor. W rozdziale 3 uruchamialiśmy efekty równolegle, tworząc produkt i mapując go. W Programowaniu Funkcyjnym obliczenia wykonywane równolegle są uznawane za słabsze niż te wykonywane sekwencyjnie.

Pomiędzy Monadą i Functorem leży Applicative, która definiuje metodę pure pozwalającą nam wynosić (lift) wartości do postaci efektów lub tworzyć struktury danych z pojedynczych wartości.

.sequence jest użyteczna, gdy chcemy poprzestawiać konstruktory typów. Gdy mamy F[G[_]] a potrzebujemy G[F[_]], np. zamiast List[Future[Int]] potrzebujemy Future[List[Int]], wtedy właśnie użyjemy .sequence.

5.1 Plan

Ten rozdział jest dłuższy niż zazwyczaj i wypełniony po brzegi informacjami. Przejście przez niego w wielu podejściach jest czymś zupełnie normalnym, a zapamiętanie wszystkiego wymagałoby supermocy. Potraktuj go raczej jako miejsce, do którego możesz wracać, gdy będziesz potrzebował więcej informacji.

Pominięte zostały typeklasy, które rozszerzają typ Monad, gdyż zasłużyły na swój własny rozdział.

Scalaz używa generacji kodu, ale nie za pomocą simulacrum. Niemniej, dla zwięzłości prezentujemy przykłady bazujące na anotacji @typeclass. Równoznaczna składanie dostępna jest, gdy zaimportujemy scalaz._ i Scalaz._, a jej implementacja znajduje się w pakiecie scalaz.syntax w kodzie źródłowym scalaz.

5.2 Rzeczy złączalne20

  @typeclass trait Semigroup[A] {
    @op("|+|") def append(x: A, y: =>A): A
  
    def multiply1(value: F, n: Int): F = ...
  }
  
  @typeclass trait Monoid[A] extends Semigroup[A] {
    def zero: A
  
    def multiply(value: F, n: Int): F =
      if (n <= 0) zero else multiply1(value, n - 1)
  }
  
  @typeclass trait Band[A] extends Semigroup[A]

Semigroup (półgrupa) może być zdefiniowana dla danego typu, jeśli możemy połączyć ze sobą dwie jego wartości. Operacja ta musi być łączna (associative), co oznacza, że kolejność zagnieżdżonych operacji nie powinna mieć znaczenia, np:

  (a |+| b) |+| c == a |+| (b |+| c)
  
  (1 |+| 2) |+| 3 == 1 |+| (2 |+| 3)

Monoid jest półgrupą z elementem zerowym (zero, zwanym również elementem pustym (empty), tożsamym (identity) lub neutralnym). Połączenie zero z dowolną inną wartością a powinno zwracać to samo niezmienione a.

  a |+| zero == a
  
  a |+| 0 == a

Prawdopodobnie przywołaliśmy tym samym wspomnienie typu Numeric z Rozdziału 4. Istnieją implementacje typeklasy Monoid dla wszystkich prymitywnych typów liczbowych, ale koncepcja rzeczy złączalnych jest użyteczna również dla typów innych niż te liczbowe.

  scala> "hello" |+| " " |+| "world!"
  res: String = "hello world!"
  
  scala> List(1, 2) |+| List(3, 4)
  res: List[Int] = List(1, 2, 3, 4)

Band (pas) dodaje prawo, gwarantujące, że append wywołane na dwóch takich samych elementach jest idempotentne, tzn. zwraca tę samą wartość. Przykładem są typy, które mają tylko jedną wartość, takie jak Unit, kresy górne (least upper bound), lub zbiory (Set). Band nie wnosi żadnych dodatkowych metod, ale użytkownicy mogą wykorzystać dodatkowe gwarancje do optymalizacji wydajności.

Jako realistyczny przykład dla Monoidu, rozważmy system transakcyjny, który posiada ogromną bazę reużywalnych wzorów transakcji. Wypełnianie wartości domyślnych dla nowej transakcji wymaga wybrania i połączenia wielu wzorów, z zasadą “ostatni wygrywa”, jeśli dwa wzory posiadają wartości dla tego samego pola. “Wybieranie” jest wykonywane dla nas przez osobny system, a naszym zadaniem jest jedynie połączyć wzory według kolejności.

Stworzymy prosty schemat, aby zobrazować zasadę działania, pamiętając przy tym, że prawdziwy system oparty byłby na dużo bardziej skomplikowanym ADT.

  sealed abstract class Currency
  case object EUR extends Currency
  case object USD extends Currency
  
  final case class TradeTemplate(
    payments: List[java.time.LocalDate],
    ccy: Option[Currency],
    otc: Option[Boolean]
  )

Jeśli chcemy napisać metodę, która przyjmuje parametr templates: List[TradeTemplate], wystarczy, że zawołamy

  val zero = Monoid[TradeTemplate].zero
  templates.foldLeft(zero)(_ |+| _)

i gotowe!

Jednak aby móc zawołać zero lub |+| musimy mieć dostęp do instancji Monoid[TradeTemplate]. Chociaż w ostatnim rozdziale zobaczymy jak wyderywować taką instancję w sposób generyczny, na razie stworzymy ją ręcznie:

  object TradeTemplate {
    implicit val monoid: Monoid[TradeTemplate] = Monoid.instance(
      (a, b) => TradeTemplate(a.payments |+| b.payments,
                              a.ccy |+| b.ccy,
                              a.otc |+| b.otc),
      TradeTemplate(Nil, None, None)
    )
  }

Jednak nie jest to do końca to, czego byśmy chcieli, gdyż Monoid[Option[A]] łączy ze sobą wartości wewnętrzne, np.

  scala> Option(2) |+| None
  res: Option[Int] = Some(2)
  scala> Option(2) |+| Option(1)
  res: Option[Int] = Some(3)

podczas gdy my chcielibyśmy zachowania “ostatni wygrywa”. Możemy więc nadpisać domyślną instancję Monoid[Option[A]] naszą własną:

  implicit def lastWins[A]: Monoid[Option[A]] = Monoid.instance(
    {
      case (None, None)   => None
      case (only, None)   => only
      case (None, only)   => only
      case (_   , winner) => winner
    },
    None
  )

Wszystko kompiluje się poprawnie, więc wypróbujmy nasze dzieło…

  scala> import java.time.{LocalDate => LD}
  scala> val templates = List(
           TradeTemplate(Nil,                     None,      None),
           TradeTemplate(Nil,                     Some(EUR), None),
           TradeTemplate(List(LD.of(2017, 8, 5)), Some(USD), None),
           TradeTemplate(List(LD.of(2017, 9, 5)), None,      Some(true)),
           TradeTemplate(Nil,                     None,      Some(false))
         )
  
  scala> templates.foldLeft(zero)(_ |+| _)
  res: TradeTemplate = TradeTemplate(
                         List(2017-08-05,2017-09-05),
                         Some(USD),
                         Some(false))

Jedyne co musieliśmy zrobić to zdefiniować jeden mały kawałek logiki biznesowej, a całą resztę zrobił za nas Monoid!

Zauważ, że listy płatności są ze sobą łączone. Dzieje się tak, ponieważ domyślny Monoid[List] zachowuje się ten właśnie sposób. Gdyby wymagania biznesowe były inne, wystarczyłoby dostarczyć inną instancję Monoid[List[LocalDate]]. Przypomnij sobie z Rozdziału 4, że dzięki polimorfizmowi czasu kompilacji możemy zmieniać zachowanie append dla List[E] w zależności od E, a nie tylko od implementacji List.

5.3 Rzeczy obiektowe

W rozdziale o Danych i funkcjonalnościach powiedzieliśmy, że sposób, w jaki JVM rozumie równość nie działa dla wielu typów, które możemy umieścić wewnątrz ADT. Problem ten wynika z faktu, że JVM był projektowany dla języka Java, a equals zdefiniowane jest w klasie java.lang.Object, nie ma więc możliwości, aby equals usunąć ani zagwarantować, że jest explicite zaimplementowany.

Niemniej, w FP wolimy używać typeklas do wyrażania polimorficznych zachowań i pojęcie równości również może zostać w ten sposób wyrażone.

  @typeclass trait Equal[F]  {
    @op("===") def equal(a1: F, a2: F): Boolean
    @op("/==") def notEqual(a1: F, a2: F): Boolean = !equal(a1, a2)
  }

=== (potrójne równa się, triple equals) jest bezpieczniejszy względem typów (typesafe) niż == (podwójne równa się, double equals), ponieważ użycie go wymaga, aby po obu stronach porównania znajdowały się instancje dokładnie tego samego typu. W ten sposób możemy zapobiec wielu częstym błędom.

equal ma te same wymagania jak Object.equals

  • przemienność (commutative): f1 === f2 implikuje f2 === f1
  • zwrotność (reflexive): f === f
  • przechodniość (transitive): f1 === f2 && f2 === f3 implikuje f1 === f3

Poprzez odrzucenie uniwersalnego Object.equals, gdy konstruujemy ADT, nie bierzemy za pewnik, że wiemy jak porównywać instancje danego typu. Jeśli instancja Equal nie będzie dostępna, nasz kod się nie skompiluje.

Kontynuując praktykę odrzucania zaszłości z Javy, zamiast mówić, że dane są instancją java.lang.Comparable, powiemy, że mają instancję typeklasy Order:

  @typeclass trait Order[F] extends Equal[F] {
    @op("?|?") def order(x: F, y: F): Ordering
  
    override  def equal(x: F, y: F): Boolean = order(x, y) == Ordering.EQ
    @op("<" ) def lt(x: F, y: F): Boolean = ...
    @op("<=") def lte(x: F, y: F): Boolean = ...
    @op(">" ) def gt(x: F, y: F): Boolean = ...
    @op(">=") def gte(x: F, y: F): Boolean = ...
  
    def max(x: F, y: F): F = ...
    def min(x: F, y: F): F = ...
    def sort(x: F, y: F): (F, F) = ...
  }
  
  sealed abstract class Ordering
  object Ordering {
    case object LT extends Ordering
    case object EQ extends Ordering
    case object GT extends Ordering
  }

Order implementuje .equal, wykorzystując nową metodę prostą .order. Kiedy typeklasa implementuje kombinator prymitywny rodzica za pomocą kombinatora pochodnego, musimy dodać domniemane prawo podstawiania (implied law of substitution). Jeśli instancja Order ma nadpisać .equal z powodów wydajnościowych, musi ona zachowywać się dokładnie tak samo jak oryginał.

Rzeczy, które definiują porządek, mogą również być dyskretne, pozwalając nam na przechodzenie do poprzedników i następników:

  @typeclass trait Enum[F] extends Order[F] {
    def succ(a: F): F
    def pred(a: F): F
    def min: Option[F]
    def max: Option[F]
  
    @op("-+-") def succn(n: Int, a: F): F = ...
    @op("---") def predn(n: Int, a: F): F = ...
  
    @op("|->" ) def fromToL(from: F, to: F): List[F] = ...
    @op("|-->") def fromStepToL(from: F, step: Int, to: F): List[F] = ...
    @op("|=>" ) def fromTo(from: F, to: F): EphemeralStream[F] = ...
    @op("|==>") def fromStepTo(from: F, step: Int, to: F): EphemeralStream[F] = ...
  }
  scala> 10 |--> (2, 20)
  res: List[Int] = List(10, 12, 14, 16, 18, 20)
  
  scala> 'm' |-> 'u'
  res: List[Char] = List(m, n, o, p, q, r, s, t, u)

EphemeralStream omówimy w następnym rozdziale, na razie wystarczy nam wiedzieć, że jest to potencjalnie nieskończona struktura danych, która unika problemów z przetrzymywaniem pamięci obecnych w klasie Stream z biblioteki standardowej.

Podobnie do Object.equals, koncepcja metody .toString dostępnej w każdej klasie ma sens jedynie w Javie. Chcielibyśmy wymusić możliwość konwersji do ciągu znaków w czasie kompilacji i dokładnie to robi typeklasa Show:

  trait Show[F] {
    def show(f: F): Cord = ...
    def shows(f: F): String = ...
  }

Lepiej poznamy klasę Cord w rozdziale poświęconym typom danych, teraz jedyne co musimy wiedzieć, to to że Cord jest wydajną strukturą danych służącą do przechowywania i manipulowania instancjami typu String.

5.4 Rzeczy mapowalne

Skupmy się teraz na rzeczach, które możemy w jakiś sposób przemapowywać (map over) lub trawersować (traverse):

5.4.1 Funktor

  @typeclass trait Functor[F[_]] {
    def map[A, B](fa: F[A])(f: A => B): F[B]
  
    def void[A](fa: F[A]): F[Unit] = map(fa)(_ => ())
    def fproduct[A, B](fa: F[A])(f: A => B): F[(A, B)] = map(fa)(a => (a, f(a)))
  
    def fpair[A](fa: F[A]): F[(A, A)] = map(fa)(a => (a, a))
    def strengthL[A, B](a: A, f: F[B]): F[(A, B)] = map(f)(b => (a, b))
    def strengthR[A, B](f: F[A], b: B): F[(A, B)] = map(f)(a => (a, b))
  
    def lift[A, B](f: A => B): F[A] => F[B] = map(_)(f)
    def mapply[A, B](a: A)(f: F[A => B]): F[B] = map(f)((ff: A => B) => ff(a))
  }

Jedyną metodą abstrakcyjną jest map i musi się ona komponować (składać, compose), tzn. mapowanie za pomocą f, a następnie g musi dawać ten sam wyniki jak mapowanie z użyciem złożenia tych funkcji (f ∘ g).

  fa.map(f).map(g) == fa.map(f.andThen(g))

Metoda map nie może też wykonywać żadnych zmian, jeśli przekazana do niej funkcja to identity (czyli x => x)

  fa.map(identity) == fa
  
  fa.map(x => x) == fa

Functor definiuje kilka pomocniczych metod wokół map, które mogą być zoptymalizowane przez konkretne instancje. Dokumentacja została celowo pominięta, aby zachęcić do samodzielnego odgadnięcia co te metody robią, zanim spojrzymy na ich implementację. Poświęć chwilę na przestudiowanie samych sygnatur, zanim ruszysz dalej:

  def void[A](fa: F[A]): F[Unit]
  def fproduct[A, B](fa: F[A])(f: A => B): F[(A, B)]
  
  def fpair[A](fa: F[A]): F[(A, A)]
  def strengthL[A, B](a: A, f: F[B]): F[(A, B)]
  def strengthR[A, B](f: F[A], b: B): F[(A, B)]
  
  // harder
  def lift[A, B](f: A => B): F[A] => F[B]
  def mapply[A, B](a: A)(f: F[A => B]): F[B]
  1. void przyjmuje instancję F[A] i zawsze zwraca F[Unit], a więc gubi wszelkie przechowywane wartości, ale zachowuje strukturę.
  2. fproduct przyjmuje takie same argumenty jak map, ale zwraca F[(A, B)], a więc łączy wynik operacji z wcześniejszą zawartością. Operacja ta przydaje się, gdy chcemy zachować argumenty przekazane do funkcji.
  3. fpair powiela element A do postaci F[(A, A)]
  4. strengthL łączy zawartość F[B] ze stałą typu A po lewej stronie.
  5. strengthR łączy zawartość F[A] ze stałą typu B po prawej stronie.
  6. lift przyjmuje funkcję A => Bi zwraca F[A] => F[B]. Innymi słowy, przyjmuje funkcję, która operuje na zawartości F[A] i zwraca funkcję, która operuje na F[A] bezpośrednio.
  7. mapply to łamigłówka. Powiedzmy, że mamy F[_] z funkcją A => B w środku oraz wartość A, w rezultacie możemy otrzymać F[B]. Sygnatura wygląda podobnie do pure, ale wymaga od wołającego dostarczenia F[A => B].

fpair, strengthL i strengthR wyglądają całkiem bezużytecznie, ale przydają się, gdy chcemy zachować pewne informacje, które w innym wypadku zostałyby utracone.

Functor ma też specjalną składnię:

  implicit class FunctorOps[F[_]: Functor, A](self: F[A]) {
    def as[B](b: =>B): F[B] = Functor[F].map(self)(_ => b)
    def >|[B](b: =>B): F[B] = as(b)
  }

.as i >| to metody pozwalające na zastąpienie wyniku przez przekazaną stałą.

W naszej przykładowej aplikacji wprowadziliśmy jeden brzydki hak, definiując metody start i stop tak, aby zwracały swoje własne wejście:

  def start(node: MachineNode): F[MachineNode]
  def stop (node: MachineNode): F[MachineNode]

Pozwala nam to opisywać logikę biznesową w bardzo zwięzły sposób, np.

  for {
    _      <- m.start(node)
    update = world.copy(pending = Map(node -> world.time))
  } yield update

albo

  for {
    stopped <- nodes.traverse(m.stop)
    updates = stopped.map(_ -> world.time).toList.toMap
    update  = world.copy(pending = world.pending ++ updates)
  } yield update

Ale hak ten wprowadza zbędną komplikację do implementacji. Lepiej będzie, gdy pozwolimy naszej algebrze zwracać F[Unit] a następnie użyjemy as:

  m.start(node) as world.copy(pending = Map(node -> world.time))

oraz

  for {
    stopped <- nodes.traverse(a => m.stop(a) as a)
    updates = stopped.map(_ -> world.time).toList.toMap
    update  = world.copy(pending = world.pending ++ updates)
  } yield update

5.4.2 Foldable

Technicznie rzecz biorąc, Foldable przydaje się dla struktur danych, przez które możemy przejść a na koniec wyprodukować wartość podsumowującą. Jednak stwierdzenie to nie oddaje pełnej natury tej “jednotypeklasowej armii”, która jest w stanie dostarczyć większość tego, co spodziewalibyśmy się znaleźć w Collections API.

Do omówienia mamy tyle metod, że musimy je sobie podzielić. Zacznijmy od metod abstrakcyjnych:

  @typeclass trait Foldable[F[_]] {
    def foldMap[A, B: Monoid](fa: F[A])(f: A => B): B
    def foldRight[A, B](fa: F[A], z: =>B)(f: (A, =>B) => B): B
    def foldLeft[A, B](fa: F[A], z: B)(f: (B, A) => B): B = ...

Instancja Foldable musi zaimplementować jedynie foldMap i foldRight, aby uzyskać pełną funkcjonalność tej typeklasy, aczkolwiek poszczególne metody są często optymalizowane dla konkretnych struktur danych.

.foldMap ma alternatywną nazwę do celów marketingowych: MapReduce. Mając do dyspozycji F[A], funkcję z A na B i sposób na łączenie B (dostarczony przez Monoid wraz z elementem zerowym), możemy wyprodukować “podsumowującą” wartość typu B. Kolejność operacji nie jest narzucana, co pozwala na wykonywanie ich równolegle.

.foldRight nie wymaga, aby jej parametry miały instancję Monoidu, co oznacza, że musimy podać wartość zerową z oraz sposób łączenia elementów z wartością podsumowująca. Kierunek przechodzenia jest zdefiniowany jako od prawej do lewej, co sprawia, że operacje nie mogą być zrównoleglone.

foldLeft trawersuje elementy od lewej do prawej. Metoda ta może być zaimplementowana za pomocą foldMap, ale większość instancji woli dostarczyć osobną implementację dla tak podstawowej operacji. Ponieważ z reguły implementacje tej metody są ogonowo rekursywne (tail recursive), nie ma tutaj parametrów przekazywanych przez nazwę.

Jedyny prawem obowiązującym Foldable jest to, że foldLeft i foldRight powinny być spójne z foldMap dla operacji monoidalnych, np. dodawanie na koniec listy dla foldLeft i dodawanie na początek dla foldRight. Niemniej foldLeft i foldRight nie muszą być zgodne ze sobą nawzajem: w rzeczywistości często produkują odwrotne rezultaty.

Najprostszą rzeczą, którą możemy zrobić z foldMap to użyć funkcji identity i uzyskać tym samym fold (naturalną sumę elementów monoidalnych), z dodatkowymi wariantami pozwalającymi dobrać odpowiednią metodę zależnie od kryteriów wydajnościowych:

  def fold[A: Monoid](t: F[A]): A = ...
  def sumr[A: Monoid](fa: F[A]): A = ...
  def suml[A: Monoid](fa: F[A]): A = ...

Gdy uczyliśmy się o Monoidzie, napisaliśmy:

  scala> templates.foldLeft(Monoid[TradeTemplate].zero)(_ |+| _)

Teraz wiemy już, że było to niemądre i powinniśmy zrobić tak:

  scala> templates.toIList.fold
  res: TradeTemplate = TradeTemplate(
                         List(2017-08-05,2017-09-05),
                         Some(USD),
                         Some(false))

.fold nie zadziała na klasie List z biblioteki standardowej, ponieważ ta definiuje już metodę o nazwie fold, która robi coś podobnego na swój własny sposób.

Osobliwie nazywająca się metoda intercalate wstawia konkretną instancję typu A pomiędzy każde dwa elementy przed wykonaniem fold.

  def intercalate[A: Monoid](fa: F[A], a: A): A = ...

i jest tym samym uogólnioną wersją mkString:

  scala> List("foo", "bar").intercalate(",")
  res: String = "foo,bar"

foldLeft pozwala na dostęp do konkretnego elementu poprzez jego indeks oraz daje nam kilka innych, blisko związanych metod:

  def index[A](fa: F[A], i: Int): Option[A] = ...
  def indexOr[A](fa: F[A], default: =>A, i: Int): A = ...
  def length[A](fa: F[A]): Int = ...
  def count[A](fa: F[A]): Int = length(fa)
  def empty[A](fa: F[A]): Boolean = ...
  def element[A: Equal](fa: F[A], a: A): Boolean = ...

Scalaz jest biblioteką czystą, składającą się wyłącznie z funkcji totalnych. Tam, gdzie List.apply wyrzuca wyjątek, Foldable.index zwraca Option[A] oraz pozwala użyć wygodnego indexOr, który zwraca A, bazując na wartości domyślnej. .element podobny jest do .contains z biblioteki standardowej, ale używa Equal zamiast niejasno zdefiniowanego pojęcia równości pochodzącego z JVMa.

Metody te naprawdę wyglądają jak API kolekcji. No i oczywiście każdy obiekt mający instancje Foldable może być przekonwertowany na listę:

  def toList[A](fa: F[A]): List[A] = ...

Istnieją również konwersje do innych typów danych zarówno z biblioteki standardowej, jak i Scalaz, takie jak: .toSet, .toVector, .toStream, .to[T <: TraversableLike], .toIList itd.

Dostępne są również przydatne metody do weryfikacji predykatów

  def filterLength[A](fa: F[A])(f: A => Boolean): Int = ...
  def all[A](fa: F[A])(p: A => Boolean): Boolean = ...
  def any[A](fa: F[A])(p: A => Boolean): Boolean = ...

filterLength zlicza elementy, które spełniają predykat, all i any zwracają true, jeśli wszystkie (lub jakikolwiek) elementy spełniają predykat i mogą zakończyć działanie bez sprawdzania wszystkich elementów.

Możemy też podzielić F[A] na części bazując na kluczu B za pomocą metody splitBy

  def splitBy[A, B: Equal](fa: F[A])(f: A => B): IList[(B, Nel[A])] = ...
  def splitByRelation[A](fa: F[A])(r: (A, A) => Boolean): IList[Nel[A]] = ...
  def splitWith[A](fa: F[A])(p: A => Boolean): List[Nel[A]] = ...
  def selectSplit[A](fa: F[A])(p: A => Boolean): List[Nel[A]] = ...
  
  def findLeft[A](fa: F[A])(f: A => Boolean): Option[A] = ...
  def findRight[A](fa: F[A])(f: A => Boolean): Option[A] = ...

na przykład

  scala> IList("foo", "bar", "bar", "faz", "gaz", "baz").splitBy(_.charAt(0))
  res = [(f, [foo]), (b, [bar, bar]), (f, [faz]), (g, [gaz]), (b, [baz])]

Zwróć uwagę, że otrzymaliśmy dwa elementy zaindeksowane za pomocą 'b'.

splitByRelation pozwala uniknąć dostarczania instancji Equal, ale za to wymaga podania operatora porównującego.

splitWith dzieli elementy na grupy, które spełniają i nie spełniają predykatu. selectSplit wybiera grupy elementów, które spełniają predykat, a pozostałe odrzuca. To jedna z tych rzadkich sytuacji gdzie dwie metody mają tę samą sygnaturę, ale działają inaczej.

findLeft i findRight pozwalając znaleźć pierwszy element (od lewej lub prawej), który spełnia predykat.

Dalej korzystając z Equal i Order dostajemy metody distinct, które zwracają elementy unikalne.

  def distinct[A: Order](fa: F[A]): IList[A] = ...
  def distinctE[A: Equal](fa: F[A]): IList[A] = ...
  def distinctBy[A, B: Equal](fa: F[A])(f: A => B): IList[A] =

distinct jest zaimplementowany w sposób bardziej wydajny niż distinctE, ponieważ może bazować na kolejności, a dzięki niej odszukiwać elementy unikalne w sposób podobny do tego, w jaki działa algorytm quicksort. Dzięki temu jest zdecydowanie szybszy niż List.distinct. Struktury danych (takie jak zbiory) mogą implementować distinct w swoich Foldable bez dodatkowego wysiłku.

distinctBy pozwala na grupowanie, bazując na rezultacie wywołania podanej funkcji na każdym z oryginalnych elementów. Przykładowe użycie: grupowanie imion ze względu na pierwszą literę słowa.

Możemy wykorzystać Order również do odszukiwania elementów minimalnych i maksymalnych (lub obu ekstremów), wliczając w to warianty używające Of, lub By aby najpierw przemapować elementy do innego typu lub użyć innego typu do samego porównania.

  def maximum[A: Order](fa: F[A]): Option[A] = ...
  def maximumOf[A, B: Order](fa: F[A])(f: A => B): Option[B] = ...
  def maximumBy[A, B: Order](fa: F[A])(f: A => B): Option[A] = ...
  
  def minimum[A: Order](fa: F[A]): Option[A] = ...
  def minimumOf[A, B: Order](fa: F[A])(f: A => B): Option[B] = ...
  def minimumBy[A, B: Order](fa: F[A])(f: A => B): Option[A] = ...
  
  def extrema[A: Order](fa: F[A]): Option[(A, A)] = ...
  def extremaOf[A, B: Order](fa: F[A])(f: A => B): Option[(B, B)] = ...
  def extremaBy[A, B: Order](fa: F[A])(f: A => B): Option[(A, A)] =

Możemy na przykład zapytać o to, który element typu String jest maksimum ze względu (By) na swoją długość lub jaka jest maksymalna długość elementów (Of).

  scala> List("foo", "fazz").maximumBy(_.length)
  res: Option[String] = Some(fazz)
  
  scala> List("foo", "fazz").maximumOf(_.length)
  res: Option[Int] = Some(4)

Podsumowuje to kluczowe funkcjonalności Foldable. Cokolwiek spodziewalibyśmy się zobaczyć w API kolekcji, jest już prawdopodobnie dostępna dzięki Foldable, a jeśli nie jest, to prawdopodobnie być powinno.

Na koniec spojrzymy na kilka wariacji metod, które widzieliśmy już wcześniej. Zacznijmy od tych, które przyjmują instancję typu Semigroup zamiast Monoid:

  def fold1Opt[A: Semigroup](fa: F[A]): Option[A] = ...
  def foldMap1Opt[A, B: Semigroup](fa: F[A])(f: A => B): Option[B] = ...
  def sumr1Opt[A: Semigroup](fa: F[A]): Option[A] = ...
  def suml1Opt[A: Semigroup](fa: F[A]): Option[A] = ...
  ...

zwracając tym samym Option, aby móc obsłużyć puste struktury danych (Semigroup nie definiuje elementu zerowego).

Typeklasa Foldable1 zawiera dużo więcej wariantów bazujących na Semigroupie (wszystkie z sufiksem 1) i używanie jej ma sens dla struktur, które nigdy nie są puste, nie wymagając definiowania pełnego Monoidu dla elementów.

Co ważne, istnieją również warianty pracujące w oparciu o typy monadyczne. Używaliśmy już foldLeftM, kiedy po raz pierwszy pisaliśmy logikę biznesową naszej aplikacji. Teraz wiemy, że pochodzi ona z Foldable:

  def foldLeftM[G[_]: Monad, A, B](fa: F[A], z: B)(f: (B, A) => G[B]): G[B] = ...
  def foldRightM[G[_]: Monad, A, B](fa: F[A], z: =>B)(f: (A, =>B) => G[B]): G[B] = ...
  def foldMapM[G[_]: Monad, A, B: Monoid](fa: F[A])(f: A => G[B]): G[B] = ...
  def findMapM[M[_]: Monad, A, B](fa: F[A])(f: A => M[Option[B]]): M[Option[B]] = ...
  def allM[G[_]: Monad, A](fa: F[A])(p: A => G[Boolean]): G[Boolean] = ...
  def anyM[G[_]: Monad, A](fa: F[A])(p: A => G[Boolean]): G[Boolean] = ...
  ...

5.4.3 Traverse

Traverse to skrzyżowanie Functora z Foldable.

  trait Traverse[F[_]] extends Functor[F] with Foldable[F] {
    def traverse[G[_]: Applicative, A, B](fa: F[A])(f: A => G[B]): G[F[B]]
    def sequence[G[_]: Applicative, A](fga: F[G[A]]): G[F[A]] = ...
  
    def reverse[A](fa: F[A]): F[A] = ...
  
    def zipL[A, B](fa: F[A], fb: F[B]): F[(A, Option[B])] = ...
    def zipR[A, B](fa: F[A], fb: F[B]): F[(Option[A], B)] = ...
    def indexed[A](fa: F[A]): F[(Int, A)] = ...
    def zipWithL[A, B, C](fa: F[A], fb: F[B])(f: (A, Option[B]) => C): F[C] = ...
    def zipWithR[A, B, C](fa: F[A], fb: F[B])(f: (Option[A], B) => C): F[C] = ...
  
    def mapAccumL[S, A, B](fa: F[A], z: S)(f: (S, A) => (S, B)): (S, F[B]) = ...
    def mapAccumR[S, A, B](fa: F[A], z: S)(f: (S, A) => (S, B)): (S, F[B]) = ...
  }

Na początku rozdziału pokazaliśmy, jak ważne są metody traverse i sequence, gdy chcemy odwrócić kolejność konstruktorów typu (np. z List[Future[_]] na Future[List[_]]).

W Foldable nie mogliśmy założyć, że reverse jest pojęciem uniwersalnym, ale sprawa wygląda zupełnie inaczej, gdy mamy do dyspozycji Traverse.

Możemy też zipować ze sobą dwie rzeczy, które mają instancję Traverse, dostając None, gdy jedna ze stron nie ma już więcej elementów. Specjalnym wariantem tej operacji jest dodanie indeksów do każdego elementu za pomocą indexed.

zipWithL i zipWithR pozwalają połączyć elementy w nowy typ i od razu stworzyć F[C].

mapAccumL i mapAccumR to standardowe map połączone z akumulatorem. Jeśli nawyki z Javy każą nam sięgnąć po zmienna typu var i używać jej wewnątrz map, to najprawdopodobniej powinniśmy użyć mapAccumL.

Powiedzmy, że mamy listę słów i chcielibyśmy ukryć te, które już wcześniej widzieliśmy. Chcemy, aby algorytm działał również dla nieskończonych strumieni danych, a więc kolekcja może być przetworzona jedynie raz.

  scala> val freedom =
  """We campaign for these freedoms because everyone deserves them.
     With these freedoms, the users (both individually and collectively)
     control the program and what it does for them."""
     .split("\\s+")
     .toList
  
  scala> def clean(s: String): String = s.toLowerCase.replaceAll("[,.()]+", "")
  
  scala> freedom
         .mapAccumL(Set.empty[String]) { (seen, word) =>
           val cleaned = clean(word)
           (seen + cleaned, if (seen(cleaned)) "_" else word)
         }
         ._2
         .intercalate(" ")
  
  res: String =
  """We campaign for these freedoms because everyone deserves them.
     With _ _ the users (both individually and collectively)
     control _ program _ what it does _ _"""

Na koniec Traverse1, podobnie jak Foldable1, dostarcza warianty wspomnianych metod dla struktur danych, które nigdy nie są puste, przyjmując słabszą Semigroupę zamiast Monoidu i Apply zamiast Applicative. Przypomnijmy, że Semigroup nie musi dostarczać .empty, a Apply nie wymaga .point.

5.4.4 Align

Align służy do łączenia i wyrównywania wszystkiego, co ma instancję typu Functor. Zanim spojrzymy na Align, poznajmy typ danych \&/ (wymawiany jako te, these lub hurray!),

  sealed abstract class \&/[+A, +B]
  final case class This[A](aa: A) extends (A \&/ Nothing)
  final case class That[B](bb: B) extends (Nothing \&/ B)
  final case class Both[A, B](aa: A, bb: B) extends (A \&/ B)

A więc jest to wyrażenie alternatywy łącznej OR: A lub B, lub A i B jednocześnie.

  @typeclass trait Align[F[_]] extends Functor[F] {
    def alignWith[A, B, C](f: A \&/ B => C): (F[A], F[B]) => F[C]
    def align[A, B](a: F[A], b: F[B]): F[A \&/ B] = ...
  
    def merge[A: Semigroup](a1: F[A], a2: F[A]): F[A] = ...
  
    def pad[A, B]: (F[A], F[B]) => F[(Option[A], Option[B])] = ...
    def padWith[A, B, C](f: (Option[A], Option[B]) => C): (F[A], F[B]) => F[C] = ...

alignWith przyjmuje funkcję z albo A, albo B (albo obu) na C i zwraca wyniesioną funkcję z tupli F[A] i F[B] na F[C]. align konstruuje \&/ z dwóch F[_].

merge pozwala nam połączyć dwie instancje F[A] tak długo, jak jesteśmy w stanie dostarczyć instancję Semigroup[A]. Dla przykładu, Semigroup[Map[K,V]]] deleguje logikę do Semigroup[V], łącząc wartości dla tych samych kluczy, a w konsekwencji sprawiając, że Map[K, List[A]] zachowuje się jak multimapa:

  scala> Map("foo" -> List(1)) merge Map("foo" -> List(1), "bar" -> List(2))
  res = Map(foo -> List(1, 1), bar -> List(2))

a Map[K, Int] po prostu sumuje wartości.

  scala> Map("foo" -> 1) merge Map("foo" -> 1, "bar" -> 2)
  res = Map(foo -> 2, bar -> 2)

.pad i .padWith służą do częściowego łącznie struktur danych, które mogą nie mieć wymaganych wartości po jednej ze stron. Dla przykładu, jeśli chcielibyśmy zagregować niezależne głosy i zachować informację skąd one pochodziły:

  scala> Map("foo" -> 1) pad Map("foo" -> 1, "bar" -> 2)
  res = Map(foo -> (Some(1),Some(1)), bar -> (None,Some(2)))
  
  scala> Map("foo" -> 1, "bar" -> 2) pad Map("foo" -> 1)
  res = Map(foo -> (Some(1),Some(1)), bar -> (Some(2),None))

Istnieją też wygodne warianty align, które używają struktury \&/

  ...
    def alignSwap[A, B](a: F[A], b: F[B]): F[B \&/ A] = ...
    def alignA[A, B](a: F[A], b: F[B]): F[Option[A]] = ...
    def alignB[A, B](a: F[A], b: F[B]): F[Option[B]] = ...
    def alignThis[A, B](a: F[A], b: F[B]): F[Option[A]] = ...
    def alignThat[A, B](a: F[A], b: F[B]): F[Option[B]] = ...
    def alignBoth[A, B](a: F[A], b: F[B]): F[Option[(A, B)]] = ...
  }

i które powinny być jasne po przeczytaniu sygnatur. Przykłady:

  scala> List(1,2,3) alignSwap List(4,5)
  res = List(Both(4,1), Both(5,2), That(3))
  
  scala> List(1,2,3) alignA List(4,5)
  res = List(Some(1), Some(2), Some(3))
  
  scala> List(1,2,3) alignB List(4,5)
  res = List(Some(4), Some(5), None)
  
  scala> List(1,2,3) alignThis List(4,5)
  res = List(None, None, Some(3))
  
  scala> List(1,2,3) alignThat List(4,5)
  res = List(None, None, None)
  
  scala> List(1,2,3) alignBoth List(4,5)
  res = List(Some((1,4)), Some((2,5)), None)

Zauważ, że warianty A i B używają alternatywy łącznej, a This i That są wykluczające, zwracając None, gdy wartość istnieje po obu stronach lub nie istnieje po wskazanej stronie.

5.5 Wariancja

Musimy wrócić na moment do Functora i omówić jego przodka, którego wcześniej zignorowaliśmy

InvariantFunctor, znany również jako funktor wykładniczy, definiuje metodę xmap, która pozwala zamienić F[A] w F[B] jeśli przekażemy do niej funkcje z A na B i z B na A.

Functor to skrócona nazwa na to, co powinno nazywać się funktorem kowariantnym. Podobnie Contravariant to tak naprawdę funktor kontrawariantny.

Functor implementuje metodę xmap za pomocą map i ignoruje funkcję z B na A. Contravariant z kolei implementuje ją z użyciem contramap i ignoruje funkcję z A na B:

  @typeclass trait InvariantFunctor[F[_]] {
    def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B]
    ...
  }
  
  @typeclass trait Functor[F[_]] extends InvariantFunctor[F] {
    def map[A, B](fa: F[A])(f: A => B): F[B]
    def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B] = map(fa)(f)
    ...
  }
  
  @typeclass trait Contravariant[F[_]] extends InvariantFunctor[F] {
    def contramap[A, B](fa: F[A])(f: B => A): F[B]
    def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B] = contramap(fa)(g)
    ...
  }

Co istotne, określenia kowariantny, kontrawariantny i inwariantny, mimo że związane na poziomie teoretycznym, nie przekładają się bezpośrednio na znaną ze Scali wariancję typów (czyli modyfikatory + i - umieszczane przy parametrach typów). Inwariancja oznacza tutaj, że możliwym jest przetłumaczenie zawartości F[A] do F[B]. Używając identity, możemy zobaczyć, że A może być w bezpieczny sposób zrzutowane (w górę lub w dół) do B, zależnie od wariancji funktora.

.map może być rozumiana poprzez swój kontrakt: “jeśli dasz mi F dla A i sposób na zamianę A w B, wtedy dam ci F dla B”.

Podobnie, .contramap mówi, że: “jeśli dasz mi F dla A i sposób na zamianę B w A, wtedy dam ci F dla B”.

Rozważymy następujący przykład: w naszej aplikacji wprowadzamy typy domenowe Alpha, Beta i Gamma, aby zabezpieczyć się przed pomieszaniem liczb w kalkulacjach finansowych:

  final case class Alpha(value: Double)

ale sprawia to, że nie mamy żadnych instancji typeklas dla tych nowych typów. Jeśli chcielibyśmy użyć takich wartości w JSONie, musielibyśmy dostarczyć JsEncoder i JsDecoder.

Jednakże, JsEncoder ma instancję typeklasy Contravariant a JsDecoder typeklasy Functor, a więc możemy wyderywować potrzebne nam instancje, spełniając kontrakt:

  • “jeśli dasz mi JsDecoder dla Double i sposób na zamianę Double w Alpha, wtedy dam ci JsDecoder dla Alpha”.
  • “jeśli dasz mi JsEncoder dla Double i sposób na zamianę Alpha w Double, wtedy dam ci JsEncoder dla Alpha”.
  object Alpha {
    implicit val decoder: JsDecoder[Alpha] = JsDecoder[Double].map(Alpha(_))
    implicit val encoder: JsEncoder[Alpha] = JsEncoder[Double].contramap(_.value)
  }

Metody w klasie mogą ustawić swoje parametry typu w pozycji kontrawariantnej (parametry metody) lub w pozycji kowariantnej (typ zwracany). Jeśli typeklasa łączy pozycje kowariantne i kontrawariantne może oznaczać to, że ma instancję typeklasy InvariantFunctor, ale nie Functor ani Contrawariant.

5.6 Apply i Bind

Potraktuj tę część jako rozgrzewkę przed typami Applicative i Monad

5.6.1 Apply

Apply rozszerza typeklasę Functor poprzez dodanie metody ap, która jest podobna do map w tym, że aplikuje otrzymaną funkcje na wartościach. Jednak w przypadku ap funkcja jest opakowana w ten sam kontekst co wartości, które są do niej przekazywane.

  @typeclass trait Apply[F[_]] extends Functor[F] {
    @op("<*>") def ap[A, B](fa: =>F[A])(f: =>F[A => B]): F[B]
    ...

Warto poświęcić chwilę na zastanowienie się co to znaczy, że prosta struktura danych, taka jak Option[A], posiada następującą implementację .ap

  implicit def option[A]: Apply[Option[A]] = new Apply[Option[A]] {
    override def ap[A, B](fa: =>Option[A])(f: =>Option[A => B]) = f match {
      case Some(ff) => fa.map(ff)
      case None    => None
    }
    ...
  }

Aby zaimplementować .ap, musimy najpierw wydostać funkcję ff: A => B z f: Option[A => B], a następnie możemy przemapować fa z jej użyciem. Ekstrakcja funkcji z kontekstu to ważna funkcjonalność, którą przynosi Apply. Pozwala tym samym na łączenie wielu funkcji wewnątrz jednego kontekstu.

Wracając do Apply, znajdziemy tam rodzinę funkcji applyX, która pozwala nam łączyć równoległe obliczenia, a następnie mapować ich połączone wyniki:

  @typeclass trait Apply[F[_]] extends Functor[F] {
    ...
    def apply2[A,B,C](fa: =>F[A], fb: =>F[B])(f: (A, B) => C): F[C] = ...
    def apply3[A,B,C,D](fa: =>F[A],fb: =>F[B],fc: =>F[C])(f: (A,B,C) =>D): F[D] = ...
    ...
    def apply12[...]

Potraktuj .apply2 jako obietnicę: “jeśli dasz mi F z A i F z B oraz sposób na połączenie A i B w C, wtedy mogę dać ci F z C”. Istnieje wiele zastosowań dla tej obietnicy, a 2 najważniejsze to:

  • tworzenie typeklas dla produktu C z jego składników A i B
  • wykonywanie efektów równolegle, jak w przypadku algebr dla drone i google, które stworzyliśmy w Rozdziale 3, a następnie łączenie ich wyników.

W rzeczy samej, Apply jest na tyle użyteczne, że ma swoją własną składnię:

  implicit class ApplyOps[F[_]: Apply, A](self: F[A]) {
    def *>[B](fb: F[B]): F[B] = Apply[F].apply2(self,fb)((_,b) => b)
    def <*[B](fb: F[B]): F[A] = Apply[F].apply2(self,fb)((a,_) => a)
    def |@|[B](fb: F[B]): ApplicativeBuilder[F, A, B] = ...
  }
  
  class ApplicativeBuilder[F[_]: Apply, A, B](a: F[A], b: F[B]) {
    def tupled: F[(A, B)] = Apply[F].apply2(a, b)(Tuple2(_))
    def |@|[C](cc: F[C]): ApplicativeBuilder3[C] = ...
  
    sealed abstract class ApplicativeBuilder3[C](c: F[C]) {
      ..ApplicativeBuilder4
        ...
          ..ApplicativeBuilder12
  }

której użyliśmy w Rozdziale 3:

  (d.getBacklog |@| d.getAgents |@| m.getManaged |@| m.getAlive |@| m.getTime)

Operatory <* i *> (prawy i lewy ptak) oferują wygodny sposób na zignorowanie wyniku jednego z dwóch równoległych efektów.

Niestety, mimo wygody, którą daje operator |@\, jest z nim jeden problem: dla każdego kolejnego efektu alokowany jest nowy obiekt typu ApplicativeBuilder. Gdy prędkość obliczeń ograniczona jest przez operacje I/O nie ma to znaczenia. Jednak gdy wykonujesz obliczenia w całości na CPU, lepiej jest użyć krotnego wynoszenia (lifting with arity), które nie produkuje żadnych obiektów pośrednich:

  def ^[F[_]: Apply,A,B,C](fa: =>F[A],fb: =>F[B])(f: (A,B) =>C): F[C] = ...
  def ^^[F[_]: Apply,A,B,C,D](fa: =>F[A],fb: =>F[B],fc: =>F[C])(f: (A,B,C) =>D): F[D] = ...
  ...
  def ^^^^^^[F[_]: Apply, ...]

na przykład:

  ^^^^(d.getBacklog, d.getAgents, m.getManaged, m.getAlive, m.getTime)

Możemy też zawołać applyX bezpośrednio:

  Apply[F].apply5(d.getBacklog, d.getAgents, m.getManaged, m.getAlive, m.getTime)

Mimo tego, że Apply używany jest najczęściej z efektami, działa równie dobrze ze strukturami danych. Rozważ przepisanie

  for {
    foo <- data.foo: Option[String]
    bar <- data.bar: Option[Int]
  } yield foo + bar.shows

jako

  (data.foo |@| data.bar)(_ + _.shows)

Gdy chcemy jedynie połączyć wyniki w tuple, istnieją metody, które służą dokładnie do tego:

  @op("tuple") def tuple2[A,B](fa: =>F[A],fb: =>F[B]): F[(A,B)] = ...
  def tuple3[A,B,C](fa: =>F[A],fb: =>F[B],fc: =>F[C]): F[(A,B,C)] = ...
  ...
  def tuple12[...]
  (data.foo tuple data.bar) : Option[(String, Int)]

Dostępne są też uogólnione wersje ap dla więcej niż dwóch parametrów:

  def ap2[A,B,C](fa: =>F[A],fb: =>F[B])(f: F[(A,B) => C]): F[C] = ...
  def ap3[A,B,C,D](fa: =>F[A],fb: =>F[B],fc: =>F[C])(f: F[(A,B,C) => D]): F[D] = ...
  ...
  def ap12[...]

razem z wariantami .lift, które przyjmują zwykłe funkcje i wynoszą je do kontekstu F[_], uogólniając Functor.lift

  def lift2[A,B,C](f: (A,B) => C): (F[A],F[B]) => F[C] = ...
  def lift3[A,B,C,D](f: (A,B,C) => D): (F[A],F[B],F[C]) => F[D] = ...
  ...
  def lift12[...]

oraz .apF, częściowo zaaplikowana wersja ap

  def apF[A,B](f: =>F[A => B]): F[A] => F[B] = ...

A na koniec .forever

  def forever[A, B](fa: F[A]): F[B] = ...

który powtarza efekt w nieskończoność bez zatrzymywania się. Przy jej użyciu instancja Apply musi być zabezpieczona prze przepełnieniem stosu (stack-safe), w przeciwnym wypadku wywołanie spowoduje StackOverflowError.

5.6.2 Bind

Bind wprowadza metodę .bind, która jest synonimiczna do .flatMap i pozwala na mapowanie efektów/struktur danych z użyciem funkcji zwracających nowy efekt/strukturę danych bez wprowadzania dodatkowych zagnieżdżeń.

  @typeclass trait Bind[F[_]] extends Apply[F] {
  
    @op(">>=") def bind[A, B](fa: F[A])(f: A => F[B]): F[B]
    def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] = bind(fa)(f)
  
    override def ap[A, B](fa: =>F[A])(f: =>F[A => B]): F[B] =
      bind(f)(x => map(fa)(x))
    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)))
  
    def join[A](ffa: F[F[A]]): F[A] = bind(ffa)(identity)
  
    def mproduct[A, B](fa: F[A])(f: A => F[B]): F[(A, B)] = ...
    def ifM[B](value: F[Boolean], t: =>F[B], f: =>F[B]): F[B] = ...
  
  }

Metoda .join może wydawać się znajoma tym, którzy używali .flatten z biblioteki standardowej. Przyjmuje ona zagnieżdżone konteksty i łączy je w jeden.

Wprowadzone zostały kombinatory pochodne dla .ap i .apply2, aby zapewnić spójność z .bind. Zobaczymy później, że to wymaganie niesie ze sobą konsekwencje dla potencjalnego zrównoleglania obliczeń.

mproduct przypomina Functor.fproduct i paruje wejście i wyjście funkcji wewnątrz F.

ifM to sposób na tworzenie warunkowych struktur danych lub efektów:

  scala> List(true, false, true).ifM(List(0), List(1, 1))
  res: List[Int] = List(0, 1, 1, 0)

ifM i ap są zoptymalizowane do cachowania i reużywania gałezi kodu. Porównajmy je z dłuższą wersją

  scala> List(true, false, true).flatMap { b => if (b) List(0) else List(1, 1) }

która produkuje nowe List(0) i List(1, 1) za każdym razem, gdy dana gałąź jest wywoływana.

Bind wprowadza też specjalne operatory:

  implicit class BindOps[F[_]: Bind, A] (self: F[A]) {
    def >>[B](b: =>F[B]): F[B] = Bind[F].bind(self)(_ => b)
    def >>![B](f: A => F[B]): F[A] = Bind[F].bind(self)(a => f(a).map(_ => a))
  }

Używając >> odrzucamy wejście do bind, a używając >>! odrzucamy wyjście`

5.7 Aplikatywy i monady

Z punkty widzenia oferowanych funkcjonalności, Applicative to Apply z dodaną metodą pure, a Monad rozszerza Applicative, dodając Bind.

  @typeclass trait Applicative[F[_]] extends Apply[F] {
    def point[A](a: =>A): F[A]
    def pure[A](a: =>A): F[A] = point(a)
  }
  
  @typeclass trait Monad[F[_]] extends Applicative[F] with Bind[F]

Pod wieloma względami Applicative i Monad są zwieńczeniem wszystkiego, co do tej pory widzieliśmy w tym rozdziale. .pure (lub .point - alias powszechnie używany przy strukturach danych) pozwala nam na tworzenie efektów lub struktur danych z pojedynczych wartości.

Instancje Applicative muszę spełniać prawa gwarantujące spójność metod:

  • Tożsamość (Identity): fa <*> pure(identity) == fa (gdzie fa to F[A]) - zaaplikowanie pure(identity) nic nie zmienia
  • Homomorfizm (Homomorphism): pure(a) <*> pure(ab) === pure(ab(a)), (gdzie ab to funkcja A => B) - zaaplikowanie funkcji osadzonej w kontekście F za pomocą pure na wartości potraktowanej w ten sam sposób jest równoznaczne z wywołaniem tej funkcji na wspomnianej wartości i wywołaniem pure na rezultacie.
  • Zamiana (Interchange): pure(a) <*> fab === fab <*> pure(f => f(a)), (gdzie fab to F[A => B]) - pure jest tożsama lewo- i prawostronnie
  • Mappy: map(fa)(f) === fa <*> pure(f)

Monad dodaje następujące prawa

  • Tożsamość lewostronna (Left Identity): pure(a).bind(f) === f(a)
  • Tożsamość prawostronna (Right Identity): a.bind(pure(_)) === a
  • Łączność (Associativity): fa.bind(f).bind(g) === fa.bind(a => f(a).bind(g)) gdzie fa to F[A], f to A => F[B], a g to B => F[C].

Łączność mówi nam, że połączone wywołania bind muszą być zgodne z wywołaniami zagnieżdżonymi. Jednakże, nie oznacza to, że możemy zamieniać kolejność wywołań - to gwarantowałaby przemienność (commutativity). Dla przykładu, pamiętając, że flatMap to alias na bind, nie możemy zamienić

  for {
    _ <- machine.start(node1)
    _ <- machine.stop(node1)
  } yield true

na

  for {
    _ <- machine.stop(node1)
    _ <- machine.start(node1)
  } yield true

start i stopnieprzemienne, ponieważ uruchomienie, a następnie zatrzymanie węzła jest czymś innym niż zatrzymanie i uruchomienie.

Nie mniej, zarówno start, jak i stop są przemienne same ze sobą samym, a więc możemy zamienić

  for {
    _ <- machine.start(node1)
    _ <- machine.start(node2)
  } yield true

na

  for {
    _ <- machine.start(node2)
    _ <- machine.start(node1)
  } yield true

Obie formy są równoznaczne w tym konkretnym przypadku, ale nie w ogólności. Robimy tutaj dużo założeń co do Google Container API, ale wydaje się to być rozsądnych wyjściem.

Okazuje się, że w konsekwencji powyższych praw Monada musi być przemienna, jeśli chcemy pozwolić na równoległe działanie metod applyX. W Rozdziale 3 oszukaliśmy uruchamiając efekty w ten sposób

  (d.getBacklog |@| d.getAgents |@| m.getManaged |@| m.getAlive |@| m.getTime)

ponieważ wiedzieliśmy, że są one ze sobą przemienne. Kiedy w dalszych rozdziałach zajmiemy się interpretacją naszej aplikacji, dostarczymy dowód na przemienność operacji lub pozwolimy na uruchomienie ich sekwencyjnie.

Subtelności sposobów radzenia sobie z porządkowaniem efektów, i tym, czym te efekty tak naprawdę są, zasługują na osobny rozdział. Porozmawiamy o nich przy Zaawansowanych monadach.

5.8 Dziel i rządź

Divide to kontrawariantny odpowiednik Apply

  @typeclass trait Divide[F[_]] extends Contravariant[F] {
    def divide[A, B, C](fa: F[A], fb: F[B])(f: C => (A, B)): F[C] = divide2(fa, fb)(f)
  
    def divide1[A1, Z](a1: F[A1])(f: Z => A1): F[Z] = ...
    def divide2[A, B, C](fa: F[A], fb: F[B])(f: C => (A, B)): F[C] = ...
    ...
    def divide22[...] = ...

divide mówi nam, że jeśli potrafimy podzielić C na A i B oraz mamy do dyspozycji F[A] i F[B] to możemy stworzyć F[C]. Stąd też dziel i rządź.

Jest to świetny sposób na generowanie instancji kowariantnych typeklas dla typów będących produktami poprzez podzielenie tychże produktów na części. Scalaz oferuje instancje Divide[Equal], spróbujmy więc stworzyć Equal dla nowego typu Foo.

  scala> case class Foo(s: String, i: Int)
  scala> implicit val fooEqual: Equal[Foo] =
           Divide[Equal].divide2(Equal[String], Equal[Int]) {
             (foo: Foo) => (foo.s, foo.i)
           }
  scala> Foo("foo", 1) === Foo("bar", 1)
  res: Boolean = false

Podążając za Apply, Divide również dostarcza zwięzłą składnię dla tupli

  ...
    def tuple2[A1, A2](a1: F[A1], a2: F[A2]): F[(A1, A2)] = ...
    ...
    def tuple22[...] = ...
  }

Ogólnie rzecz biorąc, jeśli typeklasa, oprócz instancji Contravariant, jest w stanie dostarczyć również Divide, to znaczy, że jesteśmy w stanie wyderywować jej instancje dla dowolnej case klasy. Sprawa wygląda analogicznie dla typeklas kowariantnych z instancją Apply. Zgłębimy ten temat w rozdziale poświęconym Derywacji typeklas.

Divisible to odpowiednik Applicative dla rodziny Contravariant. Wprowadzana ona metodę .conquer, odpowiednik .pure:

  @typeclass trait Divisible[F[_]] extends Divide[F] {
    def conquer[A]: F[A]
  }

.conquer pozwala na tworzenie trywialnych implementacji, w których parametr typu jest ignorowany. Takie instancje nazywane są ogólnie kwantyfikowanymi (universally quantified). Na przykład, Divisible[Equal].conquer[INil[String]] tworzy instancję Equal, która zawsze zwraca true.

5.9 Plus

Plus to Semigroupa dla konstruktorów typu a PlusEmpty to odpowiednik Monoidu (obowiązują ich nawet te same prawa). Nowością jest typeklasa IsEmpty, która pozwala na sprawdzenie czy F[A] jest puste:

  @typeclass trait Plus[F[_]] {
    @op("<+>") def plus[A](a: F[A], b: =>F[A]): F[A]
  }
  @typeclass trait PlusEmpty[F[_]] extends Plus[F] {
    def empty[A]: F[A]
  }
  @typeclass trait IsEmpty[F[_]] extends PlusEmpty[F] {
    def isEmpty[A](fa: F[A]): Boolean
  }

Pozornie może się wydawać, że <+> zachowuje się tak samo, jak |+|:

  scala> List(2,3) |+| List(7)
  res = List(2, 3, 7)
  
  scala> List(2,3) <+> List(7)
  res = List(2, 3, 7)

Najlepiej jest przyjąć, że <+> operuje jedynie na F[_] nigdy nie patrząc na zawartość. Przyjęła się konwencja, że Plus ignoruje porażki i wybiera “pierwszego zwycięzcę”. Dzięki temu <+> może być używany jako mechanizm szybkiego wyjścia oraz obsługi porażek przez fallbacki.

  scala> Option(1) |+| Option(2)
  res = Some(3)
  
  scala> Option(1) <+> Option(2)
  res = Some(1)
  
  scala> Option.empty[Int] <+> Option(1)
  res = Some(1)

Na przykład, jeśli chcielibyśmy pominąć obiekty None wewnątrz NonEmptyList[Option[Int]] i wybrać pierwszego zwycięzcę (Some), możemy użyć <+> w połączeniu z Foldable1.foldRight1:

  scala> NonEmptyList(None, None, Some(1), Some(2), None)
         .foldRight1(_ <+> _)
  res: Option[Int] = Some(1)

Teraz, gdy znamy już Plus, okazuje się, że wcale nie musieliśmy zaburzać koherencji typeklas w sekcji o Rzeczach złączalnych (definiując lokalną instancję Monoid[Option[A]]). Naszym celem było “wybranie ostatniego zwycięzcy”, co jest tożsame z wybranie pierwszego po odwróceniu kolejności elementów. Zwróć uwagę na użycie Interceptora TIE z ccy i otc w odwróconej kolejności.

  implicit val monoid: Monoid[TradeTemplate] = Monoid.instance(
    (a, b) => TradeTemplate(a.payments |+| b.payments,
                            b.ccy <+> a.ccy,
                            b.otc <+> a.otc),
    TradeTemplate(Nil, None, None)
  )

Applicative i Monad mają wyspecjalizowaną wersję PlusEmpty

  @typeclass trait ApplicativePlus[F[_]] extends Applicative[F] with PlusEmpty[F]
  
  @typeclass trait MonadPlus[F[_]] extends Monad[F] with ApplicativePlus[F] {
    def unite[T[_]: Foldable, A](ts: F[T[A]]): F[A] = ...
  
    def withFilter[A](fa: F[A])(f: A => Boolean): F[A] = ...
  }

.unite pozwala nam zwinąć strukturę danych, używając PlusEmpty[F].monoid zamiast Monoidu zdefiniowanego dla typu wewnętrznego. Dla List[Either[String, Int]] oznacza to, że instancje Left[String] zamieniane są na .empty, a następnie wszytko jest złączane. Jest to wygodny sposób na pozbycie się błędów:

  scala> List(Right(1), Left("boo"), Right(2)).unite
  res: List[Int] = List(1, 2)
  
  scala> val boo: Either[String, Int] = Left("boo")
         boo.foldMap(a => a.pure[List])
  res: List[String] = List()
  
  scala> val n: Either[String, Int] = Right(1)
         n.foldMap(a => a.pure[List])
  res: List[Int] = List(1)

withFilter pozwala nam na użycie konstrukcji for, którą opisywaliśmy w Rozdziale 2. Można nawet powiedzieć, że Scala ma wbudowane wsparcie nie tylko dla Monad, ale i MonadPlus!

Wracając na moment do Foldable, możemy odkryć kilka metod, których wcześniej nie omawialiśmy:

  @typeclass trait Foldable[F[_]] {
    ...
    def msuml[G[_]: PlusEmpty, A](fa: F[G[A]]): G[A] = ...
    def collapse[X[_]: ApplicativePlus, A](x: F[A]): X[A] = ...
    ...
  }

msuml wykonuje fold, używając Monoidu z PlusEmpty[G], a collapse używa foldRight w kombinacji z instancją PlusEmpty typu docelowego:

  scala> IList(Option(1), Option.empty[Int], Option(2)).fold
  res: Option[Int] = Some(3) // uses Monoid[Option[Int]]
  
  scala> IList(Option(1), Option.empty[Int], Option(2)).msuml
  res: Option[Int] = Some(1) // uses PlusEmpty[Option].monoid
  
  scala> IList(1, 2).collapse[Option]
  res: Option[Int] = Some(1)

5.10 Samotne wilki

Niektóre z typeklas w Scalaz są w pełni samodzielne i nie należą do ogólnej hierarchii.

5.10.1 Zippy

  @typeclass trait Zip[F[_]]  {
    def zip[A, B](a: =>F[A], b: =>F[B]): F[(A, B)]
  
    def zipWith[A, B, C](fa: =>F[A], fb: =>F[B])(f: (A, B) => C)
                        (implicit F: Functor[F]): F[C] = ...
  
    def ap(implicit F: Functor[F]): Apply[F] = ...
  
    @op("<*|*>") def apzip[A, B](f: =>F[A] => F[B], a: =>F[A]): F[(A, B)] = ...
  
  }

Metoda kluczowa tutaj to zip. Jest to słabsza wersja Divide.tuple2. Jeśli dostępny jest Functor[F] to .zipWith może zachowywać się jak Apply.apply2. Używając ap, możemy nawet stworzyć pełnoprawne Apply[F] z instancji Zip[F] i Functor[F].

.apzip przyjmuje F[A] i wyniesioną funkcję F[A] => F[B] produkując F[(A, B)], podobnie do Functor.fproduct.

  @typeclass trait Unzip[F[_]]  {
    @op("unfzip") def unzip[A, B](a: F[(A, B)]): (F[A], F[B])
  
    def firsts[A, B](a: F[(A, B)]): F[A] = ...
    def seconds[A, B](a: F[(A, B)]): F[B] = ...
  
    def unzip3[A, B, C](x: F[(A, (B, C))]): (F[A], F[B], F[C]) = ...
    ...
    def unzip7[A ... H](x: F[(A, (B, ... H))]): ...
  }

Bazą jest unzip dzielący F[(A,B)] na F[A] i F[B], a firsts i seconds pozwalają na wybranie jednej z części. Co ważne, unzip jest odwrotnością zip.

Metody od unzip3 do unzip7 to aplikacje unzip pozwalające zmniejszyć ilość boilerplatu. Na przykład, jeśli dostaniemy garść zagnieżdżonych tupli to Unzip[Id] jest wygodnym sposobem na ich wypłaszczenie:

  scala> Unzip[Id].unzip7((1, (2, (3, (4, (5, (6, 7)))))))
  res = (1,2,3,4,5,6,7)

W skrócie, Zip i Unzip są słabszymi wersjami Divide i Apply dostarczającymi użyteczne funkcjonalności bez zobowiązywania F do składania zbyt wielu obietnic.

5.10.2 Optional

Optional to uogólnienie struktur danych, które mogą opcjonalnie zawierać jakąś wartość, np. Option lub Either.

Przypomnijmy, że \/ (dysjunkcja) ze Scalaz jest ulepszoną wersją scala.Either. Poznamy też Maybe - ulepszoną wersję scala.Option.

  sealed abstract class Maybe[A]
  final case class Empty[A]()    extends Maybe[A]
  final case class Just[A](a: A) extends Maybe[A]
  @typeclass trait Optional[F[_]] {
    def pextract[B, A](fa: F[A]): F[B] \/ A
  
    def getOrElse[A](fa: F[A])(default: =>A): A = ...
    def orElse[A](fa: F[A])(alt: =>F[A]): F[A] = ...
  
    def isDefined[A](fa: F[A]): Boolean = ...
    def nonEmpty[A](fa: F[A]): Boolean = ...
    def isEmpty[A](fa: F[A]): Boolean = ...
  
    def toOption[A](fa: F[A]): Option[A] = ...
    def toMaybe[A](fa: F[A]): Maybe[A] = ...
  }

Powyższe metody powinny wydawać się znajome, może z wyjątkiem pextract, która pozwala F[_] na zwrócenie przechowywanej wartości lub specyficznego dla implementacji F[B]. Na przykład Optional[Option].pextract zwróci nam Option[Nothing] \/ A, czyli None \/ A.

Scalaz daje nam operator trenarny dla wszystkich typów mających swoją instancję Optional.

  implicit class OptionalOps[F[_]: Optional, A](fa: F[A]) {
    def ?[X](some: =>X): Conditional[X] = new Conditional[X](some)
    final class Conditional[X](some: =>X) {
      def |(none: =>X): X = if (Optional[F].isDefined(fa)) some else none
    }
  }

Przykład:

  scala> val knock_knock: Option[String] = ...
         knock_knock ? "who's there?" | "<tumbleweed>"

5.11 Ko-rzeczy

Ko-rzecz zazwyczaj ma sygnaturę przeciwną do tego, co robi rzecz, ale nie musi koniecznie być jej odwrotnością. Aby podkreślić relacje między rzeczą i ko-rzeczą, wszędzie gdzie to możliwe zawrzemy obie sygnatury.

5.11.1 Cobind

  @typeclass trait Cobind[F[_]] extends Functor[F] {
    def cobind[A, B](fa: F[A])(f: F[A] => B): F[B]
  //def   bind[A, B](fa: F[A])(f: A => F[B]): F[B]
  
    def cojoin[A](fa: F[A]): F[F[A]] = ...
  //def   join[A](ffa: F[F[A]]): F[A] = ...
  }

cobind (znany również jako coflatmap) przyjmuje funkcję F[A] => B, która operuje na F[A], a nie jego elementach. Ale nie zawsze będzie to pełne fa, często jest to substruktura stworzona przez metodę cojoin (znaną również jako coflatten), która rozwija strukturę danych.

Przekonywające przykłady użycia Cobind są rzadkie, jednak kiedy spojrzymy na tabele permutacji metod typeklasy Functor, ciężko jest uzasadnić, czemu niektóre metody miałyby być ważniejsze od innych.

method parameter
map A => B
contramap B => A
xmap (A => B, B => A)
ap F[A => B]
bind A => F[B]
cobind F[A] => B

5.11.2 Comonad

  @typeclass trait Comonad[F[_]] extends Cobind[F] {
    def copoint[A](p: F[A]): A
  //def   point[A](a: =>A): F[A]
  }

.copoint (znany też jako .copure) wydostaje element z kontekstu. Efekty z reguły nie posiadają instancji tej typeklasy, gdyż na przykład interpretacja IO[A] do A zaburza transparentność referencyjną. Dla struktur danych jednakże może to być na przykład wygodny sposób na pokazanie wszystkich elementów wraz z ich sąsiadami.

Rozważmy strukturę sąsiedztwa (Hood), która zawiera pewien element (focus) oraz elementy na lewo i prawo od niego (lefts i rights).

  final case class Hood[A](lefts: IList[A], focus: A, rights: IList[A])

lefts i right powinny być uporządkowane od najbliższego do najdalszego elementu względem elementu środkowego focus, tak abyśmy mogli przekonwertować taką strukturę do IList za pomocą poniższej implementacji

  object Hood {
    implicit class Ops[A](hood: Hood[A]) {
      def toIList: IList[A] = hood.lefts.reverse ::: hood.focus :: hood.rights

Możemy zaimplementować metody do poruszania się w lewo (previous) i w prawo (next)

  ...
      def previous: Maybe[Hood[A]] = hood.lefts match {
        case INil() => Empty()
        case ICons(head, tail) =>
          Just(Hood(tail, head, hood.focus :: hood.rights))
      }
      def next: Maybe[Hood[A]] = hood.rights match {
        case INil() => Empty()
        case ICons(head, tail) =>
          Just(Hood(hood.focus :: hood.lefts, head, tail))
      }

Wprowadzając metodę more, jesteśmy w stanie obliczyć wszystkie możliwe do osiągnięcia pozycje (positions) w danym Hood.

  ...
      def more(f: Hood[A] => Maybe[Hood[A]]): IList[Hood[A]] =
        f(hood) match {
          case Empty() => INil()
          case Just(r) => ICons(r, r.more(f))
        }
      def positions: Hood[Hood[A]] = {
        val left  = hood.more(_.previous)
        val right = hood.more(_.next)
        Hood(left, hood, right)
      }
    }

Możemy teraz stworzyć Comonad[Hood]

  ...
    implicit val comonad: Comonad[Hood] = new Comonad[Hood] {
      def map[A, B](fa: Hood[A])(f: A => B): Hood[B] =
        Hood(fa.lefts.map(f), f(fa.focus), fa.rights.map(f))
      def cobind[A, B](fa: Hood[A])(f: Hood[A] => B): Hood[B] =
        fa.positions.map(f)
      def copoint[A](fa: Hood[A]): A = fa.focus
    }
  }

cojoin daje nam Hood[Hood[IList]] zawierające wszystkie możliwe sąsiedztwa w naszej początkowej liście

  scala> val middle = Hood(IList(4, 3, 2, 1), 5, IList(6, 7, 8, 9))
  scala> middle.cojoin
  res = Hood(
          [Hood([3,2,1],4,[5,6,7,8,9]),
           Hood([2,1],3,[4,5,6,7,8,9]),
           Hood([1],2,[3,4,5,6,7,8,9]),
           Hood([],1,[2,3,4,5,6,7,8,9])],
          Hood([4,3,2,1],5,[6,7,8,9]),
          [Hood([5,4,3,2,1],6,[7,8,9]),
           Hood([6,5,4,3,2,1],7,[8,9]),
           Hood([7,6,5,4,3,2,1],8,[9]),
           Hood([8,7,6,5,4,3,2,1],9,[])])

Okazuje się, że cojoin to tak naprawdę positions! A więc możemy nadpisać ją, używając bezpośredniej (a przez to wydajniejszej) implementacji

  override def cojoin[A](fa: Hood[A]): Hood[Hood[A]] = fa.positions

Comonad generalizuje koncepcję sąsiedztwa dla arbitralnych struktur danych. Hood jest przykładem zippera (brak związku z Zip). Scalaz definiuje typ danych Zipper dla strumieni (jednowymiarowych nieskończonych struktur danych), które omówimy w następnym rozdziale.

Jednym z zastosowanie zippera jest automat komórkowy (cellular automata), który wylicza wartość każdej komórki w następnej generacji na podstawie aktualnych wartości sąsiadów tej komórki.

5.11.3 Cozip

  @typeclass trait Cozip[F[_]] {
    def cozip[A, B](x: F[A \/ B]): F[A] \/ F[B]
  //def   zip[A, B](a: =>F[A], b: =>F[B]): F[(A, B)]
  //def unzip[A, B](a: F[(A, B)]): (F[A], F[B])
  
    def cozip3[A, B, C](x: F[A \/ (B \/ C)]): F[A] \/ (F[B] \/ F[C]) = ...
    ...
    def cozip7[A ... H](x: F[(A \/ (... H))]): F[A] \/ (... F[H]) = ...
  }

Mimo że nazwa tej typeklasy brzmi Cozip, lepiej jest spojrzeć na jej symetrię względem metody unzip. Tam, gdzie unzip zamienia F[_] zawierające produkt (tuple) na produkt zawierający F[_], tam cozip zamienia F[_] zawierające koprodukty (dysjunkcje) na koprodukt zawierający F[_].

5.12 Bi-rzeczy

Czasem mamy do czynienia z typami, które przyjmują dwa parametry typu i chcielibyśmy przemapować obie jego strony. Możemy na przykład śledzić błędy po lewej stronie Either i chcieć przetransformować wiadomości z tychże błędów.

Typeklasy Functor / Foldable / Traverse mają swoich krewnych, którzy pozwalają nam mapować obie strony wspieranych typów.

  @typeclass trait Bifunctor[F[_, _]] {
    def bimap[A, B, C, D](fab: F[A, B])(f: A => C, g: B => D): F[C, D]
  
    @op("<-:") def leftMap[A, B, C](fab: F[A, B])(f: A => C): F[C, B] = ...
    @op(":->") def rightMap[A, B, D](fab: F[A, B])(g: B => D): F[A, D] = ...
    @op("<:>") def umap[A, B](faa: F[A, A])(f: A => B): F[B, B] = ...
  }
  
  @typeclass trait Bifoldable[F[_, _]] {
    def bifoldMap[A, B, M: Monoid](fa: F[A, B])(f: A => M)(g: B => M): M
  
    def bifoldRight[A,B,C](fa: F[A, B], z: =>C)(f: (A, =>C) => C)(g: (B, =>C) => C): C
    def bifoldLeft[A,B,C](fa: F[A, B], z: C)(f: (C, A) => C)(g: (C, B) => C): C = ...
  
    def bifoldMap1[A, B, M: Semigroup](fa: F[A,B])(f: A => M)(g: B => M): Option[M] = ...
  }
  
  @typeclass trait Bitraverse[F[_, _]] extends Bifunctor[F] with Bifoldable[F] {
    def bitraverse[G[_]: Applicative, A, B, C, D](fab: F[A, B])
                                                 (f: A => G[C])
                                                 (g: B => G[D]): G[F[C, D]]
  
    def bisequence[G[_]: Applicative, A, B](x: F[G[A], G[B]]): G[F[A, B]] = ...
  }

Mimo że sygnatury metod są dość rozwlekłe, to są to niemal dokładnie te same metody, które znamy z typeklas Functor, Foldable i Traverse, z tą różnicą, że przyjmują dwie funkcje zamiast jednej. Czasami funkcje te muszą zwracać ten sam typ, aby wyniki można było połączyć za pomocą Monoidu lub Semigroupy.

  scala> val a: Either[String, Int] = Left("fail")
         val b: Either[String, Int] = Right(13)
  
  scala> b.bimap(_.toUpperCase, _ * 2)
  res: Either[String, Int] = Right(26)
  
  scala> a.bimap(_.toUpperCase, _ * 2)
  res: Either[String, Int] = Left(FAIL)
  
  scala> b :-> (_ * 2)
  res: Either[String,Int] = Right(26)
  
  scala> a :-> (_ * 2)
  res: Either[String, Int] = Left(fail)
  
  scala> { s: String => s.length } <-: a
  res: Either[Int, Int] = Left(4)
  
  scala> a.bifoldMap(_.length)(identity)
  res: Int = 4
  
  scala> b.bitraverse(s => Future(s.length), i => Future(i))
  res: Future[Either[Int, Int]] = Future(<not completed>)

Dodatkowo możemy wrócić na chwile do MonadPlus (czyli Monady z metodami filterWith i unite), aby zobaczyć, że potrafi ona rozdzielać (separate) zawartość Monady, jeśli tylko jej typ ma instancję Bifoldable.

  @typeclass trait MonadPlus[F[_]] {
    ...
    def separate[G[_, _]: Bifoldable, A, B](value: F[G[A, B]]): (F[A], F[B]) = ...
    ...
  }

Jest to bardzo przydatny mechanizm, kiedy mamy do czynienia z kolekcją bi-rzeczy i chcemy podzielić ją na kolekcję A i kolekcję B.

  scala> val list: List[Either[Int, String]] =
           List(Right("hello"), Left(1), Left(2), Right("world"))
  
  scala> list.separate
  res: (List[Int], List[String]) = (List(1, 2), List(hello, world))

5.13 Podsumowanie

Dużo tego! Właśnie odkryliśmy standardową bibliotekę polimorficznych funkcjonalności. Ale patrząc na to z innej perspektywy: w Collections API z biblioteki standardowej Scali jest więcej traitów niż typeklas w Scalaz.

To całkiem normalne, jeśli twoja czysto funkcyjna aplikacja korzysta jedynie z małej części omówionych typeklas, a większość funkcjonalności czerpie z typeklas i algebr domenowych. Nawet jeśli twoje domenowe typeklasy są tylko wyspecjalizowanymi odpowiednikami tych zdefiniowanych w Scalaz, to jest zupełnie ok, aby zrefaktorować je później.

Aby ułatwić nieco sprawę, dołączyliśmy cheat-sheet wszystkich typeklas i ich głównych metod w załączniku. Jest on zainspirowany przez Scalaz Cheatsheet Adama Rosiena.

Aby pomóc jeszcze bardziej, Valentin Kasas pokazuję jak połączyć N rzeczy

6. Typy danych ze Scalaz

Kto nie kocha porządnej struktury danych? Odpowiedź brzmi nikt, a struktury danych są super!

W tym rozdziale poznamy typy danych przypominające kolekcje oraz takie, które wzbogacają Scalę o dodatkowe możliwości i zwiększają bezpieczeństwo typów.

Podstawowym powodem, dla którego używamy wielu różnych typów kolekcji, jest wydajność. Wektor i lista mogą zrobić to samo, ale ich charakterystyki wydajnościowe są inne: wektor oferuje dostęp do losowego elementu w czasie stałym, podczas gdy lista musi zostać w czasie tej operacji przetrawersowana.

Wszystkie kolekcje, które tutaj zaprezentujemy, są trwałe (persistent): jeśli dodamy lub usuniemy element, nadal możemy używać poprzedniej, niezmienionej wersji. Współdzielenie strukturalne (structural sharing) jest kluczowe dla wydajności trwałych struktur danych, gdyż bez tego musiałyby one być tworzone od nowa przy każdej operacji.

W przeciwieństwie do kolekcji z bibliotek standardowych Javy i Scali, w Scalaz typy danych nie tworzą hierarchii, a przez to są dużo prostsze do zrozumienia. Polimorfizm jest zapewniany przez zoptymalizowane instancje typeklas, które poznaliśmy w poprzednim rozdziale. Sprawia to, że zmiana implementacji podyktowana zwiększeniem wydajności, lub dostarczenie własnej, jest dużo prostsze.

6.1 Wariancja typów

Wiele z typów danych zdefiniowanych w Scalaz jest inwariantna. Dla przykładu IList[A] nie jest podtypem IList[B] nawet jeśli A <: B.

6.1.1 Kowariancja

Problem z kowariantnymi parametrami typu, takimi jak A w class List[+A], jest taki, że List[A] jest podtypem (a więc dziedziczy po) List[Any] i bardzo łatwo jest przez przypadek zgubić informacje o typach.

  scala> List("hello") ++ List(' ') ++ List("world!")
  res: List[Any] = List(hello,  , world!)

Zauważ, że druga lista jest typu List[Char] i kompilator niezbyt pomocnie wyinferował Any jako Najmniejszą Górną Granicę (Least Upper Bound, LUB). Porównajmy to z IList, która wymaga bezpośredniego wywołania .widen[Any], aby pozwolić na ten haniebny uczynek:

  scala> IList("hello") ++ IList(' ') ++ IList("world!")
  <console>:35: error: type mismatch;
   found   : Char(' ')
   required: String
  
  scala> IList("hello").widen[Any]
           ++ IList(' ').widen[Any]
           ++ IList("world!").widen[Any]
  res: IList[Any] = [hello, ,world!]

Podobnie, gdy kompilator inferuje typ z dopiskiem with Product with Serializable to najprawdopodobniej miało miejsce przypadkowe rozszerzenie typu spowodowane kowariancją.

Niestety, musimy uważać, nawet gdy konstruujemy typy inwariantne, ponieważ obliczenie LUB wykonywane jest również dla parametrów typu:

  scala> IList("hello", ' ', "world")
  res: IList[Any] = [hello, ,world]

Podobny problem powodowany jest przez typ Nothing, który jest podtypem wszystkich innych typów, wliczając w to ADT, klasy finalne, typy prymitywne oraz null.

Nie istnieją jednak wartości typu Nothing. Funkcje, które przyjmują Nothing jako parametr, nie mogą zostać uruchomione, a funkcje, które zwracają ten typ, nigdy nie zwrócą rezultatu. Typ Nothing został wprowadzony, aby umożliwić używanie kowariantnych parametrów typu, ale w konsekwencji umożliwił pisanie kodu, który nie może być uruchomiony, często przez przypadek. W Scalaz uważamy, że kowariantne parametry typu wcale nie są potrzebne, ograniczając się tym samym do praktycznego kodu, który może zostać uruchomiony.

6.1.2 Sprzeciwwariancja

Z drugiej strony, parametry kontrawariantne takie jak A w trait Thing[-A] mogą ujawnić niszczycielskie błędy w kompilatorze. Spójrzmy na to, co Paul Phillips (były członek zespołu pracującego nad scalac) nazywa contrarivariance:

  scala> :paste
         trait Thing[-A]
         def f(x: Thing[ Seq[Int]]): Byte   = 1
         def f(x: Thing[List[Int]]): Short  = 2
  
  scala> f(new Thing[ Seq[Int]] { })
         f(new Thing[List[Int]] { })
  
  res = 1
  res = 2

Tak jak byśmy oczekiwali, kompilator odnalazł najdokładniejsze dopasowanie metod do argumentów. Sprawa komplikuje się jednak gdy użyjemy wartości niejawnych

  scala> :paste
         implicit val t1: Thing[ Seq[Int]] =
           new Thing[ Seq[Int]] { override def toString = "1" }
         implicit val t2: Thing[List[Int]] =
           new Thing[List[Int]] { override def toString = "2" }
  
  scala> implicitly[Thing[ Seq[Int]]]
         implicitly[Thing[List[Int]]]
  
  res = 1
  res = 1

Niejawne rozstrzyganie odwraca definicje “najbardziej dokładnego dopasowania” dla typów kontrawariantnych, czyniąc je tym samym kompletnie bezużytecznymi do reprezentacji typeklas i czegokolwiek co wymaga polimorficznych funkcjonalności. Zachowanie to zostało poprawione w Dottym.

6.1.3 Ograniczenia podtypów

scala.Option ma metodę .flatten, która konwertuje Option[Option[B]] na Option[B]. Niestety kompilator Scali nie pozwala nam na poprawne zapisanie sygnatury tej metody. Rozważmy poniższą implementację, która pozornie wydaje się poprawna:

  sealed abstract class Option[+A] {
    def flatten[B, A <: Option[B]]: Option[B] = ...
  }

A wprowadzone w definicji .flatten przysłania A wprowadzone w definicji klasy. Tak więc jest to równoznaczne z

  sealed abstract class Option[+A] {
    def flatten[B, C <: Option[B]]: Option[B] = ...
  }

czyli nie do końca jest tym, czego chcieliśmy.

Jako obejście tego problemu wprowadzono klasy <:< i =:= wraz z niejawnymi metodami, które zawsze tworzą instancje dla poprawnych typów.

  sealed abstract class <:<[-From, +To] extends (From => To)
  implicit def conforms[A]: A <:< A = new <:<[A, A] { def apply(x: A): A = x }
  
  sealed abstract class =:=[ From,  To] extends (From => To)
  implicit def tpEquals[A]: A =:= A = new =:=[A, A] { def apply(x: A): A = x }

=:= może być użyty do wymuszenia, aby dwa parametry typu były dokładnie takie same. <:< służy do wyrażenia relacji podtypowania, pozwalając tym samym na implementację .flatten jako

  sealed abstract class Option[+A] {
    def flatten[B](implicit ev: A <:< Option[B]): Option[B] = this match {
      case None        => None
      case Some(value) => ev(value)
    }
  }
  final case class Some[+A](value: A) extends Option[A]
  case object None                    extends Option[Nothing]

Scalaz definiuje ulepszone wersje <:< i =:=: Liskov (z aliasem <=<) oraz Leibniz (===).

  sealed abstract class Liskov[-A, +B] {
    def apply(a: A): B = ...
    def subst[F[-_]](p: F[B]): F[A]
  
    def andThen[C](that: Liskov[B, C]): Liskov[A, C] = ...
    def onF[X](fa: X => A): X => B = ...
    ...
  }
  object Liskov {
    type <~<[-A, +B] = Liskov[A, B]
    type >~>[+B, -A] = Liskov[A, B]
  
    implicit def refl[A]: (A <~< A) = ...
    implicit def isa[A, B >: A]: A <~< B = ...
  
    implicit def witness[A, B](lt: A <~< B): A => B = ...
    ...
  }
  
  // type signatures have been simplified
  sealed abstract class Leibniz[A, B] {
    def apply(a: A): B = ...
    def subst[F[_]](p: F[A]): F[B]
  
    def flip: Leibniz[B, A] = ...
    def andThen[C](that: Leibniz[B, C]): Leibniz[A, C] = ...
    def onF[X](fa: X => A): X => B = ...
    ...
  }
  object Leibniz {
    type ===[A, B] = Leibniz[A, B]
  
    implicit def refl[A]: Leibniz[A, A] = ...
  
    implicit def subst[A, B](a: A)(implicit f: A === B): B = ...
    implicit def witness[A, B](f: A === B): A => B = ...
    ...
  }

Poza dostarczeniem przydatnych metod i niejawnych konwersji, <=< i === są bardziej pryncypialne niż ich odpowiedniki z biblioteki standardowej.

6.2 Ewaluacja

Java to język o ścisłej (strict) ewaluacji: wszystkie parametry przekazane do metody muszą zostać wyewaluowane do wartości, zanim metoda zostanie uruchomiona. Scala wprowadza pojęcie parametrów przekazywanych przez nazwę (by-name) za pomocą składni a: =>A. Takie parametry opakowywane są w zero-argumentową funkcję, która jest wywoływana za każdym razem, gdy odnosimy się do a. Widzieliśmy tego typu parametry wielokrotnie, gdy omawialiśmy typeklasy.

Scala pozwala również na ewaluacje wartości na żądanie za pomocą słowa kluczowego lazy: obliczenia są wykonywane najwyżej raz produkując wartość przy pierwszym użyciu. Niestety Scala nie wspiera ewaluacji na żądanie dla parametrów metod.

Scalaz formalizuje te trzy strategie ewaluacji za pomocą ADT

  sealed abstract class Name[A] {
    def value: A
  }
  object Name {
    def apply[A](a: =>A) = new Name[A] { def value = a }
    ...
  }
  
  sealed abstract class Need[A] extends Name[A]
  object Need {
    def apply[A](a: =>A): Need[A] = new Need[A] {
      private lazy val value0: A = a
      def value = value0
    }
    ...
  }
  
  final case class Value[A](value: A) extends Need[A]

Najsłabszą formą ewaluacji jest Name, która nie daje żadnych gwarancji obliczeniowych. Następna jest Need gwarantująca ewaluację najwyżej raz (at most once). Value jest obliczana przed utworzeniem, gwarantując tym samym ewaluację dokładnie raz (exactly once).

Gdybyśmy chcieli być pedantyczni, moglibyśmy wrócić do wszystkich typeklas, które poznaliśmy do tej pory i zamienić przyjmowane parametry w ich metodach na Name, Need i Value. Zamiast tego możemy też po prostu założyć, że normalne parametry mogą być zawsze opakowane w Value, a te przekazywane przez nazwę w Name.

Gdy piszemy czyste programy, możemy śmiało zamienić dowolne Name na Need lub Value, i vice versa, bez zmieniania poprawności programu. To jest właśnie esencja transparencji referencyjnej: zdolność do zamiany obliczeń na wartość wynikową lub wartości na obliczenia potrzebne do jej uzyskania.

W programowaniu funkcyjnym prawie zawsze potrzebujemy Value lub Need (znane też jako parametry ścisłe i leniwe), ale nie mamy zbyt wiele pożytku z Name. Ponieważ na poziomie języka nie mamy bezpośredniego wsparcia dla leniwych parametrów, metody często przyjmują wartości przez nazwę, a następnie konwertują je do Need, zwiększając tym samym wydajność.

Name dostarcza instancje poniższych typeklas:

  • Monad
  • Comonad
  • Traverse1
  • Align
  • Zip / Unzip / Cozip

6.3 Memoizacja

Scalaz potrafi memoizować funkcje za pomocą typu Memo, który nie daje żadnych gwarancji co do ewaluacji z powodu dużej gamy różniących się implementacji:

  sealed abstract class Memo[K, V] {
    def apply(z: K => V): K => V
  }
  object Memo {
    def memo[K, V](f: (K => V) => K => V): Memo[K, V]
  
    def nilMemo[K, V]: Memo[K, V] = memo[K, V](identity)
  
    def arrayMemo[V >: Null : ClassTag](n: Int): Memo[Int, V] = ...
    def doubleArrayMemo(n: Int, sentinel: Double = 0.0): Memo[Int, Double] = ...
  
    def immutableHashMapMemo[K, V]: Memo[K, V] = ...
    def immutableTreeMapMemo[K: scala.Ordering, V]: Memo[K, V] = ...
  }

Metoda memo pozwala nam na tworzenie własnych implementacji Memo. nilMemo nie memoizuje w ogóle, a więc funkcja wykonywana jest za każdym wywołaniem. Pozostałe implementacje przechwytują wywołania funkcji i cache’ują wynik przy użyciu kolekcji z biblioteki standardowej.

Aby wykorzystać Memo, wystarczy, że opakujemy naszą funkcję z użyciem wybranej implementacji, a następnie używać będziemy zwróconej nam funkcji zamiast tej oryginalnej:

  scala> def foo(n: Int): String = {
           println("running")
           if (n > 10) "wibble" else "wobble"
         }
  
  scala> val mem = Memo.arrayMemo[String](100)
         val mfoo = mem(foo)
  
  scala> mfoo(1)
  running // evaluated
  res: String = wobble
  
  scala> mfoo(1)
  res: String = wobble // memoised

Jeśli funkcja przyjmuje więcej niż jeden argument, musimy wywołać na niej tupled, konwertując ją tym samym do jednoargumentowej funkcji przyjmującej tuple.

  scala> def bar(n: Int, m: Int): String = "hello"
         val mem = Memo.immutableHashMapMemo[(Int, Int), String]
         val mbar = mem((bar _).tupled)
  
  scala> mbar((1, 2))
  res: String = "hello"

Memo jest traktowany w specjalny sposób i typowe reguły czystości są nieco osłabione przy jego implementacji. Aby nosić miano czystego wystarczy, aby wykonanie K => V było referencyjnie transparentne. Przy implementacji możemy używać mutowalnych struktur danych lub wykonywać operacje I/O, np., aby uzyskać LRU lub rozproszony cache bez deklarowania efektów w sygnaturze typu. Inne funkcyjne języki programowania udostępniają automatyczną memoizację zarządzaną przez środowisko uruchomieniowe, Memo to nasz sposób na dodanie podobnej funkcjonalności do JVMa, niestety jedynie jako “opt-in”.

6.4 Tagowanie

W podrozdziale wprowadzającym Monoid stworzyliśmy Monoid[TradeTemplate] jednocześnie uświadamiając sobie, że domyślna instancja Monoid[Option[A]] ze Scalaz nie robi tego, czego byśmy od niej oczekiwali. Nie jest to jednak przeoczenie ze strony Scalaz: często będziemy napotykali sytuację, w której dany typ danych może mieć wiele poprawnych implementacji danej typeklasy, a ta domyślna nie robi tego, czego byśmy chcieli lub w ogóle nie jest zdefiniowana.

Najprostszym przykładem jest Monoid[Boolean] (koniunkcja && vs alternatywa ||) lub Monoid[Int] (mnożenie vs dodawanie).

Aby zaimplementować Monoid[TradeTemplate] musieliśmy albo zaburzyć spójność typeklas, albo użyć innej typeklasy niż Monoid.

scalaz.Tag został zaprojektowany jako rozwiązanie tego problemu, ale bez sprowadzania ograniczeń, które napotkaliśmy.

Definicja jest dość pokrzywiona, ale składnia dostarczana użytkownikowi jest bardzo przejrzysta. Oto w jaki sposób możemy oszukać kompilator i zdefiniować typ A @@ T, który zostanie uproszczony do A w czasie wykonania programu:

  type @@[A, T] = Tag.k.@@[A, T]
  
  object Tag {
    @inline val k: TagKind = IdTagKind
    @inline def apply[A, T](a: A): A @@ T = k(a)
    ...
  
    final class TagOf[T] private[Tag]() { ... }
    def of[T]: TagOf[T] = new TagOf[T]
  }
  sealed abstract class TagKind {
    type @@[A, T]
    def apply[A, T](a: A): A @@ T
    ...
  }
  private[scalaz] object IdTagKind extends TagKind {
    type @@[A, T] = A
    @inline override def apply[A, T](a: A): A = a
    ...
  }

Kilka użytecznych tagów znajdziemy w obiekcie Tags:

  object Tags {
    sealed trait First
    val First = Tag.of[First]
  
    sealed trait Last
    val Last = Tag.of[Last]
  
    sealed trait Multiplication
    val Multiplication = Tag.of[Multiplication]
  
    sealed trait Disjunction
    val Disjunction = Tag.of[Disjunction]
  
    sealed trait Conjunction
    val Conjunction = Tag.of[Conjunction]
  
    ...
  }

First i Last służą do wyboru między instancjami Monoidu, które wybierają odpowiednio pierwszy lub ostatni operand. Za pomocą Multiplication możemy zmienić zachowanie Monoidu dla typów liczbowych z dodawania na mnożenie. Disjunction i Conjunction pozwalają wybrać między && i || dla typu Boolean.

W naszym przykładzie definiującym TradeTemplate, zamiast Option[Currency] mogliśmy użyć Option[Currency] @@ Tags.Last. W rzeczywistości jest to przypadek tak częsty, że mogliśmy użyć wbudowanego aliasu LastOption

  type LastOption[A] = Option[A] @@ Tags.Last

i tym samym sprawić, że implementacja Monoid[TradeTemplate] będzie znacznie czystsza.

  final case class TradeTemplate(
    payments: List[java.time.LocalDate],
    ccy: LastOption[Currency],
    otc: LastOption[Boolean]
  )
  object TradeTemplate {
    implicit val monoid: Monoid[TradeTemplate] = Monoid.instance(
      (a, b) =>
        TradeTemplate(a.payments |+| b.payments,
                      a.ccy |+| b.ccy,
                      a.otc |+| b.otc),
        TradeTemplate(Nil, Tag(None), Tag(None))
    )
  }

Tworzymy wartości typu LastOption poprzez zaaplikowanie Tag na instancji Option. W tym wypadku wołamy Tag(None).

W rozdziale o derywacji typeklas pójdziemy o jeden krok dalej i stworzymy monoid automatycznie.

Kuszącym może wydać się pomysł użycia Tagów do oznaczania danych na potrzeby walidacji (np. String @@ PersonName), ale należy oprzeć się tym pokusom, gdyż za takim oznaczeniem nie stoją żadne weryfikacje wartości używanych w czasie wykonania. Tag powinien być używany tylko do selekcji typeklas, a do ograniczania możliwych wartości dużo lepiej jest użyć biblioteki Refined, którą poznaliśmy w Rozdziale 4.

6.5 Transformacje naturalne

Funkcja z jednego typu w drugi zapisywana jest w Scali jako A => B, ale jest to tylko syntax sugar dla typu Function1[A, B]. Scalaz dostarcza podobny mechanizm w formie F ~> G dla funkcji z konstruktora typu F[_] do G[_].

Intancje typu F ~> G nazywamy transformacjami naturalnymi (natural transformation) i mówimy, że są one uniwersalnie kwantyfikowane, ponieważ nie ma dla nich znaczenia zawartość F[_].

  type ~>[-F[_], +G[_]] = NaturalTransformation[F, G]
  trait NaturalTransformation[-F[_], +G[_]] {
    def apply[A](fa: F[A]): G[A]
  
    def compose[E[_]](f: E ~> F): E ~> G = ...
    def andThen[H[_]](f: G ~> H): F ~> H = ...
  }

Przykładem transformacji naturalnej jest funkcja, która konwertuje IList na List

  scala> val convert = new (IList ~> List) {
           def apply[A](fa: IList[A]): List[A] = fa.toList
         }
  
  scala> convert(IList(1, 2, 3))
  res: List[Int] = List(1, 2, 3)

Lub, bardziej zwięźle, korzystając ze składni udostępnianej przez kind-projector:

  scala> val convert = λ[IList ~> List](_.toList)
  
  scala> val convert = Lambda[IList ~> List](_.toList)

Jednak w codziennej pracy zdecydowanie częściej będziemy używać transformacji naturalnych do konwersji między algebrami. Możemy, na przykład, chcieć zaimplementować naszą algebrę Machines, służącą do komunikacji z Google Container Engine, za pomocą gotowej, zewnętrznej algebry BigMachines. Zamiast zmieniać naszą logikę biznesową i wszystkie testy, tak aby używały nowej algebry, możemy spróbować napisać transformację naturalną BigMachines ~> Machines. Powrócimy do tego pomysłu w rozdziale o Zaawansowanych Monadach.

6.6 Isomorphism

Czasami mamy do czynienia z dwoma typami, które tak naprawdę są dokładnie tym samym. Powoduje to problemy z kompatybilnością, ponieważ kompilator tego nie wie. Najczęściej takie sytuacje mają miejsce, gdy chcemy użyć zewnętrznych bibliotek, które definiują coś, co już mamy w naszym kodzie.

W takich właśnie okolicznościach z pomocą przychodzi Isomorphism, który definiuje relację równoznaczności między dwoma typami. Ma on 3 warianty dla typów o różnym kształcie:

  object Isomorphism {
    trait Iso[Arr[_, _], A, B] {
      def to: Arr[A, B]
      def from: Arr[B, A]
    }
    type IsoSet[A, B] = Iso[Function1, A, B]
    type <=>[A, B] = IsoSet[A, B]
    object IsoSet {
      def apply[A, B](to: A => B, from: B => A): A <=> B = ...
    }
  
    trait Iso2[Arr[_[_], _[_]], F[_], G[_]] {
      def to: Arr[F, G]
      def from: Arr[G, F]
    }
    type IsoFunctor[F[_], G[_]] = Iso2[NaturalTransformation, F, G]
    type <~>[F[_], G[_]] = IsoFunctor[F, G]
    object IsoFunctor {
      def apply[F[_], G[_]](to: F ~> G, from: G ~> F): F <~> G = ...
    }
  
    trait Iso3[Arr[_[_, _], _[_, _]], F[_, _], G[_, _]] {
      def to: Arr[F, G]
      def from: Arr[G, F]
    }
    type IsoBifunctor[F[_, _], G[_, _]] = Iso3[~~>, F, G]
    type <~~>[F[_, _], G[_, _]] = IsoBifunctor[F, G]
  
    ...
  }

Aliasy typów IsoSet, IsoFunctor i IsoBiFunctor pokrywają najczęstsze przypadki: zwykłe funkcje, transformacje naturalne i binaturalne. Funkcje pomocnicze pozwalają nam generować instancje Iso z gotowych funkcji lub transformacji, ale często łatwiej jest użyć do tego klas Template. Na przykład:

  val listIListIso: List <~> IList =
    new IsoFunctorTemplate[List, IList] {
      def to[A](fa: List[A]) = fromList(fa)
      def from[A](fa: IList[A]) = fa.toList
    }

Jeśli wprowadzimy izomorfizm, możemy wygenerować wiele standardowych typeklas. Dla przykładu

  trait IsomorphismSemigroup[F, G] extends Semigroup[F] {
    implicit def G: Semigroup[G]
    def iso: F <=> G
    def append(f1: F, f2: =>F): F = iso.from(G.append(iso.to(f1), iso.to(f2)))
  }

pozwala nam wyderywować Semigroup[F] dla typu F, jeśli mamy F <=> G oraz Semigroup[G]. Niemal wszystkie typeklasy w hierarchii mają wariant dla typów izomorficznych. Jeśli złapiemy się na kopiowaniu implementacji danej typeklasy, warto rozważyć zdefiniowanie Isomorphismu.

6.7 Kontenery

6.7.1 Maybe

Widzieliśmy już Maybe, Scalazowe ulepszenie scala.Option. Jest to ulepszenie dzięki swojej inwariancji oraz braku jakichkolwiek nieczystych metod, taki jak Option.get, które mogą rzucać wyjątki.

Zazwyczaj typ ten używany jest do reprezentacji rzeczy, które mogą być nieobecne, bez podawania żadnej przyczyny ani wyjaśnienia dla tej nieobecności.

  sealed abstract class Maybe[A] { ... }
  object Maybe {
    final case class Empty[A]()    extends Maybe[A]
    final case class Just[A](a: A) extends Maybe[A]
  
    def empty[A]: Maybe[A] = Empty()
    def just[A](a: A): Maybe[A] = Just(a)
  
    def fromOption[A](oa: Option[A]): Maybe[A] = ...
    def fromNullable[A](a: A): Maybe[A] = if (null == a) empty else just(a)
    ...
  }

.empty i .just są lepsze niż tworzenie Just i Maybe bezpośrednio, ponieważ zwracają Maybe, pomagając tym samym w inferencji typów. Takie podejście często nazywane jest zwracaniem typu sumy (sum type), a więc mimo posiadania wielu implementacji zapieczętowanego traita (sealed trait) nigdy nie używamy konkretnych podtypów w sygnaturach metod.

Pomocnicza klasa niejawna pozwala nam zawołać .just na dowolnej wartości i uzyskać Maybe.

  implicit class MaybeOps[A](self: A) {
    def just: Maybe[A] = Maybe.just(self)
  }

Maybe posiada instancje wszystkich poniższych typeklas

  • Align
  • Traverse
  • MonadPlus / IsEmpty
  • Cobind
  • Cozip / Zip / Unzip
  • Optional

oraz deleguje implementację poniższych do instancji dla typu A

  • Monoid / Band
  • Equal / Order / Show

Dodatkowo, Maybe oferuje funkcjonalności niedostępne w żadnej typeklasie

  sealed abstract class Maybe[A] {
    def cata[B](f: A => B, b: =>B): B = this match {
      case Just(a) => f(a)
      case Empty() => b
    }
  
    def |(a: =>A): A = cata(identity, a)
    def toLeft[B](b: =>B): A \/ B = cata(\/.left, \/-(b))
    def toRight[B](b: =>B): B \/ A = cata(\/.right, -\/(b))
    def <\/[B](b: =>B): A \/ B = toLeft(b)
    def \/>[B](b: =>B): B \/ A = toRight(b)
  
    def orZero(implicit A: Monoid[A]): A = getOrElse(A.zero)
    def orEmpty[F[_]: Applicative: PlusEmpty]: F[A] =
      cata(Applicative[F].point(_), PlusEmpty[F].empty)
    ...
  }

.cata to zwięźlejsza alternatywa dla .map(f).getOrElse(b) dostępna również pod postacią | jeśli f to identity (co jest równoznaczne z .getOrElse).

.toLeft i .toRight oraz ich aliasy symbolicznie tworzą dysjunkcje (opisane w następnym podrozdziale), przyjmując jako parametr wartość używaną w przypadku napotkania Empty.

.orZero używa instancji typeklasy Monoid do zdobycia wartości domyślnej.

.orEmpty używa ApplicativePlus, aby stworzyć jednoelementowy lub pusty kontener. Pamiętajmy, że podobną funkcjonalność dla kolekcji udostępnia nam .to pochodzące z Foldable.

  scala> 1.just.orZero
  res: Int = 1
  
  scala> Maybe.empty[Int].orZero
  res: Int = 0
  
  scala> Maybe.empty[Int].orEmpty[IList]
  res: IList[Int] = []
  
  scala> 1.just.orEmpty[IList]
  res: IList[Int] = [1]
  
  scala> 1.just.to[List] // from Foldable
  res: List[Int] = List(1)

6.7.2 Either

Typ ulepszający scala.Either w Scalaz jest symbolem, ale przyjęło się nazywać go either lub Disjunction.

  sealed abstract class \/[+A, +B] { ... }
  final case class -\/[+A](a: A) extends (A \/ Nothing)
  final case class \/-[+B](b: B) extends (Nothing \/ B)
  
  type Disjunction[+A, +B] = \/[A, B]
  
  object \/ {
    def left [A, B]: A => A \/ B = -\/(_)
    def right[A, B]: B => A \/ B = \/-(_)
  
    def fromEither[A, B](e: Either[A, B]): A \/ B = ...
    ...
  }

z odpowiednią składnią

  implicit class EitherOps[A](val self: A) {
    final def left [B]: (A \/ B) = -\/(self)
    final def right[B]: (B \/ A) = \/-(self)
  }

pozwalającą na łatwe tworzenie wartości. Zauważ, że te metody przyjmują typ drugiej strony jako parametr. A więc jeśli chcesz stworzyć String \/ Int mając Int, wołając .right, musisz przekazać String.

  scala> 1.right[String]
  res: String \/ Int = \/-(1)
  
  scala> "hello".left[Int]
  res: String \/ Int = -\/(hello)

Symboliczna natura \/ sprawia, że dobrze się go czyta, gdy użyty jest jako infiks. Pamiętaj, że typy symboliczne w Scali wiążą argumenty od lewej, a więc zagnieżdżone \/ muszą być ujęte w nawiasy.

\/ posiada prawostronne (co oznacza, że flatMap wykonywany jest na \/-) instancje typeklas:

  • Monad / MonadError
  • Traverse / Bitraverse
  • Plus
  • Optional
  • Cozip

oraz zależne od zawartości

  • Equal / Order
  • Semigroup / Monoid / Band

Dodatkowo dostajemy kilka niestandardowych metod

  sealed abstract class \/[+A, +B] { self =>
    def fold[X](l: A => X, r: B => X): X = self match {
      case -\/(a) => l(a)
      case \/-(b) => r(b)
    }
  
    def swap: (B \/ A) = self match {
      case -\/(a) => \/-(a)
      case \/-(b) => -\/(b)
    }
  
    def |[BB >: B](x: =>BB): BB = getOrElse(x) // Optional[_]
    def |||[C, BB >: B](x: =>C \/ BB): C \/ BB = orElse(x) // Optional[_]
  
    def +++[AA >: A: Semigroup, BB >: B: Semigroup](x: =>AA \/ BB): AA \/ BB = ...
  
    def toEither: Either[A, B] = ...
  
    final class SwitchingDisjunction[X](right: =>X) {
      def <<?:(left: =>X): X = ...
    }
    def :?>>[X](right: =>X) = new SwitchingDisjunction[X](right)
    ...
  }

.fold przypomina Maybe.cata i wymaga, aby obie strony zostały przemapowane do tego samego typu.

.swap zamienia strony miejscami, lewa na prawo, prawa na lewo.

Alias | na getOrElse jest podobny do tego w Maybe. Dostajemy też ||| jako alias na orElse.

+++ pozwala na łączenie dysjunkcji, priorytetyzując te, które wypełnione są po lewej stronie.

  • right(v1) +++ right(v2) gives right(v1 |+| v2)
  • right(v1) +++ left (v2) gives left (v2)
  • left (v1) +++ right(v2) gives left (v1)
  • left (v1) +++ left (v2) gives left (v1 |+| v2)

.toEither zapewnia kompatybilność z biblioteką standardową.

Połączenie :?>> i <<?: pozwala w wygodny sposób zignorować zawartość \/ wybierając jednocześnie nową wartość zależnie od jego typu.

  scala> 1 <<?: foo :?>> 2
  res: Int = 2 // foo is a \/-
  
  scala> 1 <<?: foo.swap :?>> 2
  res: Int = 1

6.7.3 Validation

Na pierwszy rzut oka typ Validation (zaliasowany jako \?/ czyli szczęśliwy Elvis) wydaje się być klonem Disjunction:

  sealed abstract class Validation[+E, +A] { ... }
  final case class Success[A](a: A) extends Validation[Nothing, A]
  final case class Failure[E](e: E) extends Validation[E, Nothing]
  
  type ValidationNel[E, +X] = Validation[NonEmptyList[E], X]
  
  object Validation {
    type \?/[+E, +A] = Validation[E, A]
  
    def success[E, A]: A => Validation[E, A] = Success(_)
    def failure[E, A]: E => Validation[E, A] = Failure(_)
    def failureNel[E, A](e: E): ValidationNel[E, A] = Failure(NonEmptyList(e))
  
    def lift[E, A](a: A)(f: A => Boolean, fail: E): Validation[E, A] = ...
    def liftNel[E, A](a: A)(f: A => Boolean, fail: E): ValidationNel[E, A] = ...
    def fromEither[E, A](e: Either[E, A]): Validation[E, A] = ...
    ...
  }

Z pomocną składnią

  implicit class ValidationOps[A](self: A) {
    def success[X]: Validation[X, A] = Validation.success[X, A](self)
    def successNel[X]: ValidationNel[X, A] = success
    def failure[X]: Validation[A, X] = Validation.failure[A, X](self)
    def failureNel[X]: ValidationNel[A, X] = Validation.failureNel[A, X](self)
  }

Jednak sama struktura danych to nie wszystko. Validation celowo nie posiada instancji Monad, ograniczając się do:

  • Applicative
  • Traverse / Bitraverse
  • Cozip
  • Plus
  • Optional

oraz zależnych od zawartości:

  • Equal / Order
  • Show
  • Semigroup / Monoid

Dużą zaletą ograniczenia się do Applicative jest to, że Validation używany jest wyraźnie w sytuacjach, w których chcemy zebrać wszystkie napotkane problemy, natomiast Disjunction zatrzymuje się przy pierwszym i ignoruje pozostałe. Aby wesprzeć akumulacje błędów, mamy do dyspozycji ValidationNel, czyli Validation z NonEmptyList[E] po stronie błędów.

Rozważmy wykonanie walidacji danych pochodzących od użytkownika za pomocą Disjunction i flatMap:

  scala> :paste
         final case class Credentials(user: Username, name: Fullname)
         final case class Username(value: String) extends AnyVal
         final case class Fullname(value: String) extends AnyVal
  
         def username(in: String): String \/ Username =
           if (in.isEmpty) "empty username".left
           else if (in.contains(" ")) "username contains spaces".left
           else Username(in).right
  
         def realname(in: String): String \/ Fullname =
           if (in.isEmpty) "empty real name".left
           else Fullname(in).right
  
  scala> for {
           u <- username("sam halliday")
           r <- realname("")
         } yield Credentials(u, r)
  res = -\/(username contains spaces)

Jeśli użyjemy |@|

  scala> (username("sam halliday") |@| realname("")) (Credentials.apply)
  res = -\/(username contains spaces)

nadal dostaniemy tylko pierwszy błąd. Wynika to z faktu, że Disjunction jest Monadą, a jego metody .applyX muszą być spójne z .flatMap i nie mogą zakładać, że operacje mogą być wykonywane poza kolejnością. Porównajmy to z:

  scala> :paste
         def username(in: String): ValidationNel[String, Username] =
           if (in.isEmpty) "empty username".failureNel
           else if (in.contains(" ")) "username contains spaces".failureNel
           else Username(in).success
  
         def realname(in: String): ValidationNel[String, Fullname] =
           if (in.isEmpty) "empty real name".failureNel
           else Fullname(in).success
  
  scala> (username("sam halliday") |@| realname("")) (Credentials.apply)
  res = Failure(NonEmpty[username contains spaces,empty real name])

Tym razem dostaliśmy z powrotem wszystkie napotkane błędy!

Validation ma wiele metod analogicznych do tych w Disjunction, takich jak .fold, .swap i +++, plus kilka ekstra:

  sealed abstract class Validation[+E, +A] {
    def append[F >: E: Semigroup, B >: A: Semigroup](x: F \?/ B]): F \?/ B = ...
  
    def disjunction: (E \/ A) = ...
    ...
  }

.append (z aliasem +|+) ma taką samą sygnaturę jak +++, ale preferuje wariant success

  • failure(v1) +|+ failure(v2) zwraca failure(v1 |+| v2)
  • failure(v1) +|+ success(v2) zwraca success(v2)
  • success(v1) +|+ failure(v2) zwraca success(v1)
  • success(v1) +|+ success(v2) zwraca success(v1 |+| v2)

.disjunction konwertuje Validated[A, B] do A \/ B. Dysjunkcja ma lustrzane metody .validation i .validationNel, pozwalając tym samym na łatwe przełączanie się między sekwencyjnym i równoległym zbieraniem błędów.

\/ i Validation są bardziej wydajnymi alternatywami dla wyjątków typu checked do walidacji wejścia, unikającymi zbierania śladu stosu (stacktrace). Wymagają one też od użytkownika obsłużenia potencjalnych błędów, sprawiając tym samym, że tworzone systemy są bardziej niezawodne.

6.7.4 These

Napotkaliśmy These, strukturę danych wyrażającą logiczne LUB, kiedy poznawaliśmy Align.

  sealed abstract class \&/[+A, +B] { ... }
  object \&/ {
    type These[A, B] = A \&/ B
  
    final case class This[A](aa: A) extends (A \&/ Nothing)
    final case class That[B](bb: B) extends (Nothing \&/ B)
    final case class Both[A, B](aa: A, bb: B) extends (A \&/ B)
  
    def apply[A, B](a: A, b: B): These[A, B] = Both(a, b)
  }

z metodami upraszczającymi konstrukcję

  implicit class TheseOps[A](self: A) {
    final def wrapThis[B]: A \&/ B = \&/.This(self)
    final def wrapThat[B]: B \&/ A = \&/.That(self)
  }
  implicit class ThesePairOps[A, B](self: (A, B)) {
    final def both: A \&/ B = \&/.Both(self._1, self._2)
  }

These ma instancje typeklas

  • Monad
  • Bitraverse
  • Traverse
  • Cobind

oraz zależnie od zawartości

  • Semigroup / Monoid / Band
  • Equal / Order
  • Show

These (\&/) ma też wiele metod, których oczekiwalibyśmy od Disjunction (\/) i Validation (\?/)

  sealed abstract class \&/[+A, +B] {
    def fold[X](s: A => X, t: B => X, q: (A, B) => X): X = ...
    def swap: (B \&/ A) = ...
  
    def append[X >: A: Semigroup, Y >: B: Semigroup](o: =>(X \&/ Y)): X \&/ Y = ...
  
    def &&&[X >: A: Semigroup, C](t: X \&/ C): X \&/ (B, C) = ...
    ...
  }

.append ma 9 możliwych ułożeń i dane nigdy nie są tracone, ponieważ This i That mogą być zawsze zamienione w Both.

.flatMap jest prawostronna (Both i That), przyjmując Semigroupy dla strony lewej (This), tak aby móc połączyć zawartości, zamiast je porzucać. Metoda &&& jest pomocna, gdy chcemy połączyć dwie instancje \&/, tworząc tuple z prawej strony i porzucając tę stronę zupełnie, jeśli nie jest wypełniona w obu instancjach.

Mimo że zwracanie typu \&/ z naszych funkcji jest kuszące, to jego nadużywanie to antywzorzec. Głównym powodem dla używania \&/ jest łączenie i dzielenie potencjalnie nieskończonych strumieni danych w skończonej pamięci. Dlatego też dostajemy do dyspozycji kilka przydatnych funkcji do operowania na EphemeralStream (zaliasowanym tutaj, aby zmieścić się w jednej linii) lub czymkolwiek z instancją MonadPlus

  type EStream[A] = EphemeralStream[A]
  
  object \&/ {
    def concatThisStream[A, B](x: EStream[A \&/ B]): EStream[A] = ...
    def concatThis[F[_]: MonadPlus, A, B](x: F[A \&/ B]): F[A] = ...
  
    def concatThatStream[A, B](x: EStream[A \&/ B]): EStream[B] = ...
    def concatThat[F[_]: MonadPlus, A, B](x: F[A \&/ B]): F[B] = ...
  
    def unalignStream[A, B](x: EStream[A \&/ B]): (EStream[A], EStream[B]) = ...
    def unalign[F[_]: MonadPlus, A, B](x: F[A \&/ B]): (F[A], F[B]) = ...
  
    def merge[A: Semigroup](t: A \&/ A): A = ...
    ...
  }

6.7.5 Either wyższego rodzaju

Typ danych Coproduct (nie mylić z bardziej ogólnym pojęciem koproduktu w ADT) opakowuje Disjunction dla konstruktorów typu:

  final case class Coproduct[F[_], G[_], A](run: F[A] \/ G[A]) { ... }
  object Coproduct {
    def leftc[F[_], G[_], A](x: F[A]): Coproduct[F, G, A] = Coproduct(-\/(x))
    def rightc[F[_], G[_], A](x: G[A]): Coproduct[F, G, A] = Coproduct(\/-(x))
    ...
  }

Instancje typeklas po prostu delegują do instancji zdefiniowanych dla F[_] i G[_].

Najpopularniejszym przypadkiem, w którym zastosowanie znajduje Coproduct, to sytuacja, gdy chcemy stworzyć anonimowy koprodukt wielu ADT.

6.7.6 Nie tak szybko

Wbudowane w Scalę tuple oraz podstawowe typy danych takie jak Maybe lub Disjunction są ewaluowane zachłannie (eagerly-evaluated).

Dla wygody zdefiniowane zostały warianty leniwe, mające instancje oczekiwanych typeklas:

  sealed abstract class LazyTuple2[A, B] {
    def _1: A
    def _2: B
  }
  ...
  sealed abstract class LazyTuple4[A, B, C, D] {
    def _1: A
    def _2: B
    def _3: C
    def _4: D
  }
  
  sealed abstract class LazyOption[+A] { ... }
  private final case class LazySome[A](a: () => A) extends LazyOption[A]
  private case object LazyNone extends LazyOption[Nothing]
  
  sealed abstract class LazyEither[+A, +B] { ... }
  private case class LazyLeft[A, B](a: () => A) extends LazyEither[A, B]
  private case class LazyRight[A, B](b: () => B) extends LazyEither[A, B]

Wnikliwy czytelnik zauważy, że przedrostek Lazy jest nie do końca poprawny, a nazwy tych typów danych prawdopodobnie powinny brzmieć: ByNameTupleX, ByNameOption i ByNameEither.

6.7.7 Const

Const, zawdzięczający nazwę angielskiemu constant, jest opakowaniem na wartość typu A, razem z nieużywanym parametrem typu B.

  final case class Const[A, B](getConst: A)

Const dostarcza instancję Applicative[Const[A, ?]] jeśli tylko dostępny jest Monoid[A]:

  implicit def applicative[A: Monoid]: Applicative[Const[A, ?]] =
    new Applicative[Const[A, ?]] {
      def point[B](b: =>B): Const[A, B] =
        Const(Monoid[A].zero)
      def ap[B, C](fa: =>Const[A, B])(fbc: =>Const[A, B => C]): Const[A, C] =
        Const(fbc.getConst |+| fa.getConst)
    }

Najważniejszą własnością tej instancji jest to, że ignoruje parametr B, łącząc wartości typu A, które napotka.

Wracając do naszej aplikacji drone-dynamic-agents, powinniśmy najpierw zrefaktorować plik logic.scala, tak aby używał Applicative zamiast Monad. Poprzednią implementację stworzyliśmy, zanim jeszcze dowiedzieliśmy się, czym jest Applicative. Teraz wiemy jak zrobić to lepiej:

  final class DynAgentsModule[F[_]: Applicative](D: Drone[F], M: Machines[F])
    extends DynAgents[F] {
    ...
    def act(world: WorldView): F[WorldView] = world match {
      case NeedsAgent(node) =>
        M.start(node) >| world.copy(pending = Map(node -> world.time))
  
      case Stale(nodes) =>
        nodes.traverse { node =>
          M.stop(node) >| node
        }.map { stopped =>
          val updates = stopped.strengthR(world.time).toList.toMap
          world.copy(pending = world.pending ++ updates)
        }
  
      case _ => world.pure[F]
    }
    ...
  }

Skoro nasza logika biznesowa wymaga teraz jedynie Applicative, możemy zaimplementować nasz mock F[a] jako Const[String, a]. W każdym z przypadków zwracamy nazwę funkcji, która została wywołana:

  object ConstImpl {
    type F[a] = Const[String, a]
  
    private val D = new Drone[F] {
      def getBacklog: F[Int] = Const("backlog")
      def getAgents: F[Int]  = Const("agents")
    }
  
    private val M = new Machines[F] {
      def getAlive: F[Map[MachineNode, Epoch]]     = Const("alive")
      def getManaged: F[NonEmptyList[MachineNode]] = Const("managed")
      def getTime: F[Epoch]                        = Const("time")
      def start(node: MachineNode): F[Unit]        = Const("start")
      def stop(node: MachineNode): F[Unit]         = Const("stop")
    }
  
    val program = new DynAgentsModule[F](D, M)
  }

Z taką interpretacją naszego programu możemy zweryfikować metody, które są używane:

  it should "call the expected methods" in {
    import ConstImpl._
  
    val alive    = Map(node1 -> time1, node2 -> time1)
    val world    = WorldView(1, 1, managed, alive, Map.empty, time4)
  
    program.act(world).getConst shouldBe "stopstop"
  }

Alternatywnie, moglibyśmy zliczyć ilość wywołań za pomocą Const[Int, ?] lub IMap[String, Int].

W tym teście zrobiliśmy krok dalej poza tradycyjne testowanie z użyciem Mocków. Const pozwolił nam sprawdzić, co zostało wywołane bez dostarczania faktycznej implementacji. Podejście takie jest użyteczne, kiedy specyfikacja wymaga od nas, abyśmy wykonali konkretne wywołania w odpowiedzi na dane wejście. Dodatkowo, osiągnęliśmy to, zachowując pełną zgodność typów.

Idąc dalej tym tokiem myślenia, powiedzmy, że chcielibyśmy monitorować (w środowisku produkcyjnym) węzły, które zatrzymywane są w metodzie act. Możemy stworzyć implementacje Drone i Machines używając Const i zawołać je z naszej opakowanej wersji act

  final class Monitored[U[_]: Functor](program: DynAgents[U]) {
    type F[a] = Const[Set[MachineNode], a]
    private val D = new Drone[F] {
      def getBacklog: F[Int] = Const(Set.empty)
      def getAgents: F[Int]  = Const(Set.empty)
    }
    private val M = new Machines[F] {
      def getAlive: F[Map[MachineNode, Epoch]]     = Const(Set.empty)
      def getManaged: F[NonEmptyList[MachineNode]] = Const(Set.empty)
      def getTime: F[Epoch]                        = Const(Set.empty)
      def start(node: MachineNode): F[Unit]        = Const(Set.empty)
      def stop(node: MachineNode): F[Unit]         = Const(Set(node))
    }
    val monitor = new DynAgentsModule[F](D, M)
  
    def act(world: WorldView): U[(WorldView, Set[MachineNode])] = {
      val stopped = monitor.act(world).getConst
      program.act(world).strengthR(stopped)
    }
  }

Możemy to zrobić, ponieważ monitor jest czysty i uruchomienie go nie produkuje żadnych efektów ubocznych.

Poniższy fragment uruchamia program z ConstImpl, ekstrahując wszystkie wywołania do Machines.stop i zwracając wszystkie zatrzymane węzły razem WoldView.

  it should "monitor stopped nodes" in {
    val underlying = new Mutable(needsAgents).program
  
    val alive = Map(node1 -> time1, node2 -> time1)
    val world = WorldView(1, 1, managed, alive, Map.empty, time4)
    val expected = world.copy(pending = Map(node1 -> time4, node2 -> time4))
  
    val monitored = new Monitored(underlying)
    monitored.act(world) shouldBe (expected -> Set(node1, node2))
  }

Użyliśmy Const, aby zrobić coś, co przypomina niegdyś popularne w Javie Programowanie Aspektowe. Na bazie naszej logiki biznesowej zaimplementowaliśmy monitoring, nie komplikując tej logiki w żaden sposób.

A będzie jeszcze lepiej. Moglibyśmy uruchomić ConstImpl w środowisku produkcyjnym, aby zebrać informacje o tym, co ma zostać zatrzymane, a następnie dostarczyć zoptymalizowaną implementację korzystającą ze specyficznych dla implementacji wywołań batchowych.

Cichym bohaterem tej opowieści jest Applicative, a Const pozwala nam pokazać, co jest dzięki niemu możliwe. Jeśli musielibyśmy zmienić nasz program tak, aby wymagał Monady, nie moglibyśmy wtedy użyć Const, a zamiast tego zmuszeni bylibyśmy do napisania pełnoprawnych mocków, aby zweryfikować jakie funkcje zostały wywołane dla danych argumentów. Reguła Najmniejszej Mocy (Rule of Least Power) wymaga od nas, abyśmy używali Applicative zamiast Monad, kiedy tylko możemy.

6.8 Kolekcje

W przeciwieństwie do Collections API z biblioteki standardowej, Scalaz opisuje zachowanie kolekcji za pomocą hierarchii typeklas, np. Foldable, Traverse, Monoid. Co pozostaje do przeanalizowania, to konkretne struktury danych, ich charakterystyki wydajnościowe i wyspecjalizowane metody.

Ten podrozdział wnika w szczegóły implementacyjne każdego typu danych. Nie musimy zapamiętać wszystkiego, celem jest zrozumieć, jak działa każda ze struktur jedynie w ogólności.

Ponieważ wszystkie kolekcje dostarczają instancje mniej więcej tych samych typeklas, nie będziemy ich powtarzać. W większości przypadków jest to pewna wariacja poniższej listy.

  • Monoid
  • Traverse / Foldable
  • MonadPlus / IsEmpty
  • Cobind / Comonad
  • Zip / Unzip
  • Align
  • Equal / Order
  • Show

Struktury danych, które nigdy nie są puste dostarczają również

  • Traverse1 / Foldable1

oraz Semigroup zamiast Monoid i Plus zamiast IsEmpty.

6.8.1 Listy

Używaliśmy IList[A] i NonEmptyList[A] tyle razy, że powinny już być nam znajome. Reprezentują on ideę klasycznej, jedno-połączeniowej listy:

  sealed abstract class IList[A] {
    def ::(a: A): IList[A] = ...
    def :::(as: IList[A]): IList[A] = ...
    def toList: List[A] = ...
    def toNel: Option[NonEmptyList[A]] = ...
    ...
  }
  final case class INil[A]() extends IList[A]
  final case class ICons[A](head: A, tail: IList[A]) extends IList[A]
  
  final case class NonEmptyList[A](head: A, tail: IList[A]) {
    def <::(b: A): NonEmptyList[A] = nel(b, head :: tail)
    def <:::(bs: IList[A]): NonEmptyList[A] = ...
    ...
  }

Główną zaletą IList nad List jest brak niebezpiecznych metod, takich jak .head (jest ona niebezpieczna, gdyż wyrzuca wyjątek w przypadku pustej kolekcji).

Dodatkowo, IList jest dużo prostsza, gdyż nie jest częścią hierarchii, oraz zużywa zdecydowanie mniej pamięci. Ponadto, List z biblioteki standardowej ma przerażająca implementację, używającą var, aby obejść problemy wydajnościowe:

  package scala.collection.immutable
  
  sealed abstract class List[+A]
    extends AbstractSeq[A]
    with LinearSeq[A]
    with GenericTraversableTemplate[A, List]
    with LinearSeqOptimized[A, List[A]] { ... }
  case object Nil extends List[Nothing] { ... }
  final case class ::[B](
    override val head: B,
    private[scala] var tl: List[B]
  ) extends List[B] { ... }

Tworzenie instancji List wymaga ostrożnej i powolnej, synchronizacji wątków, aby zapewnić bezpieczne publikowanie. IList nie ma żadnych tego typu wymagań, a więc może przegonić List pod względem wydajności.

6.8.2 EphemeralStream

Stream z biblioteki standardowej jest leniwą wersją Listy, ale obarczoną wyciekami pamięci i niebezpiecznymi metodami. EphemeralStream nie przetrzymuje referencji do wyliczonych wartości, łagodząc problemy z przetrzymywaniem pamięci. Jednocześnie pozbawiony jest niebezpiecznych metod, tak, jak Ilist.

  sealed abstract class EphemeralStream[A] {
    def headOption: Option[A]
    def tailOption: Option[EphemeralStream[A]]
    ...
  }
  // private implementations
  object EphemeralStream extends EphemeralStreamInstances {
    type EStream[A] = EphemeralStream[A]
  
    def emptyEphemeralStream[A]: EStream[A] = ...
    def cons[A](a: =>A, as: =>EStream[A]): EStream[A] = ...
    def unfold[A, B](start: =>B)(f: B => Option[(A, B)]): EStream[A] = ...
    def iterate[A](start: A)(f: A => A): EStream[A] = ...
  
    implicit class ConsWrap[A](e: =>EStream[A]) {
      def ##::(h: A): EStream[A] = cons(h, e)
    }
    object ##:: {
      def unapply[A](xs: EStream[A]): Option[(A, EStream[A])] =
        if (xs.isEmpty) None
        else Some((xs.head(), xs.tail()))
    }
    ...
  }

.cons, .unfold i .iterate to mechanizmy do tworzenia strumieni. ##:: (a więc i cons) umieszcza nowy element na początku EStreamu przekazanego przez nazwę. .unfold służy do tworzenia skończonych (lecz potencjalnie nieskończonych) strumieni poprzez ciągłe wywoływanie funkcji f zwracającej następną wartość oraz wejście do swojego kolejnego wywołania. .iterate tworzy nieskończony strumień za pomocą funkcji f wywoływanej na poprzednim jego elemencie.

EStream może pojawiać się w wyrażeniach pattern matchingu z użyciem symbolu ##::.

Mimo że EStream rozwiązuje problem przetrzymywania pamięci, nadal możemy ucierpieć z powodu powolnych wycieków pamięci, jeśli żywa referencja wskazuje na czoło nieskończonego strumienia. Problemy tej natury oraz potrzeba komponowania strumieni wywołujących efekty uboczne są powodem, dla którego istnieje biblioteka fs2.

6.8.3 CorecursiveList

Korekursja (corecursion) ma miejsce, gdy zaczynamy ze stanu bazowego i deterministycznie produkujemy kolejne stany przejściowe, tak jak miało to miejsce w metodzie EphemeralStream.unfold, którą niedawno omawialiśmy:

  def unfold[A, B](b: =>B)(f: B => Option[(A, B)]): EStream[A] = ...

Jest to działanie odwrotne do rekursji, która rozbija dane do stanu bazowego i kończy działanie.

CorecursiveList to struktura danych wyrażająca EphemeralStream.unfold i będąca alternatywą dla EStream, która może być wydajniejsza w niektórych przypadkach:

  sealed abstract class CorecursiveList[A] {
    type S
    def init: S
    def step: S => Maybe[(S, A)]
  }
  
  object CorecursiveList {
    private final case class CorecursiveListImpl[S0, A](
      init: S0,
      step: S0 => Maybe[(S0, A)]
    ) extends CorecursiveList[A] { type S = S0 }
  
    def apply[S, A](init: S)(step: S => Maybe[(S, A)]): CorecursiveList[A] =
      CorecursiveListImpl(init, step)
  
    ...
  }

Korekursja jest przydatna, gdy implementujemy Comonad.cojoin, jak w naszym przykładzie z Hood. CorecursiveList to dobry sposób na wyrażenie nieliniowych równań rekurencyjnych, jak te używane w biologicznych modelach populacji, systemach kontroli, makroekonomii i modelach bankowości inwestycyjnej.

6.8.4 ImmutableArray

Czyli proste opakowanie na mutowalną tablicę (Array) z biblioteki standardowej, ze specjalizacją dla typów prymitywnych:

  sealed abstract class ImmutableArray[+A] {
    def ++[B >: A: ClassTag](o: ImmutableArray[B]): ImmutableArray[B]
    ...
  }
  object ImmutableArray {
    final class StringArray(s: String) extends ImmutableArray[Char] { ... }
    sealed class ImmutableArray1[+A](as: Array[A]) extends ImmutableArray[A] { ... }
    final class ofRef[A <: AnyRef](as: Array[A]) extends ImmutableArray1[A](as)
    ...
    final class ofLong(as: Array[Long]) extends ImmutableArray1[Long](as)
  
    def fromArray[A](x: Array[A]): ImmutableArray[A] = ...
    def fromString(str: String): ImmutableArray[Char] = ...
    ...
  }

Typ Array jest bezkonkurencyjny, jeśli chodzi prędkość odczytu oraz wielkość stosu. Jednak nie występuje tutaj w ogóle współdzielenie strukturalne, więc niemutowalne tablice używane są zwykle tylko, gdy ich zawartość nie ulega zmianie lub jako sposób na bezpieczne owinięcie danych pochodzących z zastanych części systemu.

6.8.5 Dequeue

Dequeue (wymawiana jak talia kart - “deck”) to połączona lista, która pozwala na dodawanie i odczytywanie elementów z przodu (cons) lub tyłu (snoc) w stałym czasie. Usuwania elementów z obu końców jest stałe statystycznie.

  sealed abstract class Dequeue[A] {
    def frontMaybe: Maybe[A]
    def backMaybe: Maybe[A]
  
    def ++(o: Dequeue[A]): Dequeue[A] = ...
    def +:(a: A): Dequeue[A] = cons(a)
    def :+(a: A): Dequeue[A] = snoc(a)
    def cons(a: A): Dequeue[A] = ...
    def snoc(a: A): Dequeue[A] = ...
    def uncons: Maybe[(A, Dequeue[A])] = ...
    def unsnoc: Maybe[(A, Dequeue[A])] = ...
    ...
  }
  private final case class SingletonDequeue[A](single: A) extends Dequeue[A] { ... }
  private final case class FullDequeue[A](
    front: NonEmptyList[A],
    fsize: Int,
    back: NonEmptyList[A],
    backSize: Int) extends Dequeue[A] { ... }
  private final case object EmptyDequeue extends Dequeue[Nothing] { ... }
  
  object Dequeue {
    def empty[A]: Dequeue[A] = EmptyDequeue()
    def apply[A](as: A*): Dequeue[A] = ...
    def fromFoldable[F[_]: Foldable, A](fa: F[A]): Dequeue[A] = ...
    ...
  }

Implementacja bazuje na dwóch listach, jednej dla danych początkowych, drugiej dla końcowych. Rozważmy instancję przechowującą symbole a0, a1, a2, a3, a4, a5, a6

  FullDequeue(
    NonEmptyList('a0, IList('a1, 'a2, 'a3)), 4,
    NonEmptyList('a6, IList('a5, 'a4)), 3)

która może być zobrazowana jako

Zauważ, że lista przechowująca back jest w odwróconej kolejności.

Odczyt snoc (element końcowy) to proste spojrzenie na back.head. Dodanie elementu na koniec Dequeue oznacza dodanie go na początek back i stworzenie nowego FullDequeue (co zwiększy backSize o jeden). Prawie cała oryginalna struktura jest współdzielona. Porównaj to z dodaniem nowego elementu na koniec IList, co wymaga stworzenia na nowo całej struktury.

frontSize i backSize są używane do zbalansowywania front i back, tak, aby zawsze były podobnych rozmiarów. Balansowanie oznacza, że niektóre operacje mogą być wolniejsze od innych (np. gdy cała struktura musi być przebudowana), ale ponieważ dzieje się to okazjonalnie możemy ten koszt uśrednić i powiedzieć, że jest stały.

6.8.6 DList

Zwykłe listy mają kiepską wydajność, gdy duże listy są ze sobą łączone. Rozważmy koszt wykonania poniższej operacji:

  ((as ::: bs) ::: (cs ::: ds)) ::: (es ::: (fs ::: gs))

Tworzonych jest 6 list pośrednich, przechodząc i przebudowując każdą z list trzy krotnie (oprócz gs, która jest współdzielona na wszystkich etapach).

DList (od difference list, listy różnic) jest bardziej wydajnym rozwiązaniem dla tego scenariusza. Zamiast wykonywać obliczenia na każdym z etapów, wynik reprezentowany jest jako IList[A] => IList[A].

  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)))
    ...
  }
  object DList {
    def fromIList[A](as: IList[A]): DList[A] = DList(xs => as ::: xs)
  }

Odpowiednikiem naszych obliczeń jest (symbole muszą zostać stworzone za pomocą DList.fromIList)

  (((a ++ b) ++ (c ++ d)) ++ (e ++ (f ++ g))).toIList

gdzie praca podzielona jest na prawostronne (czyli szybkie) złączenia

  (as ::: (bs ::: (cs ::: (ds ::: (es ::: (fs ::: gs))))))

wykorzystując szybki konstruktor na IList.

Jak zawsze, nie ma nic za darmo. Występuje tu narzut związany z alokacją pamięci, który może spowolnić nasz kod, jeśli ten i tak zakładał prawostronne złączenia. Największe przyspieszenie uzyskamy, gdy operacje są lewostronne, np.:

  ((((((as ::: bs) ::: cs) ::: ds) ::: es) ::: fs) ::: gs)

Lista różnic cierpi z powodu kiepskiego marketingu. Najprawdopodobniej znalazłaby się w bibliotece standardowej, gdyby tylko nazywała się ListBuilderFactory.

6.8.7 ISet

Struktury drzewiaste są doskonałe do przechowywania uporządkowanych danych, tak aby każdy węzeł binarny przechowywał elementy od niego mniejsze w jednej gałęzi, a większe w drugiej. Jednak naiwna implementacja takiej struktury może w łatwy sposób stać się niesymetryczna, gdyż symetria zależeć będzie od kolejności dodawanie elementów. Utrzymywanie perfekcyjnie zbalansowanego drzewa jest możliwe, ale jednocześnie niewiarygodnie nieefektywne, ponieważ każde wstawienie elementu do drzewa powodowałoby jego pełne przebudowanie.

ISet to implementacja drzewa z ograniczoną równowagą (bounded balance), co oznacza, że jest ono zrównoważone w przybliżeniu, używając size każdej gałęzi do równoważenia węzła.

  sealed abstract class ISet[A] {
    val size: Int = this match {
      case Tip()        => 0
      case Bin(_, l, r) => 1 + l.size + r.size
    }
    ...
  }
  object ISet {
    private final case class Tip[A]() extends ISet[A]
    private final case class Bin[A](a: A, l: ISet[A], r: ISet[A]) extends ISet[A]
  
    def empty[A]: ISet[A] = Tip()
    def singleton[A](x: A): ISet[A] = Bin(x, Tip(), Tip())
    def fromFoldable[F[_]: Foldable, A: Order](xs: F[A]): ISet[A] =
      xs.foldLeft(empty[A])((a, b) => a insert b)
    ...
  }

ISet wymaga, aby A miało instancję typeklasy Order oraz musi ona pozostawać taka sama pomiędzy wywołaniami, gdyż inaczej zaburzone zostaną wewnętrzne założenia, prowadząc tym samym do uszkodzenia danych. Innymi słowy, zakładamy spójność typeklas, a więc dla dowolnego A istnieje tylko jedna instancja Order[A].

ADT ISetu niestety pozwala na wyrażenie niepoprawnych drzew. Staramy się pisać ADT tak, aby w pełni opisywały to, co jest i nie jest możliwe poprzez restrykcję typów, ale nie zawsze jest to możliwe. Zamiast tego Tip i Bin są prywatne, powstrzymując użytkowników przed przypadkowym niepoprawnych drzew. .insert jest jedynym sposobem na konstrukcję drzew, definiując tym samym to, jak wygląda poprawna jego forma.

  sealed abstract class ISet[A] {
    ...
    def contains(x: A)(implicit o: Order[A]): Boolean = ...
    def union(other: ISet[A])(implicit o: Order[A]): ISet[A] = ...
    def delete(x: A)(implicit o: Order[A]): ISet[A] = ...
  
    def insert(x: A)(implicit o: Order[A]): ISet[A] = this match {
      case Tip() => ISet.singleton(x)
      case self @ Bin(y, l, r) => o.order(x, y) match {
        case LT => balanceL(y, l.insert(x), r)
        case GT => balanceR(y, l, r.insert(x))
        case EQ => self
      }
    }
    ...
  }

Wewnętrzne metody .balanceL i .balanceR są swoimi lustrzanymi odbiciami, a więc przestudiujemy jedynie .balanceL, która jest wywoływana, gdy dodawana wartość jest mniejsza niż aktualny węzeł. Jest ona również wołana przez metodę .delete.

  def balanceL[A](y: A, left: ISet[A], right: ISet[A]): ISet[A] = (left, right) match {
  ...

Równoważenie wymaga, abyśmy sklasyfikowali scenariusze, które mogą się zdarzyć. Przejdziemy przez nie kolejno, wizualizując (y, left, right) po lewej stronie i wersją zbalansowaną (znaną tez jako drzewo obrócone, rotated tree) po prawej.

  • wypełnione koła obrazują Tip
  • trzy kolumny to left | value | right pochodzące z Bin
  • diamenty wizualizują dowolny ISet

Pierwszy scenariusz jest trywialny i zachodzi, gdy obie strony to Tipy. Nigdy nie napotkamy tego scenariusza, wykonując .insert, ale może on wystąpić przy .delete

  case (Tip(), Tip()) => singleton(y)

Drugi przypadek ma miejsce, kiedy lewa strona to Bin zawierający jedynie Tip. Nie musimy nic równoważyć, dodajemy jedynie oczywiste połączenie:

  case (Bin(lx, Tip(), Tip()), Tip()) => Bin(y, left, Tip())

Przy trzecim scenariuszu zaczyna robić się interesująco: lewa strona to Bin zawierający Bin po swojej prawej stronie.

  case (Bin(lx, Tip(), Bin(lrx, _, _)), Tip()) =>
    Bin(lrx, singleton(lx), singleton(y))

Ale co z dwoma diamentami poniżej lrx? Czy nie utraciliśmy właśnie informacji? Nie, nie utraciliśmy, ponieważ możemy wnioskować (na podstawie równoważenia wielkości), że są one zawsze puste (Tip). Nie istnieje żadna reguła w naszych scenariuszach, która pozwala na wyprodukowanie drzewa, w którym którykolwiek z tych węzłów to Bin.

Czwarty przypadek jest przeciwieństwem trzeciego.

  case (Bin(lx, ll, Tip()), Tip()) => Bin(lx, ll, singleton(y))

W scenariuszu piątym mamy do czynienia z pełnymi drzewami po obu stronach left i musimy oprzeć decyzję o dalszych krokach na ich wielkości.

  case (Bin(lx, ll, lr), Tip()) if (2*ll.size > lr.size) =>
    Bin(lx, ll, Bin(y, lr, Tip()))
  case (Bin(lx, ll, Bin(lrx, lrl, lrr)), Tip()) =>
    Bin(lrx, Bin(lx, ll, lrl), Bin(y, lrr, Tip()))

Dla pierwszej gałęzi, 2*ll.size > lr.size

a dla drugiej, 2*ll.size <= lr.size

Szósty przypadek wprowadza drzewo po prawej stronie. Gdy left jest puste, tworzymy oczywiste połączenie. Taka sytuacja nigdy nie pojawia się w wyniku .insert, ponieważ left jest zawsze pełne:

  case (Tip(), r) => Bin(y, Tip(), r)

Ostatni scenariusz zachodzi, gdy mamy pełne drzewa po obu stronach. Jeśli left jest mniejszy niż trzykrotność right, możemy po prostu stworzyć nowy Bin.

  case _ if l.size <= 3 * r.size => Bin(y, l, r)

Jednak gdy ten warunek nie jest spełniony i left jest większy od right więcej niż trzykrotnie, musimy zrównoważyć drzewa jak w przypadku piątym.

  case (Bin(lx, ll, lr), r) if (2*ll.size > lr.size) =>
    Bin(lx, ll, Bin(y, lr, r))
  case (Bin(lx, ll, Bin(lrx, lrl, lrr)), r) =>
    Bin(lrx, Bin(lx, ll, lrl), Bin(y, lrr, r))

Tym samym doszliśmy do końca analizy metody .insert i tego, jak tworzony jest ISet. Nie powinno dziwić, że Foldable jest zaimplementowany w oparciu o przeszukiwanie w-głąb. Metody takie jak .minimum i .maximum są optymalne, gdyż struktura danych bazuje na uporządkowaniu elementów.

Warto zaznaczyć, że niektóre metody nie mogą być zaimplementowane tak wydajnie, jak byśmy chcieli. Rozważmy sygnaturę Foldable.element

  @typeclass trait Foldable[F[_]] {
    ...
    def element[A: Equal](fa: F[A], a: A): Boolean
    ...
  }

Oczywistą implementacją .element jest użyć przeszukiwania (prawie) binarnego ISet.contains. Jednak nie jest to możliwe, gdyż .element dostarcza Equal, a .contains wymaga instancji Order.

Z tego samego powodu ISet nie jest w stanie dostarczyć instancji typeklasy Functor, co w praktyce okazuje się sensownym ograniczeniem: wykonanie .map powodowałoby przebudowanie całej struktury. Rozsądnie jest przekonwertować nasz zbiór do innego typu danych, na przykład IList, wykonać .map i przekonwertować wynik z powrotem. W konsekwencji nie jesteśmy w stanie uzyskać Traverse[ISet] ani Applicative[ISet].

6.8.8 IMap

  sealed abstract class ==>>[A, B] {
    val size: Int = this match {
      case Tip()           => 0
      case Bin(_, _, l, r) => 1 + l.size + r.size
    }
  }
  object ==>> {
    type IMap[A, B] = A ==>> B
  
    private final case class Tip[A, B]() extends (A ==>> B)
    private final case class Bin[A, B](
      key: A,
      value: B,
      left: A ==>> B,
      right: A ==>> B
    ) extends ==>>[A, B]
  
    def apply[A: Order, B](x: (A, B)*): A ==>> B = ...
  
    def empty[A, B]: A ==>> B = Tip[A, B]()
    def singleton[A, B](k: A, x: B): A ==>> B = Bin(k, x, Tip(), Tip())
    def fromFoldable[F[_]: Foldable, A: Order, B](fa: F[(A, B)]): A ==>> B = ...
    ...
  }

Wygląda znajomo? W rzeczy samej, IMap (alias na operator prędkości światła ==>>) to kolejne równoważone drzewo, z tą różnicą, że każdy węzeł zawiera dodatkowe pole value: B, pozwalając na przechowywanie par klucz/wartość. Instancja Order wymagana jest jedynie dla typu klucza A, a dodatkowo dostajemy zestaw przydatnych metod do aktualizowania wpisów

  sealed abstract class ==>>[A, B] {
    ...
    def adjust(k: A, f: B => B)(implicit o: Order[A]): A ==>> B = ...
    def adjustWithKey(k: A, f: (A, B) => B)(implicit o: Order[A]): A ==>> B = ...
    ...
  }

6.8.9 StrictTree i Tree

Zarówno StrictTree, jak i Tree to implementacje Rose Tree, drzewiastej struktury danych z nieograniczoną ilością gałęzi w każdym węźle. Niestety, z powodów historycznych, zbudowane na bazie kolekcji z biblioteki standardowej…

  case class StrictTree[A](
    rootLabel: A,
    subForest: Vector[StrictTree[A]]
  )

Tree to leniwa (by-need) wersja StrictTree z wygodnymi konstruktorami

  class Tree[A](
    rootc: Need[A],
    forestc: Need[Stream[Tree[A]]]
  ) {
    def rootLabel = rootc.value
    def subForest = forestc.value
  }
  object Tree {
    object Node {
      def apply[A](root: =>A, forest: =>Stream[Tree[A]]): Tree[A] = ...
    }
    object Leaf {
      def apply[A](root: =>A): Tree[A] = ...
    }
  }

Użytkownik Rose Tree powinien sam zadbać o balansowanie drzewa, co jest odpowiednie, gdy chcemy wyrazić hierarchiczną wiedzę domenową jako strukturę danych. Dla przykładu, w sztucznej inteligencji Rose Tree może być użyte w algorytmach klastrowania do organizacji danych w hierarchie coraz bardziej podobnych rzeczy. Możliwe jest również wyrażenie dokumentów XML jako Rose Tree.

Pracując z danymi hierarchicznymi, dobrze jest rozważyć tę strukturę danych, zanim stworzymy swoją własną.

6.8.10 FingerTree

Finger tree (drzewo palczaste) to uogólniona sekwencja z zamortyzowanym stałym czasem dostępu do elementów i logarytmicznym złączaniem. A to typ elementów, a V na razie zignorujemy:

  sealed abstract class FingerTree[V, A] {
    def +:(a: A): FingerTree[V, A] = ...
    def :+(a: =>A): FingerTree[V, A] = ...
    def <++>(right: =>FingerTree[V, A]): FingerTree[V, A] = ...
    ...
  }
  object FingerTree {
    private class Empty[V, A]() extends FingerTree[V, A]
    private class Single[V, A](v: V, a: =>A) extends FingerTree[V, A]
    private class Deep[V, A](
      v: V,
      left: Finger[V, A],
      spine: =>FingerTree[V, Node[V, A]],
      right: Finger[V, A]
    ) extends FingerTree[V, A]
  
    sealed abstract class Finger[V, A]
    final case class One[V, A](v: V, a1: A) extends Finger[V, A]
    final case class Two[V, A](v: V, a1: A, a2: A) extends Finger[V, A]
    final case class Three[V, A](v: V, a1: A, a2: A, a3: A) extends Finger[V, A]
    final case class Four[V, A](v: V, a1: A, a2: A, a3: A, a4: A) extends Finger[V, A]
  
    sealed abstract class Node[V, A]
    private class Node2[V, A](v: V, a1: =>A, a2: =>A) extends Node[V, A]
    private class Node3[V, A](v: V, a1: =>A, a2: =>A, a3: =>A) extends Node[V, A]
    ...
  }

Przedstawmy FingerTree jako kropki, Finger jako prostokąty, a Node jako prostokąty wewnątrz prostokątów:

Dodanie elementy na początek FingerTree za pomocą +: jest wydajne, ponieważ Deep po prostu dodaje nowy element do swojego lewego (left) palca. Jeśli palec to Four, to przebudowujemy spine, tak, aby przyjął 3 z tych elementów jako Node3. Dodawanie na koniec (:+) odbywa się tak samo, ale w odwrotnej kolejności.

Złączanie za pomocą |+| lub <++> jest bardziej wydajne niż dodawanie po jednym elemencie, ponieważ instancje Deep mogą zachować swoje zewnętrzne gałęzie, przebudowując jedynie spine.

Do tej pory ignorowaliśmy V. Ukryliśmy też niejawny parametr obecny we wszystkich wariantach tego ADT: implicit measurer: Reducer[A, V].

Reducer to rozszerzenie typeklasy Monoid pozwalające na dodanie pojedynczych elementów do M

  class Reducer[C, M: Monoid] {
    def unit(c: C): M
  
    def snoc(m: M, c: C): M = append(m, unit(c))
    def cons(c: C, m: M): M = append(unit(c), m)
  }

Na przykład Reducer[A, IList[A]] może zapewnić wydajną implementację .cons

  implicit def reducer[A]: Reducer[A, IList[A]] = new Reducer[A, IList[A]] {
    override def unit(a: A): IList[A] = IList.single(a)
    override def cons(a: A, as: IList[A]): IList[A] = a :: as
  }
6.8.10.1 IndSeq

Jeśli jako V użyjemy Int, dostaniemy sekwencje zindeksowaną, gdzie miarą jest wielkość, pozwalając nam na wyszukiwanie elementu po indeksie poprzez porównywanie indeksu z rozmiarem każdej gałezi w drzewie:

  final class IndSeq[A](val self: FingerTree[Int, A])
  object IndSeq {
    private implicit def sizer[A]: Reducer[A, Int] = _ => 1
    def apply[A](as: A*): IndSeq[A] = ...
  }
6.8.10.2 OrdSeq

Inną odmianą FingerTree jest sekwencja uporządkowana, gdzie miarą jest największa wartość w danej gałęzi:

  final class OrdSeq[A: Order](val self: FingerTree[LastOption[A], A]) {
    def partition(a: A): (OrdSeq[A], OrdSeq[A]) = ...
    def insert(a: A): OrdSeq[A] = ...
    def ++(xs: OrdSeq[A]): OrdSeq[A] = ...
  }
  object OrdSeq {
    private implicit def keyer[A]: Reducer[A, LastOption[A]] = a => Tag(Some(a))
    def apply[A: Order](as: A*): OrdSeq[A] = ...
  }

OrdSeq nie posiada instancji żadnych typeklas, co sprawia, że przydatna jest tylko do stopniowego budowania uporządkowanej sekwencji (zawierającej duplikaty). Jeśli zajdzie taka potrzeba, możemy zawsze skorzystać z bazowego FingerTree.

6.8.10.3 Cord

Najpopularniejszym użyciem FingerTree jest przechowanie tymczasowej reprezentacji Stringów w instancjach Show. Budowanie pojedynczego Stringa może być tysiąckrotnie szybsze niż domyślna implementacja .toString dla case class, która tworzy nowy ciąg znaków dla każdej warstwy w ADT.

  final case class Cord(self: FingerTree[Int, String]) {
    override def toString: String = {
      val sb = new java.lang.StringBuilder(self.measure)
      self.foreach(sb.append) // locally scoped side effect
      sb.toString
    }
    ...
  }

Dla przykładu, instancja Cord[String] zwraca Three ze stringiem pośrodku i cudzysłowami po obu stronach

  implicit val show: Show[String] = s => Cord(FingerTree.Three("\"", s, "\""))

Sprawiając, że String wygląda tak jak w kodzie źródłowym

  scala> val s = "foo"
         s.toString
  res: String = foo
  
  scala> s.show
  res: Cord = "foo"

6.8.11 Kolejka Priorytetowa Heap

Kolejka priorytetowa to struktura danych, która pozwala na szybkie wstawianie uporządkowanych elementów (zezwalając na duplikaty) oraz szybki dostęp do najmniejszego elementu (czyli takiego z najwyższym priorytetem). Nie jest wymagane, aby elementy inne niż minimalny były przechowywane wg porządku. Naiwna implementacja mogłaby wyglądać tak:

  final case class Vip[A] private (val peek: Maybe[A], xs: IList[A]) {
    def push(a: A)(implicit O: Order[A]): Vip[A] = peek match {
      case Maybe.Just(min) if a < min => Vip(a.just, min :: xs)
      case _                          => Vip(peek, a :: xs)
    }
  
    def pop(implicit O: Order[A]): Maybe[(A, Vip[A])] = peek strengthR reorder
    private def reorder(implicit O: Order[A]): Vip[A] = xs.sorted match {
      case INil()           => Vip(Maybe.empty, IList.empty)
      case ICons(min, rest) => Vip(min.just, rest)
    }
  }
  object Vip {
    def fromList[A: Order](xs: IList[A]): Vip[A] = Vip(Maybe.empty, xs).reorder
  }

Taki push jest bardzo szybki (O(1)), ale reorder, a zatem i pop bazują na metodzie IList.sorted, której złożoność to O(n log n).

Scalaz implementuje kolejkę priorytetową za pomocą struktury drzewiastej, gdzie każdy węzeł ma wartość mniejszą niż jego dzieci. Heap pozwala na szybkie wstawianie (insert), złączanie (union), sprawdzanie wielkości (size), zdejmowanie (pop) i podglądanie (minimum0) najmniejszego elementu.

  sealed abstract class Heap[A] {
    def insert(a: A)(implicit O: Order[A]): Heap[A] = ...
    def +(a: A)(implicit O: Order[A]): Heap[A] = insert(a)
  
    def union(as: Heap[A])(implicit O: Order[A]): Heap[A] = ...
  
    def uncons(implicit O: Order[A]): Option[(A, Heap[A])] = minimumO strengthR deleteMin
    def minimumO: Option[A] = ...
    def deleteMin(implicit O: Order[A]): Heap[A] = ...
  
    ...
  }
  object Heap {
    def fromData[F[_]: Foldable, A: Order](as: F[A]): Heap[A] = ...
  
    private final case class Ranked[A](rank: Int, value: A)
  
    private final case class Empty[A]() extends Heap[A]
    private final case class NonEmpty[A](
      size: Int,
      tree: Tree[Ranked[A]]
    ) extends Heap[A]
  
    ...
  }

Heap zaimplementowany jest za pomocą Rose Tree z wartościami typu Ranked, gdzie rank to głębokość subdrzewa, pozwalająca na balansowanie całej struktury. Samodzielnie zarządzamy drzewem, tak aby minimum było zawsze na jego szczycie. Zaletą takiej reprezentacji jest to, że minimum0 jest darmowe:

  def minimumO: Option[A] = this match {
    case Empty()                        => None
    case NonEmpty(_, Tree.Node(min, _)) => Some(min.value)
  }

Dodając nowy element, porównujemy go z aktualnym minimum i podmieniamy je jeśli nowa wartość jest mniejsza:

  def insert(a: A)(implicit O: Order[A]): Heap[A] = this match {
    case Empty() =>
      NonEmpty(1, Tree.Leaf(Ranked(0, a)))
    case NonEmpty(size, tree @ Tree.Node(min, _)) if a <= min.value =>
      NonEmpty(size + 1, Tree.Node(Ranked(0, a), Stream(tree)))
  ...

Dodawanie wartości, które nie są minimum, skutkuje nieuporządkowanymi gałęziami drzewa. Kiedy napotkamy dwa, lub więcej, poddrzewa tej samej rangi, optymistycznie dodajemy minimum na początek:

  ...
    case NonEmpty(size, Tree.Node(min,
           (t1 @ Tree.Node(Ranked(r1, x1), xs1)) #::
           (t2 @ Tree.Node(Ranked(r2, x2), xs2)) #:: ts)) if r1 == r2 =>
      lazy val t0 = Tree.Leaf(Ranked(0, a))
      val sub =
        if (x1 <= a && x1 <= x2)
          Tree.Node(Ranked(r1 + 1, x1), t0 #:: t2 #:: xs1)
        else if (x2 <= a && x2 <= x1)
          Tree.Node(Ranked(r2 + 1, x2), t0 #:: t1 #:: xs2)
        else
          Tree.Node(Ranked(r1 + 1, a), t1 #:: t2 #:: Stream())
  
      NonEmpty(size + 1, Tree.Node(Ranked(0, min.value), sub #:: ts))
  
    case NonEmpty(size,  Tree.Node(min, rest)) =>
      val t0 = Tree.Leaf(Ranked(0, a))
      NonEmpty(size + 1, Tree.Node(Ranked(0, min.value), t0 #:: rest))
  }

Uniknięcie pełnego uporządkowania drzewa sprawia, że insert jest bardzo szybki (O(1)), a więc producenci wartości nie są karani. Jednak konsumenci muszą ponieść koszt tej decyzji, gdyż złożoność uncons to O(log n), z racji tego, że musimy odszukać nowe minimum i przebudować drzewo. Nadal jednak jest to implementacja szybsza od naiwnej.

union również odracza porządkowanie, pozwalając na wykonanie ze złożonością O(1).

Jeśli domyślna instancja Order[Foo] nie wyraża w poprawny sposób priorytetów, których potrzebujemy, możemy użyć Tagu i dostarczyć własną instancję Order[Foo @@ Custom] dla Heap[Foo @@ Custom].

6.8.12 Diev - interwały dyskretne

Możemy wydajnie wyrazić zbiór liczb całkowitych 6, 9, 2, 13, 8, 14, 10, 7, 5 jako serię domkniętych przedziałów: [2, 2], [5, 10], [13, 14]. Diev to wydajna implementacja tej metody dla dowolnego A, dla którego istnieje Enum[A]. Tym wydajniejsza im gęstsza jego zawartość.

  sealed abstract class Diev[A] {
    def +(interval: (A, A)): Diev[A]
    def +(value: A): Diev[A]
    def ++(other: Diev[A]): Diev[A]
  
    def -(interval: (A, A)): Diev[A]
    def -(value: A): Diev[A]
    def --(other: Diev[A]): Diev[A]
  
    def intervals: Vector[(A, A)]
    def contains(value: A): Boolean
    def contains(interval: (A, A)): Boolean
    ...
  }
  object Diev {
    private final case class DieVector[A: Enum](
      intervals: Vector[(A, A)]
    ) extends Diev[A]
  
    def empty[A: Enum]: Diev[A] = ...
    def fromValuesSeq[A: Enum](values: Seq[A]): Diev[A] = ...
    def fromIntervalsSeq[A: Enum](intervals: Seq[(A, A)]): Diev[A] = ...
  }

Kiedy aktualizujemy Diev, sąsiednie przedziały są łączone i porządkowane, tak, że dla każdego zbioru wartości istnieje unikalna reprezentacja.

  scala> Diev.fromValuesSeq(List(6, 9, 2, 13, 8, 14, 10, 7, 5))
  res: Diev[Int] = ((2,2)(5,10)(13,14))
  
  scala> Diev.fromValuesSeq(List(6, 9, 2, 13, 8, 14, 10, 7, 5).reverse)
  res: Diev[Int] = ((2,2)(5,10)(13,14))

Świetnym zastosowaniem dla tej struktury są przedziały czasu. Na przykład w TradeTemplate z wcześniejszego rozdziału.

  final case class TradeTemplate(
    payments: List[java.time.LocalDate],
    ccy: Option[Currency],
    otc: Option[Boolean]
  )

Jeśli okaże się, że lista payments jest bardzo gęsta, możemy zamienić ją na Diev dla zwiększenia wydajności, nie wpływając jednocześnie na logikę biznesową, gdyż polega ona na typeklasie Monoid, a nie metodach specyficznych dla Listy. Musimy tylko dostarczyć instancję Enum[LocalDate].

6.8.13 OneAnd

Przypomnij sobie Foldable, czyli odpowiednik API kolekcji ze Scalaz, oraz Foldable1, czyli jego wersję dla niepustych kolekcji. Do tej pory widzieliśmy instancję Foldable1 jedynie dla NonEmptyList. Okazuje się, że OneAnd to prosta struktura danych, która potrafi opakować dowolną kolekcję i zamienić ją w Foldable1:

  final case class OneAnd[F[_], A](head: A, tail: F[A])

Typ NonEmptyList[A] mógłby być aliasem na OneAnd[IList, A]. W podobny sposób możemy stworzyć niepuste wersje Stream, DList i Tree. Jednak może to zaburzyć gwarancje co do uporządkowania i unikalności elementów: OneAnd[ISet, A] to nie tyle niepusty ISet, a raczej ISet z zagwarantowanym pierwszym elementem, który może również znajdować się w tym zbiorze.

6.9 Podsumowanie

W tym rozdziale przejrzeliśmy typy danych, jakie Scalaz ma do zaoferowania.

Nie musimy zapamiętać wszystkiego. Niech każda z sekcji zasadzi ziarno pewnej koncepcji, które może o sobie przypomnieć, gdy będziemy szukać rozwiązania dla konkretnego problemu.

Świat funkcyjnych struktur danych jest aktywnie badany i rozwijany. Publikacje naukowe na ten temat ukazują się regularnie, pokazując nowe podejścia do znanych problemów. Implementacja nowych struktur bazujących na literaturze to dobry sposób na swój własny wkład do ekosystemu Scalaz.

7. Zaawansowane Monady

Znajomość Zaawansowanych Monad to element obowiązkowy, aby móc nazwać się doświadczonym programistą funkcyjnym.

Jednocześnie jesteśmy deweloperami, którzy nieustannie pragną prostego życia, a więc i nasza definicja “zaawansowania” jest raczej skromna. Dla porównania: scala.concurrent.Future jest strukturą dużo bardziej skomplikowaną niż jakakolwiek z prezentowanych w tym rozdziale Monad.

7.1 Always in motion is the 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:

  1. może spowodować przepełnienie stosu
  2. 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:

  1. Return reprezentuje .point
  2. Gosub reprezentuje .bind / .flatMap

Kiedy ADT odwzorowuje argumenty powiązanej funkcji, nazywamy to kodowaniem Churcha (Church encodnig).

Typ Free to skrót od FreeMonad i nazywa się tak, ponieważ pozwala uzyskać za darmo monadę dla dowolnego S[_]. Moglibyśmy na przykład wskazać algebrę Drone lub Machines z rozdziału 3 i wygenerować struktury danych reprezentujące nasz program, które stałyby się naszym S[_]. Zastanowimy się jak może nam się to przydać pod koniec tego rozdziału.

7.3.1 Trampoline

Free jest typem bardziej ogólnym, niż tego w tym momencie potrzebujemy. Ustawiając algebrę S[_] na () => ?, czyli odroczone wykonanie znane jako thunk, otrzymamy typ Trampoline, który pozwoli nam zaimplementować bezpieczną instancję 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ą .pure z Monady
  • z F[A], używając liftM z MonadTrans

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:

  1. możemy chcieć w przyszłości przerefactorować kod, aby konfiguracja była przeładowywana
  2. wartość nie jest używana przez metody pośredniczące (intermediate callers)
  3. 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ę:

  • world1 to stan świata przed uruchomieniem programu
  • view1 to widok świata z punktu widzenia aplikacji
  • world2 to stan świata po uruchomieniu programu
  • view2 to widok świata z punktu widzenia aplikacji po uruchomieniu programu

Przykład:

  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:

  1. Wiele niejawnych instancji Monad oznacza, że kompilator nie może odnaleźć odpowiedniej składni dla kontekstu
  2. Monady nie komponują się w sposób ogólny, co oznacza, że kolejność zagnieżdżania ma znaczenie
  3. 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ą w StateT i EitherT, mimo że nie będą one używane w interpreterze.
  4. Każda warstwa niesie ze sobą koszt wydajnościowy. Niektóre transformatory są gorsze niż inne, np. StateT jest szczególnie kosztowny, ale nawet EitherT może sprawiać problemy z alokacją pamięci dla aplikacji o dużej przepustowości.

Porozmawiajmy o obejściach tych problemów.

7.4.11.1 Brak składni

Powiedzmy, że mamy algebrę

  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)
    ...
  }
  • Suspend reprezentuje program, który nie został jeszcze zinterpretowany
  • Return to .pure
  • Gosub to .bind

Instancja Free[S, A] może być wygenerowana za darmo dla dowolnej algebry S. Aby zobaczyć to wprost, rozważmy naszą algebrę Machines

  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 #c0ffee przy sprawozdaniu rocznym. NIE ZATRZYMUJCIE TEJ MASZYNY!1!

Nie ma możliwości, aby wytłumaczyć Bobowi, że nie powinien używać naszych maszyn dla jego super ważnych zadań, tak więc musimy zhakować naszą logikę biznesową i wypuścić zmianę na środowisko produkcyjne tak szybko, jak to możliwe.

Nasza łatka (monkey patch) może być przetłumaczona na strukturę Free, pozwalając nam zwrócić wcześniej przygotowany wynik (Free.pure) zamiast wykonywania standardowych operacji. Implementacja to specjalna transformacja naturalna:

  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:

  1. .map na kolekcji efektów, zwracając pojedynczy efekt. Można to osiągnąć za pomocą metody .traverse, która deleguje do .apply2 danego efektu.
  2. 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

  1. Typ Future jest zepsuty, nie idź tą drogą.
  2. Możemy zapanować nad bezpieczeństwem stosu za pomocą Trampoline.
  3. Biblioteka Transformatorów Monad (MTL) abstrahuje nad popularnymi efektami za pomocą typeklas.
  4. Transformatory Monad dostarczają domyślnych implementacji dla typeklas z MTL.
  5. Struktura Free pozwala nam analizować, optymalizować i łatwo testować nasze programy.
  6. IO umożliwia implementowanie algebr jako efektów wykonywanych na świecie zewnętrznym.
  7. IO może wykonywać efekty równolegle i jest wysoce wydajnym fundamentem dla dowolnej aplikacji.

8. Derywacja typeklas

Typeklasy pozwalają na polimorfizm w naszym kodzie, ale aby z nich skorzystać potrzebujemy ich instancji dla naszych obiektów domenowych.

Derywacja typeklas to proces tworzenia nowych instancji na podstawie instancji już istniejących, i to właśnie nim zajmiemy się w tym rozdziale.

Istnieją cztery główne podejścia do tego zagadnienia:

  1. Ręcznie tworzone instancje dla każdego obiektu domenowego. Wykorzystanie tego podejścia na co dzień jest niewykonalne, gdyż skończylibyśmy z setkami linii czystego boilerplate’u dla każdej case klasy. Jego użyteczność ogranicza się więc jedynie do zastosowań edukacyjnych i doraźnych optymalizacji wydajnościowych.
  2. Abstrahowanie ponad typeklasami z użyciem isntiejących typeklas ze Scalaz. To podejście wykorzystywane jest przez bibliotekę scalaz-deriving, która potrafi wygenerować zautomatyzowane testy oraz derywacje dla produktów i koproduktów.
  3. Makra, z tym że napisanie makra dla każdej typeklasy wymaga doświadczonego dewelopera. Na szczęście Magnolia Jona Prettiego pozwala zastąpić ręcznie pisane makra prostym API, centralizując skomplikowane interakcje z kompilatorem.
  4. Pisanie generycznych programów, używając biblioteki Shapeless. Różne elementy opatrzone słowem kluczowym implicit tworzą osobny język wewnątrz Scali, który może być wykorzystany do implementowania skomplikowanej logiki na poziomie typów.

W tym rozdziale przeanalizujemy typeklasy o rosnącym stopniu skomplikowania i ich derywacje. Zaczniemy od scalaz-deriving jako mechanizmu najbardziej pryncypialnego, powtarzając niektóre lekcje z Rozdziału 5 “Typeklasy ze Scalaz”. Następnie przejdziemy do Magnolii, która jest najprostsza do użycia, a skończymy na Shapelessie, który jest najpotężniejszy i pozwala na derywacje o skomplikowanej logice.

8.1 Uruchamianie przykładów

W tym rozdziale pokażemy jak zdefiniować derywacje pięciu konkretnych typeklas. Każda z nich pokazuje funkcjonalność, która może być uogólniona:

  @typeclass trait Equal[A]  {
    // type parameter is in contravariant (parameter) position
    @op("===") def equal(a1: A, a2: A): Boolean
  }
  
  // for requesting default values of a type when testing
  @typeclass trait Default[A] {
    // type parameter is in covariant (return) position
    def default: String \/ A
  }
  
  @typeclass trait Semigroup[A] {
    // type parameter is in both covariant and contravariant position (invariant)
    @op("|+|") def append(x: A, y: =>A): A
  }
  
  @typeclass trait JsEncoder[T] {
    // type parameter is in contravariant position and needs access to field names
    def toJson(t:
	T): JsValue
  }
  
  @typeclass trait JsDecoder[T] {
    // type parameter is in covariant position and needs access to field names
    def fromJson(j: JsValue): String \/ T
  }

8.2 scalaz-deriving

Biblioteka scalaz-deriving jest rozszerzeniem Scalaz i może być dodana do build.sbt za pomocą

  val derivingVersion = "1.0.0"
  libraryDependencies += "org.scalaz" %% "scalaz-deriving" % derivingVersion

dostarczając nam nowe typeklasy, pokazane poniżej w relacji do kluczowych typeklas ze Scalaz:

Zanim przejdziemy dalej, szybka powtórka z kluczowych typeklas w Scalaz:

  @typeclass trait InvariantFunctor[F[_]] {
    def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B]
  }
  
  @typeclass trait Contravariant[F[_]] extends InvariantFunctor[F] {
    def contramap[A, B](fa: F[A])(f: B => A): F[B]
    def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B] = contramap(fa)(g)
  }
  
  @typeclass trait Divisible[F[_]] extends Contravariant[F] {
    def conquer[A]: F[A]
    def divide2[A, B, C](fa: F[A], fb: F[B])(f: C => (A, B)): F[C]
    ...
    def divide22[...] = ...
  }
  
  @typeclass trait Functor[F[_]] extends InvariantFunctor[F] {
    def map[A, B](fa: F[A])(f: A => B): F[B]
    def xmap[A, B](fa: F[A], f: A => B, g: B => A): F[B] = map(fa)(f)
  }
  
  @typeclass trait Applicative[F[_]] extends Functor[F] {
    def point[A](a: =>A): F[A]
    def apply2[A,B,C](fa: =>F[A], fb: =>F[B])(f: (A, B) => C): F[C] = ...
    ...
    def apply12[...]
  }
  
  @typeclass trait Monad[F[_]] extends Functor[F] {
    @op(">>=") def bind[A, B](fa: F[A])(f: A => F[B]): F[B]
  }
  @typeclass trait MonadError[F[_], E] extends Monad[F] {
    def raiseError[A](e: E): F[A]
    def emap[A, B](fa: F[A])(f: A => E \/ B): F[B] = ...
    ...
  }

8.2.1 Nie powtarzaj się

Najprostszym sposobem za derywacje typeklasy jest użycie typeklas już istniejących.

Typeklasa Equal posiada instancję Contravariant[Equal], która z kolei dostarcza metodę .contramap:

  object Equal {
    implicit val contravariant = new Contravariant[Equal] {
      def contramap[A, B](fa: Equal[A])(f: B => A): Equal[B] =
        (b1, b2) => fa.equal(f(b1), f(b2))
    }
    ...
  }

Jako użytkownicy Equal możemy wykorzystać .contramap dla naszych jednoparametrowych typów danych. Pamiętajmy, że instancje typeklas powinny trafić do obiektu towarzyszącego, aby znaleźć się w niejawnym zakresie:

  final case class Foo(s: String)
  object Foo {
    implicit val equal: Equal[Foo] = Equal[String].contramap(_.s)
  }
  
  scala> Foo("hello") === Foo("world")
  false

Jednak nie wszystkie typeklasy mogą posiadać instancję typu Contravariant. W szczególności typeklasy, których parametry występują w pozycji kowariantnej, mogą w zamian dostarczać Functor:

  object Default {
    def instance[A](d: =>String \/ A) = new Default[A] { def default = d }
    implicit val string: Default[String] = instance("".right)
  
    implicit val functor: Functor[Default] = new Functor[Default] {
      def map[A, B](fa: Default[A])(f: A => B): Default[B] = instance(fa.default.map(f))
    }
    ...
  }

Możemy teraz wyderywować Default[Foo] za pomocą

  object Foo {
    implicit val default: Default[Foo] = Default[String].map(Foo(_))
    ...
  }

Jeśli parametry typeklasy występują zarówno w pozycji kowariantnej, jak i kontrawariantej, jak ma to miejsce w przypadku Semigroup, to typeklasa taka może dostarczać InvariantFunctor

  object Semigroup {
    implicit val invariant = new InvariantFunctor[Semigroup] {
      def xmap[A, B](ma: Semigroup[A], f: A => B, g: B => A) = new Semigroup[B] {
        def append(x: B, y: =>B): B = f(ma.append(g(x), g(y)))
      }
    }
    ...
  }

i do jej derywacji użyjemy .xmap:

  object Foo {
    implicit val semigroup: Semigroup[Foo] = Semigroup[String].xmap(Foo(_), _.s)
    ...
  }

W ogólności łatwiej jest użyć .xmap zamiast .map lub .contramap:

  final case class Foo(s: String)
  object Foo {
    implicit val equal: Equal[Foo]         = Equal[String].xmap(Foo(_), _.s)
    implicit val default: Default[Foo]     = Default[String].xmap(Foo(_), _.s)
    implicit val semigroup: Semigroup[Foo] = Semigroup[String].xmap(Foo(_), _.s)
  }

8.2.2 MonadError

Zazwyczaj rzeczy, które wyciągają informacje z polimorficznej wartości, posiadają instancję Contravariant, a te, które zapisują do takiej wartości, definiują Functor. Jednak bardzo często taki odczyt może się nie powieść. Przykładowo, to, że mamy domyślny String nie oznacza wcale, że możemy bez problemu wyderywować z niego domyślny String Refined NonEmpty.

  import eu.timepit.refined.refineV
  import eu.timepit.refined.api._
  import eu.timepit.refined.collection._
  
  implicit val nes: Default[String Refined NonEmpty] =
    Default[String].map(refineV[NonEmpty](_))

skutkuje błędem kompilacji

  [error] default.scala:41:32: polymorphic expression cannot be instantiated to expected type;
  [error]  found   : Either[String, String Refined NonEmpty]
  [error]  required: String Refined NonEmpty
  [error]     Default[String].map(refineV[NonEmpty](_))
  [error]                                          ^

Kompilator przypomniał nam to, czego dowiedzieliśmy się w Rozdziale 4.1, czyli że refineV zwraca Either.

Jako autorzy typeklasy Default możemy postarać się troch bardziej niż Functor i dostarczyć MonadError[Default, String]:

  implicit val monad = new MonadError[Default, String] {
    def point[A](a: =>A): Default[A] =
      instance(a.right)
    def bind[A, B](fa: Default[A])(f: A => Default[B]): Default[B] =
      instance((fa >>= f).default)
    def handleError[A](fa: Default[A])(f: String => Default[A]): Default[A] =
      instance(fa.default.handleError(e => f(e).default))
    def raiseError[A](e: String): Default[A] =
      instance(e.left)
  }

Mamy teraz dostęp do .emap i możemy wyderywować instancję dla naszego rafinowanego typu:

  implicit val nes: Default[String Refined NonEmpty] =
    Default[String].emap(refineV[NonEmpty](_).disjunction)

W praktyce możemy dostarczyć regułę dla wszystkich rafinowanych typów:

  implicit def refined[A: Default, P](
    implicit V: Validate[A, P]
  ): Default[A Refined P] = Default[A].emap(refineV[P](_).disjunction)

gdzie typ Validate pochodzi z biblioteki refined, a jego instancja wymagana jest przez refineV.

Podobnie możemy użyć .emap, aby wyderywować dekoder dla typu Int z instancji dla typu Long, chroniąc się przed brakiem totalności .toInt z biblioteki standardowej.

  implicit val long: Default[Long] = instance(0L.right)
  implicit val int: Default[Int] = Default[Long].emap {
    case n if (Int.MinValue <= n && n <= Int.MaxValue) => n.toInt.right
    case big => s"$big does not fit into 32 bits".left
  }

Jako autorzy Default powinniśmy rozważyć API, w którym nie może dojść do błędu, np. z użyciem takiej sygnatury

  @typeclass trait Default[A] {
    def default: A
  }

W takiej sytuacji nie bylibyśmy w stanie zdefiniować MonadError, wymuszając, aby instancje zawsze produkowały poprawną wartość. Poskutkowałoby to większą ilością boilerplate’u, ale również zwiększonym bezpieczeństwem w czasie kompilacji. Pozostaniemy jednak przy typie zwracanym String \/ A, gdyż może służyć za bardziej ogólny przykład.

8.2.3 .fromIso

Wszystkie typeklasy ze Scalaz mają w swoim obiekcie towarzyszącym metodę o sygnaturze podobnej do:

  object Equal {
    def fromIso[F, G: Equal](D: F <=> G): Equal[F] = ...
    ...
  }
  
  object Monad {
    def fromIso[F[_], G[_]: Monad](D: F <~> G): Monad[F] = ...
    ...
  }

Oznacza to, że jeśli mamy typ F oraz sposób na jego konwersję do typu G, który posiada instancję danej typeklasy, to wystarczy zawołać .fromIso, aby otrzymać instancję dla F.

Dla przykładu, mając typ danych Bar, możemy bez problemu zdefiniować izomorfizm do (String, Int)

  import Isomorphism._
  
  final case class Bar(s: String, i: Int)
  object Bar {
    val iso: Bar <=> (String, Int) = IsoSet(b => (b.s, b.i), t => Bar(t._1, t._2))
  }

a następnie wyderywować Equal[Bar], ponieważ istnieją już instancje Equal dla tupli dowolnego kształtu

  object Bar {
    ...
    implicit val equal: Equal[Bar] = Equal.fromIso(iso)
  }

Mechanizm .fromIso może też pomóc nam, jako autorom typeklas. Rozważmy Default, której rdzeniem jest sygnatura Unit => F[A]. Tym samym metoda default jest izomorficzna w stosunku do Kleisli[F. Unit, A], czyli transformatora ReaderT.

A skoro Kleisli posiada MonadError (jeśli tylko posiada go F), to możemy wyderywować MonadError[Default, String] poprzez stworzenie izomorfizmu między Default i Kleisli:

  private type Sig[a] = Unit => String \/ a
  private val iso = Kleisli.iso(
    λ[Sig ~> Default](s => instance(s(()))),
    λ[Default ~> Sig](d => _ => d.default)
  )
  implicit val monad: MonadError[Default, String] = MonadError.fromIso(iso)

Tym samym zyskaliśmy .map, .xmap i .emap, których wcześniej używaliśmy, w praktyce za darmo.

8.2.4 Divisible i Applicative

Aby wyderywować Equal dla naszej dwuparametrowej case klasy użyliśmy instancji dostarczanej przez Scalaz dla tupli. Ale skąd wzięła się ta instancja?

Bardziej specyficzną typeklasą niż Contravariant jest Divisible, a Equal posiada jej instancję:

  implicit val divisible = new Divisible[Equal] {
    ...
    def divide[A1, A2, Z](a1: =>Equal[A1], a2: =>Equal[A2])(
      f: Z => (A1, A2)
    ): Equal[Z] = { (z1, z2) =>
      val (s1, s2) = f(z1)
      val (t1, t2) = f(z2)
      a1.equal(s1, t1) && a2.equal(s2, t2)
    }
    def conquer[A]: Equal[A] = (_, _) => true
  }

Bazując na divide2, Dvisible jest w stanie zbudować derywacje aż do divide22, które następnie możemy zawołać bezpośrednio dla naszych typów danych:

  final case class Bar(s: String, i: Int)
  object Bar {
    implicit val equal: Equal[Bar] =
      Divisible[Equal].divide2(Equal[String], Equal[Int])(b => (b.s, b.i))
  }

Odpowiednikiem dla parametrów typu w pozycji kowariantnej jest Applicative:

  object Bar {
    ...
    implicit val default: Default[Bar] =
      Applicative[Default].apply2(Default[String], Default[Int])(Bar(_, _))
  }

Należy być jednak ostrożnym, aby nie zaburzyć praw rządzących Divisble i Applicative. Szczególnie łatwo jest naruszyć prawo kompozycji, które mówi, że oba poniższe wywołania muszą wyprodukować ten sam wynik

  • divide2(divide2(a1, a2)(dupe), a3)(dupe)
  • divide2(a1, divide2(a2, a3)(dupe))(dupe)

dla dowolnego dupe: A => (A, A). Dla Applicative sprawa wygląda podobnie.

Rozważmy JsEncoder i propozycję jej instancji Divisible:

  new Divisible[JsEncoder] {
    ...
    def divide[A, B, C](fa: JsEncoder[A], fb: JsEncoder[B])(
      f: C => (A, B)
    ): JsEncoder[C] = { c =>
      val (a, b) = f(c)
      JsArray(IList(fa.toJson(a), fb.toJson(b)))
    }
  
    def conquer[A]: JsEncoder[A] = _ => JsNull
  }

Z jednej strony prawa kompozycji, dla wejścia typu String, otrzymujemy

  JsArray([JsArray([JsString(hello),JsString(hello)]),JsString(hello)])

a z drugiej

  JsArray([JsString(hello),JsArray([JsString(hello),JsString(hello)])])

Moglibyśmy eksperymentować z różnymi wariacjami divide, ale nigdy nie zaspokoilibyśmy praw dla wszystkich możliwych wejść.

Dlatego też nie możemy dostarczyć Divisible[JsEncoder], gdyż złamalibyśmy matematyczne prawa rządzące tą typeklasą, tym samym zaburzając wszystkie założenia, na bazie których użytkownicy Divisible budują swój kod.

Aby pomóc z testowaniem tych praw, typeklasy ze Scalaz zawierają ich skodyfikowaną wersję. Możemy napisać zautomatyzowany test, przypominający nam, że złamaliśmy daną regułę:

  val D: Divisible[JsEncoder] = ...
  val S: JsEncoder[String] = JsEncoder[String]
  val E: Equal[JsEncoder[String]] = (p1, p2) => p1.toJson("hello") === p2.toJson("hello")
  assert(!D.divideLaw.composition(S, S, S)(E))

Z drugiej strony, test podobnej typeklasy JsDecoder pokazuje, że prawa Applicative są przez nią zachowane

  final case class Comp(a: String, b: Int)
  object Comp {
    implicit val equal: Equal[Comp] = ...
    implicit val decoder: JsDecoder[Comp] = ...
  }
  
  def composeTest(j: JsValue) = {
    val A: Applicative[JsDecoder] = Applicative[JsDecoder]
    val fa: JsDecoder[Comp] = JsDecoder[Comp]
    val fab: JsDecoder[Comp => (String, Int)] = A.point(c => (c.a, c.b))
    val fbc: JsDecoder[((String, Int)) => (Int, String)] = A.point(_.swap)
    val E: Equal[JsDecoder[(Int, String)]] = (p1, p2) => p1.fromJson(j) === p2.fromJson(j)
    assert(A.applyLaw.composition(fbc, fab, fa)(E))
  }

dla danych testowych

  composeTest(JsObject(IList("a" -> JsString("hello"), "b" -> JsInteger(1))))
  composeTest(JsNull)
  composeTest(JsObject(IList("a" -> JsString("hello"))))
  composeTest(JsObject(IList("b" -> JsInteger(1))))

Jesteśmy teraz w stanie zaufać, przynajmniej do pewnego stopnia, że nasza wyderywowana instancja MonadError przestrzega zasad.

Jednak udowodnienie, że taki test przechodzi dla konkretnego zbioru danych, nie udowadnia, że prawa są zachowane. Musimy jeszcze przeanalizować implementację i przekonać siebie samych, że prawa są raczej zachowane, a ponadto powinniśmy spróbować wskazać przypadki, w których mogłoby się to okazać nieprawdą.

Jednym ze sposobów generowania różnorodnych danych testowych jest użycie biblioteki scalacheck. Dostarcza ona typeklasę Arbitrary, która integruje się z większością frameworków testowych, pozwalając powtarzać testy na bazie losowo wygenerowanych danych.

Biblioteka jsonFormat dostarcza Arbitrary[JsValue] (każdy powinien dostarczać Arbitrary dla swoich ADT!) pozwalając nam na skorzystanie z forAll:

  forAll(SizeRange(10))((j: JsValue) => composeTest(j))

Taki test daje nam jeszcze większą pewność, że nasza typeklasa spełnia wszystkie prawa kompozycji dla Applicative. Sprawdzając wszystkie prawa dla Divisble i MonadError dostajemy też dużo smoke testów zupełnie za darmo.

8.2.5 Decidable i Alt

Tam, gdzie Divisble i Applicative pozwalają nam na derywacje typeklas dla produktów (w oparciu o tuple), Decidable i Alt umożliwiają ją dla koproduktów (opartych o zagnieżdżone dysjunkcje):

  @typeclass trait Alt[F[_]] extends Applicative[F] with InvariantAlt[F] {
    def alt[A](a1: =>F[A], a2: =>F[A]): F[A]
  
    def altly1[Z, A1](a1: =>F[A1])(f: A1 => Z): F[Z] = ...
    def altly2[Z, A1, A2](a1: =>F[A1], a2: =>F[A2])(f: A1 \/ A2 => Z): F[Z] = ...
    def altly3 ...
    def altly4 ...
    ...
  }
  
  @typeclass trait Decidable[F[_]] extends Divisible[F] with InvariantAlt[F] {
    def choose1[Z, A1](a1: =>F[A1])(f: Z => A1): F[Z] = ...
    def choose2[Z, A1, A2](a1: =>F[A1], a2: =>F[A2])(f: Z => A1 \/ A2): F[Z] = ...
    def choose3 ...
    def choose4 ...
    ...
  }

Te cztery typeklasy mają symetryczne sygnatury:

Typeklasa Metoda Argumenty Sygnatura Typ zwracany
Applicative apply2 F[A1], F[A2] (A1, A2) => Z F[Z]
Alt altly2 F[A1], F[A2] (A1 \/ A2) => Z F[Z]
Divisible divide2 F[A1], F[A2] Z => (A1, A2) F[Z]
Decidable choose2 F[A1], F[A2] Z => (A1 \/ A2) F[Z]

wspierając odpowiednio kowariantne produkty, kowariantne koprodukty, kontrawariantne produkty i kontrawariantne koprodukty.

Możemy stworzyć instancję Decidable[Equal], która pozwoli na derywację Equal dla dowolnego ADT!

  implicit val decidable = new Decidable[Equal] {
    ...
    def choose2[Z, A1, A2](a1: =>Equal[A1], a2: =>Equal[A2])(
      f: Z => A1 \/ A2
    ): Equal[Z] = { (z1, z2) =>
      (f(z1), f(z2)) match {
        case (-\/(s), -\/(t)) => a1.equal(s, t)
        case (\/-(s), \/-(t)) => a2.equal(s, t)
        case _ => false
      }
    }
  }

Dla przykładowego ADT

  sealed abstract class Darth { def widen: Darth = this }
  final case class Vader(s: String, i: Int)  extends Darth
  final case class JarJar(i: Int, s: String) extends Darth

gdzie produkty (Vader i JarJar) mają swoje instancje Equal

  object Vader {
    private val g: Vader => (String, Int) = d => (d.s, d.i)
    implicit val equal: Equal[Vader] = Divisible[Equal].divide2(Equal[String], Equal[Int])(g)
  }
  object JarJar {
    private val g: JarJar => (Int, String) = d => (d.i, d.s)
    implicit val equal: Equal[JarJar] = Divisible[Equal].divide2(Equal[Int], Equal[String])(g)
  }

możemy wyderywować instancję dla całego ADT:

  object Darth {
    private def g(t: Darth): Vader \/ JarJar = t match {
      case p @ Vader(_, _)  => -\/(p)
      case p @ JarJar(_, _) => \/-(p)
    }
    implicit val equal: Equal[Darth] = Decidable[Equal].choose2(Equal[Vader], Equal[JarJar])(g)
  }
  
  scala> Vader("hello", 1).widen === JarJar(1, "hello).widen
  false

Typeklasy, która mają Applicative, kwalifikują się również do Alt. Jeśli chcemy użyć triku z Kleisli.iso, musimy rozszerzyć IsomorphismMonadError i domiksować Alt. Rozszerzmy więc naszą instancję MonadError[Default, String]:

  private type K[a] = Kleisli[String \/ ?, Unit, a]
  implicit val monad = new IsomorphismMonadError[Default, K, String] with Alt[Default] {
    override val G = MonadError[K, String]
    override val iso = ...
  
    def alt[A](a1: =>Default[A], a2: =>Default[A]): Default[A] = instance(a1.default)
  }

Pozwala nam to tym samym wyderywować Default[Darath]

  object Darth {
    ...
    private def f(e: Vader \/ JarJar): Darth = e.merge
    implicit val default: Default[Darth] =
      Alt[Default].altly2(Default[Vader], Default[JarJar])(f)
  }
  object Vader {
    ...
    private val f: (String, Int) => Vader = Vader(_, _)
    implicit val default: Default[Vader] =
      Alt[Default].apply2(Default[String], Default[Int])(f)
  }
  object JarJar {
    ...
    private val f: (Int, String) => JarJar = JarJar(_, _)
    implicit val default: Default[JarJar] =
      Alt[Default].apply2(Default[Int], Default[String])(f)
  }
  
  scala> Default[Darth].default
  \/-(Vader())

Wróćmy do typeklas z scalaz-deriving, gdzie inwariantnymi odpowiednikami Alt i Decidable są:

  @typeclass trait InvariantApplicative[F[_]] extends InvariantFunctor[F] {
    def xproduct0[Z](f: =>Z): F[Z]
    def xproduct1[Z, A1](a1: =>F[A1])(f: A1 => Z, g: Z => A1): F[Z] = ...
    def xproduct2 ...
    def xproduct3 ...
    def xproduct4 ...
  }
  
  @typeclass trait InvariantAlt[F[_]] extends InvariantApplicative[F] {
    def xcoproduct1[Z, A1](a1: =>F[A1])(f: A1 => Z, g: Z => A1): F[Z] = ...
    def xcoproduct2 ...
    def xcoproduct3 ...
    def xcoproduct4 ...
  }

wspierając typeklasy z InvariantFunctorem, jak np. Monoid czy Semigroup.

8.2.6 Arbitralna arność24 i @deriving

InvariantApplicative i InvariantAlt niosą ze sobą dwa problemy:

  1. wspierają jedynie produkty o 4 polach i koprodukty o 4 pozycjach.
  2. wprowadzają dużo boilerplate’u w obiektach towarzyszących.

W tym rozdziale rozwiążemy oba te problemy z użyciem dodatkowych typeklas ze scalaz-deriving

W praktyce cztery główne typeklasy Applicative, Divisble, Alt i Decidable zostały rozszerzone do arbitralnej arności, używając biblioteki iotaz, stąd też sufiks z.

Biblioteka ta definiuje trzy główne typy:

  • TList, który opisuje ciąg typów dowolnej długości
  • Prod[A <: TList] dla produktów
  • Cop[A <: TList] dla koproduktów

Oto przykładowe reprezentacje oparte o TList dla ADT Darath z poprzedniego podrozdziału:

  import iotaz._, TList._
  
  type DarthT  = Vader  :: JarJar :: TNil
  type VaderT  = String :: Int    :: TNil
  type JarJarT = Int    :: String :: TNil

które mogą być zinstancjonizowane

  val vader: Prod[VaderT]    = Prod("hello", 1)
  val jarjar: Prod[JarJarT]  = Prod(1, "hello")
  
  val VaderI = Cop.Inject[Vader, Cop[DarthT]]
  val darth: Cop[DarthT] = VaderI.inj(Vader("hello", 1))

Aby móc użyć API ze scalaz-deriving potrzebujemy Isomorphism pomiędzy naszym ADT i generyczną reprezentacją z iotaz. Generuje to sporo boilerplate’u, do które zaraz wrócimy

  object Darth {
    private type Repr   = Vader :: JarJar :: TNil
    private val VaderI  = Cop.Inject[Vader, Cop[Repr]]
    private val JarJarI = Cop.Inject[JarJar, Cop[Repr]]
    private val iso     = IsoSet(
      {
        case d: Vader  => VaderI.inj(d)
        case d: JarJar => JarJarI.inj(d)
      }, {
        case VaderI(d)  => d
        case JarJarI(d) => d
      }
    )
    ...
  }
  
  object Vader {
    private type Repr = String :: Int :: TNil
    private val iso   = IsoSet(
      d => Prod(d.s, d.i),
      p => Vader(p.head, p.tail.head)
    )
    ...
  }
  
  object JarJar {
    private type Repr = Int :: String :: TNil
    private val iso   = IsoSet(
      d => Prod(d.i, d.s),
      p => JarJar(p.head, p.tail.head)
    )
    ...
  }

Teraz możemy już bez żadnych problemów zawołać API Deriving dla Equal, korzystając z tego, że scalaz-deriving dostarcza zoptymalizowaną instancję Deriving[Equal]

  object Darth {
    ...
    implicit val equal: Equal[Darth] = Deriving[Equal].xcoproductz(
      Prod(Need(Equal[Vader]), Need(Equal[JarJar])))(iso.to, iso.from)
  }
  object Vader {
    ...
    implicit val equal: Equal[Vader] = Deriving[Equal].xproductz(
      Prod(Need(Equal[String]), Need(Equal[Int])))(iso.to, iso.from)
  }
  object JarJar {
    ...
    implicit val equal: Equal[JarJar] = Deriving[Equal].xproductz(
      Prod(Need(Equal[Int]), Need(Equal[String])))(iso.to, iso.from)
  }

Aby móc zrobić to samo dla naszej typeklasy Default, musimy zdefiniować dodatkową instancję Deriving[Default]. Na szczęście sprowadza się to jedynie do opakowania naszej instancji Alt:

  object Default {
    ...
    implicit val deriving: Deriving[Default] = ExtendedInvariantAlt(monad)
  }

i wywołania z obiektów towarzyszących

  object Darth {
    ...
    implicit val default: Default[Darth] = Deriving[Default].xcoproductz(
      Prod(Need(Default[Vader]), Need(Default[JarJar])))(iso.to, iso.from)
  }
  object Vader {
    ...
    implicit val default: Default[Vader] = Deriving[Default].xproductz(
      Prod(Need(Default[String]), Need(Default[Int])))(iso.to, iso.from)
  }
  object JarJar {
    ...
    implicit val default: Default[JarJar] = Deriving[Default].xproductz(
      Prod(Need(Default[Int]), Need(Default[String])))(iso.to, iso.from)
  }

Tym samym rozwiązaliśmy problem dowolnej liczby parametrów, ale wprowadziliśmy jeszcze więcej boilerplate’u.

Puenta jest taka, że anotacja @deriving, pochodząca z deriving-plugin, wygeneruje cały ten boilerplate za nas! Wystarczy zaaplikować ją w korzeniu naszego ADT:

  @deriving(Equal, Default)
  sealed abstract class Darth { def widen: Darth = this }
  final case class Vader(s: String, i: Int)  extends Darth
  final case class JarJar(i: Int, s: String) extends Darth

scalaz-deriving zawiera również instancje dla typeklas Order, Semigroup i Monoid. Instancje dla Show i Arbitrary dostępne są po zainstalowaniu rozszerzeń scalaz-deriving-magnolia oraz scalaz-deriving-scalacheck.

Nie ma za co!

8.2.7 Przykłady

Zakończymy naszą naukę scalaz-deriving z w pełni działającymi implementacjami wszystkich przykładowych typeklas. Jednak zanim do tego dojdziemy, musimy poznać jeszcze jeden typ danych: /~\ a.k.a. wąż na drodze, który posłuży nam do przechowywania dwóch struktur wyższego rodzaju sparametryzowanych tym samym typem:

  sealed abstract class /~\[A[_], B[_]] {
    type T
    def a: A[T]
    def b: B[T]
  }
  object /~\ {
    type APair[A[_], B[_]]  = A /~\ B
    def unapply[A[_], B[_]](p: A /~\ B): Some[(A[p.T], B[p.T])] = ...
    def apply[A[_], B[_], Z](az: =>A[Z], bz: =>B[Z]): A /~\ B = ...
  }

Zazwyczaj będziemy używać tej struktury w kontekście Id /~\ TC, gdzie TC to nasz typeklasa, wyrażając fakt, że mamy wartość oraz instancję typeklasy dla tej wartości.

W dodatku wszystkie metody w API Deriving przyjmują niejawny parametr typu A PairedWith F[A], pozwalający bibliotece iotaz na wykonywanie .zip, .traverse i innych operacji na wartościach typu Prod i Cop. Jako że nie używamy tych parametrów bezpośrednio, to możemy je na razie zignorować.

8.2.7.1 Equal

Podobnie jak przy Default, moglibyśmy zdefiniować Decidable o stałej arności i owinąć w ExtendedInvariantAlt (rozwiązanie najprostsze), ale zamiast tego zdefiniujemy Decidablez dla korzyści wydajnościowych. Dokonamy dwóch dodatkowych optymalizacji:

  1. wykonanie porównania referencji .eq przed zaaplikowaniem Equal.equal, pozwalając na szybsze określenie równości dla tych samych wartości.
  2. szybkie wyjście z Foldable.all, kiedy którekolwiek z porównań zwróci false, tzn. jeśli pierwsze pola się nie zgadzają, to nie będziemy nawet wymagać instancji Equal dla pozostałych wartości
  new Decidablez[Equal] {
    @inline private final def quick(a: Any, b: Any): Boolean =
      a.asInstanceOf[AnyRef].eq(b.asInstanceOf[AnyRef])
  
    def dividez[Z, A <: TList, FA <: TList](tcs: Prod[FA])(g: Z => Prod[A])(
      implicit ev: A PairedWith FA
    ): Equal[Z] = (z1, z2) => (g(z1), g(z2)).zip(tcs).all {
      case (a1, a2) /~\ fa => quick(a1, a2) || fa.value.equal(a1, a2)
    }
  
    def choosez[Z, A <: TList, FA <: TList](tcs: Prod[FA])(g: Z => Cop[A])(
      implicit ev: A PairedWith FA
    ): Equal[Z] = (z1, z2) => (g(z1), g(z2)).zip(tcs) match {
      case -\/(_)               => false
      case \/-((a1, a2) /~\ fa) => quick(a1, a2) || fa.value.equal(a1, a2)
    }
  }
8.2.7.2 Default

Niestety, API iotaz dla .traverse (i analogicznej .coptraverse) wymaga od nas zdefiniowania transformacji naturalnej, co nawet w obecności kind-pojectora jest niezbyt wygodne.

  private type K[a] = Kleisli[String \/ ?, Unit, a]
  new IsomorphismMonadError[Default, K, String] with Altz[Default] {
    type Sig[a] = Unit => String \/ a
    override val G = MonadError[K, String]
    override val iso = Kleisli.iso(
      λ[Sig ~> Default](s => instance(s(()))),
      λ[Default ~> Sig](d => _ => d.default)
    )
  
    val extract = λ[NameF ~> (String \/ ?)](a => a.value.default)
    def applyz[Z, A <: TList, FA <: TList](tcs: Prod[FA])(f: Prod[A] => Z)(
      implicit ev: A PairedWith FA
    ): Default[Z] = instance(tcs.traverse(extract).map(f))
  
    val always = λ[NameF ~> Maybe](a => a.value.default.toMaybe)
    def altlyz[Z, A <: TList, FA <: TList](tcs: Prod[FA])(f: Cop[A] => Z)(
      implicit ev: A PairedWith FA
    ): Default[Z] = instance {
      tcs.coptraverse[A, NameF, Id](always).map(f).headMaybe \/> "not found"
    }
  }
8.2.7.3 Semigroup

Nie da się zdefiniować Semigroupy dla wszystkich koproduktów, ale da się to zrobić dla wszystkich produktów. W tym celu użyjemy InvariantApplicative o dowolnej arności, czyli InvariantApplicativez:

  new InvariantApplicativez[Semigroup] {
    type L[a] = ((a, a), NameF[a])
    val appender = λ[L ~> Id] { case ((a1, a2), fa) => fa.value.append(a1, a2) }
  
    def xproductz[Z, A <: TList, FA <: TList](tcs: Prod[FA])
                                             (f: Prod[A] => Z, g: Z => Prod[A])
                                             (implicit ev: A PairedWith FA) =
      new Semigroup[Z] {
        def append(z1: Z, z2: =>Z): Z = f(tcs.ziptraverse2(g(z1), g(z2), appender))
      }
  }
8.2.7.4 JsEncoder i JsDecoder

scalaz-deriving nie pozwala na dostęp do nazw pól, więc nie jest możliwe zdefiniowanie enkoderów i dekoderów z jej użyciem.

8.3 Magnolia

Magnolia jest biblioteką opierającą się o makra, która dostarcza schludne i dość proste API pomagające w derywowaniu typeklas. Instaluje się ją za pomocą wpisu w build.sbt

  libraryDependencies += "com.propensive" %% "magnolia" % "0.10.1"

Jako autorzy typeklasy musimy zaimplementować poniższe pola

  import magnolia._
  
  object MyDerivation {
    type Typeclass[A]
  
    def combine[A](ctx: CaseClass[Typeclass, A]): Typeclass[A]
    def dispatch[A](ctx: SealedTrait[Typeclass, A]): Typeclass[A]
  
    def gen[A]: Typeclass[A] = macro Magnolia.gen[A]
  }

API Magnolii to:

  class CaseClass[TC[_], A] {
    def typeName: TypeName
    def construct[B](f: Param[TC, A] => B): A
    def constructMonadic[F[_]: Monadic, B](f: Param[TC, A] => F[B]): F[A]
    def parameters: Seq[Param[TC, A]]
    def annotations: Seq[Any]
  }
  
  class SealedTrait[TC[_], A] {
    def typeName: TypeName
    def subtypes: Seq[Subtype[TC, A]]
    def dispatch[B](value: A)(handle: Subtype[TC, A] => B): B
    def annotations: Seq[Any]
  }

wraz z pomocnikami

  final case class TypeName(short: String, full: String)
  
  class Param[TC[_], A] {
    type PType
    def label: String
    def index: Int
    def typeclass: TC[PType]
    def dereference(param: A): PType
    def default: Option[PType]
    def annotations: Seq[Any]
  }
  
  class Subtype[TC[_], A] {
    type SType <: A
    def typeName: TypeName
    def index: Int
    def typeclass: TC[SType]
    def cast(a: A): SType
    def annotations: Seq[Any]
  }

Typeklasa Monadic widoczna w constructMonadic jest automatycznie generowana za pomocą import mercator._, jeśli nasz typ danych posiada metody .map i .flatMap.

Nie ma sensu używać Magnolii do derywacji typeklas, które mogą być opisane poprzez Divisible/Decidable/Applicative/Alt, gdyż te abstrakcje dają nam dodatkową strukturę i testy za darmo. Jednak Magnolia oferuje nam funkcjonalności, których nie ma scalaz-deriving: dostęp do nazw pól, nazw typów, anotacji i domyślnych wartości.

8.3.1 Przykład: JSON

Musimy zadać sobie kilka pytań odnośnie tego, jak chcemy serializować dane:

  1. Czy powinniśmy załączać pola o wartości null?
  2. Czy dekodując powinniśmy traktować brakujące pola i pola o wartości null inaczej?
  3. Jak zakodować nazwę koproduktu?
  4. Jak poradzić sobie z koproduktami, które nie są JsObjectem?

Oto nasze odpowiedzi:

  • nie załączamy pól o wartości JsNull
  • brakujące pola traktujemy tak samo, jak wartości null
  • użyjemy specjalnego pola type, aby rozróżnić koprodukty na podstawie ich nazw
  • wartości prymitywne umieścimy w specjalnym polu xvalue

Pozwolimy też użytkownikowi dołączyć anotacje do koproduktów i pól produktów, aby dostosować te zachowania:

  sealed class json extends Annotation
  object json {
    final case class nulls()          extends json
    final case class field(f: String) extends json
    final case class hint(f: String)  extends json
  }

Na przykład:

  @json.field("TYPE")
  sealed abstract class Cost
  final case class Time(s: String) extends Cost
  final case class Money(@json.field("integer") i: Int) extends Cost

Zacznijmy od enkodera, który obsługuje jedynie ustawienia domyślne:

  object JsMagnoliaEncoder {
    type Typeclass[A] = JsEncoder[A]
  
    def combine[A](ctx: CaseClass[JsEncoder, A]): JsEncoder[A] = { a =>
      val empty = IList.empty[(String, JsValue)]
      val fields = ctx.parameters.foldRight(right) { (p, acc) =>
        p.typeclass.toJson(p.dereference(a)) match {
          case JsNull => acc
          case value  => (p.label -> value) :: acc
        }
      }
      JsObject(fields)
    }
  
    def dispatch[A](ctx: SealedTrait[JsEncoder, A]): JsEncoder[A] = a =>
      ctx.dispatch(a) { sub =>
        val hint = "type" -> JsString(sub.typeName.short)
        sub.typeclass.toJson(sub.cast(a)) match {
          case JsObject(fields) => JsObject(hint :: fields)
          case other            => JsObject(IList(hint, "xvalue" -> other))
        }
      }
  
    def gen[A]: JsEncoder[A] = macro Magnolia.gen[A]
  }

Widzimy, w jak prosty sposób możemy posługiwać się nazwami pól oraz instancjami typeklas dla każdego z nich.

Teraz dodajmy wsparcie dla anotacji, aby obsłużyć preferencje użytkownika. Aby uniknąć sprawdzania anotacji za każdym kodowaniem, zapiszemy je w lokalnej tablicy. Mimo że dostęp do komórek tablicy nie jest totalny, to w praktyce mamy gwarancję, że indeksy zawsze będą się zgadzać. Wydajność zazwyczaj cierpi przy okazji walki specjalizacji z generalizacją.

  object JsMagnoliaEncoder {
    type Typeclass[A] = JsEncoder[A]
  
    def combine[A](ctx: CaseClass[JsEncoder, A]): JsEncoder[A] =
      new JsEncoder[A] {
        private val anns = ctx.parameters.map { p =>
          val nulls = p.annotations.collectFirst {
            case json.nulls() => true
          }.getOrElse(false)
          val field = p.annotations.collectFirst {
            case json.field(name) => name
          }.getOrElse(p.label)
          (nulls, field)
        }.toArray
  
        def toJson(a: A): JsValue = {
          val empty = IList.empty[(String, JsValue)]
          val fields = ctx.parameters.foldRight(empty) { (p, acc) =>
            val (nulls, field) = anns(p.index)
            p.typeclass.toJson(p.dereference(a)) match {
              case JsNull if !nulls => acc
              case value            => (field -> value) :: acc
            }
          }
          JsObject(fields)
        }
      }
  
    def dispatch[A](ctx: SealedTrait[JsEncoder, A]): JsEncoder[A] =
      new JsEncoder[A] {
        private val field = ctx.annotations.collectFirst {
          case json.field(name) => name
        }.getOrElse("type")
        private val anns = ctx.subtypes.map { s =>
          val hint = s.annotations.collectFirst {
            case json.hint(name) => field -> JsString(name)
          }.getOrElse(field -> JsString(s.typeName.short))
          val xvalue = s.annotations.collectFirst {
            case json.field(name) => name
          }.getOrElse("xvalue")
          (hint, xvalue)
        }.toArray
  
        def toJson(a: A): JsValue = ctx.dispatch(a) { sub =>
          val (hint, xvalue) = anns(sub.index)
          sub.typeclass.toJson(sub.cast(a)) match {
            case JsObject(fields) => JsObject(hint :: fields)
            case other            => JsObject(hint :: (xvalue -> other) :: IList.empty)
          }
        }
      }
  
    def gen[A]: JsEncoder[A] = macro Magnolia.gen[A]
  }

Przy dekoderze skorzystamy z metody .constructMonadic, która ma sygnaturę podobną do .traverse

  object JsMagnoliaDecoder {
    type Typeclass[A] = JsDecoder[A]
  
    def combine[A](ctx: CaseClass[JsDecoder, A]): JsDecoder[A] = {
      case obj @ JsObject(_) =>
        ctx.constructMonadic(
          p => p.typeclass.fromJson(obj.get(p.label).getOrElse(JsNull))
        )
      case other => fail("JsObject", other)
    }
  
    def dispatch[A](ctx: SealedTrait[JsDecoder, A]): JsDecoder[A] = {
      case obj @ JsObject(_) =>
        obj.get("type") match {
          case \/-(JsString(hint)) =>
            ctx.subtypes.find(_.typeName.short == hint) match {
              case None => fail(s"a valid '$hint'", obj)
              case Some(sub) =>
                val value = obj.get("xvalue").getOrElse(obj)
                sub.typeclass.fromJson(value)
            }
          case _ => fail("JsObject with type", obj)
        }
      case other => fail("JsObject", other)
    }
  
    def gen[A]: JsDecoder[A] = macro Magnolia.gen[A]
  }

Raz jeszcze dodajemy wsparcie dla preferencji użytkownika i domyślnych wartości pól wraz z paroma optymalizacjami:

  object JsMagnoliaDecoder {
    type Typeclass[A] = JsDecoder[A]
  
    def combine[A](ctx: CaseClass[JsDecoder, A]): JsDecoder[A] =
      new JsDecoder[A] {
        private val nulls = ctx.parameters.map { p =>
          p.annotations.collectFirst {
            case json.nulls() => true
          }.getOrElse(false)
        }.toArray
  
        private val fieldnames = ctx.parameters.map { p =>
          p.annotations.collectFirst {
            case json.field(name) => name
          }.getOrElse(p.label)
        }.toArray
  
        def fromJson(j: JsValue): String \/ A = j match {
          case obj @ JsObject(_) =>
            import mercator._
            val lookup = obj.fields.toMap
            ctx.constructMonadic { p =>
              val field = fieldnames(p.index)
              lookup
                .get(field)
                .into {
                  case Maybe.Just(value) => p.typeclass.fromJson(value)
                  case _ =>
                    p.default match {
                      case Some(default) => \/-(default)
                      case None if nulls(p.index) =>
                        s"missing field '$field'".left
                      case None => p.typeclass.fromJson(JsNull)
                    }
                }
            }
          case other => fail("JsObject", other)
        }
      }
  
    def dispatch[A](ctx: SealedTrait[JsDecoder, A]): JsDecoder[A] =
      new JsDecoder[A] {
        private val subtype = ctx.subtypes.map { s =>
          s.annotations.collectFirst {
            case json.hint(name) => name
          }.getOrElse(s.typeName.short) -> s
        }.toMap
        private val typehint = ctx.annotations.collectFirst {
          case json.field(name) => name
        }.getOrElse("type")
        private val xvalues = ctx.subtypes.map { sub =>
          sub.annotations.collectFirst {
            case json.field(name) => name
          }.getOrElse("xvalue")
        }.toArray
  
        def fromJson(j: JsValue): String \/ A = j match {
          case obj @ JsObject(_) =>
            obj.get(typehint) match {
              case \/-(JsString(h)) =>
                subtype.get(h) match {
                  case None => fail(s"a valid '$h'", obj)
                  case Some(sub) =>
                    val xvalue = xvalues(sub.index)
                    val value  = obj.get(xvalue).getOrElse(obj)
                    sub.typeclass.fromJson(value)
                }
              case _ => fail(s"JsObject with '$typehint' field", obj)
            }
          case other => fail("JsObject", other)
        }
      }
  
    def gen[A]: JsDecoder[A] = macro Magnolia.gen[A]
  }

Teraz musimy wywołać JsMagnoliaEncoder.gen oraz JsMagnoliaDecoder.gen z obiektów towarzyszących naszych typów danych. Na przykład dla API Map Google:

  final case class Value(text: String, value: Int)
  final case class Elements(distance: Value, duration: Value, status: String)
  final case class Rows(elements: List[Elements])
  final case class DistanceMatrix(
    destination_addresses: List[String],
    origin_addresses: List[String],
    rows: List[Rows],
    status: String
  )
  
  object Value {
    implicit val encoder: JsEncoder[Value] = JsMagnoliaEncoder.gen
    implicit val decoder: JsDecoder[Value] = JsMagnoliaDecoder.gen
  }
  object Elements {
    implicit val encoder: JsEncoder[Elements] = JsMagnoliaEncoder.gen
    implicit val decoder: JsDecoder[Elements] = JsMagnoliaDecoder.gen
  }
  object Rows {
    implicit val encoder: JsEncoder[Rows] = JsMagnoliaEncoder.gen
    implicit val decoder: JsDecoder[Rows] = JsMagnoliaDecoder.gen
  }
  object DistanceMatrix {
    implicit val encoder: JsEncoder[DistanceMatrix] = JsMagnoliaEncoder.gen
    implicit val decoder: JsDecoder[DistanceMatrix] = JsMagnoliaDecoder.gen
  }

Na szczęście anotacja @deriving wspiera derywację z użyciem Magnolii! Jeśli autor typeklasy dostarcza w swoim jarze plik deriving.conf zawierający poniższe linie:

  jsonformat.JsEncoder=jsonformat.JsMagnoliaEncoder.gen
  jsonformat.JsDecoder=jsonformat.JsMagnoliaDecoder.gen

to deriving-macro wywoła odpowiednie metody:

  @deriving(JsEncoder, JsDecoder)
  final case class Value(text: String, value: Int)
  @deriving(JsEncoder, JsDecoder)
  final case class Elements(distance: Value, duration: Value, status: String)
  @deriving(JsEncoder, JsDecoder)
  final case class Rows(elements: List[Elements])
  @deriving(JsEncoder, JsDecoder)
  final case class DistanceMatrix(
    destination_addresses: List[String],
    origin_addresses: List[String],
    rows: List[Rows],
    status: String
  )

8.3.2 Derywacja w pełni automatyczna

Generowanie niejawnych instancji w obiektach towarzyszących jest techniką znaną jako generacja semi-automatyczna (semi-auto), w porównaniu do generacji w pełni automatycznej (full-auto), która ma miejsce, gdy metoda .gen jest również niejawna.

  object JsMagnoliaEncoder {
    ...
    implicit def gen[A]: JsEncoder[A] = macro Magnolia.gen[A]
  }
  object JsMagnoliaDecoder {
    ...
    implicit def gen[A]: JsDecoder[A] = macro Magnolia.gen[A]
  }

W takim wypadku użytkownicy mogą zaimportować takie metody i zyskać magiczną derywację w punkcie użycia:

  scala> final case class Value(text: String, value: Int)
  scala> import JsMagnoliaEncoder.gen
  scala> Value("hello", 1).toJson
  res = JsObject([("text","hello"),("value",1)])

Może to brzmieć kusząco, gdyż wymaga najmniejszej ilości kodu, ale niesie ze sobą dwie pułapki:

  1. makro wykonywane jest przy każdym użyciu, a więc na przykład za każdym razem, gdy wywołamy .toJson. Spowalnia to kompilacje oraz prowadzi do stworzenia większej ilości obiektów, co może spowodować spadek wydajności w czasie wykonania.
  2. wyderywowane mogą zostać rzeczy zupełnie niespodziewane.

Punkt pierwszy jest raczej oczywisty, ale nieprzewidziane derywacje manifestują się w formie subtelnych błędów. Pomyślmy co się wydarzy dla

  @deriving(JsEncoder)
  final case class Foo(s: Option[String])

jeśli zapomnimy dostarczyć niejawną instancję dla Option. Moglibyśmy oczekiwać, że Foo(Some("hello")) przyjmie formę

  {
    "s":"hello"
  }

ale zamiast tego otrzymamy

  {
    "s": {
      "type":"Some",
      "get":"hello"
    }
  }

ponieważ Magnolia wyderywowała dla na nas enkoder dla typu Option.

Chcielibyśmy, żeby kompilator informował nas o brakujących elementach, tak więc stanowczo odradzamy używanie derywacji w pełni automatycznej.

8.4 Shapeless

Biblioteka Shapeless jest niezmiennie najbardziej skomplikowaną biblioteką w ekosystemie Scali. Taka reputacja wynika z faktu, że implementuje ona niemal osoby język do programowania generycznego na poziomie typów i robi to za pomocą maksymalnego wykorzystania implicitów.

Nie jest to pomysł zupełnie obcy. W Scalaz staramy się ograniczyć używanie wartości niejawnych jedynie do typeklas, ale czasem prosimy kompilator o dostarczenie różnego rodzaju dowodów co do wskazanych typów. Przykładem mogą być relacje Liskov i Leibniz (<~< i ===) lub zdolność do wstrzyknięcia algebry free do koproduktu algebr (Inject).

Instalacja polega na dodaniu poniższego fragmentu do build.sbt:

  libraryDependencies += "com.chuusai" %% "shapeless" % "2.3.3"

Rdzeniem biblioteki są typy danych HList i Coproduct

  package shapeless
  
  sealed trait HList
  final case class ::[+H, +T <: HList](head: H, tail: T) extends HList
  sealed trait NNil extends HList
  case object HNil extends HNil {
    def ::[H](h: H): H :: HNil = ::(h, this)
  }
  
  sealed trait Coproduct
  sealed trait :+:[+H, +T <: Coproduct] extends Coproduct
  final case class Inl[+H, +T <: Coproduct](head: H) extends :+:[H, T]
  final case class Inr[+H, +T <: Coproduct](tail: T) extends :+:[H, T]
  sealed trait CNil extends Coproduct // no implementations

które są generycznymi reprezentacjami odpowiednio produktów i koproduktów. sealed trait HNil służy tylko naszej wygodzie, abyśmy nie musieli pisać HNil.type.

Shapeless ma również kopię typu IsoSet pod nazwą Generic, która pozwala nam przechodzić między ADT i jego generyczną reprezentacją:

  trait Generic[T] {
    type Repr
    def to(t: T): Repr
    def from(r: Repr): T
  }
  object Generic {
    type Aux[T, R] = Generic[T] { type Repr = R }
    def apply[T](implicit G: Generic[T]): Aux[T, G.Repr] = G
    implicit def materialize[T, R]: Aux[T, R] = macro ...
  }

Wiele z tych typów zawiera abstrakcyjny typ Repr, a w swoich obiektach towarzyszących definiują alias typu .Aux, który pozwala go zobaczyć. Umożliwia to nam żądanie Generic[Foo] bez podawania generycznej reprezentacji, która będzie wygenerowana przez makro.

  scala> import shapeless._
  scala> final case class Foo(a: String, b: Long)
         Generic[Foo].to(Foo("hello", 13L))
  res: String :: Long :: HNil = hello :: 13 :: HNil
  
  scala> Generic[Foo].from("hello" :: 13L :: HNil)
  res: Foo = Foo(hello,13)
  
  scala> sealed abstract class Bar
         case object Irish extends Bar
         case object English extends Bar
  
  scala> Generic[Bar].to(Irish)
  res: English.type :+: Irish.type :+: CNil.type = Inl(Irish)
  
  scala> Generic[Bar].from(Inl(Irish))
  res: Bar = Irish

Istnieje również komplementarny typ LabelledGeneric, który zawiera nazwy pól.

  scala> import shapeless._, labelled._
  scala> final case class Foo(a: String, b: Long)
  
  scala> LabelledGeneric[Foo].to(Foo("hello", 13L))
  res: String with KeyTag[Symbol with Tagged[String("a")], String] ::
       Long   with KeyTag[Symbol with Tagged[String("b")],   Long] ::
       HNil =
       hello :: 13 :: HNil
  
  scala> sealed abstract class Bar
         case object Irish extends Bar
         case object English extends Bar
  
  scala> LabelledGeneric[Bar].to(Irish)
  res: Irish.type   with KeyTag[Symbol with Tagged[String("Irish")],     Irish.type] :+:
       English.type with KeyTag[Symbol with Tagged[String("English")], English.type] :+:
       CNil.type =
       Inl(Irish)

Zwróć uwagę, że wartość typu LabelledGeneric jest taka sama jak Generic. Nazwy pól istnieją jedynie na poziomie typów i są wymazywane w czasie wykonania.

Nie musimy używać typu KeyTag bezpośrednio, a zamiast tego możemy użyć aliasu:

  type FieldType[K, +V] = V with KeyTag[K, V]

Jeśli chcemy uzyskać dostęp do nazwy pola z FieldType[K, A], musimy poprosić o niejawny dowód typu Witness.Aux[K], który dostarczy nam wartość K w czasie wykonania.

Na pierwszy rzut oka to jest wszystko, co musimy wiedzieć, aby móc wyderywować instancję typeklasy z użyciem Shapelessa. Jednak z czasem wszystko się komplikuje, co zobaczymy przechodząc przez przykłady o rosnącym poziomie skomplikowania.

8.4.1 Przykład: Equal

Standardowym podejściem jest rozszerzenie typeklasy i umieszczenie jej derywacji w obiekcie towarzyszącym. W taki sposób znajduje się ona w niejawnym zakresie przeszukiwanym przez kompilator bez dopisywania dodatkowych importów.

  trait DerivedEqual[A] extends Equal[A]
  object DerivedEqual {
    ...
  }

Punktem wejścia do derywacji jest metoda .gen, wymagająca dwóch parametrów typu: A, dla którego derywujemy instancję oraz R, czyli jego generycznej reprezentacji. Następnie żądamy wartości Generic.Aux[A, R], która łączy A z R, oraz instancji DerivedEqual dla R. Zacznijmy od takiej właśnie sygnatury i prostej implementacji:

  import shapeless._
  
  object DerivedEqual {
    def gen[A, R: DerivedEqual](implicit G: Generic.Aux[A, R]): Equal[A] =
      (a1, a2) => Equal[R].equal(G.to(a1), G.to(a2))
  }

Tym samym zredukowaliśmy problem do dostarczenia DerivedEqual[R], a więc instancji dla generycznej reprezentacji A. Najpierw rozważmy produkty, czyli sytuację gdzie R <: HList. Chcielibyśmy zaimplementować taką sygnaturę:

  implicit def hcons[H: Equal, T <: HList: DerivedEqual]: DerivedEqual[H :: T]

Gdy zostanie zaimplementowana, to kompilator będzie w stanie rekursywnie ją wywoływać, aż dotrze do końca listy. W tym momencie będzie potrzebował instancji dla pustego HNil:

  implicit def hnil: DerivedEqual[HNil]

A o to implementacja:

  implicit def hcons[H: Equal, T <: HList: DerivedEqual]: DerivedEqual[H :: T] =
    (h1, h2) => Equal[H].equal(h1.head, h2.head) && Equal[T].equal(h1.tail, h2.tail)
  
  implicit val hnil: DerivedEqual[HNil] = (_, _) => true

Dla koproduktów chcielibyśmy zaimplementować podobne sygnatury:

  implicit def ccons[H: Equal, T <: Coproduct: DerivedEqual]: DerivedEqual[H :+: T]
  implicit def cnil: DerivedEqual[CNil]

.cnil nie zostanie nigdy zawołany dla typeklas takich jak Equal, gdzie parametr typu występuje jedynie w pozycji kontrawariantnej, ale kompilator tego nie wie, więc musimy dostarczyć jakąkolwiek jego implementację:

  implicit val cnil: DerivedEqual[CNil] = (_, _) => sys.error("impossible")

W przypadku koproduktów możemy porównywać jedynie instancje tego samego typu, czyli wtedy, gdy mamy do czynienia z dwukrotnym Inl lub Inr.

  implicit def ccons[H: Equal, T <: Coproduct: DerivedEqual]: DerivedEqual[H :+: T] = {
    case (Inl(c1), Inl(c2)) => Equal[H].equal(c1, c2)
    case (Inr(c1), Inr(c2)) => Equal[T].equal(c1, c2)
    case _                  => false
  }

Warto zaznaczyć, że nasze metody pokrywają się z konceptami conquer (hnil), divide2 (hlist) i alt2 (coproduct)! Jedak nic nie zyskamy definiując Decidable, gdyż musielibyśmy zaczynać od zera pisząc testy dla tego kodu.

Przetestujmy więc go prostym ADT:

  sealed abstract class Foo
  final case class Bar(s: String)          extends Foo
  final case class Faz(b: Boolean, i: Int) extends Foo
  final case object Baz                    extends Foo

Dostarczamy odpowiednie instancje:

  object Foo {
    implicit val equal: Equal[Foo] = DerivedEqual.gen
  }
  object Bar {
    implicit val equal: Equal[Bar] = DerivedEqual.gen
  }
  object Faz {
    implicit val equal: Equal[Faz] = DerivedEqual.gen
  }
  final case object Baz extends Foo {
    implicit val equal: Equal[Baz.type] = DerivedEqual.gen
  }

ale kod się nie kompiluje!

  [error] shapeless.scala:41:38: ambiguous implicit values:
  [error]  both value hnil in object DerivedEqual of type => DerivedEqual[HNil]
  [error]  and value cnil in object DerivedEqual of type => DerivedEqual[CNil]
  [error]  match expected type DerivedEqual[R]
  [error]     : Equal[Baz.type] = DerivedEqual.gen
  [error]                                      ^

Witaj w Shapelessowym świecie błędów kompilacji!

Problem, który wcale nie jest jasno widoczny w komunikacie błędu, wynika z faktu, że kompilator nie umie domyślić się, czym jest R. Musimy więc dostarczyć mu ten parametr wprost:

  implicit val equal: Equal[Baz.type] = DerivedEqual.gen[Baz.type, HNil]

lub użyć makra Generic, które dostarczy kompilatorowi generyczną reprezentację

  final case object Baz extends Foo {
    implicit val generic                = Generic[Baz.type]
    implicit val equal: Equal[Baz.type] = DerivedEqual.gen[Baz.type, generic.Repr]
  }
  ...

Powodem, dla którego to rozwiązanie działa, jest sygnatura metody .gen

  def gen[A, R: DerivedEqual](implicit G: Generic.Aux[A, R]): Equal[A]

która rozwijana jest do

  def gen[A, R](implicit R: DerivedEqual[R], G: Generic.Aux[A, R]): Equal[A]

Kompilator Scali rozwiązuje ograniczenia od lewej do prawej, a więc znajduje wiele różnych rozwiązań dla DerivedEqual, zanim ograniczy je z użyciem Generic.Aux[A, R]. Innym rozwiązaniem jest nieużywanie ograniczeń kontekstu.

Tym samym nie potrzebujemy już implicit val generic ani parametrów typu przekazywanych wprost i możemy podłączyć @deriving, dodając wpis w deriving.conf (zakładając, że chcemy nadpisać implementację ze scalaz-deriving).

  scalaz.Equal=fommil.DerivedEqual.gen

i napisać

  @deriving(Equal) sealed abstract class Foo
  @deriving(Equal) final case class Bar(s: String)          extends Foo
  @deriving(Equal) final case class Faz(b: Boolean, i: Int) extends Foo
  @deriving(Equal) final case object Baz

Ale zastąpienie wersji ze scalaz-deriving oznacza, że zwiększy się czas kompilacji naszego projektu. Wynika to z faktu, że kompilator musi rozwiązać N niejawnych przeszukiwań dla każdego produktu o N polach lub koproduktu o N wariantach, podczas gdy scalaz-deriving i Magnolia nie mają tego problemu.

Zauważ, że używając scalaz-deriving lub Magnolii wystarczy umieścić anotację @deriving na korzeniu ADT, a w przypadku Shapelessa musi się ona pojawić osobno przy każdym z wariantów.

Jednak taka implementacja nadal jest błędna: nie działa dla rekurencyjnych typów danych i informuje nas o tym w czasie wykonania. Przykład:

  @deriving(Equal) sealed trait ATree
  @deriving(Equal) final case class Leaf(value: String)               extends ATree
  @deriving(Equal) final case class Branch(left: ATree, right: ATree) extends ATree
  scala> val leaf1: Leaf    = Leaf("hello")
         val leaf2: Leaf    = Leaf("goodbye")
         val branch: Branch = Branch(leaf1, leaf2)
         val tree1: ATree   = Branch(leaf1, branch)
         val tree2: ATree   = Branch(leaf2, branch)
  
  scala> assert(tree1 /== tree2)
  [error] java.lang.NullPointerException
  [error] at DerivedEqual$.shapes$DerivedEqual$$$anonfun$hcons$1(shapeless.scala:16)
          ...

Dzieje się tak, ponieważ Equal[Tree] zależy od Equal[Branch], które z kolei zależy od Equal[Tree]. Rekurencja i BUM! Rozwiązaniem jest załadować je leniwie, a nie zachłannie.

Zarówno scalaz-deriving, jak i Magnolia obsługują ten przypadek automatycznie, lecz tutaj leży to w gestii programisty.

Typy Cached, Strict i Lazy, oparte o makra, zmieniają zachowanie kompilatora, pozwalając nam na osiągnięcie potrzebnej leniwości. Generalną zasadą jest użycie Cached[Strict[_]] w punkcie wejścia i Lazy[_] w okolicach instancji dla typu H.

W tym momencie najlepiej będzie, jeśli zupełnie zapomnimy o ograniczeniach kontekstu i typach SAM:

  sealed trait DerivedEqual[A] extends Equal[A]
  object DerivedEqual {
    def gen[A, R](
      implicit G: Generic.Aux[A, R],
      R: Cached[Strict[DerivedEqual[R]]]
    ): Equal[A] = new Equal[A] {
      def equal(a1: A, a2: A) =
        quick(a1, a2) || R.value.value.equal(G.to(a1), G.to(a2))
    }
  
    implicit def hcons[H, T <: HList](
      implicit H: Lazy[Equal[H]],
      T: DerivedEqual[T]
    ): DerivedEqual[H :: T] = new DerivedEqual[H :: T] {
      def equal(ht1: H :: T, ht2: H :: T) =
        (quick(ht1.head, ht2.head) || H.value.equal(ht1.head, ht2.head)) &&
          T.equal(ht1.tail, ht2.tail)
    }
  
    implicit val hnil: DerivedEqual[HNil] = new DerivedEqual[HNil] {
      def equal(@unused h1: HNil, @unused h2: HNil) = true
    }
  
    implicit def ccons[H, T <: Coproduct](
      implicit H: Lazy[Equal[H]],
      T: DerivedEqual[T]
    ): DerivedEqual[H :+: T] = new DerivedEqual[H :+: T] {
      def equal(ht1: H :+: T, ht2: H :+: T) = (ht1, ht2) match {
        case (Inl(c1), Inl(c2)) => quick(c1, c2) || H.value.equal(c1, c2)
        case (Inr(c1), Inr(c2)) => T.equal(c1, c2)
        case _                  => false
      }
    }
  
    implicit val cnil: DerivedEqual[CNil] = new DerivedEqual[CNil] {
      def equal(@unused c1: CNil, @unused c2: CNil) = sys.error("impossible")
    }
  
    @inline private final def quick(a: Any, b: Any): Boolean =
      a.asInstanceOf[AnyRef].eq(b.asInstanceOf[AnyRef])
  }

Przy okazji dokonaliśmy optymalizacji z użyciem quick ze scalaz-deriving.

Możemy teraz wywołać

  assert(tree1 /== tree2)

bez wyjątków rzucanych w czasie wykonania.

8.4.2 Przykład: Default

Implementując derywację typeklasy z parametrem typu w pozycji kowariantnej, nie natkniemy się na szczęście na żadne nowe pułapki. Tworzymy instancje dla HList i Coproduct, pamiętając, że musimy obsłużyć też przypadek CNil, gdyż odpowiada on sytuacji, w której żaden z wariantów nie był w stanie dostarczyć wartości.

  sealed trait DerivedDefault[A] extends Default[A]
  object DerivedDefault {
    def gen[A, R](
      implicit G: Generic.Aux[A, R],
      R: Cached[Strict[DerivedDefault[R]]]
    ): Default[A] = new Default[A] {
      def default = R.value.value.default.map(G.from)
    }
  
    implicit def hcons[H, T <: HList](
      implicit H: Lazy[Default[H]],
      T: DerivedDefault[T]
    ): DerivedDefault[H :: T] = new DerivedDefault[H :: T] {
      def default =
        for {
          head <- H.value.default
          tail <- T.default
        } yield head :: tail
    }
  
    implicit val hnil: DerivedDefault[HNil] = new DerivedDefault[HNil] {
      def default = HNil.right
    }
  
    implicit def ccons[H, T <: Coproduct](
      implicit H: Lazy[Default[H]],
      T: DerivedDefault[T]
    ): DerivedDefault[H :+: T] = new DerivedDefault[H :+: T] {
      def default = H.value.default.map(Inl(_)).orElse(T.default.map(Inr(_)))
    }
  
    implicit val cnil: DerivedDefault[CNil] = new DerivedDefault[CNil] {
      def default = "not a valid coproduct".left
    }
  }

Analogicznie do relacji pomiędzy Equal i Decidable, możemy zauważyć relację z Alt w .point (hnil), apply2 (.hcons), i .altly2 (.ccons).

Niewiele nowego moglibyśmy nauczyć się z Semigroup, więc przejdziemy od razu do enkoderów i dekoderów.

8.4.3 Przykład: JsEncoder

Aby odtworzyć nasz enkoder oparty o Magnolię, musimy mieć dostęp do:

  1. nazw pól i klas
  2. anotacji odzwierciedlających preferencje użytkownika
  3. domyślnych wartości pól

Zacznijmy od wersji obsługującej jedynie nasze domyślne zachowania.

Aby uzyskać nazwy pól, użyjemy LabelledGeneric zamiast Generic, definiując typ pierwszego elementu posłużymy się FieldType[K, H] zamiast prostym H, a wartość typu Witness.Aux[K] dostarczy nam nazwę pola w czasie wykonania.

Wszystkie nasze metody będą zwracać JsObject, więc zamiast uogólniać te wartości do JsValue możemy stworzyć wyspecjalizowaną typeklasę DerivedJsEncoder o sygnaturze innej niż ta w JsEncoder.

  import shapeless._, labelled._
  
  sealed trait DerivedJsEncoder[R] {
    def toJsFields(r: R): IList[(String, JsValue)]
  }
  object DerivedJsEncoder {
    def gen[A, R](
      implicit G: LabelledGeneric.Aux[A, R],
      R: Cached[Strict[DerivedJsEncoder[R]]]
    ): JsEncoder[A] = new JsEncoder[A] {
      def toJson(a: A) = JsObject(R.value.value.toJsFields(G.to(a)))
    }
  
    implicit def hcons[K <: Symbol, H, T <: HList](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H]],
      T: DerivedJsEncoder[T]
    ): DerivedJsEncoder[FieldType[K, H] :: T] =
      new DerivedJsEncoder[A, FieldType[K, H] :: T] {
        private val field = K.value.name
        def toJsFields(ht: FieldType[K, H] :: T) =
          ht match {
            case head :: tail =>
              val rest = T.toJsFields(tail)
              H.value.toJson(head) match {
                case JsNull => rest
                case value  => (field -> value) :: rest
              }
          }
      }
  
    implicit val hnil: DerivedJsEncoder[HNil] =
      new DerivedJsEncoder[HNil] {
        def toJsFields(h: HNil) = IList.empty
      }
  
    implicit def ccons[K <: Symbol, H, T <: Coproduct](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H]],
      T: DerivedJsEncoder[T]
    ): DerivedJsEncoder[FieldType[K, H] :+: T] =
      new DerivedJsEncoder[FieldType[K, H] :+: T] {
        private val hint = ("type" -> JsString(K.value.name))
        def toJsFields(ht: FieldType[K, H] :+: T) = ht match {
          case Inl(head) =>
            H.value.toJson(head) match {
              case JsObject(fields) => hint :: fields
              case v                => IList.single("xvalue" -> v)
            }
  
          case Inr(tail) => T.toJsFields(tail)
        }
      }
  
    implicit val cnil: DerivedJsEncoder[CNil] =
      new DerivedJsEncoder[CNil] {
        def toJsFields(c: CNil) = sys.error("impossible")
      }
  
  }

Shapeless obiera ścieżkę wykonania na etapie kompilacji, bazując na obecności anotacji, co może prowadzić do bardziej wydajnego kodu kosztem jego powtarzania. Oznacza to, że liczba anotacji i ich podtypów, z którymi mamy do czynienia musi być rozsądnie mała, gdyż inaczej okaże się, że jesteśmy zmuszenie pisać 10x więcej kodu. Zamieńmy więc nasze trzy anotacje na jedną z trzema parametrami:

  case class json(
    nulls: Boolean,
    field: Option[String],
    hint: Option[String]
  ) extends Annotation

Każde użycie takiej anotacji wymaga od użytkownika podania wszystkich 3 parametrów, gdyż wartości domyślne nie są dostępne w konstruktorach anotacji. Możemy napisać własne destruktory, aby nie musieć modyfikować kodu, który napisaliśmy dla Magnolii.

  object json {
    object nulls {
      def unapply(j: json): Boolean = j.nulls
    }
    object field {
      def unapply(j: json): Option[String] = j.field
    }
    object hint {
      def unapply(j: json): Option[String] = j.hint
    }
  }

Możemy zażądać Annotation[json, A] dla case class lub sealed traitów, aby zyskać dostęp do anotacji, ale musimy stworzyć warianty hcons i ccons obsługujące oba przypadki, gdyż wartość taka nie zostanie wygenerowana, gdy anotacja nie jest obecna. Tym samym musimy wprowadzić wartości niejawne o niższym priorytecie i za ich pomocą obsłużyć brak anotacji.

Możemy też zażądać Annotations.Aux[json, A, J], aby otrzymać HListę anotacji json dla typu A. Jednak tak samo musimy powtórzyć hcons i ccons dla przypadku, gdy anotacja nie jest obecna.

Aby wesprzeć tą jedną anotację, musimy napisać czterokrotnie więcej kodu!

Zacznijmy od przepisania derywacji JsEncoder tak, aby obsługiwała kod bez jakichkolwiek anotacji. Teraz kod, który użyje @json, się nie skompiluje, co jest dobrym zabezpieczeniem.

Musimy dodać A i J do DerivedJsEncoder i przeciągnąć je poprzez metodę .toJsObject. Nasze .hcons i ccons produkują instancje DerivedJsEncoder z anotacja None.type. Przeniesiemy je do zakresu o niższym priorytecie, tak, abyśmy mogli obsłużyć Annotation[json, A] w pierwszej kolejności.

Zauważ, że instancje dla J pojawiają się przed R. Jest to ważne, gdyż kompilator musi najpierw określić typ J, zanim będzie w stanie ustalić R.

  sealed trait DerivedJsEncoder[A, R, J <: HList] {
    def toJsFields(r: R, anns: J): IList[(String, JsValue)]
  }
  object DerivedJsEncoder extends DerivedJsEncoder1 {
    def gen[A, R, J <: HList](
      implicit
      G: LabelledGeneric.Aux[A, R],
      J: Annotations.Aux[json, A, J],
      R: Cached[Strict[DerivedJsEncoder[A, R, J]]]
    ): JsEncoder[A] = new JsEncoder[A] {
      def toJson(a: A) = JsObject(R.value.value.toJsFields(G.to(a), J()))
    }
  
    implicit def hnil[A]: DerivedJsEncoder[A, HNil, HNil] =
      new DerivedJsEncoder[A, HNil, HNil] {
        def toJsFields(h: HNil, a: HNil) = IList.empty
      }
  
    implicit def cnil[A]: DerivedJsEncoder[A, CNil, HNil] =
      new DerivedJsEncoder[A, CNil, HNil] {
        def toJsFields(c: CNil, a: HNil) = sys.error("impossible")
      }
  }
  private[jsonformat] trait DerivedJsEncoder1 {
    implicit def hcons[A, K <: Symbol, H, T <: HList, J <: HList](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H]],
      T: DerivedJsEncoder[A, T, J]
    ): DerivedJsEncoder[A, FieldType[K, H] :: T, None.type :: J] =
      new DerivedJsEncoder[A, FieldType[K, H] :: T, None.type :: J] {
        private val field = K.value.name
        def toJsFields(ht: FieldType[K, H] :: T, anns: None.type :: J) =
          ht match {
            case head :: tail =>
              val rest = T.toJsFields(tail, anns.tail)
              H.value.toJson(head) match {
                case JsNull => rest
                case value  => (field -> value) :: rest
              }
          }
      }
  
    implicit def ccons[A, K <: Symbol, H, T <: Coproduct, J <: HList](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H]],
      T: DerivedJsEncoder[A, T, J]
    ): DerivedJsEncoder[A, FieldType[K, H] :+: T, None.type :: J] =
      new DerivedJsEncoder[A, FieldType[K, H] :+: T, None.type :: J] {
        private val hint = ("type" -> JsString(K.value.name))
        def toJsFields(ht: FieldType[K, H] :+: T, anns: None.type :: J) =
          ht match {
            case Inl(head) =>
              H.value.toJson(head) match {
                case JsObject(fields) => hint :: fields
                case v                => IList.single("xvalue" -> v)
              }
            case Inr(tail) => T.toJsFields(tail, anns.tail)
          }
      }
  }

Teraz możemy dodać sygnatury dla sześciu nowych metod, które pokryją wszystkie możliwe warianty tego, gdzie może pojawić się anotacja. Zauważ, że wspieramy tylko jedną anotację w każdej pozycji, każda następna będzie po cichu zignorowana.

Powoli kończą nam się nazwy, więc arbitralnie dodamy Annotated, gdy anotacja jest na typie A i Custom, gdy jest ona umieszczona na polu:

  object DerivedJsEncoder extends DerivedJsEncoder1 {
    ...
    implicit def hconsAnnotated[A, K <: Symbol, H, T <: HList, J <: HList](
      implicit
      A: Annotation[json, A],
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H]],
      T: DerivedJsEncoder[A, T, J]
    ): DerivedJsEncoder[A, FieldType[K, H] :: T, None.type :: J]
  
    implicit def cconsAnnotated[A, K <: Symbol, H, T <: Coproduct, J <: HList](
      implicit
      A: Annotation[json, A],
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H]],
      T: DerivedJsEncoder[A, T, J]
    ): DerivedJsEncoder[A, FieldType[K, H] :+: T, None.type :: J]
  
    implicit def hconsAnnotatedCustom[A, K <: Symbol, H, T <: HList, J <: HList](
      implicit
      A: Annotation[json, A],
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H]],
      T: DerivedJsEncoder[A, T, J]
    ): DerivedJsEncoder[A, FieldType[K, H] :: T, Some[json] :: J]
  
    implicit def cconsAnnotatedCustom[A, K <: Symbol, H, T <: Coproduct, J <: HList](
      implicit
      A: Annotation[json, A],
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H]],
      T: DerivedJsEncoder[A, T, J]
    ): DerivedJsEncoder[A, FieldType[K, H] :+: T, Some[json] :: J]
  }
  private[jsonformat] trait DerivedJsEncoder1 {
    ...
    implicit def hconsCustom[A, K <: Symbol, H, T <: HList, J <: HList](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H]],
      T: DerivedJsEncoder[A, T, J]
    ): DerivedJsEncoder[A, FieldType[K, H] :: T, Some[json] :: J] = ???
  
    implicit def cconsCustom[A, K <: Symbol, H, T <: Coproduct, J <: HList](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H]],
      T: DerivedJsEncoder[A, T, J]
    ): DerivedJsEncoder[A, FieldType[K, H] :+: T, Some[json] :: J]
  }

Tak naprawdę wcale nie potrzebujemy .hconsAnnotated ani .hconsAnnotatedCustom, ponieważ anotacja umieszczona na case klasie nie ma żadnego wpływu na logikę, ma ona jedynie sens w przypadku koproduktów w .cconsAnnotated. Tym samym możemy usunąć dwie metody.

.cconsAnnotated i cconsAnnotatedCustom mogą być zdefiniowane jako

  new DerivedJsEncoder[A, FieldType[K, H] :+: T, None.type :: J] {
    private val hint = A().field.getOrElse("type") -> JsString(K.value.name)
    def toJsFields(ht: FieldType[K, H] :+: T, anns: None.type :: J) = ht match {
      case Inl(head) =>
        H.value.toJson(head) match {
          case JsObject(fields) => hint :: fields
          case v                => IList.single("xvalue" -> v)
        }
      case Inr(tail) => T.toJsFields(tail, anns.tail)
    }
  }

oraz

  new DerivedJsEncoder[A, FieldType[K, H] :+: T, Some[json] :: J] {
    private val hintfield = A().field.getOrElse("type")
    def toJsFields(ht: FieldType[K, H] :+: T, anns: Some[json] :: J) = ht match {
      case Inl(head) =>
        val ann = anns.head.get
        H.value.toJson(head) match {
          case JsObject(fields) =>
            val hint = (hintfield -> JsString(ann.hint.getOrElse(K.value.name)))
            hint :: fields
          case v =>
            val xvalue = ann.field.getOrElse("xvalue")
            IList.single(xvalue -> v)
        }
      case Inr(tail) => T.toJsFields(tail, anns.tail)
    }
  }

Użycie metod .head i .get może być niepokojące, ale zauważmy, że anns jest typu Some[json] :: J, a więc obie są totalne i zupełnie bezpieczne.

.hconsCustom i cconsCustom zapiszemy jako

  new DerivedJsEncoder[A, FieldType[K, H] :: T, Some[json] :: J] {
    def toJsFields(ht: FieldType[K, H] :: T, anns: Some[json] :: J) = ht match {
      case head :: tail =>
        val ann  = anns.head.get
        val next = T.toJsFields(tail, anns.tail)
        H.value.toJson(head) match {
          case JsNull if !ann.nulls => next
          case value =>
            val field = ann.field.getOrElse(K.value.name)
            (field -> value) :: next
        }
    }
  }

oraz

  new DerivedJsEncoder[A, FieldType[K, H] :+: T, Some[json] :: J] {
    def toJsFields(ht: FieldType[K, H] :+: T, anns: Some[json] :: J) = ht match {
      case Inl(head) =>
        val ann = anns.head.get
        H.value.toJson(head) match {
          case JsObject(fields) =>
            val hint = ("type" -> JsString(ann.hint.getOrElse(K.value.name)))
            hint :: fields
          case v =>
            val xvalue = ann.field.getOrElse("xvalue")
            IList.single(xvalue -> v)
        }
      case Inr(tail) => T.toJsFields(tail, anns.tail)
    }
  }

Oczywiście, jest tutaj dużo boilerplate’u, ale jeśli przyjrzymy się bliżej, to zobaczymy, że każda z metod jest zaimplementowana tak wydajnie, jak to możliwe biorąc pod uwagę dostępne informacje, a ścieżki wykonania wybierane są w czasie kompilacji.

Ci z obsesją na punkcie wydajności mogą przerefaktorować ten kod, tak, aby wszystkie anotacje były dostępne zawczasu, a nie wstrzykiwane przez metodę .toJsFields. Dla absolutnej wydajności moglibyśmy potraktować każdą customizację jako osobną anotację, ale tym samym po raz kolejny kilkukrotnie zwiększylibyśmy ilość kodu, wydłużając jeszcze bardziej czas kompilacji dla naszych użytkowników. Tego typu optymalizacje są poza zakresem tej książki, ale jak najbardziej są one nie tylko możliwe, ale i implementowane w praktyce. Zdolność do przeniesienia pracy z czasu wykonania do czasu kompilacji jest jedną z najbardziej pociągających rzeczy w programowaniu generycznym.

Dodatkowy haczyk, o którym musimy pamiętać, to to, że LabelledGeneric nie jest kompatybilny ze scalaz.@@, ale na szczęście istnieje obejście tego problemu. Powiedzmy, że chcielibyśmy w wydajny sposób zignorować tagi. Musimy więc dodać dodatkowe reguły derywacji:

  object JsEncoder {
    ...
    implicit def tagged[A: JsEncoder, Z]: JsEncoder[A @@ Z] =
      JsEncoder[A].contramap(Tag.unwrap)
  }
  object JsDecoder {
    ...
    implicit def tagged[A: JsDecoder, Z]: JsDecoder[A @@ Z] =
      JsDecoder[A].map(Tag(_))
  }

W tym momencie powinniśmy móc wyderywować instancję JsDecoder dla typów podobnych do naszego TradeTemplate z Rozdziału 5

  final case class TradeTemplate(
    otc: Option[Boolean] @@ Tags.Last
  )
  object TradeTemplate {
    implicit val encoder: JsEncoder[TradeTemplate] = DerivedJsEncoder.gen
  }

Jednak zamiast tego otrzymujemy błąd kompilacji:

  [error] could not find implicit value for parameter G: LabelledGeneric.Aux[A,R]
  [error]   implicit val encoder: JsEncoder[TradeTemplate] = DerivedJsEncoder.gen
  [error]                                                                     ^

Komunikat błędu jest, tak jak zawsze, niezbyt pomocny. Obejściem jest wprowadzenie dowodu dla H @@ Z o niższym priorytecie, a następnie ręczne wywołanie kodu, który kompilator powinien był znaleźć na samym początku:

  object DerivedJsEncoder extends DerivedJsEncoder1 with DerivedJsEncoder2 {
    ...
  }
  private[jsonformat] trait DerivedJsEncoder2 {
    this: DerivedJsEncoder.type =>
  
    // WORKAROUND https://github.com/milessabin/shapeless/issues/309
    implicit def hconsTagged[A, K <: Symbol, H, Z, T <: HList, J <: HList](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H @@ Z]],
      T: DerivedJsEncoder[A, T, J]
    ): DerivedJsEncoder[A, FieldType[K, H @@ Z] :: T, None.type :: J] = hcons(K, H, T)
  
    implicit def hconsCustomTagged[A, K <: Symbol, H, Z, T <: HList, J <: HList](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsEncoder[H @@ Z]],
      T: DerivedJsEncoder[A, T, J]
    ): DerivedJsEncoder[A, FieldType[K, H @@ Z] :: T, Some[json] :: J] = hconsCustom(K, H, T)
  }

Na szczęście musimy obsłużyć jedynie produkty, bo tylko one mogą być otagowane.

8.4.4 JsDecoder

Dekodowanie wygląda dokładnie tak, jak mogliśmy się tego spodziewać po poprzednich przykładach. Możemy tworzyć instancje FieldType[K, H] za pomocą funkcji pomocniczej field[K](h: H). Chcąc obsłużyć jedynie zachowania domyślne, musimy napisać:

  sealed trait DerivedJsDecoder[A] {
    def fromJsObject(j: JsObject): String \/ A
  }
  object DerivedJsDecoder {
    def gen[A, R](
      implicit G: LabelledGeneric.Aux[A, R],
      R: Cached[Strict[DerivedJsDecoder[R]]]
    ): JsDecoder[A] = new JsDecoder[A] {
      def fromJson(j: JsValue) = j match {
        case o @ JsObject(_) => R.value.value.fromJsObject(o).map(G.from)
        case other           => fail("JsObject", other)
      }
    }
  
    implicit def hcons[K <: Symbol, H, T <: HList](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsDecoder[H]],
      T: DerivedJsDecoder[T]
    ): DerivedJsDecoder[FieldType[K, H] :: T] =
      new DerivedJsDecoder[FieldType[K, H] :: T] {
        private val fieldname = K.value.name
        def fromJsObject(j: JsObject) = {
          val value = j.get(fieldname).getOrElse(JsNull)
          for {
            head  <- H.value.fromJson(value)
            tail  <- T.fromJsObject(j)
          } yield field[K](head) :: tail
        }
      }
  
    implicit val hnil: DerivedJsDecoder[HNil] = new DerivedJsDecoder[HNil] {
      private val nil               = HNil.right[String]
      def fromJsObject(j: JsObject) = nil
    }
  
    implicit def ccons[K <: Symbol, H, T <: Coproduct](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsDecoder[H]],
      T: DerivedJsDecoder[T]
    ): DerivedJsDecoder[FieldType[K, H] :+: T] =
      new DerivedJsDecoder[FieldType[K, H] :+: T] {
        private val hint = ("type" -> JsString(K.value.name))
        def fromJsObject(j: JsObject) =
          if (j.fields.element(hint)) {
            j.get("xvalue")
              .into {
                case \/-(xvalue) => H.value.fromJson(xvalue)
                case -\/(_)      => H.value.fromJson(j)
              }
              .map(h => Inl(field[K](h)))
          } else
            T.fromJsObject(j).map(Inr(_))
      }
  
    implicit val cnil: DerivedJsDecoder[CNil] = new DerivedJsDecoder[CNil] {
      def fromJsObject(j: JsObject) = fail(s"JsObject with 'type' field", j)
    }
  }

Dodanie obsługi preferencji użytkownika przebiega podobnie jak w przypadku DerivedJsEncoder i jest równie mechaniczne, więc zostawimy to jako ćwiczenie dla czytelnika.

Brakuje już tylko jednej rzeczy: obsługi domyślnych wartości w case klasach. Możemy zażądać odpowiedniej wartości, ale większym problemem jest to, że nie będziemy mogli używać tej samej logiki do derywacji instancji dla produktów i koproduktów, gdyż dla tych drugich taka wartość nigdy nie zostanie wygenerowana.

Rozwiązanie jest dość drastyczne: musimy podzielić nasz DerivedJsDecoder na DerivedCoproductJsDecoder i DerivedProductJsDecoder. Skupimy się na tym drugim i jednocześnie użyjemy typu Map dla szybszego dostępu do pól:

  sealed trait DerivedProductJsDecoder[A, R, J <: HList, D <: HList] {
    private[jsonformat] def fromJsObject(
      j: Map[String, JsValue],
      anns: J,
      defaults: D
    ): String \/ R
  }

Możemy zażądać dowodu domyślnych wartości używając Default.Aux[A, D] a następnie zduplikować wszystkie metody tak, aby obsłużyć sytuacje, gdy są i nie są one zdefiniowane. Jednak Shapeless jest litościwy (choć raz) i dostarcza Default.AsOptions.Aux[A, D], pozwalając nam obsłużyć je w czasie wykonania.

  object DerivedProductJsDecoder {
    def gen[A, R, J <: HList, D <: HList](
      implicit G: LabelledGeneric.Aux[A, R],
      J: Annotations.Aux[json, A, J],
      D: Default.AsOptions.Aux[A, D],
      R: Cached[Strict[DerivedProductJsDecoder[A, R, J, D]]]
    ): JsDecoder[A] = new JsDecoder[A] {
      def fromJson(j: JsValue) = j match {
        case o @ JsObject(_) =>
          R.value.value.fromJsObject(o.fields.toMap, J(), D()).map(G.from)
        case other => fail("JsObject", other)
      }
    }
    ...
  }

Musimy przenieść metody .hcons i .hnil do obiektu towarzyszącego nowej typeklasy, która potrafi obsłużyć domyślne wartości

  object DerivedProductJsDecoder {
    ...
      implicit def hnil[A]: DerivedProductJsDecoder[A, HNil, HNil, HNil] =
      new DerivedProductJsDecoder[A, HNil, HNil, HNil] {
        private val nil = HNil.right[String]
        def fromJsObject(j: StringyMap[JsValue], a: HNil, defaults: HNil) = nil
      }
  
    implicit def hcons[A, K <: Symbol, H, T <: HList, J <: HList, D <: HList](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsDecoder[H]],
      T: DerivedProductJsDecoder[A, T, J, D]
    ): DerivedProductJsDecoder[A, FieldType[K, H] :: T, None.type :: J, Option[H] :: D] =
      new DerivedProductJsDecoder[A, FieldType[K, H] :: T, None.type :: J, Option[H] :: D] {
        private val fieldname = K.value.name
        def fromJsObject(
          j: StringyMap[JsValue],
          anns: None.type :: J,
          defaults: Option[H] :: D
        ) =
          for {
            head <- j.get(fieldname) match {
                     case Maybe.Just(v) => H.value.fromJson(v)
                     case _ =>
                       defaults.head match {
                         case Some(default) => \/-(default)
                         case None          => H.value.fromJson(JsNull)
                       }
                   }
            tail <- T.fromJsObject(j, anns.tail, defaults.tail)
          } yield field[K](head) :: tail
      }
    ...
  }

Niestety nie możemy już używać @deriving dla produktów i koproduktów, gdyż w pliku deriving.conf może być tylko jeden wpis dla danej typeklasy.

No i nie zapomnijmy o wsparciu dla @@.

  object DerivedProductJsDecoder extends DerivedProductJsDecoder1 {
    ...
  }
  private[jsonformat] trait DerivedProductJsDecoder2 {
    this: DerivedProductJsDecoder.type =>
  
    implicit def hconsTagged[
      A, K <: Symbol, H, Z, T <: HList, J <: HList, D <: HList
    ](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsDecoder[H @@ Z]],
      T: DerivedProductJsDecoder[A, T, J, D]
    ): DerivedProductJsDecoder[
      A,
      FieldType[K, H @@ Z] :: T,
      None.type :: J,
      Option[H @@ Z] :: D
    ] = hcons(K, H, T)
  
    implicit def hconsCustomTagged[
      A, K <: Symbol, H, Z, T <: HList, J <: HList, D <: HList
    ](
      implicit
      K: Witness.Aux[K],
      H: Lazy[JsDecoder[H @@ Z]],
      T: DerivedProductJsDecoder[A, T, J, D]
    ): DerivedProductJsDecoder[
      A,
      FieldType[K, H @@ Z] :: T,
      Some[json] :: J,
      Option[H @@ Z] :: D
    ] = hconsCustomTagged(K, H, T)
  }

8.4.5 Skomplikowane Derywacje

Shapeless pozwala na dużo więcej rodzajów derywacji, niż jest możliwe do osiągnięcia z użyciem scalaz-deriving lub Magnolii. Jako przykład takiego nieosiągalnego enkodera/dekodera może posłużyć model XML z xmlformat.

  @deriving(Equal, Show, Arbitrary)
  sealed abstract class XNode
  
  @deriving(Equal, Show, Arbitrary)
  final case class XTag(
    name: String,
    attrs: IList[XAttr],
    children: IList[XTag],
    body: Maybe[XString]
  )
  
  @deriving(Equal, Show, Arbitrary)
  final case class XAttr(name: String, value: XString)
  
  @deriving(Show)
  @xderiving(Equal, Monoid, Arbitrary)
  final case class XChildren(tree: IList[XTag]) extends XNode
  
  @deriving(Show)
  @xderiving(Equal, Semigroup, Arbitrary)
  final case class XString(text: String) extends XNode

Znając naturę XMLa, sensownym wydaje się mieć osobne pary dekoderów i enkoderów dla XChildren i XString. Z użyciem Shapelessa moglibyśmy je wyderywować implementując specjalną obsługę pól zależnie od typeklas jakie są dla nich dostępne oraz od tego, czy jest to Option, czy nie. Dodatkowo przy dekodowaniu moglibyśmy mieć różne strategie dekodowania ciał elementów, które mogą być wieloczęściowe, zależnie czy nasz typ ma instancję Semigroup, Monoid czy też nie ma żadnej z nich.

8.4.6 Przykład: UrlQueryWriter

Nasza aplikacja drone-dynamic-agents mogłaby skorzystać z derywacji dla typu UrlQueryWriter, gdzie każde pole kodowane jest za pomocą odpowiedniej instancji UrlEncodedWriter, a koprodukty nie są wspierane:

  @typeclass trait UrlQueryWriter[A] {
    def toUrlQuery(a: A): UrlQuery
  }
  trait DerivedUrlQueryWriter[T] extends UrlQueryWriter[T]
  object DerivedUrlQueryWriter {
    def gen[T, Repr](
      implicit
      G: LabelledGeneric.Aux[T, Repr],
      CR: Cached[Strict[DerivedUrlQueryWriter[Repr]]]
    ): UrlQueryWriter[T] = { t =>
      CR.value.value.toUrlQuery(G.to(t))
    }
  
    implicit val hnil: DerivedUrlQueryWriter[HNil] = { _ =>
      UrlQuery(IList.empty)
    }
    implicit def hcons[Key <: Symbol, A, Remaining <: HList](
      implicit Key: Witness.Aux[Key],
      LV: Lazy[UrlEncodedWriter[A]],
      DR: DerivedUrlQueryWriter[Remaining]
    ): DerivedUrlQueryWriter[FieldType[Key, A] :: Remaining] = {
      case head :: tail =>
        val first =
          Key.value.name -> URLDecoder.decode(LV.value.toUrlEncoded(head).value, "UTF-8")
        val rest = DR.toUrlQuery(tail)
        UrlQuery(first :: rest.params)
    }
  }

Pytanie “Czy te 30 linii kodu jest faktycznie lepsze niż 8 linii dla dwóch ręcznie stworzonych instancji, których potrzebujemy?” jest całkowicie rozsądne, ale trzeba odpowiedzieć sobie na nie od nowa w każdym konkretnym przypadku.

Dla kompletności, derywacja UrlEncodedWriter może być też zaimplementowana za pomocą Magnolii:

  object UrlEncodedWriterMagnolia {
    type Typeclass[a] = UrlEncodedWriter[a]
    def combine[A](ctx: CaseClass[UrlEncodedWriter, A]) = a =>
      Refined.unsafeApply(ctx.parameters.map { p =>
        p.label + "=" + p.typeclass.toUrlEncoded(p.dereference(a))
      }.toList.intercalate("&"))
    def gen[A]: UrlEncodedWriter[A] = macro Magnolia.gen[A]
  }

8.4.7 Ciemna Strona Derywacji

“Strzeż się w pełni automatycznej derywacji. Złość, strach, agresja; ciemną stroną derywacji są one. Łatwo wypływają, szybko dołączają do ciebie w walce. Gdy raz wstąpisz na ciemną ścieżkę, na zawsze zawładną twoim kompilatorem, a ciebie pochłoną.”

― starożytny mistrz Shapelessa

W dodatku do wszystkich ostrzeżeń względem w pełni automatycznej derywacji, wspomnianych dla Magnolii, Shapeless jest zdecydowanie gorszy. Taka derywacja z jego użyciem jest nie tylko najczęstszym źródłem powolnej kompilacji, ale również źródłem bolesnych błędów w kwestii koherencji typeklas.

Derywacja w pełni automatyczna ma miejsce wtedy, gdy def gen jest opatrzona modyfikatorem implicit, sprawiając, że wywołanie przejdzie rekurencyjnie przez całe ADT. Z racji tego, jak działają niejawne zakresy, zaimportowany implicit def ma wyższy priorytet niż konkretne instancje w obiektach towarzyszących, co powoduje dekoherencję typeklas. Rozważmy taką właśnie sytuację:

  import DerivedJsEncoder._
  
  @xderiving(JsEncoder)
  final case class Foo(s: String)
  final case class Bar(foo: Foo)

Spodziewalibyśmy się, że zakodowana forma Bar("hello") będzie wyglądać tak:

  {
    "foo":"hello"
  }

ponieważ użyliśmy xderiving dla Foo. Ale zamiast tego możemy otrzymać

  {
    "foo": {
      "s":"hello"
    }
  }

Sytuacja jest jeszcze gorsza, gdy taka niejawna derywacja jest dodana do obiektu towarzyszącego typeklasy, gdyż oznacza to, że jej instancje będą zawsze derywowane w punkcie użycia a użytkownik nie może wpłynąć na ten mechanizm.

Zasadniczo pisząc programy generyczne, należy przyjąć, że wartości niejawne mogą być ignorowane przez kompilator zależnie od zakresu, co oznacza, że tracimy bezpieczeństwo w czasie kompilacji, które było naszą główną motywacją do pisania tego typu programów!

Wszystko jest dużo prostsze po jasnej stronie, gdzie modyfikator implicit jest używany jedynie dla koherentnych, globalnie unikatowych instancji typeklas. Strach przed boilerplatem jest drogą na ciemną stronę. Strach prowadzi do złości. Złość prowadzi do nienawiści. Nienawiść prowadzi do cierpienia.

8.5 Wydajność

Nie ma złotego środka w kwestii derywacji typeklas. Aspektem do rozważenia jest wydajność, zarówno w czasie kompilacji, jak i wykonania.

8.5.0.1 Czasy kompilacji

Kiedy mówimy o czasach kompilacji, to Shapeless zdecydowanie wychodzi przed szereg. Nie jest niczym nadzwyczajnym, aby mały projekt przeszedł od jednej sekundy do jednej minuty czasu kompilacji. Aby prześledzić przyczyny takich zachowań, możemy użyć pluginu scalac-profiling

  addCompilerPlugin("ch.epfl.scala" %% "scalac-profiling" % "1.0.0")
  scalacOptions ++= Seq("-Ystatistics:typer", "-P:scalac-profiling:no-profiledb")

który wyprodukuje raport mogący posłużyć do wygenerowania flame grafu.

Dla typowej derywacji opartej o Shapelessa dostajemy “skoczny” wykres:

Niemal cały czas jest poświęcony na niejawne rozstrzyganie. Wprawdzie obejmuje to też kompilacje instancji tworzonych z użyciem scalaz-deriving i Magnolii, ale to Shapeless dominuje.

A wszystko to, gdy wszystko działa. Jeśli zdarzy się problem z Shapelssową derywacją, to kompilator może się zaciąć w nieskończonej pętli i musi być zabity.

8.5.0.2 Wydajność w czasie wykonania

Kiedy mówimy o wydajności wykonania, odpowiedzią zawsze jest to zależy.

Zakładając, że logika derywacji została optymalnie zaimplementowana, to jedynym sposobem, aby dowiedzieć, która jest szybsza, jest eksperymentowanie.

Biblioteka jsonformat używa Java Microbenchmark Harness (JMH) na modelach pochodzących z API GeoJSONa, Google Maps i Twittera, które zostały skontrybuowane przez Andrity’ego Plokhotnyuka. Dla każdego modelu mamy trzy testy:

  • kodowanie ADT do JsValue
  • pomyślne dekodowanie tego samego JsValue z powrotem do ADT
  • dekodowanie JsValue z błędnymi danymi

zaaplikowane do trzech implementacji:

  • opartych o Magnolię
  • opartych o Shapeless
  • napisanych ręcznie

z odpowiadającymi optymalizacjami w każdej z nich. Wyniki prezentowane są w operacjach na sekundę (im więcej, tym lepiej) i pochodzą z wykonania na mocnej maszynie i jednym wątku:

  > jsonformat/jmh:run -i 5 -wi 5 -f1 -t1 -w1 -r1 .*encode*
  Benchmark                                 Mode  Cnt       Score      Error  Units
  
  GeoJSONBenchmarks.encodeMagnolia         thrpt    5   70527.223 ±  546.991  ops/s
  GeoJSONBenchmarks.encodeShapeless        thrpt    5   65925.215 ±  309.623  ops/s
  GeoJSONBenchmarks.encodeManual           thrpt    5   96435.691 ±  334.652  ops/s
  
  GoogleMapsAPIBenchmarks.encodeMagnolia   thrpt    5   73107.747 ±  439.803  ops/s
  GoogleMapsAPIBenchmarks.encodeShapeless  thrpt    5   53867.845 ±  510.888  ops/s
  GoogleMapsAPIBenchmarks.encodeManual     thrpt    5  127608.402 ± 1584.038  ops/s
  
  TwitterAPIBenchmarks.encodeMagnolia      thrpt    5  133425.164 ± 1281.331  ops/s
  TwitterAPIBenchmarks.encodeShapeless     thrpt    5   84233.065 ±  352.611  ops/s
  TwitterAPIBenchmarks.encodeManual        thrpt    5  281606.574 ± 1975.873  ops/s

Widzimy, że przodują implementacje ręczne, za którymi podąża Magnolia. Shapeless osiągnął od 30% do 70% wydajności ręcznie tworzonych instancji. Teraz spójrzmy na dekodowanie:

  > jsonformat/jmh:run -i 5 -wi 5 -f1 -t1 -w1 -r1 .*decode.*Success
  Benchmark                                        Mode  Cnt       Score      Error  Units
  
  GeoJSONBenchmarks.decodeMagnoliaSuccess         thrpt    5   40850.270 ±  201.457  ops/s
  GeoJSONBenchmarks.decodeShapelessSuccess        thrpt    5   41173.199 ±  373.048  ops/s
  GeoJSONBenchmarks.decodeManualSuccess           thrpt    5  110961.246 ±  468.384  ops/s
  
  GoogleMapsAPIBenchmarks.decodeMagnoliaSuccess   thrpt    5   44577.796 ±  457.861  ops/s
  GoogleMapsAPIBenchmarks.decodeShapelessSuccess  thrpt    5   31649.792 ±  861.169  ops/s
  GoogleMapsAPIBenchmarks.decodeManualSuccess     thrpt    5   56250.913 ±  394.105  ops/s
  
  TwitterAPIBenchmarks.decodeMagnoliaSuccess      thrpt    5   55868.832 ± 1106.543  ops/s
  TwitterAPIBenchmarks.decodeShapelessSuccess     thrpt    5   47711.161 ±  356.911  ops/s
  TwitterAPIBenchmarks.decodeManualSuccess        thrpt    5   71962.394 ±  465.752  ops/s

Tutaj walka o drugie miejsce między Magnolią i Shapelessem jest bardziej zażarta. W końcu test dekodujący niepoprawne dane:

  > jsonformat/jmh:run -i 5 -wi 5 -f1 -t1 -w1 -r1 .*decode.*Error
  Benchmark                                      Mode  Cnt        Score       Error  Units
  
  GeoJSONBenchmarks.decodeMagnoliaError         thrpt    5   981094.831 ± 11051.370  ops/s
  GeoJSONBenchmarks.decodeShapelessError        thrpt    5   816704.635 ±  9781.467  ops/s
  GeoJSONBenchmarks.decodeManualError           thrpt    5   586733.762 ±  6389.296  ops/s
  
  GoogleMapsAPIBenchmarks.decodeMagnoliaError   thrpt    5  1288888.446 ± 11091.080  ops/s
  GoogleMapsAPIBenchmarks.decodeShapelessError  thrpt    5  1010145.363 ±  9448.110  ops/s
  GoogleMapsAPIBenchmarks.decodeManualError     thrpt    5  1417662.720 ±  1197.283  ops/s
  
  TwitterAPIBenchmarks.decodeMagnoliaError      thrpt    5   128704.299 ±   832.122  ops/s
  TwitterAPIBenchmarks.decodeShapelessError     thrpt    5   109715.865 ±   826.488  ops/s
  TwitterAPIBenchmarks.decodeManualError        thrpt    5   148814.730 ±  1105.316  ops/s

Gdy już wydawało się, że widzimy wzór, okazało się, że zarówno Magnolia, jak i Shapeless wygrały w przypadku danych dla API GeoJSONa, ale ręczne instancje osiągnęły lepszy wyniki dla Google Maps i Twittera.

Chcielibyśmy dołączyć do porównania scalaz-deriving, więc porównamy odpowiadające sobie implementacje Equal, przetestowane na dwóch wartościach, które mają tę samą zawartość (True) i dwóch o różnej zawartości (False).

  > jsonformat/jmh:run -i 5 -wi 5 -f1 -t1 -w1 -r1 .*equal*
  Benchmark                                     Mode  Cnt        Score       Error  Units
  
  GeoJSONBenchmarks.equalScalazTrue            thrpt    5   276851.493 ±  1776.428  ops/s
  GeoJSONBenchmarks.equalMagnoliaTrue          thrpt    5    93106.945 ±  1051.062  ops/s
  GeoJSONBenchmarks.equalShapelessTrue         thrpt    5   266633.522 ±  4972.167  ops/s
  GeoJSONBenchmarks.equalManualTrue            thrpt    5   599219.169 ±  8331.308  ops/s
  
  GoogleMapsAPIBenchmarks.equalScalazTrue      thrpt    5    35442.577 ±   281.597  ops/s
  GoogleMapsAPIBenchmarks.equalMagnoliaTrue    thrpt    5    91016.557 ±   688.308  ops/s
  GoogleMapsAPIBenchmarks.equalShapelessTrue   thrpt    5   107245.505 ±   468.427  ops/s
  GoogleMapsAPIBenchmarks.equalManualTrue      thrpt    5   302247.760 ±  1927.858  ops/s
  
  TwitterAPIBenchmarks.equalScalazTrue         thrpt    5    99066.013 ±  1125.422  ops/s
  TwitterAPIBenchmarks.equalMagnoliaTrue       thrpt    5   236289.706 ±  3182.664  ops/s
  TwitterAPIBenchmarks.equalShapelessTrue      thrpt    5   251578.931 ±  2430.738  ops/s
  TwitterAPIBenchmarks.equalManualTrue         thrpt    5   865845.158 ±  6339.379  ops/s

Tak jak można było się spodziewać, instancje stworzone ręcznie są daleko z przodu. Z kolei Shapeless prawie zawsze wygrywa wśród automatycznych derywacji. Biblioteka scalaz-deriving miała dobry start z GeoJSON, ale nie poradziła sobie w testach Google Maps i Twittera. Wyniki False są niemal identyczne.

  > jsonformat/jmh:run -i 5 -wi 5 -f1 -t1 -w1 -r1 .*equal*
  Benchmark                                     Mode  Cnt        Score       Error  Units
  
  GeoJSONBenchmarks.equalScalazFalse           thrpt    5    89552.875 ±   821.791  ops/s
  GeoJSONBenchmarks.equalMagnoliaFalse         thrpt    5    86044.021 ±  7790.350  ops/s
  GeoJSONBenchmarks.equalShapelessFalse        thrpt    5   262979.062 ±  3310.750  ops/s
  GeoJSONBenchmarks.equalManualFalse           thrpt    5   599989.203 ± 23727.672  ops/s
  
  GoogleMapsAPIBenchmarks.equalScalazFalse     thrpt    5    35970.818 ±   288.609  ops/s
  GoogleMapsAPIBenchmarks.equalMagnoliaFalse   thrpt    5    82381.975 ±   625.407  ops/s
  GoogleMapsAPIBenchmarks.equalShapelessFalse  thrpt    5   110721.122 ±   579.331  ops/s
  GoogleMapsAPIBenchmarks.equalManualFalse     thrpt    5   303588.815 ±  2562.747  ops/s
  
  TwitterAPIBenchmarks.equalScalazFalse        thrpt    5   193930.568 ±  1176.421  ops/s
  TwitterAPIBenchmarks.equalMagnoliaFalse      thrpt    5   429764.654 ± 11944.057  ops/s
  TwitterAPIBenchmarks.equalShapelessFalse     thrpt    5   494510.588 ±  1455.647  ops/s
  TwitterAPIBenchmarks.equalManualFalse        thrpt    5  1631964.531 ± 13110.291  ops/s

Wydajność wykonania scalaz-deriving, Magnolii i Shapelessa jest zazwyczaj wystarczająca. Bądźmy realistami, rzadko kiedy piszemy aplikacje, które muszą kodować do JSONa więcej niż 130 000 wartości na sekundę, na jednym wątku, na JVMie. Jeśli takie jest wymaganie, to może warto spojrzeć w stronę C i C++?

Mało prawdopodobne jest, żeby wyderywowane instancje stały się wąskim gardłem aplikacji. Jeśli jednak tak się stanie, to zawsze istnieje opcja ręcznych instancji, które są bardziej potężne, ale też tym samym bardziej niebezpieczne. Łatwo jest przy ich tworzeniu popełnić błędy, literówki, a nawet przypadkowo obniżyć wydajność.

Podsumowując, derywacje i antyczne makra nie są żadną konkurencją dla dobrych, własnoręcznie napisanych instancji!

8.6 Podsumowanie

Gdy musimy zdecydować jakiej technologii użyć do derywacji typeklas, pomocny może okazać się poniższy wykaz funkcjonalności:

Funkcjonalność Scalaz Magnolia Shapeless Manual
@deriving tak tak tak  
Prawa tak      
Szybka kompilacja tak tak   tak
Nazwy pól   tak tak  
Anotacje   tak częściowo  
Domyślne wartości   tak z haczykami  
Skomplikowanie     boleśnie  
Wydajność       potrzymaj mi piwo

Polecamy używanie scalaz-deriving, gdy to tylko możliwe, Magnolii do enkoderów i dekoderów oraz gdy wydajność jest bardzo istotna, a Shapelessa tam, gdzie derywacje są bardzo skomplikowane, a czasy kompilacji nie mają dużego znaczenia.

Instancje pisane ręcznie pozostają zawsze pod ręką na specjalne okazje oraz gdy trzeba osiągnąć maksymalną wydajność. Jeśli je piszesz, to staraj się unikać literówek i błędów używając narzędzi do generacji kodu.

9. Złożenie aplikacji

Na zakończenie zaaplikujemy zdobytą wiedzę do naszej przykładowej aplikacji i zaimplementujemy klienta oraz serwer HTTP za pomocą czysto funkcyjnej biblioteki http4s.

Kod źródłowy drone-dynamic-agents jest dostępny wraz z kodem źródłowym tej książki na https://github.com/fommil/fpmortals w folderze examples. Obecność przy komputerze w trakcie lektury tego rozdziału nie jest co prawda obowiązkowa, ale wielu czytelników może zechcieć śledzić kod źródłowy wraz z tekstem tego rozdziału.

Niektóre części aplikacji pozostały niezaimplementowane i pozostawione jako ćwiczenie dla czytelnika. Więcej instrukcji znajdziesz w README.

9.1 Przegląd

Nasza główna aplikacja wymaga jedynie implementacji algebry DynAgents.

  trait DynAgents[F[_]] {
    def initial: F[WorldView]
    def update(old: WorldView): F[WorldView]
    def act(world: WorldView): F[WorldView]
  }

Mamy już taką implementację w postaci DynAgentsModule, ale wymaga ona implementacji algebr Drone i Machines, które z kolei wymagają algebr JsonClient, LocalClock i Oauth2, itd., itd., itd.

Przydatnym bywa spojrzenie z lotu ptaka na wszystkie algebry, moduły i interpretery naszej aplikacji. Oto jak ułożony jest nasz kod źródłowy:

  ├── dda
  │   ├── algebra.scala
  │   ├── DynAgents.scala
  │   ├── main.scala
  │   └── interpreters
  │       ├── DroneModule.scala
  │       └── GoogleMachinesModule.scala
  ├── http
  │   ├── JsonClient.scala
  │   ├── OAuth2JsonClient.scala
  │   ├── encoding
  │   │   ├── UrlEncoded.scala
  │   │   ├── UrlEncodedWriter.scala
  │   │   ├── UrlQuery.scala
  │   │   └── UrlQueryWriter.scala
  │   ├── oauth2
  │   │   ├── Access.scala
  │   │   ├── Auth.scala
  │   │   ├── Refresh.scala
  │   │   └── interpreters
  │   │       └── BlazeUserInteraction.scala
  │   └── interpreters
  │       └── BlazeJsonClient.scala
  ├── os
  │   └── Browser.scala
  └── time
      ├── Epoch.scala
      ├── LocalClock.scala
      └── Sleep.scala

Sygnatury wszystkich algebr możemy podsumować jako

  trait Sleep[F[_]] {
    def sleep(time: FiniteDuration): F[Unit]
  }
  
  trait LocalClock[F[_]] {
    def now: F[Epoch]
  }
  
  trait JsonClient[F[_]] {
    def get[A: JsDecoder](
      uri: String Refined Url,
      headers: IList[(String, String)]
    ): F[A]
  
    def post[P: UrlEncodedWriter, A: JsDecoder](
      uri: String Refined Url,
      payload: P,
      headers: IList[(String, String)]
    ): F[A]
  }
  
  trait Auth[F[_]] {
    def authenticate: F[CodeToken]
  }
  trait Access[F[_]] {
    def access(code: CodeToken): F[(RefreshToken, BearerToken)]
  }
  trait Refresh[F[_]] {
    def bearer(refresh: RefreshToken): F[BearerToken]
  }
  trait OAuth2JsonClient[F[_]] {
    // same methods as JsonClient, but doing OAuth2 transparently
  }
  
  trait UserInteraction[F[_]] {
    def start: F[String Refined Url]
    def open(uri: String Refined Url): F[Unit]
    def stop: F[CodeToken]
  }
  
  trait Drone[F[_]] {
    def getBacklog: F[Int]
    def getAgents: F[Int]
  }
  
  trait Machines[F[_]] {
    def getTime: F[Epoch]
    def getManaged: F[NonEmptyList[MachineNode]]
    def getAlive: F[MachineNode ==>> Epoch]
    def start(node: MachineNode): F[Unit]
    def stop(node: MachineNode): F[Unit]
  }

Zauważ, że niektóre sygnatury z poprzednich rozdziałów zostały przerefaktorowane tak, aby używały typów danych ze Scalaz, skoro już wiemy, że są lepsze od tych z biblioteki standardowej.

Definiowane typy danych to:

  @xderiving(Order, Arbitrary)
  final case class Epoch(millis: Long) extends AnyVal
  
  @deriving(Order, Show)
  final case class MachineNode(id: String)
  
  @deriving(Equal, Show)
  final case class CodeToken(token: String, redirect_uri: String Refined Url)
  
  @xderiving(Equal, Show, ConfigReader)
  final case class RefreshToken(token: String) extends AnyVal
  
  @deriving(Equal, Show, ConfigReader)
  final case class BearerToken(token: String, expires: Epoch)
  
  @deriving(ConfigReader)
  final case class OAuth2Config(token: RefreshToken, server: ServerConfig)
  
  @deriving(ConfigReader)
  final case class AppConfig(drone: BearerToken, machines: OAuth2Config)
  
  @xderiving(UrlEncodedWriter)
  final case class UrlQuery(params: IList[(String, String)]) extends AnyVal

Oraz typeklasy:

  @typeclass trait UrlEncodedWriter[A] {
    def toUrlEncoded(a: A): String Refined UrlEncoded
  }
  @typeclass trait UrlQueryWriter[A] {
    def toUrlQuery(a: A): UrlQuery
  }

Derywujemy przydatne typeklasy używając scalaz-deriving oraz Magnolii. ConfigReader pochodzi z biblioteki pureconfig i służy do odczytywania konfiguracji z plików HOCON.

Przeanalizujmy też, bez zaglądania do implementacji, jak kształtuje się graf zależności w DynAgentsModule.

  final class DynAgentsModule[F[_]: Applicative](
    D: Drone[F],
    M: Machines[F]
  ) extends DynAgents[F] { ... }
  
  final class DroneModule[F[_]](
    H: OAuth2JsonClient[F]
  ) extends Drone[F] { ... }
  
  final class GoogleMachinesModule[F[_]](
    H: OAuth2JsonClient[F]
  ) extends Machines[F] { ... }

Dwa moduły implementują OAuth2JsonClient, jeden używa algebry Refresh dla usług Google’a, a drugi niewygasającego BearerToken dla Drone‘a.

  final class OAuth2JsonClientModule[F[_]](
    token: RefreshToken
  )(
    H: JsonClient[F],
    T: LocalClock[F],
    A: Refresh[F]
  )(
    implicit F: MonadState[F, BearerToken]
  ) extends OAuth2JsonClient[F] { ... }
  
  final class BearerJsonClientModule[F[_]: Monad](
    bearer: BearerToken
  )(
    H: JsonClient[F]
  ) extends OAuth2JsonClient[F] { ... }

Do tej pory widzieliśmy wymagania względem F mówiące, że musimy dostarczyć Applicative[F], Monad[F] oraz MonadState[F, BearerToken]. Wszystkie te wymagania spełnia StateT[Task, BearerToken, ?] co pozwala nam uczynić ten typ kontekstem naszej aplikacji.

Jednak niektóre algebry mają interpretery używające bezpośrednio typu Task:

  final class LocalClockTask extends LocalClock[Task] { ... }
  final class SleepTask extends Sleep[Task] { ... }

Przypomnijmy, że nasze algebry mogą dostarczać liftM w swoich obiektach towarzyszących (patrz rozdział 7.4 na temat Biblioteki Transformatorów Monad), co pozwala nam wynieść LocalClock[Task] do pożądanego StateT[Task, BearerToken, ?] czyniąc wszystko idealnie spójnym.

Niestety to nie koniec. Sprawy komplikują się na następnej warstwie, gdyż JsonClient posiada interpreter używający innego kontekstu

  final class BlazeJsonClient[F[_]](H: Client[Task])(
    implicit
    F: MonadError[F, JsonClient.Error],
    I: MonadIO[F, Throwable]
  ) extends JsonClient[F] { ... }
  object BlazeJsonClient {
    def apply[F[_]](
      implicit
      F: MonadError[F, JsonClient.Error],
      I: MonadIO[F, Throwable]
    ): Task[JsonClient[F]] = ...
  }

Zauważ, że konstruktor BlazeJsonClient zwraca Task[JsonClient[F]], a nie JsonClient[F]. Dzieje się tak, ponieważ stworzenie tego klient powoduje efekt w postaci utworzenia mutowalnej puli połączeń zarządzanej wewnętrznie przez http4s.

Nie możemy zapomnieć o dostarczeniu RefreshToken dla GoogleMachinesModule. Moglibyśmy zrzucić to zadanie na użytkownika, ale jesteśmy mili i dostarczamy osobną aplikację, która używając algebr Auth i Access rozwiązuje ten problem. Implementacje AuthModule i AccessModule niosą ze sobą kolejne wymagania, ale na szczęście żadnych zmian co do kontekstu F[_].

  final class AuthModule[F[_]: Monad](
    config: ServerConfig
  )(
    I: UserInteraction[F]
  ) extends Auth[F] { ... }
  
  final class AccessModule[F[_]: Monad](
    config: ServerConfig
  )(
    H: JsonClient[F],
    T: LocalClock[F]
  ) extends Access[F] { ... }
  
  final class BlazeUserInteraction private (
    pserver: Promise[Void, Server[Task]],
    ptoken: Promise[Void, String]
  ) extends UserInteraction[Task] { ... }
  object BlazeUserInteraction {
    def apply(): Task[BlazeUserInteraction] = ...
  }

Interpreter algebry UserInteraction jest najbardziej skomplikowanym elementem naszego kodu. Startuje on serwer HTTP, prosi użytkownika o otworzenie strony w przeglądarce, odbiera wywołanie zwrotne w serwerze i zwraca wynik, jednocześnie zakańczając pracę serwera w bezpieczny sposób.

Zamiast używać StateT do zarządzania tym stanem użyliśmy typu Promise (pochodzącego z ioeffect). Powinniśmy zawsze używać Promise (lub IORef) zamiast StateT, gdy piszemy interpreter oparty o IO, gdyż pozwala nam to opanować abstrakcje. Gdybyśmy użyli StateT, to nie tylko miałoby to wpływ na całą aplikacje, ale również powodowałoby wyciek lokalnego stanu do głównej aplikacji, która musiałaby przejąc odpowiedzialność za dostarczenie inicjalnej wartości. W tym wypadku nie moglibyśmy użyć StateT również dlatego, że potrzebujemy możliwości “czekania”, którą daje nam jedynie Promise.

9.2 Main

Najbrzydsza część FP pojawia się, gdy musimy sprawić, by wszystkie monady się zgadzały. Najczęściej ma to miejsce w punkcie wejściowym naszej aplikacji, czyli klasie Main.

Przypomnijmy, nasza główna pętla wyglądała tak:

  state = initial()
  while True:
    state = update(state)
    state = act(state)

Dobra wiadomość jest taka, że teraz ten kod będzie wyglądał tak:

  for {
    old     <- F.get
    updated <- A.update(old)
    changed <- A.act(updated)
    _       <- F.put(changed)
    _       <- S.sleep(10.seconds)
  } yield ()

gdzie F przechowuje stan świata w MonadState[F, WoldView]. Możemy zamknąć ten fragment w metodzie .step i powtarzać ją w nieskończoność wywołując .step[F].forever[Unit].

W tym momencie mamy do wyboru dwa podejścia i oba omówimy. Pierwszym i jednocześnie najprostszym jest skonstruowanie stosu monad kompatybilnego ze wszystkimi algebrami, a każda z nich musi definiować liftM, aby wynieść ją do większego stosu.

Kod, który chcemy napisać dla trybu jednorazowego uwierzytelnienia to:

  def auth(name: String): Task[Unit] = {
    for {
      config    <- readConfig[ServerConfig](name + ".server")
      ui        <- BlazeUserInteraction()
      auth      = new AuthModule(config)(ui)
      codetoken <- auth.authenticate
      client    <- BlazeJsonClient
      clock     = new LocalClockTask
      access    = new AccessModule(config)(client, clock)
      token     <- access.access(codetoken)
      _         <- putStrLn(s"got token: $token")
    } yield ()
  }.run

gdzie .readConfig i .putStrLn to wywołania funkcji z bibliotek. Możemy potraktować je jako interpretery oparte o Task dla algebr odczytujących konfigurację i wypisująca ciąg znaków.

Ten kod jednak się nie kompiluje z dwóch powodów. Po pierwsze, musimy zdecydować, jak będzie wyglądał nasz stos monad. Konstruktor BlazeJsonClient zwraca Task, ale JsonClientwymaga MonadError[..., JsonClient.Error], co można rozwiązać za pomocą EitherT. Możemy więc skonstruować nasz stos dla całej konstrukcji for jako

  type H[a] = EitherT[Task, JsonClient.Error, a]

Niestety, oznacza to, że musimy wywołać .liftM dla wszystkiego, co zwraca Task, dodając dość dużo boilerplate’u. Niestety metoda liftM nie przyjmuje typów o kształcie H[_] tylko H[_[_]. _], więc musimy stworzyć alias, który pomoże kompilatorowi:

  type HT[f[_], a] = EitherT[f, JsonClient.Error, a]
  type H[a]        = HT[Task, a]

Możemy teraz wywołać .liftM[HT] kiedy dostajemy Task

  for {
    config    <- readConfig[ServerConfig](name + ".server").liftM[HT]
    ui        <- BlazeUserInteraction().liftM[HT]
    auth      = new AuthModule(config)(ui)
    codetoken <- auth.authenticate.liftM[HT]
    client    <- BlazeJsonClient[H].liftM[HT]
    clock     = new LocalClockTask
    access    = new AccessModule(config)(client, clock)
    token     <- access.access(codetoken)
    _         <- putStrLn(s"got token: $token").liftM[HT]
  } yield ()

Ale nasz kod nadal się nie kompiluje. Tym razem dlatego, że clock jest typu LocalClock[Task] a AccessModule wymaga LocalClock[H]. Dodajmy więc potrzebny boilerplate .liftM do obiektu towarzyszącego LocalClock i wynieśmy całą algebrę.

  clock     = LocalClock.liftM[Task, HT](new LocalClockTask)

Wreszcie wszystko się kompiluje!

Drugie podejście do złożenia aplikacji jest bardziej złożone, ale niezbędne, gdy pojawiają się konflikty w stosie monad, tak jak w naszej głównej pętli. Jeśli przeanalizujemy wymagania, zobaczymy, że potrzebujemy poniższych instancji:

  • MonadError[F, JsonClient.Error] w JsonClient
  • MonadState[F, BearerToken] w OAuth2JsonClient
  • MonadState[F, WorldView] w głównej pętli

Niestety, dwa wymagania na MonadState są ze sobą sprzeczne. Moglibyśmy skonstruować typ danych, który przechowuje cały stan aplikacji, ale byłaby to cieknąca abstrakcja. Zamiast tego zagnieździmy konstrukcję for i dostarczymy stan tam, gdzie jest potrzebny.

Musimy teraz przemyśleć trzy warstwy, które nazwiemy F, G i H.

  type HT[f[_], a] = EitherT[f, JsonClient.Error, a]
  type GT[f[_], a] = StateT[f, BearerToken, a]
  type FT[f[_], a] = StateT[f, WorldView, a]
  
  type H[a]        = HT[Task, a]
  type G[a]        = GT[H, a]
  type F[a]        = FT[G, a]

Złe wieści: liftM obsługuje tylko jedną warstwę na raz. Jeśli mamy Task[A], a chcemy uzyskać F[A] to musimy przejść przez wszystkie kroki i wywołać ta.liftM[HT].liftM[GT].liftM[FT]. Podobnie, gdy wynosimy algebry, musimy zawołać liftM wielokrotnie. Aby uzyskać Sleep[F], musimy napisać

  val S: Sleep[F] = {
    import Sleep.liftM
    liftM(liftM(liftM(new SleepTask)))
  }

a żeby dostać LocalClock[G] robimy dwa wyniesienia

  val T: LocalClock[G] = {
    import LocalClock.liftM
    liftM(liftM(new LocalClockTask))
  }

Główna aplikacja wygląda więc tak:

  def agents(bearer: BearerToken): Task[Unit] = {
    ...
    for {
      config <- readConfig[AppConfig]
      blaze  <- BlazeJsonClient[G]
      _ <- {
        val bearerClient = new BearerJsonClientModule(bearer)(blaze)
        val drone        = new DroneModule(bearerClient)
        val refresh      = new RefreshModule(config.machines.server)(blaze, T)
        val oauthClient =
          new OAuth2JsonClientModule(config.machines.token)(blaze, T, refresh)
        val machines = new GoogleMachinesModule(oauthClient)
        val agents   = new DynAgentsModule(drone, machines)
        for {
          start <- agents.initial
          _ <- {
            val fagents = DynAgents.liftM[G, FT](agents)
            step(fagents, S).forever[Unit]
          }.run(start)
        } yield ()
      }.eval(bearer).run
    } yield ()
  }

gdzie zewnętrzna pętla używa Task, środkowa G a wewnętrzna F.

Wywołania .run(start) oraz .eval(bearer) dostarczają inicjalny stan dla części bazujących na StateT. .run z kolei pokazuje błędy zgromadzone w EitherT.

Na koniec wołamy te dwie aplikacji z naszej instancji SafeApp

  object Main extends SafeApp {
    def run(args: List[String]): IO[Void, ExitStatus] = {
      if (args.contains("--machines")) auth("machines")
      else agents(BearerToken("<invalid>", Epoch(0)))
    }.attempt[Void].map {
      case \/-(_)   => ExitStatus.ExitNow(0)
      case -\/(err) => ExitStatus.ExitNow(1)
    }
  }

i uruchamiamy ją!

  > runMain fommil.dda.Main --machines
  [info] Running (fork) fommil.dda.Main --machines
  ...
  [info] Service bound to address /127.0.0.1:46687
  ...
  [info] Created new window in existing browser session.
  ...
  [info] Headers(Host: localhost:46687, Connection: keep-alive, User-Agent: Mozilla/5.0 ...)
  ...
  [info] POST https://www.googleapis.com/oauth2/v4/token
  ...
  [info] got token: "<elided>"

Hurra!

9.3 Blaze

Server i klienta HTTP zaimplementujemy z użyciem zewnętrznej biblioteki http4s. Interpretery dla odpowiednich algebr dostaną w związku z tym prefiks Blaze, gdyż tak też nazywa się właściwy komponent tej biblioteki.

Dodajmy więc poniższe zależności:

  val http4sVersion = "0.18.16"
  libraryDependencies ++= Seq(
    "org.http4s"            %% "http4s-dsl"          % http4sVersion,
    "org.http4s"            %% "http4s-blaze-server" % http4sVersion,
    "org.http4s"            %% "http4s-blaze-client" % http4sVersion
  )

9.3.1 BlazeJsonClient

Będziemy potrzebować też kilku dodatkowych importów.

  import org.http4s
  import org.http4s.{ EntityEncoder, MediaType }
  import org.http4s.headers.`Content-Type`
  import org.http4s.client.Client
  import org.http4s.client.blaze.{ BlazeClientConfig, Http1Client }

Moduł Client może być podsumowany jako:

  final class Client[F[_]](
    val shutdown: F[Unit]
  )(implicit F: MonadError[F, Throwable]) {
    def fetch[A](req: Request[F])(f: Response[F] => F[A]): F[A] = ...
    ...
  }

gdzie Request i Response to typy danych:

  final case class Request[F[_]](
    method: Method
    uri: Uri,
    headers: Headers,
    body: EntityBody[F]
  ) {
    def withBody[A](a: A)
                   (implicit F: Monad[F], A: EntityEncoder[F, A]): F[Request[F]] = ...
    ...
  }
  
  final case class Response[F[_]](
    status: Status,
    headers: Headers,
    body: EntityBody[F]
  )

składające się z

  final case class Headers(headers: List[Header])
  final case class Header(name: String, value: String)
  
  final case class Uri( ... )
  object Uri {
    // not total, only use if `s` is guaranteed to be a URL
    def unsafeFromString(s: String): Uri = ...
    ...
  }
  
  final case class Status(code: Int) {
    def isSuccess: Boolean = ...
    ...
  }
  
  type EntityBody[F[_]] = fs2.Stream[F, Byte]

EntityBody jest aliasem na typ Stream z biblioteki fs2. Możemy rozumieć go jako leniwy strumień danych wykonujący efekty, bazujący na wyciąganiu danych (pull-based). Zaimplementowany jest jako monada Free z dodatkowym łapaniem wyjątków i obsługą przerwań. Stream przyjmuje dwa parametry typu: typ efektów i typ zawartości. Dodatkowo posiada wewnątrz wydajną reprezentację pozwalającą na łączenie danych (batching), więc przykładowo, używając Stream[F, Byte] tak naprawdę mamy do czynienia z opakowaną tablicą Array[Byte], która przybywa do nas za pośrednictwem sieci.

Musimy przekonwertować nasze reprezentacje nagłówków i URLi na wersje wymagane przez http4s:

  def convert(headers: IList[(String, String)]): http4s.Headers =
    http4s.Headers(
      headers.foldRight(List[http4s.Header]()) {
        case ((key, value), acc) => http4s.Header(key, value) :: acc
      }
    )
  
  def convert(uri: String Refined Url): http4s.Uri =
    http4s.Uri.unsafeFromString(uri.value) // we already validated our String

Obie nasze metody .get i .post muszą przekonwertować instancję Response pochodząca z http4s na typ A. Możemy wydzielić tę logikę do pojedynczej funkcji .handler

  import JsonClient.Error
  
  final class BlazeJsonClient[F[_]] private (H: Client[Task])(
    implicit
    F: MonadError[F, Error],
    I: MonadIO[F, Throwable]
  ) extends JsonClient[F] {
    ...
    def handler[A: JsDecoder](resp: http4s.Response[Task]): Task[Error \/ A] = {
      if (!resp.status.isSuccess)
        Task.now(JsonClient.ServerError(resp.status.code).left)
      else
        for {
          text <- resp.body.through(fs2.text.utf8Decode).compile.foldMonoid
          res = JsParser(text)
            .flatMap(_.as[A])
            .leftMap(JsonClient.DecodingError(_))
        } yield res
    }
  }

through(fs2.text.utf8Decode) pozwala przekonwertować Stream[Task, Byte] na Stream[Task, String]. compile.foldMonoid interpretuje strumień z użyciem naszego Taska i łączy wyniki przy pomocy Monoid[String], zwracając Task[String].

Następnie parsujemy string do JSONa, a JsDecoder[A]dostarcza potrzebny rezultat.

Oto nasza implementacja .get:

  def get[A: JsDecoder](
    uri: String Refined Url,
    headers: IList[(String, String)]
  ): F[A] =
    I.liftIO(
        H.fetch(
          http4s.Request[Task](
            uri = convert(uri),
            headers = convert(headers)
          )
        )(handler[A])
      )
      .emap(identity)

Trzeba przyznać, że jest to w 100% łączenie istniejących kawałków. Konwertujemy nasze typy wejściowe do http4s.Request, wołamy .fetch na kliencie przekazując nasz handler, w odpowiedzi dostajemy Task[Error \/ A]. Musimy jednak zwrócić F[A], więc używamy MonadIO.liftIO do stworzenia F[Error \/ ], na którym z kolei wywołujemy emap, umieszczając błąd wewnątrz F.

Niestety, próba skompilowania tego kodu zakończy się porażką, a błąd będzie wyglądał mniej więcej tak:

  [error] BlazeJsonClient.scala:95:64: could not find implicit value for parameter
  [error]  F: cats.effect.Sync[scalaz.ioeffect.Task]

Coś na temat zaginionego kota?

Dzieje się tak, gdyż http4s używa innej biblioteki wspomagającej FP niż Scalaz. Na szczęście scalaz-ioeffect dostarcza warstwę dodającą kompatybilność z tą biblioteką, a projekt shims definiuje niezauważalne (zazwyczaj) niejawne konwersje. Tak więc możemy sprawić, że nasz kod zacznie się kompilować dodając zależności

  libraryDependencies ++= Seq(
    "com.codecommit" %% "shims"                % "1.4.0",
    "org.scalaz"     %% "scalaz-ioeffect-cats" % "2.10.1"
  )

i importy

  import shims._
  import scalaz.ioeffect.catz._

Implementacja .post jest podobna, ale musimy jeszcze dostarczyć instancję

  EntityEncoder[Task, String Refined UrlEncoded]

Na szczęście typeklasa EntityEncoder pozwala nam łatwo ją wyderywować z istniejącego enkodera dla typu String

  implicit val encoder: EntityEncoder[Task, String Refined UrlEncoded] =
    EntityEncoder[Task, String]
      .contramap[String Refined UrlEncoded](_.value)
      .withContentType(
        `Content-Type`(MediaType.`application/x-www-form-urlencoded`)
      )

Jedyną różnicą między .get i .post jest sposób, w jaki konstruujemy http4s.Request

  http4s.Request[Task](
    method = http4s.Method.POST,
    uri = convert(uri),
    headers = convert(headers)
  )
  .withBody(payload.toUrlEncoded)

Ostatnim fragmentem układanki jest konstruktor, w którym wywołujemy Http1Client przekazując obiekt konfiguracyjny

  object BlazeJsonClient {
    def apply[F[_]](
      implicit
      F: MonadError[F, JsonClient.Error],
      I: MonadIO[F, Throwable]
    ): Task[JsonClient[F]] =
      Http1Client(BlazeClientConfig.defaultConfig).map(new BlazeJsonClient(_))
  }

9.3.2 BlazeUserInteraction

Musimy jeszcze uruchomić serwer HTTP, co jest dużo łatwiejsze, niż może się wydawać. Po pierwsze, importy

  import org.http4s._
  import org.http4s.dsl._
  import org.http4s.server.Server
  import org.http4s.server.blaze._

Następnie utwórzmy dsl dla naszego typu efektów i zaimportujmy zawartość:

  private val dsl = new Http4sDsl[Task] {}
  import dsl._

Teraz możemy używać dsla http4s do obsługi żądań HTTP. Zamiast opisywać wszystko co jest możliwe, zaimplementujemy po prostu pojedynczy endpoint, co przypomina każdy inny dsl do opisu HTTP.

  private object Code extends QueryParamDecoderMatcher[String]("code")
  private val service: HttpService[Task] = HttpService[Task] {
    case GET -> Root :? Code(code) => ...
  }

Każde dopasowanie musi zwrócić Task[Response[Task]]. W naszym przypadku chcemy wziąć code i ukończyć obietnicę ptoken:

  final class BlazeUserInteraction private (
    pserver: Promise[Throwable, Server[Task]],
    ptoken: Promise[Throwable, String]
  ) extends UserInteraction[Task] {
    ...
    private val service: HttpService[Task] = HttpService[Task] {
      case GET -> Root :? Code(code) =>
        ptoken.complete(code) >> Ok(
          "That seems to have worked, go back to the console."
        )
    }
    ...
  }

ale zdefiniowanie logiki nie wystarczy, musimy jeszcze uruchomić nasz serwer, co też zrobimy używając BlazeBuilder.

  private val launch: Task[Server[Task]] =
    BlazeBuilder[Task].bindHttp(0, "localhost").mountService(service, "/").start

Przypisanie do portu 0 sprawia, że system operacyjny użyje tymczasowego portu, który możemy odczytać z pola server.address.

Nasza implementacja .start i .stop jest więc bardzo prosta

  def start: Task[String Refined Url] =
    for {
      server  <- launch
      updated <- pserver.complete(server)
      _ <- if (updated) Task.unit
           else server.shutdown *> fail("server was already running")
    } yield mkUrl(server)
  
  def stop: Task[CodeToken] =
    for {
      server <- pserver.get
      token  <- ptoken.get
      _      <- IO.sleep(1.second) *> server.shutdown
    } yield CodeToken(token, mkUrl(server))
  
  private def mkUrl(s: Server[Task]): String Refined Url = {
    val port = s.address.getPort
    Refined.unsafeApply(s"http://localhost:${port}/")
  }
  private def fail[A](s: String): String =
    Task.fail(new IOException(s) with NoStackTrace)

Uśpienie wątku na 1.second jest niezbędne, aby uniknąć wyłączenia serwera, zanim odpowiedź trafi z powrotem do przeglądarki. Z wydajnością współbieżności IO nie ma żartów!

W końcu, aby utworzyć BlazeUserInteraction potrzebujemy jedynie dwóch niezainicjalizowanych obietnic

  object BlazeUserInteraction {
    def apply(): Task[BlazeUserInteraction] = {
      for {
        p1 <- Promise.make[Void, Server[Task]].widenError[Throwable]
        p2 <- Promise.make[Void, String].widenError[Throwable]
      } yield new BlazeUserInteraction(p1, p2)
    }
  }

Mogliśmy użyć IO[Void, ?], ale skoro reszta naszej aplikacji używa Task (czyli IO[Throwable, ?]), wywołujemy .widenError, aby nie wprowadzać zbędnego boilerplate’u.

9.4 Podziękowania

I to tyle! Gratulujemy dotarcia do końca podróży.

Jeśli w trakcie jej trwania nauczyłeś się czegoś, to proszę, powiedz o tym swoim znajomym. Ta książka nie ma działu marketingu, więc jest to jedyny sposób, w jaki potencjalni czytelnicy mogą się o niej dowiedzieć.

Aby zaangażować się w rozwój Scalaz, wystarczy dołączyć do pokoju na gitterze. Stamtąd możesz zadawać pytania, pomagać innym (teraz jesteś ekspertem!) i wspierać tworzenie kolejnych wersji biblioteki.

Skrót Typeklas

Typeklasa Metoda Z Mając Do
InvariantFunctor xmap F[A] A => B, B => A F[B]
Contravariant contramap F[A] B => A F[B]
Functor map F[A] A => B F[B]
Apply ap / <*> F[A] F[A => B] F[B]
  apply2 F[A], F[B] (A, B) => C F[C]
Alt altly2 F[A], F[B] (A \/ B) => C F[C]
Divide divide2 F[A], F[B] C => (A, B) F[C]
Decidable choose2 F[A], F[B] C => (A \/ B) F[C]
Bind bind / >>= F[A] A => F[B] F[B]
  join F[F[A]]   F[A]
Cobind cobind F[A] F[A] => B F[B]
  cojoin F[A]   F[F[A]]
Applicative point A   F[A]
Divisible conquer     F[A]
Comonad copoint F[A]   A
Semigroup append A, A   A
Plus plus / <+> F[A], F[A]   F[A]
MonadPlus withFilter F[A] A => Boolean F[A]
Align align F[A], F[B]   F[A \&/ B]
  merge F[A], F[A]   F[A]
Zip zip F[A], F[B]   F[(A, B)]
Unzip unzip F[(A, B)]   (F[A], F[B])
Cozip cozip F[A \/ B]   F[A] \/ F[B]
Foldable foldMap F[A] A => B B
  foldMapM F[A] A => G[B] G[B]
Traverse traverse F[A] A => G[B] G[F[B]]
  sequence F[G[A]]   G[F[A]]
Equal equal / === A, A   Boolean
Show shows A   String
Bifunctor bimap F[A, B] A => C, B => D F[C, D]
  leftMap F[A, B] A => C F[C, B]
  rightMap F[A, B] B => C F[A, C]
Bifoldable bifoldMap F[A, B] A => C, B => C C
(z MonadPlus) separate F[G[A, B]]   (F[A], F[B])
Bitraverse bitraverse F[A, B] A => G[C], B => G[D] G[F[C, D]]
  bisequence F[G[A], G[B]]   G[F[A, B]]

Haskell

Dokumentacja Scalaz często odwołuje się do bibliotek lub artykułów używających Haskella jako języka programowania. W tym krótkim rozdziale poznamy jego podstawy, tak, aby móc zrozumieć wspomniane materiały oraz móc uczestniczyć w Haskellowych prezentacjach na konferencjach o programowaniu funkcyjnym.

Dane

Haskell ma bardzo schludną składnię dla ADT. Oto tradycyjna lista:

  data List a = Nil | Cons a (List a)

List jest konstruktorem typu, a to parametr typu, a | rozdziela konstruktory danych, które w tym wypadku to: Nil, czyli pusta lista oraz Cons, czyli komórka listy. Cons przyjmuje dwa parametry, które są rozdzielone białym znakiem. Nie ma tu przecinków ani nawiasów.

W Haskellu nie ma też podtypowania, więc nie ma czegoś takiego jak typ Nil lub Cons, oba tworzą typ List.

Przybliżone tłumaczenie na Scalę:

  sealed abstract class List[A]
  object Nil {
    def apply[A]: List[A] = ...
    def unapply[A](as: List[A]): Option[Unit] = ...
  }
  object Cons {
    def apply[A](head: A, tail: List[A]): List[A] = ...
    def unapply[A](as: List[A]): Option[(A, List[A])] = ...
  }

A więc, konstruktor typu to odpowiednik sealed abstract class, a każdy z konstruktorów danych to para .apply/.unapply. Warto zauważyć, że przy takim kodowaniu Scala nie jest w stanie sprawdzić, czy pattern matching jest wyczerpujący i dlatego też nie jest ono używane w Scalaz.

Możemy też użyć infiksu, tworząc ładniejszą definicję z :. zamiast Cons

  data List t = Nil | t :. List t
  infixr 5 :.

Specyfikujemy tutaj fixity, które może przyjąć jedną z wartości infix, infixl lub infixr, które oznaczają odpowiednio brak łączności, łączność lewostronną i prawostronną. Liczby od 0 do 9 określają priorytet operacji. Możemy teraz stworzyć listę liczb całkowitych za pomocą

  1 :. 2 :. Nil

Haskell posiada już definicję listy i jest ona na tyle ważna dla programowania funkcyjnego, że zasłużyła na osobną składnię zdefiniowaną na poziomie języka: [a].

  data [] a = [] | a : [a]
  infixr 5 :

oraz wygodny wielowartościowy konstruktor [1, 2, 3] zamiast 1 : 2 : 3 : [].

Koniec końców nasze ADT musi przechowywać wartości prymitywne. Najpopularniejsze z nich to:

  • Char - znak unikodu
  • Text - blok tekstu opartego o unikod
  • Int - zależna od sprzętu liczba całkowita ze znakiem o stałej precyzji
  • Word - liczba całkowita bez znaku z wariantami o stałym rozmiarze Word8 / Word16 / Word32 / Word64
  • Float / Double - liczby zmiennoprzecinkowe o pojedynczej i podwójnej precyzji, zgodne z IEEE
  • Integer / Natural - liczba całkowita o arbitralnej precyzji. Ze znakiem oraz dodatnia.
  • (,) - tuple o rozmiarach od 0 do 62
  • IO - inspiracja dla IO w Scalaz, implementowana na poziomie środowiska uruchomieniowego

z honorowym miejscem dla

  data Bool       = True | False
  data Maybe a    = Nothing | Just a
  data Either a b = Left a  | Right b
  data Ordering   = LT | EQ | GT

Haskell, podobnie jak Scala, pozwala na definiowanie aliasów typów. Alias i jego rozwinięta forma mogą być używane zamiennie. Z powodów historycznych String zdefiniowany jest alias na listę Charów:

  type String = [Char]

co jest reprezentacją bardzo niewydajną i dlatego właśnie powinniśmy używać typu Text.

Możemy też definiować nazwy pól w ADT, używając składni rekordów, co oznacza umieszczenie konstruktorów danych w klamrach i dodanie do pól anotacji typów za podwójnym dwukropkiem, aby określić ich typ.

  -- raw ADT
  data Resource = Human Int String
  data Company  = Company String [Resource]
  
  -- with record syntax
  data Resource = Human
                  { serial    :: Int
                  , humanName :: String
                  }
  data Company  = Company
                  { companyName :: String
                  , employees   :: [Resource]
                  }

Zauważ, że konstruktor danych Human i typ Resource nie muszą mieć tej samej nazwy. Rekordy generują dla nas metody pozwalające na dostęp do pól i łatwe kopiowanie danych.

  -- construct
  adam = Human 0 Adam
  -- field access
  serial adam
  -- copy
  eve = adam { humanName = "Eve" }

Bardziej wydajną alternatywą dla pojedynczych definicji danych jest użycie słowa kluczowego newtype, które nie niesie ze sobą żadnego narzutu wydajnościowego:

  newtype Alpha = Alpha { underlying :: Double }

Jest to odpowiednik extends AnyVal, tyle że bez żadnych haczyków.

Funkcje

Mimo że nie jest to konieczne, to dobrą praktyką jest opatrywanie funkcji sygnaturami typu, co wyrażane jest jako nazwa funkcji, za którą podąża jej typ. Dla przykładu, oto foldl wyspecjalizowany dla listy:

  foldl :: (b -> a -> b) -> b -> [a] -> b

Wszystkie funkcje w Haskellu są domyślnie rozwinięte (curried), parametry rozdzielone są `->, a ostatni typ to typ zwracany przez funkcję. Powyższy przykład to odpowiednik Scalowej sygnatury

  def foldLeft[A, B](f: (B, A) => B)(b: B)(as: List[A]): B

Tyle że:

  • bez słów kluczowych
  • bez deklaracji typów, które wyprowadzamy
  • bez nazywania parametrów

Wszystko to sprawia, że kod jest bardziej zwięzły.

Funkcje infiksowe definiowane są w nawiasach i wymagają określenia fixity:

  (++) :: [a] -> [a] -> [a]
  infixr 5 ++

Zwykłe funkcje mogą być wołane w pozycji infiksowej poprzez otoczenie nazwy apostrofami, a infiksowe możemy wywoływać tak jak normalne, jeśli pozostawimy nawiasy wokół nazwy. Poniższe wywołania są więc równoznaczne:

  a `foo` b
  foo a b

Funkcja infiksowa może być rozwinięta z lewej bądź prawej strony, często zmieniając tym samym swoją semantykę:

  invert = (1.0 /)
  half   = (/ 2.0)

Funkcje zazwyczaj pisane są tak, aby najbardziej ogólny parametr był na początku, co pozwala zmaksymalizować reużywalność domyślnej rozwiniętej formy.

Definicje funkcji mogą używać dopasowywania wzorców z jedną linią na wariant. W tym miejscu możemy nazwać nasze parametry, używając konstruktorów danych do ich wyekstrahowania, podobnie jak w Scalowej klauzuli case:

  fmap :: (a -> b) -> Maybe a -> Maybe b
  fmap f (Just a) = Just (f a)
  fmap _ Nothing  = Nothing

Podkreślenie służy do ignorowania nieistotnych parametrów. Nazwy funkcji mogą występować w pozycji infiksowej również w definicjach:

  (<+>) :: Maybe a -> Maybe a -> Maybe a
  Just a <+> _      = Just a
  Empty  <+> Just a = Just a
  Empty  <+> Empty  = Empty

Funkcje anonimowe (lambdy) definiujemy z pomocą odwrotnego ukośnika, co przypomina grecką literę λ. Poniższe definicje są równoznaczne:

  (*)
  (\a1 -> \a2 -> a1 * a2)
  (\a1 a2     -> a1 * a2)

Funkcje używające dopasowań w Haskellu to tak naprawdę jedynie syntax sugar ponad zagnieżdżonymi lambdami. Rozważmy prostą funkcję, która tworzy tuple z 3 argumentów:

  tuple :: a -> b -> c -> (a, b, c)

Jej implementacja

  tuple a b c = (a, b, c)

jest rozwijana przez kompilator do

  tuple = \a -> \b -> \c -> (a, b, c)

W ciele funkcji możemy tworzyć lokalne przypisania za pomocą klauzul let i where. Poniższe definicje map dla listy są równoznaczne (apostrof jest poprawnym identyfikatorem):

  map :: (a -> b) -> [a] -> [b]
  
  -- explicit
  map f as = foldr map' [] as
             where map' a bs = f a : bs
  
  -- terser, making use of currying
  map f    = foldr map' []
             where map' a = (f a :)
  
  -- let binding
  map f    = let map' a = (f a :)
             in foldr map' []
  
  -- actual implementation
  map _ []       = []
  map f (x : xs) = f x : map f xs

if / then / else to słowa kluczowe do definiowania wyrażeń warunkowych:

  filter :: (a -> Bool) -> [a] -> [a]
  filter _ [] = []
  filter f (head : tail) = if f head
                           then head : filter f tail
                           else filter f tail

Alternatywnie możemy użyć ograniczeń wariantów (case guards)

  filter f (head : tail) | f head    = head : filter f tail
                         | otherwise = filter f tail

Dopasowania dla dowolnego wyrażenia definiujemy używając case ... of

  unfoldr :: (a -> Maybe (b, a)) -> a -> [b]
  unfoldr f b = case f b of
                  Just (b', a') -> b' : unfoldr f a'
                  Nothing       -> []

Ograniczenia mogą być używane wewnątrz dopasowań. Możemy, na przykład, chcieć potraktować zera w specjalny sposób:

  unfoldrInt :: (a -> Maybe (Int, a)) -> a -> [Int]
  unfoldrInt f b = case f b of
                     Just (i, a') | i == 0    -> unfoldrInt f a'
                                  | otherwise -> i : unfoldrInt f a'
                     Nothing                  -> []

Na koniec dwie funkcje warte wspomnienia: ($) i (.).

  -- application operator
  ($) :: (a -> b) -> a -> b
  infixr 0
  
  -- function composition
  (.) :: (b -> c) -> (a -> b) -> a -> c
  infixr 9

Obie są stylistycznymi alternatywami dla zagnieżdżonych nawiasów, a poniższe wywołania są równoznaczne:

  Just (f a)
  Just $ f a

tak jak i

  putStrLn (show (1 + 1))
  putStrLn $ show $ 1 + 1

Składanie funkcji za pomocą . jest przez wielu preferowane ponad wielokrotne użycie $

  (putStrLn . show) $ 1 + 1

Typeklasy

Aby zdefiniować typeklasę, używamy słowa kluczowego class, za którym podąża jej nazwa oraz parametry typu, a wymagane metody trafiają do klauzuli where. Jeśli między typeklasami istnieje zależność, jak np. w przypadku Applicative i Functor, to może być ona wyrażona za pomocą notacji =>.

  class Functor f where
    (<$>) :: (a -> b) -> f a -> f b
    infixl 4 <$>
  
  class Functor f => Applicative f where
    pure  :: a -> f a
    (<*>) :: f (a -> b) -> f a -> f b
    infixl 4 <*>
  
  class Applicative f => Monad f where
    (=<<) :: (a -> f b) -> f a -> f b
    infixr 1 =<<

Implementację danej typeklasy możemy dostarczyć za pomocą słowa kluczowego instance. Jeśli chcielibyśmy powtórzyć sygnatury metod, co dodaje nieco czytelności, musimy włączyć rozszerzenie języka InstanceSigs.

  {-# LANGUAGE InstanceSigs #-}
  
  data List a = Nil | a :. List a
  
  -- defined elsewhere
  (++) :: List a -> List a -> List a
  map :: (a -> b) -> List a -> List b
  flatMap :: (a -> List b) -> List a -> List b
  foldLeft :: (b -> a -> b) -> b -> List a -> b
  
  instance Functor List where
    (<$>) :: (a -> b) -> List a -> List b
    f <$> as = map f as
  
  instance Applicative List where
    pure a = a :. Nil
  
    Nil <*> _  = Nil
    fs  <*> as = foldLeft (++) Nil $ (<$> as) <$> fs
  
  instance Monad List where
    f =<< list = flatMap f list

Gdy chcemy skorzystać z typeklasy w funkcji, deklarujemy to za pomocą =>. Możemy na przykład zdefiniować odpowiednik Apply.apply2 ze Scalaz.

  apply2 :: Applicative f => (a -> b -> c) -> f a -> f b -> f c
  apply2 f fa fb = f <$> fa <*> fb

Skoro wprowadziliśmy już typeklasę Monad, to jest to dobry moment na omówienie notacji do, która była inspiracją dla Scalowej konstrukcji for:

  do
    a <- f
    b <- g
    c <- h
    pure (a, b, c)

Powyższy kod rozwijany jest do

  f >>= \a ->
    g >>= \b ->
      h >>= \c ->
        pure (a, b, c)

gdzie >>= to =<< z parametrami zamienionymi miejscami

  (>>=) :: Monad f => f a -> (a -> f b) -> f b
  (>>=) = flip (=<<)
  infixl 1 >>=
  
  -- from the stdlib
  flip :: (a -> b -> c) -> b -> a -> c

a return to synonim do pure.

W przeciwieństwie do Scali, nie musimy przypisywać pustych wartości ani używać yield, gdy zwracamy ().

  for {
    _ <- putStr("hello")
    _ <- putStr(" world")
  } yield ()

jest odpowiednikiem

  do putStr "hello"
     putStr " world"

Niemonadyczne wartości mogą być przypisywane z użyciem słowa kluczowego let:

  nameReturn :: IO String
  nameReturn = do putStr "What is your first name? "
                  first <- getLine
                  putStr "And your last name? "
                  last  <- getLine
                  let full = first ++ " " ++ last
                  putStrLn ("Pleased to meet you, " ++ full ++ "!")
                  pure full

Na koniec, Haskell pozwala na derywację typeklas za pomocą słowa kluczowego deriving, a mechanizm ten był inspiracją dla poznanego przez nas @scalaz.deriving. Definiowanie zasad derywacji to dość zaawansowany temat, ale sama derywacja dla ADT jest bardzo prosta:

  data List a = Nil | a :. List a
                deriving (Eq, Ord)

Algebry

W Scali zarówno typeklasy, jak i algebry definiowane są za pomocą traitów. Typeklasy wstrzykiwane są jako parametry niejawne a algebry przekazywane explicite. W Haskellu nie ma wsparcia dla algebr na poziomie języka i wyrażane są jako zwyczajne dane!

Rozważmy prostą algebrę Console ze wstępu. Możemy wyrazić ją jako rekord funkcji:

  data Console m = Console
                    { println :: Text -> m ()
                    , readln  :: m Text
                    }

z logiką biznesową używającą Monady:

  echo :: (Monad m) => Console m -> m ()
  echo c = do line <- readln c
              println c line

Produkcyjna implementacja Console prawdopodobnie miałaby typ Console IO. Funkcja liftIO ze Scalaz jest inspirowana haskellową funkcją o tej samej nazwie, która potrafi wynieść Console IO do stosu Zaawansowanych Monad.

Dwa dodatkowe rozszerzenia języka sprawiają że logika biznesowania stanie się jeszcze prostsza.

Pierwsze z nich, RecordWildCards, pozwala na zaimportowanie wszystkich pól z rekordu za pomocą {..}:

  echo :: (Monad m) => Console m -> m ()
  echo Console{..} = do line <- readln
                        println line

Drugie, NamedFieldPuns, wymaga wskazania wszystkich pól explicite, co zwiększa ilość boilerplate’u, ale sprawia, że kod jest łatwiejszy do przeczytania:

  echo :: (Monad m) => Console m -> m ()
  echo Console{readln, println} = do line <- readln
                                     println line

W Scali takie wyrażenie mogłoby zostać nazwane Finally Tagless, ale w Haskellu znane jest jako styl MTL. Bez wchodzenia w detale, przyjmijmy, że niektórzy deweloperzy Scali nie zrozumieli artykułu opisującego zalety wydajnościowe Uogólnionych ADT w Haskellu.

Alternatywą do stylu MTL są Rozszerzalne Efekty, znane również jako styl Monady Free.

Moduły

Kod źródłowy napisany w Haskellu układa się w hierarchiczne moduły, kod każdego z nich musi być zawarty z jednym pliku, a jego pierwsza linia określa jego nazwę:

  module Silly.Tree where

Kod organizowany jest według konwencji za pomocą katalogów, tak więc plik ten znajdzie się w Silly/Tree.hs.

Domyślnie wszystkie symbole w pliku są eksportowane, ale możemy kontrolować to zachowanie. Dla przykładu wyeksportujmy typ Tree wraz z konstruktorami danych i funkcje fringe, a ominiemy funkcję sapling:

  module Silly.Tree (Tree(..), fringe) where
  
  data Tree a = Leaf a | Branch (Tree a) (Tree a)
  
  fringe :: Tree a -> [a]
  fringe (Leaf x)            = [x]
  fringe (Branch left right) = fringe left ++ fringe right
  
  sapling :: Tree String
  sapling = Leaf ""

Co ciekawe, możemy eksportować symbole, które są zaimportowane z zewnątrz. Pozwala to autorom bibliotek spakować całe API do jednego modułu, niezależnie od tego, jak zostało zaimplementowane.

W innym pliku możemy zaimportować wcześniej zdefiniowane Silly.Tree.

  import Silly.Tree

Co jest równoznaczne ze Scalowym import silly.tree._. Jeśli chcielibyśmy ograniczyć symbole, które są importowane, to wystarczy wymienić je w nawiasach zaraz za nazwą importowanego modułu:

  import Silly.Tree (Tree, fringe)

Tutaj importujemy jedynie kontruktor typu Tree (bez konstruktorów danych) i funkcję fringe. Jeśli chcielibyśmy zaimportować wszystkie konstruktory danych, możemy użyć Tree(..). Jeśli potrzebujemy jedynie Branch, to wystarczy to zadeklarować:

  import Silly.Tree (Tree(Branch), fringe)

Jeśli okaże się, że nazwy importowanych symboli kolidują ze sobą, to możemy rozwiązać ten problem używając importu kwalifikowanego (qualified) z opcjonalną listą importowanych symboli.

  import qualified Silly.Tree (fringe)

Teraz by wywołać fringe, musimy posłużyć się identyfikatorem Silly.Tree.fringe zamiast zwykłego fringe. Podczas importowania możemy też zmienić nazwę modułu:

  import qualified Silly.Tree as T

Tym samym fringe jest teraz dostępne jakoT.fringe.

Alternatywnie, zamiast deklarować importowane symbole, możemy wybrać to, czego nie chcemy importować.

  import Silly.Tree hiding (fringe)

Domyślnie moduł Prelude jest niejawnie importowany, ale jeśli zaimportujemy go wprost, to tylko nasza wersja będzie użyta. Możemy użyć tego triku, aby ukryć niebezpieczne funkcje:

  import Prelude hiding ((!!), head)

Możemy też całkowicie się go pozbyć za pomocą rozszerzenia języka NoImplicitPrelude.

Ewaluacja

Haskell kompiluje się do kodu natywnego, nie ma więc maszyny wirtualnej, ale nadal jest garbage collector. Podstawową właściwością Haskellowego środowiska uruchomieniowego, jest to, że wszystkie parametry są domyślnie leniwie ewaluowane. Wyrażenia traktowane są jako “thunki”, czyli obietnice dostarczenia wartości, gdy będzie ona potrzebna. Thunki są redukowane tylko, gdy jest to absolutnie niezbędne do kontynuowania obliczeń.

Dużą zaletą leniwej ewaluacji jest to, że zdecydowanie trudniej jest przepełnić stos! Wadą jest nieuchronny narzut wydajnościowy, dlatego też Haskell pozwala nam przełączyć się na ścisłą ewaluację dla wybranych przez nas parametrów.

Nie jest też takie oczywiste, co tak naprawdę oznacza ścisła ewaluacja. Określa się, że wyrażenie jest w słabej czołowej postaci normalnej (WHNF, weak head normal form), jeśli najbardziej zewnętrzne bloki nie mogą być bardziej zredukowane, oraz w postaci normalnej, jeśli wyrażenie jest w pełni wyewaluowane. Domyślna strategia ewaluacji w Scali odpowiada właśnie postaci normalnej.

Dla przykładu, te wyrażenia są w postaci normalnej:

  42
  (2, "foo")
  \x -> x + 1

Natomiast poniższe nie są (mogą być dalej redukowane):

  1 + 2            -- reduces to 3
  (\x -> x + 1) 2  -- reduces to 3
  "foo" ++ "bar"   -- reduces to "foobar"
  (1 + 1, "foo")   -- reduces to (2, "foo")

Następujące wyrażenia są w WHNF, ponieważ zewnętrzny kod nie może być zredukowany (mimo że części wewnętrzne mogą):

  (1 + 1, "foo")
  \x -> 2 + 2
  'f' : ("oo" ++ "bar")

A te wyrażenia już w WHNF nie są:

  1 + 1              -- reduces to 2
  (\x y -> x + y) 2  -- reduces to \y -> 2 + y
  "foo" ++ "bar"     -- reduces to "foobar"

Domyślną strategią ewaluacji jest niewykonywanie żadnych redukcji, gdy wyrażenie przekazywane jest jako parametr. Wsparcie na poziomie języka pozwala nam wymusić WHNF dla dowolnego wyrażenia za pomocą ($!)

  -- evaluates `a` to WHNF, then calls the function with that value
  ($!) :: (a -> b) -> a -> b
  infixr 0

Możemy też użyć wykrzyknika na parametrach konstruktorów danych:

  data StrictList t = StrictNil | !t :. !(StrictList t)
  
  data Employee = Employee
                    { name :: !Text
                    , age :: !Int
                    }

Rozszerzenie języka StrictData sprawia, że wszystkie parametry danych w danym module są ściśle ewaluowane.

Kolejne rozszerzenie, BangPattern, pozwala na używanie ! na argumentach funkcji. Z kolei rozszerzenie Strict zamienia wszystkie argumenty funkcji na ściśle ewaluowane.

W ekstremalnym przypadku możemy użyć ($!!) i typeklasy NFData do wymuszenia ewaluacji do postaci normalnej:

  class NFData a where
    rnf :: a -> ()
  
  ($!!) :: (NFData a) => (a -> b) -> a -> b

jeśli tylko istnieje instancja tej typeklasy.

Kosztem ścisłej ewaluacji jest to, że Haskell zaczyna zachowywać się podobnie jak inne ścisłe języki i może wykonywać niepotrzebną pracę. Tym samym przełączanie się w ten tryb musi być wykonane z wielką uwagą i tylko gdy mamy do czynienia z mierzalnym wzrostem wydajności. Jeśli masz wątpliwości, to lepiej zostać przy domyślnej leniwej ewaluacji.

Kolejne kroki

Haskell jest językiem często szybszym, bezpieczniejszym i prostszym niż Scala, który używany jest również w biznesie. Rozważ kurs programowania funkcyjnego od data61, a ewentualne pytania możesz zawsze zadać w pokoju #qfpl na freenode.net.

O to parę dodatkowych materiałów, które mogą być pomocne w nauce:

Jeśli podoba Ci się Haskell i doceniasz wartość, jaką może przynieść twojej firmie, to powiedz to swoim przełożonym! W ten sposób ci nieliczni managerowie, którzy zdecydują się na ten krok, mogą przyciągnąć utalentowanych programistów funkcyjnych z miejsc, które nie były dość odważne, a wszyscy będą szczęśliwi.

Licencje

Niektóre części kodu źródłowego w tej książce zostały skopiowane z wolnego (free / libre) oprogramowania. Licencje tych projektów wymagają, aby poniższe licencje były rozprowadzane wraz ze wspomnianym kodem.

Licencja Scali

  Copyright (c) 2002-2017 EPFL
  Copyright (c) 2011-2017 Lightbend, Inc.
  
  All rights reserved.
  
  Redistribution and use in source and binary forms, with or without modification,
  are permitted provided that the following conditions are met:
  
    * Redistributions of source code must retain the above copyright notice,
      this list of conditions and the following disclaimer.
    * Redistributions in binary form must reproduce the above copyright notice,
      this list of conditions and the following disclaimer in the documentation
      and/or other materials provided with the distribution.
    * Neither the name of the EPFL nor the names of its contributors
      may be used to endorse or promote products derived from this software
      without specific prior written permission.
  
  THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
  "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
  LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
  A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
  CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
  EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
  PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
  PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
  LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
  NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
  SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

Licencja Scalaz

  Copyright (c) 2009-2014 Tony Morris, Runar Bjarnason, Tom Adams,
                          Kristian Domagala, Brad Clow, Ricky Clarkson,
                          Paul Chiusano, Trygve Laugstøl, Nick Partridge,
                          Jason Zaugg
  All rights reserved.
  
  Redistribution and use in source and binary forms, with or without
  modification, are permitted provided that the following conditions
  are met:
  
  1. Redistributions of source code must retain the above copyright
     notice, this list of conditions and the following disclaimer.
  2. Redistributions in binary form must reproduce the above copyright
     notice, this list of conditions and the following disclaimer in the
     documentation and/or other materials provided with the distribution.
  3. Neither the name of the copyright holder nor the names of
     its contributors may be used to endorse or promote products derived from
     this software without specific prior written permission.
  
  THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR
  IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
  OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
  IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT,
  INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
  NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
  DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
  THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
  THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

Notatki

1the most principled

2The Red Book

3Copyleft notice.

4Libre

5Jest to tłumaczenie angielskiego abstract over, które w języku polskim nie ma dobrego odpowiednika. Zwrot ten oznacza zrobienie czegoś bez brania pod uwagę szczegółów, np. “abstrahować nad mutowalnymi kolekcjami” oznacza używać ich ogólnej abstrakcji (interfejsu) zamiast konkretnych implementacji.

6Wielokrotnie w tej książce pojawią się spolszczone wyrażenia angielskie w miejscach w których nie ma dla nich dobrego polskiego odpowiednika. Uznaliśmy że dużo lepiej użyć wyrażenia które być może brzmi dziwnie, ale pozwala w łatwy sposób zrozumieć niesione znaczenie, niż wymyślać nową nazwę, której znaczenia trzeba się domyślać.

7Pure Functional Programming

8kawałek kodu. Jedno z wielu wyrażeń bez odpowiednika w języku polskim.

9Z angielskiego short-circuits - zakończenie przetwarzania bez wykonywania pozostałych instrukcji.

10Object Oriented Programming

11Be conservative in what you do, be liberal in what you accept from others

12Chodzi tutaj o wartości na poziomie typów (type level). Dla przykładu: produktem na poziomie wartości (value level), jest nowa wartość złożona z wielu wartości, np. (1,2,3). Produktem na poziomie typów jest nowy typ złożony z wielu typów (czyli wartości na poziomie typów), np. (Int, String, Int). Może to wydawać się zawiłe, ale nie ma potrzeby się tym przejmować. Ważne jest, aby zrozumieć, że mamy 2 poziomy, na których możemy definiować byty: poziom typów i poziom wartości, i że w tym wypadku mówimy o wartościach na poziomie typów.

13Algebraic Data Type

14Exhaustivity

15A więc String |: Int |: Double rozumiany jest jako String |: (Int |: Double), a nie (String |: Int) |: Double.

16Refined Data Types

17Property based testing.

18Ten potworek to spolszczona wersja słowa typeclass. Tłumaczenie tego terminu jako “klasa typu” jest rozwlekłe i odbiegające dość daleko od wersji angielskiej. Zdecydowaliśmy się więc pozostać przy wersji oryginalnej, dostosowując jedynie pisownie.

19Implicit resolution

20Appendable Things

21Myśliwiec TIE

22Mistrz Yoda do Luke’a Skywalkera.

23Biblioteki takie jak Monix, cats-effect i Scalaz nieustannie prześcigają się w optymalizacjach mających na celu zwiększenie wydajności, stąd ciężko jest określić kto jest aktualnym liderem.

24Liczba argumentów, arity