Лямбда-исчисление Чёрча Нотация: переменная, абстракция, аппликация и как читать выражения
0%

Нотация: переменная, абстракция, аппликация и как читать выражения

Нотация: переменная, абстракция, аппликация и как читать выражения

Первое столкновение с записью вроде λf.λx.f (f x) обычно заканчивается одинаково: глаз цепляется за греческую букву, мозг говорит «это математика, я не в теме», вкладка закрывается. Хотя на самом деле вы читаете такие штуки каждый день. Вот та же строчка на JavaScript:

f => x => f(f(x))

И на Python:

lambda f: lambda x: f(f(x))

Никакой магии: функция, которая берёт функцию f, возвращает функцию, которая берёт x и применяет f дважды. Всё лямбда-исчисление — это ровно такие выражения, только без сахара: без чисел, без строк, без if, без return. Три конструкции — и точка.

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

Зачем вообще отдельная нотация

Резонный вопрос: если это те же стрелочные функции, зачем учить чужой синтаксис?

Потому что языки программирования — грязные. В JavaScript у функции есть this, arguments, hoisting, разница между function и =>, значения по умолчанию, деструктуризация, async. Всё это полезно в работе и абсолютно мешает, когда вы хотите доказать что-то о вычислениях вообще. Чёрч в 1930-х искал минимальный язык, в котором можно выразить любое вычисление и при этом рассуждать о нём формально. Получилось три правила. Не десять, не сто — три.

Минимализм имеет цену: чтобы записать «два» или «истину», придётся выкручиваться (этим займёмся в статьях про числа Чёрча и булевы значения). Зато любое утверждение о языке доказывается перебором трёх случаев, а не тысячи страниц спецификации ECMAScript.

Аналогия: лямбда-исчисление относится к программированию примерно как ассемблер к прикладному коду. Только ассемблер — минимальная машина «снизу», а лямбда-исчисление — минимальный язык «сверху», со стороны математики. Обе модели вычислительно эквивалентны, и это отдельный красивый результат (тезис Чёрча-Тьюринга), к которому мы вернёмся в обзоре трека.

Три кирпича

Грамматика целиком — четыре строки. Термы (так называют выражения лямбда-исчисления) строятся так:

терм ::= x              -- переменная
       | (λx. терм)     -- абстракция: определение функции
       | (терм терм)    -- аппликация: вызов функции

Всё. Больше в чистом лямбда-исчислении ничего нет. Ни констант, ни операторов, ни объявлений.

Разберём каждый кирпич отдельно и сразу с кодом.

Переменная

Переменная — это имя. x, y, f, foo. Ничего с ней не сделаешь и ничего в ней не хранится: она либо будет заменена на что-то при вызове функции, либо так и останется висеть как «неизвестное». Переменная в лямбда-исчислении ближе к параметру функции или к математическому «пусть x», чем к переменной в C, куда можно присвоить.

Важный момент: присваивания в языке нет. Ни x = 5, ни let, ни мутаций. Единственный способ «дать значение» переменной — применить к аргументу функцию, у которой эта переменная стоит параметром.

Абстракция — определение функции

λx. M читается: «функция, которая принимает x и возвращает M». Точка отделяет параметр от тела.

λx. x          -- функция тождества: вернуть аргумент как есть
λx. y          -- функция, которая игнорирует аргумент и всегда возвращает y
λx. x x        -- функция, применяющая аргумент к самому себе

Параллельно:

const id       = x => x;         // λx. x
const constY   = x => y;         // λx. y   (y берётся откуда-то извне)
const selfApp  = x => x(x);      // λx. x x
id_       = lambda x: x          # λx. x
const_y   = lambda x: y          # λx. y
self_app  = lambda x: x(x)       # λx. x x

Обратите внимание: λ в записи ровно там же, где lambda в Python, а точка — там же, где двоеточие. Соответствие буквальное:

Анатомия лямбда-выражения: λ, параметр, тело

Слово «абстракция» здесь означает «абстрагирование от конкретного значения»: в теле x x мы не знаем, что такое x, и именно поэтому получаем функцию, а не результат.

Аппликация — вызов функции

M N (просто два терма через пробел) означает «применить M к N». В обычных языках вы пишете M(N); в лямбда-исчислении скобки не нужны, аппликация обозначается соседством.

f x            -- f(x)
(λx. x) y      -- (x => x)(y)
f (g x)        -- f(g(x))

Это первое, обо что спотыкаются: пробел — это оператор. f x — не «две вещи рядом», а вызов. Как умножение в 2ab у математиков.

f(x);
(x => x)(y);
f(g(x));
f(x)
(lambda x: x)(y)
f(g(x))

Два соглашения о скобках

Полная грамматика требует скобок вокруг каждой абстракции и каждой аппликации, но никто так не пишет — получается частокол. Поэтому приняты два соглашения, и именно они делают выражения нечитаемыми для новичка. Выучите их — и половина проблемы исчезнет.

Соглашения о скобках: левоассоциативная аппликация и тело, тянущееся вправо

Соглашение 1: аппликация левоассоциативна

f x y z   ≡   (((f x) y) z)

То есть f применяется к x, результат применяется к y, результат — к z. Это НЕ «функция f от трёх аргументов» и не f(x, y, z) — в лямбда-исчислении вообще нет функций от нескольких аргументов, о чём ниже.

Проверьте себя на JS — там ровно то же самое:

f(x)(y)(z)   // именно так, а не f(x, y, z)

Соглашение 2: тело абстракции тянется вправо как можно дальше

λx. f x y   ≡   λx. ((f x) y)     -- а НЕ ((λx. f) x) y

Точка «открывает скобку», которая закрывается в конце выражения или на ближайшей закрывающей скобке. Хотите оборвать тело раньше — ставьте скобки руками:

(λx. f x) y     -- вот здесь тело — только «f x», а y применяется снаружи

Это соглашение — источник примерно 90% ошибок при чтении. Всякий раз, увидев λ, мысленно ставьте открывающую скобку сразу после точки и ищите, где она закроется.

Соглашение 3 (сокращение): склейка параметров

λx y z. M   ≡   λx. λy. λz. M

Иногда пишут λxyz. M — то же самое, если имена односимвольные. Это чистая экономия чернил, никакой новой сущности.

Каррирование: почему аргумент всегда один

В лямбда-исчислении каждая функция принимает ровно один аргумент. Функцию «двух аргументов» изображают функцией, которая берёт первый и возвращает функцию, ждущую второй. Приём называется каррированием — в честь Хаскелла Карри (хотя придумал его Мозес Шёнфинкель).

λx. λy. x       -- «взять x, вернуть функцию, которая игнорирует y и отдаёт x»

Применим к двум аргументам, пошагово. Правило вычисления (бета-редукция) простое: (λx. тело) аргумент превращается в тело, где все x заменены на аргумент. Подробно разберём в статье про редукции, пока пользуемся интуицией.

(λx. λy. x) a b
  ≡ ((λx. λy. x) a) b        -- шаг 0: расставили скобки по соглашению 1
  → (λy. a) b                -- шаг 1: подставили a вместо x в тело «λy. x»
  → a                        -- шаг 2: подставили b вместо y в тело «a»; y там не встречается

Второй аргумент просто выброшен — это и есть функция K (в кодировании Чёрча она же true).

const K = x => y => x;
K(1)(2);        // 1
const always1 = K(1);   // частичное применение: осталась функция y => 1
always1(999);   // 1
K = lambda x: lambda y: x
K(1)(2)          # 1
always1 = K(1)   # y => 1
always1(999)     # 1

Частичное применение, которое во фреймворках подают как продвинутый приём, — просто побочный эффект того, что аргумент всегда один.

Практическая сторона каррирования и композиции подробно разобрана в треке функционального программирования: Композиция и каррирование.

Как читать выражение: алгоритм

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

Прогоним на живом примере: λf. λx. f (f x).

Шаг 1. Первая λλf., её тело тянется до конца: λf. (λx. f (f x)).

Шаг 2. Внутри — λx., её тело тоже до конца фрагмента: λf. (λx. (f (f x))).

Шаг 3. Аппликации: внутренняя f x уже в скобках, внешняя f (...) — тоже. Группировать нечего.

Шаг 4. Читаем вслух: «функция от f, возвращающая функцию от x, которая применяет f к результату применения f к x». То есть «применить f дважды».

Шаг 5. Записываем кодом:

const twice = f => x => f(f(x));
twice(n => n + 1)(0);   // 2
twice = lambda f: lambda x: f(f(x))
twice(lambda n: n + 1)(0)   # 2

Это, кстати, число 2 в кодировании Чёрча: «сделай что-нибудь дважды». Числа в этой системе — глаголы, а не существительные.

Дерево разбора

Любой терм — это дерево с тремя видами узлов. Полезно держать эту картинку в голове: скобки лишь описывают форму дерева, а дерево — настоящий объект.

Вот λf. λx. f (f x):

А вот структура типов такого дерева — то, что вы напишете, если решите реализовать интерпретатор:

Реализация на Python — тридцать строк, включая печать со скобками по соглашениям:

from dataclasses import dataclass

# Три конструктора термов — ровно три класса
@dataclass(frozen=True)
class Var:
    name: str

@dataclass(frozen=True)
class Abs:
    param: str
    body: "Term"

@dataclass(frozen=True)
class App:
    func: "Term"
    arg: "Term"

Term = Var | Abs | App

def show(t: Term, top: bool = True) -> str:
    """Печать терма с минимальным набором скобок."""
    match t:
        case Var(name):
            return name
        case Abs(param, body):
            s = f"\\{param}.{show(body)}"
            # абстракция берётся в скобки, если она не на верхнем уровне
            return s if top else f"({s})"
        case App(f, a):
            # аргумент скобим, если он сам аппликация или абстракция
            left = show(f, top=False) if isinstance(f, Abs) else show(f, top=True)
            right = show(a, top=False) if isinstance(a, (Abs, App)) else show(a)
            return f"{left} {right}"

two = Abs("f", Abs("x", App(Var("f"), App(Var("f"), Var("x")))))
print(show(two))   # \f.\x.f (f x)

Сложность печати — O(n) по времени и O(глубина) по памяти на стек рекурсии, где n — число узлов. То же на TypeScript:

type Term =
  | { kind: "var"; name: string }
  | { kind: "abs"; param: string; body: Term }
  | { kind: "app"; func: Term; arg: Term };

const v = (name: string): Term => ({ kind: "var", name });
const abs = (param: string, body: Term): Term => ({ kind: "abs", param, body });
const app = (func: Term, arg: Term): Term => ({ kind: "app", func, arg });

function show(t: Term, top = true): string {
  switch (t.kind) {
    case "var":
      return t.name;
    case "abs": {
      const s = `\\${t.param}.${show(t.body)}`;
      return top ? s : `(${s})`;
    }
    case "app": {
      const left = t.func.kind === "abs" ? show(t.func, false) : show(t.func);
      const right = t.arg.kind === "var" ? show(t.arg) : show(t.arg, false);
      return `${left} ${right}`;
    }
  }
}

// \f.\x.f (f x)
console.log(show(abs("f", abs("x", app(v("f"), app(v("f"), v("x")))))));

Заметьте: в коде вместо λ обычно пишут \ — так принято в Haskell и в большинстве текстовых нотаций, потому что греческую букву неудобно набирать.

Редекс: где именно «есть что вычислять»

Ещё одно слово, которое понадобится дальше. Редекс (от reducible expression) — это кусок вида (λx. M) N: абстракция, применённая к аргументу. Именно редексы можно упрощать.

(λx. x) y            -- редекс, весь терм целиком
f ((λx. x) y)        -- редекс внутри аргумента
λz. (λx. x) y        -- редекс внутри тела
x y                  -- редексов нет, вычислять нечего

Терм, в котором редексов не осталось, называется нормальной формой — это «результат». Не у каждого терма нормальная форма существует, классический контрпример:

(λx. x x) (λx. x x)
  → (x x)[x := λx. x x]
  ≡ (λx. x x) (λx. x x)      -- вернулись ровно туда же

Этот терм зовут Ω, и он редуцируется в себя бесконечно — лямбда-версия while (true) {}. На JS это буквально мгновенное переполнение стека:

const omega = (x => x(x))(x => x(x));   // RangeError: Maximum call stack size exceeded

Порядок обхода редексов (какой выбирать первым) — тема отдельной статьи о стратегиях вычисления; там же выяснится, почему один порядок находит нормальную форму, а другой может зациклиться на том же терме.

Типичные ошибки чтения

1. Считать f x y вызовом с двумя аргументами. Это (f x) y. Если f — функция одного аргумента, возвращающая не-функцию, выражение бессмысленно (в бестиповом исчислении — просто застревает, в типизированном — не пройдёт проверку типов).

2. Обрывать тело абстракции раньше времени. λx. x y — это λx. (x y), а не (λx. x) y. Разница принципиальная: первое — функция, второе редуцируется к y.

λx. x y          -- функция; в JS: x => x(y)
(λx. x) y        -- вызов; → y

3. Путать λx. λy. M и «функцию двух аргументов». Формально это разные вещи, хотя ведут себя похоже. Как только вы примените такой терм к одному аргументу, получится функция — и это законный, полезный объект.

4. Думать, что имена переменных что-то значат. λx. x и λy. y — одна и та же функция, просто параметр назван иначе. Формально это фиксируется альфа-эквивалентностью, см. статью о редукциях.

5. Забывать, что скобки вокруг аргумента обязательны. f g x — это (f g) x, то есть f применяется к g, потом к x. Если вы имели в виду f(g(x)), пишите f (g x). Один пробел и пара скобок полностью меняют смысл — как в JS f(g)(x) против f(g(x)).

Соглашения об именах, которые встретите в литературе

Не часть языка, но помогает читать чужие тексты:

  • x, y, z — обычные переменные;
  • f, g, h — переменные, играющие роль функций;
  • M, N, P — метапеременные, то есть «любой терм» (в самом языке их нет, это часть языка описания);
  • заглавные жирные или прописные имена вроде I, K, S, Y, TRUE, SUCC — общепринятые сокращения для конкретных термов. Это чистые макросы: везде, где стоит I, подставьте λx. x.
I ≡ λx. x                    -- identity
K ≡ λx. λy. x                -- константа / true
S ≡ λf. λg. λx. f x (g x)    -- «распределитель» аргумента

Комбинаторы S и K замечательны тем, что из них двоих собирается любое замкнутое лямбда-выражение — это отдельная ветвь под названием комбинаторная логика. Каноническое изложение — Barendregt, «The Lambda Calculus: Its Syntax and Semantics» (https://www.elsevier.com/books/the-lambda-calculus/barendregt/978-0-444-87508-2), а для мягкого входа — глава 5 книги Пирса «Types and Programming Languages» (https://www.cis.upenn.edu/~bcpierce/tapl/).

Разберём выражение целиком, шаг за шагом

Возьмём то, что пугает: (λf. λx. f (f x)) (λy. y z) w.

Шаг 1. Расставляем скобки по соглашениям. Аппликация левоассоциативна:

((λf. λx. f (f x)) (λy. y z)) w

Внешняя структура: сначала первый терм применяется к (λy. y z), потом результат — к w.

Шаг 2. Первая бета-редукция. Редекс — (λf. λx. f (f x)) (λy. y z). Подставляем λy. y z вместо f:

(λx. f (f x))[f := λy. y z]
  ≡ λx. (λy. y z) ((λy. y z) x)

Получилось:

(λx. (λy. y z) ((λy. y z) x)) w

Шаг 3. Вторая бета-редукция. Внешний редекс: подставляем w вместо x:

(λy. y z) ((λy. y z) w)

Шаг 4. Редуцируем внутренний аргумент (λy. y z) w — подставляем w вместо y в тело y z:

(λy. y z) (w z)

Шаг 5. Последняя редукция. Подставляем w z вместо y в тело y z:

(w z) z

Шаг 6. Редексов больше нет — это нормальная форма. По соглашению пишем без лишних скобок:

w z z

Проверим кодом — а заодно убедимся, что это не абстрактная игра:

const twice = f => x => f(f(x));
const g = y => `(${y} z)`;      // моделируем «y z» как строку
console.log(twice(g)("w"));      // ((w z) z)
twice = lambda f: lambda x: f(f(x))
g = lambda y: f"({y} z)"
print(twice(g)("w"))   # ((w z) z)

Строки здесь — просто способ увидеть форму результата; лямбда-исчисление получило w z z, и код показывает ту же вложенность.

Упражнения

Сначала решайте, потом смотрите ответ. Пишите все шаги: пропущенный шаг — это место, где обычно и прячется ошибка.

1. Расставьте все скобки: λx. λy. x y z

Ответ
λx. (λy. ((x y) z))

Тело внешней абстракции тянется до конца, тело внутренней — тоже; аппликация группируется слева.

2. Расставьте все скобки: (λx. x) λy. y z

Ответ
(λx. x) (λy. (y z))

Тело λy съедает всё, что справа, поэтому аргументом оказывается вся абстракция целиком. Редуцируется к λy. y z.

3. Сведите к нормальной форме: (λx. λy. y x) a (λz. z)

Ответ
((λx. λy. y x) a) (λz. z)     -- скобки
→ (λy. y a) (λz. z)           -- x := a
→ (λz. z) a                   -- y := λz. z
→ a                           -- z := a

4. Сведите к нормальной форме: (λf. f (f a)) (λx. x)

Ответ
(λf. f (f a)) (λx. x)
→ (λx. x) ((λx. x) a)         -- f := λx. x
→ (λx. x) a                   -- редуцируем аргумент: x := a
→ a                           -- x := a

Тот же результат получится, если сначала редуцировать внешний редекс, — это не совпадение, а следствие теоремы Чёрча-Россера (статья о стратегиях).

5. Переведите на λ-нотацию: const compose = f => g => x => f(g(x));

Ответ
λf. λg. λx. f (g x)

Скобки вокруг g x обязательны: без них получилось бы (f g) x.

6. Что не так с записью λ. x?

Ответ
Это не терм: у абстракции обязан быть ровно один параметр между λ и точкой. Грамматика не допускает пустого параметра — как x => без стрелки слева в JS.

7. Сведите к нормальной форме: (λx. x x) (λy. y)

Ответ
(λx. x x) (λy. y)
→ (λy. y) (λy. y)             -- x := λy. y, обе копии
→ λy. y                       -- y := λy. y

Самоприменение не всегда расходится: с I оно спокойно сходится к I. Расходится оно у Ω, где аргумент сам себя удваивает.

8. Сколько редексов в терме (λx. (λy. y) x) ((λz. z) a)?

Ответ
Три: весь терм целиком, (λy. y) x внутри тела и (λz. z) a в аргументе. Порядок их выбора — предмет стратегии вычисления; результат a получится при любом.

Что читать дальше по теме

Мини-итог

  • Термов ровно три вида: переменная x, абстракция λx. M, аппликация M N. Никаких констант, присваиваний и операторов.
  • Аппликация — это пробел, и она левоассоциативна: f x y = (f x) y.
  • Тело абстракции тянется вправо до упора: λx. f x y = λx. ((f x) y).
  • Функции одноаргументные всегда; «много аргументов» — это каррирование, а частичное применение получается само собой.
  • λx. M — это ровно x => M и lambda x: M. Ничего нового по сравнению с вашим ежедневным кодом; новое — только строгость.
  • Редекс (λx. M) N — место, где есть что вычислять; терм без редексов находится в нормальной форме.

Синтаксис разобрали. Дальше начинается самое коварное место всего исчисления: подстановка. Она выглядит очевидной («заменить x на аргумент»), но наивная реализация ломается на именах — и именно там прячется единственный по-настоящему тонкий момент в этой теории.

Что дальше

Свободные и связанные переменные, подстановка и захват имён — узнаем, какие переменные «свои», какие «чужие», почему (λx. λy. x) y нельзя редуцировать в лоб и как альфа-переименование спасает положение.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов