В предыдущем посте о производных в Scala я объяснил суть идеи и показал, как мы можем извлечь из нее пользу, используя производные, предоставляемые библиотеками. В этот раз копнем глубже и реализуем нашу собственную деривацию с помощью Magnolia.

Примечание: Этот пост предназначен для пользователей Scala среднего (intermediate) уровня. Если же вы еще не знакомы с данной темой, я рекомендую начать с введения для начинающих.

Резюме

Примечание: Вы можете пропустить данный раздел, если помните предыдущий пост, это просто краткое изложение.

В предыдущем посте мы работали с классом типа Show, который выглядит следующим образом:

trait Show[A] {
  def show(a: A): String
}

Мы предоставили собственную реализацию класса типа для некоторых существующих типов и собственный кейс-класс Person.

object Show {

  given Show[String] = new Show[String] { 
    def show(a: A): String
  }

  given Show[Int] = new Show[Int] {
    def show(a: Int): String = a.toString()
  }

  extension [A: Show](a: A) {
    def show: String = Show[A].show(a) 
  }
}

val ShowPerson = new Show[Person] {
  def show(a: Person): String =
    s"Person(name = ${a.name.show}, surname = ${a.surname.show}, age = ${a.age.show})"
}

Вскоре стало очевидно, что имеется  шаблон для генерации этих реализаций show, и можно найти способ, чтобы заставить компилятор осуществить имплементацию. Мы воспользовались существующей реализацией из библиотеки kittens.

Magnolia

В этом посте мы изучим Magnolia и попробуем реализовать деривацию Show самостоятельно. Что такое Magnolia? Цитирую ее readme:

Magnolia — это универсальный макрос для автоматической материализации классов типов для типов данных, состоящих из типов продуктов (например, кейс-классов) и типов копродуктов (например, перечислений). 

Звучит сложно? С практической точки зрения это означает, что мы собираемся реализовать что-то вроде:

object givens extends AutoDerivation[Show] {

  // generate Show instance for case classes 
  override def join[T](caseClass: CaseClass[Show, T]): Show[T] = ???
    
  // generate Show instance for sealed traits
  override def split[T](sealedTrait: SealedTrait[Show, T]): Show[T] = ???

}

extends AutoDerivation[Show] означает, что Magnolia должна сгенерировать экземпляры класса типа для show, предполагая, что мы предоставили join и split.

Генерирование Show для кейс-классов

Давайте начнем с реализации join, взглянув на то, что демонстрирует интерфейс CaseClass:

abstract class CaseClass[Typeclass[_], Type](
    val typeInfo: TypeInfo,
    val isObject: Boolean,
    val isValueClass: Boolean,
    val parameters: IArray[CaseClass.Param[Typeclass, Type]],
    val annotations: IArray[Any],
    val inheritedAnnotations: IArray[Any] = IArray.empty[Any],
    val typeAnnotations: IArray[Any]
)

При генерации Show для любого кейс-класса мы хотели бы выполнить следующие шаги:

  1. Получить простое имя кейс-класса и запомнить его как name

  2. Получить список параметров и сериализовать их в список s"${parameter.name} = ${Show.show(parameter.value)}".

  3. Объединить их в строку

Чтобы получить имя нашего кейс-класса, мы можем взглянуть на поле typeInfo, его тип определяется как

case class TypeInfo(
    owner: String,
    short: String,
    typeParams: Iterable[TypeInfo]
)

Имя short — это то, что мы ищем. Давайте воспользуемся полученными сведениями и приступим к реализации join

override def join[T](caseClass: CaseClass[Show, T]): Show[T] = 
  new Show[T] {
    def show(value: T) = { 
      val name = caseClass.typeInfo.short
      val serializedParams = ???
      s"$name($serializedParams)"
    }
  }

Мы прошли половину пути, теперь давайте разберемся с serializedParam. Необходимую информацию мы получим из поля parameters. Давайте взглянем на интерфейс CaseClass.Param

trait Param[Typeclass[_], Type](
    val label: String,
    val index: Int,
    val repeated: Boolean,
    val annotations: IArray[Any],
    val typeAnnotations: IArray[Any]
)

Первую часть, имя параметра, получить очень легко, она доступна в поле label. А как насчет значения параметра? Это не свойственный параметр для поля кейс-класса, поэтому его нет в конструкторе. Интерфейс Param предоставляет метод deref:

/**
  * Get the value of this param out of the supplied instance of the case class.
  *
  * @param value an instance of the case class
  * @return the value of this parameter in the case class
  */
def deref(param: Type): PType

Похоже, мы имеем именно то, что нам нужно для получения значения параметра, при условии, что мы можем предоставить экземпляр кейс-класса в качестве param. Давайте воспользуемся этим API, чтобы заполнить пробел в serializedParams:

override def join[T](caseClass: CaseClass[Show, T]): Show[T] = 
  new Show[T] {
    def show(value: T) = { 
      val name = caseClass.typeInfo.short
      val serializedParams = caseClass.parameters.map { parameter =>
        s"${parameter.label} = ${parameter.typeclass.show(parameter.deref(value))}"
      }.mkString(", ")
      s"$name($serializedParams)"
    }
  }

Вот и все. Мы осуществили маппинг параметров в массив строк key = value с помощью 

s"${parameter.label} = ${parameter.typeclass.show(parameter.deref(value))}"

и затем объединили их вместе с помощью mkString. Это очень похоже на план из трех пунктов, описанный выше.

Со split разобрались, перейдем к join.

Генерация Show для запечатанных трейтов и перечислений

Метод, который мы собираемся реализовать, имеет следующую сигнатуру:

override def split[T](sealedTrait: SealedTrait[Show, T]): Show[T] = ???

Давайте еще раз посмотрим, что мы можем выяснить, исследуя SealedTrait

/**
 * Represents a Sealed-Trait or a Scala 3 Enum.
 *
 * In the terminology of Algebraic Data Types (ADTs), sealed-traits/enums are termed
 * 'sum types'.
 */
case class SealedTrait[Typeclass[_], Type](
    typeInfo: TypeInfo,
    subtypes: IArray[SealedTrait.Subtype[Typeclass, Type, _]],
    annotations: IArray[Any],
    typeAnnotations: IArray[Any],
    isEnum: Boolean,
    inheritedAnnotations: IArray[Any]
)

Интересный подход

Видим здесь знакомые лица, ведь мы уже использовали typeInfo. Первое, что я сделал, изучая Magnolia, это реализовал split вот так:

override def split[T](sealedTrait: SealedTrait[Show, T]): Show[T] = 
  sealedTrait.typeInfo.short

Выглядит весьма заманчиво и привлекательно. Но таким образом мы получаем только имя трейта верхнего уровня. Например, если бы у нас было перечисление типа:

enum Animal {
  case Dog
  case Cat
  case Other(kind: String)
}

и мы бы осуществили вызов нашего производного Show следующим образом:

summon[Show[Animal]].show(Animal.Dog)

то получили бы  Animal, когда ожидали Dog.

Правильный подход

Поэтому нам необходимо выяснить, с каким именно подтипом мы работаем. К счастью, наряду с полями, которые мы уже видели, SealedTrait предоставляет метод choose:

/**
  * Provides a way to recieve the type info for the explicit subtype that
  * 'value' is an instance of. So if 'Type' is a Sealed Trait or Scala 3
  * Enum like 'Suit', the 'handle' function will be supplied with the
  * type info for the specific subtype of 'value', eg 'Diamonds'.
  *
  * @param value must be instance of a subtype of typeInfo
  * @param handle function that will be passed the Subtype of 'value'
  * @tparam Return whatever type the 'handle' function wants to return
  * @return whatever the 'handle' function returned!
  */
def choose[Return](value: Type)(handle: Subtype[_] => Return): Return 

Он делает именно то, что мы ищем — получает подтип нашего ADT (абстрактный тип данных). Как должен выглядеть наш метод handle? Он должен вызывать метод show на подтипе, потому что это может быть кейс-класс, и show должен быть рекурсивным.

В псевдокоде мы ищем что-то вроде:

sealedTrait.choose(value){ subtype => 
  Show[subtype.Type].show(value)
}

Это означает, что нам нужно научиться запрашивать экземпляр класса типа для нашего подтипа. Для этого необходимо вызвать subtype.typeclass. Таким образом, наша следующая итерация будет такой

sealedTrait.choose(value){ subtype => 
  subtype.typeclass.show(value)
}

Это еще не работает, компилятор выдает ошибку:

Found:    (value : T)
Required: subtype.S & T

Так произошло потому, что класс типа для подтипа работает только для подмножества нашего исходного ADT. Поскольку данный метод вызывается только в том случае, если предоставленное нами значение соответствует данному подтипу, мы можем безопасно выполнить приведение значения. Это можно сделать с помощью subtype.cast(value)

sealedTrait.choose(value){ subtype => 
  subtype.typeclass.show(subtype.cast(value))
}

Используя полученные сведения вот как мы можем реализовать метод join, применив то, чему мы научились

override def split[T](sealedTrait: SealedTrait[Show, T]): Show[T] = 
  new Show[T] {
    def show(value: T): String = 
      sealedTrait.choose(value){ subtype => 
        subtype.typeclass.show(subtype.cast(value))
      }
    
  }

Соедините их вместе

Поскольку наши строительные блоки готовы, давайте их соединим:

import magnolia1.*

object Show {

  object givens extends AutoDerivation[Show] {

    given Show[String] = value => value
    given [A](using Numeric[A]): Show[A] = _.toString

    // generate Show instance for case classes 
    override def join[T](caseClass: CaseClass[Show, T]): Show[T] = 
      new Show[T] {
        def show(value: T) = { 
          val name = caseClass.typeInfo.short
          val serializedParams = caseClass.parameters.map { parameter =>
            s"${parameter.label} = ${parameter.typeclass.show(parameter.deref(value))}"
          }.mkString(", ")
          s"$name($serializedParams)"
        }
      }
      
    // generate Show instance for sealed traits
    override def split[T](sealedTrait: SealedTrait[Show, T]): Show[T] = 
      new Show[T] {
        def show(value: T): String = 
          sealedTrait.choose(value){ subtype => 
            subtype.typeclass.show(subtype.cast(value))
          }
        
      }

  }

}

Наряду с двумя методами, необходимыми для Magnolia, я добавил гивены (контекстные параметры) для String и Numerics в качестве основы для построения более сложных типов.

Мы можем протестировать созданный код, добавив метод main и несколько тестовых структур данных.

case class MyCaseClass(number: Long, name: String)
enum Animal {
  case Dog
  case Cat
  case Other(kind: String)
}

@main
def main() = {
  import Show.givens.given

  println(
    summon[Show[MyCaseClass]].show(MyCaseClass(number = 5, name = "test"))
  )
  println(
    summon[Show[Animal]].show(Animal.Dog)
  )
  println(
    summon[Show[Animal]].show(Animal.Other("snake"))
  )
  
}

Обратите внимание, что мы никогда не предоставляем явную имплементацию Show для наших пользовательских типов, поскольку в import Show.givens.given все уже предусмотрено.

Код примера также доступен по адресу https://github.com/majk-p/derive-show-with-magnolia.


Как скрестить http4s и ZIO? Разберемся завтра на открытом уроке в OTUS. На этой встрече мы:

  • узнаем oб основных компонентах REST-сервиса;

  • сформируем представление о http4s (http-библиотека) и ZIO (библиотека асинхронного функционального эффекта);

  • попрактикуемся в создании полноценного простого http-сервиса — сервер, эндпоинты, логика.

Урок будет полезен всем, кто уже знаком со Scala и хочет писать веб-сервисы, используя наиболее популярные решения из экосистемы функционального программирования.

Записаться на открытый урок можно на странице курса "Scala-разработчик".

Комментарии (0)