Лямбда как модель вычислений: числа Чёрча

Доказываем на практике: из трёх правил можно собрать арифметику. Запускаем 2 + 3 в чистом лямбда-исчислении.

Числа Чёрча — кодировка натуральных чисел как функций: число n — это функция, применяющая свой аргумент-функцию n раз.

Тьюринг-полнота без всего

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

Идея кодировки Чёрча

Число n представляется функцией двух аргументов: бери функцию f и значение x, примени f к x ровно n раз.

0  =  \f. \x. x                 -- ноль применений
1  =  \f. \x. f x               -- одно
2  =  \f. \x. f (f x)           -- два
3  =  \f. \x. f (f (f x))       -- три

Сложение «n плюс m»: примени f сначала m раз, потом ещё n раз.

PLUS = \n. \m. \f. \x. n f (m f x)

Исполнимый интерпретатор: 2 + 3 = 5

Соберём редуктор из прошлого урока, закодируем числа Чёрча, сложим 2 и 3 и «декодируем» результат обратно в обычное целое, посчитав глубину применений.

_c = [0]
def fresh(b):
    _c[0] += 1; return b + str(_c[0])
def var(n):    return ("var", n)
def lam(p, b): return ("lam", p, b)
def app(f, a): return ("app", f, a)

def free_vars(t):
    if t[0]=="var": return {t[1]}
    if t[0]=="lam": return free_vars(t[2]) - {t[1]}
    return free_vars(t[1]) | free_vars(t[2])

def subst(t, x, s):
    if t[0]=="var": return s if t[1]==x else t
    if t[0]=="app": return app(subst(t[1],x,s), subst(t[2],x,s))
    p, b = t[1], t[2]
    if p == x: return t
    if p in free_vars(s):
        np = fresh(p); b = subst(b, p, var(np)); p = np
    return lam(p, subst(b, x, s))

def step(t):
    if t[0]=="app":
        if t[1][0]=="lam":
            return subst(t[1][2], t[1][1], t[2]), True
        nf,r = step(t[1])
        if r: return app(nf,t[2]), True
        na,r = step(t[2])
        if r: return app(t[1],na), True
        return t, False
    if t[0]=="lam":
        nb,r = step(t[2])
        if r: return lam(t[1],nb), True
        return t, False
    return t, False

def normalize(t, limit=10000):
    for _ in range(limit):
        t,r = step(t)
        if not r: return t
    return t

def church(n):
    body = var("x")
    for _ in range(n):
        body = app(var("f"), body)
    return lam("f", lam("x", body))

PLUS = lam("n", lam("m", lam("f", lam("x",
        app(app(var("n"), var("f")),
            app(app(var("m"), var("f")), var("x")))))))

def to_int(t):
    t = normalize(t)
    if t[0] != "lam": return None
    inner = t[2]
    if inner[0] != "lam": return None
    f, x = t[1], inner[1]
    body, cnt = inner[2], 0
    while body[0]=="app" and body[1]==("var", f):
        cnt += 1; body = body[2]
    return cnt

expr = app(app(PLUS, church(2)), church(3))
print("2 + 3 =", to_int(expr))
print("church(0) =", to_int(church(0)))
print("church(4) =", to_int(church(4)))

Вывод:

2 + 3 = 5
church(0) = 0
church(4) = 4

Никакой встроенной арифметики — только функции и бета-редукция. Число «существует» лишь как поведение: «примени аргумент n раз».

Булевы и не только

Тем же приёмом кодируются булевы значения и условие: TRUE = \a. \b. a (бери первое), FALSE = \a. \b. b (бери второе). Тогда IF c t e = c t e просто выбирает ветку. Пары, списки, рекурсия (через комбинатор неподвижной точки Y) — всё выражается. Так лямбда-исчисление становится полноценным языком программирования из трёх правил.

Как работает под капотом

Кодировка Чёрча — это представление данных через их сворачивание (fold). Число n — это «n-кратное применение», список — «правая свёртка по элементам». Эта идея напрямую отражается в современных языках: тип данных можно закодировать его элиминатором (как с ним работают), а не структурой. Отсюда — кодировки Чёрча/Скотта и связь с категорной семантикой (последний раздел).

Частые ошибки

  • Искать «настоящие» числа в лямбда-исчислении. Их нет — есть только функции, ведущие себя как числа.
  • Забывать про лимит шагов. Без типов терм может не завершиться; интерпретатор обязан иметь предохранитель (limit).
  • Думать, что без типов плохо. Нетипизированное исчисление мощнее (Тьюринг-полно), но именно поэтому в нём есть «плохие» термы — отсюда мотивация типизировать в следующем разделе.

Итоги

  • Лямбда-исчисление Тьюринг-полно: числа, булевы, пары — кодируются функциями.
  • Число Чёрча n — функция, применяющая аргумент n раз; сложение — композиция применений.
  • Работающий интерпретатор даёт 2 + 3 = 5 без встроенной арифметики.
  • Кодировка данных через их использование — идея, которая вернётся в типах и категориях.
Проверьте себя
1. Что представляет собой число Чёрча n?
AСтроку из n единиц
BФункцию, применяющую свой аргумент-функцию n раз
CМассив длины n
DСамо число n
2. Что значит «лямбда-исчисление Тьюринг-полно»?
AОно быстрое
BВ нём выразим любой алгоритм, как на машине Тьюринга
CВ нём есть числа
DОно всегда завершается
3. Почему интерпретатору лямбда-термов нужен лимит шагов?
AДля красоты вывода
BБез типов терм может не завершиться (например, Omega), нужен предохранитель
CЧтобы экономить память всегда
DЛимит не нужен