9. gyakorlat¶
A gyakorlat anyaga¶
Az eddigi "kifejezés"-parserünk áttekintése, eggyel karbantarthatóbb struktúrába refaktorálása és a JFlex + CUPS használata.
A múlt heti parserünk¶
A shunting yard algoritmus múlt heti implementációjában a kulcsosztályok a
ShuntingYardParser, a Token és
az ExpressionTree voltak. Utóbbi kettő csak egy-egy interfész:
a Token teljesen üres, az ExpressionTree pedig egy kifejezést reprezentálna fában, egy darab double evaluate(); metódust deklaráltunk neki.
Ameddig eljutottunk, az a Main file-ból elolvasható, a kézzel írt parserünk ezt tudja:
1 2 3 4 | |
A parser parse(String input) függvénye követi a shunting yard algoritmust:
1 2 3 4 5 6 7 8 9 10 11 | |
Eddig jó.
A yard lelke az applyOp() metódus, ami
- popol a token veremből egy elemet
- ha ez a token egy szám token, azt egy (egypontú) faként átrakja az output verembe
- ha műveleti jel, akkor az output verem tetejéről levesz még annyi fát, ahány változós az adott műveleti jel, és ezekből az operátor tokennel épít egy (nagyobb) expression tree-t, majd ezt lerakja az output verembe:
Ezt konkrétan így írtuk meg:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
A fenti kódból PLUS és STAR fix tokenek, amik az OperatorToken enum osztály egy-egy példánya:
1 2 3 4 5 6 7 8 9 10 11 | |
int-ek, mint C-ben voltak: itt az OperatorToken enum típusnak két értéke lehet, a PLUS és a STAR,
előbbi tkp az összeadásjel lesz, utóbbi pedig a szorzásjel. A yardnak a tokenek kezelésekor szüksége van az egyes tokenek prioritására és asszociativitására
is, ezt úgy oldottuk meg, hogy ebben az enum osztályban mezőként tároljuk el, lásd a konstruktort. Az enum osztályt az értékek felsorolásával kezdjük, pontosvessző
zárja az enum utolsó lehetséges értékét, mindnek deklaráljuk a neki szükséges konstruktorhívás módját, így lesz a PLUS token prioritása 2 és bal-asszociatív,
a STAR tokené pedig 1 és jobb-asszociatív.
Ez eddig nem rossz megközelítés, de ha már egyszer az OperatorToken osztályunk tud valamit az általa reprezentált műveleti jelek szemantikájáról (prioritás,
asszociativitás), akkor az már nem hangzik olyan jól, hogy a Parser osztály applyOp metódusába is jut a szemantikából. Szerencsésebb lenne, ha minden egy helyen
lenne és valahogy pl. ide az OperatorToken enumba
tudnánk rakni azt a részletet is, hogy a PLUS token egy AddExpression-t kéne építsen, a STAR token pedig egy MultExpression-t.
Amik egyébként, AddExpression és MultExpression ilyenek:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | |
Lényegében egy nagy copy az egész, csak az evaluate metódusban különböznek, ez is refactorért kiált, de legalább persze működik.
A kifejezés fa lehet pl. egy összeadás vagy egy szorzás, amit úgy értékelünk ki, hogy rekurzívan kiértékeljük mindkét részfáját, majd az eredményeken
elvégezzük az adott műveletet.
Már csak a ShuntingYardParser.parseChar(char c) függvényt nem láttuk, hogy mindent értsünk:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | |
Világos: ha jön egy számjegy, akkor megnézzük, hogy Number van-e a verem tetején, ha igen, akkor még azt a számot folytatjuk, különben pedig egy új NumberExpressiont hozunk létre ezzel a számjeggyel. Btw a NumberExpression.java is csak egy adatosztály:
1 2 3 4 5 6 7 8 9 10 | |
applyOp() metódussal az output verembe. Ezt itt nem vesszük még ki, ezért peek()
és nem pop(), amit hívunk. Eztán a karakterből (a switchben) elkészítjük a megfelelő tokent, majd a token veremben amíg erősebb
prioritású, vagy azonos prioritású jobb-asszoc műveleti token van, azt kivesszük (a while ciklussal) és alkalmazzuk az output
verem felső két fáján (hogy pont kettőn, azt az applyOp fogja tudni), végül lenyomjuk a token verembe az újonnan jött tokenünket.
Az algoritmus bővítése¶
Szeretnénk támogatni a két fenti alapműveleten kívül kivonást, osztást, hatványozást (ez jobb-asszoc, pl. 2^3^4 az nem 8^4, hanem 2^81),
és zárójelezést is. Ha ez megvan, akkor jó lenne még negatív számokat, tizedespontokat, meg néhány neves függvényt, mint a sin, ami unáris,
vagy akár a pow, ami bináris -- akkor az argumentumai közé vesszőt kérjünk. Whitespace-t is kezelnünk kéne.
Ha ez is megvan, tehetnénk bele változókat, melyeknek lehetne értéket is adni, és pontosvesszőkkel elválasztani az egyes utasításokat,
pl. x=3;x+2;x+1 kiírhatná eredményképpen, hogy 3;5;4.
Ezeket épp a shunting yard algoritmus megfelelő kibővítésével is el tudjuk érni, de nézzünk erre inkább egy szoftvercsomagot, amivel az efféle CF parsing taskokat, amikor is egy parse treet akarunk építeni egy input stringből, gyorsabban és hibamentesebben meg lehet ennél oldani.
Lexer és Parser¶
Láthattuk, hogy a folyamatban, amit implementáltunk, három lényegi elem volt: először is kaptunk egy karakterekből álló stringet, aztán ezekből a karakterekből tokeneket építettünk, amik sok esetben egy-egy karakter (mint pl. egy összeadás, szorzás stb. operátor), de sok esetben nem:
- a Java/C-like
||,&&operátorokat egy tokennek kéne kezelni; - általában egy számból, konstansból egy darab tokent jó építeni, melynek van egy mezője, ami a szám értékét tárolja
(ez történt a
NumberExpressionosztályunkban is); - ha programozási nyelv forráskódját parsoljuk, akkor stringből ugyanez: egy string literálból is jobb, ha egy token készül;
- egy azonosítóból (függvény, osztály implementált interface neve) pl. egy
Identifiertoken készülhet, aminek egy string mezőjében tárolhatjuk magát a stringet; - ha programnyelvünk van, akkor a beépített kulcsszavakból is egy-egy token készül.
Mindez azért, hogy így, az input karaktersorozatból előálló tokensorozatot aztán hatékonyabban tudjuk kezelni szemantikailag. Ezt a folyamatot a legtöbb nyelvi elemzőben egy külön motor végzi, melyet lexernek neveznek.
Eztán a lexer által előállított tokensorozatot megkapja egy parser, ami ezeket a tokeneket tkp. mint egy CF nyelvtan terminálisait veszi, és a tokensorozathoz megpróbál építeni egy CF nyelvtannak megfelelő parse tree-t, melynek pont ez a tokensorozat a határa.
Persze mivel a lexer által generált tokeneket a parser meg kell kapja, ezért kell legyen valami token interface, amiben megállapodik lexer és parser - ezért általában egy csomag részeként tudjuk megszerezni a lexert és a parsert. Pontosabban, a lexer és parser generátorokat -- olyan toolokat, amik arra képesek, hogy a megadott nyelvtani szabályok alapján kigeneráljanak egy-egy Java forráskódot, egyet a Lexernek, egyet a Parsernek, melyeknek aztán a megfelelő metódusai hívogatásával az input stringünkből meg tudunk kapni egy parse treet.
JFlex és CUP¶
Ha már Javában vagyunk, az első lexer-parser generátor párunk, amit megnézünk, hogy hogy lehet használni, és amivel szintén megoldjuk ezt a kifejezés-parsoló modult, a JFlex + CUP páros lesz. A JFlex generálja nekünk a Lexert, a CUP pedig a Parsert.
Szerezzük be a két libet innen és innen.
Amit én használni fogok: a jflex-1.8.2.tar.gz és a java-cup-bin-11b-20160615.tar.gz. A JFlex-et kicsomagoljuk valahova,
én mondjuk a dev mappámba, amin belül van az idea projektem egy mappában, ez létrehozza a jflex-1.8.2 könyvtárat.
Mellé kicsomagolom a cup tar gézában lévő két jart is, a java-cup-11b.jar és a java-cup-11b-runtime.jar jarokat.
A JFlex Maven plugin¶
Alapvetően a JFlex egy jflex fileból, melyben specifikáljuk, hogy milyen módon készítsen tokeneket az input stringből,
készít egy Java lexer osztályt, ők Scannernek hívják. Többféle módon lehet használni (a JFlex weblapon
ott a manuál), egyebek közt:
- parancssorból, kiadva a
java -jar jflex-1.8.2.jar <opciók> <inputfile>parancsot - Maven pluginként ideából gombokkal
Az utóbbit fogjuk tenni, mert kényelmes, de persze konzolból is lehet parancssorból fordíttatni a scanner javát, ha valaki ezt preferálja.
Ha esetleg úgy hoztuk létre Ideában a projektet, hogy nem Maven projektként, nem baj: a Project tool ablakban jobb klikk a project rooton,
Add Framework Support és a legördülőből kiválasztjuk a Mavent. Létrejön egy pom.xml, amit innentől szerkeszthetünk, hogy miféle fileokat
hogyan szeretnénk kezelni, ha megváltoznak. A jflex fileokat pl. le akarjuk fordíttatni majd a JFlexszel. Érdemes lehet adni egy groupId-t a
pom.xmlben, én most ezt parsers-re raktam.
A Maven 1.5-re akarja alapból állítani a language levelt, ezt felülírjuk 1.8-ra, a pom.xml-ben, és be kell húznunk a megfelelő verziójú maven-compiler-plugint is:
1 2 3 4 | |
A JFlex főoldaláról van egy link a Maven plugin oldalára.
Ahogy a Usage és a Plugin Documentation lap írja, kibővítjük a pom.xmlt:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
src/main/jflex könyvtáron belül ha talál jflex filét, akkor abból generál scannert
és a generált forráskódot berakja a target/generated-source/jflex könyvtárba.
Írjunk egy JFlex filet¶
Hozzuk létre tehát a src/main/jflex könyvtárat, majd benne mondjuk egy Expression.jflex file-t.
Egy jflex file három nagyobb részből áll, köztük egy-egy %% sorral szeparátorként.
- Az első részt változtatás nélkül be fogja másolni a generált lexer file elejére. Tipikusan ide kerül a package deklaráció, importok, ilyesmik, hogy a lexer leforduljon.
-
A második részben opciókat tudunk megadni a generálással kapcsolatban. Minden opció
%jellel kezdődik. Néhány példa:%class ExpressionLexer- így tudjuk megadni a generált Lexer osztály nevét. (EnélkülYylexlesz)%cup- ezzel adjuk meg, hogy a generált osztályunk implementálni fogja a CUP által várt Lexer interfacet, így tudnak majd együttműködni%line,%column- lexelés közben el fogjuk érni, azyylineés azyycolumnváltozkban, hogy az inputnak hanyadik sorában és azon belül hanyadik karakteren tartunk. Ha CUP-pal együtt akarjuk használni a lexert, akkor erre is szükség van, mert a CUP által várt tokenek a CUP esetében Symbol példányok lesznek, amiknek a konstruktora várja ezeket az értékeket.%unicode- a lexer unicode karakterkódolásban kapja meg az inputot%public- a lexer osztály public class legyen (alapból package privát)- ami
%{ .. %}sorok közé kerül, azt pedig a JFlex be fogja másolni változtatás nélkül a generált osztály belsejébe.
-
Végül, a harmadik részbe kerülnek a Lexer szabályai, melyek reguláris kifejezések a hozzájuk tartozó akciókkal. A legegyszerűbb esetben egy sorban
regex { akciókód }-ként adjuk meg, aholregexegy itteni nyelvi dialektusnak megfelelő reguláris kifejezés, azakciókódpedig egy tetszőleges Java kód.
A Lexer motor a következőképp működik: az input még feldolgozatlan részének az elejére megpróbálja szép sorban illeszteni a megadott reguláris kifejezéseket. Mindegyiket illeszti, némelyik illeszkedni fog, némelyik nem, ezek közül a leghosszabban illeszkedők közül a legelsőt adja be találatnak, és annak hajtja végre az akciókódját.
A legegyszerűbb esetben mindegyik akciókódban készítünk egy új tokent, ha CUPpal akarunk együtt dolgozni, akkor egy Symbolt konstruálunk
és returnöljük. Ekkor a lexer ezt a tokent fel is dobja és majd a CUP ezzel dolgozni fog tovább. Ha nem returnölünk semmit, az nem baj;
ekkor a motor csak végrehajtja az akciókódban szereplő utasításokat, és folytatja az input feldolgozását.
Az Expression.jflex file elég rövid, ha csak azt akarjuk támogatni, amit a korábbi változatban is tettünk:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | |
ExpressionParserSym dolgokra.
Mivel a [ \t]+ regexre nem teszünk semmit, effektíve ezzel lenyeljük a whitespace-t. Az utolsó, a [^] avagy
"negált semmi" karakterosztály, pontosan egy karakterre fog illeszkedni. Mivel a lexer soha nem dob fel találatnak
üres stringet (hiszen akkor végtelen ciklusba esne), a találat mindig legalább egykarakteres lesz. Ha pedig ez
az utolsó szabályunk, akkor ez egy "default" regexként fog működni: akkor hajtódik végre az akciókódja, ha semmire
nem illeszkedett fentebb a string még feldolgozatlan részének az eleje. Ez tipikusan hibajelzésre használható
kód, én is dobok itt egy Errort.
Ami még említésre méltó: az yytext() metódussal érjük el magát a stringet, melyre illeszkedett a regex, ezt
használjuk a szám mint string kinyerésére a NUMBER token akciójában, meg a hibaüzi visszaadásában az utolsó
pozin. Ezen kívül látjuk, hogy használjuk az yyline és yycolumn értékeket a Symbol konstruálásakor.
Ez azért van, mert egy CUP-beli Symbolt akarunk létrehozni, annak a konstruktora pedig azt mindenképpen vár.
Ezen kívül egy intet is vár, ami megadja a Symbol típusát: ez pontosan az az eset, amikor is korábban mi is
egy enumba tettük az összeadás és kivonás jeleket, azzal, hogy itt minden egyes token típusnak kell deklarálnunk egy-egy
int értéket - lényegében fel kell soroljuk az összes tokent és mindhez egy intet rendelni. (Ha a token ezen kívül
rendelkezik egyéb értékekkel, akkor a symbol konstruktora a típus, sor, oszlop után még pluszban kaphat egy objektumot is,
melyben adatot tudunk neki odaadni, az ilyet egyébként "szintetizált attribútum"nak hívják), ez történik a szám létrehozásakor,
hiszen ott nem lesz elég csak annyit tudnunk, hogy egy számot kapunk, hanem magának a számnak az értékére is szükségünk lesz.
Ha most generálunk ebből a fileből a maven lapon (jobb oldalt) egy lexert, akkor kapunk is a target/generated-sources/jflex/szamtud.week11/ExpressionLexer.java
fileba egy Java filet. Elég komplex és nem fordul, ennek két oka van:
- a lexerünk implementálja a
java_cup.runtime.Scannerinterfészt, amire megkértük a%cupdirektívával, de nem tudja, mi az, mert a cup csomagot még nem emeltük be. ASymbolosztállyal ugyanez a helyzet. - mik azok az
ExpressionParserSym.PLUSés társai intek?
Ezeknek a problémáknak a feloldásához be kell izzítsuk a CUPot is.
CUP¶
Először is, az látszik, hogy a lexernek látnia kéne egy java_cup.runtime csomagot. Adjuk hozzá a projekthez external libraryként a korábban
kicsomagolt CUP jarok közül a runtime-ot. (Project structure, Settings, Libraries, add, jar-t kiválaszt)
Ezen a ponton az ExpressionLexer generált java file-ban hirtelen sok minden lefordul (persze, elkezdi érteni a CUPos dolgokat),
már csak az alján nem tudja hova tenni az ExpressionParserSym osztály (ezek szerint) int mezőit. Azért nem, mert a token típusok
neveit, ezeket az enumokat, a CUP fogja nekünk generálni - ők lesznek annak a CF nyelvtannak, amit a CUP parsol, a terminálisai.
Ehhez első körben meg kell írjuk a nyelvtan filet is, első közelítésből legyen ennyi:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
ExpressionParser lesz, felsoroltuk a nyelvtanunk terminálisait: pluszjel, szorzásjel, nyitójel, csukójel
és egy külön sorba a számot is. Ennek itt még specifikáltuk azt is, hogy az adattagja (ez az, amit a lexer feltöltött pl. a számnál)
típusa egy Double.
Eztán felsoroltuk a nyelvtanunk nemterminálisait is, most egy van: a Tree, ennek a nemterminálisnak ez a neve, és ExpressionTree lesz a
típusa Javaban a belőle készített objektumnak majd.
A maradék részben pedig egy CF nyelvtan négy szabályát adtuk meg: a Tree az lehet egy szám, vagy két fa összege, vagy két fa szorzata, vagy zárójelek közt egy fa.
Szintaktikailag ez is egy helyes CUP nyelvtan file, ebből is generálunk egy Java osztályt. Ehhez most nem használunk Mavent (tbh nem néztem utána, hogy tud-e olyat), hanem parancssorból fordítunk: ehhez kell a nem-runtime jar.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | |
1 2 3 4 5 6 7 8 9 10 11 | |
ExpressionParser.java és ExpressionParserSym.java (utóbbinak a nevét már hallottuk).
Mivel ezt okosan sikerült kint generálni, bemásoljuk a src/main/java/szamtud/week11-be (vagy ahova a package infó mutat), és
- az ExpressionParserSym file lefordul, mondjuk ez lényegében egy enum-like statikus adatot tartalmazó osztály, itt láthatjuk, hogy a CUP nyelvtan file-beli nevek mind kaptak egy számot. Van köztük EOF is, ez persze az end of file, ez mindig generálódik pluszban, ahogy az error is.
- a JFlex által generált Lexer file-ban hirtelen már csak egy fordítási hiba lesz: nem találja a
sym.EOFmezőt. Persze pont ezt keresi, ami az ExpressionParserSym fileban van, de valamiért ezen az egy helyen nem írja átExpressionParserSym.EOF-ra, mint lentebb mindenhol. A megoldás semmiképp nem az, hogy kézzel hegesztünk bele generált file-ba! Hanem: írjuk be a%cupsym ExpressionParserSymsort a jflex file-ba, még a%cupdirektíva előtt. Érdekes módon a többi szimbólumot eltalálja, csak az EOF kód generálásakor zavarodik össze.
Ezen a ponton már az újragenerált lexerünk, az ExpressionLexer is le fog fordulni. A generált parser még nem. Ha belenézünk, láthatjuk, hogy
miért: megadtuk, hogy ExpressionTree lesz majd a kifejezés típusa, amit nem lát (egy import szamtud.week10.*; segít az ügyön, a CUP file
import szekciójába).
Most minden fordul, próbáljuk ki, mit alkottunk eddig!
A generált lexer és parser osztályok használata¶
Egy Main osztály main metódusában:
1 2 3 4 | |
null. Persze, a value mező az az adattag, melyet a parse tree node-jai adatként vihetnek felfelé és ilyet nem
is adtunk meg, hogy hogy kéne kiszámítani, csak a terminális leveleknél! Ott is csak a Numbernek.
Faépítéskor hogy vigyük is "felfelé" a készített fát, ahogy pl. a shunting yard kézi implementációnkkor is csináltuk, akciókódot kell megadjunk a CUP nyelvtan file szabályai mellé is, hasonló szintaxissal, mint a jflex fileban tettük:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | |
{: és :} jelek közt adhatunk meg akciókat, amik akkor sülnek el, ha a parser ezt a szabályt alkalmazza.
Bottom-up parserről lévén szó, lentről felfelé fogja alkalmazni a szabályokat. Az akció részben a védett RESULT szó az újonnan
létrehozott parse tree fapont adattagját jelöli, ha pedig a szabály részen a terminálisok vagy nemterminálisok
jobb oldalára kettősponttal írunk egy változónevet, akkor az akciókódban ennek a szimbólumnak az adat mezőjére ezen a néven hivatkozhatunk.
Újra cupolás, file bemásolás és a Main:
1 2 3 4 | |