|
Compilazione di codice a runtime
Bene, ora che sappiamo scrivere del normale codice per una qualsiasi applicazione e che sappiamo come analizzare codice esterno, che ne
dite di scrivere programmi che producano programmi? La questione è molto divertente: esistono delle apposite classi, in .NET, che
consentono di compilare codice che viene prodotto durante l'esecuzione dal'applicazione stessa, generando così nuovi assembly per
gli scopi più vari. Una volta mi sono servito in maniera intensiva di questa capacità del .NET per scrivere un installer:
non solo esso creava altri programmi (autoestraenti), ma questi a loro volta creavano altri programmi per estrarre le informazioni
memorizzate negli autoestraenti stessi: programmi che scrivono programmi che scrivono programmi! Ma ora vediamo più nel dettaglio
cosa usare nello specifico per attivare queste interessanti funzionalità.
Prima di tutto, è necessario importare un paio di namespace: System.CodeDom e System.CodeDom.Compiler. Essi contengono le classi
che fanno al caso nostro per il mestiere. Il processo di compilazione si svolge alltraverso queste fasi:
- Prima si ottiene il codice da compilare, che può essere memorizzato in un file o prodotto direttamente dal programma sottoforma
di normale stringa;
- Si impostano i parametri di compilazione: ad esempio, si può scegliere il tipo di output (*.exe o *.dll), i riferimenti da
includere, se mantenere i file temporanei, se creare l'assembly e salvarlo in memoria, se trattare gli warning come errori, eccetera...
Insomma, tutto quello che noi scegliamo tramite l'interfaccia dell'ambiente di sviluppo o che ci troviamo già impostato grazie
all'IDE stesso;
- Si compila il codice richiamando un provider di compilazione;
- Si leggono i risultati della compilazione. Nel caso ci siano stati errori, i risultati conterranno tutta la lista degli errori, con relative
informazioni sulla loro posizione nel codice; in caso contrario, l'assembly verrà generato correttamente;
- Se l'assembly conteneva codice che serve al programma, si usa la Reflection per ottenerne e invocarne i metodi.
Queste cinque fasi corrispondono a cinque oggetti che dovremo usare nel codice:
- String : ovviamente, il codice memorizzato sottoforma di stringa;
- CompilerParameters : classe del namespace CodeDom.Compiler. Contiene come proprietà tutte le opzioni che ho esemplificato nella
lista precedente;
- VBCodeProvider : provider di compilazione per il linguaggio Visual Basic. Esiste un provider per ogni linguaggio .NET, anche se può
non trovarsi sempre nello stesso namespace. Esso fornirà i metodi per avviare la compilazione;
- CompilerResults : contiene tutte le informazioni relative all'output della compilazione. Se si sono verificati errori, ne espone una
lista; se la compilazion è andata a buon file, riferisce dove si trova l'assembly compilato e, se ci sono, dove sono posti i
file temporanei;
- Assembly : classe che abbiamo già analizzato. Permette di caricare in memoria l'assembly prodotto come output e richiamarne il
codice od usarne le classi ivi definite.
Il prossimo esempio costituisce uno dei casi più evidenti di quando conviene rivolgersi alla reflection, e sono sicuro che potrebbe
tornarvi utile in futuro:
Module Module1
'Questa classe rappresenta una funzione matematica:
'ne ho racchiuso il nome tra parentesi quadre poiché Function
'è una keyword del linguaggio, ma in questo caso la si
'vuole usare come identificatore. In generale, si possono usare
'le parentesi quadre per trasformare ogni keyword in un normale
'nome.
Class [Function]
Private _Expression As String
Private EvaluateFunction As MethodInfo
'Contiene l'espressione matematica che costruisce il valore
'della funzione in base alla variabile x
Public Property Expression() As String
Get
Return _Expression
End Get
Set(ByVal value As String)
_Expression = value
Me.CreateEvaluator()
End Set
End Property
'La prossima è la procedura centrale di tutto l'esempio.
'Il suo compito consiste nel compilare una libreria di
'classi in cui è definita una funzione che, ricevuto
'come parametro un x decimale, ne restituisce il valore
'f(x). Il corpo di tale funzione varia in base
'all'espressione immessa dall'utente.
Private Sub CreateEvaluator()
'Crea il codice della libreria: una sola classe
'contenente la sola funzione statica Evaluate. Gli {0}
'verranno sostituti con caratteri di "a capo", mentre
'in {1} verrà posta l'espressione che produce
'il valore desiderato, ad esempio "x ^ 2 + 1".
Dim Code As String = String.Format( _
"Imports Microsoft.VisualBasic{0}" & _
"Imports System{0}" & _
"Imports System.Math{0}" & _
"Public Class Evaluator{0}" & _
" Public Shared Function Evaluate(ByVal X As Double) As Double{0}" & _
" Return {1}{0}" & _
" End Function{0}" & _
"End Class", Environment.NewLine, Me.Expression)
'Crea un nuovo oggetto CompilerParameters, per
'contenere le informazioni relative alla compilazione
Dim Parameters As New CompilerParameters
With Parameters
'Indica se creare un eseguibile o una libreria di
'classi: in questo caso ci interessa la seconda,
'quindi impostiamo la proprietà su False
.GenerateExecutable = False
'Gli warning vengono considerati come errori
.TreatWarningsAsErrors = True
'Non vogliamo tenere alcun file temporaneo: ci
'interessa solo l'assembly compilato
.TempFiles.KeepFiles = False
'L'assembly verrà tenuto in memoria temporanea
.GenerateInMemory = True
'I due riferimenti di cui abbiamo bisogno, che si
'trovano nella GAC (quindi basta specificarne il
'nome). In questo caso, si richiede anche
'l'estensione (*.dll)
.ReferencedAssemblies.Add("Microsoft.VisualBasic.dll")
.ReferencedAssemblies.Add("System.dll")
End With
'Crea un nuovo provider di compilazione
Dim Provider As New VBCodeProvider
'E compila il codice seguendo i parametri di
'compilazione forniti dall'oggetto Parameters. Il
'valore restituito dalla funzione
'CompileAssemblyFromSource è di tipo
'CompilerResults e viene salvato in CompResults
Dim CompResults As CompilerResults = _
Provider.CompileAssemblyFromSource(Parameters, Code)
'Se ci sono errori, lancia un'eccezione
If CompResults.Errors.Count > 0 Then
Throw New FormatException("Espressione non valida!")
Else
'Altrimenti crea un riferimento all'assembly di
'output. La proprietà CompiledAssembly di
'CompResults contiene un riferimento diretto a
'quell'assembly, quindi ci è molto comoda.
Dim Asm As Reflection.Assembly = CompResults.CompiledAssembly
'Dall'assembly ottiene un OT che rappresenta
'l'unico tipo ivi definito, e da questo ne
'estrae un MethodInfo con informazioni sul
'metodo Evaluate (l'unico presente).
Me.EvaluateFunction = _
Asm.GetType("Evaluator").GetMethod("Evaluate")
End If
End Sub
'Per richiamare la funzione, basta invocare il metodo
'Evaluate estratto precedentemente. Al pari delle
'proprietà e dei campi, che possono essere letti o
'scritti dinamicamente, anche i metodi possono essere
'invocati dinamicamete attraverso MethodInfo. Si usa
'la funzione Invoke: il primo parametro da
'passare è l'oggetto da cui richiamare il metodo, mentre
'il secondo è un array di oggetti che indicano i
'parametri da passare a tale metodo.
'In questo caso, il primo parametro è Nothing poiché
'Evaluate è una funzione statica e non ha bisgno di nessuna
'istanza per essere richiamata.
Public Function Apply(ByVal X As Double) As Double
Return EvaluateFunction.Invoke(Nothing, New Object() {X})
End Function
End Class
Sub Main()
Dim F As New [Function]()
Do
Try
Console.Clear()
Console.WriteLine("Inserisci una funzione: ")
Console.Write("f(x) = ")
F.Expression = Console.ReadLine
Exit Do
Catch ex As Exception
Console.WriteLine("Espressione non valida!")
Console.ReadKey()
End Try
Loop
Dim Input As String
Dim X As Double
Do
Try
Console.Clear()
Console.WriteLine("Immettere 'stop' per terminare.")
Console.WriteLine("Il programma calcola il valore di f in X: ")
Console.Write("x = ")
Input = Console.ReadLine
If Input <> "stop" Then
X = CType(Input, Double)
Console.WriteLine("f(x) = {0}", F.Apply(X))
Console.ReadKey()
Else
Exit Do
End If
Catch Ex As Exception
Console.WriteLine(Ex.Message)
Console.ReadKey()
End Try
Loop
End Sub
End Module
In questo esempio ho utilizzato solo alcuni dei membri esposti dalle classi sopra menzionate. Di seguito elenco tutti quelli più
rilevanti, che potrebbero servirvi in futuro:
CompilerParameters CompilerOptions : contiene sottoforma di stringhe dei parametri aggiuntivi da passare al compilatore. Vedremo solo più avanti di
cosa si tratta e di come possano generalmente essere modificati dall'IDE nell'ambito del nostro progetto;
EmbeddedResources : una lista di stringhe, ognuna delle quali indica il percorso su disco di un file di risorse da includere nell'assembly
compilato. Di questi file parlerò nella sezione B;
GenerateExcutable : determina se generare un eseguibile o una libreria di classi;
GenerateInMemory : determina se non salvare l'assembly generato su un supporto permanente (disco fisso o altre memorie non volatili);
IncludeDebugInformations : determina se includere nell'eseguibile anche le informazioni relative al debug. Di solito questo non è
molto utile perché è possibile accedere prima a queste informazioni tramite l'IDE facendo il debug del codice stesso che
compila altro codice XD;
MainClass : imposta il nome della classe principale dell'assembly. Se si sta compilando una libreria di classi, questa proprietà
è inutile. Se, invece, si sta compilando un programma, questa proprietà indica il nome della classe dove è contenuta
la procedura Main, il punto di ingresso nell'applicazione. Generalmente il compilatore riesce ad individuare da solo tale classe, ma nel caso
ci siano più classi contenenti un metodo Main bisogna specificarlo esplicitamente. Nel caso l'applicazione da compilare sia di
tipo windows form, come vedremo nella sezione B, la MainClass può anche indicare la classe che rappresenta la finestra iniziale;
OutputAssembly : imposta il percorso dell'assembly da generare. Nel caso questa proprietà non venga impostata prima della compilazione,
sarà il provider di compilazione a preoccuparsi di creare un nome casuale per l'assembly e di salvarlo nella stessa cartella del
nostro programma;
ReferencedAssemblis : anche questa è una collezione di stringhe, e contiene il nome degli assemblies da includere come rierimeneto
per il codice corrente. Dovete sempre includere almeno System.dll (quello più importante). Gli altri assemblies pubblici sono
facoltativi e variano in funzione del compito da svolgere: se doveste usare file xml, importerete anche System.Xml.dll, ad esempio;
TempFiles : collezione che contiene i percorsi dei file temporanei. Espone qualche proprietà e metodo in più rispetto
a una normale collezione;
TreatWarningsAsErrors : tratta gli warning come se fossero errori. Questo impedisce di portare a termine la compilazione quando ci
sono degli warning;
WarningLevel : livello da cui il compilatore interrompe la compilazione. La documentazione su questa proprietà non è
molto chiara e non si capisce bene cosa intenda. È probabile che ogni warning abbia un certo livello di allerta e questo valore
dovrebbe comunicare al compilatore di visualizzare solo gli warning con livello maggiore o uguale a quello specificato... solo ipotesi,
tuttavia.
CompilerResults CompiledAssembly : restituisce un oggetto Assembly in riferimento all'assembly compilato;
Errors : collezione di oggetti di tipo CompilerError. Ognuno di questi oggetti espone delle proprietà utili a identificare
il luogo ed il motivo dell'errore. Alcune sono: Line e Column (linea e colonna dell'errore), IsWarning (se è un warning o un errore),
ErrorNumber (numero identificativo dell'errore), ErrorText (testo dell'errore) e FileName (nome del file in cui si è verificato);
NativeCompilerReturnValue : restituisce il valore che a sua volta il compilatore ha restituito al programma una volta terminata
l'esecuzione. Vi ricordo, infatti, che compilatore, editor di codice e debugger sono tre programmi differenti: l'ambiente di sviluppo
integrato fornisce un'interfaccia che sembra unirli in un solo applicativo, ma rimangono sempre entità distinti. Come tali, un
programma può restituire al suo chiamante un valore, solitamente intero: proprio come si comporta una funzione;
PathToAssembly : il percorso su disco dell'assembly generato;
TempFiles : i file temporaneai rimasti.
Avrete notato che anche VBCodeProvider espone molti metodi, ma la maggior parte di questi servono per la generazione di codice. Questo
meccanismo permette di assemblare una collezione di oggetti ognuno dei quali rappresenta un'istruzione di codice, e poi di generare
codice per un qualsiasi linguaggio .NET. Sebbene sia un funzionalità potente, non la tratterò in questa guida.
Generazione di programmi
Il prossimo sorgente è un esempio che, secondo me, sarebbe stato MOLTO più fruttuoso se usato in un'applicazione windows
forms. Tuttavia siamo nella sezione A e qui si fa solo teoria, perciò, purtroppo, dovrete sorbirvi questo entusiasmante esempio
come applicazione console.
Ammettiamo che un'impresa abbia un software di gestione dei suoi materiali, e che abbastanza spesso acquisti nuove tipologie di prodotti
o semilavorati. Gli addetti al magazzino dovranno introdurre i dati dei nuovi oggetti, ma per far ciò, è necessario un
programma adatto per gestire quel tipo di oggetti: in questo modo, ogni volta, serve un programma nuovo. L'esempio che segue è una
possibile soluzione a questo problema: il programma principale richiede di immettere informazioni su un nuovo tipo di dato e crea un
programma apposta per la gestione di quel tipo di dato (notate che ho scritto cinque volte programma sulla stessa colonna XD).
Module Module1
'Classe che rappresenta il nuovo tipo di dato, ed
'espone una funzione per scriverne il codice
Class TypeCreator
Private _Fields As Dictionary(Of String, String)
Private _Name As String
'Fields è un dizionario che contiene come
'chiavi i nomi delle proprietà da definire
'nel nuovo tipo e come valori il loro tipi
Public ReadOnly Property Fields() As Dictionary(Of String, String)
Get
Return _Fields
End Get
End Property
'Nome del nuovo tipo
Public Property Name() As String
Get
Return _Name
End Get
Set(ByVal value As String)
_Name = value
End Set
End Property
Public Sub New()
_Fields = New Dictionary(Of String, String)
End Sub
'Genera il codice della proprietà
Public Function GenerateCode() As String
Dim Code As New Text.StringBuilder()
Code.AppendLine("Class " & Name)
For Each Field As String In Me.Fields.Keys
Code.AppendFormat("Private _{0} As {1}{2}", _
Field, Me.Fields(Field), Environment.NewLine)
Code.AppendFormat( _
"Public Property {0} As {1}{2}" & _
" Get{2}" & _
" Return _{0}{2}" & _
" End Get{2}" & _
" Set(ByVal value As {1}){2}" & _
" _{0} = value{2}" & _
" End Set{2}" & _
"End Property{2}", _
Field, Me.Fields(Field), Environment.NewLine)
Next
Code.AppendLine("End Class")
Return Code.ToString()
End Function
End Class
'Classe statica contenente la funzione per scrivere
'e generare il nuovo programma
Class ProgramCreator
'Accetta come input il nuovo tipo di dato
'da gestire. Restituisce in output il percorso
'dell'eseguibile creato
Public Shared Function CreateManagingProgram(ByVal T As TypeCreator) As String
Dim Code As New Text.StringBuilder()
Code.AppendLine("Imports System")
Code.AppendLine("Imports System.Collections.Generic")
Code.AppendLine("Module Module1")
Code.AppendLine(T.GenerateCode())
Code.AppendLine("Sub Main()")
Code.AppendLine(" Dim Storage As New List(Of " & T.Name & ")")
Code.AppendLine(" Dim Cmd As Char")
Code.AppendLine(" Do")
Code.AppendLine(" Console.Clear()")
Code.AppendLine(" Console.WriteLine(""Inserimento di oggetti " & T.Name & """)")
Code.AppendLine(" Console.WriteLine()")
Code.AppendLine(" Console.Writeline(""Scegliere un'operazione: "")")
Code.AppendLine(" Console.WriteLine("" i - inserimento;"")")
Code.AppendLine(" Console.WriteLine("" e - elenca;"")")
Code.AppendLine(" Console.WriteLine("" u - uscita."")")
Code.AppendLine(" Cmd = Console.ReadKey().KeyChar")
Code.AppendLine(" Console.Clear()")
Code.AppendLine(" Select Case Cmd")
Code.AppendLine(" Case ""i"" ")
Code.AppendLine(" Dim O As New " & T.Name & "()")
Code.AppendLine(" Console.WriteLine(""Inserire i dati: "")")
'Legge ogni membro del nuovo tipo. Usa la CType
'per essere sicuri che tutto venga interpretato nel
'modo corretto.
For Each Field As String In T.Fields.Keys
Code.AppendFormat("Console.Write("" {0} = ""){1}", Field, Environment.NewLine)
Code.AppendFormat("O.{0} = CType(Console.ReadLine(), {1}){2}", Field, T.Fields(Field), Environment.NewLine)
Next
Code.AppendLine(" Storage.Add(O)")
Code.AppendLine(" Console.WriteLine(""Inserimento completato!"")")
Code.AppendLine(" Case ""e"" ")
Code.AppendLine(" For I As Int32 = 0 To Storage.Count - 1")
Code.AppendLine(" Console.WriteLine(""{0:000} + "", I)")
'Fa scrivere una linea per ogni proprietà
'dell'oggetto, mostrandone il valore
For Each Field As String In T.Fields.Keys
Code.AppendFormat("Console.WriteLine("" {0} = "" & Storage(I).{0}.ToString()){1}", Field, Environment.NewLine)
Next
Code.AppendLine(" Next")
Code.AppendLine(" Console.ReadKey()")
Code.AppendLine(" End Select")
Code.AppendLine(" Loop Until Cmd = ""u""")
Code.AppendLine("End Sub")
Code.AppendLine("End Module")
Dim Parameters As New CompilerParameters
With Parameters
.GenerateExecutable = True
.TreatWarningsAsErrors = True
.TempFiles.KeepFiles = False
.GenerateInMemory = False
.ReferencedAssemblies.Add("Microsoft.VisualBasic.dll")
.ReferencedAssemblies.Add("System.dll")
End With
Dim Provider As New VBCodeProvider
Dim CompResults As CompilerResults = _
Provider.CompileAssemblyFromSource(Parameters, Code.ToString())
If CompResults.Errors.Count = 0 Then
Return CompResults.PathToAssembly
Else
For Each E As CompilerError In CompResults.Errors
Stop
Next
Return Nothing
End If
End Function
End Class
Sub Main()
Dim NewType As New TypeCreator()
Dim I As Int16
Dim Field, FieldType As String
Console.WriteLine("Creazione di un tipo")
Console.WriteLine()
Console.Write("Nome del tipo = ")
NewType.Name = Console.ReadLine
Console.WriteLine("Inserisci il nome del campo e il suo tipo:")
I = 1
Do
Console.Write("Nome campo {0}: ", I)
Field = Console.ReadLine
If String.IsNullOrEmpty(Field) Then
Exit Do
End If
Console.Write("Tipo campo {0}: ", I)
FieldType = Console.ReadLine
'Dovrete immettere il nome completo e con
'le maiuscole al posto giusto. Ad esempio:
' System.String
'e non string o system.String o STring.
If Type.GetType(FieldType) Is Nothing Then
Console.WriteLine("Il tipo {0} non esiste!", FieldType)
Console.ReadKey()
Else
NewType.Fields.Add(Field, FieldType)
I += 1
End If
Loop
Dim Path As String = ProgramCreator.CreateManagingProgram(NewType)
If Not String.IsNullOrEmpty(Path) Then
Console.WriteLine("Programma di gestione per il tipo {0} creato!", NewType.Name)
Console.WriteLine("Avviarlo ora? y/n")
If Console.ReadKey().KeyChar = "y" Then
Process.Start(Path)
End If
End If
End Sub
End Module
|
|