Моя предыдущая статья была про неявные преобразования и 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
и работу со списком неявных параметров метода.
Beholder
Моноиды, складывание чисел… А можно приводить примеры из реальных приложений где это оказывается полезно?
Beholder
Как всегда, ответа нет, зато минусы.
Mainer5s
Вы просите выложить код реального приложения?
nehaev Автор
Примеры же просто демонстрируют новый синтаксис. Моноид, я думаю, выбрали потому, что удобно показывать разницу между методом, привязанным к типу (
unit
), и методом, привязанным к экземпляру (combine
).Если вас интересует какой-нибудь сравнительно простой пример прикладного использования тайпклассов, и при этом аллергия на теорию категорий, я бы рекомендовал посмотреть сюда. Это очень простая библиотека для чтения конфигов. В качестве упражнения, можно попробовать сделать такую же функциональность на "классическом ООП" без тайпклассов.