4. Dane i funkcjonalności

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

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

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

4.1 Dane

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

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

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

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

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

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

co zapisane w Scali wygląda tak:

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

4.1.1 Rekursywne ADT

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

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

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

4.1.2 Funkcje w ADT

ADT mogą zawierać czyste funkcje

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

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

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

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

4.1.3 Wyczerpywalność14

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

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

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

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

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

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

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

4.1.4 Alternatywne produkty i koprodukty

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

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

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

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

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

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

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

  type Accepted = String |: Long |: Boolean

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

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

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

4.1.5 Przekazywanie informacji

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

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

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

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

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

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

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

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

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

oraz poniższe importy do swojego kodu

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

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

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

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

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

Jeśli dodamy poniższy import

  import refined.auto._

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

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

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

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

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

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

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

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

4.1.6 Dzielenie się jest łatwe

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

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

4.1.7 Wyliczanie złożoności

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Możemy ją przekonwertować i przetransformować

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

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

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

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

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

  A => B => C

jest równoznaczna z

  (A, B) => C

co znane jest jako currying lub rozwijanie funkcji.

4.1.8 Preferuj koprodukty nad produkty

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

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

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

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

4.1.9 Optymalizacja

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

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

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

4.2 Funkcjonalności

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

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

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

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

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

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

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

4.2.1 Funkcje polimorficzne

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

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

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

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

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

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

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

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

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

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

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

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

4.2.2 Składnia

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4.2.3 Instancje

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

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

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

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

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

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

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

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

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

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

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

4.2.4 Niejawne rozstrzyganie19

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4.3 Modelowanie OAuth2

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

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

4.3.1 Opis

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

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

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

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

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

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

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

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

na które odpowiedzią będzie dokument JSON

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

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

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

na który odpowiedzią jest

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

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

  Authorization: Bearer BEARER_TOKEN

z podstawioną rzeczywistą wartością BEARER_TOKEN.

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

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

4.3.2 Dane

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

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

4.3.3 Funkcjonalność

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

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

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

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

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

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

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

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

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

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

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

Musimy zapewnić instancje dla typów podstawowych:

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

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

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

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

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

4.3.4 Moduł

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

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

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

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

Pozyskanie CodeToken z serwera Google OAuth2 wymaga

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

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

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

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

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

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

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

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

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

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

4.4 Podsumowanie

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