This is an old revision of the document!


Schelet si checker pentru fiecare limbaj:

Deadline etapa 2: 12 decembrie ora 23:59

Proiect

Proiectul consta in implementarea unui lexer in python sau scala.

Un lexer este un program care imparte un sir de caractere in subsiruri numite lexeme, fiecare dintre acestea fiind clasificat ca un token, pe baza unei specificatii.

Care este input-ul unui lexer?

Lexer-ul primeste initial o specificatie de forma:

TOKEN1 : REGEX1;

TOKEN2 : REGEX2;

TOKEN3 : REGEX3;

...

unde fiecare TOKENi este un nume dat unui token, iar REGEXi este un regex ce descrie lexemele ce pot fi clasificate ca acel token. Puteti imagina aceasta specificatie ca un fisier de configurare, care descrie modul in care va functiona lexerul pe diverse fisiere de text.

Inputul efectiv al unui lexer este un text care va fi impartit in lexeme folosind expresii regulate. In cursurile viitoare veti afla mai multe detalii despre cum functioneaza si cum sunt implementate lexerele.

Care este output-ul unui lexer?

Lexer-ul are ca output o lista de forma : [(lexema1, TOKEN_LEXEMA_1), (lexema2, TOKEN_LEXEMA_2), …], unde TOKEN_LEXEMA_N este numele token-ului asociat lexemei n, pe baza specificatiei.

Datorita dificultatii lucrului direct cu regex-uri pentru verificarea apartenentei unui cuvant in limbaj, lexerele reale trec prin cateva etape intermediare inainte de inceperea analizei textului.

Etapa 2 consta in conversia unui Regex in forma prenex.

Forma prenex a fost prezentata in etapa precedenta. In aceasta etapa ne vom ocupa de parsarea Regexurilor scrise in forma prezentata la curs.

Forma standard a expresiilor regulate

Avantajul formei Prenex este ca folosirea parantezelor nu mai este necesara pentru a specifica prioritatea operatiilor. In forma standard trebuie insa sa avem grija la prioritatea operatiilor pentru a evalua corect o expresie regulata.

Facem o scurta analogie cu ordinea operatiilor aritmetice: + si -, * si /, respectiv paranteze pentru a intelege mai usor ordinea operatiilor din Regex-uri.

Prioritatea operatiilor aritmetice este: paranteze (), inmultiri sau impartiri (* sau /), adunari sau scaderi (+ sau -). Astfel, expresia a + b*c - (d + e*f)/g se va transforma in urmatorul AST:

Putem observa ca paranteza se evalueaza inaintea inmultirilor, iar inmultirile inaintea adunarilor.

Similar, prioritatea pentru Regex-uri este paranteze (), star *, concat (nu are un simbol asociat) si union |.

De exemplu (a|b)*c|d, care va genera arborele:

Din arbore putem genera usor forma prenex: UNION CONCAT STAR UNION a b c d. Nu este necesar sa implementati per se AST-ul, insa prin ierarhia de clase acesta este intrisec.

Implementarea consta in parsarea unui Regex si transformarea sa in forma Prenex. Pentru acest lucru se recomanda folosirea unui AST - Abstract Syntax Tree.

Pentru a parsa un Regex, avem nevoie de o stiva care sa retina parti ale expresiei / operatii parsate deja. Vom interactiona in doua feluri cu stiva:

  • reducerea expresiilor (sau cooling):
    • Exemplul 1: daca pe stiva avem: 0 | Star(?) | … , atunci vom inlocui cele doua expresii cu : Star(0) | …
    • Exemplul 2: daca pe stiva avem: 0 | 0 | Concat(?,?) | … , rezultatul reducerii va fi: Concat(0,0) | …
  • adaugarea expresiilor: vom citi operatorul sau operandul curent, si vom adauga elementele corespunzatoare pe stiva.

Trebuie sa avem grija la ordinea operatiilor pentru a putea traduce expresia corect: de exemplu, daca intalnim operatorul * (star), ar trebui sa facem un cooling, fiind un operator unar cu cea mai mare prioritate.

Implementarea voastra trebuie sa combine in mod eficient adaugarea cu reducerea.

Pe langa caracterele alfa-numerice si operatiile de baza star, concat si union, in aceasta etapa vom testa si:

  1. doua operatii noi:
    1. plus (+) - expresia asupra careia este aplicat apare de 1 data sau mai multe ori.
    2. semnul intrebarii (?) - expresia asupra careia este aplicat apare o data sau niciodata.
  2. 3 syntactic sugars:
    1. [a-z] - orice caracter litera mica din alfabetul englez
    2. [A-Z] - orice caracter litera mare din alfabetul englez
    3. [0-9] - orice cifra
  3. escaparea anumitor caracter de tipul '\n', '\t'. In general 'c', unde c este un caracter va fi interpretat drept c.

Pentru a facilita diferenta intre caractere si operatori aveti in schelet doua clase (Character, Operator in Python, respectiv Left, Right in Scala).

Operatiile + si ? pot fi transformate direct in echivalentul lor. De exemplu a+ va fi transformat in aa*, iar a? in a|eps.

Testarea este similara cu cea de la etapa 1. Mai mult, este necesara implementarea intregii etape 1, deoarece vom testa comportamentul corect al DFA-ului construit si nu rezultatul transformarii. Astfel, la etapa 2 veti obtine o forma prenex dintr-un Regex: Regex → Prenex, pe care o sa il dam mai departe in transformarea facuta la etapa 1 Prenex → NFA → DFA (→ MinDFA eventual). La final testam daca DFA-ul construit accepta/respinge un set de cuvinte.

Verificarea corectitudinii implementarii voastre se va face automat, printr-o serie de teste unitare, o parte punctate si o parte nepunctate. Aceste teste nu acopera fiecare caz posibil si testeaza doar comportarea corecta a AFD-urilor obtinute din cateva expresii regulate, pe cateva secvente reprezentative.

Sunteti incurajati sa va adaugati propriile teste:

  1. pentru a asigura corectitudinea pe mai multe cazuri simple sau intermediare
  2. pentru a testa alte componente intermediare ale codului vostru (de exemplu parsarea corecta a expresiilor in forma prenex si construirea unui arbore corect pentru acestea).

O abordare eficienta, economica dpdv al timpului, de scris cod poate fi sumarizata astfel:

  1. scriem un test pentru o functie/componenta noua, sau o parte bine determinata a acesteia (e.g. parsarea corecta a reuniunii a doua regexuri)
  2. scriem implementarea pentru acea componenta
  3. folosim eventuale afisari doar pentru debugging, atunci cand nu este evident de ce un test pica
  4. cand un test trece, trecem la urmatoarea componenta
  5. cand e necesar sa modificam componente la care am lucrat anterior, re-rulam toate testele anterioare, pentru a ne asigura ca modificarea nu a afectat corectitudinea codului
Folderul care contine testele va fi suprascris de checker, testele luate in considerare pentru nota fiind doar cele din skeletul de cod.

Python

Pentru rularea testelor folositi comanda python3 -m unittest. Aceasta comanda va detecta automat testele definite in folder-ul test si le va rula pe rand, afisand la final testele care au esuat, daca exista.

Pentru a va defini propriile teste, creati o noua clasa in folderul test care sa extinda clasa unittest.TestCase si creati cate o metoda pentru fiecare test. Numele acestor metode trebuie sa inceapa cu test pentru a fi recunoscute ca fiind cazuri de testare. Pentru a indica comportamentul testat de fiecare test putem folosi metodele de tipul self.assert…(). Unele dintre cele mai frecvent folosite astfel de metode sunt:

  • self.assertTrue(expression_expected_to_be_true) si self.assertFalse(expression_expected_to_be_false)
  • self.assertEqual(expression, expected_value_of_expression)
  • self.assertIn(expression, list_of_possible_expected_values_of_expression)

Daca in cadrul unui test vreuna din asertii nu este indeplinita cazul de test este marcat ca esuat.

Scala

Pentru rularea testelor, puteti folosi interfata pusa la dispozitie de IntellIJ. Daca folositi doar command-line, folositi comanda sbt test.

Aceasta comanda va rula testele definite in folderul src/test/scala si va afisa cu verde testele terminate cu succes si cu rosu testele esuate.

Pentru definirea propriilor teste, creati o noua clasa in folderul src/test/scala care sa extinda clasa munit.FunSuite, in corpul careia puteti sa adaugati oricate teste sub forma:

test("nume test") {
    // instructiuni si asertii
    assert(booleanValue) // -> testul va esua daca booleanValue se evalueaza la fals
}
In radacina proiectului trebuie pus un fisier intitulat ID.txt ce va avea pe prima linie a sa ID-ul vostru anonim (ar trebui sa il fi primit pe mail, dar daca din vreun motiv nu il aveti, luati legatura cu asistentul vostru) si pe a doua linie limbajul in care rezolvati tema (python sau scala)

Exemplu de continut pentru ID.txt:

9921225
scala

sau

9246163
python

Structura arhivei (Python)

.
├── ID.txt
└── src
    ├── DFA.py
    ├── __init__.py
    ├── NFA.py
    ├── Regex.py
    ├── Parser.py
    ... (alte surse pe care le folositi)

Structura arhivei (Scala)

.
├── build.sbt
├── ID.txt
└── src
    └── main
        └── scala
            ├── Dfa.scala
            ├── Nfa.scala
            ├── Regex.scala
            ... (alte surse pe care le folositi)
Pentru niciunul din limbaje nu este necesar sa includeti folder-ul cu teste, dar includerea sa nu va cauza erori.