Az F# saját lexer és parser generátorai: fslex és fsyacc.
Ezeket az eszközöket legtöbbször olyan esetben használjuk, amikor jól definiált szintakszisú felhasználói bementet szeretnénk saját szerkezetté, legtöbbször absztrakt szintakszis fává (AST) alakítani.
Ezeknek az eljárásoknak legtöbbször az a menete, hogy először egy lexikális elemzés során az input stringet tokenek sorozatává alakítjuk, ezután következik a szintaktikai elemzés, mely során pedig a nyelvtani szabályok alapján felépítjük a saját adatszerkezetünket.
A lexelés és parsolás folyamatát nem kell mindig szétválasztanunk, de néhány feladat során hasznos lehet.
A következő részek rövid tartalma:
A lexikális elemzés során egy adott inputon szeretnénk tokeneket azonosítani.
Token: az input egy részlete, ami a lexerünk szempontjából egy különálló "szót" alkot. Ez lehet szám, azonosító, speciális kulcsszó, vagy karaktereknek akármilyen sorozata, amely önálló egységet alkot.
A szintaktikai analízis során azt vizsgáljuk, hogy az input megfelel-e a nyelvhez rendelt nyelvtani szabályoknak.Pl. F#-ban lehet a let a = b * 2 in ... kifejezés szintaktikailag helyes, de szemantikailag csak akkor fogadható el, ha b már rendelkezik értékkel ebben a hatásköri egységben.
A hatáskörök és a kötések a nyelv szemantikájától függnek, és a folyamatot, amellyel ezeket meghatározzuk, szemantikai analízisnek nevezzük.
Egy tipikus fordító a forrásprogramon a következő lépéseket hajtja végre: lexelés -> parsolás -> szemantikai analízis -> optimalizációk/transzformációk -> kód generálás.
A legegyszerűbb esete a parsolásnak és lexelésnek, amikor meghatározott input sorokkal dolgozunk. Ebben az esteben gyakran a parsolás csak annyiból áll, hogy szétvágjuk a sort a megfelelő határolók mentén, és a whitespace-eket figyelmen kívül hagyjuk:
Minden sor feldolgozása függvénnyel:
Ezen függvények típusa:
A Seq-generate_using függvény segítségével egy sequence-ben tudjuk tárolni a soronkénti visszatérési értékeket:
A következő példa az első három sorát veszi annak a fájlnak, ami 10000 ugyanolyan tartalmú sort tartalmaz:
Ezt a technikát gyakran nagy fájlok előzetes elemzésére használják. Ha az algoritmust az adatok egy prefixére már finomítottuk, az analízis hatékonyabban fog lefutni az egész adatfájlon.
Egy másik gyakran használatos módszer, hogy egy stringből különböző információkat nyerjünk ki, a reguláris kifejezések használata.
A System.Text.RegularExpressions névtérben találhatunka reguláris kifejezések kezeléséhez (illesztés, helyettesítés) szükséges függvényeket.
Például vegyünk egy olyan fájlt, amely HTML GET request-ek rekordjait tartalmazza:
A fenti kód tartalmazza az igényelt forrást (favicon.ico) valamint használt HTML protocol alverziószámát (1).
A szükséges elemeket a Groups attribútum segítségével nyerhetjük ki. A reguláris kifejezésben minden bezárójelezett rész egy-egy csoport (group).
Megvan a lehetőségünk, hogy kézzel készítsünk lexert, különböző ad hoc technikákat használjunk, vagy olyan függvényeket írjunk, amely explicit módon tudja manupilálni a karakterek listáját, de ilyenek készíteni gyakran unalmas és időt pazarló tevékenység.
Ehelyett könnyebb, ha ezt a munkát a lexer generátorra bízzuk. A következőkben az F# lexer generátorával, az fslex eszközzel fogunk megismerkedni.
Egy egyszerű példa: a lexer minden < és > karaktert a HTML-beli < és > megfelelőjére cseréli:
text2htmllex.fsl
A generált lexer használata:
text2html.fs
Az F# forráskódot a fent definiált lexerből a következő parancs futtatásával lehet generálni:
Ez az utasítás létrehoz egy text2htmllex.fs nevű fájlt, ami a convertHtml lexer implementációját tartalmazza. Ez a lexer imperatív, így nem tokenekkel tér vissza, hanem egy output stream-be ír ki.
A generált lexer szignatúrája:
A példaprogramot és lexert tudjuk együtt fordítani:
Ezután az elkészült programot már tudjuk futtatni a megfelelő formátumú fájlokra megadva a könyvtárat és a fájl mintát:
Nézzük meg közelebbről a fenti példát! A text2htmllex.fsl-nek a rule-szekciója definiálja lényegében a lexert, paraméterként kap egy kimeneti csatornát, és egy lex buffert. A szabályok szerint, ha < vagy > jelet találunk, akkor kiírjuk a kimenetre ennek a HTML-beli megfelelőjét, és rekurzívan meghívjuk a lexert a maradék inputra. Ha fájlvége jelet találunk, egyszerűen megállunk, akármilyen más karakter esetén pedig kiírjuk azt a kimeneti csatornára. A lexbuf nevű változó csak a szabályokon belül látható, típusa Lexing.lexbuf, amely a Microsoft.FSharp.Tools.FsLex.LexBuffer-ben található. Ezáltal a változó által különböző információkhoz juthatunk a lexelés állapotáról (ez a későbbiekben részletesebben).
A 'meghajtó' lehet akármilyen F# kód.Ellenőrizzük az argumentumokat, ahol megadjuk a könyvtárnevet és egy fájl mintát, amely alapján kiválogatjuka könyvtárból azokat a fájlokat, amire a minta illeszkedik, majd minden fájlt sorban megnyitunk, és példányosítjuk hozzá a generált lexert a következő sorokkal:
Ez a kód néhány fontos függvényt használ a Lexing modulból.
További fontosabb függvények és tulajdonságuk:
Az elemző számára elérhető lexbuf típust létrehozhatunk:
Az aktuális token pozíciói: LexBuffer.EndPos : Lexing.position, LexBuffer.StartPos : Lexing.position
További függvények:
Megjegyzés:
Az FsLex egy táblázat alapú véges automatával dolgozik, amely az input karakterek sorozatát egyenként, egymás után olvassa be, amíg egy tokennel vissza nem tér. Az automata felfüggeszti a működését, amíg nem érkezik újabb input. Az állapota a reguláris kifejezések segítségével leírt szabályoktől függ. Ha egykarakteres literálokkal dolgozunk, akkor egy új karakter új állapotba viszi az automatát, ismétlődő karakter esetén ugyanabban az állapotban marad.
Az fslex inputjának a következő szerkezettel kell rendelkeznie:
Minden szabályból egy F# függvény fog létrejönni, amit más modulokból, vagy a lexerből magából elérhetünk.
Kétféle komment létezik: (* és *) közötti többsoros és // utáni egysoros; mindkettőt ugyanúgy használhatjuk az akciókban, mint akármilyen más F# kódnál.
A szabályokban használható minták:
Az akciók azok az F# kódok, amelyek { és } között helyezkednek el, és lefutnak, amikor az előtte álló mintára illeszkedés történik. Eljárhatunk akármilyen logika szerint, de legtöbb esetben itt állítjuk össze a tokeneket.
A parser definíciójában a %token direktívát használjuk tokenek jelölésére, vagy ha nincs parserünk, akármilyen, a felhasználó által definiált típust használhatunk. (Ha a szabályokkal nem szeretnénk tokeneket lértehozni, akkor a lexer nagyon egyszerűvé válik).
A következő példában tokenek listáját fogjuk generálni és kiíratni. A lexer fel tud ismerni integer, float típusú értékeket, azonoítókat, és a '^', '*', '-' és '+' jeleket. A többi egyéb karakter futási idejű hibát fog okozni.
simpleTokensLex.fsl:
A lexert a következő módon generálhatjuk:
A generált generált lexer egy modult fog tartalmazni, a nevét a bement alapján kapja: SimpleTokensLex. Minden szabályhoz egy belépési pont van, ebben az esetben a függvény típusa a következő:
A következőkben láthatjuk, hogyan tudunk imperatívan token stream-et generálni egy stringből, és az eredményt kiírni.
Az fslex által generált lexer mindig tartalmazza a legutóbb elfogadott tokan pozíció információit a bemeneti karakter stream alapján.
A LexBuffer StartPos és EndPos property-jeinek visszatérési értékei Lexing.Position típusúak.
A position típus szignatúrája:
Néhány esetben a lexerek akcóinak néhány extra feladatot kell teljesíteniük. Különösképpen, amikor új sor karakter dolgozunk fel, hiszen ilyenkor mindig frissíteni kell a LexBuffer EndPos property-jét. (ez azért a lexer feladata, mert különböző lexerek esetén különbözhetnek az újsor karakterek). Az endOfLine szabályt megváltoztatva tehetjük ezt meg:
Gyakran előfordul, hogy pozícióinformációt kell csatolni minden feldolgozott tokenhez. Azonban amikor a lexert együtt használjuk a fsyacc parser generátorral, a pozíció információkat minden feldolgozott token után automatikusan kiolvasásra kerülnek, és tárolódnak a parser állapotában.
Eddig csak olyan példákat láthattunk, ahol egy lexelési szabály elegendő volt. Ez azért volt, mert a főszabály elégséges volt minden tokenre, és nem kellett olyan inputot lexelnünk, ami nem lett volna leírható reguláris kifejezéssel.
Vegyünk most pélának a kommentezést! Egy komment "(*" és "*)" karakterek között helyezkedik el. Formálisan tehát van egy nyitó elválasztó, a komment törzse és a záró elválasztó.
Az első megközelítés:
hibás, mert a középső minta mindenre illeszkedni fog, így nem jut el a csukó "*)" jelig.
A következő megoldás már jobbnak tűnik:
itt addig illesztjük a kommentet, amíg el nem érünk egy csillagig, utána próbáljuk illeszteni a csukó "*)" jelet. Természetesen hibás eredményt kapunk minden olyan kommentnél, amely tartalmaz csillag jelet.
Ezzel a reguláris kifejezéssel még egy kicsit tovább játszhatunk. A komment törzse lehet bármi, kivéve a csillagot, de olyan csillag viszont lehet, amit nem követ ")" kerek záró zárójel:
Ezt a reguláris kifejezést már nem tudjuk tovább fejleszteni, de még ennek is van egy hibája: nem tudja kezelni az egymásba ágyazott kommenteket, hiszen az első kommentzárónál megáll, figyelmen kívül hagyva a többi eddig megnyitott kommentet.
A probléma kezelése érdekében használjunk multirule lexert. A következőkben leírtak megmutatják, hogyan kell kiegészíteni a simpleTokensLex.fsl lexert, hogy tudja kezelni a kommenteket:
A kommentek kezelését végző folyamat akkor indul, amikor elértünk egy kommentnyitót a token szabályban. Amikor egy kommentzárót találunk, egy comment szabály hívásból kilépünk. Az elképzelésünk szerint úgy kezeljük a beágyazott kommenteket, hogy rekurzívan alkalmazzuk a lexert.
Az fcyacc eszköz LALR(1) parsert generál, amely egy olyan speciális részhalmaza az LR(1) parseroknak, ahol az állapottáblát úgy csökkentjük, hogy összevonjuk a hasonló állapotokat. Ez a gyakorlatban nem szűkíti a parsolható nyelvek számát, de jelentősen csökkentheti a parsolási tábla méretét.
A generált parser automata négy különböző műveletet tud végrehajtani bármely állapoton az előreolvasott token alapján, és ezeket fontos megérteni akkor, ha különféle nyelvtani konfliktusaink vannak, amiket fel kell oldani:
Először azt vizsgáljuk meg. hogyan tudunk parsert generálni egy egyszerű programozási nyelvhez.
Egy példa részlet egy BASIC-hez hasonló nyelvből, amelyet parsolni szeretnénk:
Az egyszerűség kedvéért a nyelvet nevezzük Kitty-nek. Ahogy az előző példában láthattuk, a Kitty tartalmaz változó deklarációkat, változók értékének kiiratását, alapvető aritmetikai operátorokat, while, valamint feltételes szerkezeteket.
Az Ast modulban definiáljuk a Kitty programok szerkezetét.
kittyAst.fs:
A következőkben bemutatjuk a Kitty nyevtanához készítendő lexert. Hasonló lesz az előző részben bemutatott lexerhezm azzal a különbséggel, hogy itt kulcsszó táblázatot használunk.
A tokeneket lexémmákkal való illesztéssel kapjuk meg, ez jó megoldás akkor, ha nem túl sok esetet kell megvizsgálnunk. Túl nagy lexert eredményezhet, ha explicit szabályokkal szerentnénk tokenizálni kulcsszavak és operátorok viszonylag nagy halmazát. Ezt a problémát úgy tudjuk kezeleni, hogy egy táblázatot használnuk, amely tartalmazza a lehetséges illeszkedő lexémákat, és hogy milyen tokent kell visszaadni az egyes esetekben. Egy egyszerű dictionary adatszerkezetet, a Map-et használjuk:
Megyjegyzés: lexer fordítási ideje a később definiálandó parsertől fog függeni. Ez azért van, mert a lexernek olyan típusú tokenekkel kell visszatérenie, amelyet a parser vár.
A lexert fslex futtatásával generálhatjuk:
Ennek eredményeképpen létrejön a lexer implementációját tartalmazó fájl, a kittyLexer.fs.
Kittyben a ;-vel elválasztott utasításokat az StmtList-ben utasítások listájaként tároljuk.
Ezt a szabály le tudjuk írni fej-rekurzív módon:
Hátrány: mindig, amikor egy új kifejezést fűzünk az utasításlistához, egy másolat készül róla.
Másik megoldás:
A hozzáfűzés egyenként történik meg egy egy elemű listához, ami az első utasítás. Eredményként egy fordított sorrendű listát kapunk, ez szükségessé teszi a List.rev használatát.
Üres vagy tetszőleges lista parsolása: üres lista szimbólum használata a következő példának megfelelően:
Ez a szabály illeszkedi fog akármilyen listára, és üres listával tér vissza, ha nincs már parsolható utasítás.
Mint általában, az aritmetikai operátoroknál a osztás vagy szorzás magasabb precedenciájúak, mint az összeadás vagy kivonás, így az 1+2*3-at a következő zárójelezésnek megfelelően pasolhatjuk: 1+(2*3). Fsyacc-al ezt könnyedén kifejezhetjük az asszociativitás irányával, vagy ha még pontosabbak akarunk lenni, akkor rendezéssel is:
Ha megadjuk tokenekhez az asszociativitást és precedenciát (milyen erősen kötnek), akkor szabályozhatjuk a parsolást. Például adott egy bal-asszociatív összeadás: 1+2+3. A parser már ezt egy egyértelmű, nem felcserélhető műveletként dolgozza fel, ami a következő formájú: (1+2)+3. Az alapvető aritmetikai operátorok bal-asszociatívak, és egy olyan listában helyezkednek el, ahol sorba jönnek a különböző precedenciájú műveletek a legalacsonyabbtól a legmagasabb felé. Egyéb asszociativitás kifejező specifikációk még a %right és a %nonassoc, ami azt fejezi ki, hogy az adott szimbólum jobb-asszociatív, vagy nincsen asszociativitása. Az utóbbi akkor használandó, hogyha az operátor nem applikatív, ilyenek például a <, >, != jelek, így pl. az 1 > 2 > 3 szintaktikai hibát fog eredményezni.