Промежуточное руководство по производным в Scala: Magnolia

от автора

В предыдущем посте о производных в 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-разработчик».


ссылка на оригинал статьи https://habr.com/ru/companies/otus/articles/743094/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *