Мастера Угвэя ничуть не удивляло, что отношения между типами напоминают школьную математику.
Мастера Угвэя ничуть не удивляло, что отношения между типами напоминают школьную математику.

Содержание пятой части:

Другие части обзора

Натуральные числа

В своё время для меня стал настоящим открытием тот факт, что тип натуральных чисел также является рекурсивным:

type Nat = μ[Option]

Продемонстрировать это не сложно. Сперва введём более удобные конструкторы:

// основные
val zero:          Nat = inFix[μ, Option](None   )
def succ(n:Nat):   Nat = inFix[μ, Option](Some(n))
// вспомгательные
val one:           Nat = succ(zero)
def toNat(i: Int): Nat = (1 to i).foldRight(zero)((_, acc) => succ(acc)) // не корректно для отрицательных i, но сейчас это не важно

Затем реализуем обычную арифметику по Пеано:

extension (nat: Nat)
  def +     = foldFix[μ]((_: Option[Nat]).fold(nat )(succ   ))
  def *     = foldFix[μ]((_: Option[Nat]).fold(zero)(_ + nat))
  def **    = foldFix[μ]((_: Option[Nat]).fold(one )(_ * nat))  
  def toInt = foldFix[μ]((_: Option[Int]).fold(0   )(_ + 1  ))(nat)

Операции для Nat, обратные этим, будут частично определёнными. Попытка вывести типы, замкнутые относительно обратных операций (чтобы они стали полностью определёнными) приведёт нас к целым, рациональным и прочим числам. Но отложим эту тему на другой раз, потому что такие типы, по большей части, будут нерекурсивными (хотя и основанными на рекурсивном Nat).

А работает наша арифметика следующим образом:

val two   = succ(one)
val three = succ(two)
val six   = toNat(6)

val theAnswer = (two * three) ** two + six
theAnswer.toInt // 42

Важно обратить внимание, что конструкторы Nat реализованы через inFix, а арифметические операции и деконструкция в Int – через foldFix. Пара этих методов как раз определяет основные возможности для наименьшей неподвижной точки μ, введённой в предыдущей части статьи. Вообще, всё полезное, что может дать тип Nat, выражается через свёртку этого рекурсивного типа! Например, факториал – это свёртка Nat в такой же рекурсивный Nat:

def factorial: Nat => Nat =
  foldFix[μ][Option][(Nat, Nat)]{
    _.fold((one, one))((n, fact) => succ(n) -> fact * n)
  } andThen {_._2}

factorial(toNat(5)).toInt // 120

Натуральные числа играют огромную роль в математике в целом и в программировании в частности. Они олицетворяют идею математической индукциипошагового вывода новых конструкций из существующих. Все значения, с которыми имеют дело программисты, конструктивны. Следовательно все типы этих значений счётны – они все изоморфны натуральным числам!

«Несчётные» типы

Технически, можно сформулировать и «несчётные» типы, но создавать мы сможем только значения их счётных подтипов. Работая с термами «несчётных» типов всегда можно полагаться на то, что найдётся способ без потерь привести их к счётным подтипам.

Разложение в ряд

Ранее список был выражен через неподвижную точку конструктора типов OptCell:

\begin{align}OptCell[A][X] &\cong 1 + A \times X,\\List[A] &= \mu[OptCell[A]]\\\end{align}

При этом μ определялся в стиле передачи продолжений, чтобы скрыть «ссылку на себя». Но давайте теперь посмотрим на чуть более привычное определение списка:

List[A] = 1 + A \times List[A]\\

Это не что иное, как функциональное уравнение для «функции» List[A]. Выражение справа можно попробовать раскрыть, шаг за шагом подставляя вместо List[A] всё выражение целиком. У нас будет получаться такая бесконечная сумма:

List[A] = 1 + A + A \times A + A \times A \times A + \ldots = \sum_{n=0}^\infty A^n\\

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

List[A] = \frac1{1 - A}\\

Точно такой же результат получается, если напрямую решить исходное функциональное уравнение. Оказывается, типы можно не только складывать и перемножать но и делить и вычитать!

На самом деле это не так уж и удивительно, ведь само понятие неподвижной точки функции формулирует обратную задачу – по заданному соотношению найти такое значение, которое этому соотношению удовлетворяет. Поэтому неподвижные точки конструкторов типов и позволяют определять обратные операции для суммы, произведения и возведения в степень.

Здесь такие выражения следует трактовать скорее как производящие функции для разложения в ряд. Это достаточно распространённый приём в математике. Такие выражения можно комбинировать друг с другом и даже упрощать, получая в итоге разложения в новые ряды.

И такие выражения появляются не только при работе со списками. К примеру, обычное бинарное дерево представляется следующим функциональным уравнением:

Tree[A] = 1 + A \times Tree[A] \times Tree[A]\\

Его «решением» является такой ряд:

Tree[A] = 1 + A + 2 A^2 + 5 A^3 + 14 A^4 + \ldots\\

Коэффициенты перед A^n – это типы с соответствующим количеством значений. Они показывают, сколько существуют различных «форм» деревьев, содержащих n элементов A. Например, в случае двух элементов, один оказывается в корне (или на вершине, смотря как смотреть)), а другой может быть слева или справа – всего два варианта. В то же время разных форм деревьев из четырёх элементов наберётся целых четырнадцать.

Производящая функция этого ряда будет содержать радикал:

Tree[A] = \frac{1-\sqrt{1-4A}}{2A}\\
Второе решение

Второе решение, со знаком + перед корнем из дискриминанта оказывается непродуктивным. Разложение подразумевает «малость» A, но при A \rightarrow 0 такое решение устремляется в «бесконечность», и сложно понять, как использовать получаемый ряд: 1/A - 1 - A - 2 A^2 - 5 A^3 + \ldots

Если построить дерево с пятью или более ветвями в узле, то точное решение для соответствующего функционального уравнения не удастся получить. Тем не менее, разложение в ряд интерпретируется также просто, как и в случае бинарного дерева.

И всё же, не все полиномиальные рекурсивные типы приводят к полезным разложениям в ряд. Рассмотрим такой тип:

\begin{eqnarray}UMagma[A] &=& A + 1 + UMagma[A] \times UMagma[A]\\UMagma[A] &=& \frac12 \left(1 \pm \sqrt{-4 A - 3}\right)\\\end{eqnarray}

При попытке разложения UMagma[A] в ряд выясняется, что все его коэффициенты бесконечны! Другими словами, для любого заданного наперёд количества элементов A существует бесконечное множество различных форм дерева такой структуры. Причём, сам по себе тип корректный и не бесполезный:

final case class UMagma[A](value: A `Either` Option[(UMagma[A], UMagma[A])])

def leaf[A](a: A) = UMagma(Left(a))
def twig[A] = UMagma[A](Right(None))
def node[A](left: UMagma[A], right: UMagma[A]) = UMagma(Right(Some(left, right)))

val someTree = node( //     /\
  leaf(42),          //   42  \
  twig               //        ∅
)

val sameTree = node( //     /\
  leaf(42),          //   42  \
  node(              //       /\    // ∅ добавляются неограниченно.
    twig,            //      ∅  \   // вот откуда возникают
    twig             //          ∅  // все эти бесконечные формы 
  )                                 // для одинакового количества A
)

Неподвижные точки любых полиномиальных конструкторов типов («алгебраические» типы, состоящие из сумм произведений) представляют собой различные деревья. Вырожденными случаями таких деревьев являются и списки (дерево с единственной веткой), и даже натуральные числа:

Nat \cong List[Unit]

Функциональное уравнение для натуральных чисел выглядит просто

Nat = 1 + Nat

Тип не параметризирован, и единственное решение уравнения – бесконечность)).

Что же до неподвижных точек конструкторов типов, в которых есть экспоненциалы (типы функций), то тут картина такая. Если тип-параметр хотя бы раз находится в отрицательной позиции, то, как уже говорилось ранее (см. в конце этого раздела), такая неподвижна точка неконструктивна и с её значениями не получится работать. В прочих же случаях, когда тип-параметр является результатом какой-либо функции, например A^{Int} («степенной» тип), то тип в значении экспоненты, как говорилось ранее, считается счётным, изоморфным Nat. Это означает что все степенные типы можно представить как произведения типа-параметра с самим собой. Соответственно, неподвижные точки таких выражений можно трактовать как знакомые деревья, просто с большим количеством ветвей в узлах.

Производные от типов

Контейнерные типы F[+_] используют для обслуживания различных эффектов (см. предыдущую статью цикла). Такие типы определяют некий контекст преобразований, производящихся над «значениями внутри» контейнера. Но после всех преобразований неизбежно следует «распаковка» F[A] => B. В частности, для многих контейнеров важно извлечение именно «хранимого» значения F[A] => A. Этот вариант сейчас и рассмотрим подробнее.

Попробуем выяснить, получится ли выразить контекст «хранения» значения в контейнере F[A] в виде некого типа ∂[F][A]. Тогда результат распаковки можно было бы записать как пару (∂[F][A], A). Мы как бы «выкалываем» какое-то одно значение A из F[A].

В простых случаях это действительно возможно:

type Const[X] = SomeType
type Id   [X] = X

summon[∂[Const][X] =:= Nothing] // 0    (Nothing, A) ≅ Nothing
summon[∂[Id   ][X] =:= Unit   ] // 1    (Unit, A) ≅ A = F[A]

Для контейнера Id имеем тривиальный результат – контекст не несёт ничего, кроме самого факта хранения значения типа A. В то же время тип Const[X] не использует в теле параметр X, значит он не может хранить значение и не содержит никого контекста.

Так как извлекается единственное значение, то для суммы типов в контексте должен сохранится выбор, из какого именно слагаемого достаём значение:

infix type  +[A,    B   ] = A `Either` B
infix type ++[F[_], G[_]] = [X] =>> F[X] + G[X]

summon[∂[F ++ G][X] =:= ∂[F][X] + ∂[G][X]]

summon[∂[Id ++ (Const ++ Id)][X] =:= Unit + Unit]
//    ∂ₓ[X  + (SomeType + X)]    =:=   1  +  1   ≅ Boolean  - тип с двумя вариантами, местом вхождения X

С произведением типов ситуация похожая – в контексте также должен остаться выбор, из какого множителя достаём значение. Следовательно, контекстом произведения будет сумма типов. Причём, у каждого слагаемого, помимо контекста выбранного множителя, будет целиком и другой множитель, из которого значение не извлекали (ведь это также часть контекста выбранного варианта):

infix type  ×[A,    B   ] = (A, B)
infix type ××[F[_], G[_]] = [X] =>> F[X] × G[X]

summon[∂[F ×× G]][X] =:= ∂[F][X] × G[X] + F[X] × ∂[G][X]]

summon[∂[Id ×× (Const ++ Id) ][X] =:= SomeType + X + X]
summon[∂[Id ×× Id ×× Id ×× Id][X] =:= (X × X × X) + X × (X × X + X × (X + X))]
//     ∂[ X  ×  X  ×  X  ×  X]     ≅  4 × X × X × X

Получается, что контекст «хранения» значения X в четырёхместном кортеже (X, X, X, X) – это не только оставшиеся значения (X, X, X) но также ещё и тип 4, значение которого указывает, в какой из четырёх позиций исходного кортежа лежал наш X.

Deja vu… Но и это ещё не всё. Так же из общих соображений поиска контекста извлекаемого из контейнера значения можно также получить и «цепное правило» для вложенных контейнеров. При извлечении значения из вложенного контейнера у нас остаются контексты от обоих контейнеров:

summon[∂[F ∘ G]][X] =:= ∂[F][G[X]] × ∂[G][X]]

type Prod1[X] = X × X
type Prod2[X] = X × SomeType

summon[∂[Prod1 ∘ Prod2][X]  =:= (X × SomeType + X × SomeType) × SomeType]

Выпишем эти соотношения в виде математических формул:

\begin{eqnarray}\partial_A Const &=& 0 \hspace{2cm} \text{выражение $Const$ не содрежит типовой переменной $A$}\\\partial_A A &=& 1\\\partial_A \left(F[A] + G[A]\right) &=& \partial_A F[A] + \partial_A G[A]\\\partial_A \left(F[A] \times G[A]\right) &=& G[A] \times \partial_A F[A] + F[A] \times \partial_A G[A]\\\partial_A \left(F[G[A]]\right) &=& \partial_X F[X]_{{}_{X = G[A]}} \times \partial_A G[A]\\\end{eqnarray}

Числа (0, 1, 2 и другие) обозначают типы с соответствующим количеством «обитателей» (Nothing, Unit, Boolean и т.д.). Ещё в дальнейшем нам пригодится дополнительное правило для многоаргументного контейнера, которое можно вывести из тех же положений:

\partial_A\; F[G[A],\; H[A]] =\left.\partial_G\; F[G,\; H[A]]\right._{{}_{G=G[A]}} \times \partial_A G[A] +\left.\partial_H\; F[G[A],\; H]\right._{{}_{H=H[A]}} \times \partial_A H[A]

Все эти правила действительно напоминают обычное дифференцирование функций. По этой причине подобные контексты называют производными от типов.

Но на эту картину можно взглянуть и по-иному. Дело в том, что извлекаемое значение и контекст расположения в контейнере вместе несут всю информацию для восстановления исходного контейнерного значения. Ничего не теряется, но нужно также учесть, что для некоторых F[_] могут существовать такие значения F[A], в которых вообще нет ни одного значения A (например, для Option[A] это будет None: Option[Nothing]). С учётом вышесказанного, для любых (полиномиального) F[_] и A может быть построен следующий изоморфизм:

F[A] \cong \partial_A F[A] \times A + F[0] \tag{DiffIso} \label{DiffIso}

Извлекая каким-либо способом из F[A] значение A вместе с контекстом, всегда можно восстановить исходное значение! Так что на этот изоморфизм можно смотреть не только как на дифференцирование (получение ∂[F][A]) и интегрирование (восстановление F[A]), но и как простое правило деления с остатком.

Действительно, попробуем разделить 18 на 5. Делимое раскладывается на следующие простые множители: 18=2\times3\times3. В разложении отсутствует число 5, контекст его расположения в 18 равен 0. В свою очередь, 18 без 5 – это те же 18_{5=0}=18. Таким образом, 18 = 0 \times 5 + 18. Для демонстрации аналогии с типами мы не будем пользоваться возможностями натуральных чисел, выделять ту часть делимого, которая будет-таки делиться на 5. Если мы по тем же правилам поделим на 3, то получим контекст (2\times3\times3) / 3 = 2\times3 = 6, а остаток 18_{3=0} = 2\times0\times3 = 0, то есть 18=6\times3+0. Здесь действуют всё те же правила получения контекста, выведенные ранее для типов. Принципиальное же отличие заключается в том, что натуральные числа, внезапно, сложнее! Дело в том, что для них умножение коммутативно, поэтому нет разницы, где в выражении 3\times3 стоит нужная 3. Можно считать, что мы вроде бы и получаем контекст 2\times3, но двойка сокращается из-за неразличимости \_ \times 3 и 3 \times \_. В то время как для типов этот коэффициент не сокращается и играет важную роль. Поэтому «деление с остатком» для типов выглядит как вычисление производных.

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

Для дифференцирования потребуется несколько типов. Сперва нам нужен тип, который отметит все вхождения типа-параметра в контейнер. Этот фантомный тип Hole по-хорошему должен быть недоступен для пользователей… но сделаем его хотя бы бесполезным для них)). Нужно также семейство типов DiffHolled с четырьмя (как минимум) правилами дифференцирования. По одному из этих правил должен возвращаться нулевой тип, но, к сожалению, Nothing не подойдёт, так как существующий механизм сопоставления типов завязан на подтипизацию. Лучше заведём ещё один фантомный тип Zero. Простое применение правил дифференцирование приведёт к кучерявым структурам, со жуткими сложениями и умножениями нулевого и единичного типов. Поэтому нам не обойтись без семейства типов Simplify, упрощающего алгебраическое выражение типов (и в помощь ему ещё пара нерекурсивных семейств типов). И в самом конце нужно будет подставить тип-параметр X вместо дырок Hole – сделаем это с помощью семейства типов SubstHole.

private object zero
opaque type Zero = zero.type // Nothing не сработает ((

private object hole
opaque type Hole = hole.type // тип дырки в идеале должен быть недоступен пользователям

type SimplifiedSum[a, b] = a match
  case Zero   => b
  case _      => b match
    case Zero => a
    case _    => a + b

type SimplifiedProd[a, b] = a match
  case Zero   => Zero
  case Unit   => b
  case _      => b match
    case Zero => Zero
    case Unit => a
    case _    => a × b

type Simplify[T] = T match // упрощение алгебраического выражения
  case a + b  => SimplifiedSum [Simplify[a], Simplify[b]] // рекурсия!
  case a × b  => SimplifiedProd[Simplify[a], Simplify[b]] // рекурсия!
  case _      => T

type DiffHolled[T] = T match // основные правила дифференцирования
  case Hole   => Unit
  case a + b  => Simplify[DiffHolled[a]     +     DiffHolled[b]]  // рекурсия!
  case a × b  => Simplify[DiffHolled[a] × b + a × DiffHolled[b]]  // рекурсия!
  case _      => Zero

type SubstHole[T, X] = T match // подстановка X в «дырку»
  case Hole   => X
  case a + b  => SubstHole[a, X] + SubstHole[b, X]  // рекурсия!
  case a × b  => SubstHole[a, X] × SubstHole[b, X]  // рекурсия!
  case _      => T

type ∂[F[_]] = [X] =>> SubstHole[DiffHolled[F[Hole]], X] // собственно дифференцирование

Любопытная особенность: типы Simplify, DiffHolled, SubstHole являются рекурсивными, и их даже можно описать через неподвижные точки, вроде введённой в предыдущей части FixF (что в данном случае привело бы к переусложнению). Ведь даже «простые» типы данных представляют собой деревья алгебраических выражений, то есть фактически являются рекурсивными структурами!

Эти конструкции неплохо работают для кортежей (пар) и Either. Их вполне можно использовать для соответствующих конструкторов типов. Но универсальный тип ∂[_[_]][_] подразумевает использование его значений в универсальных методах. Ключевым свойством для этого типа является изоморфизм \eqref{DiffIso}. Для каждого контейнерного значения можно получить список распакованных значений с их контекстами, и пересобрать контейнер, подставляя в эти контексты уже, возможно, другие значения:

def decompose[F[_], X]: F[X]        => Seq[X × ∂[F][X]]
def integrate[F[_], X]: X × ∂[F][X] => F[X]

Можно придумать и более изощрённые алгоритмы, например, с динамической навигацией по контекстам в зависимости от конкретных значений в текущем контексте… Но реализации таких универсальных алгоритмов задача весьма нетривиальная, так как семейства типов, определяемые через сопоставления типов, приводят к зависимым функциям, а это будет уже совсем другая история)).

Производные от типов сами по себе являются контейнерами, и их также можно представить как «значение в контексте его расположения». Значит для исходного контейнера возможны и более сложные разложения:

F \cong \partial_X (\partial_X F) \times X \times X + \partial_X F|_{{}_{X = 0}} \times X + F|_{{}_{X = 0}}

Дальнейшие же разложения не приведут к привычному ряду Тейлора, с факторами 1/n! ввиду, опять же, того, что произведение типов не коммутирует и значение каждого множителя из X \times \ldots \times X должно попадать именно в свой контекст.

Производные от экспоненциалов

Объявленные выше правила получения контекста расположения действуют только для сумм и произведений типов. Но как быть с экспоненциалами (типами функций)? Для функции F[X] = Boolean => X контекст очевиден: \partial_X\; X^2 \cong \partial_X\; (X \times X) = 2 \times X^1. Аналогичная ситуация с перечислениями: для любого enum NCases с N вариантами всегда можно написать другой enum NMinusOneCases, у которого вариантов будет на один меньше. Для таких перечислений будет работать формула

\partial_X\; X^n = n \times X^{n-1}

Секрет заключается в возможности представления степенных типов в виде произведения. В то же время, вычитание единицы из бесконечного типа Nat (\mathbb{N}), введённого в начале этой публикации, даст в точности его же, и контекст будет таким:

\partial_X\; X^{\mathbb{N}} = \mathbb{N} \times X^{\mathbb{N}}

Вычитать единичный тип из Int, у которого более четырёх миллиардов вариантов, задача весьма неблагодарная, хотя и решаемая (например, с помощью зависимых типов библиотеки iron). Полезность дифференцирования степенных типов определяется трактовкой «вычитания единицы» из типа показателя.

А вот собственно экспоненциальные типы значительно интереснее. Казалось бы, они вообще являются контравариантными, но даже их иногда можно трактовать как контейнеры. Например, тип

type Class[X] = X => Boolean

представляет собой классификатор значений типа X – они либо удовлетворяют этому предикату, либо нет. Конструктором такого типа может выступить любое λ-выражение (или η-конверсия любого метода). И тут кроется загвоздка, так как это может быть неконструктивная частично определённая функция, которая для некоторых значений может зациклиться, или вообще прерваться с исключением, а ещё это может быть нечистая функция, зависящая от внешних состояний... С точки зрения математики, такой предикат определяет класс значений, который может быть как собственным, так и описывать некоторое множество, если он кажется тотальным (определённым для всех значений).

Добиться последнего можно, явно указав правильные конструкторы:

opaque type Set[X] <: X => Boolean = X => Boolean
object Set:
  def empty[X]    : Set[X] = _ => false
  def one[X](x: X): Set[X] = _ == x

  extension[X](set: Set[X])
    infix def ||(set2: Set[X]): Set[X] = x => set(x) || set2(x)
    infix def &&(set2: Set[X]): Set[X] = x => set(x) && set2(x)

Такие множества можно представить как контейнеры значений X – предикат будет возвращать true только для тех x: X, которые есть в это контейнере. Множество размера n – это любой из всевозможных n-кортежей. Так как для множества не важно, в каком порядке оно собиралось, все кортежи с переставленными одинаковыми элементами будут эквивалентны. «Мощность» такого множества будет равна «мощности» кортежа, делённого на количество перестановок (в множестве все элементы различны!). Совокупностю множеств всех размеров будет:

Set[X] = \sum_{n=0}^{\infty} \frac{X^n}{n!} \tag{Set} \label{Set}

Несложно проверить, что такой контейнер при дифференцировании ведёт себя как экспонента!

\partial_X Set[X] = Set[X]

Секрет превращения контравариантного типа X => Boolean в ковариантный контейнер заключается в том, что, фиксировав конструкторы, мы по сути изменили его тип на примерно такой:

type Set[X] = μ[SetBase[X]]
type SetBase[X] = [Self] =>> Unit + X + Self × Self + Self × Self
//                          empty  one       ||            &&

Наивное разложение этой неподвижной точки в ряд путём последовательных подстановок даст члены с бесконечно растущими коэффициентами. Однако законы множества (вшитые в реализацию конструкторов) устранят все бесконечности, и в итоге останется в точности ряд \eqref{Set}.

Также рассмотрим такой классификатор:

type Bag[X] = X => Nat

Его можно интерпретировать как контейнер, но который «хранит произвольное количество» одинаковых значений (собственно, количество этот классификатор и возвращает). Кстати, для Bag актуален такой же набор конструкторов, что и для Set, и представление через неподвижную точку будет в точности таким же. Однако не совсем понятно, как честно выразить Bag в виде ряда. Не видно, как правильно подсчитать эквивалентные кортежи заданного размера, ведь значения в них могут повторяться. А без этого невозможно получить контексты расположения значений в этом контейнере. Можно заключить, что существуют такие экспоненциальные типы, которые, даже если и получится использовать их как контейнеры, но не понятно как их честно определить контексты хранящихся там значений.

Производные от рекурсивных типов

Концепция «контекста расположения значения» лучше всего раскрывается в приложении к рекурсивным типам. Правил из предыдущего раздела достаточно, чтобы научиться дифференцировать неподвижные точки T[A] = \mu X. Base[A, X].

Сперва с помощью outFix развернём T[A] \rightarrow Base[A, T[A]], и раскроем производную. Полученное линейное рекурсивное соотношение для \partial_A\; T[A] выразим через неподвижную точку \mu. Получившаяся неподвижная точка очень похожа на список, только «пустой» вариант отличен от 1. Его смело можно вынести наружу как множитель (при разложении в ряд у каждого члена будет такой коэффициент), и тогда останется обычный список. В итоге получается частная производная от Base по первому аргументу и список производных по второму:

\begin{eqnarray} \tag{DiffRec}\label{DiffRec} \partial_AT[A] &=& \partial_A Base[A, T[A]] \\ &=& \left.\partial_X Base[X, T[A]]\right._{{}_{X=A}} + \left.\partial_X Base[A,X]\right._{{}_{X=T[A]}} \times \underline{\partial_A T[A]}\\ &=& \mu Y. \left( \left. \partial_X Base[X, T[A]]\right._{{}_{X=A}} + \left.\partial_X Base[A,X]\right._{{}_{X=T[A]}} \times Y \right)\\ &=& \left. \partial_X Base[X, T[A]]\right._{{}_{X=A}} \times \mu Y. \left( 1 + \left.\partial_X Base[A,X]\right._{{}_{X=T[A]}} \times Y \right)\\ &=& \left. \partial_X Base[X, T[A]]\right._{{}_{X=A}} \times  List \left[\left.\partial_X Base[A,X]\right._{{}_{X=T[A]}}\right]\\ \end{eqnarray}

К сожалению, Scala версии 3.5.2 при сопоставлении типов не поддерживает переменные высокого рода. Поэтому для дифференцирования рекурсивных контейнеров напишем ещё один тип, принимающий двухпараметрическую базу рекурсии:

type diffμ[Base[_, _]] = [A] =>>
  List[∂[[X] =>> Base[A, X]][μ[[T] =>> Base[A, T]]]] ×  
       ∂[[X] =>> Base[X, μ[[T] =>> Base[A, T]]]][A]

Разберём как это работает на примере такого тернарного дерева:

type Base3[A, T] = Unit + A × (T × T × T) // 1 + A × T × T × T  
type Tree3[A] = μ[[T] =>> Base3[A, T]]

type Tree3Context[A] = diffμ[Base3][A]  
  
type T = Tree3[Int]  
summon[Tree3Context[Int] =:= List[Int × ((T + T) × T + T × T)] × (T × T × T)]
//                           List[Int × 3 × T × T]             ×  T × T × T

Для лучшего понимания всех этих непростых выражений посмотрим на следующие картинки.

Пример конкретного дерева типа Tree3. Крестиками отмечены пустые поддеревья, кружками - значения типа A. Нас интересует значение в узле красного цвета.
Пример конкретного дерева типа Tree3. Крестиками отмечены пустые поддеревья, кружками - значения типа A. Нас интересует значение в узле красного цвета.

Здесь представлен пример конкретного тернаного дерева типа Tree3[A]. Сфокусируемся на значении A в <font color="red">красном</font> узле. Путь к нему отмечен чёрными стрелками. Пройденные мимо поддеревья окрашены в разные цвета. Оставшиеся за нашим фокусом поддеревья отмечены <font color="red">фиолетовым</font>. Представим, что мы как бы «подняли» это дерево, взявшись за корневой и фокусный узлы, «ориентировали» его вдоль той единственной ветки, что ведёт к нужному значению.

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

Чтобы не потерять информацию, нужно не забыть, какая из трёх развилок выбрана в каждом узле на пути к фокусному: левая, средняя, или правая.

Осталось только стереть все стрелки, связывающие фокусное значение с исходным деревом:

Значение Tree3 может быть декомпозировано на значение A вместе с контекстом его расположения в исходном дереве – списком игнорированных поддеревьев на пути к значению, а также поддеревьями, расположенными после A.
Значение Tree3 может быть декомпозировано на значение A вместе с контекстом его расположения в исходном дереве – списком игнорированных поддеревьев на пути к значению, а также поддеревьями, расположенными после A.

На последней картинке проявляется смысл составляющих производной рекурсивной структуры. Список определяет путь к фокусному значению с оставленными позади поддеревьями и указанием, куда продолжать путь на развилках (тип с тремя значениями в случае Tree3). И ещё тут есть все поддеревья, до которых мы не добрались.

В оригинальных работах про дифференцирование типов (ссылки см. в конце) используются ещё два понятия, которые исторически появились раньше:

  • «однодырочный контекст» расположения в дереве какого-либо его поддерева (а не одного значения);

  • «застёжка» (zipper) – это поддерево в своём однодырочном контексте.

type OneHoleContext[Base[_, _]] = [A] =>> List[∂[[X] =>> Base[A, X]][μ[[T] =>> Base[A, T]]]]
type Zipper        [Base[_, _]] = [A] =>> OneHoleContext[Base][A] ×  μ[[T] =>> Base[A, T]]
//   Zipper[Base] = OneHoleContext[Base] × μ[Base] ≅ μ[Base]         ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ - поддерево μ[Base]

Застёжка изоморфна исходному контейнеру (непустому и с фокусом на конкретном поддереве в нём) ввиду того, что для любого дерева T \equiv \mu X. Base[A, X] можно построить изоморфизм

A \times \partial_A Base[A, T] \cong T

Здесь множитель A обозначает головной (коренной?) элемент, а \partial_A Base[A, T] – его поддеревья. Этот изоморфизм получается из \eqref{DiffIso} и \eqref{DiffRec} для случая, когда путь тривиален, то есть список однодырочного контекста пустой.

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

\begin{align}\partial_X List[X] &= \partial_X \left(\frac1{1-X}\right)=\\&=\frac1{(1-X)^2}=\\&=\frac1{1-X}\times\frac1{1-X}=\\\\&=List[X] \times List[X]\end{align}

Действительно, контекстом значения в списке является пара списков – предшествующие значения и последующие. Для дерева можно провести аналогичные вычисления, только выразить производную через тип исходного дерева будет несколько сложнее (и нужно будет учесть, что 1/(1-2 \times X \times Tree[X]) = List[2 \times X \times Tree[X]]).

Список литературы

  • Abusing the algebra of algebraic data types - why does this work? – несколько ответов на StackOverflow о тесной связи теории типов с алгеброй и матанализом.

  • Unordered tuples and type algebra – статья про подсчёт перестановок в кортежах (в тему о представлении Set[A]=2^A в качестве контейнера).

  • Про дифференцирование типов:

    • Haskell/Zippers. В начале идёт забавная история, как программист Ариадна помогает Тесею найти путеводную нить для древовидного лабиринта Минотавра)).

    • The Derivative of a Regular Type is its Type of One-Hole Contexts (pdf). Тут строгий вывод правил дифференцирования типов. Есть и более глубокие исследования, вроде Derivatives of Containers, но при желании можно их найти самостоятельно.

Заключение по всему обзору

Здесь были раскрыты следующие темы:

  • рекурсия и неподвижные точки функций;

  • рекурсивные типы – это способ формализации рекурсивных алгоритмов;

    • неподвижные точки конструкторов типов, как средства описания любых рекурсивных типов;

    • все неподвижные точки изоморфны, но среди них выделяются самые главные – наименьшая и наибольшая;

    • наименьшая неподвижная точка характеризуется парой функций (свёртка, конструктор), где свёртка – рекурсивная;

    • наибольшая неподвижная точка характеризуется парой функций (развёртка, деконструктор), где развёртка – рекурсивная;

  • свободные контейнеры трансформируют любые другие контейнеры, обогащая их возможностями комбинирования контейнерных вычислений вида A => F[B], что очень полезно при обработке эффектов;

    • свободные контейнеры позволяют обходить ограничение на стек в рекурсивных вычислениях;

    • популярные примеры реализаций свободных контейнеров;

  • схемы рекурсии – это частные случаи обобщения свёртки/развёртки для типовых задач;

    • все схемы рекурсии выражаются через две исходные функции свёртки и развёртки;

    • свободные контейнеры позволяют «заглядывать в прошлое» и решать задачи динамического программирования;

    • ко-свободные контейнеры позволяют «заглядывать в будущее» и преобразовывать списки в деревья;

  • типизированное программирование тесно связано с математикой (выводы по данной пятой части обзора);

    • натуральные числа – это рекурсивный тип, совмещающий в себе родственные идеи конструктивной математики и последовательных вычислений;

    • программисты по большей части имеют дело с неподвижными точками «полиномиальных» конструкторов типов (также известных, как «алгебраических», составленных из сумм произведений типов);

    • полиномиальные рекурсивные типы можно представить в виде степенных рядов, что открывает иную точку зрения на их структуру;

    • рекурсивные определения неподвижных точек иногда удаётся решить явно – полученные выражения (с вычитаниями, делениями и радикалами) можно рассматривать как производящие функции для степенных рядов;

    • многие контейнерные типы обладают семантикой хранения значения типа-параметра в контексте его расположения в контейнере, и этот контекст тоже можно выразить в виде него типа;

    • даже контравариантные конструкторы типов иногда можно трактовать, как рекурсивные ковариантные контейнеры;

    • вычисление типа контекста напоминает «деление с остатком», но следует привычным правилам вычисления производной;

    • лучше всего производные раскрываются именно для рекурсивных полиномиальных типов (деревьев).

Концепция повторения действий может быть реализована единообразно для самых разных сценариев. Это устраняет необходимость «велосипедить» собственные циклы, или рекурсию. Обработка наборов данных может быть выражена через неподвижные точки конструкторов типов – для них, как правило, уже реализованы все рекурсивные алгоритмы, и остаётся только корректно замоделировать в типах условие конкретной задачи.

В обзоре не раз обнаруживались отсылки к другим важным и объёмным темам. Например, свойства рекурсивных типов обусловлены соотношениями, которые формулируются и доказываются на языке теории категорий. А разнообразие используемых на практике типов опирается на не менее интересное понятие «зависимые типы». Эти темы стоит обсудить более обстоятельно, но как-нибудь в другой раз)).

Комментарии (0)