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:
- 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.
- Abstrahowanie ponad typeklasami z użyciem isntiejących typeklas ze Scalaz. To podejście wykorzystywane jest przez
bibliotekę
scalaz-deriving, która potrafi wygenerować zautomatyzowane testy oraz derywacje dla produktów i koproduktów. - Makra, z tym, że napisanie makra dla każdej typeklasy wymaga doświadczonego dewelopera. Na szczęście Magnolia Jona Prettiego pozwala zastąpić ręcznie pisane makra prostym API, centralizując skomplikowane interakcje z kompilatorem.
- Pisanie generycznych programów używając biblioteki Shapeless. Różne elementy opatrzone słowem
kluczowym
implicittworzą 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:
- wspierają jedynie produkty o 4 polach i koprodukty o 4 pozycjach.
- wprowadzają dużo boilerplate’u w obiektach towarzyszących.
W tym rozdziale rozwiążemy oba te problemy z użyciem dodatkowych typeklas ze scalaz-deriving
W praktyce, cztery główne typeklasy Applicative, Divisble, Alt i Decidable zostały rozszerzone do
arbitralnej arności używając biblioteki iotaz, stąd też sufiks z.
Biblioteka ta definiuje trzy główne typy:
-
TList, który opisuje ciąg typów dowolnej długości -
Prod[A <: TList]dla produktów -
Cop[A <: TList]dla koproduktów
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:
- wykonanie porównania referencji
.eqprzed zaaplikowaniemEqual.equal, pozwalając na szybsze określenie równości dla tych samych wartości. - szybkie wyjście z
Foldable.allkiedy którekolwiek z porównań zwrócifalse, tzn. jeśli pierwsze pola się nie zgadzają to nie będziemy nawet wymagać instancjiEqualdla 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:
- Czy powinniśmy załączać pola o wartości
null? - Czy dekodując powinniśmy traktować brakujące pola i pola o wartości
nullinaczej? - Jak zakodować nazwę koproduktu?
- 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:
- makro wykonywane jest przy każdym użyciu, a więc na przykład za każdym razem, gdy wywołamy
.toJson. Spowalnia to kompilacje oraz prowadzi do stworzenia większej ilości obiektów, co może spowodować spadek wydajności w czasie wykonania. - wyderywowane mogą zostać rzeczy zupełnie niespodziewane.
Punkt pierwszy jest raczej oczywisty, ale nieprzewidziane derywacje manifestują się w formie subtelnych błędów. Pomyślmy co się wydarzy dla
@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:
- nazw pól i klas
- anotacji odzwierciedlających preferencje użytkownika
- domyślnych wartości pól
Zacznijmy od wersji obsługującej jedynie nasze domyślne zachowania.
Aby uzyskać nazwy pól, użyjemy LabelledGeneric zamiast Generic, definiując typ
pierwszego elementu posłużymy się FieldType[K, H] zamiast prostym H, a wartość
typu Witness.Aux[K] dostarczy nam nazwę pola w czasie wykonania.
Wszystkie nasze metody będą zwracać JsObject, więc zamiast uogólniać te wartości do JsValue
możemy stworzyć wyspecjalizowaną typeklasę DerivedJsEncoder o sygnaturze innej niż
ta w JsEncoder.
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
ADTdoJsValue - pomyślne dekodowanie tego samego
JsValuez powrotem do ADT - dekodowanie
JsValuez 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.