Szintaxisfák építése
A 14. előadáson áttekintettük, hogy hogyan lehet nyelvi elemzőket készíteni EBNF-ben adott nyelvtanok kiértékelésére. Ott egy olyan programot készítettünk el, amely a négy alapműveletből és zárójelekből felépített tetszőleges bonyolultságú matematikai kifejezéseket elemzett és rögtön ki is számolta az eredményt.
Egy egyszerű probléma esetén, mint amilyen ez is, a helyben történő kiértékelő teljesen megfelelő megoldás, hiszen mind az elemzéshez szükséges, mind a műveleteket elvégző kód egyszerű, így a kettő „összekeverése” nem rontja az áttekinthetőséget.
Nagy nyelvtanok esetén (pl. egy programnyelv elemzésekor) azonban ez a megoldás már járhatatlan: szét kell választani az elemzést és a kiértékelést. Ez a gyakorlatban azt jelenti, hogy az elemző a feldolgozott szövegnek egy új reprezentációját állítja elő, amit már sokkal egyszerűbb értelmezni. Így a kiértékelő algoritmusnak már nem kell törődnie a szintaxisből adódó nehézségekkel, csak az előírt műveletsor végrehajtására kell koncentrálnia. Ráadásul a két feladat szétválasztása azt is biztosítja, hogy az értelmező egység csak hibátlan adatokat kap, tehát hibakezeléssel már nem kell foglalkoznia – ez az elemző feladata.
1 Az absztrakt szintaxisfa
Felmerül a kérdés, hogy milyen adatszerkezet lehet alkalmas arra, hogy egy értelmezett szöveget (pl. egy matematikai kifejezést) eltároljunk benne. A válasz természetesen nem egyértelmű, de nagyon gyakran a fákat szokták alkalmazni. A nyelvi elemzés során előálló, a szöveget reprezentáló fákat absztrakt szintaxisfának (abstract syntax tree, AST) hívják.
Ahogy azt már kifejezésekről szóló írásban is elmondtuk, a fa előnye az, hogy az adatokon túl azok hierarchiáját is képes eltárolni. Ott azért jött ez a tulajdonság nagyon jól, mert így képesek voltunk a műveletek precedenciáját is eltárolni, ezáltal biztosítva azt, hogy a kiértékelés mindig helyes sorrendben történjék.
Azoban ennél sokkal összetettebb esetekben is kiválóan alkalmazható ez az adatszerkezet. A Wikipédia erről szóló cikkében egy rövid programkódrészletnek megfelelő fát mutat be.
Itt most a 14. előadás programját alakítjuk át úgy, hogy az ne számolja ki helyben a kifejezés értékét, hanem építse fel belőle a szintaxisfát.
Figyeljük meg az ábrán látható fát, amely a 4*(5+7) kifejezésnek
felel meg. A kifejezésünk azt írja elő, hogy az 5 és 7 összegét előbb kell
képezni kell és csak ezután számolhatjuk ki a 4-gyel való szorzatukat. Ezt a fa
úgy jelöli, hogy a szorzás jobboldali operandusa nem egy szám, hanem az összeg.
A fa kiértékelése úgy történik, hogy megkérdezzük a gyökerétől, hogy mennyi a kifejezés értéke. A válasza attól függ, hogy milyen jellegű csomópont. Ha ő egy szám, akkor megmondja az értékét és már kész is vagyunk. Ha viszont egy művelet, akkor megkérdezi az operandusaitól (a gyermekeitől), hogy mennyi az ő értékük, majd a kapott számokon elvégzi az előírt művelet és így vagyunk készen.
Ez tehát egy rekurzív algoritmus, hiszen, amikor egy elem megkérdezi az gyermekeitől az ő értéküket, akkor ugyanazt a kérdést teszi fel, mint amit mi tettünk fel a szintaxisfánk gyökerének, vagyis ugyanazt a függvényt hívja meg.
A példában tehát először a szorzástól kérdezzük meg az értékét, aki pedig tovább kérdezi a gyermekeit. A balgyermek rögtön tud válaszolni, az ő értéke 4. A jobbgyermek egy összeadás, tehát ő megkérdezi a gyermekeit. Azok megmondják az értéküket, így az összeg előáll, amit megkap a szorzás, és ezzel kiszámoltuk a kifejezést.
2 A megoldás alapötlete
Az előadáson szerepelt a matematikai kifejezéseket leíró nyelvtan, amit most ismétlésképpen idemásoltunk:
kifejezés ::= összeg
összeg ::= szorzat (('+' | '-') szorzat)*
szorzat ::= tényező (('*' | '/') tényező)*
tényező ::= szám | zárójeles
zárójeles ::= '(' kifejezés ')'
A nehézséget az fogja jelenteni most a számunkra, hogy a nyelvtanunk rekurzív, tehát az elemzőnk is az lesz. Vagyis a fát egy rekurzív algoritmussal kell majd felépítenünk! Ez elsőre nagyon ijesztőnek tűnhet.
Rekurzív problémák megoldásához azt szokták javasolni, hogy legyünk optimisták és tegyük fel, hogy a függvényünk helyesen működik. A feladatunk ezután kettős. Egyrészt ki kell találnunk, hogy hogyan tudjuk lebontani a megoldást lépésekre, másrészt meg kell megoldást kell adnunk egy lépésre úgy, hogy feltételezhetjük, hogy a probléma maradékát a függvényünk már meg fogja oldani.
Ez így nagyon általánosan hangzik, ezért nézzünk meg egy konkrét példát! Vegyük a fenti nyelvtanunkhoz írt elemző egy részletének egyszerűsített pszeudó-kódját:
logikai összeg(szöveg_mutató, érték_mutató) {
munka_mutató = szöveg_mutató
if (szorzat(munka_mutató, &érték1)) {
if (karakter(munka_mutató, "+-", &művelet)) {
if (szorzat(munka_mutató, &érték2) {
érték_mutató = (művelet == '+') ? (érték1 + érték2) : (érték1 - érték2)
szöveg_mutató = munka_mutató
return IGAZ
}
}
}
return HAMIS
}
A fenti kód egyelemű összegeket (vagy különbségeket) tud feldolgozni. Ha a
szövegben talál egy szorzatot, akkor annak értékét elmenti az
érték1 változóba. Ezután ha talál egy + vagy
- karaktert, akkor az azt jelenti, hogy lesz még egy tag, tehát
újból keres egy szorzatot, aminek az értékét a érték2 változóba
helyezi. Ha mindez sikeresen megtörtént, akkor elvégzi az előírt műveleten, az
eredményt beleírja az érték_mutatóba, a szövegmutatót az elemzett szöveg végére
állítja és IGAZ értékkel tér vissza. Minden hibás esetben HAMIS értéket ad a
függvény.
A teljes elemzőnk ehhez hasonló függvényekből áll. A kérdés az, hogy hogyan tudnánk ezekbe az elemi kis algoritmusokba becsempészni a fa felépítésének egy-egy mozzanatát úgy, hogy az előálló kód végül egy teljes fát eredményezzen.
Az egészen biztos, hogy egy-egy függvény csak egy farészletet tud elkészíteni.
Azt is tudjuk, hogy a fa kezdetben üres lesz, tehát az első függvényhívás során
egy NULL pointert kapunk.
Az lehet a kiinduló ötletünk, hogy ne egy fa gyökerére mutató pintert vegyünk
át, hanem egy olyan pointernek a címét, amibe mi írjuk bele az általunk
elkészített fa gyökerének a címét. Tehát ha mondjuk a kifejezésünk egy darab
számból áll, akkor megkapjuk egy olyan pointernek a címét, aminek az értéke
NULL, elkészítünk egy fa-elemet, amit a kettes értéket tárolja és
ennek a címét beleírjuk az átvett pointerbe.
Mi történik összetettebb esetben, például a fenti összeg függvény esetén?
A függvényünk tehát át fogja venni egy pointer címét, amibe egy „összeg” típusú elemnek a címét kell elhelyeznie.
A kérdés az, hogy mi lesz ennek az „összeg” elemnek a két gyermeke? Természetesen két „szorzat”, hiszen ez a szabály
szorzatok összegére illeszkedik!
A teendő tehát az, hogy veszünk két lokális mutatót, amelyek a két szorzat
által előállított fa gyökerei lesznek. Ezeket a szorzat feltölti
tartalommal. Nekünk mindössze annyi a dolgunk, hogy az „összeg” elem
gyermekeire mutató pointereket beállítjuk a két visszakapott fa gyökerére.
logikai összeg(szöveg_mutató, gyökér_mutató) {
munka_mutató = szöveg_mutató
if (szorzat(munka_mutató, &bal_gyermek)) {
if (karakter(munka_mutató, "+-", &művelet)) {
if (szorzat(munka_mutató, &jobb_gyermek) {
új_elem = új_összeg
új_elem->bal=bal_gyermek
új_elem->jobb=jobb_gyermek
gyökér_mutató = új_elem
szöveg_mutató = munka_mutató
return IGAZ
}
}
}
return HAMIS
}
Az történt tehát, hogy feltettük, hogy a függvényeink előállítják a megfelelő fákat, és mi csak azt elemi műveletet adtuk meg, ami két részfából egy összeget képez!
Természetesen a szám szabályunk leveleket fog létrehozni, hiszen
egy számnak már nincsenek operandusai. A zárójeles szabály pedig
elkéri az összeg szabálytól az általa előállított fa gyökerét és
ezt adja meg a zárójel előtt művelet jobboldali operandusaként. (Valójában
persze egyszerűen visszaadja a kapott fa gyökerét, és az ő hívója fogja a
pointerek beállítását végezni.)
3 A faépítő program
A szintaxisfát leíró adattípus egészen egyszerű lesz: a fa egy elem vagy szám,
vagy művelet. Ez a szimbolum_tipus nevű felsorolt típussal adjuk
meg. Egy elemben vagy egy műveleti jelet, vagy egy számértéket tárolunk el
– ilyenkor jönnek jól a unionok.
typedef enum {MUVELET, SZAM} szimbolum_tipus;
typedef struct szimbolum {
szimbolum_tipus tipus;
union {
double szam;
char muveleti_jel;
} adat;
struct szimbolum *op1, *op2;
} szimbolum;
Nézzük meg most valójában az osszeg függvény kódját. A dolog
annyival bonyolultabb itt, hogy tetszőleges számú tagból álló összeget fel kell
tudnunk dolgozni.
Az a feladatunk, hogy előállítsunk egy olyan farészletet, aminek a kiértékelése a helyes eredményre vezet. A megoldás az lehet, ha az első elkészült összegünk (aminek tehát két szorzat operandusa van) lesz a következő összegnek a baloldali operandusa, a jobboldali pedig az újonnan beolvasott szorzat lesz. Így tetszőleges számú tagot fel tudunk fűzni egymás után és amikor a legfelsőt megkérjük, hogy adja meg az értékét, akkor sorban meghívja majd a baloldali irányban lévő összegeket és előáll a helyes érték.
int osszeg(char **szoveg, szimbolum **ast) {
char *munka = *szoveg;
char kar;
szimbolum *op1, *op2, *uj = NULL;
szokoz(&munka);
if (szorzat(&munka, &op1)) {
while (karakter(&munka, "+-", &kar)) {
if (szorzat(&munka, &op2)) {
uj = uj_muvelet(kar);
uj->op1 = op1;
uj->op2 = op2;
op1 = uj;
}
else return 0;
}
if (uj == NULL) *ast = op1;
else *ast = uj;
*szoveg = munka;
return 1;
}
else {
return 0;
}
}
Figyeljük meg, hogy az osszeg függvény az op1 és
op2 pointerekben menti el a szorzat hívásokból kapott
részfák gyökerét, az uj pointer pedig arra szolgál, hogy az
újonnan létrehozot „összeg” típusú elem címét tárolja.
Az első két tag feldolgozásakor létrehozunk egy új elemet és a két gyermekeként
beállítjuk az op1 és op2 pointereket. Ezután azt
mondjuk, hogy az op1 innentől mutasson az új elemre. Ez nem okoz
gondot, hiszen az op1 értékét már eltároltuk
uj->op1-ben, másrészt viszont így értjük el azt, hogy ha
további tagok következnek, tehát ha újból belépünk a while
ciklusba, akkor az ebben az iterációban létrehozott új elemnek az
op1 mutatója az előző uj elemre fog mutatni –
pontosan úgy, ahogyan az ábrán látható.
Még érdemes megnézni tenyezo szabályunk kódbeli megvalósulását:
int tenyezo(char **szoveg, szimbolum **ast) {
char *munka = *szoveg;
char kar;
double ertek;
szokoz(&munka);
if (zarojeles(&munka, ast)) {
*szoveg = munka;
return 1;
}
else if (szam(&munka, &ertek)) {
*ast = uj_szam(ertek);
*szoveg = munka;
return 1;
}
else {
return 0;
}
}
A kód nagyon egyszerű: egy tényező az vagy egy szám, vagy egy zárójeles kifejezés.
Ha zárójeles kifejezés, akkor a zarojeles hívásból kapott fa lesz maga a tényező,
tehát egyszerűen továbbadhatjuk a kapott pointert és hagyhatjuk, hogy az a függvény állítsa be őt.
Ha pedig számot találtunk, akkor létrehozunk egy új szám elemet és ezt adjuk
vissza. Az uj_szam függvény a gyermekek pointereit
NULL-ra állítja, hiszen szám egy kifejezésfában csak levél lehet.
A zárójeles kifejezés szintén továbbadja a megkapott pointert, mégpedig az osszeg
függvénynek, hiszen a nyelvtanunk szerint egy zárójeles kifejezés az egy összeg zárójelek között:
int zarojeles(char **szoveg, szimbolum **ast) {
char *munka = *szoveg;
char kar;
szokoz(&munka);
if (karakter(&munka, "(", &kar) && osszeg(&munka, ast) && karakter(&munka, ")", &kar)) {
*szoveg = munka;
return 1;
}
else {
return 0;
}
}
4 A fa kiértékelése
Végezetül álljon itt a fa kiértékelését végző rekurzív algoritmus, bár igazából ez lényegében nem különbözik a kifejezésekről szóló írásunkban adott megoldástól:
double ast_kiertekel(szimbolum *ast) {
if (ast == NULL) return 0.0;
else {
double op1 = ast_kiertekel(ast->op1), op2 = ast_kiertekel(ast->op2);
switch (ast->tipus) {
case SZAM:
return ast->adat.szam;
case MUVELET:
switch (ast->adat.muveleti_jel) {
case '+':
return op1 + op2;
case '-':
return op1 - op2;
case '*':
return op1 * op2;
case '/':
return op1 / op2;
}
}
return 0.0; //default ágak hiánya miatt
}
}
Látható, hogy az aktuális elem típusa szerint ágazunk el egy switch szerkezettel.
Ha az aktuális elemünk egy szám, akkor egyszerűen visszatérünk az értékével, ha pedig egy művelet, akkor elvégezzük
a megfelelő műveletet a két operandusunkon.
Az operandusok kiszámolásánál történik a rekurzív hívás, a báziskritérium pedig természetesen az, hogy ha a kapott
fa gyökere NULL, akkor az érték legyen nulla. Igazából ez csak a rekurzió leállításához szükség, hiszen
ez a nulla érték sehol sem fog megjelenni, ugyanis csak számok lehetnek levelek a fában, náluk viszont nem használjuk fel
a gyermekek értékét.
A teljes kód letölthető innen.
