• Nenhum resultado encontrado

licenta_expresiiRegulate

N/A
N/A
Protected

Academic year: 2021

Share "licenta_expresiiRegulate"

Copied!
65
0
0

Texto

(1)

Universitatea de Vest Timişoara

Facultatea de Matematică şi Informatică, Specializarea Informatică

Lucrare de diplomă

Generarea de cod executabil pe platforma .NET

Profesor coordonator Absolvent

Conf. Dr. Drăgan Mircea Rişcuţia Vlad

(2)

Universitatea de Vest Timişoara

Facultatea de Matematică şi Informatică, Specializarea Informatică

Lucrare de diplomă

Generarea de cod executabil pe platforma .NET

Profesor coordonator Absolvent

Conf. Dr. Drăgan Mircea Rişcuţia Vlad

(3)

Cuprins

1. Introducere... 1 1.1 Motivaţie... 1 1.2 Sumar... 1 2. Structura compilatorului ... 3 2.1 Analiza lexicală ... 4 2.2 Analiza sintactică... 6

2.3 Syntax directed translation... 13

2.4 Reprezentări intermediare... 14

2.5 Analiza semantică... 17

2.6 Optimizări independente de maşină ... 19

2.7 Generarea codului şi optimizări dependente de maşină ... 20

3. Generarea de cod pentru platforma .NET ... 24

3.1 Platforma .NET... 24

3.2 Structura unei aplicaţii ... 27

3.3 Generarea de cod... 30

4. Descrierea aplicaţiei ... 34

4.1 Limbajul sursă ... 34

4.2 Analiza lexicală şi analiza sintactică ... 39

4.3 Reprezentarea intermediară... 40

4.4 Analiza semantică şi optimizări independente de maşină ... 47

4.5 Generarea codului şi optimizări dependente de maşină ... 53

5. Concluzii... 60

5.1 Contribuţie personală ... 60

5.2 Direcţii de dezvoltare... 60

(4)

1. Introducere

Lucrarea îşi propune să descrie atât din punct de vedere teoretic cât şi din punct de vedere al implementării, procesul de compilare al unui text sursă în cod executabil pe platforma .NET, trecând prin toţi paşii necesari pentru efectuarea transformării.

1.1 Motivaţie

Construirea compilatoarelor este unul din domeniile informaticii ce necesită o arie largă de cunoştinţe – structuri de date, algoritmică, arhitectura calculatoarelor. Atât pentru consolidarea şi aprofundarea materiei predate la cursurile de limbaje formale şi teoria automatelor şi tehnici de compilare cât şi pentru a înţelege modul de lucru intern al unui compilator, lucrarea îşi propune să detalieze principiile teoretice şi paşii necesari pentru a dezvolta o astfel de aplicaţie.

Această oportunitate permite şi cunoaşterea detaliilor arhitecturale ale platformei .NET precum şi modul de funcţionare a maşinii virtuale. Cu toate că sunt utilizate numeroase limbaje de programare de nivel înalt pentru a scrie aplicaţii pentru platformă (C++, C#, Visual Basic, Delphi etc.), limbajul de asamblare pus la dispoziţie permite evidenţierea procesului de execuţie, a capabilităţilor maşinii virtuale (setul de instrucţiuni), precum şi observarea instrucţiunilor sintetizate de majoritatea compilatoarelor pentru diferite construcţii de limbaj de nivel înalt uzuale.

1.2 Sumar

Următoarele capitole cuprind descrierea unui compilator din punct de vedere teoretic, a platformei pentru care se generează cod, a aplicaţiei şi a concluziilor la care s-a ajuns în urma implementării, precum şi posibilele căi de evoluţie ulterioară.

Capitolul 2, Structura compilatorului, prezintă fundamentele teoretice necesare pentru implementarea unui compilator, structurile de date utilizate şi paşii procesului de compilare. Sunt expuse elementele de bază ale analizei lexicale – token-uri, expresii regulate, automate finite – şi elemente ale analizei sintactice – gramatici, algoritmi de parsing, acţiuni semantice. Se continuă cu descrierea reprezentărilor intermediare utilizate – tabele de simboluri, arbori abstracţi de sintaxă şi elemente ale analizei semantice – evaluarea şi modificarea dinamică a arborilor de sintaxă. Capitolul se încheie cu optimizări

(5)

efectuate asupra reprezentării intermediare şi asupra codului final – teorie, tehnici şi generarea codului pentru o maşină abstractă.

Capitolul 3, Generarea de cod pentru platforma .NET, începe cu o descriere a platformei, maşina virtuală pentru care aplicaţia generează cod executabil, atât din punct de vedere arhitectural cât şi din punct de vedere funcţional. Sunt prezentate şi instrumentele puse la dispoziţie pentru a facilitata generarea codului, setul de instrucţiuni precum şi tehnicile utilizate de aplicaţia prezentată în lucrare.

Capitolul 4, Descrierea aplicaţiei, prezintă implementarea compilatorului, insistând asupra detaliilor de implementare relevante precum structurile de date şi algoritmii utilizaţi – se reiau subiectele prezentate în capitolul 2, privite acum din perspectivă practică – generarea analizatorului lexical şi al parser-ului, implementarea arborelui abstract de sintaxă, evaluarea diferitelor tipuri de noduri, optimizări pe arbore, generarea de cod .NET.

Capitolul 5, Concluzii, expune concluziile la care s-a ajuns în urma dezvoltării aplicaţiei şi direcţiile în care aplicaţia va fi extinsă în viitor.

(6)

2. Structura compilatorului

Un compilator este format din două componente – o componentă de analiză (numită şi front-end) şi o componentă de sinteză (numită şi back-end). Componenta de analiză preia textul sursă, îl procesează, validează şi îl aduce într-o formă intermediară. Componenta de sinteză operează pe forma intermediară, făcând optimizări şi generând cod.

Analiza este împărţit în mai multe procese: analiză lexicală, analiză sintactică şi analiză semantică. Aceste procese pot fi executate pe rând sau simultan, majoritatea compilatoarelor efectuând în acelaşi timp cel puţin analiza lexicală şi analiza sintactică.

Componenta de sinteză efectuează, în primul rând, optimizări independente de maşină – optimizări la nivel logic aplicate reprezentării intermediare, apoi generează codul final şi efectuează optimizări dependente de maşină – optimizări a codului în funcţie de sistemul pe care acesta va rula.

(7)

2.1 Analiza lexicală

Analiza lexicală presupune împărţirea textului sursă în cuvinte individuale, numite

tokens. Componenta compilatorului care efectuează analiza lexicală se numeşte scanner sau lexer. Aceasta primeşte un şir de caractere şi întoarce un şir de tokens – nume, cuvinte cheie

etc. şi ignoră elementele irelevante pentru sensul programului – comentariile şi spaţiile albe din text.

2.1.1 Token

Un token este o secvenţă de caractere ce reprezintă o singură entitate în gramatica limbajului. Orice limbaj are o mulţime finită de categorii de token-uri: simboluri, cuvinte cheie, identificatori, constante numerice etc.

Un token este definit prin tipul său (categoria din care face parte), şi, eventual, alte informaţii asociate. De exemplu token-ul pentru constanta număr întreg “12” este definit ca având tipul “constantă număr întreg” şi valoarea “12”.

Token-urile sunt specificate cu ajutorul expresiilor regulate iar analizatoarele lexicale, pe baza acestor expresii, sunt implementate folosind automate finite deterministe.

2.1.2 Expresii regulate

Cu toate că mulţimea categoriilor de uri este finită, numărul efectiv de token-uri dintr-o categorie poate fi infinit. De exemplu, definind limbajul identificatorilor ca un şir de caractere ce începe cu o literă sau simbolul “_” urmat de oricâte litere, cifre sau simboluri “_”, este evident că mulţimea astfel definită este infinită.

Pentru a specifica limbaje de dimensiune infinită cu o descriere finită, se folosesc

expresiile regulate. O expresie regulată defineşte o mulţime, posibil infinită, de şiruri de

caractere.

Cu toate că limbajul de expresii regulate este extins în majoritatea implementărilor, la baza sa stau 5 proprietăţi:

1. Pentru orice simbol a din alfabetul limbajului, expresia regulată a defineşte limbajul ce conţine doar şirul de caractere a.

2. Date fiind două expresii regulate M şi N, operatorul | (de alternare) crează o nouă expresie regulată M | N unde un şir de caractere este în limbajul M | N dacă este în M sau în N.

3. Date fiind două expresii regulate M şi N, operatorul · (de concatenare) crează o nouă expresie regulată M·N unde un şir de caractere este în limbajul M·N dacă este compus din alăturarea a două şiruri de caractere α şi β astfel încât α face parte din M şi β face parte din N.

4. Expresia regulată ε reprezină limbajul format din şirul de caractere gol (şirul de caractere format din zero caractere).

5. Dată fiind expresia regulată M, operatorul * denotă închiderea Kleene a mulţimii

M. Un şir de caractere face parte din M dacă este compus din alăturarea a

(8)

Extensiile limbajului au fost adăugate pentru a uşura scrierea expresiilor regulate, dar şi ele pot fi exprimate în funcţie de cele 5 proprietăţi de mai sus. De exemplu, operatorul + aplicat expresiei regulate M, defineşte mulţimea şirurilor de caractere compuse din alăturarea oricâtor şiruri de caractere, dar cel puţin unul, făcând parte din M. Se observă că expresia M+ poate fi exprimată folosind proprietăţile enumerate ca M·M*.

Cu ajutorul expresiilor regulate se pot descrie în întregime elementele unui limbaj de programare – mulţimea tuturor token-urilor – numită şi gramatica lexicală. Pe lângă descrierea limbajului mai sunt necesare două reguli de rezolvare a ambiguităţilor pentru ca un compilator să poată efectua analiza lexicală a unui text.

1. Se va considera ca token identificat cel mai lung subşir de caractere ce face parte din limbajul definit de o expresie regulată: pentru şirul de caractere form, se va considera token identificatorul form, cu toate că şirul for este un cuvânt cheie şi face parte din gramatica lexicală a limbajului.

2. Expresiile regulate se vor aplica într-o anumită ordine asupra textului, ordinea fiind relevantă. De exemplu, pentru majoritatea limbajelor, cuvintele cheie respectă şi definiţia identificatorilor, astfel că un şir de caractere precum for trebuie identificat corect ca un cuvânt cheie şi nu un identificator.

2.1.3 Automate finite

Cu toate că expresiile regulate pot descrie gramatica lexicală a unui limbaj din punct de vedere teoretic, este necesar un model practic de implementare pentru a se putea efectua analiza lexicală.

Pentru aceasta se folosesc automatele finite. Un automat finit are o mulţime finită de

stări; muchii ce pornesc dintr-o stare şi merg în altă stare şi simboluri ce etichetează

muchiile. O stare este identificată ca stare de start iar un număr de stări sunt identificate ca

stări finale.

Un automat finit porneşte din starea de start şi, în funcţie de input-ul primit, îşi schimbă starea trecând într-o stare adiacentă – legată de starea curentă prin muchia etichetată cu un simbol ce corespunde input-ului. Input-ul este reprezentat de un şir de caractere, astfel automatul acceptă sau respinge un şir de caractere. Dacă, la terminarea citirii şirului de caractere automatul se află într-o stare finală, şirul de caractere este acceptat; în caz că automatul nu se află într-o stare finală, şirul de caractere este respins. În cazul în care automatul ajunge într-o stare de unde, pe baza input-ului, nu poate trece în altă stare (nu există muchia etichetată cu un simbol corespunzător), automatul devine blocat, respingând pe loc şirul de caractere. Limbajul recunoscut de un automat este mulţimea de şiruri de caractere pe care o acceptă.

Într-un automat finit determinist (DFA), simbolurile ce etichetează muchiile sunt caractere şi automatul are proprietatea că, pentru orice pereche de muchii ce pleacă din aceaşi stare, simbolurile care le etichetează sunt diferite. Cu alte cuvinte, ştiind structura automatului şi citind caractere unul câte unul, se poate stabili la fiecare pas în ce stare se află automatul. Scanner-ele utlizate de compilatoare folosesc astfel de automate pentru a efectua analiza lexicală.

Automatele finite nondeterministe (NFA) se deosebesc de cele deterministe prin

(9)

consuma caractere input şi prin faptul că permit existenţa a două muchii ce pleacă din aceaşi stare etichetate cu acelaşi simbol. Acest tip de automate se numesc nondeterministe deoarece, citind caractere unul câte unul de la intrare, nu se poate determina starea în care un astfel de automat se află într-un anumit moment. Pentru a determina stările prin care trece automatul trebuie să se cunoască apriori tot şirul de caractere de intrare. Expresiile regulate definesc, de fapt, astfel de automate.

Automatele finite nondeterministe definite de expresiile regulate nu pot fi implementate direct pe calculator însă pot fi transformate în automate finite deterministe. S-a demonstrS-at că pentru orice NFA se poS-ate construi un DFA echivS-alent (cS-are S-acceptă acelaşi limbaj) şi pentru orice DFA se poate construi un NFA echivalent[8], astfel, o expresie regulată poate fi implementată ca un DFA.

Automatele finite pot fi legate în paralel (analogul operaţiei de alternare a expresiilor regulate) sau legate în serial (analog operaţiei de concatenare a expresiilor regulate) putându-se astfel crea un singur automat finit care să accepte tot limbajul descris de utilizator.

2.1.4 Generarea automată a analizatoarelor lexicale

Transformarea expresiilor regulate în DFA este o sarcină simplă pentru calculator, astfel că s-au dezvoltat numeroase generatoare automate de analizatoare lexicale precum celebrul Lex sau Coco/R, folosit de compilatorul prezentat în această lucrare.

Aceste generatoare primesc lista de expresii regulate ce defineşte toată gramatica lexicală a limbajului de programare şi generează automatul finit ce recunoaşte limbajul, precum şi programul ce încapsulează acest automat.

Analizatoarele lexicale astfel generate primesc ca input şiruri de caractere şi returnează token-uri. În cazul în care, pentru un şir de caractere, automatul se blochează, se raportează o eroare în textul sursă (caracter invalid). În cazul în care, la epuizarea caracterelor din textul sursă, automatul nu se află într-o stare finală, se raportează sfârşit de fişier neaşteptat (unexpected end of file).

2.2 Analiza sintactică

Analiza sintactică presupune identificarea frazelor din textul sursă. Analizatorul sintactic, numit parser, primeşte un şir de token-uri şi le grupează în construcţii corecte din punct de vedere sintactic. Sintaxa unui limbaj este definită de o gramatică sintactică.

2.2.1 Gramatici

Gramaticile sunt formate din patru elemente:

1. Terminale – sunt simbolurile de bază ce formează construcţiile, echivalente cu token-urile identificate de analizatorul lexical. Exemple de terminale sunt cuvinte cheie precum if, then sau simboluri precum “(” sau “)”.

2. Nonterminale – sunt variabile sintactice ce definesc seturi de simboluri terminale şi/sau alte simboluri nonterminale. Mulţimile simbolurilor definite

(10)

astfel determină limbajul generat de gramatică. Nonterminalele impun o structură ierarhică limbajelor, pe care se bazează analiza sintactică.

3. Simbol de start – într-o gramatică, un nonterminal este diferenţiat de celelalte ca fiind simbol de start, mulţimea de simboluri definită de acesta fiind chiar limbajul definit de gramatică.

4. Producţii – producţiile unei gramatici specifică modul în care terminalele şi nonterminalele pot fi combinate pentru a forma seturile de simboluri. Fiecare producţie este formată din:

a. Un nonterminal numit head sau left side b. Simbolul .

c. Un corp (body) sau right side, format din zero sau mai multe terminale şi nonterminale. Componentele corpului descriu diferite feluri în care simbolurile corespunzătoare nonterminalului head pot fi construite[3]. O formă de reprezentare a gramaticilor este notaţia BNF (Backus-Naur Form) şi extensiile ei.

2.2.2 BNF şi extensii

Deşi specificaţia iniţială a notaţiei BNF nu include toate convenţiile prezentate mai jos, acestea au fost introduse ca şi extensii ale notaţiei făcute pentru a uşura citirea gramaticilor de către oameni[1].

Ca şi convenţie de notaţie, pentru a face distincţia dintre elemente, nonterminalele unei gramatici sunt de obicei reprezentate cu font italic iar terminalele sunt reprezentate cu font bold. Simbolul ε denotă şirul vid, adică absenţa terminalelor şi a nonterminalelor.

Pentru mai multe posibile corpuri asociate unui nonterminal head, acestea sunt separate de simbolul |.

E E + T

E E – T

poate fi scris ca

E E + T | E – T

Pentru a reprezenta 0 sau mai multe repetări ale unui şir de terminale şi/sau nonterminale, şirul respectiv este încadrat de simbolurile “{” şi “}”.

N digit N | digit poate fi scris ca

(11)

Pentru a reprezenta un şir de terminale şi/sau nonterminale ce poate să apară o dată sau niciodată (şir opţional), şirul respectiv este încadrat de simbolurile “[” şi “]”.

N – digit { digit } | digit { digit } poate fi scris ca

N [ – ] digit { digit }

2.2.3 Arbori de derivare

Un arbore de derivare (arbore de parsing) este o reprezentare a derivării simbolului de start într-o mulţime formată exlusiv din terminale. Analiza sintactică presupune, de fapt, determinarea arborelui de derivare corespunzător unei mulţimi de token-uri pe baza producţiilor ce definesc gramatica.

Un nonterminal head este reprezentat de un nod având ca şi copii nodurile corespunzătoare mulţimilor de terminale şi nonterminale dintr-un set body, păstrând ordinea elementelor de la stânga la dreapta. Rădăcina arborelui este simbolul de start. Nodurile frunză pot fi terminale sau nonterminale în timpul construirii arborelui iar în momentul în care acesta a fost determinat în întregime (s-a ajuns la un şir format exclusiv din terminale), toate nodurile frunză vor fi terminale.

Pentru şirul a + b * c

(12)

Se observă că, datorită gramaticii, ordinea corectă a operaţiilor a fost păstrată (înmulţirea înaintea adunării). Arborele de derivare este rareori construit cu adevărat în practică ca o structură arborescentă de date dar se spune că, în urma determinării derivării, e “construit” la nivel abstract.

2.2.4 Derivări şi reduceri

Pentru a determina arborele de derivare ce corespunde unui text utilizând o gramatică există două abordări: derivarea şi reducerea.

Derivarea presupune tratarea producţiilor ca reguli de rescriere[3]. În funcţie de textul analizat, la fiecare iteraţie, un nonterminal de tip head este înlocuit cu un şir de terminale şi nonterminale body care îi sunt asociate. Astfel, pentru a analiza tot textul unui program, se porneşte de la simbolul de start şi se repetă derivarea nonterminalelor până când şirul la care se ajunge este un şir de simboluri exclusiv terminale. Notând o derivare cu simbolul “”, pentru şirul

a + b * c şi gramatica

E E + T | T

T T * F | F

F  ( E ) | id

avem următoarele derivări pornind de la simbolul de start:

E E + T  T + T  F + T  id + T  id + T * F  id + F * F  id + id * F  id + id * id

Nonterminalul ales la fiecare pas pentru rescriere poate fi primul nonterminal de la stânga spre dreapta din corpul producţiei, caz în care derivarea se numeşte leftmost

derivation sau ultimul nonterminal de la stânga spre dreapta, caz în care derivarea se

numeşte rightmost derivation. În exemplul de mai sus, derivarea este de tip leftmost.

Un alt mod de a trata producţiile este dat de către reduceri. Reducerile sunt opusul derivărilor. Pornindu-se de la un şir de terminale, se aplică iterativ inversul operaţiei de derivare, şiruri de terminale şi nonterminale body reducându-se astfel în nonterminalele head asociate până când singurul nonterminal rămas este simbolul de start.

2.2.5 Top-down şi bottom-up parsing

Pentru a realiza analiza sintactică, un parser trebuie, în esenţă, să “construiască” arborele de parsing corespunzător şirului de token-uri primite ca input.

Există două metode caracteristice implementării parserelor pentru a determina arborele, corespunzătoare derivărilor şi reducerilor: construirea arborelui top-down,

(13)

respectiv construirea arborelui bottom-up. Un parser top-down construieşte arborele începând de la nodul rădăcină şi adăugând noduri în preordine (depth-first). Un parser bottom-up construieşte arborele începând de la nodurile frunză şi adaugă noduri părinte.

Desigur, nu orice gramatică e potrivită pentru orice tip de parsing. Parsing-ului top-down îi corespund gramaticile LL(k), pe când parsing-ului bottom-up îi corespund gramaticile LR(k).

Gramaticile LL(k) sunt gramatici unde simbolurile sunt citite de la stânga spre dreapta, se produc derivări leftmost şi, pentru a se determina derivarea corectă, se citesc înainte (lookahead) k simboluri.

Gramaticile LR(k) sunt gramaticile unde simbolurile sunt citite de la stânga spre dreapta, se produc reduceri rightmost şi se citesc înainte k simboluri.

2.2.6 Lookahead

În practică, token-urile nu sunt citite deodată, parser-ul efectuând derivări sau reduceri în funcţie de token-urile citite până într-un moment, acestea fiind citite unul câte unul. Din acest motiv, pentru a lua decizia corectă de a deriva sau reduce în funcţie de un şir de token-uri citite, în unele situaţii trebuie să se ţină cont şi de token-urile ce urmează a fi citite. Acest procedeu se numeşte lookahead. Cu cât numărul de simboluri lookahead este mai mare, cu atât gramatica poate fi mai complexă. În implementări se folosesc de obicei gramatici LL(1) şi LR(1).

Un exemplu pentru necesitatea utilizării procedurii de lookahead este, pentru şirul a + b * c

şi gramatica

E E + T | T

T T * F | F

F  ( E ) | id

producţia E E + T | T. Utilizând un bottom-up parser şi citind token-ul “b”, dacă parser-ul nu ar ţine cont că următorparser-ul token e “*” ar putea decide să facă reducerea corpparser-ului E + T la nonterminalul E (desigur, reducerea începe de la id la F). Continuând astfel, rezultatul nu ar mai respecta prioritatea înmulţirii faţă de adunare din moment ce este considerarată ca operaţie “a + b”. Ţinând cont însă de faptul că următorul token e “*”, parser-ul nu ar mai efectua reducerea şi ar continua citirea simbolurilor, făcând întâi reducerea T * F la T şi abia apoi E + T la E.

2.2.7 Ambiguităţi

De multe ori, o gramatică poate să fie ambiguă, neputându-se determina o derivare leftmost sau rightmost unică pentru un anumit şir de simboluri. Un parser automat nu poate decide în astfel de condiţii ce derivare să facă, deci nu se poate decide cum va fi construit

(14)

arborele de parsing. Pentru a soluţiona astfel de probleme, fie gramatica este rescrisă astfel încât ambiguităţile să fie eliminate, fie se introduc reguli suplimentare de decizie. O ambiguitate întâlnită la majoritatea limbajelor de programare este ambiguitatea if-else. Considerând parte a unei gramatici ce descrie propoziţia condiţională if şi, opţional, partea

else,

stmt  ifstmt | ...

ifstmt if ( expr ) then stmt [ else stmt ]

unde nonterminalul stmt poate fi derivat atât în ifstmt cât şi în alte terminale/nonterminale şi considerând expr ca un nonterminal ce poate fi derivat într-o expresie de egalitate, există următorul text:

if ( a == b ) then if (b == c) then b++ else c++

Atât timp cât propoziţia se încheie aici, fără a fi urmată de un alt simbol “else“, parser-ul nu poate decide dacă simbolul “else” aparţine primei propoziţii if sau celei de a doua. Această ambiguitate este rezolvată cu ajutorul regulii ce consideră că simbolul “else” aparţine întotdeauna ultimei propoziţii if citite, cu alte cuvinte propoziţia de mai sus se reduce la

if ( a == b) then ifstmt şi nu la

if (a == b) then ifstmt else c++

2.2.8 Gramatici LL(1)

Mulţimea gramaticilor LL(1) este suficient de mare pentru a include majoritatea limbajelor de programare[3]. Totuşi, anumite gramatici trebuiesc reformulate pentru a deveni LL(1), eliminând instanţele de recursivitate-stânga şi ambiguităţile.

O gramatică este LL(1) dacă pentru orice A α | β, unde α şi β sunt două producţii distincte a gramaticii, următoarele condiţii sunt îndeplinite:

1. Pentru nici un terminal a α şi β să nu poată deriva, ambele, un şir ce începe cu terminalul a.

2. Cel mult una dintre producţiile α şi β pot deriva şirul vid ε.

3. Dacă β derivează în oricâţi paşi ε, atunci α nu derivează nici un şir ce începe cu un terminal care se poate găsi după A în orice producţie a gramaticii. Analog pentru β, dacă α derivează ε în oricâţi paşi[3].

Anumite elemente ale unor gramatici LR(1) trebuiesc rescrise pentru a respecta aceste proprietăţi. De exemplu gramatica operaţiilor aritmetice

E E + T | T

(15)

F  ( E ) | id

nu este o gramatică LL(1) deoarece are recursivitate-stânga pentru derivările E E + T şi

T  T * F. Gramatica LL(1) echivalentă este

E T E'

E' + T E'

T F T'

T' * F T'

F ( E ) | id

Pentru această clasă de gramatici există numeroşi algoritmi de parsing. Parserul utlizat de compilatorul prezentat în această lucrare este un recursive descent parser.

2.2.9 Recursive descent parser

Un recursive descent parser este un parser top-down ce asociază o funcţie fiecărei producţii a gramaticii. Pornind de la funcţia asociată simbolului de start, în funcţie de token-urile citie, parser-ul face derivări leftmost apelând recursiv funcţiile corespunzătoare nonterminalilor.

De exemplu pentru gramatica

E T E'

E' + T E'

T F T' T' * F T'

F ( E ) | id

sunt create funcţiileE(),E1(), T(), T1() şi F() şi pentru textul

a + b * c

analiza decurge astfel: întâi este apelată funcţia E(). Citind simbolul “a” este apelată

recursiv funcţia T() care apelează funcţia F()unde simbolul este consumat. Se revine astfel

în funcţia E() şi se apelează E1() care consumă “+” şi apelează funcţia T() şamd.

Se observă că procesul nu poate avea loc pentru o gramatică stânga-recursivă deoarece pentru producţia E E + T | T funcţia E() s-ar putea apela recursiv pe ea însăşi

la infinit.

În cazul în care şirul de simboluri input se întrerupe într-un moment în care nu se poate finaliza derivarea simbolului de start în şirul de simboluri exclusiv terminale sau în cazul în care într-un anumit moment simbolul citit nu permite efectuarea niciunei derivări din toate derivările posibile, atunci textul sursă este considerat incorect din punct de vedere sintactic şi se raportează o eroare de sintaxă.

(16)

2.3 Syntax directed translation

Un recursive descent parser ca cel prezentat mai sus este capabil să producă toate derivările necesare pornind de la simbolul de start şi finalizând cu un şir de simboluri terminale. În cazul în care textul sursă este incorect din punct de vedere sintactic, acest parser raportează erorile întâlnite. Algoritmul este bun dar insuficient deoarece nu procesează mai departe textul sursă – practic singurul rezultat al execuţiei este confirmarea că textul sursă este corect formulat.

Pentru a continua procesarea, următorul pas fiind aducerea textului sursă la o reprezentare intermediară, este necesară introducerea unor reguli de translaţie. Regulile de translaţie extind definiţia producţiilor gramaticale cu instrucţiuni de procesare ulterioară a datelor.

2.3.1 Acţiuni semantice

Acţiunile semantice sunt instrucţiuni scrise în limbajul de programare în care este implementat parser-ul, adăugate producţiilor gramaticale.

Considerând gramatica E T E' E' + T E' T F T' T' * F T' F ( E ) | id

extinsă cu acţiunile semantice (în pseudocod)

E T E'

E' + T E' { write “+” }

T F T'

T' * F T' { write “*” }

F ( E ) | id {write id.value}

unde id.value semnifică valoarea token-ului, în urma efectuării parsing-ului asupra unui şir

a + b * (c + d)

pe ecran va fi afişată expresia matematică în formă poloneză postfixă a b c d + * +

(17)

Există numeroase aplicaţii care, pe baza descrierii unei gramatici într-o formă BNF extinsă (forma utilizată depinde de aplicaţie), împreună cu acţiuni semantice, generează codul sursă al parser-ului asociat gramaticii.

YACC generează pe baza descrierii unei astfel de gramatici un parser de tip bottom-up cu codul sursă scris în limbajul C.

Coco/R generează un parser de tip top-down, recursive descent, cu codul sursă în limbajul C#1. Pentru fiecare producţie gramaticală, Coco/R construieşte o funcţie asociată, introducând în această funcţie acţiunile semantice specificate în definiţia gramaticii.

2.4 Reprezentări intermediare

Urmând acţiunile semantice, textul sursă este tradus într-o reprezentare intermediară pentru a fi procesat ulterior. În trecut, când cantitatea de memorie RAM disponibilă era limitată, compilatoarele generau codul final citind fragmente din textul sursă şi transformând fiecare fragment în cod, fără a păstra o reprezentare abstractă completă a textului în memorie. Astfel de compilatoare erau numite one-pass compilers, având nevoie de o singură parcurgere a textului.

Principalul dezavantaj al acestor compilatoare era lipsa unei “vederi” de ansamblu asupra programului. De exemplu, în limbajul Pascal, pentru a apela o funcţie înainte de a o defini, aceasta trebuia declarată (cu nume, parametri şi valoare returnată) înaintea apelului cu cuvântul cheie forward. Acest artificiu a fost introdus deoarece compilatorul era nevoit

să codifice apelul de funcţie înainte de a cunoaşte funcţia ce trebuia apelată.

În compilatoarele moderne se preferă utilizarea mai multor paşi, producerea unei reprezentări complete şi parcurgerea reprezentării pentru a emite cod, diferenţa de performanţă fiind nesemnificativă comparativ cu avantajele aduse[4].

2.4.1 Tabele de simboluri

Tabelele de simboluri sunt prezente în toate compilatoarele, chiar şi cele ce nu crează reprezentări intermediare complete. La nivelul interpretării textului sursă, simboluri sunt considerate construcţiile specifice limbajului din textul sursă sau alte surse referite. Tabelele de simboluri sunt completate pe măsură ce textul este analizat sintactic, iar sinteza codului se face utilizând informaţiile din aceste tabele.

Simboluri sunt, de exemplu, variabilele şi funcţiile. În momentul în care o variabilă este declarată, se crează o intrare în tabela de simboluri ce conţine numele variabilei, tipul ei, o eventuală valoare iniţială, adresa din memorie etc. În momentul în care în textul sursă se face o referire la variabilă (prin numele ei), căutând în tabela de simboluri se poate determina locaţia ei în memorie sau tipul. La fel, funcţiile sunt definite prin nume, parametri şi valoare returnată. În funcţie de limbaj, simboluri sunt şi declaraţiile de tipuri, structuri, clase, interfeţe etc.

1Există versiuni ale generatorului Coco/R şi pentru alte limbaje, precum Java, VB.NET sau C++. Pentru

(18)

Oricărui program îi corespund mai multe tabele de simboluri, organizate ierarhic astfel: variabilele globale, declaraţiile de funcţii globale etc. sunt introduse într-o tabelă. Variabilele locale ce aparţin unei funcţii sunt introduse într-o tabelă de simboluri ce corespunde funcţiei şi care păstrează o legătură către tabela de simboluri care conţine definiţia funcţiei.

Tabela de simboluri activă este tabela corespunzătoare blocului de cod procesat la un moment dat. Astfel sunt delimitate scope-urile, adică zonele de vizibilitate pentru diferite variabile. În momentul în care este referită o variabilă (sau orice alt simbol), i se caută intrarea corespunzătoare în tabela de simboluri activă. În cazul în care nu se găseşte nicio intrare, se caută în tabela de simboluri părinte (tabela spre care tabela activă păstrează o legătură). Procesul se repetă până când fie este găsită intrarea, fie se parcurge fără rezultat tabela de simboluri rădăcină, care nu are părinte, situaţie în care referinţa este invalidă (se încearcă referirea unui simbol inexistent). Datorită acestei organizări nu se poate referi o variabilă locală unei funcţii în afara funcţiei (deoarece este out of scope – tabela activă nu este tabela ce conine variabila i nici copil al tabelei ce conine variabila) şi tot de aceea, când există o variabilă globală ce are acelaşi nume cu o variabilă locală, referirea după nume se face întotdeauna la variabila locală (deoarece intrarea ei este găsită prima).

Tabelele de simboluri sunt completate cu ajutorul acţiunilor semantice introduse în parser.

2.4.2 Arbori abstracţi de sintaxă

Deşi există mai multe tipuri de reprezentări intermediare a expresiilor – grafuri aciclice direcţionate, cvadruple, triplete etc., compilatorul prezentat utilizează doar arborii abstracţi de sintaxă.

Arborii abstracţi de sintaxă reprezintă expresiile ca un nod părinte ce conţine operatorul şi unul sau mai multe noduri copii ce conţin operanzii. Expresia

(19)

este reprezentată într-un arbore abstract de sintaxă astfel:

Folosind tabelele de simboluri şi arbori abstracţi de sintaxă, se poate produce o reprezentare intermediară completă a unui text sursă, independentă de limbajul în care textul a fost scris. Această reprezentare păstrează doar semantica (înţelesul) textului sursă, renunţând la sintaxă.

Pe lângă operatori sau operanzi, nodurile mai pot conţine diferite atribute construite deodată cu nodul sau în timpul analizei semantice.

2.4.3 Propoziţii

Pe lângă subarborii de tip expresie se mai distinge cel puţin o categorie de subarbori corespunzătoare construcţiilor din textul sursă: propoziţiile.

Propoziţiile, spre deosebire de expresii, nu sunt reprezentate prin operatori şi operanzi. Rădăcina unei propoziţii reprezintă o acţiune specifică iar nodurile reprezintă alte propoziţii sau expresii.

Exemple de propoziţii sunt structurile condiţionale if-else, structurile repetitive

precum while sau for şi chiar blocurile, acestea din urmă fiind reprezentate printr-o

rădăcină ce are atâţia copii câte propoziţii se găsesc în interiorul blocului. Textului sursă if (a == b) then begin a = a + 1 b = b – 1 end

(20)

if == a b bloc + a 1 a -b 1 b

unde subarborii reprezentaţi de textele “a == b”, “a + 1” şi “b – 1” sunt expresii, iar

subarborii reprezentaţi de textele “if expresie propoziţie”, “begin listă_propoziţii end” şi

“variabilă = expresie” sunt propoziţii.

Principala diferenţă dintre expresii şi propoziţii este că, în urma evaluării, expresiile returnează o valoare (rezultatul evaluării) pe când propoziţiile nu returnează nimic, ele reprezentând doar acţiuni precum atribuirea.

2.5 Analiza semantică

Analiza semantică poate fi efectuată deodată cu analiza sintactică sau ca un pas separat, asupra reprezentării intermediare. Analiza semantică validează expresiile limbajului corecte din punct de vedere sintactic, luând în considerare “semnificaţia” lor.

De exemplu, pentru gramatica

E E + T | T

T T * F | F

F  ( E ) | id

dacă considerăm că operatorul pentru concatenarea şirurilor de caractere este tot “+”, putem avea în textul sursă expresia

‘text’ + 14

care, deşi corectă din punct de vedere sintactic, nu are sens din punct de vedere logic. Într-un astfel de caz, în fÎntr-uncţie de limbajul utilizat, fie se semnalează o eroare datorată

(21)

incompatibilităţii între tipuri, fie, de exemplu, 14 este transformat implicit în şirul de caractere ‘14’, rezultatul fiind ‘text14’1.

2.5.1 Atribute semantice

Componentă a analizei semantice este şi sinteza atributelor semantice. În momentul parcurgerii şi evaluării arborelui abstract de sintaxă, se determină diferite atribute a nodurilor care nu erau cunoscute înaintea analizei. Pentru nodurile

se cunoaşte încă din momentul în care au fost create că nodurile ce reprezintă operatorii “10” şi “12” sunt noduri de tip număr întreg dar rezultatul operaţiei, adică tipul nodului operator “+”, este determinat doar în timpul analizei semantice. Operatorului i se atribuie tot tipul număr întreg.

2.5.2 Modificarea dinamică a arborelui

Există numeroase cazuri în care, în timpul analizei semantice, se constată necesitatea inserării unor noduri în interiorul arborelui.

De exemplu, dacă expresia prezentată la punctul 2.5.1 este o sub expresie, ca 2.5 * (10 + 12)

se poate stabili că se încearcă înmulţirea unui număr întreg cu un număr în virgulă mobilă, ceea ce presupune inserarea unei operaţii de conversie implicită în număr în virgulă mobilă a rezultatului operaţiei de adunare:

1Conversia între tipuri este tot o expresie, având ca operator tipul în care se doreşte conversia şi ca unic

(22)

Unde în timpul analizei semantice se determină tipul nodurilor operand (adică a rezultatului expresiilor) şi se introduce nodul de conversie.

Rezultatul analizei semantice este un arbore abstract de sintaxă validat din punct de vedere logic (în cazul în care textul sursă este corect), având toate atributele specifice fiecărui nod determinate.

2.6 Optimizări independente de maşină

Optimizările independente de maşină sunt optimizările la nivel logic efectuate pe reprezentarea intermediară a programului. Problema optimizării este o problemă NP-completă (imposibil de soluţionat cu un algoritm având ordinul de complexitate polinomial), cu alte cuvinte nu există niciun algoritm care să garanteze că, pentru orice text sursă, produce codul optim[3]. Totuşi, există o mulţime de tehnici de optimizare a diferitelor aspecte ale unui program, de multe ori aceste optimizări contând cel mai mult când se compară diferite compilatoare deoarece de ele depinde performanţa codului produs.

Compilatorul prezentat efectuează două astfel de optimizări: constant folding şi

dead code elimination.

2.6.1 Constant folding

Constant folding presupune transformarea expresiilor cu operanzi exclusiv constanţi

într-o singură valoare constantă. Astfel, expresia “10 + 12” formată din operatorul “+” şi operanzii “10” şi “12” şi reprezentată în arborele abstract de sintaxă prin trei noduri poate fi redusă la valoarea “22” şi reprezentarea în arborele abstract de sintaxă printr-un singur nod.

Avantajul unei astfel de optimizări este evident în momentul generării codului: în loc de a încărca două valori, a le aplica operatorul “+” şi a stoca rezultatul operaţiei, se încarcă doar o valoare.

(23)

2.6.2 Dead code elimination

Dead code elimination presupune eliminarea din codul generat a segmentelor de

instrucţiuni ce nu vor fi executate niciodată. Astfel de instrucţiuni pot să apără dacă în textul sursă, în acelaşi bloc de cod, există propoziţii după o propoziţie return, propoziţie

care părăseşte imediat blocul. Astfel, chiar în timpul compilării se poate determina că nu se va ajunge niciodată la propoziţiile respective.

La fel, pentru o propoziţie condiţională if în care expresia-condiţie este constantă

sau devine constantă în urma constant folding-ului, în caz că expresia este adevărată, se poate renunţa la introducerea în codul generat al ramurii else. Analog, dacă expresia este

constant falsă, se poate renunţa la introducerea ramurii if.

Practic, operând pe reprezentarea intermediară, întreaga expresie if este înlocuită

cu una din ramurile sale, renunţându-se atât la codul ce corespunde evaluării expresiei-condiţie cât şi la cealaltă ramură.

Avantajul acestui tip de optimizare este atât reducerea în dimensiune a codului produs cât şi, în cazul transformării instrucţiunii if, reducerea numărului de operaţii

efectuate.

2.7 Generarea codului şi optimizări dependente de maşină

Ultimul pas efectuat de un compilatorul este sinteza codului pe baza reprezentării intermediare şi optimizările dependente de maşină. Sinteza codului este, evident, strâns legată de maşina pentru care se generează codul respectiv.

Luând în considerare faptul că majoritatea maşinilor pot fi programate cu ajutorul unui limbaj de asamblare ce pune la dispoziţie instrucţiuni pentru încărcarea valorilor, instrucţiuni pentru a opera asupra valorilor şi instrucţiuni pentru stocarea valorilor, se pot determina la nivel abstract reprezentarea expresiilor uzuale.

2.7.1 Expresii unare şi binare

În categoria expresiilor unare şi binare intră operaţiile aritmetice şi logice, comparaţiile etc. Codul este generat parcurgând arborele abstract de sintaxă în postordine.

De exemplu, pentru o maşină bazată pe o stivă1, ştiind că limbajul de asamblare este format din următoarele instrucţiuni:

- LD x încarcă variabila x pe stivă

- SUM adună primele 2 variabile din vârful stivei şi le înlocuieşte cu suma lor

- MUL înmulţeşte primele 2 variabile din vârful stivei şi le înlocuieşte cu produsul

lor,

pentru arborele abstract

1Acesta este modelul folosit de unele calculatoare vechi şi adoptat de obicei de maşinile virtuale. O maşină

bazată pe stivă încarcă şi scoate valori pe stivă şi execută operaţii pe elementele din vârful stivei. O astfel de maşină nu foloseşte regiştri.

(24)

se generează, parcurgând arborele în postordine, secvenţa de instrucţiuni 1:LD a 2:LD b 3:LD c 4:MUL 5:SUM

În urma execuţiei secvenţei de instrucţiuni, stiva va conţine rezultatul expresiei.

2.7.2 Structuri condiţionale

Structurile condiţionale sunt reprezentate de structuri precum IF-ELSE sau SWITCH,

care, în funcţie de rezultatul unei expresii, decid ce instrucţiuni urmează a fi executate. Extinzând limbajul de asamblare cu instrucţiunile

- JMP x mută necondiţionat controlul la instrucţiunea x

- JZ x mută controlul la instrucţiunea x dacă pe vârful stivei se află valoarea zero

- ST x scoate valoarea din vârful stivei şi o stochează în variabila x

- EQ verifică egalitatea primelor două valorile din vârful stivei şi pune pe stivă 1

dacă acestea sunt egale, 0 în caz contrar pentru textul sursă

IF (a == b) a = a + 1

(25)

se generează secvenţa de instrucţiuni 1:LD a 2:LD b 3:EQ 4:JZ 9 5:LD a 6:LD 1 7:SUM 8:ST a 9: ...

Unde instrucţiunea numărul 9 reprezintă instrucţiunea care urmează în textul sursă după secvenţa de mai sus.

2.7.3 Structuri repetitive

Structurile repetitive sunt reprezentate de propoziţii precum WHILE, FOR, DO-WHILE, REPEAT-UNTIL etc.

Pentru secvenţa de text sursă

WHILE (a == b) a = a + 1

(26)

se generează secvenţa de instrucţiuni 1:LD a 2:LD b 3:EQ 4:JZ 10 5:LD a 6:LD 1 7:SUM 8:ST a 9:JMP 1 10: ...

Unde instrucţiunea numărul 10 reprezintă instrucţiunea care urmează în textul sursă după secvenţa de mai sus.

2.7.4 Optimizări dependente de maşină

Optimizările dependente de maşină sunt optimizările care nu se pot realiza luând în considerare doar reprezentarea intermediară ci necesită cunoaşterea maşinii pe care codul produs de compilator va rula. Un exemplu de astfel de optimizare – care nu se aplică la compilatoarele ce produc cod pentru platforma .NET, ca cel prezentat – ar fi, în momentul în care se remarcă faptul că o variabilă este intens referită, să se aleagă opţiunea de a stoca variabila respectivă într-un registru al procesorului în loc de a o stoca în memoira RAM.

Astfel de optimizări nu pot fi prezentate din punct de vedere teoretic, în mod abstract, deoarece depind direct de arhitectura maşinii pentru care este generat codul.

(27)

3. Generarea de cod pentru platforma .NET

Componenta compilatorului ce se ocupă de generarea şi optimizarea codului

(back-end-ul compilatorului) este strâns legată de platforma pentru care compilatorul generează

cod – în acest caz platforma .NET.

Pentru a putea emite un executabil valid, trebuie cunoscută structura unui fişier executabil precum şi setul de instrucţiuni puse la disponibile de maşina pe care codul va fi executat.

3.1 Platforma .NET

Platforma .NET (.NET Framework) este o componentă software pusă la dispoziţie de către Microsoft şi integrată în sistemele de operare începând cu Windows 2003 Server. .NET Framework este o ofertă cheie a firmei, intenţionându-se ca aceasta să fie folosită de către majoritatea noilor aplicaţii create pentru platforma Windows[9].

Mai mult, o versiune a platformei există şi pentru dispozitive mobile (.NET Compact Framework) precum şi o portare pentru sistemele de operare Linux (Mono Project).

Platforma este compusă din două mari componente: maşina virtuală - .NET Common Language Runtime şi o bibliotecă de clase (Base Class Library) ce acoperă o arie largă de necesităţi precum interfaţa cu utilizatorul, accesul la date, conectivitatea la baze de date, criptografie, dezvoltarea de aplicaţii web, algoritmi numerici şi comunicaţii prin reţea[9]. Organizarea internă a tipurilor de date este pur orientată obiect.

Un alt mare avantaj îl constituie interoperabilitatea extinsă între limbajele de programare care produc aplicaţii pentru platformă – interoperabilitate realizată nu doar la nivel de apel reciproc al funcţiilor ci la nivel de clase, un program scris într-un limbaj putând moşteni clase scrise în alt limbaj şi compilate de un alt compilator.

3.1.1 Common Language Runtime

Maşina virtuală (CLR) asigură un “strat” operaţional între aplicaţiile .NET şi sistemul de operare. Compilatoarele ce produc cod pentru platformă reprezintă acest cod într-o formă intermediară abstractă, independentă de limbajul de programare sursă folosit şi

(28)

independentă de sistemul de operare pe care codul va fi executat. Reprezentarea intermediară pusă la dispoziţie asigură interoperabilitatea extinsă dintre limbaje.

O aplicaţie .NET poartă numele de assembly. Un assembly poate fi format din unul sau mai multe fişiere, denumite module.

Un modul este compus din două mari componente, metadata şi limbajul intermediar, reprezentat în formă binară abstractă, numit Microsoft Intermediate Language (MSIL) sau Common Intermediate Language (CIL) sau, pe scurt IL.

Metadata este un sistem de descriptori pentru toate elementele structurale ale unei aplicaţii – clase, mebri şi atributele claselor, elemente globale etc. – şi a relaţiei dintre ele. Acest sistem stă la baza interoperabilităţii, clasele compilate într-un assembly putând fi utilizate citind metadata ce le descrie.

IL conţine instrucţiunile specifice maşinii virtuale. Codul executat de CLR se numeşte managed code, runtime-ul asigurând managementul codului. Printre altele, acesta asigură garbage collection (obiectele neutilizate sunt eliberate automat din memorie), tratarea excepţiilor, verificarea şi conversia automată a tipurilor în timpul execuţiei etc.

În principiu, CLR se aseamănă altor run-time-uri pentru limbaje interpretate, cum ar fi GBasic. Dar asemănarea este doar principială, CLR nu este un interpretor[6]. Modelul de execuţie utilizat de CLR foloseşte două componente, un loader şi un compilator

just-in-time (JIT).

Loader-ul citeşte metadata şi crează în memorie o reprezentare internă a claselor executând în acelaşi timp verificări pentru a confirma integritatea metadatelor referite.

Compilatorul JIT transformă metodele din IL în cod nativ maşinii pe care este rulată aplicaţia, păstrând în memorie codul nativ în aşteptarea execuţiilor ulterioare. Codul nativ astfel compilat este executat de către sistemul de operare, oferind astfel o performanţă mult mai ridicată faţă de un interpretor.

Atât loader-ul cât şi compilatorul JIT lucrează doar la cerere – când o clasă este referită, loader-ul citeşte metadata asociată clasei; când o metodă este apelată, compilatorul JIT transformă codul IL în cod nativ. În caz că o clasă nu este referită niciodată dea lungul execuţiei unei aplicaţii sau o metodă nu este apelată niciodată, loader-ul, respectiv compilatorul JIT, nu le vor procesa.

3.1.2 Common Language Specification

Având în vedere că aplicaţiile .NET sunt generate de diferite compilatoare (cum ar fi Microsoft Visual C#, Microsoft Visual Basic .NET sau chiar compilatorul prezentat în această lucrare), pot apărea diferite incompatibilităţi datorate diferenţei dintre limbaje, chiar dacă toate compilatoarele produc metadata şi cod IL corect. De exemplu un limbaj poate fi case-sensitive, declarând două clase cu acelaşi nume şi diferenţiidu-le doar prin litere mici/mari. Folosind un alt limbaj, case-insensitive, utlizatorul încearcă să utilizeze una din clase. Compilatorul acestui limbaj nu va putea decide care din cele două clase este referită.

Pentru a rezolva astfel de probleme s-a impus stabilirea unui set de reguli care să asigure o interoperabilitate consistentă. Acest set de reguli poartă denumirea de Common Language Specification (CLS) şi limitează elementele limbajelor precum convenţiile de

(29)

denumire, tipurile de date, funcţiile acceptate şi altele. CLS este detaliat în partea a 2-a a standardului ECMA 335 - Common Langauge Infrastructure 4th Edition din iunie 20061.

Trebuie avut în vedere că aceste specificaţii sunt doar recomandări pentru a asigura interoperabilitatea aplicaţiilor .NET. În cazul în care un compilator produce cod ce nu respectă CLS, acest cod s-ar putea să nu poată fi utilizat de către alte aplicaţii dar el poate să fie cod valid, ce poate fi executat de către CLR.

3.1.3 Reflection

Reflection este numele generic dat capabilităţilor specifice maşinilor virtuale de a inspecta (citi) cod compilat şi de a pune la dispoziţie programatorilor unelte necesare pentru generarea de cod executabil la runtime.

Platforma .NET pune la dispoziţie un set de clase grupate sub denumirea de

System.Reflection pentru a inspecta codul compilat – acest lucru făcându-se citind

metadata, nu prin dezasamblare. Sunt puse astfel la dispoziţie clase ce încapsulează informaţiile conţinute de un assembly, de un modul, de o clasă, de o metodă etc. Proprietăţile acestor clase sunt extrase întotdeauna din assembly-uri compilate.

Este pus la dispoziţie şi un set de clase, grupate sub denumirea de

System.Reflection.Emit, utilizate pentru a genera dinamic assembly-uri şi cod executabil.

Aici, claselor System.Reflection le corespund clase ce pot construi entităţi precum metode, clase şi assembly-uri pe care le pot ulterior salva şi/sau executa.

Avantajul utilizării capabilităţilor Reflection oferite de .NET pentru construirea unui compilator sunt numeroase: compilatorul nu trebuie să cunoască toate clasele puse la dispoziţie de .NET sau de alte aplicaţii - când se doreşte apelarea unei entităţi externe programului, compilatorul poate folosi Reflection pentru a identifica şi valida entitatea externă. În momentul generării unui executabil, compilatorul poate completa mult mai uşor informaţiile metadata folosind clasele specifice decât scrierea directă a fişierului binar. În acest fel, compilatorul poate să nu ţină cont de structura efectivă a unui executabil şi header-ele specifice acestuia (care, desigur, diferă în funcţie de sistemul de operare), folosind chiar platforma .NET pentru a produce aplicaţii .NET. Astfel se oferă un plus de portabilitate – compilatorul poate fi utilizat pe orice sistem de operare pe care este instalat .NET.

3.1.4 Intermediate Language

Cu toate că utilizarea capabilităţilor Reflection uşurează compilarea informaţiilor metadata, codul executabil trebuie scris utilizând corect limbajul intermediar şi facilităţile puse la dispoziţie de maşina virtuală.

Maşina virtuală este bazată pe o stivă – o instrucţiune IL poate să adauge date pe stivă, sau să consume date din stivă. Maşina nu foloseşte regiştri şi nu permite adresarea

(30)

directă a memoriei1. Tipurile de date utilizate se împart în două categorii: tipuri valoare (value types) şi tipuri referinţă (reference types). Tipurile valoare sunt încărcate direct pe stivă (cum ar fi întregii, numerele în virgulă flotantă, structurile etc.). Tipurile referinţă sunt încărcate în memoria heap, pe stivă punându-se doar o referinţă către aceste tipuri. Referinţele nu sunt pointeri deoarece ele nu adresează o locaţie fixă din memorie – mecanismul de garbage collection poate muta instanţele stocate în heap dintr-o zonă de memorie în alta.

Setul de instrucţiuni IL specific versiunii 2.0 a platformei .NET numără 220 de instrucţiuni. O instrucţiune IL este reprezentată de un opcode (codul operaţiei) şi, eventual, unul sau mai mulţi parametri. Parametrii pot fi fie value types, fie referinie, fie referiri la metadata (metadata token). De exemplu apelarea unei metode se poate face folosind instrucţiunea call urmată nu de un pointer la metoda apelată ci de o referinţă la metadata

metodei.

3.2 Structura unei aplicaţii

O aplicaţie pentru platformă (un assembly) este format din patru componente: un

assembly manifest ce conţine informaţii despre assembly precum versiunea, cultura,

fişierele componente, semnătura digitală etc.; cod Intermediate Language; tabele de metadata şi resurse.

Aceste componente se regăsesc într-unul sau mai multe module. Pentru fişiere executabile există întotdeauna un modul principal, care conţine punctul de intrare al aplicaţiei. De menţionat că, cu toate că modulele conţin cod IL şi metadata, ele nu pot fi executate independent, în afara contextului assembly-ului. Pentru majoritatea aplicaţiilor, este suficient un singur modul. Platforma permite executarea aplicaţiilor ce menţin module pe alte calculatoare, modulele fiind copiate pe calculatorul pe care rulează aplicaţia doar în momentul în care sunt referite – în această situaţie, pentru un assembly de dimensiuni mari, este indicată folosirea modulelor multiple.

Desigur, o aplicaţie poate apela cod dintr-un assembly extern – la fel cum o aplicaţie pentru platforma Windows poate apela funcţii din bibliotecile sistemului sau biblioteci scrise de utilizatori.

3.2.1 Referirea unui assembly

În modelul .NET, assembly-urile sunt identificate un strong name, nu printr-un nume de fişier şi printr-o cale. Astfel, printr-un strong name este format din cel puţin patru componente: un nume de fişier (fără extensie), o versiune, o cultură (pentru a identifica versiunea unui assembly în cazul aplicaţiilor internaţionalizate, cu mai multe variante pentru diferite regiuni/limbi) şi un public key token utilizat pentru validarea integrităţii.

Astfel, în momentul referirii unui assembly, acesta este căutat în directorul aplicaţiei, în subdirectoarele acesteia şi în Global Assembly Cache, menţinând astfel

1Se pune la dispoziţie un context (unsafe) care permite manipularea directă a memoriei şi lucrul cu pointeri

dar utilizarea acestui context nu este încurajată din motive evidente – CLR nu poate asigura managementul memorie, tratarea excepţiilor şi securitatea pe care le asigură în modul normal de lucru (context safe)

(31)

restricţiile de securitate (nu se poate specifica un director arbitrar) şi integritatea aplicaţiilor.

3.2.2 Global Assembly Cache

Uzual, bibliotecile utilizate de o aplicaţie şi numai de o aplicaţie sunt distribuite în acelaşi director cu aplicaţia sau într-unul din subdirectoare. Totuşi, există biblioteci utilizate de mai multe aplicaţii (întreg Base Class Library este format din biblioteci puse la dispoziţia oricărei aplicaţii). Pentru acestea există spaţiul virtual numit Global Assembly Cache (GAC).

Locaţia pe disc este irelevantă pentru aplicaţiile .NET deoarece o referire printr-un strong name asigură că assembly-urile referite vor fi căutate şi în GAC.

O bibliotecă parte a Base Class Library este mscorlib.dll, bibliotecă ce trebuie

referită de orice assembly deoarece aceast conţine, printre altele, declaraţiile tipurilor de bază (object, string etc.) necesare oricărei aplicaţii.

3.2.3 Metadata

Tabelele metadata conţin descrieri a tuturor elementelor logice dintr-o aplicaţie. Intrarea corespunzătoare unui tip (majoritatea tipurile sunt clase datorită modelului orientat obiect al platformei) în tabelul de metadata conţine, printre altele, numele tipului, vizibilitatea (public, private etc.), clasa pe care o moşteneşte (.NET nu suportă moşteniri multiple), interfeţele pe care le implementează şi informaţii despre membri.

Intrarea corespunzătoare unui membru al unei clase conţine numele membrului, tipul său, vizibilitatea şi alţi modificatori (static, readonly etc.)

Intrarea corespunzătoare unei metode conţine numele metodei, tipurile parametrilor, tipul returnat, vizibilitatea, definiţia de bază a metodei (în caz că metoda este moştenită), alţi modificatori (virtual, abstract, static etc.). Platforma suportă method overloading – metode cu acelaşi nume, declarate în aceiaşi clasă, care diferă doar prin numele şi/sau tipul argumentelor.

Orice apel de metodă sau referire la un tip sau un membru al unui tip se face utilizând tabelele de metadata, printr-un token. Diferite instrucţiuni IL primesc ca parametru un metadata token de care mediul de execuţie se foloseşte pentru a regăsi informaţiile necesare în tabela corespunzătoare. Utilizarea token-urilor metadata reprezintă un mod unificat de a referi atât elementele interne aplicaţiei precum şi elementele externe (definite într-o bibliotecă referită).

3.2.4 Setul de instrucţiuni IL

Instrucţiunile adresate maşinii virtuale sunt citite dintr-un instruction stream, un şir binar de opcode-uri şi parametri dintr-un modul. Maşina virtuală utilizează o stivă care, la lansarea oricărei aplicaţii, este goală. Instrucţiunile pot pune elemente pe stivă – de exemplu ldstr urmat de un şir de caractere pune respectivul şir de caractere pe stivă, pot

(32)

sau ambele – un apel de metodă scoate de pe stivă un număr de elemente egal cu numărul de parametri aşteptaţi şi depune valoarea returnată de metodă.

Astfel, identificăm o proprietate a instrucţiunilor numită delta-stack – diferenţa dintre numărul de elemente aflate pe stivă înainte şi după executarea instrucţiunii[6].

În cazul în care o instrucţiune încearcă să consume mai multe elemente decât sunt pe stivă sau, la ieşirea dintr-o metodă, aceasta lasă pe stivă mai multe elemente decât valoarea returnată, stiva este considerată dezechilibrată şi CLR termină aplicaţia considerând-o invalidă.

Cu toate că setul de instrucţiuni este relativ mare (220 de instrucţiuni) compilatorul prezentat utlizează doar 36 de instrucţiuni:

Operaţii aritmetice:

- neg– înlocuieşte valoarea din vârful stivei cu negativul ei (produsul ei cu -1) - add– înlocuieşte primele două elemente din vârful stivei cu suma lor

- sub – scade primul element din vârful stivei din al doilea şi le înlocuieşte cu

rezultatul operaţiei

- mul– înlocuieşte primele două elemente din vârful stivei cu produsul lor

- div – împarte al doilea element din vârful stivei la primul şi le înlocuieşte cu

rezultatul operaţiei

- rem – determină restul împărţirii celui de al doilea element din vârful stivei la

primul şi le înlocuieşte cu rezultatul operaţiei Operaţii pe biţi:

- xor– înlocuieşte valoarea din vârful stivei cu complementul său binar

Comparaţii:

- ceq – verifică dacă primele două elemente din vârful stivei sunt egale şi le

înlocuieşte cu 1 dacă sunt, cu 0 dacă nu

- cgt – verifică dacă al doilea element din vârful stivei este mai mare decât

primul şi le înlocuieşte cu 1 dacă este, cu 0 dacă nu

- clt– verifică dacă al doilea element din vârful stivei este mai mic decât primul

şi le înlocuieşte cu 1 dacă este, cu 0 dacă nu Încărcare pe stivă:

- ldc_i4 i– încarcă constanta întreagă i pe stivă - ldc_i4_0– încarcă constanta întreagă 0 pe stivă - ldc_i4_1– încarcă constanta întreagă 1 pe stivă - ldc_i4_2– încarcă constanta întreagă 2 pe stivă - ldc_i4_3– încarcă constanta întreagă 3 pe stivă - ldc_i4_4– încarcă constanta întreagă 4 pe stivă - ldc_i4_5– încarcă constanta întreagă 5 pe stivă - ldc_i4_6– încarcă constanta întreagă 6 pe stivă - ldc_i4_7– încarcă constanta întreagă 7 pe stivă - ldc_i4_8– încarcă constanta întreagă 8 pe stivă

(33)

- ldstr s– încarcă constanta şir de caractere s pe stivă - ldloc x– încarcă pe stivă variabila locală cu numărul x

- ldarg x– încarcă pe stivă argumentul cu numărul x al funcţiei

- ldsfld <token> – primeşte ca parametru un metadata token al unui atribut

static al unei clase şi îl încarcă pe stivă Mutare de pe stivă:

- pop– scoate elementul din vârful stivei

- stloc x– mută valoarea din vârful stivei în variabila locală cu numărul x - stsfld <token> – primeşte ca parametru un metadata token al unui atribut

static al unei clase şi mută valoarea din vârful stivei în el Instrucţiuni de branching:

- ret– părăseşte necondiţionat funcţia

- br x– face un salt necondiţionat la instrucţiunea x

- brfalse x– face un salt la instrucţiunea x dacă valoarea din vârful stivei este 0 - brtrue x – face un salt la instrucţiunea x dacă valoarea din vârful stivei este

diferită de 0

- blt x – face un salt la x dacă prima valoare de pe stivă este mai mică decât a

doua

- bgt x – face un salt la x dacă prima valoare de pe stivă este mai mare decât a

doua

Apeluri de funcţii

- call <token> – primeşte ca parametru un metadata token al unei metode pe

care o apelează pasându-i ca argumente elementele aflate în vârful stivei

- newobj <token>– primeşte ca parametru un metadata token al unui constructor

şi instanţiază un obiect de acel tip, punând pe stivă o referinţă la el

Argumentele unei metode sunt numerotate în ordinea în care sunt primite, 0 corespunzând primului argument, 1 celui de al doilea argument etc. Variabilele locale (declarate în corpul unei metode) sunt numerotate în acelaşi mod, după ordinea în care au fost declarate.

În format binar, numele instrucţiunilor este înlocuit cu o valore reprezentată de 1 sau 2 octeţi (în funcţie de instrucţiune) urmată de eventualii parametri – spre exemplu instrucţiunea ldc_i4 primeşte ca parametru un întreg reprezentat pe 4 octeţi (32 de biţi).

3.3 Generarea de cod

Pentru generarea codului, compilatorul prezentat se foloseşte de capabilităţile Reflection puse la dispoziţie de platformă, care facilitează atât inspectarea assembly-urilor externe – pentru un apel de funcţie de exemplu se verifică dacă funcţia se regăseşte într-adevăr într-o bibliotecă sau apelul din codul sursă este incorect – precum şi scrierea

(34)

fişierului executabil – header pentru sistemul de operare, manifest pentru CLR, tabele de metadata şi instruction stream.

3.3.1 System.Reflection

Printre clasele puse la dispoziţie sub numele de System.Reflection, esenţiale pentru un compilator sunt clasele Assembly, Module, FieldInfo şi MethodInfo. Pe lângă acestea, Reflection utilizează deseori clasa Type, care se regăseşte în biblioteca System.

Fiecărui tip de date îi corespunde o instanţă a clasei Type, care conţine informaţii relevante despre tip, extrase din tabela de metadata (ce clasă moşteneşte, vizibilitatea etc.) şi metode care permit inspectarea ulterioară a membrilor tipului (ce atribute şi ce metode conţine).

Aşa cum unui tip îi corespunde un obiect Type, unui atribut conţinut de un tip îi corespunde un obiect FieldInfo pe când unei metode (conţinută sau nu într-un tip, deoarece .NET suportă metode globale) îi corespunde un obiect MethodInfo. La fel ca pentru tipuri, aceste obiecte conţin datele ce se regăsesc în tabelele de metadata corespunzătoare atributelor şi metodelor - vizibilitate, modificatori; tip pentru atribute; valoare returnată şi argumente pentru metode etc.

O instanţă a clasei Assembly conţine informaţii despre un assembly .NET, precum tipurile pe care acesta le conţine, entrypoint-ul şi manifestul. Este pusă la dispoziţie o metodă pentru a extrage modulele ce compun assembly-ul – GetModules() – care returnează o listă de module, modulele punând la rândul lor la dispoziţie o metodă ce extrage din metadata definiţiile de tipuri – GetTypes(). Metoda returnează o listă de obiecte Type pentru toate tipurile definite în modul.

Folosindu-se metoda statică Assembly.Load, care primeşte ca parametru un strong name ce corespunde unui assembly, se crează o instanţă a clasei Assembly corespunzătoare assembly-ului referit.

Compilatorul se foloseşte de aceste clase pentru a valida referirile la atribute şi apeluri de metode externe. În momentul în care se doreşte compilarea unui text sursă, compilatorul primeşte, opţional, ca argumente şi o listă de strong names care identifică assembly-urile a căror componente sunt referite în textul sursă, creând instanţe Assembly pentru fiecare dintre ele. În momentul în care se identifică o referire externă, de exemplu către o metodă, compilatorul se foloseşte de listă pentru a căuta o metodeă care se potriveşte cu semnătura apelului (acelaşi nume, parametri compatibili). Se determină astfel în timpul compilării dacă apelul este corect sau se încearcă apelarea unei metode inexistente. Biblioteca mscorlib.dll este încărcată implicit pentru orice text sursă.

3.3.2 System.Reflection.Emit

Clasele puse la dispoziţie sub numele de System.Reflection.Emit şi utilizate de compilator sunt AssemblyBuilder, ModuleBuilder, TypeBuilder, ConstructorBuilder,

MethodBuilder, FieldBuilder şi ILGenerator.

Clasele AssemblyBuilder, ModuleBuilder, TypeBuilder, ConstructorBuilder, MethodBuilder şi FieldBuilder permit declararea structurată a intrărilor metadata, folosind un model orientat obiect. De exemplu, pentru a construi un modul, acesta trebuie să

Referências

Documentos relacionados

Uma boa gestão de stock é imprescindível para o bom funcionamento da farmácia, desta forma, é necessário que qualquer artigo esteja sempre disponível para o utente

Foi criada em 1118, em Jerusalém, uma Ordem de Cavalaria chamada de Ordem dos Pobres Cavaleiros de Cristo e do Templo de Salomão, famosa como Ordem dos Templários.

CARLOS MARCELO D’ISEP-Cel BM Comandante-Geral do CBMES Protocolo 364540 RESUMO DE CONVÊNIO Nº 011/2017 CONCEDENTE: Estado do Espírito Santo, por intermédio do Corpo de Bombeiros

O candidato deverá encaminhar até o dia 05/03/2020, exclusivamente para o e-mail do PPEA-UFOP (acima identificado) com assunto: PROCESSO 2021 – PPEA/UFOP,

vermelho sólido O controlador detectou uma falha não recuperável, portanto, ele removeu o projeto da memória.. Mude para o

Effectiveness of two-dose monovalent rotavirus vaccine in preventing hospital admission with rotavirus diarrhea was high, lasted for two years and it was similar against both G1P[8]

1.1 Em caso de Morte da pessoa segura, ocorrida no prazo de dois anos após a data do acidente que lhe deu causa, o segurador garante aos beneficiários designados

Neste artigo serão apresentados os dois sistemas de an- daimes metálicos mais utilizados para elaborar obras arqui- tetônicas utilizando andaimes como elementos estruturais: o tubo