Лямбда-исчисление в живых языках: от Lisp до современных стрелочных функций
Мы прошли весь трек: синтаксис, подстановку, редукции, булевы значения, числа, структуры данных, рекурсию через Y, стратегии вычисления и типы. Всё это время формулы жили на бумаге.
Эта статья — про то, что они не на бумаге. Когда вы пишете
const inc = x => x + 1;
вы буквально пишете λx. x + 1. Не «похоже на», не «вдохновлено» — это оно и есть, с точностью до значка. Синтаксис => появился в JavaScript в 2015 году, но выбранная за ним семантика — из статьи Чёрча 1936 года, и она прошла в язык через Lisp (1958), ALGOL 60, ISWIM, Scheme, ML и Smalltalk-блоки.
Дальше — экскурсия: где в вашем рабочем коде спрятаны абстракция, аппликация, бета-редукция, альфа-конверсия, эта-редукция и даже комбинатор неподвижной точки. Плюс то, чего в чистом лямбда-исчислении нет, но что языки добавили сверху, и почему.
Короткая история одной идеи
Лямбда-исчисление задумывалось как основание математики, а не как язык программирования. Чёрч предложил его в 1932–1936 годах, первая версия оказалась противоречивой, урезанная «чисто функциональная» часть — нет. Через двадцать с лишним лет Джон Маккарти делал язык для символьных вычислений, увидел у Чёрча удобную нотацию для безымянных функций и утащил её вместе с именем.
Важная деталь для интуиции: Lisp 1.0 взял у Чёрча синтаксис, но не семантику. Первые реализации использовали динамическую область видимости, и знаменитая проблема funarg (когда возвращённая функция видела чужие переменные) — это ровно то, что в нашем треке называется захватом имён. Правильное решение нашли в Scheme: лексическая область видимости плюс замыкания. То есть индустрия наступила на грабли из статьи про подстановку и потратила пятнадцать лет, чтобы прийти к альфа-конверсии.
Три конструкции — и всё ваше рабочее утро
Напомню весь язык: переменная, абстракция, аппликация. Теперь перевод на живые языки.
Переменная — обращение к имени. Ничего интереснее в ней нет.
Абстракция λx. M — создание функции:
x => M // JavaScript
function (x) { return M } // он же, длинно
lambda x: M # Python
def f(x): return M # он же, с именем
Аппликация f a — вызов:
f(a)
f(a)
Разница только в скобках: в лямбда-исчислении аппликация обозначается пробелом и левоассоциативна, f a b = (f a) b. В JS/Python это f(a)(b). И то и другое — каррирование: функция одного аргумента, возвращающая функцию одного аргумента.
Возьмём терм из статьи про синтаксис и разложим:
λx. λy. x
const K = x => y => x;
K(1)(2); // 1
K = lambda x: lambda y: x
K(1)(2) # 1
Это TRUE из статьи про булевы значения и комбинатор K из комбинаторной логики одновременно. В стандартной библиотеке Haskell он называется const, в Ramda — always, в Python его пишут руками. Один и тот же терм, четыре имени.
Вызов функции — это бета-редекс, буквально
Бета-редукция — правило (λx. M) N → M[x := N]. В коде редекс выглядит как немедленно вызванное функциональное выражение (IIFE):
(x => x + 1)(5)
Разберём по шагам, как разбирали бы на бумаге:
Шаг 0. (λx. x + 1) 5
редекс: слева абстракция, справа аргумент
Шаг 1. подставляем 5 вместо x в теле x + 1
(x + 1)[x := 5] = 5 + 1
Шаг 2. 5 + 1
это дельта-редекс: встроенная арифметика, не часть чистого исчисления
Шаг 3. 6
нормальная форма
Движок JavaScript делает ровно эти шаги, только вместо переписывания текста кладёт 5 в слот аргумента на стеке. Наблюдаемый результат тот же.
Теперь два аргумента, чтобы увидеть каррирование в действии:
Шаг 0. (λx. λy. x + y) 3 4
Шаг 1. аппликация левоассоциативна: ((λx. λy. x + y) 3) 4
Шаг 2. внутренний редекс: (λy. 3 + y)
Шаг 3. (λy. 3 + y) 4
Шаг 4. 3 + 4
Шаг 5. 7
const add = x => y => x + y;
add(3)(4); // 7
const add3 = add(3); // это результат шага 2 — «частично применённая» функция
add3(4); // 7
add = lambda x: lambda y: x + y
add(3)(4) # 7
add3 = add(3) # результат шага 2
add3(4) # 7
Промежуточный шаг 2 в реальном языке — не абстракция ума, а объект в куче: замыкание, внутри которого лежит x = 3. Об этом ниже.
let, где, аргументы по умолчанию: сахар над аппликацией
Питер Ландин в 1966 году показал: let не нужен как отдельная конструкция языка, это сокращённая запись аппликации.
let x = N in M ≡ (λx. M) N
Проверим на примере, полностью раскрутив:
Шаг 0. let x = 5 in x * x
Шаг 1. переписываем как аппликацию: (λx. x * x) 5
Шаг 2. бета: (x * x)[x := 5] = 5 * 5
Шаг 3. 25
В JavaScript это видно невооружённым глазом — до появления модулей так делали пространства имён:
// let x = 5 in x * x
(x => x * x)(5); // 25
// вложенные let — вложенные лямбды
// let a = 2 in let b = 3 in a + b
(a => (b => a + b)(3))(2); // 5
# в Python то же самое, только выражений-let нет вовсе
(lambda x: x * x)(5) # 25
(lambda a: (lambda b: a + b)(3))(2) # 5
Тот же трюк объясняет несколько «странностей» реальных языков:
- Аргумент по умолчанию — это лямбда с уже подставленным значением:
def f(x, n=10)эквивалентно наличию терма, где свободнаяnзаранее связана. - Мутабельный дефолт в Python (
def f(xs=[])) кусается ровно потому, что подстановка происходит один раз при создании функции, а не при каждом вызове. В терминах трека: значение подставлено в терм, а не пересчитывается. withв Python илиusingв C# — не лямбда-сахар, а обёртка над протоколом; не путайте.- Блочные выражения в Kotlin/Rust (
let x = { ... };) — снова связывание, синтаксически другое, семантически то же.
Замыкания: то, чего в исчислении нет — и почему это одно и то же
В чистом исчислении бета-редукция физически переписывает тело: (λn. λy. n + y) 10 превращается в λy. 10 + y. Тело изменилось.
Настоящий компилятор так не делает — переписывать машинный код на лету дорого. Вместо этого он оставляет тело как есть и создаёт замыкание: пару «указатель на код + окружение с захваченными свободными переменными».
function makeAdder(n) {
return y => n + y; // n здесь свободна внутри стрелочной функции
}
const add10 = makeAdder(10);
add10(5); // 15
Что здесь происходит на языке трека:
Шаг 0. makeAdder 10 ≡ (λn. λy. n + y) 10
Шаг 1. бета: (λy. n + y)[n := 10]
Шаг 2. λy. 10 + y
Шаг 3. (λy. 10 + y) 5
Шаг 4. 10 + 5
Шаг 5. 15
Что происходит в движке:
Шаг 1'. создаётся замыкание { code: "λy. n + y", env: { n: 10 } }
Шаг 3'. вызов: заводим кадр { y: 5 }, родитель — env замыкания
Шаг 4'. ищем n: нет в кадре → есть в родителе → 10
Шаг 5'. 15
Результаты совпадают всегда — при условии, что область видимости лексическая. Замыкание — это ленивая подстановка, отложенная до момента, когда значение реально понадобится. Именно поэтому динамическая область видимости (искать n в кадре вызывающего, а не в месте определения) даёт другие ответы: это ровно захват имён, от которого нас спасает альфа-конверсия.
Проверка на понимание — код, который ломается при динамической области видимости и работает при лексической:
n = "глобальная"
def make():
n = "локальная"
return lambda: n # свободная n связана лексически
def call(f):
n = "чужая"
return f() # при динамике вернулось бы "чужая"
print(call(make())) # "локальная"
Подробный разбор замыканий с точки зрения практики — в статье про функции высшего порядка.
Альфа-конверсия в проде: минификаторы, гигиена макросов, де Брёйн
Альфа-конверсия — «имя связанной переменной не имеет значения». В индустрии это не философия, а рабочий инструмент.
Минификаторы. Terser, esbuild, Closure Compiler переименовывают локальные переменные в a, b, c. Это буквально альфа-конверсия, и она корректна ровно потому, что связанные имена не наблюдаемы. Свободные (глобальные, импортированные) переименовывать нельзя — и минификаторы их не трогают. Различие «свободная/связанная» из второй статьи — это то, по чему минификатор принимает решение.
Гигиенические макросы. Макрос, разворачивающийся в код с временной переменной tmp, сломает пользовательский tmp — это захват имён в чистом виде. Scheme (syntax-rules), Rust (macro_rules!) решают проблему автоматическим переименованием при раскрытии. Классический источник — работа Кольбекера и др. о гигиеничном раскрытии макросов; текущее описание есть в документации Rust по макросам. В C макросы негигиеничны, поэтому там пишут __tmp_line_42 руками.
Индексы де Брёйна. Радикальное решение: не давать связанным переменным имён вообще, а ссылаться на «сколько лямбд наружу». Терм λx. λy. x записывается как λ λ 2. Альфа-эквивалентные термы становятся посимвольно равными, сравнение термов — тривиальным. Так устроены внутренние представления Coq, Agda, многих движков вывода типов.
λx. λy. x → λ λ 2
λa. λb. a → λ λ 2 те же самые, сравнение стало равенством строк
λx. λy. y x → λ λ 1 2
Эта-редукция: код-ревью на автомате
λx. f x → f, если x не свободна в f. На ревью это звучит так: «зачем оборачивать, передай функцию напрямую».
// эта-расширенная форма
users.map(u => formatUser(u));
// эта-редуцированная
users.map(formatUser);
list(map(lambda u: format_user(u), users))
list(map(format_user, users))
В Haskell это называют point-free стилем, hlint предлагает такие правки автоматически. Но у эта-редукции в реальных языках есть ловушки, которых нет в исчислении:
["1", "2", "3"].map(n => parseInt(n)); // [1, 2, 3]
["1", "2", "3"].map(parseInt); // [1, NaN, NaN]
Почему: map передаёт колбэку три аргумента (элемент, индекс, массив), а parseInt принимает второй аргументом систему счисления. parseInt("2", 1) — недопустимое основание. То есть JS-функции не являются каррированными функциями одного аргумента, и потому эта-эквивалентность здесь неверна.
Второй случай — строгие языки с побочными эффектами: λx. f x откладывает вычисление f, а f вычисляет его сразу. Если f — дорогое выражение с эффектом, эта-редукция меняет момент срабатывания. В чистом ленивом Haskell разницы нет, в JS/Python — есть.
Мораль: эта-правило верно для чистых каррированных функций. Проверяйте оба условия, прежде чем «упрощать».
Стратегии вычисления: почему && не может быть обычной функцией
В статье о стратегиях мы разбирали нормальный и аппликативный порядок. Практическое следствие живёт в каждом языке.
JavaScript и Python вычисляют аргументы до вызова — это аппликативный порядок, call-by-value. Значит, обычная функция не может реализовать короткое замыкание:
const and = (a, b) => a ? b : false;
and(false, expensive()); // expensive() всё равно вызовется
false && expensive(); // не вызовется — && встроен в язык как особая форма
Лямбда-исчисление даёт готовое лекарство — обернуть аргумент в абстракцию (thunk), потому что абстракция вычисляется не раньше, чем к ней применят аргумент:
const andLazy = (a, thunk) => a ? thunk() : false;
andLazy(false, () => expensive()); // expensive не вызовется
Разберём по шагам, что делает обёртка:
Шаг 0. and FALSE expensive аргумент — терм, который надо вычислить сразу
Шаг 1. аппликативный порядок требует нормализовать expensive → большая работа
Шаг 0'. andLazy FALSE (λ_. expensive)
Шаг 1'. (λ_. expensive) — уже нормальная форма, абстракция, вычислять нечего
Шаг 2'. в теле выбирается ветка FALSE, thunk просто выбрасывается
Шаг 3'. expensive не вычислен ни разу
Это тот же приём, которым в статье про булевы значения чинили IF при аппликативном порядке. На практике он же — под капотом:
Supplier<T>иOptional.orElseGetв Java (в отличие отorElse, который вычисляет всегда);lazyв Kotlin и Swift,Lazy<T>в C#;- ленивые сигналы и мемоизация в React/Vue/Solid;
- генераторы Python:
yieldпревращает тело в цепочку отложенных шагов.
Полный разбор ленивости — в статье про ленивые вычисления и потоки.
Y-комбинатор в языке, где есть имена
Комбинатор неподвижной точки из седьмой статьи нужен, когда у функции нет имени, чтобы вызвать саму себя. В реальном языке имена есть, поэтому Y там не нужен — но он работает, и это лучший способ убедиться, что вы поняли механизм.
Классический Y = λf. (λx. f (x x)) (λx. f (x x)) в строгом языке зациклится: x x вычислится до передачи в f. Лекарство — эта-расширение, дающее Z-комбинатор (он же аппликативный Y):
Y = λf. (λx. f (x x)) (λx. f (x x))
Z = λf. (λx. f (λv. x x v)) (λx. f (λv. x x v))
^^^^^^^^^^^^^^^^ обёртка λv. ... откладывает самоприменение
const Z = f => (x => f(v => x(x)(v)))(x => f(v => x(x)(v)));
const factStep = self => n => (n <= 1 ? 1 : n * self(n - 1));
const fact = Z(factStep);
fact(5); // 120
Z = lambda f: (lambda x: f(lambda v: x(x)(v)))(lambda x: f(lambda v: x(x)(v)))
fact_step = lambda self: lambda n: 1 if n <= 1 else n * self(n - 1)
fact = Z(fact_step)
fact(5) # 120
Раскрутим первый вызов по шагам, чтобы было видно, откуда берётся рекурсия:
Шаг 0. Z factStep 3
Шаг 1. (λx. factStep (λv. x x v)) (λx. factStep (λv. x x v)) применённое к 3
обозначим X = λx. factStep (λv. x x v)
Шаг 2. X X 3
Шаг 3. бета по x := X: factStep (λv. X X v) 3
Шаг 4. factStep получает self = (λv. X X v) и возвращает
λn. if n <= 1 then 1 else n * (λv. X X v) (n - 1)
Шаг 5. применяем к 3: 3 * (λv. X X v) 2
Шаг 6. (λv. X X v) 2 → X X 2 — и мы снова в шаге 2, но с n = 2
Шаг 7. 3 * (2 * (X X 1))
Шаг 8. X X 1 → ... → 1
Шаг 9. 3 * 2 * 1 = 6
Обратите внимание на шаг 6: обёртка λv. ... v — это и есть эта-расширение, единственное отличие Z от Y. Оно превращает бесконечно раскручивающийся терм в абстракцию, которая ждёт аргумента. В строгом языке это разница между работающим кодом и переполнением стека.
Где Y-подобные вещи всплывают на практике: определение рекурсивных типов через Fix, fix в Haskell (fix f = let x = f x in x), рекурсивные схемы (catamorphism/anamorphism), а также обход letrec при компиляции языков, где рекурсивных связываний нет в ядре.
Как компилятор видит вашу программу
Самое неожиданное для многих: промышленные компиляторы функциональных языков буквально переводят исходник в лямбда-исчисление и работают уже с ним.
Что тут важно понять:
- Ядро языка крошечное. У Haskell в исходнике десятки конструкций, в GHC Core — около десяти форм, и почти все они узнаваемы из нашего трека: переменная, абстракция, аппликация,
let,case, литералы, приведения типов. Всё остальное — сахар. Официальное описание есть в GHC Commentary про Core. - Оптимизации — это редукции. Инлайн функции = бета-редукция, выполненная компилятором заранее. Устранение лишней обёртки = эта-редукция. Разбор «почему GHC не заинлайнил» на практике сводится к рассуждениям о размере терма и о том, сколько раз он используется.
- Closure conversion — это перевод из мира «свободные переменные» в мир «явные поля». После него в программе нет свободных переменных вообще: каждая функция получает окружение аргументом. Смотри рисунок с замыканием выше.
- CPS (continuation-passing style) — преобразование, где каждая функция получает дополнительный аргумент «что делать дальше». Оно тоже чисто лямбда-исчислительное и лежит в основе многих оптимизирующих компиляторов, а также async/await: компилятор превращает линейный код с
awaitв цепочку колбэков, то есть в CPS.
Эндрю Аппель показал («SSA is Functional Programming», 1998), что SSA-форма в императивных компиляторах (LLVM, JVM JIT) изоморфна функциональному представлению: phi-узлы соответствуют параметрам функций-блоков. То есть даже компилятор C внутри рассуждает термами, просто называет их иначе.
Где ещё сидит лямбда в обычном дне разработчика
Пара примеров, где связь прямее, чем кажется.
Middleware — это композиция. Express, Koa, ASP.NET Core pipeline, Django middleware — всё это f ∘ g ∘ h, применённое к запросу. В терминах трека: λx. f (g (h x)).
const compose = (...fns) => x => fns.reduceRight((acc, f) => f(acc), x);
const pipeline = compose(auth, logging, rateLimit);
pipeline(request);
Promise — это не совсем монада, но then — это bind. Идея та же: связывание отложенного вычисления с продолжением. Разбор в статье про монады.
Дженерики — это System F. <T>(x: T) => T — это Λα. λx:α. x из статьи о типах. Java-стирание типов, C#-реификация, Rust-мономорфизация — это три разных инженерных ответа на вопрос «что делать с большой лямбдой во время исполнения».
AWS Lambda названа так не случайно: единица развёртывания — функция без состояния, которая получает вход и возвращает выход. Маркетинг, но честный.
Типичные ошибки и заблуждения
«Замыкания в цикле работают неправильно». Работают ровно правильно, вопрос в том, сколько связываний создаётся.
const fs = [];
for (var i = 0; i < 3; i++) fs.push(() => i);
fs.map(f => f()); // [3, 3, 3]
const gs = [];
for (let i = 0; i < 3; i++) gs.push(() => i);
gs.map(g => g()); // [0, 1, 2]
var создаёт одну связку на всю функцию — все три терма ссылаются на одну свободную переменную, и после цикла её значение равно 3. let спецификация определяет так, что на каждую итерацию создаётся новая связка — три разных лямбды с разными окружениями. В Python классика та же:
fs = [lambda: i for i in range(3)]
[f() for f in fs] # [2, 2, 2]
gs = [lambda i=i: i for i in range(3)] # значение по умолчанию = ранняя подстановка
[g() for g in gs] # [0, 1, 2]
«Лямбда — это анонимная функция». Не совсем: анонимность — свойство синтаксиса, а лямбда-абстракция — конструкция, создающая функцию. def f(x): return x — тоже лямбда-абстракция, просто с именем сверху.
«Функциональный код медленнее, потому что лямбды». Смешиваются два разных вопроса. Аллокация замыкания — реальная стоимость (объект в куче, давление на GC). Но JIT-компиляторы агрессивно инлайнят мономорфные колбэки, и в горячем цикле arr.map(f) часто компилируется в такой же машинный код, что и for. Мерить, а не гадать. При этом кодирования Чёрча из статьи про числа действительно катастрофически медленные — они учебные, не производственные.
«Раз всё есть функции, уберём типы». Наоборот: как раз потому, что нетипизированное исчисление допускает λx. x x и незавершающиеся термы, типы и появились. Подробнее — в девятой статье и в статье про вычислимость.
Хвостовая рекурсия. В чистом исчислении рекурсия ничего не «кладёт на стек» — стека нет, есть переписывание терма. В реальных языках стек есть. Scheme и Elixir обязаны устранять хвостовые вызовы, JVM и CPython — нет, и в Python вы упрётесь в RecursionError на глубине около 1000. Поэтому академически элегантное решение через Y в Python — учебный пример, а не рабочий код.
Мини-лаборатория: интерпретатор за 45 строк
Лучшее упражнение — реализовать нормальный порядок редукции и посмотреть, как термы раскручиваются. Термы кодируем кортежами: ("var", "x"), ("lam", "x", body), ("app", f, a).
from itertools import count
_fresh = count()
def free_vars(t):
"""Свободные переменные терма."""
kind = t[0]
if kind == "var":
return {t[1]}
if kind == "lam":
return free_vars(t[2]) - {t[1]}
return free_vars(t[1]) | free_vars(t[2])
def subst(t, name, value):
"""t[name := value] с переименованием при угрозе захвата."""
kind = t[0]
if kind == "var":
return value if t[1] == name else t
if kind == "app":
return ("app", subst(t[1], name, value), subst(t[2], name, value))
# абстракция
param, body = t[1], t[2]
if param == name:
return t # переменная перекрыта, подстановка не идёт внутрь
if param in free_vars(value): # захват — делаем альфа-конверсию
fresh = f"{param}#{next(_fresh)}"
body = subst(body, param, ("var", fresh))
param = fresh
return ("lam", param, subst(body, name, value))
def step(t):
"""Один шаг нормального порядка: самый левый внешний редекс. None — нормальная форма."""
if t[0] == "app":
f, a = t[1], t[2]
if f[0] == "lam": # это редекс
return subst(f[2], f[1], a)
nf = step(f)
if nf is not None:
return ("app", nf, a)
na = step(a)
if na is not None:
return ("app", f, na)
return None
if t[0] == "lam":
nb = step(t[2])
return None if nb is None else ("lam", t[1], nb)
return None
def show(t):
kind = t[0]
if kind == "var":
return t[1]
if kind == "lam":
return f"(\\{t[1]}. {show(t[2])})"
return f"({show(t[1])} {show(t[2])})"
def normalize(t, limit=100, trace=True):
for i in range(limit):
if trace:
print(f"{i}: {show(t)}")
nxt = step(t)
if nxt is None:
return t
t = nxt
raise RuntimeError("предел шагов исчерпан — возможно, терм расходится")
Проверка на числах Чёрча: 2 применённая к succ и 0.
V = lambda n: ("var", n)
L = lambda p, b: ("lam", p, b)
A = lambda f, a: ("app", f, a)
two = L("f", L("x", A(V("f"), A(V("f"), V("x")))))
succ = L("n", L("f", L("x", A(V("f"), A(A(V("n"), V("f")), V("x"))))))
zero = L("f", L("x", V("x")))
normalize(A(A(two, succ), zero))
# 0: (((\f. (\x. (f (f x)))) (\n. ...)) (\f. (\x. x)))
# ... и через несколько шагов — терм, альфа-эквивалентный числу 2
Сложность: каждый шаг — обход терма, то есть O(размер терма) по времени; число шагов в худшем случае неограниченно (нетипизированное исчисление полно по Тьюрингу, так что проблема остановки применима — см. вычислимость). Память — O(глубина рекурсии + размер текущего терма).
Аналог на JavaScript пишется один в один; если хотите, добавьте счётчик редексов и сравните нормальный порядок с аппликативным (меняется только то, редуцируем ли a до проверки f) — увидите разницу из восьмой статьи на живых числах.
Упражнения
Сведите к нормальной форме, выписывая каждый шаг. Ответы ниже.
1. (λx. λy. x y) (λz. z) w
2. (λf. λx. f (f x)) (λn. n + 3) 4 (арифметику считайте встроенной)
3. (λx. λy. x y) y — осторожно с именами.
4. (λx. λy. x) a ((λz. z z) (λz. z z)) в нормальном порядке. Что будет в аппликативном?
5. Что напечатает код и почему:
const fs = [];
for (var i = 0; i < 2; i++) fs.push(() => i);
console.log(fs[0](), fs[1]());
6. Верна ли эта-редукция здесь: ["10","10","10"].map(x => Number(x)) → ["10","10","10"].map(Number)? А для parseInt?
Ответ 1.
Шаг 0. (λx. λy. x y) (λz. z) w
Шаг 1. аппликация левоассоциативна: ((λx. λy. x y) (λz. z)) w
Шаг 2. внешний левый редекс — внутренняя аппликация; бета по x := (λz. z):
(λy. (λz. z) y)
Шаг 3. (λy. (λz. z) y) w
Шаг 4. бета по y := w: (λz. z) w
Шаг 5. бета по z := w: w
Нормальная форма: w
Между шагами 2 и 3 в теле сидит эта-редекс λy. (λz. z) y, который эта-редуцируется прямо в λz. z — короткий путь даёт тот же ответ, как и обещает теорема Чёрча–Россера.
Ответ 2.
Шаг 0. (λf. λx. f (f x)) (λn. n + 3) 4
Шаг 1. ((λf. λx. f (f x)) (λn. n + 3)) 4
Шаг 2. бета по f := (λn. n + 3):
(λx. (λn. n + 3) ((λn. n + 3) x))
Шаг 3. применяем к 4:
(λn. n + 3) ((λn. n + 3) 4)
Шаг 4. нормальный порядок — редуцируем внешний редекс, по n := ((λn. n + 3) 4):
((λn. n + 3) 4) + 3
Шаг 5. внутренний редекс: (4 + 3) + 3
Шаг 6. 7 + 3
Шаг 7. 10
Это TWO succ 4 — двукратное применение, ровно как в числах Чёрча.
Ответ 3.
Шаг 0. (λx. λy. x y) y
внешняя y — свободная, внутри абстракции y — связанная. Разные переменные!
Шаг 1. наивная подстановка дала бы λy. y y — свободная y попала под связку. Это захват.
Шаг 2. альфа-конверсия связанной переменной: (λx. λv. x v) y
Шаг 3. бета по x := y: λv. y v
Нормальная форма: λv. y v (и эта-редукцией она сводится к самой y)
Ответ 4.
Обозначим Ω = (λz. z z) (λz. z z) — терм, который редуцируется сам в себя вечно.
Нормальный порядок (самый левый внешний редекс):
Шаг 0. ((λx. λy. x) a) Ω
Шаг 1. бета по x := a: (λy. a) Ω
Шаг 2. бета по y := Ω — y не встречается в теле, аргумент выбрасывается: a
Ответ: a за два шага.
Аппликативный порядок (сначала нормализуем аргумент):
Шаг 0. (λy. a) Ω
Шаг 1. нормализуем Ω: (λz. z z) (λz. z z) → (λz. z z) (λz. z z) → ...
Ответ: не завершается.
Это стандартная демонстрация теоремы о нормализации: если нормальная форма существует, нормальный порядок её найдёт, аппликативный — не обязательно. Именно поэтому Haskell (ленивый) вернёт результат там, где Python зациклится.
Ответ 5. Напечатает 2 2. var i — одна связка на всю функцию; обе стрелочные функции ссылаются на одну и ту же свободную переменную, а после выхода из цикла её значение равно 2 (условие i < 2 нарушилось именно при i = 2). С let было бы 0 1.
Ответ 6. Для Number — верна: Number использует только первый аргумент, лишние индекс и массив игнорирует, результат совпадает. Для parseInt — неверна: он принимает второй аргумент radix, и map передаст туда индекс. ["10","10","10"].map(parseInt) даст [10, NaN, 2] (radix 0 → по умолчанию 10; radix 1 → недопустимо, NaN; radix 2 → “10” в двоичной = 2). Эта-редукция корректна только для функций, чья арность действительно равна одному.
Мини-итог трека
- Стрелочная функция — это лямбда-абстракция, вызов — аппликация, а вычисление вызова — бета-редукция. Разница между учебником и вашим редактором чисто нотационная.
let, аргументы по умолчанию, IIFE, блочные выражения — сахар над аппликацией. Ландин показал это в 1966-м, с тех пор ничего не изменилось.- Замыкание — инженерная реализация подстановки: тело не переписывается, значения свободных переменных носятся рядом. Лексическая область видимости — это отказ от захвата имён.
- Альфа-конверсия работает в минификаторах и гигиенических макросах; эта-редукция — в советах линтера; индексы де Брёйна — во внутренностях пруф-ассистентов.
- Порядок вычисления — не абстракция: он определяет, зациклится программа или нет, и почему
&&нельзя написать функцией в строгом языке. - Компиляторы функциональных языков в буквальном смысле переводят программу в типизированное лямбда-исчисление и оптимизируют её редукциями.
Если из всего трека остаётся одна мысль, пусть будет эта: маленькое ядро с ясными правилами сильнее большого языка с неясными. Именно поэтому проектировщики языков сначала описывают ядро в терминах лямбда-исчисления, а потом добавляют сахар — и именно поэтому вы теперь можете читать спецификации языков как термы.
Что дальше
Трек закончен — дальше расходятся три дороги.
Практика. Всё, что мы кодировали в лямбдах, в рабочем коде выглядит как функции высшего порядка, композиция и типы данных. Идите в трек функционального программирования: обзор, композиция и каррирование, ФП в мейнстрим-языках и архитектура на ФП.
Теория. Лямбда-исчисление — одна из трёх эквивалентных моделей вычислимости, а типы — это логика. Смотрите вычислимость, логику и доказательства, основы теории категорий и теорию категорий в программировании.
Кругозор. Как функциональный стиль соотносится с остальными — в треке парадигм: функциональная парадигма и как выбирать и смешивать.
Не знаете, что брать следующим, — загляните в дорожную карту портала: там треки выстроены в порядке, в котором они друг друга поддерживают.
Источники
- Alonzo Church. An Unsolvable Problem of Elementary Number Theory, 1936 — оригинал.
- Peter Landin. The Next 700 Programming Languages, 1966 — текст на портале ACM; там же
letкак сахар над аппликацией. - Guy Steele, Gerald Sussman. Lambda: The Ultimate Imperative и серия «Lambda Papers», MIT AI Memos, 1976–1980 — архив.
- Benjamin Pierce. Types and Programming Languages, MIT Press, 2002 — сайт книги.
- Simon Peyton Jones. The Implementation of Functional Programming Languages, 1987 — полный текст бесплатно.
- Andrew Appel. SSA is Functional Programming, SIGPLAN Notices, 1998 — PDF.
- GHC Commentary: Core representation.
- MDN: Arrow function expressions, Closures.