Интерпретация во время компиляции, или Альтернативное понимание лямбд в C++11
На Хабре недавно проскочила ещё одна статья про вычисления на шаблонах C++ от HurrTheDurr. В комментариях к ней лично я увидел вызов:> С каждым новым релизом количество способов нетривиально вывихнуть себе мозг при помощи С++ продолжает увеличиваться)> > Особенно, если не менять подход к реализации игрового поля и продолжать пытаться все вычисления выполнять не над константами, а над типами.
А так ли сложно будет написать универсальный вычислитель на типах, более удобный для программирования, чем клеточный автомат? Как оказалось, несложно; я в 30 раз больше времени потратил на эту статью, чем на написание и отладку собственно кода вычислителя.
Чуть раньше AveNat опубликовала введение в лямбда-исчисление в двух частях, так что вдохновение пришло мгновенно. Хотелось, чтобы можно было (образно) писать так:
#include
#include
int main () { // Представление натуральных чисел в виде лямбда-абстракций typedef ChurchEncode<2> Two; // 2 = λfx.f (f x) typedef ChurchEncode<3> Three; // 3 = λfx.f (f (f x))
// * = λab.λf.a (b f) typedef Lambda<'a', Lambda<'b', Lambda<'f', Apply, Apply, Var<'f'> > > > > > Multiply;
// Вычисление (* 2 3)
typedef Eval
// Переход обратно от лямбда-абстракций к натуральным числам typedef ChurchDecode
std: cout << Result::value; } А на выходе получать такое: ilammy@ferocity ~ $ gcc cpp.cpp ilammy@ferocity ~ $ ./a.out 6 Статья получилась несколько великоватой, так как мне хотелось рассказать обо всех интересных штуках, которые здесь используются. И ещё она требует базового набора знаний о лямбда-исчислении. Приведённых выше обзоров, среднего знания C++ (с шаблонами), и здравого смысла должно быть достаточно для понимания содержимого.Под катом находится очередное прокомментированное конструктивное доказательство Тьюринг-полноты шаблонов C++ в виде compile-time интерпретатора бестипового лямбда-исчисления (плюс печеньки в виде макросов и рекурсии).
(Так как компиляторы C++03 требуют <асимметричных<угловых<скобок<в<шаблонах> > > > >, а меня в большинстве случаев коробит от их внешнего вида, то для компиляции необходима или поддержка C++11, или sed ': l; s/>>/> >/g; tl'. За исключением части про макросы, в коде не используются никакие другие возможности C++11.)
Синтаксис Поехали. Как известно, термы в лямбда-исчислении бывают трёх видов:
Все эти конструкции необходимо представить с помощью шаблонов C++. Значениями в языке шаблонов являются типы. Каждый из этих типов должен нести информацию о своих компонентах. Объявляя их, мы вводим аксиому «существуют следующие выражения»:
template
Умея записывать термы, необходимо научиться их вычислять. Вот тут уже всё не так просто, поэтому применим достаточно известный подход к написанию адовой магии на шаблонах: сначала всё записать на каком-нибудь декларативном языке программирования, затем только исправить синтаксические различия. Лично мне ближе Scheme, так что я буду использовать его. С равным успехом можно применить и любой другой функциональный язык вроде Haskell. (А ещё лучше — Пролог.)Не будем изобретать велосипед для записи самих термов, а используем традиционные списки:
; Y = λf.(λx.f (x x)) (λx.f (x x)) (define Y '(lambda (f) ((lambda (x) (f (x x))) (lambda (x) (f (x x)))))) Зафиксируем синтаксис лямбда-исчисления: ; Терм — это ссылка, абстракция, или аппликация. (define (term? exp) (or (ref? exp) (lam? exp) (app? exp)))
; Ссылки записываются символами. (define (ref? exp) (symbol? exp))
; Абстракция — это список из трёх элементов. (define (lam? exp) (and (list-of? 3 exp) ; Где первый — это символ lambda. (eqv? 'lambda (first exp)) ; Второй — это список из одного символа. (list-of? 1 (second exp)) (symbol? (first (second exp))) ; А третий — это терм. (term? (third exp))))
; Аппликация — это список из двух термов. (define (app? exp) (and (list-of? 2 exp) (term? (first exp)) (term? (second exp)))) Далее определим функцию eval, которая будет интерпретировать термы, различая их по синтаксису: (define (eval exp) (cond ((ref? exp) (eval-ref exp)) ((lam? exp) (eval-lam (first (second exp)) (third exp))) ((app? exp) (eval-app (first exp) (second exp))) (else (error «eval: syntax error» exp)))) Отлично. Но как реализовать eval-ref? Откуда интерпретатор узнает значение переменной? Для этого существует такое понятие как окружение (environment). В окружениях сохраняются связи между переменными и их значениями. Поэтому eval на самом деле выглядит так — с дополнительным аргументом: (define (eval exp env) (cond ((ref? exp) (eval-ref exp env)) ((lam? exp) (eval-lam (first (second exp)) (third exp) env)) ((app? exp) (eval-app (first exp) (second exp) env)) (else (error «Syntax error» exp)))) Теперь вычисление ссылки на переменную определяется просто — это поиск значения переменной в окружении: (define (eval-ref var env) (lookup var env)) Значением абстракции должна быть анонимная функция одного аргумента. Эту функцию вызовет аппликация, когда придёт время. Смысл абстракции состоит в том, чтобы вычислить своё тело exp в окружении, где переменная абстракции var имеет значение переданного аргумента arg. О создании такого окружения позаботится функция bind. (define (eval-lam var exp env) (lambda (arg) (eval exp (bind var arg env)))) Значения остальных переменных могут варьироваться, но конкретно в лямбда-исчислении принято, чтобы они оставались такими же, как и в месте определения абстракции (то есть брались из окружения env). Из-за свойства сохранения исходного окружения подобные функции называются замыканиями.Наконец, аппликация — применение значения абстракции fun к значению аргумента arg.
(define (eval-app fun arg env) ((eval fun env) (eval arg env))) Здесь сначала вычисляется абстракция и её аргумент, а потом уже происходит вызов. Соответственно, eval выполняет редукцию лямбда-термов в аппликативном порядке (с вызовом по значению).Осталось только определить пару функций для работы с окружениями. lookup для поиска значения переменной в окружении и bind для создания новых окружений:
(define (bind var val env) ; Для окружений используются ассоциативные списки:
(cons (cons var val) env)) ; (bind 'x 1 '((y. 2))) ===> ((x. 1) (y. 1))
; (lookup 'x '((x. 1) (y. 2))) ===> 1
(define (lookup var env) ; (lookup 'y '((x. 1) (y. 2))) ===> 2
(let ((cell (assq var env))) ; (lookup 'z '((x. 1) (y. 2))) ===> #
Глядя на исходный код интерпретатора на Scheme, можно относительно легко догадаться, как писать интерпретатор лямбда-термов на шаблонах C++. Напомню, что сами термы записываются следующими шаблонами:
template
template
template
template
template
template
Окей, попробуем провести простейшее вычисление, используя полученные возможности (полный код):
/* Определения, показанные выше */
#include
int main () { // 1 = λfx.f x typedef Lam<'f', Lam<'x', App, Ref<'x'>>>> One;
// 2 = λfx.f (f x) typedef Lam<'f', Lam<'x', App, App, Ref<'x'>>>>> Two;
// + = λab.λfx.a f (b f x)
typedef Lam<'a', Lam<'b', Lam<'f', Lam<'x',
App
// Output:= (+ 1 2)
typedef Eval
// Э-э-э… А как вывести результат?
Output: invalid_field;
}
ilammy@ferocity ~/dev/tlc $ g++ -std=c++11 lc.cpp
lc.cpp: В функции «int main ()»:
lc.cpp:79:5: ошибка: неполный тип «Output {aka Closure
Во-первых, замыкания шаблонного интерпретатора — это его внутренние структуры данных. Им соответствуют только типы C++, но не значения. С ними необходимо работать внутри самого интерпретатора и никогда не выносить за пределы механизма шаблонов. (Поэтому они специально оставлены неопределёнными типами.)Во-вторых, очевидно, что в случае представления аргументов в виде чисел Чёрча, результат их сложения тоже будет числом Чёрча — функцией двух аргументов, которая N раз применяет первый аргумент ко второму. (Потому-то мы и получили на выходе именно замыкание, как говорит выдача gcc.) Но что нам делать с этой функцией? Ведь в качестве аргументов мы ей можем передавать лишь такие же функции!
Действительно, сейчас наш интерпретатор понимает только чистое лямбда-исчисление, в котором есть лишь абстракции, аппликации и переменные (которые ссылаются на абстракции или аппликации). Синтаксис позволяет составлять лямбда-термы исключительно из этих трёх компонентов. Любое нарушение этого правила приводит к ошибке компиляции.
Чтобы получить возможность расшифровки результатов вычислений, необходимо использовать прикладное лямбда-исчисление — в нём множество термов расширено элементами некоторого предметного множества. В нашем случае это будет множество типов данных C++.
Введём для них соответствующий терм:
template
template
Eval
Apply
Apply
struct Succ;
template
/* Определения, показанные выше */
#include
int main () { // 1 = λfx.f x typedef Lam<'f', Lam<'x', App, Ref<'x'>>>> One;
// 2 = λfx.f (f x) typedef Lam<'f', Lam<'x', App, App, Ref<'x'>>>>> Two;
// + = λab.λfx.a f (b f x)
typedef Lam<'a', Lam<'b', Lam<'f', Lam<'x',
App
// Sum = (+ 1 2)
typedef App
// Result:= (Sum +1 =0)
typedef App
typedef Eval
std: cout << Result::interpretation; } ilammy@ferocity ~/dev/tlc $ g++ -std=c++11 lc.cpp
ilammy@ferocity ~/dev/tlc $ ./a.out 3 Глобальное окружение и свободные переменные Я думаю, вы уже заметили, что в main () мы всегда вызываем Eval с пустым окружением, а все необходимые абстракции «инлайним». Но это вовсе не обязательно. Если передать в первый вызов Eval какое-то окружение, то оно будет играть роль глобального для вычисляемого терма: именно оно задаёт значения свободных переменных — тех, которые ни с чем не связаны лямбда-абстракциями.Однако, нельзя просто так взять и положить функцию-тип в окружение. В окружениях лежат значения, так что сначала их требуется вычислить (полный код):
/* … */
#include
int main () { /* Определения One, Two, Plus пропущены */
// Unchurch = λn.(n +1 =0), преобразование чисел Чёрча в int
typedef Lam<'n', App
// Result:= (Unchurch (+ 1 2))
typedef Eval
Bind<'+', typename Eval
>:: value Result;
std: cout << Result::interpretation; } Окружения представляют память интерпретатора. Если бы это был компилятор с линкером, то там должны были бы лежать скомпилированные функции. В случае же интерпретатора они находятся в «предвычисленном» состоянии — уже пропущенные через Eval.Ещё стоит обратить внимание на свободные переменные Unchurch. Они входят в окружение безо всяких Inj вокруг. Это тоже потому, что в памяти интерпретатора эти значения представляются именно таким образом. Inj необходима только для записи их в тексте программ (в лямбда-термах).
Макросы
Никого ещё не достало, что для записи функций нескольких аргументов их необходимо вручную каррировать? И все эти постоянные Ref<'foo'>? А между прочим, даже в лямбда-исчислении приняты удобные сокращения:
Давайте реализуем такие же.Существует множество подходов к реализации макросов. Для нашего случая стоит избрать самый простой: внешний препроцессор. «Внешний» означает, что определения макросов находятся вне обрабатываемой программы. То есть мы не будем вводить какой-либо новый синтаксис для лямбда-исчисления, чтобы выражать внутри него макросы; это было бы слишком сложно. Макропроцессор просто «натравливается» на программу и на выходе выдаёт чистый лямбда-терм — так же, к примеру, работает MOC в Qt.
Две фазы
До этого момента в жизни наших программ было только одно важное событие — определение их значения с помощью Eval. Теперь к нему прибавится ещё раскрытие макросов с помощью Expand. Всё, что подаётся на вход Eval, сначала необходимо пропустить через Expand. Введём новую удобную функцию Compute, которая объединяет эти действия:
template
template
template
template
Скрытый текст
Для начала необходимо унифицировать аргументы аппликации. Они могут быть или пачкой имён переменных, или пачкой лямбда-термов. Имена надо превратить в термы. Если бы шаблоны C++11 позволяли написать map для пачек, то было бы проще, а так мы построим ещё один связный список. (А может, map реально написать? Anyone?)
struct Nil;
template
template
template
template
template
template
template
template
template
template
template
template
template
template
Сказав про полноту по Тьюрингу полученного вычислителя, мы как-то обошли стороной один момент, удовлетворившись простой арифметикой. Ведь Тьюринг-полная система позволяет выразить циклы! Рассмотрим циклические вычисления на примере (барабанная дробь) факториала.В лямбда-исчислении нельзя выразить рекурсию напрямую, так как у абстракций отсутствуют имена. Для записи рекурсии в привычном виде потребуется магический оператор Rec, который подобен обычной абстракции Lam, но создаёт абстракции с дополнительным аргументом — ссылкой на саму определяемую абстракцию.
Однако, хитрые математики нашли способ обойти это ограничение: так называемый Y-комбинатор позволяет выражать рекурсивные функции как решения функциональных уравнений о неподвижных точках. Что это такое и как расшифровывается предыдущее предложение, можете прочитать в другом месте, а сейчас важно, что Y-комбинатор записывается вот так:
// Y = λf.(λx.f (x x)) (λx.f (x x))
typedef Lam<'f', App
// F = λfn.if (= n 0) 1
// (* n (f (- n 1)))
typedef Lam_
/* … */
#include
int main () { /* Определения Y и Fact_gen */
typedef Compute
std: cout << Result::interpretation; } ilammy@ferocity ~/dev/tlc $ g++ -std=c++11 lc.cpp 2>&1 | wc -c 64596 Не компилируется. И лог ошибок на 64 килобайта. Почему? Всё дело в порядке вычислений. Обычный Y-комбинатор записывается из расчёта на нормальный порядок вычислений. В нём кусочек f (x x) сначала вызовет подстановку (x x) в тело f, и только потом, если это понадобится, значение (x x) будет вычислено (тоже с ленивой подстановкой).
В случае аппликативного порядка (вызова по значению) это выражение вычисляется сразу же, что очевидно приводит к бесконечному циклу (если посмотреть, чему должен быть равен аргумент x). Например, интерпретатор лямбда-исчисления на Scheme, показанный ранее, зацикливается.
Если расшифровать лог, выплёвываемый gcc, то там так и написано, что:
struct Apply<
Closure<
Lam<'x', App, App, Ref<'x'>>>>,
Bind<'f',
Closure
В языках с аппликативным порядком вычислений следует применять Z-комбинатор — модификацию Y-комбинатора, в которой выражение (x x) завёрнуто в абстракцию, что предотвращает его преждевременное вычисление:
// Z = λf.(λx.f (λy.x x y)) (λx.f (λy.x x y))
typedef Lam<'f', App
typedef Lam_
template
template
template
template
real 0m12.630s user 0m11.979s sys 0m0.466s
ilammy@ferocity ~/dev/tlc $ ./a.out 720 К сожалению, дождаться вычисления факториала семи мне не удалось, не говоря уже о том, что на 32-битных системах компилятор попросту умирает от переполнения стека. Но всё равно!… Заключение Практической инженерной пользы от этого интерпретатора, наверное, никакой нет, но сама по себе идея восхитительна. Шаблоны C++ — это она из «нечаянно Тьюринг-полных» вещей в теоретической информатике. Схожее ощущение у меня возникло разве что тогда, когда я узнал, что подсистема управления страничной памятью x86-процессоров тоже полна по Тьюрингу. Если этот интерпретатор позволяет выполнять вычисления, не выполняя ни одного оператора C++, то MMU позволяет выполнять вычисления, не выполняя ни одной машинной инструкции программы.К сожалению, DCPL я дочитал пока только до восьмой главы, поэтому написание интерпретатора типизированного лямбда-исчисления оставляется читателям в качестве упражнения. У меня пока ещё слишком слабая математическая подготовка для этого.
P.S. Всё время, пока я писал пост, меня не покидала мысль: «Это или уже есть в Бусте, или пишется там в три строки».
