Pull to refresh

Автоматическая генерация type classes в Scala 3

Reading time10 min
Views2.4K

В Scala широко используется подход к наделению классов дополнительной функциональностью, называемый type classes. Для тех, кто никогда не сталкивался с этим подходом рекомендую почитать вот эту статью. Этот подход позволяет держать код каких-то аспектов функционирования класса отдельно от самой реализации класса. И создавать его даже не имея доступа к коду самого класса. В частности, такой подход оправдан и рекомендуем при наделении классов возможностью сериализации/десериализации в определенный формат. Например библиотека работы с Json из фреймворка Play использует type classes для задания правил представления объектов в json формате.

Если type class предназначен для использования в большом количестве разнообразных классов (как например при сериализации/десериализации), то писать код type class для каждого класса с которым он должен работать нерационально и трудозатратно. Во многих случаях можно сгенерировать реализацию type class автоматически зная набор атрибутов класса для которого он предназначается. К сожалению в текущей версии scala автоматическая генерация type class затруднена. Она требует либо самостоятельного написания макросов, либо использования сторонних фреймворков для генерации type class таких как shapeless или magnolia, которые также основаны на макросах.

В Scala 3, которая стремительно движется к релизу появилась встроенная в язык возможность автоматической генерации type class. В этой статье делается попытка разобраться с использованием этого механизма на примере конкретного type class.

Объявление type class

В качестве примера будет использоваться достаточно искусственный type class который мы назовем Inverter. Он будет содержать один метод:

trait Inverter[T] {

  def invert(value: T): T

}

Предполагается что он будет принимать значение и каким либо образом "инвертировать" его. Для строк под инвертированием мы будем понимать изменение порядка символов на противоположный, для чисел - изменение знака числа, а для логического типа - применение логического NOT. Для всех структурных типов инвертированием будет инвертирование значений всех их атрибутов. Такой type class имеет мало практического смысла, но для нас он хорош тем, что позволяет продемонстрировать как обработку входящего значения базового класса так и выдачу результата в виде значения базового класса.

Итак первое что нужно сделать - это определить type class для элементарных типов. Делается это объявлением given значений (аналог implicit из Scala 2) с реализацией Inverter в объекте компаньоне Inverter:

object Inverter {

  given Inverter[String] = new Inverter[String] {
    override def invert(str: String): String =
      str.reverse
  }

  given Inverter[Int] = new Inverter[Int] {
    override def invert(value: Int): Int =
      -value
  }
  
  given Inverter[Boolean] = new Inverter[Boolean] {
    override def invert(value: Boolean): Boolean =
      !value
  }
  
}

Теперь займемся автоматической генерацией Inverter для сложных типов. Для того чтобы автоматическая генерация была возможна необходимо объявить в объекте компаньоне метод derived[T] возвращающий Inverter[T]. Реализация этого метода может быть любой. Например можно генерировать type class с помощью макроса или при помощи высокоуровневой библиотеки генерации (например shapeless 3). Нас же будет интересовать генерация через встроенный низкоуровневый механизм. Для его работы метод derived должен получить контекстный параметр типа Mirror.Of[T]. Этот параметр позволит нам получить информацию о структуре нашего типа. Параметр Mirror.Of[T] генерируется компилятором автоматически для следующих типов:

  • case классы и case объекты

  • перечисления (enum и enum cases)

  • sealed trait-ы единственными наследниками которых являются case классы и case объекты.

Собственно этот список это и есть тот список классов для которых может автоматически генерироваться type class при использовании описываемого механизма.

Сразу нужно отметить что это не runtime механизм получения информации о типе во время исполнения, а compile time механизм использующий новый фичи Scala 3 по кодогенерации (это объясняет, в частности, то, что большинство методов генерации должны объявляться как inline).

Приведем реализацию метода derived для нашего случая. В первом варианте реализации мы будем реализовывать только генерацию для case классов и объектов (а также кортежей).

  inline def derived[T](using m: Mirror.Of[T]): Inverter[T] = {
    val elemInstances = summonAll[m.MirroredElemTypes]
    inline m match {
      case p: Mirror.ProductOf[T] =>
        productInverter[T](p, elemInstances)
      case s: Mirror.SumOf[T] => ???
    }
  }

  inline def summonAll[T <: Tuple]: List[Inverter[_]] =
    inline erasedValue[T] match
      case _: EmptyTuple => List()
      case _: (t *: ts) => summonInline[Inverter[t]] :: summonAll[ts]

Разберемся что здесь происходит. Для всех структурных типов Miror.Of[T] позволяет определить типы элементов класса через MirroredElemTypes. Для случая case классов и кортежей это просто типы всех полей. Поскольку для инвертирования нашего типа нам надо инвертировать все его поля, то нам необходимо получить экземпляры Inverter для всех типов полей нашего класса. Это делается через метод summonAll. Реализация summonAll использует новый механизм поиска given значений summonInline. Мы сейчас не будет останавливаться на тонкостях этой реализации, так как для наших целей реализация метода summonAll будет всегда одинаковой независимо от того какой type class мы генерируем.

После получения списка Inverter для всех элементов класса мы определяем чем является наш класс - произведением других классов (case классы, case объекты, кортежи) или суммой (sealed trait или enum). Поскольку сейчас нас интересуют только случай произведения, то для этого случая вызывается метод productInverter, который создает имплементацию Inverter на основе Inverter для всех элементов класса:

def productInverter[T](p: Mirror.ProductOf[T], elems: List[Inverter[_]]): Inverter[T] = {
    new Inverter[T] {
      def invert(value: T): T = {
        val oldValues = value.asInstanceOf[Product].productIterator
        val newValues = oldValues.zip(elems)
          .map { case (value, inverter) =>
            inverter.asInstanceOf[Inverter[Any]].invert(value)
          }
          .map(_.asInstanceOf[AnyRef])
          .toArray
        p.fromProduct(Tuple.fromArray(newValues))
      }
    }
  }

Реализация этого метода делает следующее. Во-первых, получается список значений всех полей экземпляра класса. Так как мы знаем что наш класс является произведением типов то он реализует trait Product, который позволяет получить итератор всех значений полей. Во-вторых список значений полей объединяется со списком Inverter для них и для каждого поля к значению применяется свой Inverter. Наконец, в-третьих, из списка инвертированных значений собирается новый экземпляр класса. За эту сборку отвечает метод fromProduct доступный через Mirror объект.

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

Каким же образом теперь можно использовать созданный метод derived для получения type class для конечного класса. Тут есть несколько подходов. Самый простой - использовать при объявлении case класса конструкцию derives которая указывает что для этого класса необходимо сгенерировать указанный type class. Вот пример такого объявления:

case class Sample(intValue: Int, stringValue: String, boolValue: Boolean) derives Inverter

После этого экземпляр Inverter[Sample] будет сгенерирован и доступен везде где виден класс Sample. Далее мы просто можем получать его через summon и использовать:

println(summon[Inverter[Sample]].invert(Sample(1, "abc", false)))
// Результат: Sample(-1,cba,true)

Такой подход можно использовать если у нас есть полный доступ к классам для которых нам необходимы type class и мы хотим явно заявлять в них эту новую функциональность.

Однако часто мы не хотим или не можем в явном виде модифицировать класс, для которого нам нужен type class. В этом случае мы можем объявить given значение для этого класса пользуясь методом derived:

case class Sample(intValue: Int, stringValue: String, boolValue: Boolean)

@main def mainProc = {
  
  given Inverter[Sample] = Inverter.derived
  println(summon[Inverter[Sample]].invert(Sample(1, "abc", false)))
  // Результат: Sample(-1,cba,true)
  
} 

Нужно однако понимать что такая генерация type class является полуавтоматической. Для вложенных case классов type class автоматически сгенерирован не будет. Скажем для иерархии:

case class InnerSample(s: String)
case class OuterSample(inner: InnerSample)

необходимо будет последовательно сгенерировать необходимые type class:

  given Inverter[InnerSample] = Inverter.derived
  given Inverter[OuterSample] = Inverter.derived
  println(summon[Inverter[OuterSample]].invert(OuterSample(InnerSample("abc"))))
  // Результат: OuterSample(InnerSample(cba))

В большинстве случаев однако можно разрешить компилятору автоматически генерировать type class для всех типов для которых доступен Mirror.Of. Для этого просто объявляем универсальный given:

  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]
  
  println(summon[Inverter[OuterSample]].invert(OuterSample(InnerSample("abc"))))
  // Результат: OuterSample(InnerSample(cba))

По какому пути идти и насколько автоматизировать генерацию type class в каждом конкретном случае нужно решать индивидуально. Разработчикам библиотек я бы рекомендовал прятать автоматическую генерацию в отдельный trait или объект, которые можно подключить через наследование (или import соответственно) там, где это необходимо:

trait AutoInverting {
  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]
}

Кастомные type class и автоматическая генерация

Автоматическая генерация type class вполне допускает использование type class созданных вручную для тех типов где нужна специальная реализация. При этом при эти созданные вручную реализации будут использоваться в том числе и там где они входят в качестве элементов в другие классы.

Например рассмотрим следующую иерархию case классов:

case class SampleUnprotected(value: String)
case class SampleProtected(value: String)
case class Sample(prot: SampleProtected, unprot: SampleUnprotected)

Допустим для класса SampleProtected мы хотим иметь специальную реализацию Inverter, которая не инвертирует его поле value. Посмотрим как будет это сочетаться с автоматической генерацией type class для Sample:

  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]
  
  given Inverter[SampleProtected] = new Inverter[SampleProtected] {
    override def invert(prot: SampleProtected): SampleProtected = SampleProtected(prot.value)
  }
  
  println(summon[Inverter[Sample]].invert(Sample(SampleProtected("abc"), SampleUnprotected("abc"))))
  // Результат: Sample(SampleProtected(abc),SampleUnprotected(cba))

Как видим Inverter автоматически сгенерированный для класса Sample подхватил кастомную реализацию Inverter для SampleProtected. Это позволяет определять в библиотеке автоматическую генерацию и все равно оставлять пользователю возможность делать кастомные реализации там где это необходимо.

Обработка sealed trait и enum

Помимо генерации type class для case классов (и прочих произведений классов) можно генерировать type class и для sealed trait иерархий. Для этого в методе derived необходимо дописать ветку, отвечающую за сумму классов:

  inline def derived[T](using m: Mirror.Of[T]): Inverter[T] = {
    val elemInstances = summonAll[m.MirroredElemTypes]
    inline m match {
      case p: Mirror.ProductOf[T] =>
        productInverter[T](p, elemInstances, getFields[p.MirroredElemLabels])
      case s: Mirror.SumOf[T] => 
        sumInverter(s, elemInstances)
    }
  }

  def sumInverter[T](s: Mirror.SumOf[T], elems: List[Inverter[_]]): Inverter[T] = {
    new Inverter[T] {
      def invert(value: T): T = {
        val index = s.ordinal(value)
        elems(index).asInstanceOf[Inverter[Any]].invert(value).asInstanceOf[T]
      }
    }
  }

Посмотрим что здесь происходит. В случае суммы типов типы элементы в Mirror определяют типы наследников от базового типа которые и могут выступать типом нашего экземпляра. Для того чтобы определить какой именно тип элемента нужно использовать нужно воспользоваться методом ordinal из Mirror. Он возвращает индекс типа элемента который соответствует текущему значению экземпляра. Далее мы берем соответствующий Inverter (выбирая его из списка по этому индексу) и используем для инвертирования нашего экземпляра.

Посмотрим как это работает на простейших примерах. Мы не будем создавать собственную иерархию с sealed trait а воспользуемся уже готовыми классами Either и Option:

def checkInverter[T](value: T)(using inverter: Inverter[T]): Unit = {
  println(s"$value => ${inverter.invert(value)}")
}
  
@main def mainProc = {
  
  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]
  
  given Inverter[SampleProtected] = new Inverter[SampleProtected] {
    override def invert(prot: SampleProtected): SampleProtected = SampleProtected(prot.value)
  }
  
  val eitherSampleLeft: Either[SampleProtected, SampleUnprotected] = Left(SampleProtected("xyz"))
  checkInverter(eitherSampleLeft)
  // Результат: Left(SampleProtected(xyz)) => Left(SampleProtected(xyz))
  val eitherSampleRight: Either[SampleProtected, SampleUnprotected] = Right(SampleUnprotected("xyz"))
  checkInverter(eitherSampleRight)
  // Результат: Right(SampleUnprotected(xyz)) => Right(SampleUnprotected(zyx))
  val optionalValue: Option[String] = Some("123")
  checkInverter(optionalValue)
  // Результат: Some(123) => Some(321)
  val optionalValue2: Option[String] = None
  checkInverter(optionalValue2)
  // Результат: None => None
  checkInverter((6, "abc"))
  // Результат: (6,abc) => (-6,cba)
}

Здесь мы для наглядности выделили использование Inverter в отдельный метод чтобы показать автоматическую генерацию type class без явного указания summon. Как видно генерация работает правильно и для Either и для опционального типа и для кортежей (они на самом деле обрабатываются не веткой SumOf, а веткой ProductOf).

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

Вернемся к вопросу генерации type class для случая произведения классов и рассмотрим еще один аспект, который может оказаться важным для задач сериализации/десериализации. В нашем примере реализация инвертирования зависела только от типов полей класса, но не от названий этих полей. Однако во многих случаях реализация type class должна будет использовать наименования полей. Чтобы продемонстрировать как это можно делать введем в наш пример генерации Inverter еще одно требование: те поля класса наименование которых начинается на два символа подчеркивания инвертирование выполняться не должно. Попробуем реализовать это требование. Для этого нам понадобится реализовать метод получения списка названий полей и поправить реализацию derived:

  inline def derived[T](using m: Mirror.Of[T]): Inverter[T] = {
    val elemInstances = summonAll[m.MirroredElemTypes]
    inline m match {
      case p: Mirror.ProductOf[T] =>
        productInverter[T](p, elemInstances, getFields[p.MirroredElemLabels])
      case s: Mirror.SumOf[T] => 
        sumInverter(s, elemInstances)
    }
  }

  inline def getFields[Fields <: Tuple]: List[String] =
    inline erasedValue[Fields] match {
      case _: (field *: fields) => constValue[field].toString :: getFields[fields]
      case _ => List()
    }

  def productInverter[T](p: Mirror.ProductOf[T], elems: List[Inverter[_]], labels: Seq[String]): Inverter[T] = {
    new Inverter[T] {
      def invert(value: T): T = {
        val newValues = value.asInstanceOf[Product].productIterator
          .zip(elems).zip(labels)
          .map { case ((value, inverter), label) =>
            if (label.startsWith("__"))
              value
            else
              inverter.asInstanceOf[Inverter[Any]].invert(value)
          }
          .map(_.asInstanceOf[AnyRef])
          .toArray
        p.fromProduct(Tuple.fromArray(newValues))
      }
    }
  }

Проверим как работает такая реализация на следующем классе:

case class Sample(value: String, __hidden: String)

Для такого класса должно инвертироваться значение value, но не должно инвертироваться значение __hidden:

  inline given[T] (using m: Mirror.Of[T]): Inverter[T] = Inverter.derived[T]
  
  println(summon[Inverter[Sample]].invert(Sample("abc","abc")))
  // Результат: Sample(cba,abc)

Выводы

Как видим встроенная реализация генерации type class вполне пригодна к использованию, достаточно удобна и покрывает основные паттерны использования. Мне кажется, что данный механизм позволит в большинстве случаев обходится и без макросов и без сторонних библиотек по генерации type class.

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

Tags:
Hubs:
Total votes 11: ↑11 and ↓0+11
Comments0

Articles