.NET compiler

Page 1

Sviluppo applicativo: Creazione di un compilatore di linguaggio per ...

1 di 9

http://msdn.microsoft.com/it-it/magazine/cc136756(printer).aspx

©2008 Microsoft Corporation. Tutti i diritti riservati.

Sviluppo applicativo Creazione di un compilatore di linguaggio per .NET Framework Joel Pobar

In questo articolo verranno discussi i seguenti argomenti: Definizione del linguaggio Le fasi di un compilatore Lo stack astratto CLR Strumenti per un linguaggio IL (Intermediate Language) corretto

In questo articolo verranno utilizzate le seguenti tecnologie: .NET Framework

Sommario I pirati informatici dei compilatori sono delle celebrità nel mondo dei computer. Mi è capitato di presenziare a una presentazione di Anders Hejlsberg alla Professional Developers Conference al termine della quale è sceso dal palco accolto da una folla di persone che gli chiedevano di firmare libri e fare fotografie. Vi è una certa aria di mistero che avvolge chi si dedica allo studio e alla comprensione degli aspetti più intrinseci delle espressioni lambda, dei sistemi di tipi e dei linguaggi assembly. Ora, questa celebrità è a portata di tutti grazie alla possibilità di scrivere un proprio compilatore per Microsoft® .NET Framework. Sono disponibili centinaia di compilatori per dozzine di linguaggi destinati a .NET Framework. CLR .NET attua una gestione comune di questi linguaggi in modo che possano operare e interagire senza problemi. Uno bravo sviluppatore può sfruttare questo aspetto nello sviluppo di grandi sistemi software, integrando il tutto con un po' di C# e un tocco di Python. Certamente questi sviluppatori sono bravi, ma non arrivano a livello dei veri maestri, i pirati informatici dei compilatori, che hanno una conoscenza approfondita delle macchine virtuali, dell'impostazione dei linguaggi e degli aspetti pratici di tali linguaggi e compilatori. In questo articolo verrà illustrata la creazione del codice per un compilatore scritto in C# (denominato per l'occasione "Good for Nothing") e verrà anche introdotta l'architettura ad alto livello, la teoria e le API di .NET Framework necessarie per creare un compilatore .NET. Inizierò con una definizione del linguaggio per poi passare a esaminare l'architettura del compilatore e addentrarmi quindi nel sottosistema di generazione del codice che produce un assembly .NET. L'obiettivo è far comprendere gli elementi base dello sviluppo dei compilatori e ottenere una buona conoscenza dei metodi di interazione efficace tra i linguaggi e il CLR. Non mi occuperò dello sviluppo dell'equivalente di C# 4.0 o IronRuby, ma la discussione offrirà materiale sufficiente per stimolare gli appassionati dell'arte dello sviluppo di compilatori. Definizione del linguaggio I linguaggi software iniziano con uno scopo specifico. Questo può riguardare aspetti quali espressività (come nel caso di Visual Basic®), produttività (come nel caso di Python, che mira a sfruttare al massimo ogni riga di codice), specializzazione (come nel caso di Verilog, che è un linguaggio di descrizione hardware utilizzato dai produttori di processori) o la semplice soddisfazione delle preferenze personali dell'autore. Il creatore di Boo, ad esempio, predilige .NET Framework ma non è soddisfatto dei linguaggi disponibili. Dopo avere stabilito lo scopo, è possibile passare alla fase di progettazione del linguaggio. I linguaggi di programmazione devono essere molto precisi in modo che il programmatore sia in grado di esprimere esattamente ciò che viene richiesto e che il compilatore sia in grado di capire e generare accuratamente il codice eseguibile che corrisponde esattamente a ciò che è stato dichiarato. È necessario specificare il progetto di un linguaggio per eliminare ogni ambiguità durante l'implementazione di un compilatore. A tal fine viene utilizzata una metasintassi, che è una sintassi utilizzata per descrivere la sintassi dei linguaggi. Vi sono varie metasintassi disponibili e questo consente di scegliere quella più adatta alle preferenze personali. Il linguaggio Good for Nothing verrà specificato utilizzando una metasintassi denominata EBNF (Extended Backus-Naur Form). Vale la pena ricordare che le origini di EBNF sono molto rispettabili: è legata a John Backus, vincitore del Turing Award e importante sviluppatore in FORTRAN. Una discussione approfondita su EBNF esula dallo scopo dell'articolo, ma è opportuno illustrare i concetti fondamentali. La definizione del linguaggio Good for Nothing è illustrata nella Figura 1. In base alla mia definizione del linguaggio, un'istruzione (stmt) può essere costituita da dichiarazioni di variabili, assegnazioni, cicli for, lettura di numeri interi dalla riga di comando o stampa dello schermo e può essere specificata molte volte, separata da punti e virgola. Le espressioni (expr) possono essere stringhe, numeri interi, espressioni aritmetiche o identificatori. Gli identificatori (ident) possono essere denominati utilizzando un carattere alfabetico come prima lettera, seguito da caratteri o numeri. E così via. Con semplicità, ho definito una sintassi del linguaggio che fornisce funzionalità aritmetiche di base, un piccolo sistema di tipi e una semplice interazione utente basata su console. Figure 1 Definizione del linguaggio Good for Nothing Si sarà notato che in termini di specificità questa definizione del linguaggio è concisa. Non ho specificato la grandezza massima dei numeri, ovvero se possono essere maggiori di un intero di 32 bit, o anche se possono essere

08/06/2008 15.24


Sviluppo applicativo: Creazione di un compilatore di linguaggio per ...

2 di 9

http://msdn.microsoft.com/it-it/magazine/cc136756(printer).aspx

negativi. Una definizione EBNF reale determinerebbe in modo accurato questi dettagli, ma per ragioni pratiche, l'esempio di questo articolo sarà più semplice. Di seguito è riportato un esempio del programma del linguaggio Good for Nothing: Copia codice var ntimes = 0; print "How much do you love this company? (1-10) "; read_int ntimes; var x = 0; for x = 0 to ntimes do print "Developers!"; end; print "Who said sit down?!!!!!";

È possibile confrontare questo semplice programma con la definizione del linguaggio per comprendere meglio il funzionamento della grammatica. Questo completa la definizione del linguaggio. Architettura ad alto livello Un compito del compilatore è tradurre le attività ad alto livello create dal programmatore nelle attività che possono essere comprese ed eseguite dal processore di un computer. In altre parole, esso prenderà un programma scritto in linguaggio Good for Nothing e lo tradurrà in qualcosa che può essere eseguito da CLR .NET. Questo risultato viene ottenuto da un compilatore attraverso una serie di passaggi di traduzione, suddividendo il linguaggio nelle parti che ci interessano e scartando il resto. I compilatori seguono principi comuni di progettazione del software: componenti abbinati liberamente, fasi chiamate, combinati insieme per svolgere i passaggi di traduzione. Nella Figura 2 vengono illustrati i componenti che svolgono le fasi di un compilatore: scanner, parser e generatore di codice. In ogni fase, il linguaggio viene scomposto ulteriormente e queste informazioni sull'intento del programma vengono utilizzate nella fase successiva.

Figura 2 Fasi del compilatore (Fare clic sull'immagine per ingrandirla) I meno esperti di compilatori raggruppano spesso astrattamente le fasi nel front-end e nel back-end. Il front-end è costituito dalle attività di scansione e analisi, mentre il back-end è costituito in genere dalla generazione del codice. Il compito del front-end è scoprire la struttura sintattica di un programma e tradurla da testo in una rappresentazione ad alto livello in memoria denominata albero sintattico astratto, sul quale mi soffermerò tra poco. Il back-end ha il compito di convertire l'albero sintattico astratto in qualcosa che possa essere eseguito da un computer. Le tre fasi sono generalmente divise in un front-end e un back-end perché scanner e parser sono tipicamente abbinati, mentre il generatore di codice è normalmente associato a una piattaforma di destinazione. Questa impostazione consente a uno sviluppatore di sostituire il generatore di codice per piattaforme diverse se il linguaggio deve essere multipiattaforma. Il codice del compilatore Good for Nothing è disponibile nel codice distribuito con l'articolo. È possibile seguire la discussione sui vari componenti di ogni fase ed esplorare i dettagli di implementazione. Scanner Il compito primario di uno scanner è di scomporre il testo (un flusso di caratteri nel file sorgente) in grandi porzioni, chiamate token, che possono essere utilizzate dal parser. Lo scanner determina quali token vengono inviati in definitiva al parser ed è quindi in grado di eliminare gli elementi che non sono definiti nella grammatica, come i commenti. Nel caso del linguaggio Good for Nothing, lo scanner prende in considerazione i caratteri (A-Z e i normali simboli), i numeri (0-9), i caratteri che definiscono le operazioni (quali +, -, *, e /), le virgolette per l'incapsulamento delle stringhe e i punti e virgola. Uno scanner raggruppa flussi di caratteri correlati in token destinati al parser. Ad esempio, il flusso di caratteri " h e l l o w o r l d ! " può essere raggruppato in un token: "hello world!". Lo scanner di Good for Nothing è incredibilmente semplice in quanto richiede solo un System.IO.TextReader in fase di creazione dell'istanza. In questo modo viene avviato il processo di scansione, come illustrato di seguito:

08/06/2008 15.24


Sviluppo applicativo: Creazione di un compilatore di linguaggio per ...

3 di 9

http://msdn.microsoft.com/it-it/magazine/cc136756(printer).aspx

public Scanner(TextReader input)

Copia codice

{ this.result = new Collections.List<object>(); this.Scan(input); }

Nella Figura 3 viene illustrato il metodo Scan, che contiene un semplice ciclo while che elabora ogni carattere nel flusso di testo e individua i caratteri riconoscibili dichiarati nella definizione del linguaggio. Ogni volta che viene trovato un carattere o una porzione di caratteri riconoscibile, lo scanner crea un token e l'aggiunge a un List<object>. In questo caso, viene tipizzato come oggetto. Tuttavia, è possibile creare una classe Token o qualcosa di simile per incapsulare ulteriori informazioni sul token, come i numeri di riga e colonna. Figure 3 Metodo di scansione dello scanner Si può notare che quando il codice incontra un carattere ", presuppone che questo incapsuli un token di stringa; di conseguenza, la stringa viene utilizzata e inserita in un'istanza di StringBuilder e aggiunta all'elenco. Dopo che la scansione ha creato l'elenco di token, questi vengono inviati alla classe parser per mezzo di una proprietà denominata Tokens. Parser Il parser è l'elemento essenziale del compilatore ed è disponibile in molte forme e dimensioni. Il parser di Good for Nothing ha diversi compiti: assicura che il programma sorgente sia conforme alla definizione del linguaggio e gestisce l'output di errore in caso di problemi. Inoltre crea la rappresentazione in memoria della sintassi di programma, che viene utilizzata dal generatore di codice e, infine, scopre quali tipi di runtime utilizzare. La prima cosa da fare è verificare la rappresentazione in memoria della sintassi di programma, ossia l'albero sintattico astratto. Quindi verrà preso in esame il codice che crea questo albero dai token dello scanner. Il formato dell'albero sintattico astratto è immediato, efficace, facile da codificare e può essere attraversato molte volte dal generatore di codice. La definizione del compilatore Good for Nothing è illustrata nella Figura 4. Figure 4 Albero sintattico astratto del compilatore di Good for Nothing Un rapido sguardo alla definizione del linguaggio Good for Nothing indica che l'albero sintattico astratto corrisponde ai nodi della definizione del linguaggio della grammatica di EBNF. La cosa migliore da fare è pensare che la definizione del linguaggio incapsuli la sintassi, mentre l'albero sintattico astratto acquisisce la struttura di questi elementi. Sono disponibili numerosi algoritmi per l'analisi, ma la relativa esplorazione esula dallo scopo di questo articolo. In generale, questi si differenziano nel modo in cui elaborano il flusso dei token per creare l'albero sintattico astratto. Nel compilatore Good for Nothing viene utilizzato un cosiddetto parser LL (Left-to-right, Left-most derivation). Questo significa semplicemente che legge il testo da sinistra verso destra, costruendo l'albero sintattico astratto in base al successivo token di input disponibile. Il costruttore della mia classe parser prende semplicemente un elenco di token che è stato creato dallo scanner: Copia codice public Parser(IList<object> tokens) { this.tokens = tokens; this.index = 0; this.result = this.ParseStmt();

if (this.index != this.tokens.Count) throw new Exception("expected EOF"); }

La parte centrale del lavoro di analisi viene eseguita dal metodo ParseStmt, come illustrato nella Figura 5. Questo metodo restituisce un nodo Stmt, che funge da nodo principale dell'albero e corrisponde al nome di livello superiore della definizione della sintassi del linguaggio. Il parser elabora l'elenco dei token utilizzando un indice come posizione corrente mentre identifica i token secondari del nodo Stmt nella sintassi del linguaggio (dichiarazioni e assegnazioni di variabili, cicli for, letture di numeri interi e stampe). Se non è possibile identificare un token, viene generata un'eccezione. Figure 5 Il metodo ParseStmt identifica i token Quando un token viene identificato, viene creato un nodo dell'albero sintattico astratto e viene eseguita un'analisi ulteriore richiesta dal nodo. Il codice necessario per la creazione del nodo dell'albero sintattico astratto di stampa è riportato di seguito: Copia codice // <stmt> := print <expr> if (this.tokens[this.index].Equals("print")) {

08/06/2008 15.24


Sviluppo applicativo: Creazione di un compilatore di linguaggio per ...

4 di 9

http://msdn.microsoft.com/it-it/magazine/cc136756(printer).aspx

this.index++; Print print = new Print(); print.Expr = this.ParseExpr(); result = print; }

Qui accadono due cose. Il token di stampa viene ignorato aumentando il contatore di indice e viene eseguita una chiamata al metodo ParseExpr per ottenere un nodo Expr, in quanto la definizione del linguaggio richiede che il token di stampa sia seguito da un'espressione. Nella Figura 6 viene mostrato il codice ParsExpr. L'elenco dei token viene attraversato a partire dall'indice corrente, identificando quelli che soddisfano la definizione del linguaggio di un'espressione. In questo caso, il metodo cerca semplicemente stringhe, numeri interi e variabili (che sono stati creati dall'istanza dello scanner) e restituisce i nodi appropriati dell'albero sintattico astratto che rappresentano queste espressioni. Figure 6 ParseExpr esegue l'analisi dei nodi di espressione Per le istruzioni stringa che soddisfano la definizione della sintassi del linguaggio "<stmt> ; <stmt>", viene utilizzata la sequenza del nodo dell'albero sintattico astratto. Questo nodo di sequenza contiene due puntatori ai nodi stmt e forma la base della struttura dell'albero sintattico astratto. Di seguito viene descritto in dettaglio il codice utilizzato per gestire il caso della sequenza: Copia codice if (this.index < this.tokens.Count && this.tokens[this.index] == Scanner.Semi) { this.index++;

if (this.index < this.tokens.Count && !this.tokens[this.index].Equals("end")) { Sequence sequence = new Sequence(); sequence.First = result; sequence.Second = this.ParseStmt(); result = sequence; } }

L'albero sintattico astratto illustrato nella Figura 7 è il risultato del seguente frammento di codice Good for Nothing:

Figura 7 albero sintattico astratto helloworld.gfn e traccia ad alto livello (Fare clic sull'immagine per ingrandirla) Copia codice var x = "hello world!"; print x;

Destinazione .NET Framework Prima di passare al codice che esegue la generazione del codice, è necessario fare un passo indietro e descrivere la destinazione. Qui verranno descritti i servizi del compilatore messi a disposizione da CLR .NET, compresa la macchina virtuale basata su stack, il sistema di tipi e le librerie utilizzate per la creazione di assembly .NET. Saranno esaminati brevemente anche gli strumenti necessari per identificare e diagnosticare gli errori nell'output del

08/06/2008 15.24


Sviluppo applicativo: Creazione di un compilatore di linguaggio per ...

5 di 9

http://msdn.microsoft.com/it-it/magazine/cc136756(printer).aspx

compilatore. Il CLR è una macchina virtuale, ovvero una porzione di software che emula un sistema informatico. Come qualunque computer, il CLR dispone di una serie di operazioni a basso livello che è in grado di eseguire, una serie di servizi di memoria e un linguaggio assembly per definire i programmi eseguibili. Il CLR utilizza una struttura di dati astratta basata su stack per modellare l'esecuzione del codice e un linguaggio assembly, chiamato Intermediate Language (IL), per definire le operazioni che possono essere eseguite nello stack. Quando un programma definito in IL viene eseguito, il CLR simula semplicemente le operazioni specificate in base a uno stack, attivando il push e il pop dei dati che devono essere eseguiti da un'istruzione. Supponiamo di voler aggiungere due numeri utilizzando il linguaggio IL. Di seguito è riportato il codice utilizzato per eseguire 10 + 20: Copia codice ldc.i4

10

ldc.i4

20

add

La prima riga (ldc.i4 10) esegue il push nello stack del numero intero 10. Quindi la seconda riga (ldc.i4 20) esegue il push nello stack del numero intero 20. La terza riga (add) esegue il pop dallo stack dei due numeri interi, li somma ed esegue il push nello stack del risultato. La simulazione della macchina dello stack avviene traducendo IL e la semantica dello stack nel linguaggio macchina del processore sottostante, sia in fase di esecuzione tramite la compilazione JIT (Just in Time) o preventivamente tramite servizi quali Ngen (Native Image Generator). Sono disponibili numerose istruzioni IL per la creazione di programmi, che variano dall'aritmetica di base al controllo di flusso fino a diverse convenzioni di chiamata. I dettagli di tutte le istruzioni IL sono disponibili nella Partizione III delle specifiche ESMA (European Computer Manufacturers Association) disponibili all'indirizzo msdn2.microsoft.com/aa569283). Lo stack astratto del CLR esegue operazioni non solo su numeri interi. Dispone infatti di un ampio sistema di tipi, tra cui stringhe, numeri interi, valori booleani, valori in virgola mobile, valori double e così via. Per garantire un funzionamento sicuro del linguaggio nel CLR e l'interoperabilità con altri linguaggi compatibili con .NET, nel programma viene incorporata una parte del sistema di tipi CLR. In particolare, il linguaggio Good for Nothing definisce due tipi, numeri e stringhe, che vengono mappate su System.Int32 e System.String. Il compilatore Good for Nothing utilizza un componente della libreria di classi di base denominato System.Reflection.Emit per gestire la generazione del codice IL nonché la creazione e l'assemblaggio dell'assembly .NET. Si tratta di una libreria a basso livello, strettamente correlata agli elementi bare metal fornendo semplici astrazioni di generazione del codice rispetto al linguaggio IL. La libreria viene utilizzata anche in altre API BCL note, tra cui System.Xml.XmlSerializer. Le classi ad alto livello necessarie per creare un assembly .NET (illustrate nella Figura 8) seguono più che altro il modello di progetto software del generatore, con API del generatore per ciascuna astrazione logica di metadati .NET. La classe AssemblyBuilder viene utilizzata per creare il file PE e impostare elementi dei metadati dell'assembly .NET necessari quali il manifesto. La classe ModuleBuilder viene utilizzata per creare moduli all'interno dell'assembly. Per creare i tipi e i relativi metadati associati viene utilizzato TypeBuilder. MethodBuilder e LocalBuilder si occupano rispettivamente dell'aggiunta di metodi ai tipi e di locali ai metodi. La classe ILGenerator viene utilizzata per generare il codice IL per i metodi, utilizzando la classe OpCodes (una grande enumerazione contenente tutte le istruzioni IL possibili). Tutte queste classi Reflection.Emit vengono utilizzate nel generatore di codice di Good for Nothing.

08/06/2008 15.24


Sviluppo applicativo: Creazione di un compilatore di linguaggio per ...

6 di 9

http://msdn.microsoft.com/it-it/magazine/cc136756(printer).aspx

Figura 8 Librerie Reflection.Emit utilizzate per creare un assembly .NET (Fare clic sull'immagine per ingrandirla) Strumenti per un linguaggio IL (Intermediate Language) corretto Anche i pirati informatici più esperti di compilatori commettono errori a livello di generazione del codice. L'errore più comune si verifica nel codice IL e provoca una mancanza di equilibrio nello stack. In genere, quando viene trovato codice IL errato, il CLR genera un'eccezione (in fase di caricamento dell'assembly o quando il linguaggio IL viene compilato come JIT (Just In Time), a seconda del livello di attendibilità dell'assembly). La diagnosi e correzione di questi errori è semplice grazie all'ausilio di uno strumento SDK denominato peverify.exe che esegue una verifica del linguaggio IL, controllando che il codice sia corretto e sicuro da eseguire. Ad esempio, di seguito è riportato il codice IL che tenta di aggiungere il numero 10 alla stringa "bad": Copia codice ldc.i4 ldstr

10 "bad"

add

L'esecuzione di peverify su un assembly che contiene questo linguaggio IL errato determina il seguente errore: Copia codice [IL]: Error: [C:\MSDNMagazine\Sample.exe : Sample::Main][offset 0x0000002][found ref 'System .String'] Expected numeric type on the stack.

In questo esempio, peverify segnala che l'istruzione add si aspettava due tipi numerici, mentre ha trovato un numero intero e una stringa. ILASM (assembler IL) e ILDASM (disassembler IL) sono due strumenti SDK che è possibile utilizzare per compilare il linguaggio IL di testo in assembly .NET e decompilare assembly .NET in IL, rispettivamente. ILASM consente di testare in modo semplice e veloce i flussi di istruzioni IL che costituiranno la base dell'output del compilatore. Il codice IL di test viene creato semplicemente in un editor di testo e passato a ILASM. Nel frattempo, lo strumento ILDASM può esaminare rapidamente il linguaggio IL generato da un compilatore per un particolare percorso di codice. Questo include il linguaggio IL prodotto dai compilatori in commercio, quali il compilatore C#. Ciò offre un ottimo metodo per visualizzare nel codice IL istruzioni che sono simili tra linguaggi; in altre parole, il codice del controllo di flusso IL generato per un ciclo for C# può essere riutilizzato da altri compilatori dotati di costrutti simili. Generatore di codice Il generatore di codice per il compilatore Good for Nothing si basa notevolmente sulla libreria Reflection.Emit per produrre un assembly .NET eseguibile. Descriverò e analizzerò le parti importanti della classe tralasciando il resto che potrà essere consultato dagli utenti in un secondo momento. Il costruttore CodeGen, illustrato nella Figura 9, imposta l'infrastruttura Reflection.Emit necessaria per avviare l'emissione del codice. Si inizia definendo il nome dell'assembly e passandolo al generatore di assembly. In questo esempio, come nome del file sorgente viene utilizzato il nome dell'assembly. Quindi troviamo la definizione di ModuleBuilder; per la definizione di un modulo, questo utilizza lo stesso nome dell'assembly. In ModuleBuilder viene poi definito un TypeBuilder per contenere l'unico tipo presente nell'assembly. Nella definizione del linguaggio Good for Nothing non sono stati definiti cittadini di prima classe, tuttavia è necessario almeno un tipo per contenere il metodo, che verrà eseguito all'avvio. MethodBuilder definisce un metodo Main per contenere il linguaggio IL che sarà generato per il codice di Good for Nothing. In questo MethodBuilder è necessario chiamare SetEntryPoint in modo che venga eseguito all'avvio quando un utente seleziona l'eseguibile. Inoltre, viene creato ILGenerator globale (il) da MethodBuilder utilizzando il metodo GetILGenerator. Figure 9 Costruttore CodeGen Dopo avere impostato l'infrastruttura Reflection.Emit, il generatore di codice chiama il metodo GenStmt, che viene utilizzato per elaborare l'albero sintattico astratto. Questo genera il codice IL necessario tramite ILGenerator globale. Nella Figura 10 viene illustrato un sottoinsieme del metodo GenStmt, che, alla prima chiamata, inizia con un nodo Sequence e prosegue nell'albero sintattico astratto attivando il tipo di nodo dell'albero sintattico astratto corrente. Figure 10 Sottoinsieme del metodo GenStmt Il codice per il nodo dell'albero sintattico astratto DeclareVar (che dichiara una variabile) è riportato di seguito: Copia codice else if (stmt is DeclareVar) { // declare a local DeclareVar declare = (DeclareVar)stmt; this.symbolTable[declare.Ident] = this.il.DeclareLocal(this.TypeOfExpr(declare.Expr));

// set the initial value

08/06/2008 15.24


Sviluppo applicativo: Creazione di un compilatore di linguaggio per ...

7 di 9

http://msdn.microsoft.com/it-it/magazine/cc136756(printer).aspx

Assign assign = new Assign(); assign.Ident = declare.Ident; assign.Expr = declare.Expr; this.GenStmt(assign); }

La prima cosa da fare è aggiungere la variabile a una tabella dei simboli. La tabella dei simboli è una struttura dati di base del compilatore che viene utilizzata per associare un identificatore simbolico (in questo caso, il nome della variabile basato su stringa) con il tipo, la posizione e l'ambito relativi all'interno di un programma. La tabella dei simboli di Good for Nothing è semplice, in quanto tutte le dichiarazioni di variabili sono locali al metodo Main. Quindi viene associato un simbolo a un LocalBuilder utilizzando una semplice istruzione Dictionary<string, LocalBuilder>. Dopo avere aggiunto il simbolo alla tabella dei simboli, il nodo dell'albero sintattico astratto DeclareVar viene tradotto in un nodo Assign per assegnare l'espressione di dichiarazione della variabile alla variabile. Il seguente codice consente di generare le istruzioni di assegnazione: Copia codice else if (stmt is Assign) { Assign assign = (Assign)stmt; this.GenExpr(assign.Expr, this.TypeOfExpr(assign.Expr)); this.Store(assign.Ident, this.TypeOfExpr(assign.Expr)); }

In questo modo viene generato il codice IL per caricare un'espressione sullo stack ed emesso quindi il linguaggio IL per memorizzare l'espressione nel LocalBuilder appropriato. Il codice GenExpr illustrato nella Figura 11 prende un nodo dell'albero sintattico astratto Expr ed emette il linguaggio IL necessario per caricare l'espressione nella macchina dello stack. StringLiteral e IntLiteral sono simili in quanto contengono istruzioni IL dirette che caricano le rispettive stringhe e numeri interi sullo stack: ldstr e ldc.i4. Figure 11 Metodo GenExpr Le espressioni variabili caricano semplicemente la variabile locale di un metodo sullo stack chiamando ldloc e passando il rispettivo LocalBuilder. L'ultima sezione di codice illustrata nella Figura 11 si riferisce alla conversione del tipo di espressione nel tipo previsto (definita come coercizione del tipo). Ad esempio, in una chiamata al metodo print un tipo potrebbe richiedere la conversione di un numero intero in una stringa in modo che possa essere stampato correttamente. Nella Figura 12 viene illustrata l'assegnazione di espressioni alle variabili nel metodo Store. Il nome viene cercato tramite la tabella dei simboli, quindi il rispettivo LocalBuilder viene passato all'istruzione IL stloc. L'operazione esegue semplicemente il pop dell'espressione corrente dallo stack e la assegna alla variabile locale. Figure 12 Memorizzazione di espressioni in una variabile Il codice utilizzato per generare il linguaggio IL per il nodo dell'albero sintattico astratto Print è interessante perché ricorre a un metodo BCL. L'espressione viene generata sullo stack e l'istruzione di chiamata IL viene utilizzata per chiamare il metodo System.Console.WriteLine. Reflection è utilizzata per ottenere l'handle del metodo WriteLine, che è necessario per passare l'istruzione di chiamata: Copia codice else if (stmt is Print) { this.GenExpr(((Print)stmt).Expr, typeof(string)); this.il.Emit(Emit.OpCodes.Call, typeof(System.Console).GetMethod("WriteLine", new Type[] { typeof(string) })); }

Quando viene eseguita una chiamata a un metodo, gli argomenti del metodo vengono inseriti nello stack in sequenza a partire dall'ultimo. In altre parole, il primo argomento del metodo è l'elemento superiore dello stack, il secondo argomento è l'elemento successivo e così via. Il codice più complicato è quello che genera il linguaggio IL dei cicli for di Good for Nothing (vedere la Figura 13). È abbastanza simile al modo in cui verrebbe generato il codice dai compilatori in commercio. Tuttavia, il modo migliore per spiegare il codice del ciclo for è di esaminare il linguaggio IL generato, illustrato nella Figura 14. Figure 14 Codice IL del ciclo for Figure 13 Codice del ciclo for Il codice IL inizia con l'assegnazione del contatore iniziale del ciclo for e salta immediatamente al test del ciclo for utilizzando l'istruzione IL br (branch). Vengono utilizzate alcune etichette come quelle elencate sulla sinistra del codice IL per consentire al runtime di conoscere il punto in cui passare all'istruzione successiva. Il codice di test verifica se la variabile x è minore di 100, utilizzando l'istruzione blt (branch if less than). Se la condizione è true, il

08/06/2008 15.24


Sviluppo applicativo: Creazione di un compilatore di linguaggio per ...

8 di 9

http://msdn.microsoft.com/it-it/magazine/cc136756(printer).aspx

corpo del ciclo viene eseguito, la variabile x viene incrementata e il test viene eseguito nuovamente. Il codice del ciclo for illustrato nella Figura 13 genera il codice necessario per eseguire le operazioni di assegnazione e incremento sulla variabile del contatore. In ILGenerator viene utilizzato anche il metodo MarkLabel per generare etichette di riferimento per le istruzioni di diramazione. Riepilogo Ho esaminato la base del codice di un semplice compilatore .NET e ho esplorato parte della teoria sottostante. L'obiettivo di questo articolo è fornire alcune nozioni di base sul misterioso mondo della creazione di un compilatore. Anche se molte informazioni utili sono disponibili in linea, vi sono alcuni libri che vale la pena consultare. Il mio consiglio è di procurarsi una copia dei seguenti testi: Compiling for the .NET Common Language Runtime di John Gough (Prentice Hall, 2001), Inside Microsoft IL Assembler di Serge Lidin (Microsoft Press®, 2002), Programming Language Pragmatics di Michael L. Scott (Morgan Kaufmann, 2000) e Compilers: Principles, Techniques, and Tools di Alfred V. Oho, Monica S. Lam, Ravi Sethi e Jeffrey Ullman (Addison Wesley, 2006). Questi testi comprendono tutto ciò che è necessario sapere per scrivere un compilatore di linguaggi; tuttavia, il mio articolo non è ancora giunto al termine. Per i più esperti, desidero esaminare alcuni argomenti avanzati per stimolare ulteriormente l'impegno dei lettori. Chiamata dinamica dei metodi Le chiamate dei metodi costituiscono la base di qualunque linguaggio di programmazione, tuttavia è disponibile una serie di chiamate che è possibile eseguire. I linguaggi più recenti, come Phyton, ritardano l'associazione di un metodo e la chiamata fino all'ultimo minuto, concetto definito come chiamata dinamica. I linguaggi dinamici più diffusi, quali Ruby, Javascript, Lua e anche Visual Basic, condividono tutti questo modello. Per emettere il codice necessario per eseguire la chiamata di un metodo, il compilatore deve trattare il nome del metodo come un simbolo, passandolo a una libreria di runtime che eseguirà le operazioni di associazione e chiamata in base alla semantica del linguaggio. Supponiamo di disattivare Option Strict nel compilatore di Visual Basic 8.0. Le chiamate al metodo diventano ad associazione tardiva e il runtime di Visual Basic eseguirà l'associazione e la chiamata in fase di esecuzione. Invece di emettere un'istruzione di chiamata IL al metodo Method1, il compilatore di Visual Basic emette un'istruzione di chiamata al metodo di runtime di Visual Basic denominato CompilerServices.NewLateBinding.LateCall. In tal modo, passa un oggetto (obj) e il nome simbolico del metodo (Method1) insieme con tutti gli argomenti del metodo. Il metodo LateCall di Visual Basic cerca quindi il metodo Method1 nell'oggetto utilizzando Reflection e, se viene trovato, esegue una chiamata al metodo basata su Reflection: Copia codice Option Strict Off

Dim obj obj.Method1()

IL_0001:

ldloc.0

IL_0003:

ldstr

"Method1"

call

object CompilerServices.NewLateBinding::LateCall(object, ... , string, ...)

... IL_0012:

Utilizzo di LCG per eseguire l'associazione tardiva rapida La chiamata al metodo basata su Reflection può essere notoriamente lenta (vedere il mio articolo "Reflection: come evitare i tranelli delle prestazioni comuni e creare applicazioni veloci" all'indirizzo msdn.microsoft.com/msdnmag/issues/05/07/Reflection). Sia l'associazione del metodo che la chiamata al metodo sono molto più lente di una semplice istruzione di chiamata IL. Il CLR di .NET Framework 2.0 contiene una funzionalità denominata Lightweight Code Generation (LCG), che può essere utilizzata per creare dinamicamente codice in tempo reale che collega il sito di chiamata al metodo utilizzando l'istruzione di chiamata IL più veloce. Questo accelera notevolmente la chiamata al metodo. La ricerca del metodo in un oggetto è comunque necessaria, ma una volta trovato, è possibile creare un bridge DynamicMethod e memorizzarlo nella cache per ogni chiamata ripetuta. Nella Figura 15 viene illustrata una versione molto semplice di un'associazione tardiva che esegue la generazione di codice dinamico di un metodo bridge. Per prima cosa cerca nella cache e verifica se il sito di chiamata è stato visto in precedenza. Se il sito di chiamata viene eseguito per la prima volta, genera un DynamicMethod che restituisce un oggetto e considera una matrice di oggetti come argomento. L'argomento della matrice di oggetti contiene l'oggetto istanza e gli argomenti che devono essere utilizzati per la chiamata finale al metodo. Il codice IL viene generato per decomprimere la matrice di oggetti sullo stack, iniziando con l'oggetto di istanza e poi con gli argomenti. Quindi viene emessa un'istruzione di chiamata e il relativo risultato viene restituito al chiamante. La chiamata al metodo bridge LCG viene eseguita tramite un delegato, operazione che risulta molto veloce. Eseguo semplicemente il wrapping degli argomenti del metodo bridge in una matrice di oggetti e poi eseguo la chiamata. La prima volta, il compilatore JIT compila il metodo dinamico e procede all'esecuzione del linguaggio IL, che, a sua

08/06/2008 15.24


Sviluppo applicativo: Creazione di un compilatore di linguaggio per ...

9 di 9

http://msdn.microsoft.com/it-it/magazine/cc136756(printer).aspx

volta, chiama il metodo finale. Questo codice è identico al codice che verrebbe generato da un compilatore statico ad associazione tardiva. Esegue il push di un oggetto di istanza sullo stack, seguito dagli argomenti e quindi chiama il metodo utilizzando l'istruzione di chiamata. Questo è un buon metodo per ritardare la semantica quanto più possibile, rispettando la semantica di chiamata ad associazione tardiva disponibile nella maggior parte dei linguaggi dinamici. Dynamic Language Runtime Se si considera seriamente l'implementazione di un linguaggio dinamico nel CLR, è opportuno analizzare il Dynamic Language Runtime (DLR), annunciato alla fine dello scorso aprile dal team CLR. Questo comprende gli strumenti e le librerie necessari per creare linguaggi dinamici con ottime prestazioni e interoperabili con .NET Framework e con l'ecosistema di altri linguaggi compatibili con .NET. Le librerie forniscono tutto il necessario per chiunque desideri creare un linguaggio dinamico, inclusa un'implementazione ad alte prestazioni per astrazioni comuni del linguaggio dinamico (chiamate al metodo ad associazione tardiva estremamente veloci, interoperabilità del sistema di tipi e così via), un sistema di tipi dinamico, un albero sintattico astratto condiviso, il supporto per Read Eval Print Loop (REPL) e altro ancora. Un esame approfondito del DLR esula dallo scopo di questo articolo, ma consiglio di studiarlo in separata sede per scoprire i servizi che fornisce ai linguaggi dinamici. Ulteriori informazioni sul DLR sono disponibili nel blog di Jim Hugunin (blogs.msdn.com/hugunin). Joel Pobar è un ex Program Manager del team CLR in Microsoft. Attualmente vive nella Gold Coast in Australia occupandosi di compilatori, linguaggi e altri aspetti molto interessanti. La sua produzione più recente su .NET può essere consultata all'indirizzo callvirt.net/blog.

08/06/2008 15.24


Turn static files into dynamic content formats.

Create a flipbook
Issuu converts static files into: digital portfolios, online yearbooks, online catalogs, digital photo albums and more. Sign up and create your flipbook.