Сергей Романенко
14 февраля 2010 г.
В послании
было показано, что недетерминированные программы можно писать в рамках
детерминированного ленивого функционального языка, реализовав с помощью
комбинаторов проблемно-ориентированный язык, содержащий конструкцию
“произвольного выбора”. Идея-то с виду простая: реализуем конструкцию
choice2 e1 e2
. И пусть вычисление этой конструкции сводиться к
вычислению e1
или e2
, а выбор между e1
и e2
делается произвольно.
Или, говоря более формально, считаем, что семантика языка определяется
через правила редукции выражений (т.е. сведения одних выражений к
другим), просто считаем, что для choice2
определено два правила редукции:
choice2 e1 e2 ⟹ e1
choide2 e1 e2 ⟹ e2
Вроде бы, всё ясно и понятно… Однако, есть тут один “подводный камень”. Рассмотрим такое недетерминированное выражение:
(\x -> P x x) (choice2 True False)
где P
- конструктор, формирующий пару. Каковы возможные результаты
вычисления этого выражения? Это зависит от того, в каком порядке
выполнять редукцию. Если первый шаг редукции - применение функции
(\x -> P x x)
к аргументу (choice2 True False)
, то на первом шаге
получается выражение
P (choice2 True False) (choice2 True False)
в результате дальнейшей редукции которого могут получиться 4 разных
выражения: P True True
, P True False
, P False True
, P False False
.
А если первый шаге редукции - упрощение (choice2 True False)
, то
получается одно из двух выражений
(\x -> P x x) True
(\x -> P x x) False
и дальнейшие вычисления дают только два возможных результата:
P True True
, P False False
.
Из рассмотренного примера видно, что результат вычисления недетерминированных конструкций может зависеть от порядка вычислений и от моментов, в которые производится выбор между возможностями. Можно затягивать выбор до последней возможности: выбирать только тогда, когда результат, возвращаемый конструкцией выбора реально понадобился. В литературе этот вариант называется “выбором по надобности” (need-time choice). А можно выбирать в тот момент, когда конструкция выбора оказывается аргументом функции. В литературе этот вариант называется “выбором при вызове” (call-time choice).
В послании
была рассмотрена реализация “выбора по надобности”. При исполнении
вызова функции, конструкция choice2
, оказавшаяся в аргументе функции,
прямо подставлялась в тело функции, а выбор из двух вариантов
откладывался до момента “надобности”.
Это наглядно можно увидеть просуперкомпилировав выражение
(app (lam (\x -> pairP (var x) (var x)))
(choice2 (cst True) (cst False))) c
Получается остаточная программа
case c of {
D u z3 ->
case z3 of {
D u5 y5 -> (P case u5 of { L t4 -> True; R p4 -> False; }
case y5 of { L y8 -> True; R p2 -> False; });
};}
из которой видно, что каждый из компонентов пары может принимать значение независимо от другого.
Однако, существует мнение, что такой подход - не очень хорош с
“идеологической” точки зрения. Вот смотрим мы на определение функции
(\x -> P x x)
и наивно думаем, что при вызове функции переменная x
должна “принять некоторое значение”, и что это значение - “одно и то же”
для всех вхождений x
. А оказывается, что это - не так. Чувствуется в
этом какая-то гниль (по мнению некоторых авторитетных товарищей). Не
лучше ли делать все выборы до того, как с переменной связывается
значение? Тогда в P x x
оба входения x
будут означать одно и то же.
Но тут сразу же выясняется, что если мы хотим, чтобы выбор происходил до передачи аргумента в тело функции, то это означает, что аргумент функции должен вычисляться до передачи аргумента в тело функции. Другими словами, параметры должны передаваться в тело функции не “по имени” (call by name), а “по значению” (call by value).
(Конечно, ещё возможны и изощрённые варианты, когда параметр может передаваться всяко: либо по имени, если он детерминированный, либо по значению, если он недетерминированный, но в такие жуткие дебри мы углубляться не будем.)
А теперь предположим, что нам хочется реализовать недетерминированный язык через набор комбинаторов, и при этом комбинаторы нужно писать на “ленивом” языке. Можно ли при этом реализовать “выбор при вызове”? Ведь для этого надо, чтобы параметры передавались “по значению”, а в “ленивом” языке параметры передаются “по имени”. Оказывается, что можно…
Вызов параметров по значению можно реализовать в рамках “ленивого” языка,
если писать программу в стиле “передачи продолжений”
(continuation passing style). А именно, к параметрам каждой функции f
нужно добавить дополнительный параметр k
(который называется
“продолжением”). Если вызов функции f
выглядел как f x_1 x_2 ... x_N
,
то теперь он принимает вид f x_1 x_2 ... x_N k
. При этом, в тех местах,
где функция возвращала результат a
, нужно выдачу результата делать не
напрямую, а вызывать k
, передав ему результат в качестве аргумента: k a
.
В послании
рассматривалась реализация “ленивого” λ-исчисления через набор комбинаторов (см. задание на суперкомпиляцию Lambda: higher-order syntax (transparent)):
var = \x -> x;
lam = \f -> f;
app = \e1 e2 -> e1 e2;
cst = \a -> a;
fix = \f -> f (fix f);
Эти комбинаторы настолько тривиальны, что трудно даже понять их смысл! Тавтология - она тавтология и есть. Если мы реализуем ленивый язык с вызовом параметров по имени через ленивый же язык с вызовом параметров по имени, то понятия реализуемого языка тривиально отображаются на язык реализации.
А теперь попробуем сделать так, чтобы аргумент функции сначала вычислялся и только после этого связывался с параметром функции, переписав комбинаторы в стиле с продолжениями (см. задание на суперкомпиляцию Lambda: higher-order syntax: HOAS, CPS, CBV)!
var = \x k -> k x;
lam = \f k -> k f;
app = \e1 e2 k -> e1 (\f -> e2 (\v -> f v k));
cst = \a k -> k a;
var
, lam
и cst
устроены неинтересно: переменная подсовывает продолжению
k
своё значение, lam
- функцию, а cst
- свой аргумент. Весь интерес - в
комбинаторе app
! Он работает так. Сначала вычисляется e1
, и результат
этого вычисления подсовывается продолжению (и связывается с переменной
f
). Затем вычисляется e2
, и результат этого вычисления подсовывается
продолжению (и связывается с переменной v
). После этого вычисляется
результат применения f
к v
и подсовывается продолжению k
. Вот и
получается, что при вызове функции предварительно вычисляется её
аргумент. В рамках “ленивого” языка реализован вызов параметра по значению.
Самый тонкий вопрос - как определить fix
! Определение fix
- это некая
смесь из определений app
и lam
:
fix = \e k -> k (fixLoop e);
fixLoop = \e x k -> e(fixLoop e)(\f -> f x k);
run
- вспомогательная функция, с помощью которой исполняется выражение,
записанное с помощью комбинаторов. Дело сводится к тому, что нужно
подсунуть выражению тривиальное “продолжение”, которое ничего не делает,
а просто возвращает результат, вырабатываемый выражением:
run = \e -> e (\x -> x);
Для того, чтобы можно было определять функции, что-то делающие с
натуральными числами, определим ещё три комбинатора. natZ
- вырабатывает
ноль, natS
сначала вычисляет свой аргумент, а потом прибавляет к тому,
что получилось, единицу. natCase
реализует разбор случаев: проверяет,
является ли нулём её первый аргумент, а затем вызывает второй или третий
аргумент.
data Nat = Z | S Nat;
natZ = \k -> k Z;
natS = \e k -> e (\v -> k(S v));
natCase = \e eZ eS k -> e (\v -> case v of {
Z -> eZ k;
S n -> eS n k;
});
Теперь можно определять функции над натуральными числами
data Bool = True | False;
natIsZ = \e -> natCase e (cst True) (\n -> (cst False));
natPred = \e k -> e (\v -> case v of { S v1 -> k v1;});
А с помощью fix
- и рекурсивные функции. Например, “тождественную”
функцию, разбирающую на части натуральное число и собирающую его
обратно, можно определить так:
run (fix(\natId -> lam(\x ->
natCase (var x)
natZ
(\x1 -> (natS (app (var natId) (var x1)))))))
Просуперкомпилировав это выражение, получаем остаточную программу
\x k->
letrec f = \x0 k0 -> case x0 of {
Z -> k0 Z;
S x1 -> f x1 (\x2 -> k0 (S x2)); }
in f x k
На первый взгляд, выглядит она загадочно, но если запрограммировать тождественную функцию в стиле с продолжениями вручную, то ровно это и получается.
Теперь мы готовы “совершить фигуру высшего пилотажа”: реализовать недетерминированный язык с передачей параметров по значению и выбором при вызове в рамках ленивого языка! Для этого мы смешаем вместе два ингредиента: заставим программу потреблять дополнительный параметр, содержащий последовательность выборов, как это было сделано в послании
но используем в комбинаторах, реализующих недетерминированный язык, стиль с продолжениями. Результат можно увидеть заглянув в задание на суперкомпиляцию Lambda: non-determinism (HOAS, CPS k c, CBV).
Посмотрим, что получается. Сначала определяются типы данных, которые используются в дальнейшем:
data Bool = True | False;
data Choice = L Choice | R Choice;
data Nat = Z | S Nat;
data Pair a b = P a b;
Здесь интересно то, что при реализации выбора по надобности в
определении Choice
нам понадобились три конструтора: L
(левый выбор), R
(правый выбор) и D
(разделение последовательности выборов на две
подпоследовательности). А теперь в определении Choice
- только два
конструктора: L
и R
. Это связано с тем, что при реализации языка с
передачей параметров по значению и выбором при вызове получается, что
последовательность, в которой выполняются вычисления, проста и заранее
предсказуема. Поэтому нет и надобности “разветвлять” последовательность
выборов: можно аккуратно “протаскивать” одну последовательность через
весь процесс вычислений.
Основная идея проста. Пусть у нас есть выражение e
. При вычислении этого
выражения ему должно подсовываться два дополнительных аргумента:
продолжение k
и последовательность выборов c
. Другими словами, хотим
получить значение e
- вычисляем e k c
. При этом, в отличие от случая
детерминированных программ, продолжение k
должно принимать не только
результат, вырабатываемый выражением e
, но ещё и некоторую
последовательность выборов c1
. Т.е., k
должно иметь вид (\v1 c1 -> e1)
.
Откуда же берётся c1
? Дело в том, что если выражение e
содержит
конструкции choice2
, то эти конструкции могут потреблять выборы из
входной последовательности выборов c
. И продолжение должно получить
оставшуюся “неизрасходованную” часть последовательности выборов через
параметр c1
.
Для исполнения недетерминированных программ, определим функцию run
,
возвращающую результат вычисления выражения e
для заданной
последовательности выборов c
:
run = \e с -> e (\v c1 -> v) с;
Эта функция подсовывает выражению e
продолжение \v c1 -> v
и
последовательность выборов c
. Продолжение принимает результат,
возвращаемый выражением e
и выдаёт этот результат. А остаток
последовательности выборов просто игнорирует. Ведь исполнение программы
завершено, и этот остаток больше ни для чего не нужен.
Теперь определим комбинатор cst
предназначенный для вставления констант
в программу:
cst = \a k с -> k a с;
cst
просто передаёт продолжению k
константу a
и неизменённую
последовательность выборов (при вычислении константы выбирать ничего не
приходится). Определение cst
можно немного упростить используя
“eta-редукцию”. Зачем принимать и передавать c
продолжению k
в явном
виде? Просто выдаём k a
, т.е. функцию. И пусть уж она сама и забирает c
!
Те же соображения применимы и к функции run
. Упростив run
и cst
получаем:
run = \e -> e (\v c -> v);
cst = \a k -> k a;
Теперь можно попробовать просуперкомпилировать первое выражение
run (cst True) ⟹ (\c -> True)
Результат получается вполне разумный: для любой последовательности
выборов всегда выдаётся True
.
Теперь “возьмём быка за рога” и определим самую интересную конструкцию -
choice2
(ради реализации которой всё и затевалось):
choice2 = \e1 e2 k c ->
case c of {
L c1 -> e1 k c1;
R c2 -> e2 k c2;};
choice2
должен решить, какое выражение вычислять: e1
или e2
? Для этого и
используется c
. Если c
начинается с L
, то выбираем e1
, а если с R
- то
выбираем e2. После чего передаём остаток последовательности выборов либо
в e1
, либо в e2
.
Теперь пробуем просуперкомпилировать некоторые выражения:
run (choice2 (cst True) (cst False)) ⟹
\c -> case c of { L c1 -> True; R c2 -> False; }
run (choice2 (cst Z) (choice2 (cst (S Z)) (cst (S(S Z))))) ⟹
\c -> case c of {
L c1 -> Z;
R c2 -> case c2 of { L c21 -> S Z; R c22 -> S (S Z); }; }
Теперь определим самые главные комбинаторы - var
, lam
и app
:
var = \x k -> k x;
lam = \f k -> k f;
app = \e1 e2 k -> e1 (\f -> e2 (\v -> f v k));
Выглядит загадочно, но если не применять eta-редукцию и выписать определения в неоптимизированном виде, то получается
var = \x k c -> k x c;
lam = \f k c -> k f c;
app = \e1 e2 k c -> e1 (\f c1 -> e2 (\v c2 -> f v k c2) c1) c;
Поскольку в реализуемом языке параметры передаются по значению, то с
переменными связываются уже “полностью готовые” результаты, а не
выражения, которые ещё нужно вычислять (как в случае передачи по имени).
Т.е., в значениях переменных уже всё вычислено и никакого недетерминизма
уже нет! Поэтому при вычислении var x k c
значение переменной x
в
неизменном виде передаётся продолжению k
. Последовательность выборов c
тоже передаётся в k
без изменения. То же самое происходит и при
вычислении lam f k c
. f
- это готовая функция, с которой ничего делать
не надо.
А вот вычисление app e1 e2 k c
- это уже более интересный случай.
Сначала вычисляется e1
. Ему подсовывается продолжение и
последовательность выборов c
. Продолжение получает результат f
и остаток
последовательности выборов c1
. Затем c1
подсовывается под e2
, и
продолжение получает результат v
и остаток последовательности выборов
c2
. После этого имеется известная функция f
, известный аргумент v
и
остаток последовательности выборов c2
. Вычисляем f v k c2
.
Тонким местом является то, что f
- это не просто функция от одного
аргумента v
, а функция, которая принимает ещё два дополнительных
аргумента: продолжение k
и последовательность выборов c2
!
Теперь, чтобы проверить, что мы не запутались, просуперкомпилируем некоторые выражения:
lam (\x -> var x) ⟹
\k-> k (\x k1-> k1 x)
run (app (lam (\x -> (var x))) (cst True)) ⟹
(\c -> True)
Теперь наступил момент, когда мы можем вернуться к вопросу о том, какой
результат должен получаться в результате вычисления выражения
(\x -> P x x) (choice2 True False)
. Для этого определим комбинатор pairP
,
формирующий пару из своих двух аргументов:
pairP = \e1 e2 k ->
e1 (\v1 -> e2 (\v2 -> k (P v1 v2)));
Здесь просто выписано то, что при вычислении pairP e1 e2
нужно сначала
вычислить e1
, получив v1
, затем - e2
, получив v2
, а затем - выдать
P v1 v2
. Ну, естественно, при этом должны надлежащим образом выполняться
манипуляции с продолжениями и с последовательностью выборов.
Теперь суперкомпилируем
run (app (lam (\x -> pairP (var x) (var x)))
(choice2 (cst True) (cst False))) ⟹
\c -> case c of { L c1 -> P True True; R c2 -> P False False; }
и видим, что, как мы и хотели, возможными результатами вычисления этого
выражения могут быть только P True True
и P False False
. За что
боролись, то и получили.
Теперь, для интереса, напишем недетерминированную программу, генерирующую “произвольное натуральное число”. Для этого определим комбинаторы, работающие с натуральными числами:
natZ = \k -> k Z;
natS = \e k -> e (\v -> k (S v));
natCase = \e eZ eS k -> e (\v -> case v of {
Z -> eZ k;
S n -> eS n k;
});
natZ
возвращает ноль Z
, а natS e
вычисляет e
, получает значение v
и
выдаёт S v
. Теперь мы умеем генерировать натуральные числа!
Но есть одна тонкость: конструкция choice2
умеет делать выбор только из
двух вариантов, а при генерации “произвольного” числа нужно делать
выбор из счётного множества вариантов. Понятно, что без рекурсии нам не
обойтись. Поэтому определим ещё и конструкцию fix
, вычисляющую
“неподвижную точку”:
fix = \e k -> k (fixLoop e);
fixLoop = \e x k -> e(fixLoop e)(\f c1 -> f x k c1);
При первом взгляде на такое определение у всякого нормального человека начинается “расплавление мозгов”. Но если не отступаться, то через некоторое время наступает “просветление”, и смысл этого определения становится понятен. Поэтому я не буду пытаться растолковать это определение с помощью человеческих слов… Кому суждено понять - тот поймёт сам. А кому не суждено - тому будет непонятно и словесное объяснение.
А теперь, имея в руках возможность определять рекурсивные функции, запишем выражение, выдающее “произвольное” натуральное число. Это можно сделать разными способами, но, раз уж мы реализовали “строгий” язык с передачей параметров по значению (ну, прямо почти C++ :-) ), то и решим задачу в “императивном” стиле.
Заведём переменную и присвоим ей 0. А дальше будем крутить цикл и на каждом шаге решать: остановиться или продолжать. Если останавливаемся, то выдаём значение переменной в качестве результата. Если решаем не останавливаться, то прибавляем к переменной 1 и крутим цикл дальше.
В функциональных понятиях это можно переформулировать так. Определим функцию
f x = choice2 x (f (S x));
и вычислим выражение
f Z
Теперь расписываем всё это в виде имеющихся у нас комбинаторов.
Определение функции f
принимает вид
fix (\f -> lam (\x -> choice2 (var x) (app (var f)(natS (var x)))))
и теперь нужно применить эту функцию к аргументу Z
и запустить
получившееся выражение с помощью функции run
. Для большей
удобочитаемости результата, передадим функции run
ещё и
последовательность выборов c
. Получается такое выражение:
run (app (fix (\f -> lam (\x -> choice2 (var x) (app (var f)(natS (var x)))))) natZ) c
Суперкомпилируем его и получаем:
letrec g = \v c1 -> case c1 of {
L c11 -> v;
R c12 -> g (S v) c12; }
in g Z c
Ну что же, суперкомпилятор Хоск показал себя молодцом! В остаточной программе от комбинаторов ничего не осталось…