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

Доказываем на практике: из трёх правил можно собрать арифметику. Запускаем 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Лимит не нужен
py
Курс по теме
Пройдите курс «Python с нуля» — по шагам, с проверкой
8 уроков · ~14 ч · теория, упражнения и экзамен с бейджем
Открыть курс →