Prolog: Gramatike (DCG)

Kako se ljudje sporazumevamo med seboj? Z jeziki, seveda. No, saj obstajajo tudi drugi načini komunikacije, a je vendarle jezik tisti osrednji, najpogostejši, način. In kako se sporazumevamo z računalnikom, kako mu razložimo, kaj naj naredi? Prav tako z jeziki, pa čeprav programskimi. Saj tako velike razlike med naravnimi in programskimi jeziki niti ni, le da so slednji bolj formalizirani, bolj natančni, bi lahko rekli. In prav vsi jeziki temeljijo na slovnici oz. gramatiki. Ta natančno opredeli, katere besede (včasih jim pravimo tudi stavki) so slovnično pravilne (so elementi jezika) in katere niso (niso elementi danega jezika).

Gramatiko jezika navadno zapišemo v obliki množice produkcij oz. preslikovalnih pravil. Naredimo to kar na primeru. Imejmo naslednjo množico (dveh) pravil:

s --> [].           % pravilo #1
s --> [a,a], s.     % pravilo #2

Medklic: Pravila načeloma niso videti kot prologov program, a v resnici jih prolog razume in interno pretvori v kodo, kot si je vajen. A danes se v to ne bomo spuščali, ker niti ni pomembno.

Poglejmo si sestavne dele enega pravila in kako ga razumemo ter uporabljamo. Za primer vzemimo pravilo #2 zgoraj. Puščica (-->) nam pravilo razdeli na levi in desni del. Pomeni pa naslednje: simbol na levi strani preslika v to, kar je na desni. Torej naše konkretno pravilo pomeni „zamenjaj simbol s s seznamom [a,a] in simbolom s“. Na enak način pravilo #1 pomeni „zamenjaj simbol s s praznim seznamom“.

Zakaj so nekatere stvari pisane v seznamih in nekatere ne? Razlika je zelo pomembna: tistemu, kar je pisano v seznamih (npr. [a,a]), bomo rekli končni simboli ali terminali, simbolom izven seznamov (npr. s) pa nekončni simboli oz. neterminali. Nekončne simbole gramatike moramo vedno preslikati še naprej, medtem ko končnih simbolov ne moremo več preslikati naprej – od tu tudi njihovo poimenovanje. Ko si nekoč v šoli delal stavčno analizo pri pouku slovenščine, si stavek verjetno razdelil na samostalnik, glagol in podobne sintaktične elemente – to so nekončni simboli. Končni simboli pa so konkretni primeri teh elementov: npr. glagol „programirati“.

DCG je le en izmed možnih zapisov gramatike. Na levi strani pravil v DCG se vedno pojavlja en in samo en nekončni simbol. Na desni strani je lahko poljubno zaporedje končnih in nekončnih simbolov, ločenih z vejicami. Slednje pomenijo konjunkcijo, kot smo že vajeni. Pravil je lahko poljubno mnogo – katero bomo uporabili v nekem konkretnem primeru, je stvar izbire. Ko smo že pri konjunkciji: podpičje tudi tu pomeni disjunkcijo. Zgornji dve pravili bi lahko zapisali tudi takole:

s --> [] ; [a,a], s.

Oziroma v duhu drugih jezikov za opis gramatik, kot je npr. BNF, tudi takole:

s --> [] | [a,a], s.

Skratka tudi navpičnica pomeni disjunkcijo: neterminal s se lahko preslika ali v prazen seznam ali pa v seznam dveh terminalov a in v neterminal s.

Prav, sedaj poznamo obliko zapisa. Kaj pa lahko s tem počnemo oz. kakšno povezavo ima to z jeziki? Pri vsaki gramatiki imamo tipično določen tudi nek začetni nekončni simbol, pri našem primeru bo to kar simbol s, ki je tudi edini nekončni simbol. Izpeljavo poljubne besede oz. stavka v jeziku začnemo s tem simbolom in na njem v poljubnem redu uporabljamo preslikovalna pravilo dokler ne dobimo rezultata sestavljenega samo iz končnih simbolov. Poglejmo nekaj primerov.

Primer 1: s --(2)--> aas --(1)--> aa

Za lažji prikaz so odebeljeno prikazani nekončni simboli, z normalno pisavo pa končni simboli. V prvem primeru na začetnem simbolu najprej uporabimo pravilo #2 in dobimo aas, nato pa na tem pravilo #1 (na neterminalu s, seveda) in dobimo aa. Ker po drugem koraku nimamo v rezultatu več nobenih nekončnih simbolov, smo zaključili. Beseda oz. stavek aa je sintaktično pravilen stavek v jeziku, ki ga definira naša zgornja gramatika.

Primer 2: s --(2)--> aas --(2)--> aaaas --(1)--> aaaa

V tem primeru smo izpeljali stavek aaaa. Če malce pomislimo, opazimo, da naša gramatika generira prazen stavek (nič a-jev), aa, aaaa, aaaaaa, itd. Skratka, naši stavki so vedno sestavljeni iz sodega števila a-jev. Lahko se še tako trudiš, a ti iz začetnega simbola s ne bo uspelo izpeljati stavka z lihim številom a-jev. Pravzaprav lahko ta razmislek peljemo še malce dlje. Vsak stavek lahko izpeljemo le na en sam način! Za stavek z n a-ji moramo najprej n/2-krat uporabiti pravilo #2 in na koncu enkrat pravilo #1.

Preden to misel razvijemo naprej, naj odgovorim še na vprašanje, kako lahko zgornje stvari poskusiš v prologu. Preposto, zgornjo gramatiko prepišeš kot program (kar v resnici je!) in poganjaš poizvedbe v konzoli takole:

?- s(Word, []).
Word = [] ;
Word = [a,a] ;
Word = [a,a,a,a] ;
…

Poizvedbo torej pokličeš z začetnim simbolom (ki ga prolog v resnici interno prevede v predikat, kot si jih vajen), ki mu podaš dva argumenta. V bistvu je ta par argumentov en sam argument, ki tvori besedo (oz. stavek), ki jo bo gramatika generirala. Zakaj tak zapis z dvema seznamoma? Gre za razliko seznamov – če drugega pustiš praznega, bo prvi ostal tak, kot je; tako bomo večinoma pustili. Tak zapis je posledica učinkovite implementacije. Ko prolog generira besede nekega jezika, to počne s konkatenacijo seznamov. Za slednjo si videl, da je časovne zahtevnosti O(n), a se jo da s trikom razlike seznamov narediti v času O(1).

Kot si že vajen, stvari delujejo v več smeri. Zgoraj smo gramatiko uporabili za generiranje besed jezika. Bolj običajna uporaba pa je v drugi smeri: računalniški prevajalniki tipično preverjajo, ali je podana beseda sintaktično pravilna. To pomeni, ali jo lahko izpeljemo z uporabo pravil dane gramatike. Spodnji poizvedbi sta tak primer uporabe.

?- s([a,a], []).
true
?- s([a,a,a,a,a], []).
false

Dvoumnost gramatik

Zakaj smo posebej izpostavili, da lahko nek stavek izpeljemo le na en način? Ker je to verjetno ena najpomembnejših lastnosti gramatik! Predpostavimo, da imamo poleg zgornjih dveh pravil še tretje:

s --> [].          % pravilo #1
s --> [a,a], s.    % pravilo #2
s --> s, [a,a].    % pravilo #3

Sedaj lahko stavek aaaa izpeljem na več kot en način, npr. takole:

Način 1: s --(2)--> aas --(2)--> aaaas --(1)--> aaaa
Način 2: s --(3)--> saa --(2)--> aasaa --(1)--> aaaa

Kaj je v tem tako strašnega? Je morda problem v tem, da imamo več izbire? Ne, problem je v tem, da isti stavek (aaaa) lahko pomeni več stvari. Če računalniku rečeš „Prištej ena.“, ne želiš, da to pomeni več različnih stvari, kajne? Predstavljaj si, da bi te računalnik razumel malce po svoje: saj bi bilo zabavno, ampak jaz si tako sprogramiranega avtopilota na primer ne želim kaj preveč.

Omenili smo pomen in da je pomen tesno povezan z izpeljavo (zaporedjem uporabe konkretnih preslikovalnih pravil gramatike). Tako je, kasneje bomo videli, da tipično vsaki preslikavi pripišemo pomen.

Izpeljavo besed v jeziku tipično predstavimo z drevesi izpeljave. Tako bolj pregledno pokažemo tisto kar smo zgoraj naredili s puščicami v eni vrstici. Pri več neterminalih tako točno vemo kateri neterminal smo preslikali v kaj. Primera zgornjih izpeljav sta podana spodaj. Končni rezultat preberemo z levim obhodom drevesa, posamezne sezname pa konkateniramo med seboj.

Različni drevesi izpeljave za besedo aaaa

Dodajanje kode v gramatiko

Ker v našem primeru gramatiko uporabljamo znotraj programskega jezika (prologa), nam seveda pride na misel, da bi lahko znotraj gramatike uporabili tudi običajne prologove predikate. To je dejansko mogoče in nam v nekaterih primerih pride precej prav. Poglejmo, kako to naredimo na naslednjem primeru. Recimo, da želimo definirati gramatiko za jezik, kjer imamo večjo izhodno abecedo kot samo a v prejšnjem primeru, a želimo, da se vse črke vedno podvajajo.

s --> [].                                               % pravilo #1
s --> letter, s.                                        % pravilo #2
letter --> [X,X], { member(X, [a,b,c,d, …, z]) }.       % pravilo #3

Pravilo #3 je zanimivo: velike črke nam kot vedno v prologu tudi tu predstavljajo spremenljivke. Rezultat tega pravila je seznam oblike [X,X]. S predikatom member/2 pa generiramo vse možnosti za X: v tem primeru vse črke abecede od a do z. Predikat member/2 smo zapisali znotraj zavitih oklepajev. V tem primeru ne gre za omejitve kot pri CLP(R), ampak prologu na ta način povemo, da gre za običajno prologovsko kodo. Tako lahko uporabljamo poljubne predikate znotraj pravil DCG. Celo CLP(R) lahko uporabimo, tako da znotraj zavitih oklepajev zapišemo še ene zavite oklepaje.

V kakšnem vrstnem redu prolog bere pravila?

Vrstni red uporabe pravil je tak, kot smo ga že vajeni pri prologu. Ta poskuša pravila za posamezni neterminal od zgoraj navzdol. In poskuša vse, torej zopet generira vse možne izpeljave eno za drugo z mehanizmom vračanja, če seveda tako zahtevamo.

Vrstni red preslikave znotraj posameznega pravila je od leve proti desni. Vrstni red je pomemben. Kodo (z zavitimi oklepaji) lahko vključimo na poljubno mesto in jo ločimo z vejicami. Izvedla se bo takrat, ko bo prolog nanjo naletel, torej zopet od leve proti desni.

Dodatni argumenti in pomen

Nekončnim simbolom gramatike lahko dodajamo dodatne argumente. Ti nam lahko služijo za različne stvari, a največkrat z njimi definiramo pomen besed, ki jih generira oz. razpozna dana gramatika.

Definirajmo gramatiko, ki sprejme/generira poljubno zaporedje črk a in b, njen pomen pa naj bo razlika v številu teh črk. Z malo domišljije lahko rečemo, da gre za izid nogometne tekme: a pomeni gol prve ekipe, b pa gol druge ekipe. Neterminal game naj bo začetni simbol te gramatike.

game(0) --> [].
game(Diff) --> goal(Diff1), game(Diff2), {Diff is Diff1 + Diff2}.
goal( 1) --> [a].
goal(-1) --> [b].

Pomen lahko razumemo takole. Če je tekma brez golov (oz. ko v rekurziji pridemo do praznega seznama golov), je razlika 0. Vsak a pomeni gol za prvo ekipo in je vreden +1, vsak b pa gol za drugo ekipo in je vreden -1. Pravilo, da je tekma sestavljena iz gola in potem še poljubno golov v nadaljevanju, pa pove, da je razlika taka kot je v nadaljevanju, ki ji prištejemo še vrednost gola: plus ali minus 1. Kot vidiš, je precej podobno, kot če bi to sprogramili v prologu z rekurzijo. Pravzaprav se prevede v točno to! Pomembno je, da smo dali kodo znotraj zavitih oklepajev na konec pravila #2. Do takrat namreč že izvemo vrednosti za Diff1 in Diff2.

Primer uporabe:

?- game(Diff, [a,b,a,a], []).       % končni rezultat 3-1 za prvo ekipo
Diff = 2.