Robinsonova aritmetika
Nyní si zkusíme v Prologu sepsat část Robinsonovy aritmetiky a budeme sledovat, jak se s ní Prolog vypořádá. Nejprve však započneme pravidlem říkajícím, co je platné číslo naší aritmetiky.
% num(X) :- X je 0 nebo X je naslednikem jineho cisla. num(0). num(s(X)) :- num(X).
Přirozené číslo N bude v této teorii reprezentované nulou vnořenou N-krát do
s
. Zkusme se nyní dotazovat na náš predikát num/1
a sledovat, co se stane
vložením proměnné do prvního parametru.
?- num(s(s(s(0)))). true. ?- num(X). X = 0 ; X = s(0) ; X = s(s(0)) ; X = s(s(s(0))) .
Jak si můžeme všimnout, dotaz na num(X)
bude postupně odpovídat každým
přirozeným číslem počínaje nulou. Vychází to přímo z definice predikátu, protože
vždy máme možnost se buď zastavit a vrátit nulu, nebo obalit proměnnou do
dalšího s
a zarekurzit se.
Nyní můžeme začít porovnávat. Začneme predikátem leq(X, Y)
značícím, že X ≤
Y
. Víme, že 0 ≤ Y
pro libovolné číslo Y
. Jestliže navíc X ≤ Y
, pak s(X)
≤ s(Y)
. Jiná možnost je však říct, že X ≤ X
pro libovolné číslo X
a pak X
≤ s(Y)
, jestliže X ≤ Y
. Tuto možnost zapíšeme predikátem leq2/2
.
% leq(X, Y) :- X ≤ Y. leq(0, X) :- num(X). leq(s(X), s(Y)) :- leq(X, Y). leq2(X, X) :- num(X). leq2(X, s(Y)) :- leq2(X, Y).
Cvičení: Zapište pravidla pro lt/2
značící relaci <
a pomocí již
existujících pravidel zapište pravidla geq/2
a gt/2
.
Zkusme nyní položit dotazy na naše vytvořené porovnávací pravidla a zkoumat, v čem se liší.
?- leq(s(s(0)), s(s(s(s(0))))). true. ?- leq2(s(s(0)), s(s(s(s(0))))). true ; false. ?- leq(s(s(X)), s(s(s(s(0))))). X = 0 ; X = s(0) ; X = s(s(0)) ; false. ?- leq2(s(s(X)), s(s(s(s(0))))). X = s(s(0)) ; X = s(0) ; X = 0 ; false.
Jak vidíme, na dotazy dostáváme stejné odpovědi, akorát obě varianty vracejí
výsledky v jiném pořadí. Predikát leq2/2
navíc kromě true
vrací i false
.
To proto, že po nalezení X = Y
najde splňující řešení, ale podle druhé
varianty může stále zkracovat Y
, než dojde k Y = 0
a rekurze selže.
V Prologu tedy záleží na pořadí a způsobu zapsání predikátů. I maličké změny v definici mohou způsobit, že se při vyhodnocování změní složitost výpočtu, nebo se dokonce Prolog zacyklí.
Nejlépe můžeme tento rozdíl vidět, když v dotazu budou oba parametry
porovnávání budou proměnné. Zatímco leq(X, Y)
bude do nekonečna vracet X =
0
a Y
zvětšovat, leq2(X, Y)
vždy zvětší X
i Y
najednou.
Cvičení: Zkuste napsat dotaz, který postupně vrátí všechny
dvojice X
a Y
takové, že X ≤ Y
a X, Y ≤ Z
. Dotaz by se neměl zacyklit.
Ladění v Prologu
SWI-Prolog má k dispozici rozhraní na ladění dotazů. Nejčastěji budeme používat jeho grafickou variantu, ve které lze vidět aktuální stav zásobníku, kontext ve zdrojovém kódu a unifikaci proměnných.
Toto ladění se spouští zadáním dotazů guitracer.
následovaným trace.
.
Prompt Prologu se při zapnutí ladění změní na [trace] ?-
a po zadání dotazu
se otevře ladící okno.
Ladič se ovládá přes tlačítka nahoře (jejichž význam lze přečíst najetím ukazatele) nebo jejich klávesové zkratky. Podobně jako u jiných ladičů máme k dispozici krokování a breakpointy. Jakmile je dotaz splněn, výsledek se normálně vypíše do příkazové řádky, která očekává, zda chceme pokračovat. Ladící okno v tuto chvíli nereaguje.
Od porovnávání se přesuneme ke sčítání. Predikát add(X, Y, Z)
bude splněn,
jestliže X + Y = Z
. Víme, že 0 + Y = Y
a jestliže zvětšíme X
o jedna,
zvětší se o jedna i Z
.
add(0, X, X) :- num(X). add(s(X), Y, s(Z)) :- add(X, Y, Z).
Jestliže v dotazu na add(X, Y, Z)
dosadíme za X
i Y
, dostaneme právě
jeden výsledek, a to jejich součet Z
. Pokud zafixujeme pouze jeden sčítanec,
Prolog bude postupně generovat druhý sčítanec a jeho součet přičítáním o 1.
Naopak, zafixováním Z
Prolog postupně vygeneruje všechny možnosti sčítanců.
Cvičení: Napište predikát sub(X, Y, Z)
splnitelný, jestliže X - Y = Z
.
Nakonec si zkusíme zapsat násobení. Tentokrát 0 krát cokoliv bude 0 a přičtením k prvnímu činiteli se nám zvětší součin o druhý činitel. Stejným postupem jako u sčítání tedy dojdeme k tomuto:
mul(0, Y, 0) :- num(Y). mul(s(X), Y, Z) :- mul(X, Y, Z2), add(Y, Z2, Z).
Tento predikát má však háček. Jestliže se dotážeme na mul(X, Y, Z)
, kde X
i
Y
jsou dosazená čísla, vše je v pořádku a dostaneme odpověď. Když zafixujeme
Y
a necháme X
a Z
volné, vygenerované Z
budou postupně všechny násobky
Y
. Bohužel, jestliže necháme zafixovat Z
a necháme X
nebo Y
volné,
dostaneme nejvýše jednu variantu, pak se výpočet zacyklí.
Kde se schovává problém? Uvažme dotaz mul(X, s(s(0)), s(s(s(0)))).
, který se
zacyklí. V druhém pravidle voláme mul/3
s volným X
a Z2
a Prolog
najde z tohoto volání nekonečno variant X
a Z2
libovolně velkých. Tudíž nic
Prolog nezastavuje zvyšovat X
do nekonečna, ale žádné X > Z
nesplní add
.
Notace parametrů
Z tohoto důvodu budeme u některých predikátů chtít omezovat, jaké parametry mohou dostat. K tomuto účelu si zavedeme notaci pro parametry v definici predikátu, kterou též používá SWI-Prolog ve své dokumentaci. Podle toho, jakým způsobem můžeme daný parametr použít, před tento parametr přidáme:
++
pro parametry, které nesmí obsahovat proměnnou,+
pro vstupní parametry; v době zavolání predikátu musí být parametr dosazený term splňující námi specifikovanou vlastnost,?
pro parametry, které musí být částečné termy hledaného typu; proměnná je vždy částečný term (tyto parametry tedy mohou být vstup i výstup),@
pro parametry, které dále neupravujeme (typicky jen přeposíláme dál),-
pro výstupní parametry; nemusí to nutně být proměnná, můžeme částečně rozbalit strukturu,--
pro parametry, které musí být proměnné.
Podle této notace tedy v Robinsonově aritmetice zapíšeme definice naších predikátů takto:
num(?N) :- N je číslo (0 nebo následník čísla). leq(?X, ?Y) :- X ≤ Y. lt(?X, ?Y) :- X < Y. add(?X, ?Y, ?Z) :- X + Y = Z. mul(?X, ?Y, -Z) :- X × Y = Z.
V takové notaci je pak dotaz mul(s(s(0)), Y, s(Z))
dobrý, avšak mul(s(X), Y,
s(s(s(s(0))))).
je nesprávné použití predikátu.
Cvičení: Zkuste napsat predikát factor(-X, -Y, +Z)
, který pro Z
najde
X
a Y
taková, že Z = X × Y
. Stačí, když predikát bude fungovat pro volná
X, Y
.
Jestliže si chcete přečíst, jak zapsat násobení tak, aby se vždy zastavilo a fungovalo všemi směry, můžete si o tom přečíst v tomto článku.
Cvičení: Naprogramujte predikát half(?X, ?Y) :- X je polovina Y
a
even(?X) :- X je sudé
.
Seznamy v Prologu
Prolog má jako jazyk podporu pro seznamy. Jedná se však o jednosměrné spojové seznamy, ve kterých máme rychlý přístup pouze k prvním prvkům.
Seznamy se zapisují pomocí nezáporného počtu termů oddělených čárkou uvnitř
hranatých závorek. Do seznamu můžeme kdykoliv přidat svislítko |
, které nám
rozdělí seznam na dvě části. První část je seznam všech prvků nalevo, druhá
část je pak zbytek seznamu.
Pojďme se podívat, jak se seznamy chovají a jak lze využít tato pravidla.
?- [a,b,c] = [a|[b|[c|[]]]]. true. ?- [a,b,c] = [X|Zbytek]. X = a, Zbytek = [b, c]. ?- [a,b,c] = [X, b|Y]. X = a, Y = [c]. ?- L = [a,b | X], X = [c,a]. L = [a, b, c, a], X = [c, a]. ?- [a,b,c] = [a,b,c|X]. X = []. ?- [a,b,c] = [b|X]. false. ?- [] = [X|Y]. false. ?- [a] = [X,Y|Z]. false. ?- L = [a|X], X = b. L = [a|b].
Tato definice seznamu nám tudíž umožňuje vytvořit částečné seznamy, kde zbytek seznamu je proměnná. Za tu pak můžeme dosadit seznam pomocí jiného predikátu. Tuto vlastnost budeme používat velmi často.
Nic nám též nebrání za zbytek seznamu dosadit něco jiného, než je seznam, ale pak s takovým "podivným seznamem" nebude většina obecných predikátů fungovat.
Celý systém seznamů je pouze syntaktická zkratka. Jestli bychom chtěli, mohli
bychom si vytvořit "vlastní" seznamy. Za prázdný seznam []
si zvolíme atom
l
a konstrukci [X | Xs]
přeložíme na l(X, Xs)
. Jediné, co neumíme říci
přímo, je jednoduchý zápis více prvků za sebou.
Z našeho postupu pak jednoduše vyplývá, jak převádět Prologovský seznam na ten náš.
% nas_seznam(?PL, ?NL) :- NL je naše reprezentace seznamu PL. nas_seznam([], l). nas_seznam([X|Xs], l(X, Ys)) :- nas_seznam(Xs, Ys).
Napišme si nyní predikát prvek(X, L)
, který je splnitelný, jestliže seznam
L
obsahuje prvek X
. Ukážeme si i variantu pro naši reprezentaci seznamu.
% prvek(?X, ?L) :- L je seznam obsahující prvek X. prvek(X, [X|_]). prvek(X, [_|L]) :- prvek(X, L). prvek_nas(X, l(X, _)). prvek_nas(X, l(_, L)) :- prvek_nas(X, L).
Všimněte si použitého výrazu _
. Ten říká, že nás daný parametr nezajímá a
Prolog za něj může dosadit cokoliv. Dosazení za tento parametr pak Prolog
neudržuje.
Tento predikát má Prologu ekvivalent member/2
. Otestujme teď, jak se náš
predikát chová.
?- prvek(b, [a, b, c]). true ; false. ?- prvek(X, [a, b, c]). X = a ; X = b ; X = c ; false. ?- prvek(_, []). false. ?- nas_seznam([a, b, c], L), prvek_nas(b, L). L = l(a, l(b, l(c, l))) . ?- prvek(a, L). L = [a|_2692] ; L = [_2690, a|_2698] ; L = [_2690, _2696, a|_2704].
Co se stalo u posledního dotazu? V odpovědi se objevily nové volné proměnné,
pojmenované podtržítkem a číslem. Prolog totiž našel seznam L
takový, že
pokud za tyto proměnné dosadíme cokoliv, formuli jistě splníme.
U této odpovědi to speciálně znamená, že když zvolíme libovolné prvky před a
a libovolný zbytek seznamu, pak L
stále bude obsahovat a
. Tudíž můžeme
generovat seznamy, které jistě budou obsahovat náš prvek, ale zbytek obsahu nás
nezajímá.
Cvičení: Napište predikáty lichy(?L)
a sudy(?L)
splnitelné, jestliže
seznam L
má lichou nebo sudou délku.
Už umíme v seznamu hledat, pojďme začít s ním manipulovat. Začneme přidáváním prvku na začátek seznamu, které zvládneme velmi jednoduše.
% pridej_zacatek(?P, ?L, ?PL) :- PL je seznam L navíc s P na začátku. pridej_zacatek(P, L, [P|L]).
Nyní napíšeme predikát, který naopak přidá prvek na konec seznamu. Ten už bude muset být rekurzivní, protože se nejprve budeme muset dostat ke konci (spojového) seznamu. Jakmile ale už máme v ruce nový seznam s prvkem na konci, rekurzí jej doplníme o zbytek prvků původního seznamu. Přidávání na konec seznamu bude tedy na rozdíl od přidávání na začátek trvat alespoň lineární čas.
% pridej_konec(?P, ?L, ?PL) :- PL je seznam L navíc s P na konci. pridej_konec(X, [], [X]). pridej_konec(X, [L|Ls], [L|PLs]) :- pridej_konec(X, Ls, PLs).
Cvičení: Napište predikát liche_prvky(?L, ?OL)
:- splnitelný, jestliže
seznam OL
vznikne ze seznamu L
vypuštěním každého druhého prvku. Dotaz
liche_prvky([a, b, c], [a, c])
je pravdivý.
Predikát pridej_konec/3
lze zobecnit na spojování dvou seznamů jednoduchou
úpravou základního případu. Tento predikát též najdete jako standardní
append/3
.
% spoj(?A, ?B, ?L) :- L je seznam vzniklý spojením A a B. spoj([], X, X). spoj([A|As], X, [A|Ls]) :- spoj(As, X, Ls).
Cvičení: Napište predikáty pridej_zacatek/3
a pridej_konec/3
pomocí
predikátu spoj/3
.
Cvičení: Napište predikát prefix(?P, ?L)
splnitelný, jestliže seznam P
je prefixem seznamu L
.
Nyní, když umíme přidávat prvky na začátek i na konec seznamu, zkusme napsat
predikát otoc(Seznam,Manzes)
, který obrátí pořadí prvků v seznamu.
Budeme tedy postupovat přímočaře. Vždy vezmeme první prvek Seznam
u a přidáme
jej na konec Manzes
u, prázdný seznam se neotáčí.
% otoc(?L, ?LR) :- LR má pořadí prvků obráceně oproti L. otoc([], []). otoc([X | Ls], LR) :- otoc(Ls, LRs), pridej_konec(X, LRs, LR).
Tento predikát je jistě korektní, ale není vůbec efektivní. Každé zavolání
pridej_konec/3
trvá lineárně dlouho, celkově tedy strávíme kvadratický čas.
Cvičení: Naprogramujte predikát suffix(?S, ?L)
splnitelný, jestliže seznam S
je suffixem seznamu L
.
Cvičení: Naprogramujte predikát prostredni(?L, ?P)
splnitelný, jestliže
P
je prostřední prvek seznamu L
. V případě seznamu sudé délky je prostřední
prvek ten bližší hlavě.
Akumulátory
Zatím jsme ve všech rekurzivních predikátech postupně zmenšovali seznamy, které do nich šly jako parametry. V mnoha případech je však mnohem efektivnější mít seznam, který se s hloubkou rekurze zvětšuje, a na konci jej celý vybublat ven.
Takový seznam je příkladem akumulátoru -- objektu, který v průběhu rekurze upravujeme a jeho konečnou podobu v základním případě pak vytáhneme ven.
Ukážeme si techniku použití akumulátoru na otáčení seznamu:
% otoc_acc(?L, ?LR) :- otoc/2 s použitím akumulátoru. otoc_acc(L, LR) :- otoc(L, [], LR). % otoc(?L, @A, ?LR) % Jestliže je seznam prázdný, vrať akumulátor otoc([], A, A). % Odebereme první prvek seznamu a vložíme jej do akumulátoru otoc([X|Ls], A, LR) :- otoc(Ls, [X|A], LR).
Jak vidíme, v každém kroku rekurze otoc/3
odebereme první prvek ze seznamu
L
, vložíme jej na začátek A
a pokračujeme dále. Druhý prvek seznamu, pokud
existuje, se v následujícím kroku rekurze dostane do A
před ten první.
Jakmile nám dojdou prvky, prohlásíme v základním případě akumulátor, obsahující
prvky pozpátku, za výsledný seznam LR
.
Nejprve však ještě musíme inicializovat A
na prázdný seznam, k tomu slouží
predikát otoc_acc/2
. Kdybychom se rozhodli, že A
začneme jako neprázdný
seznam, jako výsledek dostaneme otočený seznam L
, za kterým dostaneme původní
prvky A
.