8. Derywacja Typeklas

Typeklasy pozwalają na polimorfizm w naszym kodzie, ale aby z nich skorzystać potrzebujemy ich instancji dla naszych obiektow domenowych.

Derywacja typeklas to proces tworzenia nowych instancji na podstawie instancji już istniejących, i to właśnie nim zajemiemy 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 codzień 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 machanizmu 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 ponad to 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

Dla przykładu, oto 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 W Pełni Automatyczna Derywacja

Generowanie niejawnych instancji w obiektach towarzyszących typom danych 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ć niejawna 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 odradzamy używanie w pełni automatycznej derywacji.

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 wartości niejawnych.

Nie jest to pomysł zupełnie obcy. W Scalaz staramy się ograniczyć używanie takich wartości 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 algebry (Inject).

Aby zainstalować Shapeless musimy dodać poniższy fragment 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 jest to wszystko co musimy wiedzieć, aby móc wyderywować instancję typeklasy z użyciem Shapelessa. Jednak z czasem wszystko się komplikuje, więc my również przejdziemy przez przykłady o rosnącym poziomie skomplikowania.

8.4.1 Przykład: Equal

Standardowym podejściem jest rozszerzenie typeklasy i umieszczenie jej derywacji z 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]

Jeśli ją zaimplementujemy 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]

Zaimplementujmy je

  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 z kolei, 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, a 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, a więc 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 nie uż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 działania.

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ę ze 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ą anotacje 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ń co do 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 ich koherencji.

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 żywy 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ść Czasu Uruchomienia

Kiedy mówimy o wydajności wykonania, odpowiedzią zawsze jest to zależy.

Zakładając ze 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ęczenie 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.