Часть 4. Implicit значения и преобразования. Тайпклассы. Чтение и запись JSON в play-json.

Страничка курса: https://maxcom.github.io/scala-course-2022/

План

Последняя лекция про язык Scala

  1. Implicit значения
  2. Передача контекста и конфигурации
  3. “pimp my library”
  4. Тайпклассы (примеры из Cats)
  5. Тайп классы на примере Play-JSON

"Неявные" значения и преобразования

  • Уникальная конструкция, существующая только в Scala.
  • Множество разных применений
  • В Scala 3 многие применения получили свой синтаксис

Неявные значения для передачи контекста

Дополнительный блок параметров функции


def getUser(id: Int)(implicit session: DatabaseSession): User
					

значения которого можно явно не задавать

Без implicit значения пришлось бы

  • Явно передавать значения
  • Передавать неявно через thread local и "магию" Java-фреймворков

Значение должно существовать в области видимости


DB localTx { implicit session =>
  getUser(10)
}					
					

при необходимости можно передать явно

// implicit тут не нужен, но не мешает
DB localTx { implicit session =>
  getUser(10)(session)
}					
					

Где это применяется?
Для передачи контекста

  • сессии/транзакции БД
  • конфигурации
  • свойств запроса (права, язык, таймзона и т.п.)
  • ...

for может использовать flatMap/map с
implicit параметром

implicit значения ищутся в

  • области видимости
  • import'ах - сначала в точных, потом в wildcard
  • companion object связанных типов

implicit контекст обычно в области видимости

Неоднозначности при поиске на одном уровне - ошибка компиляции

Неявные классы

Преобразование одного класса в другой


implicit class RichString(str: String)
					

автоматическое преобразование там, где необходимо

"Pimp my library" - прием из динамических языков, например Ruby

В Ruby код может "улучшить" любой класс, даже стандартную библиотеку

"Monkey patching" - весьма опасная техника

Опасности:

  • Нарушение инкапсуляции
  • Проблемы при обновлении - неявные зависимости на реализацию
  • Конфликты разных "улучшателей"
  • "Ад зависимостей" - даже минорное обновление может всё сломать
  • Сложности отладки

Implicit class - безопасная замена этой техники

Пример - добавляем метод в String


// дальше будет более эффективная реализация
// название класса значения не имеет
implicit class RichString(str: String) {
  def letters = str.count(_.isLetter)
}

"scala 2.12".letters // = 5
					
  • для использования должен быть в области видимости
  • разрешается в процессе компиляции
  • не влияет на существующий код

Более правильная версия примера:


implicit class RichString(val str: String) extends AnyVal {
  def letters = str.count(_.isLetter)
}
					

класс существует только в compile time

AnyVal можно использовать для оптимизации, но осторожно.
Есть другие варианты, например tagged types

Технику применяют для "красоты", например:


import scala.concurrent.duration._

val timeout: Duration = 5.minutes
// замена Duration(5, TimeUnit.MINUTES)
					

еще пример - DSL в scalatest/specs2

Тайпклассы

Typeclass - шаблон проектирования, построенный на использовании implicit преобразований.

Рассмотрим реализацию .sum для List[Int]:


def sum(list: List[Int]): Int = list.foldLeft(0)(_ + _)
					

Как её обобщить для нашего типа значений?


// наш собственный тип значений
case class BusValue(amount: Int)
					

Нужно два элемента:

  • Нулевой элемент
  • Функция "сложения"

Проблема с ООП:

  • нужно как-то из типа добраться до "нуля", но
    нет общего механизма доступа к данным и методам типа
  • trait для метода сложения, но trait можно добавить только к "нашим" типам.
  • и еще trait'ов может быть много

//  упрощенная реализация на основе исходников Cats
def combineAll[A](fa: Seq[A])(implicit m: Monoid[A]): A =
  fa.foldLeft(m.empty) { (acc, a) =>
    m.combine(acc, a)
  }
					

Monoid есть в Cats и др. библиотеках,
можете сделать свой


implicit val busMonoid: Monoid[BusValue] =
  new Monoid[BusValue] {
    override val empty = BusValue(0)

    override def combine(x: BusValue, y: BusValue): BusValue =
      BusValue(x.amount + y.amount)
  }
					
monoid может объявить для любого типа,
даже "чужого"
Требования к моноиду:

 // ассоциативность
combine(x, combine(y, z)) = combine(combine(x, y), z)
					

combine(x, empty) = combine(empty, x) = x
					
коммутативность не требуется!

Примеры:

  • Числа и операция сложения, empty = 0
  • Числа и операция умножения, empty = 1
  • Конкатенация строк, empty = ""
  • Коллекции, empty = пустая коллекция

Примеры:


import cats.implicits._

implicit val busMonoid: Monoid[BusValue] = ???

// pimp my library
Vector(BusValue(1), BusValue(9000)).combineAll
					

для Seq нужна новая версия Cats

NonEmptyList - не моноид, а полугруппа (Semigroup)

Обладает ассоциативным методом combine

Красивая операция "сложения" для Semigroup:

BusValue(1) |+| BusValue(9000)

// Monoid - это наследник Semigroup,
// так что работает и для моноидов
					
Более полезное применение:

val one = Map("first" -> BusValue(1), "second" -> BusValue(2))
val two = Map("second" -> BusValue(3), "third" -> BusValue(3))

one ++ two // "second" первого заменен на второй
one |+| two // "second" складывается

// Map("first" -> BusValue(1), 
//   "second" -> BusValue(5), 
//   "third" -> BusValue(3))
					

Как это работает?

Еще один механизм - неявное преобразование


// неявное преобразование моноида для значения
// в моноид для Map
implicit def monoidForMap[K, V]
              (implicit ev: Monoid[V]): Monoid[Map[K, V]] =
                 new MapMonoid[K, V]
					

Если значение не Monoid, то и Map нет.

Вывод преобразований - рекурсивный

Еще раз посмотрим на этот код


// неявное преобразование моноида для значения
// в моноид для Map
implicit def monoidForMap[K, V]
              (implicit ev: Monoid[V]): Monoid[Map[K, V]] =
                 new MapMonoid[K, V]
					

ev не используем, а только передаем


// context bounds
implicit def monoidForMap[K, V : Semigroup]: Monoid[Map[K, V]] =
  new MapMonoid[K, V]
					

Тайпклассы можно объявить для
более интересных конструкций.

6-я лекция: Cats и Circe

Конец первой части

далее работа с тайпклассами на примере play-json

Что же такое тайпклассы?

Способ реализации некой абстрактной функциональности вне данных, с которыми она связана.

JSON

Текстовый формат обмена данными.

Пришел на замену XML, который оказался слишком сложным и слишком медленным.

Использует синтаксис объявления данных JavaScript


{
   "firstName": "Иван",
   "lastName": "Иванов",
   "address": {
       "streetAddress": "Московское ш., 101, кв.101",
       "city": "Ленинград",
       "postalCode": 101101
   },
   "phoneNumbers": [
       "812 123-1234",
       "916 123-4567"
   ]
}					
					

Формально описан в rfc8259

Весь стандарт - 16 страниц текста, включая оглавления, ссылки и т.п.

Json легко читается (если отформатировать)

Библиотеки для работы с ним есть для большинства языков программирования.

Формат JSON был разработан
Дугласом Крокфордом.

Видео: The JSON Saga

"The Software shall be used for Good, not Evil."

Используем библиотеку Play JSON.

Часть play-framework; можно использовать отдельно.

Circe, другая библиотека, будет на лекции про Cats

Документация:

Подключаем библиотеку в проект


libraryDependencies += 
  "com.typesafe.play" %% "play-json" % "2.8.2"
					

JsValue - алгебраический тип

  • JsArray
  • JsBoolean (JsTrue/JsFalse)
  • JsNumber
  • JsObject
  • JsString
  • JsNull

Запись JSON


import play.api.libs.json._

val v: JsValue = JsObject(Seq(
  "id" -> JsNumber(1),
  "name" -> JsString("Vasya"),
  "marks" -> JsArray(Seq(JsNumber(2), JsNumber(3)))
))

Json.stringify(v) // компактный формат
Json.prettyPrint(v) // с форматированием			
					

pretty print используем только для отладки

Большие структуры так выводить не удобно.

Хотим выводить JSON для наших case-классов, коллекций и т.п.

Какие есть варианты?

Pattern matching?

Не походит - мало операций,
много вариаций данных.

Объектный полиморфизм? Возможно


trait Writable {
  def toJson: JsValue
}					
					
  • trait не добавить к стандартным коллекциям
  • trait не добавить к сторонним классам
  • смешивается основная логика и доп. функции

Reflection? Да, это "Java-way".

В runtime запрашиваем структуру объекта средствами JVM и выводим её.

Однако:

  • Не типобезопасно: можно вывести что-то не то, например сессию БД или внутренний пароль
  • Медленно
  • Трудности с настройкой формата вывода
  • Нельзя использовать контекст при выводе

Решение - тайпкласс, как еще один вариант полиморфизма.


// упрощенный код
trait Writes[A] {
  def writes(o: A): JsValue
}
					

использование


object Json {
  def toJson[T](o: T)(implicit tjs: Writes[T]): JsValue = 
    tjs.writes(o)

  ...
}
					

Для примитивных типов готовая реализация


Json.toJson("some string")					
					

реализация


// из play-json
implicit object StringWrites extends Writes[String] {
  def writes(o: String) = JsString(o)
}
					

Для Seq нужно преобразование

// реализация из play-json
implicit def traversableWrites[A](implicit w: Writes[A]): 
    Writes[Traversable[A]] = {

  Writes[Traversable[A]] { as =>
    val builder = mutable.ArrayBuilder.make[JsValue]()
    as.foreach { a =>
      builder += w.writes(a)
    }
    JsArray(builder.result())
  }
}

Json.toJson(Seq("some string", "second string"))
// ["some string", "second string"]
Типобезопасно - работает только если для элементов есть Writes

Выводим case-класс

import play.api.libs.json._

case class User(id: UUID, name: String, title: Option[String])

object User { // объявляем в companion object
  // OWrites вместо Writes (возвращает JsObject)
  // используем Writes.apply
  implicit val writes: OWrites[User] = OWrites { user =>
    Json.obj( // сам использует Writes для значений
      "id" -> user.id,
      "name" -> user.name,
      "title" -> user.title 
    )
  }
} 

Для простых случаев есть макрос


object User {
  implicit val writes: OWrites[User] = Json.writes[User]
}					
					

Writes может зависеть от контекста


// writes зависит от языка пользователя
implicit def writes(implicit lang: Lang): OWrites[User] = ???
					

Такой полиморфизм работает в компиляторе, типы должны быть точно известны.

Абстрактные типы записать не сможем.

Записываем выражение калькулятора
из первой лекции


// один Writes для всего ADT
object Expr {
  implicit val writes: Writes[Expr] = Writes {
    case Number(value) ⇒ 
      JsNumber(value)
    case Plus(lhs, rhs) ⇒ 
      Json.obj("op" -> "plus", "left" -> lhs, "right" -> rhs)
    case Minus(lhs, rhs) ⇒ 
      Json.obj("op" -> "minus", "left" -> lhs, "right" -> rhs)
  }
}
					
(макрос из play умеет простые ADT)

Итого мы получили:

  • Эффективный скомпилированный код
  • Типобезопасность
  • Код записи JSON отделен от данных
  • Возможности по кастомизации, использование контекста

Чтение JSON и валидация

Тайпкласс Reads для чтения


trait Reads[A] {
  def reads(json: JsValue): JsResult[A]
}
					

есть макрос Json.writes[A]

JsResult - алгебраический тип

  • JsSuccess - успех
  • JsError - ошибка; внутри список ошибок с путями

val v: JsValue = Json.arr("str1", "str2")

v.validate[Seq[String]] 
// JsSuccess(List("str1", "str2"))

v.as[Seq[String]] // значение или Exception
// List("str1", "str2")
					

Case class: плохой вариант


case class User(id: UUID, name: String, title: Option[String])

object User {
  implicit val reads: Reads[User] = Reads[User] { json ⇒
    JsSuccess(User(
      id = (json \ "id").as[UUID],
      name = (json \ "name").as[String],
      title = (json \ "title").asOpt[String]
    ))
  }
}
					

Exception вместо JsError;
диагностика только по номерам строк

JsResult - монада


implicit val reads: Reads[User] = Reads[User] { json ⇒
  for {
    id <- (json \ "id").validate[UUID]
    name <- (json \ "name").validate[String]
    title <- (json \ "title").validateOpt[String]
  } yield {
    User(id, name, title)
  }
}
					

Исключений больше нет,
но потеряли пути ошибок

Вместо работы над значениями будем конструировать функцию


val v: JsValue = ???
val r: JsLookupResult = v \ "id" 
// результат поиска

val path: JsPath = __ \ "id"
// путь, не привязанный к значению
					

Строим Reads


// метод JsPath
def read[T](implicit r: Reads[T]): Reads[T]
					

Использование


val idReads: Reads[UUID] = (__ \ "id").read[UUID]
					

при ошибке путь будет добавлен в JsError

Reads - тоже монада


// тут мы уже не парсим значение, а собираем функцию парсинга
implicit val reads: Reads[User] = 
  for {
    id <- (__ \ "id").read[UUID]
    name <- (__ \ "name").read[String]
    title <- (__ \ "title").readNullable[String]
  } yield {
    User(id, name, title)
  }
					
JsError, никаких исключений.
В ошибке закодирован путь

Что не так?

Монада - абстракция цепочки вычислений.
У нас же все вычисления независимые,
кроме шага сборки.

Еще плохо - ошибка только первая;
надо выдать все ошибки.

Монада тут - плохая абстракция.

На помощь приходит
аппликативный функтор.

О нем поговорим на лекции про Cats.

Reads имеет операцию сборки: пару Reads можно собрать в Reads пары (аналогично для троек и т.д.).

При сборке ошибки "складываются".

Синтаксис:


import play.api.libs.json._
import play.api.libs.functional.syntax._
					
val combined: Reads[(UUID, String, Option[String])] = (
  (__ \ "id").read[UUID] and
  (__ \ "name").read[String] and
  (__ \ "title").readNullable[String]).tupled

val userReads: Reads[User] = 
  combined.map((User.apply _).tupled)  
					

Более удобный синтаксис:


val userReads: Reads[User] = (
  (__ \ "id").read[UUID] and
  (__ \ "name").read[String] and
  (__ \ "title").readNullable[String])(User.apply _)
					

Итого что мы получили:

  • Удобный язык описания парсинга
  • JsError для ошибок, никаких исключений
  • Все ошибки с путями
  • Всё множество ошибок, а не только первая

Полезные инструменты:

Используем готовые Reads


Reads.of[Int].map(Number.apply)

// аналог
implicitly[Reads[Int]].map(Number.apply)
					

Валидация значений:


import play.api.libs.json.{JsonValidationError ⇒ JsErr}

Reads.of[Int].filter(JsErr("must be positive"))(_ > 0)
					

Разбор алгебраического типа


import play.api.libs.json.{JsonValidationError ⇒ JsErr}

sealed trait Numbers
case object One extends Numbers
case object Two extends Numbers
case object Three extends Numbers

val reads: Reads[Numbers] = 
  Reads.of[String].collect(JsErr("bad number")) {
    case "one"   ⇒ One
    case "two"   ⇒ Two
    case "three" ⇒ Three  
}
					

Напоминаю: