В предыдущем посте о производных в 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
для любого кейс-класса мы хотели бы выполнить следующие шаги:
Получить простое имя кейс-класса и запомнить его как name
Получить список параметров и сериализовать их в список
s"${parameter.name} = ${Show.show(parameter.value)}
".Объединить их в строку
Чтобы получить имя нашего кейс-класса, мы можем взглянуть на поле 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-разработчик".