Compilador universal de objetos usando reglas BNF II
BNFUP es una librería de clases que implementa un compilador de objetos a partir de la definición de un lenguaje mediante reglas BNF. También proporciona servicios de edición de reglas. En este artículo continúo mostrando como utilizar el editor para compilar y probar nuestros propios objetos mediante el lenguaje que hayamos definido para ello. También mostraré tres ejemplos de implementación.
En este enlace puedes ver el primer artículo de la serie sobre la definición de la sintaxis y editor de reglas.
En este otro enlace puedes descargar el código fuente del proyecto BNFUP, con la librería de clases que implementa el compilador, el editor de reglas BNF y tres ejemplos de librerías de clases que implementan objetos compilables a partir de tres lenguajes diferentes. Está escrito en CSharp usando Visual Studio 2015.
Compilar y probar objetos con BNFUPEditor
Para mostrar cómo utilizar el compilador integrado en el editor de reglas BNF vamos a utilizar el primero de los ejemplos. Se trata de una versión reducida de la definición de tablas en HTML, que se puede encontrar en el archivo htmltable.bnf del subdirectorio Samples del proyecto BNFUP.
El primer botón de la barra de herramientas se utiliza para seleccionar la librería de clases con la que vamos a generar los objetos. En este caso se trata de CompilableDataTable.dll, que se encuentra en el subdirectorio CompilableDataTable/bin/Debug del proyecto BNFUP.
Una vez seleccionada la librería de clases, se activará el segundo botón de la barra de herramientas, con el cual podemos acceder al compilador de objetos. El último botón de la barra de herramientas permite configurar el compilador de manera que distinga o no entre mayúsculas y minúsculas.
Puedes escribir el código fuente o cargarlo desde un fichero de texto usando el primer botón de la barra de herramientas. En el subdirectorio Samples puedes encontrar el archivo tables.txt con un ejemplo. Con el segundo botón puedes guardar el código fuente en un archivo.
Una vez escrito el código que queremos compilar, utilizaremos el tercer botón de la barra de herramientas para iniciar la compilación. Si no se han producido errores de sintaxis se activarán los dos botones restantes de la barra de herramientas. El primero es para probar el objeto que hemos generado, mientras que el segundo, que también se activará en caso de fallo, mostrará los mensajes generados durante la compilación.
El resultado en este caso es la tabla que hemos compilado mostrada en un control DataGridView:
En cuanto a los mensajes de compilación, existen tres puntos en los que se generan. El primero es cuando se crea el objeto, el segundo cuando se ha completado, porque se ha terminado de analizar el elemento que lo ha generado, y se añade al anterior objeto en la jerarquía. También se producen mensajes si el objeto se descarta, cuando procesamos un elemento opcional que genera objetos cuyo análisis resulta fallido.
Los datos que se muestran en los mensajes sobre los objetos generados se obtienen mediante el método ToString del objeto. Este es el aspecto de la lista de mensajes:
Respecto al código fuente de esta librería de clases, el objeto ICompilableObjectFactory se encuentra implementado en la clase DataTableFactory, este es el método CreateObject con el que se crean los objetos:
public ICompilableObject CreateObject(string ctype, IRuleItem item)
{
switch (ctype)
{
case "identifier":
return new Identifier();
case "emptyid":
return new EmptyIdentifier();
case "idlist":
return new StringDataList();
case "rowlist":
return new BodyRowCollection();
case "table":
_data = new CompilableDataTable();
return _data;
default:
return null;
}
}
Con el identificador table creamos el objeto raíz, CompilableDataTable. La creación de este objeto está asignada a la regla <<table>>, que es la regla principal del lenguaje, por lo que este es el primer objeto que se crea y a este objeto se le irán añadiendo el resto según se vayan creando y completando, mediante el método AddItem:
public bool AddItem(ICompilableObject item)
{
StringDataList sl = item as StringDataList;
if ((sl != null) && (_table.Columns.Count == 0))
{
foreach (string s in sl)
{
_table.Columns.Add(new DataColumn(s));
}
return true;
}
BodyRowCollection bc = item as BodyRowCollection;
if (bc != null)
{
foreach (StringDataList l in bc)
{
DataRow row = _table.NewRow();
for (int ix = 0; ix <
Math.Min(_table.Columns.Count, l.Count); ix++)
{
row[ix] = l[ix];
}
_table.Rows.Add(row);
}
return true;
}
return false;
}
Este método debe devolver true si se acepta el objeto añadido o false en caso contrario, lo que producirá un error de compilación.
La tabla está compuesta de una cabecera, con los nombres de las columnas, que se obtiene de un objeto StringDataList, que es una lista de cadenas de texto, y de una serie de filas de datos que se obtiene de un objeto DataRowCollection, que es una colección de objetos StringDataList. Con estos objetos se va construyendo un objeto DataTable que será mostrado en un control DataGridView cuando llamemos al método Test.
El objeto StringDataList se crea mediante el identificador idlist. En la definición del lenguaje he marcado las reglas <header>, <headerlist>, <datalist> y <row> con este identificador para que generen este tipo de objetos. El método AddItem con el que se construyen estas listas es el siguiente:
public bool AddItem(ICompilableObject item)
{
Identifier id = item as Identifier;
if (id != null)
{
Add(id.Text);
return true;
}
StringDataList dl = item as StringDataList;
if (dl != null)
{
AddRange(dl);
return true;
}
return false;
}
Como se puede ver, este objeto se puede componer añadiendo objetos Identifier o bien otro objeto StringDataList, del que se toma todo su contenido.
Los objetos Identifier se crean utilizando el identificador identifier, que está asignado a las reglas <identifier> y <data>. La regla <identifier> se utiliza para generar los nombres de columna, ya que deben empezar obligatoriamente con una letra. Con la regla <data> creamos el contenido de las filas de datos, que pueden comenzar por cualquier carácter.
Por último, la colección de filas de la tabla se implementa en la clase BodyRowCollection, cuyo método de construcción AddItem es el siguiente:
public bool AddItem(ICompilableObject item)
{
StringDataList sl = item as StringDataList;
if (sl != null)
{
Add(sl);
return true;
}
BodyRowCollection bc = item as BodyRowCollection;
if (bc != null)
{
AddRange(bc);
return true;
}
return false;
}
El objeto acepta otros objetos de tipo StringDataList u otro BodyRowCollection, del que toma su contenido. La razón de que estos objetos se construyan utilizando objetos de su mismo tipo es que las reglas están definidas de forma recursiva, y de esta manera al final solo tenemos un único objeto de este tipo para añadir a su contenedor principal, que es el objeto CompilableDataTable.
Para crear objetos BodyRowCollection utilizamos el identificador rowlist, que está asignado a la regla <body>.
Generador de expresiones aritméticas
El segundo ejemplo es un lenguaje que genera expresiones aritméticas. Estas expresiones pueden contener números, constantes y hasta tres variables, que se identifican con las letras x, y o z. Se pueden utilizar los operadores más comunes, la suma (+), la resta o menos unario (-), la multiplicación (*), la división (/) y la exponenciación (^). También podemos utilizar subexpresiones encerradas entre paréntesis.
El lenguaje se encuentra definido en el archivo expressions.bnf, en el subdirectorio Samples del directorio de la solución. La implementación la puedes encontrar en el subdirectorio Expressions/bin/Debug. La solución con el código fuente correspondiente es Expressions.
La clase ExpressionFactory implementa el objeto ICompilableObjectFactory necesario para la compilación:
public ICompilableObject CreateObject(string ctype, IRuleItem item)
{
switch (ctype)
{
case "number":
return new Number(_exproot);
case "constant":
return new Constant(_exproot);
case "variable":
return new Variable(_exproot);
case "operator":
return new Operator();
case "expression":
Expression ex = new Expression(_exproot);
if (_exproot == null)
{
_exproot = ex;
}
return ex;
case "p-expression":
Expression pex = new Expression(_exproot);
pex.Parenthesis = true;
if (_exproot == null)
{
_exproot = pex;
}
return pex;
default:
return null;
}
}
Los diferentes tipos de objeto que se pueden crear son los números, Number, con el identificador number, las constantes, Constant, con el identificador constant, las variables, Variable, con el identificador variable, los operadores, Operator, con el identificador operator y las expresiones, Expression, con los identificadores expression y p-expression. Este último se utiliza para marcar las expresiones que van entre paréntesis. Todas estas clases, excepto Operator, se derivan de una clase base común, ExpressionBase.
El objeto principal es Expression, que puede estar compuesta por uno o dos argumentos, que pueden ser expresiones, números, constantes o variables y por un operador. El único tipo de objetos que acepta componentes mediante el método AddItem es la clase Expression. En esta clase he implementado también el método Simplify, del interfaz ICompilableObject, que permite reducir una expresión que solo contiene un número, variable o constante a uno de estos objetos, de manera que el objeto resultante contenga la menor cantidad de subexpresiones posible.
public override ICompilableObject Simplify()
{
_exp1 = _exp1.Simplify() as ExpressionBase;
if ((_exp2 == null) && (_op == null))
{
Expression ex1 = _exp1 as Expression;
if (ex1 != null)
{
_exp2 = ex1._exp2;
_op = ex1._op;
_exp1 = ex1._exp1;
Parenthesis = Parenthesis || ex1.Parenthesis;
}
else
{
return _exp1;
}
}
else if (_exp2 != null)
{
_exp2 = _exp2.Simplify() as ExpressionBase;
}
GroupLeft();
return this;
}
Las variables _exp1 y _exp2 contienen los argumentos de la expresión, mientras que _op contiene el operador. Si no existe ningún operador ni segundo argumento y el primer argumento no es una expresión, se devuelve como resultado este elemento. Si el primer argumento es una expresión, se toman sus argumentos y su operador de manera que se reduce el número de subexpresiones anidadas.
Al llamar al método GroupLeft, las subexpresiones se reagrupan de manera que sean evaluadas de izquierda a derecha, ya que, debido a la recursividad del lenguaje, se construyen de manera que se invierte el orden de evaluación, lo que produciría errores en el resultado.
Si compilamos y probamos una expresión cualquiera, obtenemos un resultado como este:
En la barra de herramientas podemos darle valor a las variables y constantes, mientras que en la lista jerárquica de la derecha podemos seleccionar las diferentes subexpresiones para ver el resultado total o los resultados parciales.
En cuanto al lenguaje, todos los token que representan operadores generan objetos de tipo Operator, la regla <number> genera objetos Number, <var> genera objetos Variable, <const> genera objetos Constant y las reglas <<expr>>, <expr2>, <expr1> y <expr0> generan objetos Expression. La regla <pexpr> también genera objetos Expression, pero marcados para indicar que se trata de una expresión entre paréntesis.
Representación de funciones
El último ejemplo que voy a mostrar está construido a partir del anterior. Se trata del mismo lenguaje de expresiones anterior, al que he añadido algunas reglas para definir un rango de valores para una variable y los valores de las constantes, de manera que se represente gráficamente como resultado la función indicada mediante una expresión aritmética.
El archivo con el lenguaje es graphic.bnf, que se encuentra en el subdirectorio Samples de la solución, y la librería de clases que implementa los objetos es ExpressionDrawer.dll, en el proyecto con el mismo nombre. He tomado como base el lenguaje de expresiones aritméticas y le he añadido las reglas <<graphic>>, <valuedef>, <varrange> y <constvalue> para añadir las definiciones del rango de valores de la variable y los valores de las constantes.
La expresión debe contener solamente una única variable, aunque puede contener cualquier número de constantes. El objeto ICompilableObjectFactory se implementa en la clase GraphicFactory. Esta clase a su vez utiliza un objeto ExpressionsFactory con el que se construyen los elementos correspondientes a la expresión aritmética. En el método Init se crea la instancia de esta clase:
public void Init()
{
_eFactory = new ExpressionsFactory();
_graphic = null;
}
Y el método CreateObject es el siguiente:
public ICompilableObject CreateObject(string ctype, IRuleItem item)
{
switch (ctype)
{
case "constvalue":
return new ConstValue();
case "varrange":
return new VariableRange();
case "graphic":
if (_graphic == null)
{
_graphic = new ExpressionDrawer();
}
return _graphic;
default:
return _eFactory.CreateObject(ctype, item);
}
}
Podéis encontrar un ejemplo de código fuente para este lenguaje en el archivo graphsample.txt del subdirectorio Samples de la solución:
graphic {
x^2+a;
x from -10 to 10 by 0,1;
a = 2;
}
Que al ser compilado produce este resultado:
Puedes leer más sobre el código fuente (en inglés) en el sitio CodeProject.