1. Introducere Curs1 LFT Limbajele de nivel înalt au o serie de avantaje în raport cu limbajele de asamblare.

Pentru a putea însă folosi limbaje de nivel înalt, trebuie să existe posibilitatea de a converti programele scrise în aceste limbaje într-o formă binară. Această necesitate a dus la dezvoltarea translatoarelor sau compilatoarelor – programe care acceptă o reprezentare textuală a unui algoritm exprimat printr-un program sursă şi care produc o reprezentare a aceluiaşi algoritm exprimat într-un alt limbaj, limbajul obiect sau un limbaj echivalent. Translatorul este deci un program care traduce programele scrise de utilizator (într-un limbaj) în programe echivalente (într-un alt limbaj). Dacă acestea din urmă sunt programe în cod maşină sau un limbaj apropiat de limbajul calculatorului, translatorul se numeşte compilator. Programul utilizatorului se numeşte program sursă, iar programul în cod maşină obţinut se numeşte program obiect. Între cele două programe trebuie să existe o relaţie de echivalenţă în ceea ce priveşte efectul lor asupra calculatorului. Execuţia unui program în limbaj simbolic are loc în două faze: Faza 1. Compilarea: Program sursă ⇒ Compilator ⇒ Program obiect Faza 2. Execuţia propriu-zisă: Date iniţiale ale programului ⇒ Program obiect ⇒ Rezultate În faza de translatare, calculatorul execută programul compilator, iar în faza de execuţie propriu-zisă, calculatorul execută programul obiect, adică citirea datelor iniţiale, prelucrarea lor şi memorarea sau tipărirea rezultatelor. Pentru scrierea unui compilator, trebuiesc foarte bine definite atât limbajul sursă, cât şi limbajul ţintă. Aceasta înseamnă că ambele limbaje trebuie să fie formale. Un limbaj are două aspecte: sintaxă şi semantică. Sintaxa stabileşte care text este corect din punct de vedere gramatical, iar semantica stabileşte modul în care se derivă semnificaţia unui text corect din punct de vedere gramatical. Există numeroase formalisme şi instrumente software pentru descrierea sintaxei unui limbaj formal. Pentru descrierea semanticii însă, formalismele şi instrumentele existente nu sunt atât de simple şi uşor de utilizat ca şi specificaţiile de sintaxă. 2. Clasificarea şi structura translatoarelor Un translator poate fi definit formal ca o funcţie având domeniul de definiţie limbajul sursă şi mulţimea valorilor funcţiei limbajul obiect sau un limbaj echivalent (destinaţie). Instrucţiunile Instrucţiunile limbajului Translator limbajului sursă destinaţie În dezvoltarea translatoarelor, sunt implicate cel puţin trei limbaje: limbajul sursă de translatat, limbajul obiect sau destinaţie şi limbajul gazdă folosit la implementarea translatorului. Dacă translatarea are loc în mai multe etape, pot exista şi alte limbaje intermediare. Desigur, limbajul gazdă şi limbajul obiect nu sunt cunoscute de utilizatorul limbajului sursă. 2.1. Diagrame T Pentru descrierea programelor şi în particular a compilatoarelor, există o reprezentare schematică consacrată, numită diagramă T, introdusă de Bratman în 1961. O diagramă T pentru un program general este de forma: Numele programului Limbajul de implementare

Date de intrare

Date de ieşire

1

O diagramă T pentru un translator general este de forma: Numele translatorului Limbajul gazdă de implementare a translatorului 2.2. Clasificarea translatoarelor. - Asamblorul. Termenul de asamblor este asociat cu translatoarele care transformă instrucţiuni scrise în limbaj de nivel coborât în cod maşină, care poate fi executat direct. De obicei liniile individuale ale programului sursă corespund cu o instrucţiune la nivel maşină. Rolul asamblorului este deci să convertească reprezentările simbolice ale instrucţiunilor în configuraţii de biţi, reprezentând echivalentele în cod-maşină ale instrucţiunilor. - Macroasamblorul este un asamblor care permite utilizarea macrodefiniţiilor. El utilizează o primă trecere şi pentru colectarea macrodefiniţiilor. Rezultatul asamblării este un text în formă binară în care doar referinţele externe sunt păstrate în formă simbolică în tabele asociate secţiunilor. Codul binar al secţiunilor este însoţit de informaţii ce indică locul referinţelor relocabile pentru ca, în momentul încărcării, valorile acestora să se poată transforma în referinţe absolute. Combinarea acestor secţiuni într-un program executabil se face prin rezolvarea referinţelor externe (înlocuirea numelor simbolice cu adrese de memorie) şi adăugarea eventual a rutinelor din bibliotecile standard, şi ele păstrate sub formă relocabilă. Aceste operaţii sunt deobicei făcute de un editor de legături ( linkage editor). Programul furnizat de acesta este adus în memorie de încărcător (loader). - Compilatorul este de obicei un translator care traduce instrucţiuni de nivel înalt în cod maşină, care poate fi executat direct. Liniile individuale din programul sursă corespund de obicei cu mai multe instrucţiuni la nivel maşină. - Preprocesorul este un translator care traduce dintr-un superset al unui limbaj de nivel înalt în limbajul de nivel înalt original, sau care face simple substituiri de text înainea procesului de translatare propriu-zis. De exemplu, există numeroase preprocesoare de FORTRAN structurat care traduc din versiuni structurate ale FORTRAN-ului în FORTRAN obişnuit. - Translatorul de nivel înalt este un translator care traduce un limbaj de nivel înalt într-un alt limbaj de nivel înalt, pentru care există deja compilatoare sofisticate pentru un număr mare de maşini. - Interpretorul este un program care, primind un program sursă, îl prelucrează în prealabil pentru a-l aduce într-o formă mai simplă, după care îl execută simulând execuţia în limbaj sursă. Forma intermediară executată de de interpretor este de fapt un alt limbaj mai simplu, mai apropiat de limbajele de asamblare. Principalul avantaj al folosirii unui interpretor este portabilitatea foarte simplă a unui limbaj, prin implementarea maşinii virtuale pe un nou hardware. În plus, deoarece instrucţiunile sunt interpretate şi executate în timpul rulării, pot fi implementate limbaje foarte flexibile. - Compilatoarele incrementale îmbină calităţile compilatoarelor cu cele ale interpretoarelor. Programul sursă este divizat de compilator în mici porţiuni numite incremente. Acestea prezintă o oarecare independenţă sintactică şi semantică faţă de restul programului. Incrementele sunt traduse de compilator. Execuţia are loc interpretativ, permiţându-se intervenţia utilizatorului atât în timpul compilării cât şi în timpul execuţiei. - Decompilatorul sau dezasamblorul sunt tremeni care se referă la translatoare care au ca intrare un cod obiect şi regenerează codul sursă într-un limbaj de nivel mai înalt. În timp ce acest lucru se poate realiza destul de bine pentru limbaje de asamblare, este mult mai dificil de implementat pentru limbaje de nivel înalt cum ar fi C, Pascal. Cele mai multe compilatoare nu produc cod maşină cu adrese fixe, ci o formă cunoscută sub numele de "semicompilat", "simbolic binar" sau formă relocatabilă. Rutinele astfel

Limbaj sursă

Limbaj destinaţie

2

compilate sunt legate cu ajutorul unor programe numite editoare de legături, linker, care pot fi privite ca ultima etapă în procesul de translatare. Limbajele care permit compilarea separată a părţilor unui program depind esenţial de existenţa acestor editoare de legături. Diagramele T pot fi combinate pentru a arăta interdependenţa translatoarelor, editoarelor de legături etc. COMPILATOR.EXE Limbajul gazd de ă implementare a compilatorului Program în cod relocabil L2

Program în limbaj surs L1 ă

Bibliotec de ă programe

Cod relocabil L2

LINK.EXE Limbajul gazd de ă implementare a editorului de leg turi ă

PROG.EXE

Observaţie. Un compilator nu necesită un limbaj ţintă (de asamblare sau limbaj maşină) real. De exemplu, compilatoarele Java generează cod pentru o maşină virtuală numită "Java Virtual Machine" (JVM). Interpretorul JVM interpretează apoi instrucţiunile JVM fără nici o translatare ulterioară. 2.3. Fazele translaţiei. Translatoarele sunt programe complexe, care necesită o abordare sistematică. Imaginea unui translator este cea a unui şir de transformări în cascadă a programului sursă în reprezentări din ce în ce mai apropiate de limbajul destinaţie. Procesul de translaţie se divide într-o serie de faze. O fază este o operaţie unitară de transformare a programului sursă dintr-o reprezentare în alta. Cea mai simplă descriere împarte procesul de translatare într-o fază analitică urmată de o fază sintetică. - În faza analitică se analizează programul sursă pentru a determina dacă el corespunde cu restricţiile sintactice şi static semantice impuse de limbajul sursă. - În faza sintetică se generează efectiv codul obiect în limbajul destinaţie. Componentele translatorului care îndeplinesc aceste faze majore se mai numesc "front end" şi "back end". Prima este total independentă de maşină, în timp ce a doua depinde puternic de maşina destinaţie. În cadrul acestei structuri există componente mai mici sau faze, aşa cum se prezintă în figura următoare: Secţiunea de gestionare caractere este cea care comunică cu lumea exterioară, prin sistemul de operare, pentru citirea caracterelor care formează textul sursă. Cum setul de caractere şi gestiunea fişierelor variază de la sistem la sistem, această fază este de obicei dependentă de maşină sau de sistem de operare. Analizorul lexical (Scanner) preia textul sursă sub forma unei secvenţe de caractere şi le grupează în entităţi numite atomi (tokens). Aceştia sunt simboluri ca identificatori, şiruri, constante numerice, cuvinte cheie cum ar fi while şi if, operatori ca <= etc. Atomilor li se atribuie coduri lexicale, astfel că, la ieşirea acestei faze, programul sursă apare ca o secvenţă de asemenea coduri.

-

3

Cod sursă

Gestiune caractere

Fază analitică (Front end)

Analizor lexical (Scanner)

Analizor sintactic (Parser)

Gestiune tabele

Analizor semantic

Raportare erori

Generator de cod intermediar

Fază sintetică (Back end)

Optimizator de cod

Generator de cod final

Cod obiect

-

-

-

Analizorul sintactic (Parser) are ca scop gruparea atomilor rezultaţi în urma analizei lexicale în structuri sintactice. O structură sintactică poate fi văzută ca un arbore ale cărui noduri terminale reprezintă atomi, în timp ce nodurile interioare reprezintă şiruri de atomi care formează o entitate logică. Exemple de structuri sintactice: expresii, instrucţiuni, declaraţii etc. Pe durata analizei sintactice, de obicei are loc şi o analiză semantică, adică efectuarea unor verificări legate de compatibilitatea tipurilor datelor cu operaţiile în care ele sunt implicate, de respectarea regulilor de vizibilitate impuse de limbajul sursă. Generatorul de cod intermediar este o fază sintetică care, în practică, poate fi integrată în faze anterioare ori poate fi omisă în cazul translatoarelor foarte simple. În această fază are loc transformarea arborelui sintactic într-o secvenţă de instrucţiuni simple, similare macroinstrucţiunilor unui limbaj de asamblare. Diferenţa dintre codul intermediar şi un limbaj de asamblare este în principal aceea că, în codul intermediar nu se specifică registrele utilizate în operaţii. Exemple de reprezentări pentru codul intermediar: notaţia postfix, instrucţiunile cu trei adrese etc. Codul intermediar prezintă avantajul de a fi mai uşor de optimizat decât codul maşină.

4

-

-

-

-

Optimizatorul de cod este o fază opţională cu rolul de a modifica porţiuni din codul intermediar generat, astfel încât programul rezultat să satisfacă anumite criterii de performanţă vizând timpul de execuţie şi/sau spaţiul de memorie ocupat. Generatorul de cod final este faza cea mai importantă din secţiunea "back end" . În această fază se preia ieşirea de la faza precedentă şi se generează codul obiect, prin decizii privind locaţiile de memorie pentru date, generarea de coduri de acces pentru aceste date, selectarea registrelor pentru calcule intermediare şi indexare etc. Astfel, instrucţiunile din codul intermediar (eventual optimizat) sunt transformate în instrucţiuni maşină (sau de asamblare). Unele translatoare continuă cu o fază numită "peephole optimizer" în care se fac încercări de reducere a unor operaţii inutile prin examinarea în detaliu a unor secvenţe scurte din codul generat. Un translator foloseşte inevitabil o structură de date complexe, numită tabela de simboluri. Această tabelă memorează informaţii despre simbolurile folosite în programul sursă şi asociază proprietăţi acestor simboluri (tipul lor, spaţiul de memorie necesar pentru variabile sau valoarea lor pentru constante). Compilatorul face referire la această tabelă aproape în toate fazele compilării. Tratarea erorilor. Un compilator trebuie să fie capabil să recunoască anumite categorii de erori care apar în programul sursă. Tratarea unei erori presupune detectarea ei, emiterea unui mesaj corespunzător şi revenirea din eroare, adică, pe cât posibil, continuarea procesului de compilare până la epuizarea textului sursă, astfel încât numărul de compilări necesare eliminării tuturor erorilor dintr-un program să fie cât mai mic. Practic, există erori specifice fiecărei faze de compilare.

2.4. Translatoare cu mai multe treceri Deşi conceptual procesul de translatare este divizat în faze, deseori translatoarele sunt divizate în treceri, în care fazele pot fi combinate ori întreţesute. Tradiţional, o trecere citeşte programul sursă, sau ieşirea unei treceri anterioare, face unele transformări şi scrie ieşirea sa într-un fişier intermediar care va fi citit de o trecere ulterioară. Aceste treceri pot fi gestionate de diferite părţi integrate în acelaşi compilator, sau pot fi gestionate prin rularea a două sau mai multe programe separate. Trecerile pot comunica folosind forme specializate ale propriului lor limbaj intermediar, sau pot folosi structuri de date interne în loc de fişiere, dar se pot face şi mai multe treceri folosind acelaşi cod sursă original. Numărul trecerilor depinde de o varietate de factori. Unele limbaje necesită cel puţin două treceri pentru a genera mai uşor codul obiect. Compilatoarele cu mai multe treceri folosesc de obicei mai puţină memorie şi sunt mai performante în optimizarea codului şi raportarea de erori, dar sunt mai lente decât cele cu o singură trecere. În practică, cel mai des sunt folosite compilatoare cu două treceri, în care prima trecere este un translator de nivel înalt care converteşte programul sursă în limbaj de asamblare, sau chiar întrun alt limbaj de nivel înalt pentru care există deja un translator eficient. 2.5. Interpretoare, compilatoare incrementale, emulatoare Compilatoarele descrise mai sus au câteva proprietăţi comune: - Produc cod obiect care poate rula cu viteza completă a maşinii ţintă. - Uzual compilează o secvenţă întreagă de cod înainte de orice execuţie. În unele medii interactive, există necesitatea rulării unor părţi ale aplicaţiei fără a fi necesară pregătirea aplicaţiei în ansamblu, ori se permite utilizatorului modificarea din mers a acţiunii următoare. Astfel de sisteme folosesc deseori un interpretor. Interpretorul este un translator care acceptă efectiv un program sursă şi îl execută direct, fără a produce aparent nici un cod obiect. Interpretorul preia din programul sursă instrucţiunile una câte una, le analizează şi le "execută" una câte una. Evident, pentru ca un astfel de scenariu să poată funcţiona, este necesară impunerea unor restricţii severe programului sursă. Nu pot fi folosite structuri complexe de program, cum ar fi de exemplu proceduri imbricate dar prezintă avantajul unor interogări on-line pentru baze de date etc.

5

Compilatoarele incrementale îmbină calităţile compilatoarelor cu cele ale interpretoarelor. Programul sursă este divizat de compilator în mici porţiuni numite incremente care prezintă o oarecare independenţă sintactică şi semantică faţă de restul programului. Compilatorul produce un cod incremental care este suficient de simplu pentru a satisface restricţiile impuse de un interpretor. Interpretorul "execută" algoritmul original prin simularea unei maşini virtuale pentru care codul intermediar numit şi pseudocod este efectiv codul maşină. Distincţia dintre codul maşină şi pseudocod este ilustrată în figura următoare: Instrucţiunile limbajului sursă Etapa 1 Cod intermediar (pseudocod) Etapa 2 Instrucţiuni în cod maşină

(încărcat)

(încărcat) Execuţie

Interpretor

Dacă se parcurge numai etapa 1 de compilare, pseudocodul este intrare în interpretor. Dacă se parcurge şi etapa 2, rezultatul este un program obiect cu instrucţiuni în cod maşină care poate fi lansat în execuţie independent. Desigur, orice maşină reală poate fi văzută ca un interpretor specializat care preia din programul sursă instrucţiunile una câte una, le analizează şi le "execută" una câte una. Într-o maşină reală această execuţie este realizată prin hardware, deci mult mai rapid. Se poate conclude că se pot scrie programe care permit unei maşini reale să emuleze orice altă maşină reală, cu dezavantajul vitezei reduse. Aceste programe sunt numite emulatoare şi sunt uzual folosite în proiectarea de noi maşini şi a software-ului care va rula pe acestea. Una din cele mai cunoscute aplicaţii de compilator incremental portabil este "Pascal–P" (Zurich 1981) care constă din 3 componente: - Un compilator Pascal, scris într-un subset foarte complet al limbajului, numit Pascal-P. Scopul acestui compilator este translatarea programelor sursă Pascal-P într-un limbaj intermediar foarte bine definit şi documentat, numit P-code, care este "codul maşină" pentru un calculator ipotetic bazat pe stivă, calculator numit P-machine. - O versiune compilată a primului compilator, astfel încât primul obiectiv al compilatorului este compilarea lui însuşi. - Un interpretor pentru limbajul P-code scris în Pascal. Interpretorul a servit în principal ca model pentru scrierea unor programe similare pentru alte maşini, în scopul emulării unei maşini ipotetice P-machine. 3. Analiza lexicală (scanner) C2 Este prima fază a procesului de compilare şi are rolul de a transforma programul sursă, văzut ca un şir de caractere într-un şir de simboluri numite atomi lexicali (tokens). Mulţimea tuturori atomilori lexicali detectabili în programul sursă se împarte în clase de atomi: - clasa identificatorilor - clasa constantelor întregi (numerelor întregi) - clasa numerelor reale - clasa operatorilor - clasa cuvintelor cheie. În urma analizei lexicale, fiecare atom lexical identificat primeşte o codificare internă, iar programul sursă se transformă într-un şir de coduri aranjate în ordinea detectării atomilor. Deşi rolul principal al analizei lexicale este detectarea atomilor lexicali, putem vorbi de operaţii conexe analizei lexicale, cum sunt: eliminarea spaţiilor şi comentariilor, numărarea liniilor sursă (pentru raportarea de erori) etc. 3.1. Descriere 3.1.1. Analiza lexicală ca etapă specifică a compilării

6

Analiza lexicală este o interfaţă între programul sursă şi analizorul sintactic (parser). Rolul analizorului lexical este asemănător cu cel al analizorului sintactic: - identificarea conform anumitor reguli a unităţilor distincte în cadrul programului semnalarea de erori în cazul abaterii de la aceste reguli codificarea unităţilor identificate etc. Funcţiile analizorului lexical ar putea fi preluate de analizorul sintactic. Cu toate acestea, în majoritatea cazurilor, se preferă separarea celor două activităţi în faze distincte din următoarele motive: a) analizorul lexical este mare consumator de timp deoarece necesită preluarea înregistrare cu înregistrare a textului de pe suportul extern, acesul la fiecare caracter, comparaţii ale atomilor lexicali cu mulţimi de caractere cunoscute în vederea clasificării, căutari în tabele etc. De aceea, pentru a obţine un analizor lexical mai eficient se recomandă implementarea analizorului în limbaj de asamblare, spre deosebire de celelalte faze în care implementarea se face în limbaje de nivel înalt. b) textul rezultat în urma analizei lexicale, deci cel primit de analizorul sintactic este mai simplu, adică sunt eliminate spaţiile şi comentariile, numărul atomilor lexicali este mult mai mic decât numărul caracterelor din textul sursă. Analizorul lexical preia astfel sarcina analizei unor construcţii dificile care ar complica şi mai mult procesul de analiză sintactică. c) sintaxa atomilor lexicali este mai simplă decât a construcţiilor gramaticale de limbaj, se poate exprima prin gramatici regulate şi se poate analiza cu ajutorul automatelor finite, existând tehnici mai simple decât pentru analiza sintactică; d) prin separarea fazelor, compilatorul poate fi scris modular, deci realizat în echipă; e) separarea creşte portabiliatatea compilatorului în sensul că pentru o versiune nouă a limbajului va fi necesar să facem modificări doar la analiza lexicală, nu şi la analiza sintactică. 3.1.2. Modele de comunicare analizor lexical-analizor sintactic. Din punct de vedere al interacţiunii dintre analizorul lexical şi cel sintactic există 3 posibilităţi: 1. Analizorul lexical procesează textul sursă într-o trecere separată, înainte de a începe analiza sintactică; în acest caz, cuvintele sunt extrase din programul sursă şi depuse într-un fisier sau într-un tablou de mari dimensiuni în memorie. 2. Analizorul sintactic apelează analizorul lexical ori de câte ori acesta are nevoie de un nou cuvânt; este varianta preferată, deoarece nu este necesară construirea unei forme interne a programului sursă înainte de analiza sintactică. Un alt avantaj al metodei constă în faptul că pentru aceleaşi limbaj pot fi construite analizoare lexicale multiple, în funcţie de suportul de stocare a textului sursă. 3. Cele două analizoare funcţionează în regim de corutină, adică cele 2 faze sunt simultan active, transferând controlul una alteia atuci când este necesar. Modelul ales în continuare este cel de analizor sintactic care apelează analizorul lexical (2). 3.2. Noţiuni specifice analizorului lexical. 3.2.1. Codificarea atomilor lexicali Mulţimea atomilor detectabili este organizată în clase de atomi. Fiecare atom detectat în programul sursă primeşte o codificare internă (şir de informaţii) care descrie complet atomul respectiv: - clasa fiecarui atom - valoarea sa (dacă este un număr) sau adresa unde poate fi găsit dacă este un şir de caractere. Codul lexical este un număr întreg ce identifică atomul şi care este asociat în felul următor: • dacă atomul lexical aparţine unei clase cu număr cunoscut de elemente (clasa operatorilor, clasa cuvintelor cheie), fiecărui atom al clasei i se asociază un număr distinct. De exemplu: pentru "+"codul 16; pentru "-" codul 18; etc. Astfel, prin acest număr intern, atomul fi identificat complet. • clasei cu un număr nedeterminat de elemente (posibil infinit) i se asociază un unic cod intern; distincţia dintre atomii ce aparţin unei asemenea clase se face prin suplimentarea codului clasei cu alte informaţii. Astfel, la codul intern se adaugă adresa din tabela de simboluri, în timp ce în tabela de simboluri se memorează un identificator.

7

De exemplu: - un simbol a va primi ca şi codificare codul clasei simbolurilor şi o adresă care pointează spre tabela de simboluri -o constantă va primi ca şi codificare codul clasei constantelor şi valoarea constantei. a clasa valoare 1 adresa tabela de simboluri a 25 clasa valoare 2 25

identificator valoare Informaţiile suplimentare ataşate codului clasei atomului lexical se numesc atribute. În majoritatea cazurilor, ca şi în cel de sus, este suficient un singur atribut: valoarea constantei (numere întregi, reale) sau valoarea adresei din tabela de simboluri. Avantajul codificării atomilor lexicali constă în preluarea unitară de către analizorul sintactic a datelor furnizate de analizorul lexical, în sensul că analizorul sintactic nu va prelua atomii lexicali (şiruri de caractere de lungime variabilă), ci numere, codificări ale atomilor. Exemplu: Deseori, un atom (token) are structura din secvenţa următoare: #ifndef LEX_H #define LEX_H typedef enum { NAME, NUMBER, LBRACE, RBRACE, LPAREN, RPAREN, ASSIGN, SEMICOLON, PLUS, MINUS, ERROR } TOKENT; typedef struct { TOKENT type; union { int value; /* type == NUMBER */ char far* name; /* type == NAME */ }info; } TOKEN; extern TOKEN lex(); /*functial lex() e definita altundeva)*/ #endif LEX_H Funcţia lex() este cea care returnează următorul atom din textul sursă. 3.2.2 Observaţii Unele limbaje de programare , îndeosebi cele vechi, conţin unele particularităţi care îngreunează procesul de analiză lexicală. De exemplu FORTRAN şi COBOL impun o anumită structură a programului sursă pe mediul de intrare. Limbajele moderne au în exclusivitate formatul liber pe fişierul de intrare, aranjarea instrucţiunilor pe linii fiind făcută pe criterii de claritate şi lizibilitate. În ALGOL 68, spaţiile sunt nesemnificative, ceea ce duce la îngreunarea identificării atomilor în anumite instrucţiuni. Există limbaje de programare în care cuvintele cheie nu sunt rezervate (PL/I), urmând ca analizorul lexical să deosebească din context cuvintele cheie de cele rezervate. 3.3. Construirea unui analizor lexical. Pentru construirea analizorului lexical se au în vedere următoarele aspecte: recunoaşterea atomilor lexicali (cu genererarea codurilor de eroare în caz de eşec), furnizarea către analizorul sintactic a unei ieşiri ce codifică atomii lexicali şi introducerea în tablele compilatorului a datelor obţinute în această fază (identificatori , constante). Un analizor lexical poate fi construit manual sau cu ajutorul unui generator de analizoare lexicale. - Construirea manuală a analizorului lexical înseamnă scrierea programului propriu zis pe baza unor diagrame realizate în prealabil, care precizează structura atomilor din textul sursă. Tehnica manuală asigură creerea unor analizoare lexicale eficiente, dar scrierea programului e monotonă, prezintă riscul unor erori, mai ales dacă există un număr mare de stări.

8

Secvenţa următoare prezintă o implementare manuală simplă a unui scanner: #include #include #include #include #include <stdio.h> <ctype.h> <stdlib.h> <string.h> / "lex.h"

static int state = 0; #define MAXBUF 256 static char buf[MAXBUF]; static char *pbuf; static char *token_name[] = { "NAME", "NUMBER", "LBRACE", "RBRACE", "LPAREN", "RPAREN", "ASSIGN", "SEMICOLON", "PLUS", "MINUS", "ERROR" }; static TOKEN token; char far* dup_str ; /* Acest cod nu e complet. Nu se testeaza depasirea bufferului, etc*/ TOKEN *lex() { char c; while (1) switch(state) { case 0: /* pentru unul din 1,4,6,8,10,13,15,17,19,21,23 */ pbuf = buf; c = getchar(); if (isspace(c)) state = 11; else if (isdigit(c)) { *pbuf++ = c; state = 2; } else if (isalpha(c)) { *pbuf++ = c; state = 24; } else switch(c) { case '{': state = 5; break; case '}': state = 7; break; case '(': state = 9; break; case ')': state = 14; break; case '+': state = 16; break; case '-': state = 18; break; case '=': state = 20; break; case ';': state = 22; break; default: state = 99; break; } break; case 2: c = getchar();

9

if (isdigit(c)) *pbuf++ = c; else state = 3; break; case 3: token.info.value= atoi(buf); token.type = NUMBER; ungetc(c,stdin); state = 0; return &token; break; case 5: token.type = LBRACE; state = 0; return &token; break; case 7: token.type = RBRACE; state = 0; return &token; break; case 9: token.type = LPAREN; state = 0; return &token; break; case 11: c = getchar(); if (isspace(c)) ; else state = 12; break; case 12: ungetc(c,stdin); state = 0; break; case 14: token.type = RPAREN; state = 0; return &token; break; case 16: token.type = PLUS; state = 0; return &token; break; case 18: token.type = MINUS; state = 0; return &token; break; case 20: token.type = ASSIGN; state = 0; return &token; break; case 22: token.type = SEMICOLON; state = 0; return &token; break; case 24: c = getchar(); if (isalpha(c)||isdigit(c))

10

*pbuf++ = c; else state = 25; break; case 25: *pbuf = (char)0; dup_str= strdup(buf); /*aloca spatiu*/ token.info.name =dup_str; token.type = NAME; ungetc(c,stdin); state = 0; return &token; break; case 99: if (c==EOF) return 0; fprintf(stderr,"Caracter ilegal: \'%c\'\n",c); token.type = ERROR; state = 0; return &token; break; default: break; /* Nu se poate intampla */ } } int main() { TOKEN *t; while (((t=lex())!=0)) { printf("%s",token_name[t->type]); switch(t->type) { case NAME: printf(":%s\n",t ->info.name); break; case NUMBER: printf(":%d\n",t ->info.value); break; default: printf("\n"); break; } } return 0; } Fluxul procedurii lex() se poate reprezenta prin diagramele de tranziţii din figura următoare.

11

1

digit

2 digit

not(digit)

3*

4

{

5

6

}

7

23

letter

24

not(letter|digit)

25*

8

(

9

letter|digit 10 sp 11 sp 15 1. + 16 17 18 19 = 20 21 ; 22 not(sp) 12* 13 ) 14

2.

La preluarea unui nou atom (de exemplu la intrarea în lex() ) folosim starea specială state 0 pentru a reprezenta faptul că nu am decis încă ce diagramă să urmăm. Alegerea e făcută pe baza următorului caracter de intrare Uneori, de exemplu pentru atomul LBRACE atomul e recunoscut imediat prin scanarea ultimului caracter din atom. Pentru alţi atomi însă, de exemplu pentru NUMBER, cunoaştem lungimea atomului numai după citirea unui extracaracter care nu aparţine numărului (stări notate cu *). În acest caz, caracterul în plus trebuie returnat la intrare. Dacă citim un caracter care nu corespunde cu o secvenţă acceptată, se returnează atomul special ERROR.

Diagramele de tranziţie sunt grafuri orientate şi etichetate în care nodurile simbolizează stările, iar arcele trecerea (tranziţia) dintr-o stare în alta. - Generarea automată a analizorului lexical presupune conceperea unui program de traducere (un fel de compilator) care primeşte la intrare într-un limbaj de specificare, atât structura atomilor lexicali, cât şi eventualele acţiuni semantice care trebuiesc realizate împreună cu analiza lexicală. Ieşirea unui astfel de compilator va fi un program de analiză lexicală. Un astfel de compilator poate fi aplicat unei clase mai largi de limbaje.

4. Noţiuni generale de limbaje formale. C3 Limbajele de programare sunt modelate matematic în cadrul limbajelor formale. Studiul modelării limbajelor de programare are în vedere în primul rând, structurile finite care permit dezvoltarea de limbaje cu un număr infinit de fraze. Calea uzuală de descriere formală a unui limbaj este de a folosi o gramatică pentru acel limbaj. O gramatică G este definită de 4 componente: - O mulţime finită T de simboluri care pot apare în frazele limbajului, numite simboluri terminale, sau primitivele limbajului. - O mulţime infinită N de simboluri neterminale, sau categorii sintactice, care sunt utilizate pentru descrierea limbajului, dar nu apar în frazele acestuia. Deci, mulţimile T şi N sunt disjuncte. - O mulţime P de reguli de generare sau producţii care descriu aspectele constructive ale acestuia

12

-

Un simbol neterminal special S care apare doar într-o singură producţie din mulţimea P şi care se numeşte simbol iniţial, simbol de start sau axioma gramaticii. Producţiile sau regulile de generare din P arată cum pot fi construite toate frazele limbajului pornind de la simbolul neterminal S. G={N, T, P, S}, unde N, T, P, S au semnificaţiile menţionate,

Deci, cvadruplul: constituie o gramatică.

Un alfabet A reprezintă o mulţime finită şi nevidă de simboluri. Un simbol din A este reprezentat printr-o literă, cifră sau semn, uneori printr-un şir finit de litere, cifre sau semne. Se notează cu A* mulţimea aranjamentelor cu repetiţie ale simbolurilor din A, în care unele pot apărea de mai multe ori. Exemplu: Pentru A1={(,)}, următoarele şiruri sunt elemente din A1*: ( , ) (( ((( (()) () etc. În A* există un şir care nu conţine nici un simbol din A. Acest simbol, numit şir vid îl notăm cu ε . Un limbaj L peste alfabetul A este o submulţime a lui A*. Orice şir din A* care aparţine şi lui L este un simbol sau cuvânt al limbajului L. Evident, mulţimea A* este infinită, deci şi limbajul L poate reprezenta o mulţime infinită. Acest lucru înlătură orice abordare de tip enumerativ în definirea limbajului, fiind necesară o reprezentare finită a mulţimii infinite. Se disting două categorii de astfel de reprezentări: - reprezentarea (finită) sintetică care generează toate cuvintele limbajului şi corespunde noţiunii de gramatică. - reprezentarea (finită) analitică care permite recunoaşterea apartenenţei sau nonapartenenţei unei construcţii la limbajul considerat, reprezentare care corespunde noţiunii de automat sau analizor. Cu notaţile definite se construiesc mulţimile: A = N ∪ T şi A+ = A* - {ε }. Reuniunea A = N ∪ T reprezintă alfabetul sau vocabularul gramaticii. O producţie p∈ P din gramatica G reprezintă o transformare de forma: α→ β unde α ∈ A+ şi β ∈ A* . Fiind date γ şi δ două şiruri oarecare din A* se poate defini relaţia : γ α δ ⇒γ β δ care specifică transformarea şirului concaternat γ α δ în şirul γ β δ pe baza regulii de generare α→ β existentă în mulţimea P a producţiilor. Relaţia notată cu "⇒" exprimă doar o singură transformare de la un şir la altul în cadrul gramaticii G, dar ea poate fi extinsă pentru a exprima o întreagă succesiune de transformări, sub una din formele: α 1 ⇒* k α k sau α 1 ⇒+k α k Astfel se specifică că şirul α k este derivat (dedus) succesiv din şirul α 1 prin aplicarea unei serii de transformări, (derivare în k paşi) prin utilizarea şirului nul ε (relaţia ⇒* k) sau prin neutilizarea acestui şir (relaţia ⇒+ k). Se poate defini un limbaj L generat de gramatica G ca fiind alcătuit din acele simboluri terminale din G, numite propoziţii, care derivă din simbolul iniţial S: L(G) = { s | s ∈ T* şi S ⇒ s}

13

O propoziţie, notată mai sus cu s conţine în exclusivitate simboluri terminale. Orice şir de simboluri terminale şi neterminale derivat din axioma S este numit formă propoziţională. -derivarea canonică stânga - este un şir de transformări în care neterminalul care se expandează este întotdeauna cel mai din stânga neterminal al formei propoziţionale -derivarea canonică dreapta - este un şir de transformări în care neterminalul care se expandează este întotdeauna cel mai din dreapta neterminal al formei propoziţionale Operaţia inversă derivării se numeşte reducere. Un limbaj se poate descrie prin mai multe gramatici diferite. Două gramatici se spune că sunt echivalente dacă şi numai dacă limbajele generate de fiecare din acestea sunt identice. O gramatică se numeşte recursivă dacă permite derivări de forma: u ⇒+ α u β , unde u ∈N iar α ,β ∈ A* O gramatică este: + - recursivă la stânga dacă: u ⇒ u w + - recursivă la dreapta dacă: u ⇒ w u Exemplu: Considerăm o gramatică care descrie un set restrâns de operaţii algebrice G = {N, T, P, S} N = {S, <expr>, <term>, <fact>} T = {a, b, c, -, * } P = {S → <expr> <expr> → < term> | <expr> - <term> <term> → <factor> | <term>*<factor> <factor> → a | b | c }

Se observă că propoziţia a - b * c s-a obţinut în urma a 11 derivări, substituind la fiecare pas câte un simbol în forma propoziţională curentă.

Să încercăm să vedem dacă expresia a-b*c aparţine sau nu gramaticii. S→ <expr> → <expr>-<term> → <fact> - <term> → a - <term> → a- <term>*<factor>→ → a - <factor>* <factor> → a - b* <fact> → a - b * c

4.1. Tipuri de gramatici şi limbaje
După forma producţiilor, N. Chomsky a împărţit gramaticile în 4 mari clase:


• •

gramatici de tip 0 - este forma cea mai generală de gramatică, făra restricţii, de tipul celei prezentate mai sus. gramatici de tip 1 - sunt gramatici dependente de context (sensibile la context); ele au producţii de forma α u β → α γ β , adică producţia u → γ se poate aplică doar dacă u apare în contextul α u β . Gramaticile dependente de context generează limbaje dependente de context. gramatici de tipul 2 - sunt gramatici independente de context de forma u →α , adică derivarea are loc independent de contextul în care se află u. gramatici de tipul 3 - se numesc gramatici regulate, în care părţile drepte ale producţiilor încep cu un terminal. Clasa mulţimilor regulate peste alfabetul A reprezintă clasa limbajelor regulate L3(A).

14

Notând cu Gi clasa gramaticilor de tipul i, N. Chomsky a ademonstrat că există următoarele relaţii de incluziune între gramatici: G0 ⊇G1 ⊇G2 ⊇ G3 iar pentru limbaje incluziunile sunt stricte : L0 ⊃ L1 ⊃ L2 ⊃ L3.
Dintre cele 4 tipuri de gramatici, doar gramaticile regulate şi cele independente de context şi-au găsit o aplicabilitate practică în construirea limbajelor de programare. Celelalte două tipuri de gramatici prezintă un interes pur teoretic. Gramaticile regulate sunt un caz particular al gramaticilor independente de context. De aceea se spune că limbajele formale independente de context modelează limbaje de programare. Un limbaj poate fi generat de o gramatică regulată dacă el poate fi recunoscut de un automat finit. Dacă în producţiile gramaticii independente de context se foloseşte un singur tip de recursivitate la stânga sau la dreapta, ea devine o gramatică regulată.

Sintaxa unei propoziţii într-un limbaj independent de context se poate reprezenta printr-o structură de arbore, numit arbore de derivare (sau deducţie). Pentru recunoaşterea unei propoziţii dintr-un limbaj, este necesar ca arborele asociat să fie unic. În caz contrar, gramatica care generează limbajul se numeşte ambiguu. Un limbaj este inerent ambiguu dacă nu poate fi generat decât de o gramatică ambiguă. Există posibilitatea ca prin modificarea setului de producţii ale unei gramatici ambigue să se poată elimina ambiguităţile existente, fără ca limbajul generat să sufere vreo modificare.

Producţii vide Partea dreaptă a unei producţii conţine un şir de terminale sau neterminale. Uneori este util să se genereze un şir vid, adică un şir ce nu conţine nici un simbol. Acest şir este notat cu e. De exemplu, gramatica <unsigned integer> → <digit> <rest of integer> <rest of integer> → <digit><rest of integer> | ε <digit> → 0 | 1 | …|9
defineşte <rest of integer> ca o secvenţă de 0 sau mai multe cifre.

Producţia <rest of integer> → e se numeşte producţie vidă. În general, dacă pentru un şir σ este valabilă o derivare de forma σ⇒ *ε , atunci σ se numeşte simbol anulabil. Un neterminal este anulabil dacă există o producţie a cărei definiţie (parte dreaptă) este anulabilă.

4.2. Aspecte privind definirea limbajelor de programare
Pentru descrierea unui limbaj de programare este necesară adoptarea unui limbaj de descriere corespunzător, numit metalimbaj. Această idee aparţine lui John Backus şi notaţia introdusă de el este cunoscută sub numele de BNF (Backus Naur Form). O producţie defineşte o clasă sintactică (simbol neterminal) sub forma generală: < nume clasă sintactică> :: = definiţie
-

Notaţia :: = are semnificaţia : "definit prin"

15

-

clasa sintactică, denumită şi partea stângă, corespunde unui simbol neterminal şi este inclusă între paranteze unghiulare. partea de definiţie este denumită şi partea dreaptă

Simbolurile terminale nu sunt incluse în perechea de paranteze unghiulare şi ele apar în propoziţiile limbajului.
BNF utilizează un set restrâns de metasimboluri ( | < > :: =) şi un set (specific limbajului) de simboluri terminale.

Formalismul BNF impune nişte restricţii asupra regulilor de generare: fiecare clasă sintactică (simbol neterminal) trebuie să apară în partea stângă a unei singure producţii; simbolul de start nu trebuie să apară în partea stângă a nici unei producţii;

Ulterior s-au utilizat variante şi completări la notaţia BNF pentru a se descrie diferite limbaje de programare. Pentru a creşte lizibilitatea notaţiilor, s-au adoptat prescurtări inspirate de metasimbolurile folosite pentru expresii regulate. Aceste notaţii extinse au denumirea de forma Backus Naur extinsă EBNF. De exemplu pentru următoarea gramatică de descriere a întregilor cu semn: <integer> → <sign> <unsigned integer> <unsigned integer> → <digit> <unsigned integer> <sign> → + | - | ε folosind EBNF se va rescrie: <unsigned integer> → <digit> (<digit>)* sau mai restrans: <integer> → (+ | - | ε )<digit> (<digit>)*
Extensii introduse de Wirth

În definirea limabjelor Pascal şi Modula-2, 1977, Wirth a introdus câteva extensii la forma originală de notaţie, obtinând o formă extinsă care a devenit larg utilizată: neterminale - sunt scrise cu litere italice instructiune terminale - litere drepte şi între apostrofuri ‘begin’ | () - au semnificaţia din notaţia originală [] - semnifică aparitia opţională a şirului dintre paranteze {} - denotă repetiţia de 0 sau mai multe ori a şirului . - marchează sfârşitul fiecărei producţii (* *) - simboluri pentru comentarii ε - se înlocuieşte cu [] Exemplu: unsigned integer ::= digit {digit} digit ::= ‘0’ | ‘1’ | ‘2’ | …| ‘9’. 4.3. Automate de recunoaştere. Diagrame de tranziţie.

16

Pe baza gramaticii limbajului stabilit pentru atomi, analizorul lexical are sarcina să decidă apartenenţa la limbaj a atomilor detectaţi în fişierul de intrare. Pentru gramatici regulate, problema apartenenţei la limbaj este decidabilă. Problema deciziei trebuie completată cu sarcina codificării atomilor lexicali, cu cea a semnalării şi tratării erorilor. Gramaticile de descriere a atomilor lexicali oferă analizorului lexical tiparele pentru identificarea atomilor. Pe baza acestor gramatici, implementarea procesului de recunoaştere a atomilor se face folosind un model matematic, numit automat de recunoaştere sau automat finit. Modelul fizic al unui automat finit este o "maşină" cu operaţii foarte simple care are un cap de citire, o unitate de comandă şi opţional o memorie. Maşina citeşte câte un caracter de pe banda de intrare şi unitatea de comandă decide în ce stare trece automatul pe baza caracterului citit. Automatul se poate afla într-un număr finit de stări. În momentul în care automatul începe citirea unui caracter, acesta se află în starea numită starea de start. Automatul are un număr de stări numite, stări finale. Un şir x este acceptat de automat dacă pornind din starea de start, după citirea tuturor caracterelor din şirul de intrare, automatul ajunge într-o stare finală. Cu alte cuvinte, şirul aparţine limbajului acceptat de automat. Modelul matematic de reprezentare a automatului finit este acela al diagramelor de tranziţii. - Simbolurile care etichetează arcele indică caracterul la citirea căruia automatul va trece din starea de la care porneşte arcul în starea în care ajunge arcul respectiv. - Săgeata etichetată cu cuvântul "start" indică nodul de start al diagramei de tranziţii, ori poate fi o săgeată de intrare neetichetată. - Pentru a indica orice alt caracter care poate urma la ieşirea unei stări, în afara celor deja trecute pe arcele care ies din starea respectivă, se va utiliza o etichetă specială "altceva". Diagramele de tranziţii sunt deterministe, adică acelaşi simbol nu poate eticheta două sau mai multe tranziţii care ies din aceeaşi stare. - Unei tranziţii, pe lângă simbol i se pot asocia şi anumite acţiuni care se vor executa în momentul când fluxul de comandă trece prin tranziţia respectivă. Exemplu:
b a b În general analizorul lexical este format din mai multe astfel de diagrame de tranziţii care pornesc din aceeaşi stare de start şi recunosc grupe de atomi. Dacă parcurgând o anumită diagramă se semnalează eşec, se revine în starea de start şi se trece la următoarea diagramă. Revenirea în starea de start presupune şi revenirea capului de citire în poziţia anterioară încercării nereuşite. Readucerea capului de citire se poate face memorând adresa locaţiei cu citirea căreia a început ultima recunoaştere. Dacă prin parcuregerea secvenţială a tuturor diagramelor de tranziţii. se va semnala eşec la toate, înseamnă că s-a gasit o eroare lexicală şi se va apela rutina de tratare a erorii. a

q0

q1

17

Un alt aspect al analizei lexicale îl constituie comunicarea datelor detectate de analizorul lexical analizorului sintactic, generearea erorilor lexicale şi, dacă este necesar, introducerea datelor în tabelă. Pentru realizarea acestor sarcinci, diagramele de tranziţii se completează cu proceduri semantice asociate cu tranziţiile din diagramă. Aceste proceduri semnatice fie generează ieşiri către analizorul sintactic, realizând şi gestionarea tabelelor, fie tratează erorile lexicale. 4.4. Exemplu de gramatică a atomilor lexicali şi diagrame de tranziţii În cele ce urmează, dăm notaţia BNF a unei gramatici a atomilor lexicali, reprezentative pentru majoritatea limbajelor de programare. Notam G0 această gramatică. 1. < şir atomi>::=<atom> | <şir atomi> < atom> 2. < atom>::= <id> | <const> | <op> | <del> | <com> 3. <id> ::=<lit> | <id> <lit> | <id><cif> 4. <const>::= <cif> | <const> | <cif> 5. <op>::= + | * | < | <= | > | >= | = | <> 6.<del>::= ; |blanc 7. <com>::= (* < orice şir de caractere ce nu conţine grupul '*)'> *) 8. <lit>::= A | ... | Z 9. <cif>::= 0 | ... | 9 Gramatica G0 nu este regulată dar poate fi transformată uşor într-o gramatică regulată mărind numărul producţiilor şi al neterminalelor. Procedând astfel însă, gramatica se complică şi procesul de proiectare al analizorului se poate lungi. De exemplu, producţiile 4 se pot scrie: <const>::= 0 | ... | 9| <const>0 | | <const>1| …| <const>9 Se preferă o simplificare a gramaticii prin "stratificarea" gramaticii G0 într-o ierarhie de gramatici mai simple, care fiecare în parte este regulată, sau se transformă în gramatică regulată. Pentru fiecare din aceste gramatici se va construi diagrama de tranziţie, iar în final se asamblează diagramele astfel încât limbajul în ansamblu rămâne acelaşi. Se partţionează mulţimea de neterminale şi se stabileşte o ierarhie între elementele partiţiei. În exemplul dat, o asemenea partiţionare este: N1={<sir atomi>, <atom>} N2={<id>, <const>, <op>, <del>, <com>} N3={<lit>, <cif>} Formăm, în jurul celor trei mulţimi, gramatici plecând de la producţiile lui G0. Pentru fiecare gramatică vom considera ca terminale, pe lângă terminalele lui G0 şi neterminalele din grupul imediat inferior din ierarhie. Noile gramatici sunt: (G1) :< şir atomi>::=<atom> | <şir atomi> < atom> < atom>::= id | const | op | del | com (G21) :<id> ::=lit | <id> lit | <id>cif (G22) :<const>::= cif | <const> | cif (G23) :<op>::= + | * | < | <= | > | >= | = | <> (G24) :<del>::= ; |blanc (G25): <com>::= (* < orice şir de caractere ce nu conţine grupul '*)'> *) (G31) :<lit>::= A | ... | Z (G32): <cif>::= 0 | ... | 9

18

Gramaticile sunt regulate cu excepţia lui G1 şi G25. Gramatica G1 se poate rescrie într-o formă EBNF: (G1) :< şir atomi>::=(id | const| op| del| com) | (id | const| op| del| com)* G25 s-ar putea şi ea rescrie într-un mod asemănător, dar se preferă construirea automatului direct din această formă intuitivă. În figura următoare se prezintă diagramele de tranziţii ale automatelor finite echivalente cu gramaticile G1, G2i ( i = 1, …, 5) G3j (j = 1,2):
id, const, op, del, com id, const, op, del, com 11 12 lit, cif A21: lit 211 12 cif cif 221 232 + 233 * = A23: 231 < 235 > 238 = 239 234 = > 236 237 222

A1 :

A22:

19

A24:

; 241 blanc 243 altceva 242

A25:

251

(

252

*

253

*

254

) 255

altceva 312 A 313 0 323 322

B A31:

1 A32:

311 … Z 3127

321 … 9 3210

Din analiza diagramelor, observăm că efectul stratificării constă în existenţa unor tranziţii condiţionate de terminale care pe nivelul inferior reprezintă diagrame de tranziţii. Acest lucru înseamnă că nu putem activa o asemenea tranziţie pe nivelul superior decât dacă diagrama de tranziţie respectivă de pe nivelul inferior a fost parcursă din starea iniţială într-o stare finală. Deci, un automat aflat pe un nivel inferior trebuie să transmită nivelului superior informaţia de acceptare a şirului inspectat. Vom avea deci o asamblare a automatelor ca în figura următoare:

20

A21 A22 A31 A23 A31 A3 A2 A24 A25 A0 A1 A1

Cuplarea automatelor A1, A2, A3 se face în serie, ieşirea unuia fiind intrarea celuilalt. Pentru a putea descrie funcţionarea automatului A0 printr-un limbaj de programare, trebuiesc îndeplinite două condiţii: - Automatele rezultate din diferite cuplări trebuie să fie deterministe - Orice simbol primit la intrarea unui automat şi care nu activează nici o tranziţie trebuie să ducă la o situaţie de neacceptare sau de eroare. Observaţie: Există situaţii în care, pentru identificarea unui simbol, un automat consumă un simbol care aparţine atomului următor. Soluţiile de implementare constau fie în revenirea în şirul de intrare cu un simbol, fie în generalizarea avansului pentru toate stările finale. Completarea diagramei de tranziţii cu proceduri semantice. În proiectarea analizorului lexical, automatul de recunoaştere are un rol orientativ. El arată care sunt sarcinile analizorului în identificarea atomilor lexicali. Pentru semnalarea erorilor şi emiterea unor ieşiri, se folosesc proceduri semantice asociate tranziţiilor automatului de recunoaştere. Procedurile semantice lucrează cu cu structuri de date pe care le utilizează în luarea deciziilor şi pe care, eventual, le modifică. Aceste structuri de date alcătuiesc baza de date a analizorului lexical şi controlul contextului activităţii lui. Controlul contextului are ca scop restabilirea – la sfârşitul analizei unui atom lexical – a unui context adecvat căutării următorului atom, emiterea unei ieşiri corecte care să reprezinte atomul analizat şi semnalarea erorilor.

4.5.

Probleme specifice implementarii unui analizor lexical

4.5.1. Gestionarea tampoanelor de intrare Textul sursă parcurs şi analizat de analizorul lexical este citit de pe suportul de intrare. Pentru efectuarea acestei operaţii se recomandă utilizarea a 2 zone tampon din următoarele motive: - Poate creşte viteza prin umplerea unui tampon când analizorul lucreaza cu celălalt - Se poate trata simplu cazul în care un atom se continuă dintr-un tampon în altul.

21

Soluţiile concrete de gestiune ale tampoanelor depind de modul de implementare al analizorului: 1. Utilizarea unui generator de analizoare lexicale: rutinele pentru gestiune sunt incluse in generator, şi nu se află sub controlul programatorului. 2. Analizorul se scrie intr-un limbaj de nivel inalt. Posibilităţile de gestionarea tampoanelor sunt cele specifice limbajului. 3. Analizorul se scrie în limbaj de asamblare. Tampoanele se pot gestiona în modul explicit la cel mai scăzut nivel. Eficienţa şi efectul cresc de la 1 la 3 Notăm cu n dimensiunea tamponului; ea corespunde lungimii dimensiunii fizice (linie, articol) pe mediul de intrare. Fiecare tampon se umple printr-o singură comandă de citire iar sfârşitul textului este marcat de EOF Pentu localizarea atomului lexical (lexemei curente) în tampoane se utilizează pointeri numiţi pointeri de inceput pi şi pointeri de anticipare pa. La început, ambii pointeri indică primul caracter al lexemei curente, apoi p a avanseaza până când analizorul găseşte corespondenţa cu un tipar. Şirul de caractere dintre cei doi pointeri reprezintă următorul atom lexical. După detectarea unui atom lexical, pointerul de anticipare poate sa rămână ori pe ultimul caracter al lexemei curente ori pe primul caracter al lexemei următoare. Din motive de uniformitate se preferă a doua situaţie. După prelucrarea lexemei curente, pi este adus în aceeaşi poziţie cu pointerul de anticipare, situaţie în care se poate trece la analiza unui nou atom. Trecerea pointerului pi din primul tampon în al doilea trebuie precedată de umplerea (citirea) celui de-al doilea tampon, operaţie care se poate desfasura în paralel cu analiza propriu zisă când sistemul de calcul şi limbajul permite acest lucru. Analog, trecerea pointerului pa din al doilea tampon în primul în mod circular, trebuie precedată de umplerea (citirea) tamponului 1. Această tehnică poate fi aplicată atunci când lungimea maximă a unei lexeme nu poate depăşi 2n, ceea ce este o limitare rezonabilă. Algoritmul de avans al lui pa pentru situaţia de mai sus este următorul: if * pa este la sfirsitul tamponului 1 then begin * incarca tamponul 2; pa:= pa +1; end else if * pa este la sfirsitul tamponului 2 then begin * incarca tamponul1; pa:=1; end else pa:= pa +1 Principalul dezavantaj al acestui algoritm îl reprezintă faptul că pentru fiecare caracter (exceptând sfârşitul tamponului 1, avansul pointerului de anticipare este precedat de 2 teste. Cele două teste se pot reduce la unul singur dacă se marchează sfârşitul fiecărui tampon cu un caracter special numit santinelă, care să fie acelaşi cu cel de sfârşit EOF . Algoritmul se modifică astfel: pa = pa +1; if pa = EOF then if* pa este la sfirsitul tamponului 1 then begin *incarca tapon2

22

pa:= pa +1 end else if * pa este la sfirsitul tamponului 2 then begin *incarca tampon 1 pa :=1 end else *termină analiza lexicală. Se mai remarcă şi faptul că acelaşi unic test de sfârşit de tanpon rezolvă şi testul de sfârşit al textului sursă necesar pentru încheierea analizei lexicale. 4.5.2. Scrierea codului pentru diagramele de tranziţii Din punct de vedere al programării, o secvenţă de diagrame de tranziţii poate fi implementată fie prin case fie prin succesiune de if. Pentru aceasta fiecărei stari i se asociază o porţiune de program distinctă. - Dacă starea nu este finală, adică există arce care ies din acea stare, atunci porţiunea de program se încheie cu citirea unui caracter pe baza căruia se pot selecta tranziţii spre stare următoare, dacă există arc de ieşire etichetat cu acel caracter. Citirea unui caracter se poate face cu o procedură care gestionează tamponul de intrare, avansează pointerul de anticipare şi returnează următorul caracter. - Dacă există arc pornind din starea curentă etichetat cu caracterul citit se va transfera controlul la secvenţa de program pentru noua stare. - Dacă nu există un astfel de arc şi starea curentă nu este finală, se va apela o procedură eşec care returnează pointerul de început al atomului lexical şi asigură saltul la următoarea diagramă. În cazul când s-au epuizat toate posibilităţile, se va apela procedura de eroare.

Dacă limbajul nu are case, acesta poate fi simulat printr-un tablou (indexat prin codul caracterului de la intrare). Fiecare element al tabloului corespunde unei stări noi şi reprezintă un pointer spre o secvenţă de cod ce trebuie executată atunci când caracterul curent corespunde indicelui. Porţiunea de corespunzătoare fiecărei stări se va încheia fie cu luarea în considerare a stării următoare, fie cu salt la tabloul corespunzător următoarei diagrame.

5. Construirea automată a analizoarelor lexicale

C4

Un generator de analizoare lexicale porneşte de la expresiile regulate care descriu toţi atomii limbajului sursă şi obţine diagrama de tranziţie corespunzătoare, sub forma unei tabele de analiză. Această tabelă, împreună cu procedura de analiză alcătuiesc analizorul lexical.

5.1.Obţinerea tabelei de analiză pe baza expresiilor regulate
Există două metode de transformare a expresiilor regulate în automate finite deterministe. Metoda I Această metodă presupune parcurgerea următoarelor etape: a.Construirea unui automat finit nedeterminist (AFN) pornind de la expresiile regulate (ER).

23

b.Transformarea automatului nedeterminist (AFN) în automat finit determinist (AFD). c.Minimizarea numărului de stări ale automatului determinist.

Metoda II Această metodă presupune parcurgerea următoarelor etape:
a.Construirea arborelui binar corespunzător ER. b.Construirea AFD pe baza arborelui.

5.1.1. Construirea unui automat finit nedeterminist din expresii regulate
În diagramele de tranziţii folosite până acum, am implementat automate finite deterministe, (AFD)de genul celui din figura următoare: a a b Figura 1 Din fiecare stare iese o singură săgeată etichetată cu un simbol de intrare. Matricea de tranziţii pentru acest automat este:
a q0 q1 q1 q1 b q0 q0

b

q0

q1

Dacă renunţăm la unele restricţii şi permitem ca dintr-un nod să iasă mai multe săgeţi etichetate cu acelaşi simbol de intrare, precum şi săgeţi etichetate cu λ - care vor reprezenta tranziţii independente de intrare – obţinem un automat finit nedeterminist. (AFN), prezentat în Figura 2.
a a

0

1 b

b b

3

λ

2 a

Figura 2

24

Unei stări şi unui simbol de intrare nu îi mai corespunde o stare ci o mulţime, eventual vidă de stări. Matricea de tranziţii pentru acest automat este:
a 0 1 2 3 {0,1} -{2} -b -{2,3} {3} -λ {2} ----

Evident, AFD este un caz particular al AFN. Pornind de la expresii regulate, se pot construi automate finite nedeterministe. Fie o expresie regulată R peste un alfabet Σ . Algoritmul de mai jos va genera un automat finit nedeterminist N, care va accepta limbajul definit de R. Se descompune expresia R în componentele sale elementare (simboluri şi operatori). Se vor construi automate elementare pentru fiecare simbol, după care, folosind aceste automate, se vor construi automatele pentru expresiile compuse. Automatele pentru expresiile compuse se construiesc inductiv, pentru fiecare operaţie : reuniune, concatenare, inchidere. Algoritmul de construcţie introduce la fiecare pas cel mult 2 stări noi, prin urmare, automatul rezultat va avea cel mult de 2 ori atâtea stari câte simboluri si operaţii are expresia regulată. Algoritmul lui Thomson prezentat în continuare, nu este cel mai eficient (un algoritm mai performant ar genera un AFN cu mai puţine stări pentru aceeaşi expresie regulată). Are însă avantajul simplităţii, iar după transformarea din automat nedeterminist în automat determinist, există posibilitatea reducerii numărului de stări ale automatului finit determinist obţinut. Folosim următoarele notaţii: i - stare iniţială f - stare finală N(Ri) - automatul corespunzător expresiei regulate Ri. - Pentru λ (simbolul vid notat şi ε ) se generează:
i λ f

- Pentru a ( un simbol oarecare al alfabetului sursă):
i a f

Pentru fiecare AFN elementar construit, stările vor fi notate cu nume (numere) distincte; dacă un acelaşi simbol al alfabetului apare de mai multe ori în ER, se va construi pentru fiecare apariţie a sa câte un AFN separat, cu stări notate distinct.

În continuare, se conectează între ele AFN elementare construite, corespunzător operatorilor aplicaţi asupra primitivelor din ER, compunându-se astfel, din aproape în aproape (prin inducţie) AFN final.

25

Descompunerea ER în componente elementare, respectiv compunerea acestora se face aducând ER la forma postfix, tinând cont că operatorii se evaluează în ordinea următoare: parantezele, închiderea ( * ), concatenarea si selecţia (|). - Pentru R1|R2 λ
i N(R1)

λ
f

λ
Figura 3

N(R2

λ

Automatul corespunzător expresiei R1 | R2, este N(R1 |R2), obţinut prin creerea a 2 stări noi: o stare iniţială, diferită de stările iniţiale ale automatelor N(R1) şi N(R2) şi o stare finală diferită de stările finale din N(R1) şi N(R2), care îşi pierd proprietatea de satre iniţială şi finală. Limbajul corespunzător expresiei regulate R1 |R2 este: L(R1) ∪ L(R2). - Pentru R1R2
i Figura 4

λ

N(R1)

N(R2)

f

λ

λ

Automatul corespunzător expresiei R1R2 este N( R1R2) pentru care starea iniţială este starea se start a automatului N(R1) iar starea finală este cea a automatului N(R2). Starea finală a automatului N(R1) se identifică cu starea se start a automatului N(R2). Un drum între i şi f va traversa mai întâi automatul N(R1), după care va trece prin automatul N(R2). Prin urmare, şirul de simboluri recunoscut va fi un şir din limbajul expresiei R 1 urmat de un şir al limbajului expresiei R2. În consecinţă, limbajul modelat de automat este: L(R1)L(R2). - Pentru R1* λ
i N(R1) f

λ

λ

Figura 5

λ

Automatul are 2 stări noi şi ne putem deplasa din starea iniţială i în starea finală f, fie direct prin tranziţia λ , fie prin automatul N(R1), de un număr oarecare de ori.
Un automat obţinut pe baza algoritmului lui Thomson are următoarele proprietăţi:

-AFN final va avea o singură stare de start şi o singură stare finală. - Fiecare stare a automatului are cel mult o tranziţie etichetată cu un simbol din alfabet sau cel mult 2 tranziţii etichetate cu λ .

26

Aplicaţie: Se consideră expresia regulată R = (ab *aa . Automatul construit pas cu pas, pornind de a) la această expresie este: λ
2 a 3 b 4

i

λ λ
1

λ
7 5 6 8 a 9 a f

λ

a

λ

λ

λ
Figura 6

5.1.2. Transformarea AFN în AFD
Un AFN se poate transforma într-un automat finit determinist (AFD) care să accepte acelaşi limbaj ca şi AFN. Notăm cu s0 starea iniţială a AFN. O stare a AFD va fi compusă dintr-o mulţime de stări {s1, s2,..., sn } ale AFN. Noţiunea de λ -închidere se defineşte pentru fiecare mulţime de stări T ale unui automat: este mulţimea stărilor în care se poate trece din stările mulţimii T pentru un simbol de intrare. Exemplu: Pentru automatul din Figura 2, prin tranziţii vide, λ -închidere(0) = {0,2}, λ închidere(1) = {1}, λ -închidere(0, 3) = {0,2,3} etc. Notăm: ∑ alfabetul limbajului sursă Dstări mulţimea stărilor AFD Dtranz mulţimea tranziţiilor Pentru implementarea algoritmului putem folosi ca structuri de date două stive şi un şir de cifre binare indexat de stările automatului. Într-una din stive se ţine evidenţa mulţimii curente a stărilor nedeterministe iar a doua stivă se utilizează pentru calculul mulţimii de stări următoare. Vectorul de cifre binare înregistrează dacă o stare este prezentă în stivă, pentru a se evita dublarea ei. Organizarea acestei structuri ca vector are avantajul timpului de căutare constant al unei stări. După încheierea procesului de calcul a mulţimii de stări următoare, rolul stivelor se inversează. Se iniţializează stările AFD căutat Dstări cu un singur element (o stare), şi anume cu mulţimea stărilor în care se poate ajunge din starea s0 a AFN numai prin tranziţii vide (de fapt λ închidere({s0}), care va fi notată cu λ -închidere({s0}). La început această stare e nemarcată. Totodată, mulţimea tranziţiilor este vidă. Pentru fiecare stare nemarcată din Dstări şi pentru fiecare simbol din alfabet se caută stările în care se poate ajunge în AFN pentru simbolul respectiv. Adaugă aceste stări la Dstări dacă ele nu sunt deja incluse în această mulţime, adaugă tranziţia la Ditranz şi marchează starea testată din Dstări.
Algoritmul de obţinere a AFD este:

procedura AFN2AFD este *iniţializează Dstări cu λ -închidere({s0}) *la început stările din Dstări sunt nemarcate Dtranz = ∅ cât timp mai există în Dstări o stare x = {s1, s2,. . ., sn } nemarcată execută *marchează x pentru fiecare a ∈ ∑ execută

27

*fie T = mulţimea stărilor din AFN pentru care ∃ o tranziţie etichetată cu a de la o stare si ∈ x; y = λ -închidere(T); dacă y ∉ Dstări atunci *adaugă y la Dstări, y - nemarcată *adaugă tranziţia x → y la Dtranz, dacă nu există deja €
€ € sfârşit AFN2AFD

Algoritmul de calcul pentru funcţia λ -închidere este: funcţia λ -închidere( T ) este *pune toate stările din T într-o stivă *iniţializează λ -închidere( T ) cu T cât timp stiva nu e vidă execută *extrage starea s din vârful stivei pentru fiecare stare t pentru care ∃ s → t pentru simbolul λ execută dacă t ∉ λ -închidere( T ) atunci *adaugă t la λ -închidere( T ) *pune t în stivă € € € sfârşit λ -închidere( T )