Compilador - Pascal
Para la elaboración de nuestro compilador del
lenguaje Pascal, es necesario tener ciertos conocimientos acerca de "Que
es, para y como funciona el Analizador Léxico", "Que es, para y como
funciona el Analizador Sintáctico" y "Que es, para y como funciona el
Analizador Semántico" y además, conocer acerca del jFlex y Cup que es en
lo que se basa nuestro compilador y el que nos ayudó a su elaboración, no se
preocupen si no conocen lo básico o acerca de esto, más adelante empezaré a
explicar cada uno de ellos.
ANÁLISIS LÉXICO
El
analizador léxico o scanner (en lengua inglesa) lee los caracteres del programa
fuente para agruparlos en palabras o lexemas y luego clasificarlos en componentes
léxicos denominados “tokens” que serán posteriormente
utilizados por el analizador sintáctico
como entradas.
- - Palabras reservadas: if, while,
do, break
- - Identificadores: asociados a
variables, nombres de funciones, tipos definidos por el usuario,
etiquetas,... Por ejemplo: velocidad, - y11, elsex, _100
- - Operadores: = * + - / ==
- - Símbolos especiales: ; ( ) [ ]
- - Constantes numéricas: valores
enteros, valores en coma flotante, etc, 982,0xF678, -83.2E+2,...
- - Constantes de caracteres: cadenas
de caracteres, “hola mundo”,...
- - Comentarios: /** abcde **/
El
analizador léxico opera a solicitud del analizador sintáctico devolviendo un
componente léxico conforme el analizador sintáctico lo va necesitando para
obtener una representación de la estructura sintáctica del programa fuente.
El
Scanner suele implementarse como una subrutina del analizador sintáctico.
Cuando
recibe la orden obtén el siguiente componente léxico, el analizador léxico lee
los caracteres de entrada hasta identificar el siguiente componente léxico.
El
scanner realiza también otras tareas, tales como:
- -
Administrar el archivo de entrada del programa fuente: abrirlo, leer sus
caracteres, cerrarlo y gestionar posibles errores de lectura.
- -
“Eliminar” comentarios, espacios en blanco, tabuladores y saltos de línea
(caracteres no validos para formar un token).
- -
Incluir archivos: # include ...
- -
Expandir macros y funciones inline: # define ...
- -
Contabilizar el número de líneas y columnas para emitir mensajes de error.
- -
Reconocer las marcas de fin de archivo, de los archivos que contienen al
- -
programa fuente.
- -
Insertar los identificadores en la Tabla de Símbolos.
- -
Reconocer errores léxicos que ocurren cuando se escribe mal una palabra o
- -
cuando se encuentran caracteres que no pertenecen al alfabeto del lenguaje
fuente.
1. DEFINICIONES BÁSICAS
1) TOKEN:
Categorías
sintácticas más simples del lenguaje fuente que permiten clasificar las
palabras que conforman el léxico del programa fuente.
2) PATRÓN:
Regla
que especifica la secuencia de caracteres que puede representar a un
determinado componente léxico.
3) LEXEMA:
Cadena
de caracteres que concuerda con un patrón que describe un componente léxico.
4) ATRIBUTOS:
Información
adicional sobre los tokens, que es usada por los analizadores sintáctico y
semántico. El número de atributos depende de cada token. En la práctica, los
tokens tienen un único atributo: un registro que contiene toda la información
propia de cada caso (lexema, tipo de dato, valor, etc).
Ejemplo:
2. FUNCIONAMIENTO DEL ANALIZADOR
LEXICO
Cada
vez que el analizador sintáctico llama al scanner éste debe leer caracteres
desde donde se quedó en la anterior llamada hasta completar un nuevo lexema, para luego clasificarlo según una
categoría de token y devolver
el par (token, lexema).
El
analizador léxico almacena el lexema que se acaba de reconocer para ser usado posteriormente
en el análisis semántico (completar la tabla de símbolos); y codifica los
componentes léxicos como enteros.
Cuando
el scanner intenta reconocer tokens no
específicos (identificadores, números, etc.), el analizador debe leer
caracteres hasta que lea uno que no pertenece a la categoría del token que está leyendo; ese último
carácter no puede perderse, y debe devolverse al buffer de entrada para ser
leído en primer lugar la próxima vez que se llame al analizador léxico.
Ejemplos:
Obtener
los pares token-lexema que devolvería un analizador de léxico ante las
expresiones:
Expresión:
654–
b * 30/5.07
(TKN_ENTERO,
“654”)
(TKN_OPRES,
“-“)
(TKN_ID, “b”)
(TKN_OPMUL, “*”)
(TKN_ENTERO,
“30”)
(TKN_OPDIV,
“/”)
(TKN_REAL,
“5.07”)
Expresión:
printf(“Resultado:
%d\n”, 25);
(TKN_PRINTF, “printf”)
(TKN_PARI, “(“)
(TKN_STR,
“”Resultado: %d\n””)
(TKN_NUM,
“25”)
(TKN_COMA,
“,”)
(TKN_PARD,
“)”)
(TKN_PYC,
“;”)
JFLEX
Para la generación del
autómata de estados del analizador léxico hemos utilizado JFlex(http://jflex.de/). Se trata de
una potente herramienta, que dadas las expresiones regulares que reconocen los tokens
del lenguaje y sus acciones asociadas, generan el autómata reconocedor de forma
automática.
En
el código se incluyen, en el paquete Léxico, el archivo original (Léxico.flex) y el
compilado porJFlex, Léxico.java.
Estructura del archivo .flex
- Una
cabecera con las inclusiones de código (imports) necesarias.
- Una
serie de variables utilizadas por JFlex para insertar las declaraciones, y
la definición del Token devuelto en el final del fichero.
- Código
empotrado en donde se declara la tabla de símbolos
return new Token(type.ordinal(), yyline,
yycolumn, atributos);
- El
ordinal hace referencia al enumerado, que dice de qué tipo de Token se trata.
- yyline hace
referencia a la línea donde está ubicado el Token.
- yycolumn hace referencia a
la columna donde está ubicado el Token.
- atributos es un Object que define
los atributos del Token, generados en esta función a partir del tipo.
- Luego
viene el conjunto de expresiones regulares, que sigue un esquema:
nombre_expresión
= expresión_regular
Más abajo
se explica cómo se definen las expresiones regulares.
- Por
último, se definen las acciones asociadas a los patrones, que bien pueden
estar definidos por expresiones regulares en el apartado anterior, o se
pueden definir directamente. Aquí se utiliza la función creaToken mencionada
antes.Si llega al final y no ha entrado en ninguna de las acciones,
entonces se considera que el Token es erróneo y se notifica al gestor de
errores.
Expresiones regulares
Introducimos
aquí un pequeño resumen de cómo se escriben algunas expresiones regulares
que hemos usado de JFlex, como referencia para aquéllos que no conozcan el
lenguaje:
Léxico
Como
ya se mencionó en la introducción, en esta sección de proyecto se
observa cómo se comporta elanalizador léxico para gestionar cada entrada
del código.Podemos distinguir dos partes, la especificación y la
implementación. En la primera comentaremoscómo es la gramática que reconoce el
analizador, cómo se ha diseñado la estructura interna de lostokens del lenguaje
y su codificación. En la segunda parte,
veremos la forma en que hemos plasmadoen JAVA toda esta información
y la interfaz de acceso para el analizador sintáctico.
Especificación Gramática
Estudiando
el lenguaje pascal, llegamos a ciertas reglas con las que hemos generado
una serie de expresiones regulares para reconocer los tokens. A
continuación mostramos dichas expresiones y las acciones semánticas que
hemos asociado a dichos patrones
El
autómata que obtenemos por la gramática, obtenido gracias a JFlex
minimizado. Solo mostraremos una parte ya que es demasiado para mostrarlo en imágenes
(con el programa ya descargado podrán ya observar todo en cuanto a palabras
reservadas y demás)
En estas imágenes vemos:
LlenadorDeTabla.addArrayList0
LlenadorDeTabla.addArrayList1
LlenadorDeTabla.addArrayList2
LlenadorDeTabla.addArrayList3
Esto
lo único que nos permite es mostrar en nuestra Interfaz los tokens. (Ya más
adelante y con el programa ya descargado se podrán dar cuenta y resolver sus
dudas)
Tokens
La
materia de trabajo esencial para un analizador léxico son los tokens,
que deben estar bien especificados y con una estructura clara. Cada token
de nuestro analizador consta de un tipo y de los atributos asociados a
dicho token. Además incluye la línea y la columna donde se encuentra
situado en el código.Los tipos de token que pueden darse, responden a una
lista concreta que hemos diseñado partiendo del lenguaje Pascal. El
scanner se encarga de elegir cual será el tipo de un token recibido. Las directrices
proporcionadas a JFlex, condicionadas por la gramática del diseño y sus
acciones asociadas obligan a que se encuentre siempre un tipo. En caso de
que el token no se asociase a ningún patrón establecido, sería un error
léxico. A continuación mostramos una tabla orientativa del diseño de los
tokens.
Errores
Hemos
creado un tipo enumerado con los tipos básicos de errores:
ANÁLISIS SINTÁCTICO
El analizador sintáctico o parser (en lengua inglesa) comprueba que la
sintaxis del programa fuente sea correcta. El analizador sintáctico recibe
los tokens que envía el scanner y verifica que estén debidamente
combinados de tal manera que cumplan con las reglas sintácticas del
lenguaje fuente especificadas mediante una gramática libre de contexto. El
parser verifica que la sucesión de tokens pueda ser generada por la
gramática correspondiente al lenguaje fuente.
Son funciones del analizador sintáctico:
·
Comprobar si la cadena de tokens proporcionada por el analizador léxico
puede ser generada por la gramática.
·
Construir el árbol de análisis sintáctico que define la estructura
sintáctica de un programa y obtener las derivaciones para generar la
cadena de tokens.
·
Informar de los errores sintácticos de forma precisa y significativa.
En la práctica, el analizador sintáctico realiza otras tareas tales
como:
·
Acceder a la tabla de símbolos (para hacer parte del trabajo del
analizador semántico).
·
Comprobación de tipos (del analizador semántico).
·
Generar código intermedio.
·
Generar mensajes de errores cuando se producen.
ESPECIFICACIÓN SINTÁCTICA DE LOS LENGUAJES
DE PROGRAMACIÓN
La estructura sintáctica de los lenguajes de programación se especifica
mediante Gramáticas Libres de Contexto que además son la base para los
diferentes esquemas de traducción de la etapa de síntesis.
Las producciones de la gramática libre de contexto obedecen al formato:
P = { A?a | A?N y a?(N?S)* } donde:
N = Símbolos no terminales o variables sintácticas
S = Símbolos terminales o tokens
Durante el análisis sintáctico se utiliza una G.L.C. para determinar si
la cadena de tokens, que se recibe del scanner, puede ser derivada a
través de dicha gramática.
VENTAJAS DE UTILIZAR GRAMÁTICAS
- Son especificaciones
sintácticas y precisas de lenguajes.
- Se puede generar
automáticamente un analizador.
- El proceso de construcción
puede llevar a descubrir ambigüedades.
- Imparte estructura al
lenguaje de programación, siendo más fácil generar código y detectar
errores.
- Es más fácil ampliar y
modificar el lenguaje.
DERIVACIONES Y ÁRBOL DE ANÁLISIS SINTÁCTICO
Analizar sintácticamente una cadena de tokens es construir para ella un
árbol sintáctico o de derivación, que tenga como raíz el símbolo inicial
de la gramática y mediante la aplicación sucesiva de sus producciones se
pueda generar dicha cadena como hojas del árbol sintáctico. En caso de
éxito la sentencia cumple con las reglas sintácticas de la gramática, en
caso contrario, el compilador emite un mensaje de error sintáctico.
Un árbol de análisis sintáctico es un árbol tal que:
1) La raíz esta etiquetada con el símbolo inicial.
2) Cada hoja esta etiquetada con un componente léxico. Las hojas de
izquierda a
derecha forman la frase o frontera (el programa fuente).
3) Cada nodo interior esta etiquetado con un no terminal.
4) Si A es un no terminal y X1,X2,…,Xn son sus hijos de izquierda a
derecha, entonces
existe la producción AàX1X2...Xn.
Ejemplo:
Sean las producciones de la gramática G:
E -> E A E | ( E ) | - E | id
1,2,3,4
A -> + | - | * | /
5,6,7,8
Determinar si la cadena id+id*id pertenece al lenguaje definido por
la gramática.
Construimos su árbol de derivación:
RECURSIVIDAD
Las reglas de producción de una gramática están definidas de forma que
al realizar derivaciones dan lugar a recursividades.
Una producción es recursiva a izquierda si es de la forma A -> Aß. De
la misma forma A -> aA es recursiva a la derecha.
AMBIGÜEDAD
Una gramática es ambigua si el lenguaje que define contiene alguna
sentencia que tiene más de un único árbol sintáctico. Es decir, si se
puede construir más de un árbol de análisis sintáctico significa que esa
sentencia puede “querer decir” cosas diferentes (tiene más de una
interpretación). La ambigüedad es difícil de resolver pues no existe una
metodología para eliminarla y tampoco hay otra fórmula para saber que una
gramática es ambigua. Cuando se presenta una gramática ambigua se debe
replantear el diseño de la misma para encontrar una gramática no ambigua
equivalente (que genere el mismo lenguaje). Sin embargo en algunos
lenguajes de programación, la ambigüedad sintáctica existe y se deberá
eliminar con consideraciones semánticas.
DISEÑO DE GRAMÁTICAS PARA LENGUAJES DE PROGRAMACIÓN
Algunas propiedades de los operadores y operandos en los lenguajes de
programación se deben plasmar a través de las reglas sintácticas.
La precedencia y la asociatividad, son propiedades suficientes para
convertir la gramática ambigua basada en operadores en no ambigua.
1. ASOCIATIVIDAD
La asociatividad de un operador binario define cómo se operan tres o más
operandos. La asociatividad por la izquierda quiere decir que si aparecen tres
o más operandos, primero se evalúan los dos operandos de más a la izquierda, y
el resultado de esa operación se opera con el siguiente operando por la
izquierda, y así sucesivamente. En la asociatividad por la derecha, los
operandos se evalúan de derecha a izquierda.
En la gramática, la asociatividad de un operador por la izquierda,
requiere que la producción en la que interviene dicho operador debe ser
recursiva por la izquierda, y cuando es por la derecha, la producción debe
tener recursión por la derecha.
2. PRECEDENCIA
La precedencia de un operador especifica el orden de evaluación de cada
operador con respecto a los demás operadores. Si un operador tiene mayor
precedencia que otro operador, cuando en una expresión aparezcan los dos
operadores, se debe evaluar primero el operador con mayor precedencia.
En la gramática se utiliza un símbolo no terminal por cada operador de
distinta precedencia. Cuanto más “cerca” esté la producción a la producción del
símbolo inicial, menor será la precedencia del operador involucrado.
3. PARENTIZACIÓN
Los lenguajes de programación utilizan los paréntesis (operadores
especiales de máxima precedencia) para agrupar los operadores según la
conveniencia del programador y sortear las precedencias y asociatividades
definidas en el lenguaje.
En la gramática se añade un símbolo no terminal que produzca expresiones
entre paréntesis y los operandos (números, variables, etc.) a la mayor
distancia posible del símbolo inicial. En esta producción también se
pondrían los operadores unarios a no ser que tengan una precedencia menor.
Ejemplo:
Sea la gramática no ambigua que describe expresiones aritméticas:
E -> E + T | E - T | T
T -> T * F | T / F | F
F -> P ^ F | P
P -> P | B
B -> ( E ) | id | cte
El árbol sintáctico de la sentencia a*c+d^4 se muestra en la figura siguiente.
CUP
Es la herramienta principal de todo el proceso. Se encarga de ir
demandando al JFlex los lexemas válidos a analizar y generar el código
necesario para el análisis sintáctico y semántico.
CUP analiza lsa reglas de la gramática del lenguaje a compilar y genera
una serie de acciones a partir del análisis de dichas reglas.
Además CUP distingue entre terminales (devueltos por el JFlex) y no
terminales que son las reglas de la gramática.
Dentro de cada regla, CUP puede ejecutar código de Java para analizar su
trabajo de análisis.
Un fichero de entrada para CUP consta de las siguientes partes.
1. Definición de paquete y sentencias import.
2. Secciòn de còdigo de usuario.
3. Declaraciòn de símbolos terminales y no terminales
4. Declaraciones de precedencia.
5. Definiciòn del símbolo inicial de la gramàtica y las reglas de
producción.
ESTRUCTURA DEL ARCHIVO CUP
A continuación detallaré como se estructura un archivo de entrada para
Cup.
Básicamente un archivo para Cup tiene la siguiente estructura:
< imports java >
< codigo del usu rio para el parser>
<codigo del usuario para las acciones de la gramatica>
< Declaración de Variables para la gramatica>
<Gramatica>
Código del Usuario para el Parser: Como
el código Java es generado por la herramienta es
muy difícil modificar lo en el archivo de
salida. Así que aquí podemos declarar métodos y
variables que pensamos usar en la clase resultante. Si se declaran variables o
métodos públicos en esta sección estos podrán ser accedidos por otras
clases.
Se declara:
parser code {: /* Código del parser*/:}
Código del Usuario para las Acciones de la Gramática: Como
nuestro propósito es el de generar un Compilador con estas
herramientas o un interprete, necesitamos generar una salida ya sea esta
errores semánticos, sintácticos o traducción a un
código equivalente, para esto tenemos que hacer uso de traducciones dirigidas
por sintaxis.
Sería muy engorroso programar largas funciones en
cada acción del gramática así que estas las podemos
declarar en esta sección y solo mandarlas a llamar en
cada acción gramatical.
Se declara de la siguiente manera:
action code {:/*Codigo para las acciones*/:}
Declaración de Variables para la Gramática : En
esta sección toca declarar las variables que se utilizaran en la
gramática, estas variables pueden ser de dos tipos:
- Variables Terminales <
terminal>
- Variables No Terminales <non
terminal>
Las variables terminales serán todos los símbolos terminales
de la gramática y las variables No-Terminales serán todas
las variables que representaran producciones.
La sintaxis para la declaración es la siguiente:
<tipo de variable>
< tipo de dato >
< id de la
variable >
Donde <tipo de variable > puede ser Terminal o No
terminal
<tipo de dato> puede ser cualquier tipo de dato primitivo de Java
o uno creado por nosotros mismos. Si se no se especifica el tipo de dato Cup lo
trabajará como un tipo de dato Symbol.
<id de la variable > aquí se especifica el id de la variable, se
puede usar una lista de identificadores separadas por coma si deseamos
variables del mismo tipo.
Gramática: En esta sección del archivo es donde
escribiremos nuestra gramatica. La gramatica tiene la siguiente sintaxis :
<non terminal > ::= < terminales o No terminales > ;
Como un no terminal puede tener mas de un lado derecho en Cup se utiliza
el simbolo “|”
<non terminal > ::= < terminales o No terminales >
|<terminales o No terminales> ;
Como es esperado se pueden escribir muchas producciones.
Sección de gramática:
Ahora si procederemos a mostrar la ejecución de nuestro programa usando Jflex y Cup y terminado ya nuestro compilador.
1) Descargamos el jFlex y Cup
http://www.jflex.de/download.html
http://www2.cs.tum.edu/projects/cup/
Y lo agregamos a nuestra librería de nuestro proyecto.
Ya una vez puesto todo eso, al descargar ambos nos viene con lo que irá en este caso en A_Lexico.flex y A_sintactico.cup, lo tenemos que crear e implementar (como venía diciendo anteriormente, en la sección de JFlex y Cup)
A_Lexico.flex
A_sintacto.cup
Luego de esto, procedemos a crear un clase para cada uno, es decir, para generar la clase del A_lexico.flex y para generar la clase del A_sintactico.cup
Pero tener en cuenta que para el caso de GeneradorLexico.java hemos puesto la ubicación de nuestra hoja en blanco (A_lexico.flex) al igual que para GeneradorSintactico.java hemos puesto la ubicación de nuestra hoja en blanco (A_sintactico.cup)
Pero al momento de implementar nuestro A_lexico.flex debemos tener en cuenta esto:
Ya que los tokens que ponemos en A_lexico.flex deben ir en esta clase Simblos.java, todo igual.
Ahora para detectar los errores que puede ocurrir, se crea esta clase
Y ojo, también debemos crear un hoja en blanco para ingresar lo que queremos analizar, en este caso lo hemos llamado, archivo_texto.txt
Que su dirección/ubicación será guardada en el botón compilar de nuestra interfaz
Y para el llenado de la tabla hicimos estas clases LlenadoTabla.java y LlenadoTablaA.java, estas clases se toman en cuenta también en el A_lexico.flex y A_sintactico.cup (ver en las imagenes anteriores) ya que de ahí serán pasadas a la tabla y plasmarse al momento de compilar.
Aquí parte de nuestra interfaz como creamos y llenaremos, mostraremos la tabla
Aquí parte para nuestro semantico
Compilado
Bibliografía
https://openfecks.wordpress.com/jlex-y-cup/estructura-de-archivo-cup/
https://books.google.com.pe/books?id=Mk1E8RTO1qwC&pg=PR20&lpg=PR20&dq=uso+del+cup&source=bl&ots=gp2I0lDUMi&sig=HV_6nu7Ku6d5VuGkBAfL1o04sy0&hl=es&sa=X&ved=0ahUKEwjH7reX5PrVAhWmhlQKHR-iBokQ6AEILzAC#v=onepage&q=uso%20del%20cup&f=false
http://www.academia.edu/5815561/Compilador_de_Pascal_Analizador_l%C3%A9xico_Contenido
Compiladores principios, técnicas y herramientas, 2da Edición - Alfred V. Aho
Integrantes - Elaboración del informe y proyecto final
- Rodriguez Alva, Manuel Fernando
- Yoplac Torres, Miguel Humberto
- Méndez Barreto, Giancarlo Yoel
UNIVERSIDAD NACIONAL DE TRUJILLO - UNT
http://www.jflex.de/download.html
http://www2.cs.tum.edu/projects/cup/
Y lo agregamos a nuestra librería de nuestro proyecto.
Ya una vez puesto todo eso, al descargar ambos nos viene con lo que irá en este caso en A_Lexico.flex y A_sintactico.cup, lo tenemos que crear e implementar (como venía diciendo anteriormente, en la sección de JFlex y Cup)
A_Lexico.flex
A_sintacto.cup
Luego de esto, procedemos a crear un clase para cada uno, es decir, para generar la clase del A_lexico.flex y para generar la clase del A_sintactico.cup
Pero tener en cuenta que para el caso de GeneradorLexico.java hemos puesto la ubicación de nuestra hoja en blanco (A_lexico.flex) al igual que para GeneradorSintactico.java hemos puesto la ubicación de nuestra hoja en blanco (A_sintactico.cup)
Pero al momento de implementar nuestro A_lexico.flex debemos tener en cuenta esto:
Ya que los tokens que ponemos en A_lexico.flex deben ir en esta clase Simblos.java, todo igual.
Ahora para detectar los errores que puede ocurrir, se crea esta clase
Y ojo, también debemos crear un hoja en blanco para ingresar lo que queremos analizar, en este caso lo hemos llamado, archivo_texto.txt
Que su dirección/ubicación será guardada en el botón compilar de nuestra interfaz
Y para el llenado de la tabla hicimos estas clases LlenadoTabla.java y LlenadoTablaA.java, estas clases se toman en cuenta también en el A_lexico.flex y A_sintactico.cup (ver en las imagenes anteriores) ya que de ahí serán pasadas a la tabla y plasmarse al momento de compilar.
Aquí parte de nuestra interfaz como creamos y llenaremos, mostraremos la tabla
Aquí parte para nuestro semantico
Compilado
Bibliografía
https://openfecks.wordpress.com/jlex-y-cup/estructura-de-archivo-cup/
https://books.google.com.pe/books?id=Mk1E8RTO1qwC&pg=PR20&lpg=PR20&dq=uso+del+cup&source=bl&ots=gp2I0lDUMi&sig=HV_6nu7Ku6d5VuGkBAfL1o04sy0&hl=es&sa=X&ved=0ahUKEwjH7reX5PrVAhWmhlQKHR-iBokQ6AEILzAC#v=onepage&q=uso%20del%20cup&f=false
http://www.academia.edu/5815561/Compilador_de_Pascal_Analizador_l%C3%A9xico_Contenido
Compiladores principios, técnicas y herramientas, 2da Edición - Alfred V. Aho
Integrantes - Elaboración del informe y proyecto final
- Rodriguez Alva, Manuel Fernando
- Yoplac Torres, Miguel Humberto
- Méndez Barreto, Giancarlo Yoel
UNIVERSIDAD NACIONAL DE TRUJILLO - UNT
Excelente ejemplo y explicación. Es posible que puedan compartir la gramática completa que utilizaron? Gracias
ResponderBorrar