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 switch
ben) 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
NumberExpression
osztá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
Identifier
token 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 Scanner
nek 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.xml
t:
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ülYylex
lesz)%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 azyycolumn
vá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, aholregex
egy itteni nyelvi dialektusnak megfelelő reguláris kifejezés, azakciókód
pedig 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 Symbol
t 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 Error
t.
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.Scanner
interfészt, amire megkértük a%cup
direktívával, de nem tudja, mi az, mert a cup csomagot még nem emeltük be. ASymbol
osztá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.EOF
mező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 ExpressionParserSym
sort a jflex file-ba, még a%cup
direktí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 |
|