Язык Prolog создавался для задач искусственного интеллекта, который сейчас обычно называют «классическим», чтобы не путать с машинным обучением путем подбора большого количества числовых параметров. Важным классом таких задач является моделирование «мира», в котором можно совершать какие-либо действия. Игрушечным примером такого мира является Nani Search. И решают их часто в таком стиле: состояние мира помещается в прологовскую базу данных и все изменения производятся путем удаления и добавления фактов в это хранилище. Но это получается уже не логическое программирование, а самое настоящее императивное! При этом используются худшие практики программирования — глобальное состояние! Мимо этого я пройти не могу!
Но самое плохое в таком подходе не стиль, в конце концов большая часть современного кода императивна, и даже частенько использует, явно или неявно, глобальные переменные. Важно что состояние мира перестает быть first-order value и пропадает возможность решать задачи в моделируемом мире, для чего и создавался язык Prolog.
Такие решения сравнительно удобно программируются на Prolog, и естественное желание это удобство сохранить. Напрашивается написать интерпретатор Prolog, такой, что его состояние хранится в переменной, чем мы сейчас и займемся.
Prolog — язык, претендующий на гомоиконичность, и мы этим воспользуется. Синтаксически предикаты пролога совпадают с термами, а база данных является по сути списком термов, в которой при выполнении программы ищутся термы, соответствующие проверяемым предикатам.
К сожалению, прологовская база данных устроена не слишком логично. Простому предикату p(x) может соответствовать просто запись p(x), а может и вычислимое правило p(X) :- X = Y, Y = x.. Такое правило также представлено термом: ‘:-‘(p(X), ‘,'(‘='(X,Y), ‘='(Y,x))), То есть терм ‘:-‘ является особенным — все термы равноправны, но некоторые равноправнее. Я решил не копировать эту особенноcть, и хранить все определения в виде списка, голова которого будет определяемый предикат, а хвост — список предикатов, обединяемых через AND. Приведенные выше определения p будут записаны как [p(x)] и [p(X), ‘='(X, Y), ‘='(Y,x)]. В родном прологовском ‘:-‘ тело предиката может хранится в виде одного терма-предиката или тупла (‘,’) термов-предикатов. Но в Prolog нет пустого тупла и туплов из одного элемента. Поэтому я отказался от использования туплов во внутреннем представлении в пользу более универсальных списков.
Важной концепцией пролога являются неопределенные переменные. В частности, связывание этих переменных со значениями или другими переменными используется для протаскивания агрументов предиката в его тело. Заманчиво было бы использовать такие переменные в представлении программы и в базе данных интерпретатора, но это приведет к неожиданным последствиям. Допустим, в базе данных есть определения f(X) :- g(X). g(a). g(b)., и проверяется пара предикатов f(a), f(b).. В Prolog эта проверка пройдет, но что будет с интерпретатором, использующим наивное внутреннее представление? При проверке f(a) переменная X будет связана с атомом ‘a’, проверка g(a) пройдет успешно, но в базе данных запись об f превратится в f(a) :- g(a). и пропытка проверить f(b) провалится. По этому в нашей базе данных придется хранить шаблоны определений, в которые подставлять при проверке новые неопределенные переменные. Имена переменных можно задавать уникальными в пределах определения атомами, список которых будет храниться рядом с определением.
mkfree([], []). mkfree([V|T], [(V,_)|TF]) :- mkfree(T, TF). subst(F, V, O) :- atom(V) -> ( member((V, R), F) -> O=R ; O=V ) ; ( V =.. [P | A], maplist(subst(F), A, N), O =.. [P | N] ) . setfree((N, V), O) :- mkfree(N, F), maplist(subst(F), V, O).
Предикат mkfree первым параметром получает список «имен» новых переменных, а во втором возвращает список пар — имя, новая переменная.
subst подставляет в терм вместо атомов-имен что-то другое, в данном случае новосозданные переменные. В нем используется предикат ‘=..’ который превращает терм в список (и обратно, как и положено «хорошим» прологовским предикатам). Например a(x,y) =.. [a, x, y]. Интересен предикат «высшего порядка» maplist, получающий первым параметром терм, конвертируемый в вызов предиката добавлением двух параметров. Пока такие предикаты в этом интерпретаторе не поддержаны. Возможно, с целью самоприменимости я избавлюсь от него в следующих версиях.
setfree собственно объединяет mkfree и subst, применяя последний к представлению определения в виде списка (возможность использовать готовый maplist — еще один аргумент в пользу использования списка, а не тупла).
Сам интепретатор разобьем на два предиката: check — для проверки/выполнения списка предикатов, связанных общими переменными, и doio, реализующий встроенные предикаты, в том числе ввод/вывод. Предикаты также получают два дополнительных параметра — исходное и конечное состояние базы данных.
doio(DB, writeln(X), DB) :- writeln(X). check([], DB, DB). check([P|R], DB, DBO) :- (doio(DB, P, DBN) -> true ; (member(D, DB), setfree(D, [P | B]), check(B, DB, DBN))), check(R, DBN, DBO).
check пытается выполнить сначала встроеный предикат, а если это не получилось, то ищет его в базе данных. Это можно было бы реализовать с использованием «отсечения», но я не люблю этот прием (как слишком императивный) и реализовать его в интерпретаторе нетривиально (а я все еще думаю о самоприменимости). Вместо отсечения я использую конструкцию P -> T ; E, которая реализуется сравнительно просто.
doio(DB, (P -> T ; F), NDB) :- check(P, DB, TDB) -> check(T, TDB, NDB) ; check(F, DB, NDB).
Здесь стоит обратить внимание, что в этой конструкции в Prolog каждый предикат может быть туплом предикатов. Так как я отказался от туплов в пользу списков, у меня даже единственный предикат должен быть завернут в список.
?- check([[a(x)] -> [writeln(a)] ; [writeln(b)]], [([], [a(x)])], _). a true.
Для работы игровых миров требуются предикаты, модифицирующие базу данных. Элементарный вариант предиката asserta почти тривиален.
doio(DB, asserta(P), [([],[P])|DB]).
Он позволяет добавлять в базу только простые факты. В приведенных выше примерах моделируемых миров этого достаточно. Если требуется полноценная реализация, придется повозиться.
atoms(T, A) :- atom(T) -> A=[T] ; var(T) -> A=[] ; T =.. [_ | L], maplist(atoms, L, LL), append(LL, A). nextatom(D, A, N) :- name(A, M), append(M, [97], NM), name(NP, NM), (member(NP, D) -> nextatom(D, NP, N) ; N = NP). fillfree(D, V, (A, L), (AN, LN)) :- (var(V) -> (nextatom(D, A, AN), V = A, LN = [A | L]) ; atom(V) -> AN=A, LN=L ; V =.. [_ | F], foldl(fillfree(D), F, (A, L), (AN, LN))). fillfree(V, VO, L) :- atoms(V, A), nextatom(A, w, I), findall((V, L1), fillfree(A, V, (I,[]), (_,L1)), [(VO, L)]). compile(P, I) :- (P = (H, T)) -> compile(H, I1), compile(T, I2), append(I1, I2, I) ; (P = (C -> T ; E)) -> compile(C, IC), compile(T, IT), compile(E, IE), I = [IC -> IT ; IE]; I = [P]. compileDef(P, I) :- (P = (D :- C)) -> compile(C, IC), I = [D | IC]; I = [P]. doio(DB, asserta(P), [(V,DO)|DB]) :- def2list(P, D), fillfree(D, DO, V).
Здесь нетривиальны предикаты fillfree/4 и fillfree/3.
fillfree/4 ищет несвязанные переменные (для них выполняется предикат var) и связывет их с уникальными атомами. Если в иходном терме переменная встречается более одного раза, то она будет установлена только первый раз, а при последующих проверках var сочтет ее связянной. На выходе будет получен исходный терм, в котором все переменные связяны с атомами-именами, и список этих имен, для последующей передачи в setfree. Но остается небольшая проблема — если переменная в терме используется еще где-то в интерпретируемой программе, она там тоже будет связяна с сгенерированным атомом. Что бы их развязать fillfree/3 вызывает fillfree/4 из findall, который агреггирует все возможные решения fillfree/4 забывая при этом произошедшие связывания.
Использование findall — еще одино препятствие к самоприметимости интерпретатора. Вызов findall можно было бы заменить на вызов setfree, который заменит атомы на новосозданные переменные и еще один вызов fillfree/4, который создаст терм не зависящий от внешних переменных.
Для реализации retract тоже приходится повозиться с несвязанными переменными.
deleteall(_, [], []). deleteall(P, [E | L], O) :- (P = E -> fail ; deleteall(P, L, O1), O = [E | O1]) -> true ; deleteall(P, L, O). doio(DB, retract(P), DBO) :- def2list(P, D), deleteall((_,D), DB, DBO).
Здесь используются две вложенные конструкции P -> T ; E. Внутренняя проверяет что запись в базе данных соотвествует шаблону и фейлится, чтобы забыть результаты унификации, внешняя обрабатывает эту ситуацию и удаляет найденый предикат.
У этих реализаций assert и retract появляется интересное отличие от стандартной реализации — «транзакционность». Если какой-либо предикат модифицирует базу данных, а потом фейлится, то в обычном Prolog изменения сохраняются, а в этом интерпретаторе — откатываются. Хотя это и может оказаться удобным в задачах моделирования миров, теряется возможность отладки таких моделей в стандартной реализации Prolog с последующим переносом в интерпретатор.
Nani search — сравнительно сложный мир. Попробуем смоделировать мир попроще, с козлом/carba, волком/lobo и капустой/repollo, которых надо переправить на лодке/barco через реку. (Для большей абстрактости названия взяты из испанского).
:- dynamic(barco/1). :- dynamic(esta/2). barco(izquierda). esta(carba, izquierda). esta(lobo, izquierda). esta(repollo, izquierda). costados(izquierda, derecha). costados(derecha, izquierda). comer(lobo, repollo). comer(repollo, lobo). movimiento(X) :- barco(L), esta(X, L), costados(L, N), retract(esta(X, L)), asserta(esta(X, N)), retract(barco(L)), asserta(barco(N)). paso(carba) :- movimiento(carba). paso(X) :- comer(X, Y), esta(carba, L), costados(L, O), esta(Y, O), movimiento(X). paso(none) :- esta(carba, L), costados(L, O), esta(lobo, O), esta(repollo, O), barco(X), costados(X, N), retract(barco(X)), asserta(barco(N)).
Перемещения делаются командами paso(X), где X это carba, lobo, repollo или none.
Перепишим этот код один к одному в представление интепрететора и попытаемся решить задачу автоматически.
solve(X, R) :- X = [paso(_), paso(_), paso(_), paso(_), paso(_), paso(_), paso(_), esta(repollo, derecha), esta(lobo, derecha), esta(carba, derecha)], PROG = [([], [barco(izquierda)]), ([], [esta(carba, izquierda)]), ([], [esta(lobo, izquierda)]), ([], [esta(repollo, izquierda)]), ([], [costados(izquierda, derecha)]), ([], [costados(derecha, izquierda)]), ([], [comer(lobo, repollo)]), ([], [comer(repollo, lobo)]), ([x, l, n], [movimiento(x), barco(l), esta(x, l), costados(l, n), retract(esta(x, l)), retract(barco(l)), asserta(esta(x, n)), asserta(barco(n))]), ([], [paso(carba), movimiento(carba)]), ([a, b, l, o], [paso(a), comer(a, b), esta(carba, l), costados(l, o), esta(b, o), movimiento(a)]), ([l, o, x, n], [paso(none), esta(carba, l), costados(l, o), esta(lobo, o), esta(repollo, o), barco(x), costados(x, n), retract(barco(x)), asserta(barco(n))])], check(X, PROG, R).
Prolog реализует алгоритм поиска «вглубь», который может зациклиться на бесконечных структурах. Поэтому мы делаем небольшую подсказку, что ищется решение ровно из семи шагов. В моих планах переделать интерпретатор на поиск «вширь», но это не слишком просто.
?- solve(P, D). P = [paso(carba), paso(none), paso(lobo), paso(carba), paso(repollo), paso(none), paso(carba), esta(repollo, derecha), esta(..., ...)|...], D = [([], [barco(derecha)]), ([], [esta(carba, derecha)]), ([], [esta(repollo, derecha)]), ([], [esta(lobo, derecha)]), ([], [costados(izquierda, derecha)]), ([], [costados(derecha, izquierda)]), ([], [comer(..., ...)]), ([], [...]), (..., ...)|...] ; P = [paso(carba), paso(none), paso(repollo), paso(carba), paso(lobo), paso(none), paso(carba), esta(repollo, derecha), esta(..., ...)|...], D = [([], [barco(derecha)]), ([], [esta(carba, derecha)]), ([], [esta(lobo, derecha)]), ([], [esta(repollo, derecha)]), ([], [costados(izquierda, derecha)]), ([], [costados(derecha, izquierda)]), ([], [comer(..., ...)]), ([], [...]), (..., ...)|...] ; false.
Как видно, нашлось два решения, подходящие под это условие.
Если воспользоваться «транзакционностью» нашей базы данных, то код упрощается — теперь не нужно проверять громоздкие предусловия возможности хода, а можно сделать ход и проверить сравнительно простое постусловие.
solve(X, R) :- X = [paso(_), paso(_), paso(_), paso(_), paso(_), paso(_), paso(_), esta(repollo, derecha), esta(lobo, derecha), esta(carba, derecha)], PROG = [([], [barco(izquierda)]), ([], [esta(carba, izquierda)]), ([], [esta(lobo, izquierda)]), ([], [esta(repollo, izquierda)]), ([], [costados(izquierda, derecha)]), ([], [costados(derecha, izquierda)]), ([x, l], [seguro(x), esta(carba, l), esta(x, l), barco(l)]), ([x, l, o], [seguro(x), esta(carba, l), esta(x, o), costados(l, o)]), ([], [seguro, seguro(lobo), seguro(repollo)]), ([x, l, n], [paso(x), barco(l), esta(x, l), costados(l, n), retract(esta(x, l)), retract(barco(l)), asserta(esta(x, n)), asserta(barco(n)), seguro]), ([l, n], [paso(none), barco(l), costados(l, n), retract(barco(l)), asserta(barco(n)), seguro])], check(X, PROG, R).
Где все это можно примерить? Один из вариантов — прототипирование новых прологоподобных языков. С помощью этого можно моделировать модальности (если не хочется осваивать что-нибудь сложное и экзотическое, типа Modal Prolog). Свой интерпретатор также понадибится при реализации методов генерации программ с помощью машинного обучения, таких как Индуктивное логическое программирование (спасибо @robofreakза статью в Википедии).
В заключение выражаю благодорность @usr345за подброшенный мне Nani Search.
Полные исходники можно взять на github — там можно посмотреть код в боевой расскраске, которую для Prolog здешний редактор не поддерживает.
Картинка для привлечения внимания была взята из мира Nani Search и адаптирована под мир капусты, козла и волка.
ссылка на оригинал статьи https://habr.com/ru/post/693880/
Добавить комментарий