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.