Etapa 1 - Lexer cu AFD-uri

Checker si teste: Checker proiect LFA.

Etapa 1 consta in implementarea unui lexer simplu (in Python sau Haskell).

Ce este un lexer?

Un lexer este un program care imparte un sir de caractere in subsiruri numite lexeme, fiecare fiind clasificat ca un token. Tabelul de mai jos ilustreaza cateva perechi de tokens si lexeme:

Token Lexeme
variabla x
variabla var
valoare 23
plus +
eq =

Spre exemplu, sirul x=12+y va fi transformat de lexerul implementat de voi in: (variabila,“x”),(eq,“=”),(valoare,“12”),(plus,“+”),(variabila,“y”).

Ce primeste la input un lexer?

In realitate, un lexer primeste la input o specificatie de tokens, si produce un program de scanare a inputului care este integrat, intr-o forma sau alta, cu un parser. In aplicatia noastra, lexer-ul va primi doua fisiere:

  1. un fisier cu specificatia, descris mai jos.
  2. un fisier cu un input (cuvantul) care va fi scanat.

Specificatia

In aceasta etapa, inputul unui lexer va fi conceptual foarte simplu, anume o lista: dfa1, dfa2, … dfan de automate finite deterministe, fiecare codificand un anume token. Mai exact, inputul are urmatoarea structura:

<dfa1>

<dfa2>

<dfa3>

...

<dfan>

unde:

  • <dfai> este codificarea unui token impreuna cu AFD-ul asociat lui
  • codificarea fiecarui AFD se termina printr-o linie goala.

Codificarea unui AFD are urmatoarea structura:

<alphabet>
<token>
<stare_initiala>
<tranzitie1>
<tranzitie2>
...
<tranzitie_n>
<stari_finale>

unde:

  • <alfabet> este un sir ce codifica alfabetul pentru AFD-ul aferent
    • Atentie: un alfabet valid poate contine orice simbol alfanumeric, prin urmare inclusiv SPATII ALBE, care vor fi necesare pt implementarea etapei 4.
  • <token> este o linie ce contine numele token-ului descris de AFD, deobicei scris cu litere mari (spre exemplu: VARIABILA)
  • <stare_initiala> este o linie ce contine un intreg ce desemneaza starea initiala a AFD-ului (deo bicei 0)
  • <stari_finale> este o linie ce contine o secventa de intregi separati printr-un spatiu alb ce desemneaza starile finale (spre exemplu: 2 3)
  • <tranzitie_i> este o linie condifica o tranzitie a AFD-ului, si are urmatoarea forma:
    • <s>,<c>,<d> unde <s> si <d> sunt intregi ce codifica starea sursa respectiv destinatie, iar <c> este un caracter (poate fi orice caracter cu exceptia ,)

Ce intoarce la output un lexer?

Output-ul se va realiza intr-un fisier si va fi un sir de tokens si lexeme identificate. El va avea forma:

<token1> <lexeme1>
...
<token_n> <lexeme_n>

Cum implementam un lexer?

In linii mari, implementarea unui lexer este relativ simpla: dupa ce a citit lista AFD-urilor (in ordinea din fisier), acesta mentine configuratiile fiecarui AFD pe masura ce scaneaza input-ul. Implementarea lexerului necesita atentie din cauza unei proprietati importante a acestuia:

  • el va cauta intotdeauna cel mai lung sir care este acceptat de un AFD

Spre exemplu, daca avem token-urile (definite prin DFA-uri) pentru limbajele:

  • ZEROS 0+
  • ONES 1+
  • PAT 0*10*

si input-ul 00011000, atunci lexer-ul va recunoaste input-ul ca fiind: (PAT,00011000) si nu: (ZEROS,000),(ONES,11),(ZEROS,000).

Pentru a intelege ratiunea acestei abordari, ganditi-va la urmatorul program:

varif = if x > 0 then 1 else 0

Daca token-urile au fost definite corect, programul trebuie sa identifice varif ca fiind o variabla, si nu o variabila urmata de cuvantul cheie if. (Mai multe detalii in Anexa 1).

Sink states

Tocmai de aceea, in procesul de analiza lexicala, avem nevoie sa determinam, pentru fiecare AFD, multimea sink-states (deobicei, va fi una singura). Aceasta poate fi calculata inversand tranzitiile fiecarui AFD si vizitand toate starile accesibile din starea finala. Starile ce nu vor fi gasite astfel vor fi sink-states.

Configuratii

Spre deosebire de teoria de la curs, unde configuratiile erau perechi cuvant(ramas a fi scanat) si stare, pentru lexerul nostru configuratiile vor fi usor diferite. In primul rand, noi nu verificam daca un AFD accepta un cuvant, ci incercam sa spargem un sir in mai multe cuvinte (de lungime maximala), ce ar fi acceptate de unul din AFD-urile citite de la input.

De aceea, o configuratie pentru un AFD trebuie sa retina (intr-o forma sau alta):

  1. starea curenta
  2. ultima pozitie din input unde a acceptat (daca este cazul)
  3. daca starea curenta este un sink-state sau nu.

Explicam necesitatea lor folosind un exemplu. Sa consideram token-urile (definite ca AFD-uri):

  • FST (11)+
  • SND (10)+

si input-ul 1011. Lexer-ul va evolua in felul urmator:

  • primul caracter: 1. Ambele AFD-uri vor citi 1 si se vor muta in starea succesor, asteptand urmatorul caracter.
  • al doilea caracter: 0. AFD-ul aferent FST va ajunge intr-un sink state (odata citit 0 nu mai putem accepta). Al doilea AFD va ajunge intr-o stare finala si in configuratia acestuia vom retine ca la pozitia 1 din sir am identificat un token. Scanarea insa continua, pentru ca acesta nu e in mod necesar cel mai lung lexem aferent token-ului.
  • al treilea caracter: 1. AFD-ul aferent FST ramane in sink state. AFD-ul aferent SND ajunge si el in sink-state, si cum nu mai exista alte AFD-uri in stari non-sink, lexer-ul va raporta token-ul avand lexemul cel mai lung care a acceptat anterior, adica cel aferent pozitiei 1: (SND,10).
  • reset: procesul de scanare va continua, cu doua modificari:
    • toate AFD-urile vor fi re-aduse in starea initiala
    • ne vom intoarce la input la pozitia urmatoare celei unde am raportat token-ul gasit anterior, adica pozitia 2. Asadar, sirul de la input va fi 11
  • revizitam al treilea caracter: 1. Ambele AFD-uri trec intr-o noua stare;
  • al patrulea caracter: 0. Al doilea AFD trece intr-un sink state. Primul AFD trece intr-o stare finala. In mod normal, procesul de cautare ar continua, insa intalnim finalul sirului. Prin urmare, raportam (FST,11).

Pattern matching in Python

Limbajul Python suporta un mecanism simplu de pattern matching pentru tupluri, care poate fi util in proiect. Ilustram cateva exemple:

def f():
    a,b,c = (1,2,3)
    return a,b,c

print(f()) afiseaza (1,2,3)

def f():
    a,b,*c = (1,2,3)
    return a,b,*c

print(f()) afiseaza (1,2,3)

def f():
    a,b,*c = (1,2)
    return a,b,*c

print(f()) afiseaza (1,2)

Puteti testa voi insiva alte scenarii similare.

Dictionare si hashing in Python

Dictionarele din Python vor fi folosite extensiv in proiectul vostru, motiv pentru care este important sa intelegem proprietatea de a fi hashable a cheilor din acestea. Mai multe detalii despre hashing si dictionare gasiti aici.

Starile unui AFD

In aceasta etapa a proiectului, AFD-urile citite de la input contin stari codificate ca intregi. In implementarea structurii de date care va reprezenta AFD-uri trebuie sa anticipam urmatoarele:

  • In alte etape ale proiectului vom folosi AFD-uri in care starile sunt codificate altfel (multimi de intregi).
  • De asemenea, vom implementa AFN-uri - o structura de date foarte asemanatoare cu AFD-uri si cu functionalitati comune.
  • In cadrul AFN-urilor, starile trebuie sa suporte transformari (sa expuna o functie asemanatoare cu map), care sa nu modifice functionalitatea acestora.

In aceasta etapa a proiectului, puteti:

  1. alege o implementare simpla de AFD (e.g. stari codificate ca intregi impreuna cu un dictionar). Aceasta va trebui sa fie usor de modificat pentru urmatoarele etape a proiectului
  2. pregati o implementare de AFD care sa permita cele de mai sus, si a carei implementare va fi rafinata in urmatoarele etape. Oferim cateva sugestii:
    1. implementarea unei clase Stare, care poate fi modificata (sau extinsa) ulterior
    2. codificarea starilor printr-o lista: [s1, s2, …, sn], in care elementele pot avea orice tip (profitam astfel de flexibilitatea typing-ului in Python). Dictionarul AFD-ului va folosi indecsii acestei liste si nu valorile efective. In felul asta, putem transforma valorile starilor transparent fata de dictionar, si astfel sa evitam problema hashing-ului.

Igiena codului

In multe situatii, in implementarea unei operatii generale, avem nevoie de a izola operatii locale, insa acestea au nevoie de intregul context pentru implementare. Spre exemplu:

def smallOperation(x,y,z):
   ...
def tinyOperation(x,y):
   ...
def bigOperation(data1, data2, data3):
    chunk1 = smallOperation(0,data1,data2)
    chunk2 = tinyOperation(data2,data3)
    ...

Puteti evita pasarea contextului global fiecarei operatii, definind functii in functii (suportate in Python). Acestea vor avea vizibil intregul context. Astfel, nu mai mutam date redundant in apeluri de functii, iar codul este mai usor de urmarit:

def bigOperation(data1, data2, data3):
    def smallOperation(x):
       ...
    def tinyOperation():
       ...
    chunk1 = smallOperation(0)
    chunk2 = tinyOperation()
    ...

Tipuri

Pentru a putea integra mai usor lexer-ul vostru cu alte componente pe care le vom implementa ulterior, folositi urmatoarea clasa (la care vom adauga mai tarziu, definitii), care modeleaza Automate Finite (nu neaparat deterministe):

class FA t where
    fromList :: State s => Set Char -> s -> [(s,Char,s)] -> [s] -> t s
    states :: (State s) => t s -> [s]

In anticiparea diverselor transformari care vor fi implementate peste AFD-uri, este important ca starile acestora sa fie polimorfice:

type Delta a = Map (a,Char) a
data Dfa a = Dfa {sigma :: Set Char, initial :: a, delta :: (Delta a), fin :: [a]}

Definitia pentru Delta foloseste Maps care sunt cele mai usor de folosit in acest context.

Inrolati containerul Dfa :: * ⇒ * in clasa FA.

Citirea input-ului

Parcurgerea input-ului linie cu line se implementeaza greoi in Haskell, insa separarea AFD-urilor poate fi realizata usor, plecand de la urmatoarea observatie. Daca impartim folosind caracterul '\n' o portiune din input, vom obtine:

[ <linie1>, ... <linie_k>, "", <linie_k+1> , .... <linie_k+n>, "", ...]

Daca aplicam aceeasi functie de impartire (cu signatura rescrisa polimorfic) folosind obiectul “”, obtinem o lista de liste de linii, unde fiecare lista de linii reprezinta codificarea unui AFD.

Organizarea codului

Tipuri intuitive

Signaturile functiilor voastre pot deveni lungi. Va recomandam sa le scrieti voi insiva (va ajuta la implementarea corecta), si sa introduceti, de fiecare data cand simtiti nevoia, type-def-uri menite sa faca citirea codului mai usoara. Exemplu:

type Index = Integer
type State = Integer
 
f :: State -> Index -> ...
f = ...

Igiena codului

Cautati sa evitati implementarile mamut in detrimentul izolarii functionalitatii in functii simple, cu signaturi scrise de voi, si cu comentarii intuitive. De multe ori, functiile pot fi atat de scurte, incat pot fi scrise inline. Va incurajam sa folositi $ impreuna cu . si sa formatati cat mai intuitiv codul.

Exemplu negativ:

f l = zipWith (\x y-> (x:y)) (head (take 20 ((reverse (tail l)))) (tail (reverse l))

Exemplu pozitiv:

f l = zipWith (:) l1 l2
       where l1 = head           -- i am extracting the first element of the list because... 
                   $ (take 20)   -- i only take the first 20 elements because ...
                   $ reverse     -- the list must be reversed
                   $ tail l
             l2 = (tail . reverse) l -- i am composing two transformations over l

Helper functions

  • Vizitati functiile din biblioteca Data.Map si Data.Set. Unele pot fi foarte utile pentru implementare
  • Refolositi functia splitBy de la PP.
  • Aruncati o privire peste functia sortOn. Este mai simplu de folosit si mai performanta decat sortBy.
  • Aruncati o privire peste list comprehensions. Pot fi utile local.
  • Nu uitati de tipuri precum Data.Maybe si Data.Either, ar putea fi utile in anumite contexte.

Debugging

Cel mai probabil, la baza implementarii voastra va sta o functie de forma:

lexer :: InputString -> [TokensAndTheirDfas] -> Output

care va fi tail-recursive, si la tipul careia veti adauga diverse alte componente/acumulatori. Debugging-ul acesteia poate fi dificil, in special daca functia cicleaza. O modalitate simpla de debugging este sa adaugati functiei voastre doua variabile, modificand output-ul astfel:

auxLexer :: InputString -> [TokensAndTheirDfas] -> String -> Integer -> (String,Output)
auxLexer _ _ _ 100 = ...

unde:

  • variabila de tip String va codifica elemente de logging utile pentru debugging (e.g. starea sirului, starile curente ale AFD-urilor). La fiecare apel recursiv puteti adauga informatii la logging pentru a urmari mai usor executia functiei. La final, puteti intoarce informatiile de logging, impreuna cu outputul.
  • variabila de tip Integer va reprezenta numarul de apeluri recursive. Cand o limita (hardcodata) este atinsa, functia intoarce indiferent de valoarea parametrilor. In felul asta, puteti inspecta logging-ul si in situatia cand functia cicleaza. Nu uitati sa incrementati la fiecare apel recursiv variabila in cauza.
  • puteti pastra aceasta forma si in implementarea voastra finala ( imbracand functia auxLexer cu o alta functie care ascunde detaliile de logging)