Моя предыдущая статья была про неявные преобразования и extension-методы. В этой статье обсудим новый способ объявления тайпклассов в Scala 3.


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


Но сначала разберемся, что же такое тайпкласс. Как и сама эта концепция, термин "класс типов" (от англ. type class — прим. перев.) появился в Haskell. Слово "класс" используется здесь не в том узком смысле, который принят в ООП, а в более широком — как обозначение набора сущностей, имеющих что-то общее. ( Я понимаю, что большинство людей, которые будут читать эту статью, имеют ООП-бекграунд, и для них термин "класс типов" звучит примерно как "масло масел", хотя имеется в виду "категория масел". Чтобы избежать путаницы с обычными ООП-классами, я вместо "класса типов" буду использовать просто транслитерацию "тайпкласс" — прим. перев.)


Синтаксис примеров актуален на момент Scala 3.0.0-M3.

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


// Adapted from this Dotty documentation:
// https://dotty.epfl.ch/docs/reference/contextual/type-classes.html

trait Semigroup[T]:
  extension (t: T)
    def combine(other: T): T
    def <+>(other: T): T = t.combine(other)

trait Monoid[T] extends Semigroup[T]:
  def unit: T

В математике полугруппа это абстракция сложения, как можно догадаться по введенному нами оператору <+>. Моноид — это полугруппа с нейтральным элементом, например, 0 — нейтральный элемент для операции сложения. В примере мы объявляем эти абстракции, используя трейты Semigroup и Monoid.


В Semigroup для произвольного типа T добавляются extension-методы combine и <+>, причем combine не реализован. unit в Monoid объявлен как обычный, а не как extension-метод. Это сделано потому, что значение unit для каждого отдельного типа T будет одно, и оно не зависит от того, с каким конкретным значением, имеющим тип T, мы работаем.


Пример реализации моноида для конкретных типов:


given StringMonoid: Monoid[String] with
  def unit: String = ""
  extension (s: String) def combine(other: String): String = s + other

given IntMonoid: Monoid[Int] with
  def unit: Int = 0
  extension (i: Int) def combine(other: Int): Int = i + other

Реализация выглядит достаточно прямолинейно. Стоит лишь отметить, что given foo: Bar — это новый синтаксис для implicit-значений. Если ввести код этого примера в Scala3 REPL, можно увидеть, что в реальности создаются два объекта: StringMonoid и IntMonoid.


Давайте теперь попробуем сделать с нашими моноидами что-нибудь полезное:


"2" <+> ("3" <+> "4")             // "234"
("2" <+> "3") <+> "4"             // "234"
StringMonoid.unit <+> "2"         // "2"
"2" <+> StringMonoid.unit         // "2"

2 <+> (3 <+> 4)                   // 9
(2 <+> 3) <+> 4                   // 9
IntMonoid.unit <+> 2              // 2
2 <+> IntMonoid.unit              // 2

StringMonoid и IntMonoid содержат внутри реализацию unit. Оператор <+> объявлен как extension-метод, который вызывается для конкретных экземпляров String или Int. По определению полугруппы <+> должен быть ассоциативным, что и продемонстрировано в примере.


Мы могли бы объявить реализации моноида анонимными: given Monoid[String] with .... Но тогда для доступа к методу unit нам пришлось бы вызывать summon[Monoid[String]]. Где summon — это аналог старого implicitly, глобального метода для получения ссылки на implicit-значение из контекста. Или можно использовать автоматически сгенерированное компилятором имя given_Monoid_String, хотя лучше не полагаться на то, что в будущих версиях компилятора будут придерживаться этой же конвенции именования сгенерированных объектов.


Наш пример наглядно демонстрирует, как использовать тайпклассы для реализации общих абстракций для множества целевых типов, включая даже аналог членов объекта-компаньона (в нашем случае это был метод unit). Хотя сгенерированные объекты не являются компаньонами целевых типов.


Наконец, конкретную реализацию тайпкласса можно сделать параметризованной. В примере ниже мы, чтобы не писать реализацию моноида для каждого числового типа, просто обобщаем IntMonoid для любого Numeric[T]:


given NumericMonoid[T](using num: Numeric[T]): Monoid[T] with
  def unit: T = num.zero
  extension (t: T) def combine(other: T): T = num.plus(t, other)

2.2 <+> (3.3 <+> 4.4)             // 9.9
(2.2 <+> 3.3) <+> 4.4             // 9.9

BigDecimal(3.14) <+> NumericMonoid.unit
NumericMonoid[BigDecimal].unit  <+> BigDecimal(3.14)

Обратите внимание на новое ключевое слово using, оно заменяет использовавшееся в Scala 2 implicit для объявления неявных параметров метода. Мы обсудим это подробнее в следующей статье.


Остановимся подробнее на первой строке примера. NumericMonoid — это имя реализации тайпкласса, а Monoid[T] — ее тип. Поскольку теперь у нас есть параметр T, вместо объекта компилятор сгенерирует класс. И когда мы пишем NumericMonoid[BigDecimal], будет создаваться экземпляр класса NumericMonoid для BigDecimal. num — это аргумент конструктора класса NumericMonoid, но благодаря using нам не нужно задавать его явно.


Также обратите внимание на то, как мы вызываем unit. В последней строке мы явно указываем тип-параметр, в то время как в предпоследней он выводится автоматом из типа левого операнда <+>. Вывод типов в Scala не симметричен относительно операции вызова метода obj1.method(obj2).


Что дальше?


В следующей статье мы подробнее рассмотрим новое ключевое слово using и работу со списком неявных параметров метода.