Привет, Хабр! Предлагаю вашему вниманию перевод статьи "A tale on Semirings" автора Luka Jacobowitz.
Когда-нибудь задумывались, почему сумма типов называется суммой типов. Или, может, вы всегда хотели узнать, почему оператор <*>
записывается именно так? И что это имеет общего с полукольцами? Заинтересовавшихся прошу под кат!
Данная статья является переводом поста в блоге компании Typelevel под авторством Луки Якобовича. Для наилучшего ее восприятия требуется хотя бы поверхностное знакомство с языком Scala и его экосистемой (в том числе, библиотекой cats) и знание базовых понятий абстрактной алгебры: полугруппа, моноид и т.д.
Алгебраические структуры
Многие из нас знают о моноидах и полугруппах и даже пользуются ими. Эти структуры обладают свойствами, которые позволяют применять их напрямую для получения более высокого уровня абстракции (если вы о них не знаете, можете прочитать в документации библиотеки сats). Тем не менее, иногда тип данных обладает более чем одним моноидом или полугруппой. Очевидным примером являются различные численные типы, где умножение и сложение образуют два полноценных моноида.
В абстрактной алгебре существует отдельный класс структур с двумя моноидами, взаимодействующими определенным образом. Этот класс полуколец. Поскольку моноиды часто используются для описания численных типов, их обычно разделяют на мультипликативный и аддитивный. Как и в случае с числами, законы полукольца определяют, что умножение дистрибутивно по сложению, а умножение любого числа на единицу по сложению — ноль — дает ноль.
Существуют различные способы представить это в виде классов типов и разные библиотеки делают это по-своему, но давайте рассмотрим как это сделано в проекте алгебра. Базой в ней являются AdditiveSemigroup
и MultiplicativeSemigroup
.
[Примечание: поскольку название "класс типов" не особо прижилось в русском языке, далее будет использоваться его английский вариант — type class]
import simulacrum._
@typeclass trait AdditiveSemigroup[A] {
def +(x: A)(y: A): A
}
@typeclass trait AdditiveMonoid[A] extends AdditiveSemigroup[A] {
def zero: A
}
@typeclass trait MultiplicativeSemigroup[A] {
def *(x: A)(y: A): A
}
@typeclass trait MultiplicativeMonoid[A] extends MultiplicativeSemigroup[A] {
def one: A
}
[Примечание: Аннотация @typeclass из проекта simulacrum позволяет автоматически генерировать часто-используемые методы для type class-ов и не влияет на логическую составляющую кода]
В таком случае, полукольцо (Semiring
) — это аддитивный моноид (AdditiveMonoid
), объединенный с мультипликативным моноидом (MultiplicativeMonoid
) и снабженный следующими дополнительными законами:
- Коммутативность по сложению:
- Дистрибутивность справа:
- Дистрибутивность слева:
- Наличие нуля справа:
- Наличие нуля слева:
Чтобы задать соответствующий полукольцу type class, объединим AdditiveMonoid
и MultiplicativeMonoid
:
@typeclass trait Semiring[A] extends MultiplicativeMonoid[A] with AdditiveMonoid[A]
Теперь, когда у нас есть Semiring
, мы можем использовать его с различными численными типами: Int
, Long
, BigDecimal
и т.д., но разве это стоит целой статьи о полукольцах? Оказывается, множество других вещей также являются полукольцами, включая логические значения, множества и даже анимации.
Хотелось бы отметить, что можно сформировать полукольцевой гомоморфизм из множества типов в общее число возможных представителей этих типов. Что это значит? Что ж, наберитесь терпения, а я постараюсь объяснить, что я имею в виду.
Кардинальные числа
Давайте начнем с того, что вообще подразумевается под кардинальным числом. Каждому типу соответствует число значений, которые представители этого типа могут принимать. Например, тип Boolean
обладает кардинальным числом , потому что у него всего два возможных значения: true
и false
.
Итак, у Boolean
— , а сколько у других типов? У Byte
— , у Short
— , у Int
— , а у Long
— . А что насчет строк? String
— формально неограниченный тип и теоретически обладает бесконечным числом значений (на практике, естественно, мы не обладаем бесконечной памятью, поэтому конкретное число будет зависеть от конфигурации компьютера).
Для каких еще типов мы можем определить их кардинальное число? Два довольно простых примера: Unit
, у которого ровно один представитель, и Nothing
, который является нижней границей всех возможных типов в Scala и соответственно имеет возможных значений. То есть, вы никогда не сможете создать значение Nothing
, что соответствует кардинальному числу .
Отлично, теперь можно попробовать выразить это в коде. Мы можем создать type class, способный дать нам общее число значений соответствующего типа.
trait Cardinality[A] {
def cardinality: BigInt
}
object Cardinality {
def of[A: Cardinality]: BigInt = apply[A].cardinality
def apply[A: Cardinality]: Cardinality[A] = implicitly
}
Теперь попробуем создать несколько экземпляров этого класса:
implicit def booleanCardinality = new Cardinality[Boolean] {
def cardinality: BigInt = BigInt(2)
}
implicit def longCardinality = new Cardinality[Long] {
def cardinality: BigInt = BigInt(2).pow(64)
}
implicit def intCardinality = new Cardinality[Int] {
def cardinality: BigInt = BigInt(2).pow(32)
}
implicit def shortCardinality = new Cardinality[Short] {
def cardinality: BigInt = BigInt(2).pow(16)
}
implicit def byteCardinality = new Cardinality[Byte] {
def cardinality: BigInt = BigInt(2).pow(8)
}
implicit def unitCardinality = new Cardinality[Unit] {
def cardinality: BigInt = 1
}
implicit def nothingCardinality = new Cardinality[Nothing] {
def cardinality: BigInt = 0
}
[Примечание: приведенные значения могут быть также объявлены как implicit val
]
Давайте опробуем их в REPL:
scala> Cardinality.of[Int]
res11: BigInt = 4294967296
scala> Cardinality.of[Unit]
res12: BigInt = 1
scala> Cardinality.of[Long]
res13: BigInt = 18446744073709551616
Здорово, но все это очень просто, что насчет ADT? Можем ли мы обрабатывать их подобным образом? Оказывается, можем, нужно лишь понять, как обращаться с простейшими суммами и произведениями типов. Для начала, рассмотрим простейшее произведение типов: (Boolean, Byte)
Как много представителей у этого типа? Мы знаем, что у Boolean
их , а у Byte
— . Таким образом, получаются числа от до объединенные с true
или false
. Получается уникальных значений.
выглядит как , умноженное на , так что, возможно, нужно просто умножить число значений первого типа на число значений второго? Если вы проверите это с остальными примерами, то убедитесь, что это действительно так. Давайте представим это в виде экземпляра type class’а:
implicit def tupleCardinality[A: Cardinality, B: Cardinality] =
new Cardinality[(A, B)] {
def cardinality: BigInt = Cardinality[A].cardinality * Cardinality[B].cardinality
}
Теперь рассмотрим пример суммы типов: Either[Boolean, Byte]
. В этом случае ответ еще более очевиден, поскольку значением этого типа (по сути) может быть либо Boolean
, либо Byte
, так что достаточно просто сложить кардинальные числа. Таким образом, у Either[Boolean, Byte
] должно быть представителей.
Давайте аналогичным образом выразим это в коде и подтвердим результаты в REPL:
implicit def eitherCardinality[A: Cardinality, B: Cardinality] =
new Cardinality[Either[A, B]] {
def cardinality: BigInt = Cardinality[A].cardinality + Cardinality[B].cardinality
}
scala> Cardinality.of[(Boolean, Byte)]
res14: BigInt = 512
scala> Cardinality.of[Either[Boolean, Byte]]
res15: BigInt = 258
scala> Cardinality.of[Either[Int, (Boolean, Unit)]]
res16: BigInt = 4294967298
Следовательно, для суммы типов кардинальные числа складываются, а для произведения — перемножаются. Это логично, учитывая их названия.
Так что насчет того гомоморфизма, о котором шла речь ранее? Гомоморфизм — это сохраняющее структуру отображение между двумя алгебраическими структурами одного типа (в данном случае, полукольцами).
Это означает, что для любых , и гомоморфизма мы имеем:
Последние выражения могут показаться достаточно абстрактными, но они имеют непосредственное отношение к тому, что мы только что сделали. Если мы “сложим” два типа Byte
и Boolean
, то получим Either[Byte, Boolean]
, и если мы применим к нему гомоморфизм cardinality
, то получим . Это равносильно применению cardinality
к Byte
и к Boolean
отдельно с последующим сложением результатов.
Конечно, то же самое применимо к умножению и произведению типов. Тем не менее, нам не хватает еще кое-чего для корректного полукольца, поскольку мы упомянули только сложение и умножение, но не соответствующие единичные элементы.
Как мы уже видели, у типа Unit
— один представитель, а у типа Nothing
— ни одного. Можем ли мы использовать эти два типа, чтобы образовать полукольцо?
Давайте попробуем! Если Unit
— единица по умножению, то произведение любого типа с Unit
должно быть эквивалентно первому типу. Естественно, это выполняется, поскольку мы легко можем отобразить что-нибудь из разряда (Int, Unit)
в Int
и обратно без потерь, так что и кардинальное число остается без изменения.
scala> Cardinality.of[Int]
res17: BigInt = 4294967296
scala> Cardinality.of[(Unit, Int)]
res18: BigInt = 4294967296
scala> Cardinality.of[(Unit, (Unit, Int))]
res19: BigInt = 4294967296
А что насчет Nothing
? Поскольку это единица по сложению, сумма любого типа с Nothing
должна быть эквивалентна этому же типу. Совпадает ли Either[Nothing, A]
с A
? Да! Поскольку у Nothing
нет ни одного значения, то Either[Nothing, A]
может быть только Right
и, как следствие, только A
, так что эти типы эквивалентны.
Нам также необходимо проверить корректность нуля по умножению: любое число, умноженное на единицу по сложению zero
, должно совпадать с zero
. Поскольку Nothing
является нашей единицей по сложению, такое произведение типов как (Int, Nothing)
должно быть эквивалентно Nothing
. Это выполняется, поскольку мы не можем создать значение типа Nothing
и, как следствие, содержащий такое значение кортеж.
Проверим, как это соотносится с кардинальными числами.
Единица по сложению:
scala> Cardinality.of[Either[Nothing, Boolean]]
res0: BigInt = 2
scala> Cardinality.of[Either[Nothing, (Byte, Boolean)]]
res1: BigInt = 258
Поглощение нулем:
scala> Cardinality.of[(Nothing, Boolean)]
res0: BigInt = 0
scala> Cardinality.of[(Nothing, Long)]
res1: BigInt = 0
Осталось проверить дистрибутивность. В контексте типов это означает, что (A, Either[B, C])
должно совпадать с Either[(A, B), (A, C)]
. Если проверить, то эти два типа действительно окажутся эквивалентными.
scala> Cardinality.of[(Boolean, Either[Byte, Short])]
res20: BigInt = 131584
scala> Cardinality.of[Either[(Boolean, Byte), (Boolean, Short)]]
res21: BigInt = 131584
Алгебраические структуры высшего порядка
Некоторые возможно уже слышали о type class-е Semigroupal
. Но почему он так называется и как соотносится с Semigroup
? Для начала, давайте посмотрим на сам Semigroupal
:
@typeclass trait Semigroupal[F[_]] {
def product[A, B](fa: F[A], fb: F[B]): F[(A, B)]
}
Есть определенная схожесть с Semigroup
: комбинируются два значения, причем соответствующая операция должна быть ассоциативна (аналогично Semigroup
).
Пока все в порядке, но имя функции product
немного смущает. Оно логично, поскольку мы объединяем A
и B
в кортеж, который является произведением типов, но если мы используем произведение, возможно, это не произвольный Semigroupal
, а мультипликативный? Давайте его переименуем.
@typeclass trait MultiplicativeSemigroupal[F[_]] {
def product[A, B](fa: F[A], fb: F[B]): F[(A, B)]
}
Теперь рассмотрим, как мог бы выглядеть Semigroupal
по сложению. Естественно, все, что мы должны поменять, это произведение типов на сумму:
@typeclass trait AdditiveSemigroupal[F[_]] {
def sum[A, B](fa: F[A], fb: F[B]): F[Either[A, B]]
}
Выглядит неплохо — можем ли мы добавить единицы, чтобы получить Monoidal
? Конечно можем! Это будут опять Nothing
и Unit
для суммы и произведения соответственно:
@typeclass trait AdditiveMonoidal[F[_]] extends AdditiveSemigroupal[F] {
def nothing: F[Nothing]
}
@typeclass trait MultiplicativeMonoidal[F[_]] extends MultiplicativeSemigroupal[F] {
def unit: F[Unit]
}
Теперь у нас есть эти type class-ы, но как мы можем их использовать? Что ж, с уверенностью заявляю, что они уже используются в библиотеке cats, но под другими именами.
Что вообще может быть похоже на эти классы? Для начала, взглянем на функцию sum
и попробуем найти что-то похожее на AdditiveSemigroupal
. Поскольку у аналогов этих классов для типов низшего порядка есть соответствующие символьные операторы, давайте добавим такой оператор и в AdditiveSemigroupal
.
Так как речь идет о сумме, этот оператор скорее всего должен содержать +
и показывать, что операция выполняется в каком-то контексте. Идеальным было бы использование чего-то вроде [+]
, но это некорректный идентификатор, так что попробуем <+>
вместо него.
def <+>[A, B](fa: F[A], fb: F[B]): F[Either[A, B]]
Функция <+>
уже существует в cats в качестве псевдонима для combineK
, который можно найти в SemigroupK
, но он ведет себя по-другому. Он принимает на вход два F[A]
и возвращает F[A]
— не совсем похоже на то, что мы имеем.
Или похоже? На самом деле, эти две функции совпадают и мы можем определить одну через другую при наличии функтора:
def sum[A, B](fa: F[A], fb: F[B]): F[Either[A, B]]
def combineK[A](x: F[A], y: F[A]): F[A] = {
val feaa: F[Either[A, A]] = sum(x, y)
feaa.map(_.merge)
}
Поскольку AdditiveSemigroupal
эквивалентен SemigroupK
, возможно, AdditiveMonoidal
совпадает с MonoidK
? Да, и это показать это достаточно легко. MonoidK
дополнен функцией empty
:
def empty[A]: F[A]
Эта функция использует универсальный квантор для A
, то есть, работает для любого A
, что означает, что на самом деле она не может оперировать никаким конкретным A
и тем самым эквивалентна F[Nothing]
в AdditiveMonoidal
.
Что ж, мы нашли аналоги для аддитивных классов и уже знаем, что MultiplicativeSemigroupal
эквивалентен cats.Semigroupal
. Все, что нам осталось, это найти эквивалент MultiplicativeMonoidal
.
Я немного сжульничаю и скажу, что этим эквивалентом является Applicative
. Он добавляет функцию pure
, которая принимает A
и возвращает F[A]
. MultiplicativeMonoidal
в свою очередь обладает функцией unit
, не принимающей параметров и возвращающей F[Unit]
. Как перейти от одного к другому? Ответ опять подразумевает использование функтора:
def unit: F[Unit]
def pure(a: A): F[A] = unit.map(_ => a)
Applicative
использует ковариантный функтор, но в общем случае мы можем использовать также инвариантные и контравариантные структуры. В дополнение к этому Applicative
включает в себя <*>
в качестве псевдонима для комбинации product
и map
, что выглядит как еще одна подсказка, что это мультипликативный класс и интуиция нас не подвела.
Теперь в cats у нас есть <+>
и <*>
, но существует ли type class, объединяющий их, аналогично как Semiring
объединяет +
и *
? Есть, и он называется Alternative
. Он наследуется от Applicative
и MonoidK
. Чтобы быть последовательными, мы назовем его Semiringal
:
@typeclass
trait Semiringal[F[_]] extends MultiplicativeMonoidal[F] with AdditiveMonoidal[F]
Отлично, теперь у нас есть и Semiring
, и его аналог для типов высшего порядка. К сожалению, первого в cats нет, но, возможно, он появится в будущих версиях.
Если бы он был доступен, мы могли бы вывести Semiring
для любого Alternative
аналогично выведению Monoid
для MonoidK
или Applicative
. Также, мы могли бы превратить Semiring
обратно в Alternative
, используя Const
, аналогично превращению Monoid
в Applicative
.
В завершение, посмотрим, как можно записать данное преобразование:
import Semiring.ops._
case class Const[A, B](getConst: A)
implicit def constSemiringal[A: Semiring] = new Semiringal[Const[A, ?]] {
def sum[B, C](fa: Const[A, B], fb: Const[A, C]): Const[A, Either[B, C]] =
Const(fa.getConst + fb.getConst)
def product[B, C](fa: Const[A, B], fb: Const[A, C]): Const[A, (B, C)] =
Const(fa.getConst * fb.getConst)
def unit: Const[A, Unit] =
Const(Semiring[A].one)
def nothing: Const[A, Nothing] =
Const(Semiring[A].zero)
}
Заключение
Кольца и полукольца представляют собой весьма интересные алгебраические структуры, и, даже если вы не задумывались об этом, скорее всего вы уже ими пользовались. Этот пост был написан, чтобы показать как Applicative
и MonoidK
соотносятся с Monoid
, почему алгебраические типы данных образуют полукольцо и как эти алгебраические структуры распространились в Scala и других языках программирования. Лично для меня осознание того, как все это взаимосвязано и образует очень занятную симметрию, было просто взрывом мозга, и я надеюсь, что этот пост сможет помочь найти аналогичные интересные параллели в Cats
и других библиотеках, основанных на различных математических абстракциях. Дальнейший материал по этой теме вы сможете найти здесь.
Дополнение
В этом посте я умолчал о коммутативности в записи type class-ов. Коммутативность — очень важное свойство для полуколец и код должен отражать это свойство. Тем не менее, поскольку пост уже содержит множество определений, дополнение его еще несколькими коммутативными type class-ами, которые не делают ничего, кроме внесения новых законов, выглядело излишним и отвлекающим от основной цели поста.
Более того, я фокусировался на кардинальных числах только тех классов, которые были нам нужны, но для полноты можно добавить определения Cardinality
для таких вещей как A => B
, Option[A]
, Ior[A, B]
:
Cardinality.of[A => B] === Cardinality.of[B].pow(Cardinality.of[A])
Cardinality.of[Option[A]] === Cardinality.of[A] + 1
Cardinality.of[Ior[A, B]] === Cardinality.of[A] + Cardinality.of[B] + Cardinality.of[A] * Cardinality.of[B]
Комментарии (14)
Nimtar
01.04.2019 00:36В eitherCardinality не забыли вычесть мощность пересечения множеств значений типов?
J0kerPanda Автор
01.04.2019 16:49Нет, с `eitherCardinality` всё верно. В Scala `Either[A, B]` представлен двумя объектами: `Left[A, B]`, содержащим объекты типа `A`, и `Right[A, B]`, содержащим объекты типа `B`. `Left` с `Right` никак не пересекается, поэтому получаем строгую сумму.
Говоря о сумме типов вообще, можно провести аналогию, что сумма типов — это контейнер с ячейками, каждую из которых может занимать какой-либо тип, но всего может быть занята ровно одна ячейка. Соответственно, значения суммы типов — это «контейнер с заполненной ячейкой 1», «контейнер с заполненной ячейкой 2» и т.д. Таким образом, опять получаем строгую сумму, даже если типы в ячейках совпадают.Nimtar
01.04.2019 17:30Т.е. нет "наследования" типов? (Integer)2 и (Number)2 обязательно разные вещи?
J0kerPanda Автор
01.04.2019 18:08Не понял нотацию — поясните, что вы имеете в виду.
Nimtar
02.04.2019 00:40Какое-нибудь включение множеств значений типов друг в друга. Пусть E = Either[N, Z], где Either — сумма типов N и Z, как оно применяется в статье, N — тип, элементами которого являются только все натуральные числа меньше 3 (пусть с нулём), Z — тип, элементами которого являются только все целые числа меньше 3 по модулю. Я интуитивно ожидаю кардинальное число |E| = 5.
J0kerPanda Автор
02.04.2019 10:44Я так понимаю, что вы пытаетесь рассматривать сумму типов как объединение множеств значений этих типов, но эти операции не совпадают.
В данном случае речь не идёт о каком-либо наследовании или включении одного типа в другой. Каждому из суммируемых типов можно сопоставить индекс, а значение суммы типов — это по сути "индекс" + "значение типа с соответствующим индексом в сумме". То есть для значения суммы типов важно, какой именно по порядку тип выбран.
Пример с
Either[A, B]
наиболее иллюстративен, так какLeft[Int, Int](1) != Right[Int, Int](1)
, потому что различаются "обёртки"Nimtar
02.04.2019 20:32Разумеется, пытался рассматривать как объединение. Ок, спасибо.
Как-то я не ожидал, что предполагается отказ от наследования
Nimtar
01.04.2019 00:42И в первом примере дополнения откуда степень? "A => B" — тип функции? Тогда достаточно перемножить мощности множеств значений A и B, нет?
J0kerPanda Автор
01.04.2019 17:01Да,
A => B
— тип функции. Интуитивно понятно, что, если бы мы просто перемножили кардинальные числа, то получили бы результат, аналогичный произведению типов. Но количество функций изA
вB
в общем случае с количеством пар(A, B)
не совпадает.
Откуда же берётся формула
B^A
? Рассмотрим произвольный элемент типаA
. ФункцияA => B
может переводить его в любой элемент типаB
, значит, всего|B|
вариантов. Рассмотрим следующий элемент типаA
. Для него ситуация аналогичная —|B|
вариантов. Это будет выполняться для любого представителяA
. Соответственно, чтобы получить общее количество вариантов по превращениюA
вB
, перемножим количество вариантов для отдельных представителей:|B| * |B| * |B| * |B| ...
(всего|A|
раз). Отсюда и следует|B => A| = |B|^|A|
.
Аналогичным образом мы можем подумать о функции
B => A
как о представителе произведения типов(B, B, B, ..., B)
(всего|A|
раз). Элементами этого произведения можно однозначно задать любую функцию, а как вычислять кардинальное число такого типа уже известно.
speshuric
01.04.2019 22:02А что насчет строк? String — формально неограниченный тип и теоретически обладает бесконечным числом значений (на практике, естественно, мы не обладаем бесконечной памятью, поэтому конкретное число будет зависеть от конфигурации компьютера).
String в текущих реалиях совсем не бесконечный. В JVM он точно не будет длиннее
Integer.MAX_VALUE
байт (так как содержитbyte[]
адресуемый целым числом). Причем не удивлюсь, если из-за деталей реализации окажется еще более ограниченным. Это не так много — 2 ГиБ памяти, а это уже не ограничение даже для современных телефонов. Да, конечно, кардинальное числоexp(2,(8*(exp(2,31)-1))
изрядно большое (и даже в BigInteger в Java не влезет), но всё-таки эти пределы уже вполне можно "пощупать".
speshuric
01.04.2019 23:00Еще мне из дополнения 2 момента непонятны
Первое:
Cardinality.of[Option[A]] === Cardinality.of[A] + 1
А что будет для
Option[Option[Option[A]]]
? Насколько я понимаю, новых значений там не появится — всё также будетCardinality.of[A] + 1
.
Второе — про
Cardinality.of[A => B]
.
Я правильно понимаю, что это всё только про чистые детерминированные функции (иначеUnit=>Unit
не счесть).J0kerPanda Автор
02.04.2019 10:52Для
Option
не происходит удаление вложенныхNone
, поэтому формула всё-таки остаётся верной. Вот все значения, которые может принимать такой тип:
Option[Option[A]] = None, Some(None), Some(Some(A))
. Кардинальное число:|A| + 1 + 1
.
По поводу функций — дельное замечание, мне, наверное, стоило об этом упомянуть. Речь, естественно, идёт не о функциях Scala как таковых, а о морфизмах. Потому что даже чистых детерминированных функций
Boolean => Boolean
можно набрать бесконечное число, так какTrue
можно представить какTrue && True
,True && True && True
и т.д.Cardinality.of[A => B]
— это количество возможных вариантов перевести элементыA
в элементыB
простым сопоставлением/таблицей.
Nimtar
Зачем выделять дополнительные условия 4 и 5 в определении полукольца? Они же следуют из свойств моноидов и дистрибутивности.
J0kerPanda Автор
Вы правы, однако, думаю, автор оригинального поста рассматривал классическое определение, когда эти свойства вынесены в аксиомы полукольца. В разной литературе определяют по-разному, но, видимо, такое определение показалось ему более иллюстративным.