martedì 23 ottobre 2007

Parser per espressioni matematiche

Almeno una volta nella vita programmativa ci si è posti di fornite all'esigenza di fare anche un piccolo parse di una stringa. Beh questa volta è toccato a me. Ho dovuto creare un parser per espressioni matematiche che a run-time "parsasse" le espressioni e le eseguisse inserendo al posto di alcuni marcatori dei valori passati da programma.

Da prima ho provato a cercare su San Google e ho trovato su Santissimo CodeProject e ho trovato una soluzione che poteva andare ma che non era di mio gradimento, infatti anche se molto performante e molto vicina a quello che realmente è un parser, è molto difficile da "smanettare": infatti fa largo uso di lunghe e qualche volta non del tutto comprensibili Espressioni Regolari. Analizzando il codice ho scoperto che questo parser ti permetteva di inserire funzioni matematiche e di richiamare una serie di funzioni del .NET (Sin, Cos, ecc... la lista completa è su the code project), pecca però per la mia utilità di una sostituzioni di variabili. Provando a mettere insieme qualcosa di concreto ne ho tirato fuori poco o niente.

Mi sono chiesto allora: ci sarà di meglio? Beh se devo dirla tutta di meglio non è (anche perchè il meglio in questo settore è soggettivo rispetto al contesto) ma cmq è qualcosa che si avvicinava molto a quello che interessava a me.

Cercando sempre sui theCodeProject ho trovato un secondo parser per espressioni che però era legato alla sintassi C#. Infatti quest'ultimo prende l'espressione matematica scritta in C# e la esegue creando a run-time un assembly in memoria che esegue l'espressione. Le pecche di questo parser sono ovvie! Nessuna protezione! Io posso girare il codice che mi interessa senza sessuna limitazione, il programma, ne perde in sicurezza. Inoltre, neanche questo parser
permette di inserire delle variabili e quindi bisogna dargli in pasta una espressione bella e formattata.

Ho pensato allora di inserire alcune nuove features in questa nuova versione e di creare un parser matematico che si basasse su quel modello, ma avesse in più: l'inserimento di variabili, una certa indipendenza dal linguaggio C# avvicinandolo il più possibile a quello matematico, l'inserimento di variabili che attraverso collezioni restituissero valori, una certa sicurezza che bloccasse il codice maligno, una robustezza nell'esecuzione.

Ne è venuto fuori questo:

MATH PARSER

Il parser che da ora in poi chiameremo MathParser esegue espressioni matematiche tra le più comuni. Le espressioni possono essere nella prima versione la 1.0.0b solo di tipo algebrico. Le variabili possono essere definite inserendo il nome delle variabili racchiuso tra parentesi quadre (e.g. [valore1]), le funzioni trigonometrie e le più normali funzioni algebriche supportate sono:
addizione +, sottrazione -, moltiplicazione *, divisione /,% modulo, assoluto abs(espressione), arcocoseno acos(espressione), arcoseno asin(espressione), numero intero int(espressione), arrotondamento a n cifre decimali round(espressione,n), radice quadrata sqrt(espressione).
Possono essere inserite le costanti matematiche pi-greco e e (numero di nepero) attraverso l'inserimento delle stringe @pi ed @e rispettivamente. I numeri decimali devono essere divisi dalla loro parte intera tramite la virgola. L'ordine delle partentesi è dettato dal loro ordine nelle espressioni e sono tutte di tipo ( e ).
Ci sono alcuni caratteri che non è possibile inserire quali: . punto, " virgolette, { } parentesi graffe, ? punto interrogativo, : due punti, & e commerciale, | pipe, = uguale, < > minore e maggiore, per ragioni di sicurezza.
Le pecche di questo sistema almeno nella sua prima versione beta sono: non c'è l'elevamento a potenza, non ha un elevatissimo controllo sul codice dell'espressione, è ancora abbastanza legato al linguaggio di programmazione, non ha tutte le funzioni matematiche di base, quando si fanno delle divisioni tra numeri interi, se si vuole fare una divisione reale bisogna inserire i numeri comprensivi di parte decimale anche se zero (e.g. 4/3 deve essere 4.0/3.0), ma rispetto alla sua versione, ... chiamiamola alfa e da cui eredita la maggior parte di questi bug, è abbastanza avanzato e sicuro.

L'implementazione

Passiamo ora a implementare il sistema. Per prima cosa definiamo le classi di servizio che ci servono:

Una classe di eccezione che sarà utilizzata dal parser per lanciare le eccezioni:

using System;
namespace MathParser {
public class MathParserException : Exception {
public MathParserException() {}

public MathParserException(String message) : base(message) {}

public MathParserException(String message, Exception innerException) : base(message, innerException) {}
}
}
[+/-] Listato 1

niente di complicato, abbiamo semplicemente ereditato dalla classe Exception e ricostruito i costruttori che ci interessano.

La seconda classe di servizio è quella che poi verrà ereditata dalla classe dell'espressione:

using System;

namespace MathParser {
public abstract class BaseClass : IDisposable {
public virtual double eval() {
return 0.0;
}

#region IDisposable Membri di
public void Dispose() {}
#endregion
}
}
[+/-] Listato 2

anche qui niente di complicato definiamo una classe che è IDisposable (da implementare) e un metodo virtuale double eval() che ci ritorna 0.0. Questo metodo verrà poi ridefinito quando la classe viene ereditata e viene sostituito dall'espressione.

Bene abbiamo fatto la base del nostro parser, passiamo ora a implementare la nostra ultima classe, che sarà quella principale del parser che creerà l'assembly ed eseguirà l'espressione.

Definiamo qualche using tra cui gli using base System e il conosciuto System.Collection.Generic per le collezioni. I restanti due servono per la creazione dell'assembly e sfruttano il compilatore integrato nel Framework .NET 2.0.

using System;
using System.CodeDom.Compiler;
using System.Collections.Generic;
using Microsoft.CSharp;

e definiamo la classe:

namespace MathParser {
public class MathExpressionParser : IDisposable {

dichiariamo poi alcuni membri per la classe:

#region Membri
//Lista di funzioni di sistema
private static readonly Dictionary functionList = new Dictionary();
//Lista di errori di compilazione
private readonly List listError = new List();
//Espressione da parsare
private String expression = "";
//Oggetto che eseguirà l'espressione di tipo BaseClass precedentemente definito
private BaseClass myobj = null;
//Variabile che indica se l'espressione è stata passata già al parser o no
private bool parsed = false;
//Collezione di valori per le variabili
private Dictionary valori = new Dictionary();
#endregion

[+/-] Listato 3

penso non abbiamo bisogno di ulteriori commenti oltre quelli già inseriti direttamente nel codice.

Inseriamo un costruttore statico che verrà chiamato solo la prima volta che la classe viene istanziata:

#region Costruttori Statici
static MathExpressionParser() {
functionList.Add("abs", "Math.Abs");
functionList.Add("acos", "Math.Acos");
functionList.Add("asin", "Math.Asin");
functionList.Add("int", "Math.Floor");
functionList.Add("round", "Math.Round");
functionList.Add("sqrt", "Math.Sqrt");
functionList.Add("@pi", "Math.PI");
functionList.Add("@e", "Math.E");
//Aggiungere qui altre funzioni di sistema
}
#endregion
[+/-] Listato 4

questo costruttore contiene la lista delle funzioni di sistema che possono essere inserite nell'espressione, come si nota ogni espressione verrà poi trasformata nel suo rispettivo corrispondente in C#. Questo fa capire anche com'è semplice inserire delle funzioni personalizzate all'interno del parser in una nuova features, o completare semplicemente la lista.

Creiamo qualche proprietà che aiuta chi utilizza il Parser:

#region Proprietà
// Lista degli errori dopo il Parse
public List ListError {
get { return listError; }
}

// Restituisce o imposta la lista dei valori
public Dictionary Valori {
get { return valori; }
set {
listError.Clear();
parsed = false;
if(valori == null) valori = new Dictionary();
else valori = value;
}
}

// Restituisce se l'espressione è stata passata al parser
public bool Parsed {
get { return parsed; }
}

// Restituisce o imposta la stringa di espressione
public string Expression {
get { return expression; }
set {
listError.Clear();
parsed = false;
expression = value;
}
}
#endregion
[+/-] Listato 5

Come possiamo vedere le uniche due proprietà settabili sono Expression e Valori, che a loro volta azzerano il parse se settate ponendo parsed=false. Le restanti proprietà sono opportunamente commentate.

Vengono i metodi privati. Questi sono particolarmente importanti poichè permettono di trasformare l'espressione passata al Parser in una espressione completamente comprensibile al C# (ovviamente se scritta correttamente).



#region Metodi privati
// Sostituisce le funzioni con quelle di sistema

Espressione in cui sostituire i valori della function di Sistema
// Ritorna la stringa filtrata
private static string sobstituteSystemFunction(String expr) {
Dictionary.Enumerator c = functionList.GetEnumerator();
while (c.MoveNext()) {
KeyValuePair kvp = c.Current;
expr = expr.Replace(kvp.Key.ToLower(), kvp.Value.Replace(",", "."));
}
return expr;
}

//
// Sostituisce i marcatori con i valori
//
//

Espressione in cui sostituire i marcatori con i valori
// Ritorna la stringa filtrata
private string sobstitute(String expr) {
Dictionary.Enumerator c = Valori.GetEnumerator();
while (c.MoveNext()) {
KeyValuePair kvp = c.Current;
expr = expr.Replace("[" + kvp.Key.ToLower() + "]", kvp.Value.ToString().Replace(",", "."));
}
return expr;
}

//
// Filtro anti hack. Filtra tutti i caratteri o le stringhe che potrebbero essere usate come codice non desiderato
//
//

Espressione da filtrare
// Espressione filtata
private static string filter(String expr) {
if(expr.Contains(".")) throw new MathParserException("I numeri decimali devono essere separati con la virgola (,)");
if(expr.Contains("{") || expr.Contains("}")) throw new MathParserException("Non sono ammesse parentesi {, solo parentesi tonde per priorità alle espressioni e quadre per identificare i marcatori");
if(expr.Contains("=") || expr.Contains("&amp;gt;") || expr.Contains("&amp;gt;") || expr.Contains("!") || expr.Contains("\"") || expr.Contains("'") || expr.Contains("?") || expr.Contains(":") || expr.Contains(";") || expr.Contains("&") || expr.Contains("|")) throw new MathParserException("Sono stati inseriti caratteri non ammessi");
if(expr.Contains("^")) throw new MathParserException("Impossibile effettuare l'elevamento a potenza in questa versione");
expr = expr.ToLower();
expr = expr.Replace(" ", "");
expr = expr.Replace("\r\n", "");
expr = expr.Replace("\n\r", "");
expr = expr.Replace("\n", "");
expr = expr.Replace("\r", "");
return expr;
}
#endregion

#region IDisposable Membri di
public void Dispose() {
if(myobj != null) myobj.Dispose();
}
#endregion
[+/-] Listato 6

In ordine abbiamo il metodo di sostituzione delle espressioni di sistema con le espressioni .NET. Questo non fa altro che inserire le espressioni C# al posto delle espressioni del parser prendendole dalla collezione listafunzioni. In seguito abbiamo il metodo che sostituisce le variabili in valori: è del tutto analogo al precedente salvo per il fatto che le variabili vengono racchiuse tra parentesi quadre. L'ultimo metodo privato è il metodo per la sicurezza e qui spendiamo 2 parole in più: per prima cosa nei primi 4 if determina se esistono caratteri che non devono essere inseriti (vedi sopra) poi formatta la stringa tutta in minuscolo (questo previene il possibile inserimento di codice che richiama classi del Framework, infatti iniziano tutte con lettera grande), rimpiazza gli spazi con una stringa vuota (questo permette di evitare che venga scritto codice personalizzato del tipo new classe() che diventa newclasse() quindi non compilabile) , vengono tolti tutti i tipi di ritorno a capo di modo da formare una unica espressione. Viene anche ovviato il problema di sicurezza riguardo ai punti: classe.metodo, infatti non è possibile inserire punti ma solo , che dopo il parse di sicurezza vengono convertiti in punti.

Per quanti riguarda i metodi pubblici ce ne sono 2 e sono alla base del Parse, bisogna usarli in coppia per eseguire una espressione.

#region Metodi
/// &lt;summary&gt;
/// Esegue il parse dell'espressione con l'espressione e la lista valori contenuta in memoria
/// &lt;/summary&gt;
/// &lt;exception cref="MathParserException"&gt;&lt;/exception&gt;
public void Parse() {
Parse(Expression, Valori);
}


/// &lt;summary&gt;
/// Esegue il parse dell'espressione
/// &lt;/summary&gt;
/// &lt;param name="expr"&gt;Espressione da eseguire&lt;/param&gt;
/// &lt;param name="listaValori"&gt;Lista parametri&lt;/param&gt;
/// &lt;exception cref="MathParserException"&gt;&lt;/exception&gt;
public void Parse(string expr, Dictionary&lt;string, double&gt; listaValori) {
try {
parsed = false;
listError.Clear();
valori.Clear();
if(expr == null || expr == "") throw new MathParserException("Nessuna espressione impostata");
if(listaValori != null) foreach (KeyValuePair&lt;string, double&gt; kvp in listaValori) Valori.Add(kvp.Key, kvp.Value);
expression = expr;
//Settaggio della stringa
expr = filter(expr);
expr = sobstitute(expr);
expr = sobstituteSystemFunction(expr);
expr = expr.Replace(",", ".");
//Creazione dell'assembly di esecuzione
CSharpCodeProvider cp = new CSharpCodeProvider();
ICodeCompiler ic = cp.CreateCompiler();
CompilerParameters cpar = new CompilerParameters();
cpar.GenerateInMemory = true;
cpar.GenerateExecutable = false;
cpar.ReferencedAssemblies.Add("system.dll");
cpar.ReferencedAssemblies.Add("MathParser.dll");
string src = "using System;" +
"class myclass:MathParser.BaseClass" +
"{" +
"public myclass(){}" +
"public override double eval()" +
"{" +
"return " + expr + ";" +
"}" +
"}";
CompilerResults cr = ic.CompileAssemblyFromSource(cpar, src);
foreach (CompilerError ce in cr.Errors) {
listError.Add(ce.ErrorText);
}


if(cr.Errors.Count == 0 && cr.CompiledAssembly != null) {
Type ObjType = cr.CompiledAssembly.GetType("myclass");
try {
if(ObjType != null) myobj = (BaseClass) Activator.CreateInstance(ObjType);
} catch {
throw new MathParserException("Errore parse nell'espressione");
}
parsed = true;
} else parsed = false;
} catch (MathParserException mpe) {
parsed = false;
throw mpe;
} catch {
parsed = false;
throw new MathParserException("Errore parse nell'espressione");
}
}


/// &lt;summary&gt;
/// Valuta l'espessione inserita
/// &lt;/summary&gt;
/// &lt;returns&gt;Ritorna il valore dell'espressione inserita&lt;/returns&gt;
/// &lt;exception cref="MathParserException"&gt;&lt;/exception&gt;
public double Evaluate() {
if(Parsed)
if(myobj != null)
try {
return myobj.eval();
} catch {
throw new MathParserException("Errore nell'esecuzione dell'espressione, controllare tipi e valori");
}
else throw new MathParserException("Impossibile valutare l'espressione");
else throw new MathParserException("Impossibile valutare l'espressione se prima non si esegue il Parse dell'oggetto");
}
#endregion

[+/-] Listato 7

Il parse fa il parsing dell'espressione filtra l'espressione con i tre metodi privati esposti prima nel preciso ordine filter sobstitute e sobstituteSystemFunction in modo da evitare accavallamenti di stringe uguali del tipo se una variabile si chiama cosenza non viene confusa con la funzione cos.

Viene poi il momento della creazione dell'assembly: si crea il compilatore e con la proprietà GenerateInMemory viene detto al compilatore di creare l'assembly in memoria e non fisicamente, di modo che poi verrà eliminato:


//Creazione dell'assembly di esecuzione
CSharpCodeProvider cp = new CSharpCodeProvider();
ICodeCompiler ic = cp.CreateCompiler();
CompilerParameters cpar = new CompilerParameters();
//Comunica che l'assembly viene inserito in memoria
cpar.GenerateInMemory = true;
//Comunica che l'assembly è una dll
cpar.GenerateExecutable = false;
//Aggiunge i riferimenti alla nuova classe
cpar.ReferencedAssemblies.Add("system.dll");
cpar.ReferencedAssemblies.Add("MathParser.dll");
//Crea il codice sorgente della classe
string src = "using System;" +
"class myclass:MathParser.BaseClass" +
"{" +
"public myclass(){}" +
"public override double eval()" +
"{" +
"return " + expr + ";" +
"}" +
"}";
//Compila la classe
CompilerResults cr = ic.CompileAssemblyFromSource(cpar, src);

[+/-] Listato 8

Una volta compilato viene creata una istanza della classe myclass appena compilata e viene impostato su true il parsed di modo da comunicare al programmatore che il Parser è pronto a eseguire l'espressione. Nella lista ListError, viene inserita la lista degli errori in compilazione se ci sono stati.

//Se non ci sono stati errori crea una nuova istanza della classe
if(cr.Errors.Count == 0 && cr.CompiledAssembly != null) {
Type ObjType = cr.CompiledAssembly.GetType("myclass");
try {
if(ObjType != null) myobj = (BaseClass) Activator.CreateInstance(ObjType);
} catch {
throw new MathParserException("Errore parse nell'espressione");
}
parsed = true;
} else parsed = false;

[+/-] Listato 9

Infine il metodo Evaluate() valuta l'espressione e la restituisce chiamando il metodo eval della classe appena compilata.

Per provare il Parser in un programmino potete usare questo codice:

String c = "";
do {
c = Console.ReadLine();
MathExpressionParser p = new MathExpressionParser();
Dictionary valori = new Dictionary();
valori.Add("numero", 3.5);
valori.Add("desi", 12);
valori.Add("system", 32.254123);
try {
p.Parse(c, valori);
if(p.Parsed) Console.WriteLine("Risultato: " + p.Evaluate());
else {
foreach(String s in p.ListError) {
Console.WriteLine("Errore di no parse: " + s);
}
}
}catch(Exception ex) {
Console.WriteLine(ex.Message);
}
} while (c != "");

[+/-] Listato 10

Questo frammento codice permette di scrivere da Console l'espressione e via codice di inserire le variabili che poi andranno a sostituire i valori corretti nell'espressione dalla collezzione valori.

Espressioni del tipo sono ad esempio:

[numero]*3+4/[desy]

In questo modo si avrà un parser semplice ed efficace da usare nelle vostre applicazioni.

ALLEGATI
- Assembly MathParser
- Codice sorgente

LINKS
- Parser alfa

BUGS

I bugs finora riscontrati sono stati citati nell'intestazione.

COMMENTI
Una serie di commenti sono disponibili sul link della versione alfa del Parser

--------------------------------------------------------------------------------------------

1 commento:

Anonimo ha detto...

da Turbo Pascal a Visual C
http://www.fraspe.it/effedix/effedix.htm

un parser matematico che consente di creare funzioni di una variabile e di inserirle all'interno di una qualsiasi espressione matematica anche complessa