О кодировании натуральных чисел (часть 1)

Широко известно классическое индуктивное объявление натуральных чисел: ноль — натуральное число, и число следующее за натуральным — натуральное. На языке с поддержкой индуктивных типов это можно записать как-то так:

Haskell
data Nat :: * where
  Zero :: Nat
  Succ :: Nat -> Nat
  
zero :: Nat
zero = Zero

one :: Nat
one = Succ Zero

two :: Nat
two = Succ (Succ Zero)

add :: Nat -> Nat -> Nat
add n Zero     = n
add n (Succ k) = Succ (add n k)

mul :: Nat -> Nat -> Nat
mul n Zero     = Zero
mul n (Succ k) = add m (mul n k)

pred :: Nat -> Nat
pred Zero     = Zero
pred (Succ k) = k

minus :: Nat -> Nat -> Nat
minus Zero     m        = Zero
minus n        Zero     = n
minus (Succ k) (Succ l) = minus k l

Простейший способ кодирования таких чисел при помощи нетипизированного лямбда-исчисления — метод Чёрча, не имеющий, как кажется, прямого отношения к понятному индуктивному типу:

0=λsz.z1=λsz.sz2=λsz.s(sz)n=λsz.snz\begin{align*} 0 &= \lambda s z. z\\ 1 &= \lambda s z. sz\\ 2 &= \lambda s z. s(sz)\\ &\ldots\\ n &= \lambda s z. s^nz\\ \end{align*}

При этом используется нотация степени, являющаяся синтаксическим сахаром для следующей записи:

F0(X)=XFN(X)=FN1(F(X)) \begin{align*} F^0(X) &= X\\ F^N(X) &= F^{N-1}(F(X)) \end{align*}

Несмотря на странный вид таких термов, некоторую аналогию найти можно: z в лямбда-термах будет означать ноль, а некоторое количество s перед ним — увеличение этого нуля на единицу несколько раз. Для получения еще более сильной аналогии введем функцию succ:

succ=λnsz.s(nsz)succ = \lambda n s z.s (n s z)

Действительно, воспользовавшись таким термом, мы можем получить ожидаемое поведение:

succ  n=(λnsz.s(nsz))(λsz.snz)==(λsz.s((λsz.snz)sz))==(λsz.s((λz.snz)z))==(λsz.s(snz))=(λsz.sn+1z)\begin{align*} succ \; n &= (\lambda \color{red}n \color{black} s z.s (\color{red}n \color{black} s z)) \color{blue} (\lambda s z. s^n z) \color{black} = \\ &= (\lambda s z.s (\color{green} (\lambda \color{red} s \color{green} z. \color{red} s \color{green} ^n z) \color{blue} s \color{black} z)) = \\ &= (\lambda s z.s (\color{green} (\lambda \color{red} z \color{green}. s^n \color{red} z \color{green}) \color{blue} z \color{black})) = \\ &= (\lambda s z.s (s^n z)) = (\lambda s z.s^{n+1} z) \end{align*}

Также нам доступны простейшие арифметические операции (их можно проверить абсолютно аналогично тому, как выше проверяется функция succ):

add=λmn.m  succ  nmul=λmn.m  add  npow=λmn.m  mul  n \begin{align*} add &= \lambda m n. m \; succ \; n\\ mul &= \lambda m n. m \; add \; n\\ pow &= \lambda m n. m \; mul \; n \end{align*}

Эти функции дают важное свойство натуральных чисел в кодировке Чёрча. Применение терма, являющегося n-ным натуральным числом к некоторым термам f и x обеспечит n-кратное применение f к x:

nfx=(λsz.snz)fx=fnxn f x = (\lambda s z.s^n z) f x = f^n x

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

Haskell
data Pair :: * -> * -> * where
  MkPair :: a -> b -> Pair a b
  
fst :: Pair a b -> a
fst (Pair x y) = x

snd :: Pair a b -> b
snd (Pair x y) = y

Мы же вновь должны пользоваться лямбда-исчислением для их кодирования:

pair=λabc.cabpair = \lambda a b c.c a b

Действительно, мы може записать любую пару элементов таким образом, ведь pairXY=λc.cXYpair X Y = \lambda c. c X Y. Более того, нам доступны две проекции, позволяющие брать первый и второй элемент пары:

fst=λp.p  Ksnd=λp.p  K\begin{align*} fst &= \lambda p. p \; K \\ snd &= \lambda p. p \; K* \end{align*}

KK и KK* здесь — это комбинаторы λxy.x\lambda x y.x и λxy.y\lambda x y. y соответственно. Действительно:

fst(pairXY)=(λp.p  K)  ((λabc.cab)XY)==(λp.p  K)  (λc.cXY)=(λc.cXY)K==KXY=(λxy.x)XY=(λy.X)Y=X\begin{align*} fst (pair X Y) &= (\lambda p.p \; K) \; ((\lambda a b c.c a b) X Y) = \\ &= (\lambda p.p \; K) \; (\lambda c.c X Y) = (\lambda c.c X Y) K = \\ &= K X Y = (\lambda x y.x) X Y = (\lambda y.X) Y = X \end{align*}

Как же воспользоваться этой конструкцией для получения предыдущего числа? Давайте зададим пару, которая будет содержать в качестве первого аргумента некоторое число, а в качестве второго — следующее за ним. Получить её довольно просто:

pair  n  (succ  n)pair \; n \; (succ \; n)

В частности, первые несколько таких пар будут (мы считаем, что предыдущее для ноля — ноль):

pair  0  0pair  0  1pair  1  2\begin{aligned} pair \; 0 \; 0\\ pair \; 0 \; 1\\ pair \; 1 \; 2\\ \ldots \end{aligned}

А можем ли мы вместо выписывания всех пар просто придумать терм, позволяющий нам получить следующую пару из предыдущей? Да, запросто!

sp=λp.pair  (snd  p)(snd  (succ  p))sp = \lambda p. pair \; (snd \; p) (snd \; (succ \; p))

Здесь первым элементом новой пары становится второй элемент предыдущей, а вторым — следующее число. Применив такую функцию к паре (pair  0  0)(pair \; 0 \; 0) n раз мы получим пару, где второй элемент — число n, а первое — n - 1. А ровно это нам и требуется. Собирая все вместе получим функцию получения предыдущего натурального числа:

pred  n=λn.fst  (n  sp  (pair  0  0))pred \; n = \lambda n.fst \; (n \; sp \; (pair \; 0 \; 0))

Из тех же соображений можно получить вычитание чисел:

minus=λnm.m  pred  nminus = \lambda n m. m \; pred \; n

Важным наблюдением тут является то, что функция pred работает за O(n)O(n), а minus и вовсе за O(n2)O(n^2)! Это, очевидно, является проблемой кодировки натуральных чисел Чёрча.

Целочисленное деление также можно получить, вспомнив рекурсивное выражение через вычитание:

[nm]={1+[nmm]nm0n<m\left[ \frac{n}{m} \right] = \begin{cases} 1 + \left[ \frac{n - m}{m} \right] & n \ge m \\ 0 & n < m \end{cases}

Для написания данного выражения нам потребуется еще один тип данных — Boolean, для того, чтоб выражать условие, в соответствии с которым мы будем возвращать 0 или идти в рекурсивную ветку. Классическое определение Boolean тривиально — это тип, который населяют два конктреных значения:

Haskell
data Bool :: * where
  True  :: Bool
  False :: Bool
  
ifThenElse :: Bool -> a -> a -> a
ifThenElse True  x y = x
ifThenElse False x y = y

В нетипизированном лямбда-исчислении представление тоже не особо сложное:

true=λtf.tfalse=λtf.f\begin{align*} true &= \lambda t f.t \\ false &= \lambda t f.f \end{align*}

Оператор if для такой кодировки совсем тривиальный:

if=λcxy.cxyif = \lambda c x y.c x y

Действительно, это можно проверить:

if  true  X  Y=(λcxy.cxy)  true  X  Y==(λxy.true  x  y)  X  Y==(λy.true  X  y)  Y=true  X  Y==(λtf.t)  X  Y=(λf.X)  Y==Xif  false  X  Y=(λcxy.cxy)  false  X  Y==(λxy.false  x  y)  X  Y==(λy.false  X  y)  Y=false  X  Y==(λtf.f)  X  Y=(λf.f)  Y==Y\begin{align*} if \; true \; X \; Y &= (\lambda c x y.c x y) \; true \; X \; Y = \\ &= (\lambda x y.true \; x \; y) \; X \; Y = \\ &= (\lambda y.true \; X \; y) \; Y = true \; X \; Y = \\ &= (\lambda t f.t) \; X \; Y = (\lambda f.X) \; Y = \\ &= X\\ if \; false \; X \; Y &= (\lambda c x y.c x y) \; false \; X \; Y = \\ &= (\lambda x y.false \; x \; y) \; X \; Y = \\ &= (\lambda y.false \; X \; y) \; Y = false \; X \; Y = \\ &= (\lambda t f.f) \; X \; Y = (\lambda f.f) \; Y = \\ &= Y \end{align*}

Для получения нашей формулы требуется еще понять, как сравнивать два числа. Простейший способ — вычесть одно из другого и сравнить с нулем. Вычитать мы уже умеем, а вот сравнение с нулем — это новое смешение двух миров: натуральных чисел и булевых значений. Тем не менее делается это сравнение очень просто. Заметим, что ноль выглядит так же, как false, а число отличное от нуля применит n раз какую-то функцию к нулю. Это позволяет нам написать функцию iszero по аналогии с if:

iszero=λn.n  (λx.false)  trueiszero = \lambda n.n \; (\lambda x.false) \; true

Теперь мы можем записать рекурсивную форму нашего деления:

div=λnm.if  (iszero  (minus  m  (add  n  1)))  0  (add  1  (div  (minus  n  m)  m))div = \lambda n m. if \; (iszero \; (minus \; m \; (add \; n \; 1))) \; 0 \; (add \; 1 \; (div \; (minus \; n \; m) \; m))

Отлично! За одним исключением: мы не умеем работать с рекурсивными термами. Но это легко исправить. Для начала проабстрагируемся по упоминанию div в нашем терме:

div=(λdnm.if  (iszero  (minus  m  (add  n  1)))  0  (add  1  (d  (minus  n  m)  m)))  divdiv = (\lambda \color{red} d \color{black} n m. if \; (iszero \; (minus \; m \; (add \; n \; 1))) \; 0 \; (add \; 1 \; (\color{red} d \color{black} \; (minus \; n \; m) \; m))) \; \color{red} div \color{black}

Теперь наш терм принял вид X=F  XX = F \; X, иными словами div является неподвижной точкой для терма:

λdnm.if  (iszero  (minus  m  (add  n  1)))  0  (add  1  (d  (minus  n  m)  m))\lambda d n m. if \; (iszero \; (minus \; m \; (add \; n \; 1))) \; 0 \; (add \; 1 \; (d \; (minus \; n \; m) \; m))

В соответствии с теоремой о комбинаторе неподвижной точки такая точка есть у любого терма, и находится она просто с помощью применения такого комбинатора к терму:

Y=λf.(λx.f(xx))(λx.f(xx))Y = \lambda f.(\lambda x.f (xx))(\lambda x.f (xx))

Итого, финальное нерекурсивное выражение для деления:

div=Y(λdnm.if  (iszero  (minus  m  (add  n  1)))  0  (add  1  (d  (minus  n  m)  m)))div = Y (\lambda d n m. if \; (iszero \; (minus \; m \; (add \; n \; 1))) \; 0 \; (add \; 1 \; (d \; (minus \; n \; m) \; m)))

Подведем итог. Мы увидели простые и очевидные определения для таких типов данных как натуральные числа, пары и булевы значения. Также мы определили набор функций для работы с каждым. Следующим шагом было появление из воздуха некоторых лямбда-термов, удивительным образом копирующих поведение этих конструкций. Применяя их заодно с построением примитивной рекурсии и выражением рекурсивых термов через Y-комбинатор, мы получили тот же набор функций. Вопрос же, на который мы постараемся ответить в следующий раз, а существует ли алгоритм, позволяющий переводить понятные индуктивные конструкции в лямбда-термы?

↑↓ навигация ↵ открыть esc закрыть