Apuntes de compiladores I

Anuncio
COMPILADORES I
Compiladores I - Introducción
Desde el punto de vista de un informático, prácticamente todas las acciones que se va a ver obligado
a desarrollar en el transcurso de su carrera profesional, tendrá que ver con traductores: la programación, la
creación de ficheros batch, la utilización de un intérprete de comando, etc.
Por ejemplo ¿ Que ocurre si nos dan un documento de Word que procede de una fusión con una base de
datos y se quiere, a partir de él, obtener la B.D. original?. Pues se puede: a) Convertirla a texto.
b) Procesarla con un traductor para quitar el texto superfluo y dar como resultado un texto en el que cada
campo está entre comillas.
c) El texto anterior se importa con cualquier SGBD.
Otro ejemplo
Creación de preprocesadores para lenguajes que no lo tienen . Por ejemplo para trabajar fácilmente con SQL
en C, se puede hacer un preprocesador para meter SQL inmerso.
¿Qué es un traductor?
Un traductor es un programa que traduce o convierte desde un texto o programa escrito en un lenguaje fuente
hasta un texto o programa escrito en un lenguaje destino produciendo, si cabe, mensajes de error. * Los
traductores engloban tanto al compilador como al intérprete.
* Esquema inicial para un traductor
Programa Fuente
escrito en Lenguaje
Fuente
TRADUCTORES
Programa Destino
escrito en Lenguaje
Destino
Mensajes de Error
* Es importante destacar la velocidad en la que hoy en día se hacen. En la década de 1950, se consideró a
los traductores como programas notablemente difíciles de escribir.
El primer compilador de FORTRAN, por ejemplo, necesitó para su implementación 18 años de trabajo en
grupo. Hasta que apareció la teoría de autómatas no se pudo acelerar ni formalizar la creación de traductores.
Tipos de Traductores
Traductores del idioma : Traducen de un idioma dado a otro, por ejemplo, un traductor de Inglés a Español..
* Problemas:
Inteligencia Artificial y problemas de las frases hechas: El problema de la inteligencia artificial es que tiene
mucho de artificial y poco de inteligencia. Por ejemplo una vez se tradujo del Ingles al Ruso (por lo de la guerra
fría) : “El espíritu es fuerte pero la carne es débil” que, de nuevo, se pasó al Inglés, y dio: “El vino está bueno
pero la carne está podrida” ( En inglés spirit significa tanto espíritu como alcohol ).
Falta de formalización en la especificación del significado de las palabras.
Preparado por Prof: Ing. Diego Casco
1
COMPILADORES I
Cambio del sentido de las palabras según el contexto. Ej: “Por decir aquello, se llevó una galleta”. Sólo
un subconjunto del lenguaje.
Compiladores : Es aquel traductor que tiene como entrada una sentencia en lenguaje formal y como salida
tiene un fichero ejecutable, es decir, hace una traducción de alto nivel a código máquina.
Intérpretes : Es como un compilador, solo que la salida es una ejecución. El programa de entrada se interpreta
y ejecuta a la vez.
* Hay lenguajes que solo pueden ser interpretados.
Ej: SNOBOL (StriNg Oriented SimBOlyc Language),
LISP (LISt Processing)
BASIC (Beginner’s All ...)
La principal ventaja es que permiten una fácil depuración. Los inconvenientes son, en primer lugar la lentitud
de ejecución , ya que si uno ejecuta a la vez que traduce no puede aplicarse mucha optimización, además si
el programa entra en un bucle tiene que interpretar y ejecutar todas las veces que se realice el bucle. Otro
inconveniente es que durante la ejecución, es necesario el intérprete en memoria por lo que consumen más
recursos.
Preprocesadores : Permiten modificar el programa fuente antes de la verdadera compilación. Hacen uso de
macroinstrucciones y directivas.
Ej:
//Uno.c
#include “Dos.c”
Void main( ) {
xxxx
xxxxxxx
}
PREPROCESADOR
//Dos.c
yyy
yyyy
//Uno.c
yyy
yyyy
void main( ) {
xxxx
xxxxxxx
}
COMPILADOR
El preprocesador sustituye la instrucción “#include Uno.c” por el código que tiene “Uno.c”, cuando el
compilador empieza se encuentra con el código ya incluido en el programa fuente.
Ejemplos de algunas directivas de procesador (Clipper, C): #fi, #ifdef, #define, #ifndef, #define, #include ...
que permiten compilar trozos de códigos opcionales.
Intérpretes de comandos : Lo que hace es traducir sentencias simples a llamadas a programas de una
biblioteca. Son especialmente utilizados por Sistemas Operativos.
Ej: El shell del DOS o del UNIX. Desencadenan la ejecución de programas que pueden estar residentes en
memoria o encontrarse en disco.
Preparado por Prof: Ing. Diego Casco
2
COMPILADORES I
Por ejemplo, si ponemos en MS-DOS el comando “copy” se ejecuta la función “copy” del sistema
operativo.
Ensambladores y Macroensambladores : Son los pioneros de los compiladores, ya que en los albores de
la informática, los programas se escribían directamente en código máquina, y los ensambladores establecen
una relación biunívoca entre cada instrucción y una palabra nemotécnica, de manera que el usuario escribe
los programas haciendo uso de los mnemotécnicos, y el ensamblador se encarga de traducirlo al código
máquina puro.
El lenguaje que utiliza se llama lenguaje ensamblador y tiene una correspondencia uno a uno entre sus
instrucciones y el código máquina.
Ej: Código máquina 65h.00h.01h
Ensamblador LD HL, #0100
- Macroensamblador : Hay ensambladores que tienen macroinstrucciones que se suelen traducir a varias
instrucciones máquinas, pues bien, un macroensamblador es un ensamblador con un preprocesador delante.
Conversores fuente - fuente : Pasan un lenguaje de alto nivel a otro lenguaje de alto nivel, para conseguir
mayor portabilidad.
Por ejemplo en un ordenador sólo hay un compilador de PASCAL, y queremos ejecutar un programa escrito
en COBOL; Un conversor COBOL –> PASCAL nos solucionaría el problema.
Compilador cruzado : Es un compilador que obtiene código para ejecutar en otra máquina. Se utilizan en la
fase de desarrollo de nuevos ordenadores.
Otros Conceptos Referido a Traductores
Compilar-linkar-ejecuta : Estas son las tres fases básicas de un computador. Nosotros nos
centraremos en la primera fase a lo largo de la asignatura.
* El compilador obtiene un código objeto, junto con una tabla de símbolos.
Archivo.fue
Archivo.obj
COMPILAR
* ¿Porqué no hace directamente un fichero ejecutable?
Para permitir la compilación separada, de manera que puedan fusionarse diversos ficheros OBJ en un solo
ejecutable.
* Un fichero OBJ es un fichero que posee una estructura de registros. Estos registros tienen longitudes
diferentes. Unos de estos registros tienen código máquina, otros registros van a tener información. También
incluye información sobre los objetos
externos. P.ej: Variables que están en otros ficheros declaradas (EXTERN)
Preparado por Prof: Ing. Diego Casco
3
COMPILADORES I
* El enlazador resuelve las referencias cruzadas, o externas, que pueden estar o en otros OBJ, o en librerías
LIB, y se encarga de generar el ejecutable final.
* Se obtiene un código reubicable, es decir, un código que en su momento se podrá ejecutar en diferentes
posiciones de memoria, según la situación de la misma en el momento de la ejecución.
Pasadas de compilación : Es el número de veces que se lee el programa fuente. Hay algunas situaciones
en las que, para realizar la compilación, no es suficiente con leer el fichero fuente una sola vez. Por ejemplo:
¿Que ocurre si tenemos una recursión indirecta?
A llama a B
B llama a A
Cuando se lee el cuerpo de A, no se sabe si B va a existir o no, y no se sabe su dirección de comienzo, luego
en una pasada posterior hay que rellenar estos datos.
* Para solucionar el problema
1.- Hacer dos pasadas de compilación.
2.- Hacer una sola pasada de compilación utilizando la palabra reservada
FORWARD.
FORWARD B( )
A( )
* Algunos compiladores dan por implícito el FORWARD. Si no encuentra aquello a que se hace referencia,
continúan, esperando que el linkador resuelva el problema, o emita el mensaje de error.
Compilación incremental: Es aquella que compila un programa en el que si después se descubren errores,
en vez de corregir el programa fuente y compilarlo por completo, se compilan solo las modificaciones. Lo
ideal es que solo se recompilen aquellas partes que contenían los errores, y que el código generado se
reinserte con cuidado en el OBJ generado cuando se encontraron los errores. Sin embargo esto es muy difícil.
Autocompilador: Es un compilador escrito en el mismo lenguaje que compila.
* Cuando se extiende entre muchas máquinas diferentes el uso de un compilador, y éste se desea mejorar,
el nuevo compilador se escribe con el antiguo, de manera que pueda ser compilado por todas esas máquinas
diferentes, y dé como resultado un compilador más potente de ese mismo lenguaje.
Metacompilador: Es un programa que acepta la descripción de un lenguaje y obtiene el compilador de dicho
lenguaje, es decir, acepta como entrada una gramática de un lenguaje y genera un autómata que reconoce
cualquier sentencia del lenguaje . A este autómata podemos añadirle código para realizar el compilador.
* Por ejemplo LEX y YACC, FLEX, Bison, JavaCC, PCCTS, MEDISE, etc.
* Unos metacompiladores pueden trabajar con gramáticas de contexto libre y otros trabajan con gramática
regular. Los que trabajan con gramáticas de contexto libre se dedican a reconocer la sintaxis del lenguaje y
los de gramática regular trocean la entrada y la dividen en palabras.
* El PCLEX es un metacompilador cuya función es generar un programa que es la parte del compilador que
reconoce las palabras reservadas y otros componentes léxicos.
* El PCYACC es un metacompilador cuya función es generar un programa que es la parte del compilador que
indica si una sentencia del lenguaje es válida o no (análisis sintáctico).
Preparado por Prof: Ing. Diego Casco
4
COMPILADORES I
Descompilador: Pasa de un código máquina (o programa de salida) al lenguaje que lo generó ( o programa
fuente). Cada descompilador trabaja con un lenguaje de alto nivel concreto.
* Es una operación casi imposible, porque al código máquina casi siempre se le aplica una optimización. Por
eso lo que hay suelen ser desensambladores, ya que existe una bisección entre cada instrucción máquina
y cada instrucción ensamblador.
* Se utilizan especialmente cuando el código máquina ha sido generado con opciones de depuración, y
contiene información adicional de ayuda a la depuración de errores ( puntos de ruptura, opciones de
visualización de variables, etc)
También se emplea cuando el compilador original no generó código máquina puro, sino pseudocódigo (para
ejecutarlo a través de un pseudointérprete)
Estructura de un Compilador
Un compilador se divide en dos fases : Una parte que analiza la entrada y genera estructuras intermedias y
otra parte que sintetiza la salida. En base a tales estructuras intermedias
El esquema de traductor es ahora
Fuente
ANÁLISIS
SÍNTESIS
Mensajes de Error
Básicamente los objetivos de la fase de Análisis son:
Preparado por Prof: Ing. Diego Casco
5
Destino
COMPILADORES I
* Controlar la corrección del programa fuente
* Generar estructuras necesarias para comenzar la síntesis.
Para llevar esto a cabo el Análisis consta de las siguientes tareas:
* Análisis Lexicográfico : Divide el programa fuente en los componentes básicos: números, identificadores
de usuario (variables, constantes, tipos, nombres de procedimientos,...), palabras reservadas, signos de
puntuación. A cada componente le asocia la categoría a la que pertenece.
* Análisis Sintáctico : Comprueba que la estructura de los componentes básicos sea correcta según ciertas
reglas gramaticales.
* Análisis semántico : Comprueba todo lo demás posible, es decir ,todo lo relacionado con el significado,
chequeo de tipos, rangos de valores, existencia de variables, etc.
* En cualquiera de los tres análisis puede haber errores.
El objetivo de la fase de síntesis consiste en:
* Construir el programa objeto deseado a partir de las estructuras generadas por la fase de análisis. Para ello
realiza tres tareas fundamentales.
* Generación de código intermedio : Genera un código independiente de la máquina.
Ventajas, es fácil hacer seudo compiladores y además facilita la optimización de código.
* Generación del código máquina : Crea un fichero ‘.exe’ directamente o un fichero ‘.obj’. Aquí también se
puede hacer optimización propia del microprocesador.
* Fase de optimización: La optimización puede realizarse durante las fases de generación de código
intermedio y/o generación de código máquina y puede ser una fase aislada de éstas, o estar integrada con
ellas.
La optimización del código intermedio debe ser independiente de la máquina.
El siguiente cuadro muestra un ejemplo de compilación de una sentencia de asignación, que incluye una
expresión aritmética:
Preparado por Prof: Ing. Diego Casco
6
COMPILADORES I
TRADUCCION DE UNA PROPOSICION
posicion = inicial + velocidad * 60
=
Analizador sintáctico
id1
Analizador
Semántico
+
id2
*
id3
Generador de
Código intermedio
60
temp1= entareal (60)
temp2 = id3 * temp1
temp3 = id2 + temp2
id1 = temp3
temp1 =id3 * 60.0
id1 = id2 + temp1
id1=id2 + id3num
*
Analizador léxico
Generador de Código
Optimizador deódigo
C
MOVF id3, R2
MULF #60.0, R2
MOVF id2, R1
ADDF R2, R1
MOVF R1, id1
En este esquema, se supone que el compilador realiza todas las tareas listadas:
La entrada fuente es la sentencia posicion = inicial + velocidad * 60
El análisis léxico separa la sentencia en sus componentes léxicos: id: es un terminal, en una gramática libre
de contexto, que representa a cualquier nombre o identificador de variable de memoria, presente en el
programa fuente. num: es un terminal, de la misma gramática, que representa a un número entero. La salida
del análisis léxico será la entrada para el análisis sintáctico.
Con frecuencia, las fases se agrupan en una etapa inicial (Front-End) y una etapa final (Back- End). La etapa
inicial comprende aquellas fases, o partes de fases que dependen principalmente del lenguaje fuente y que
son en gran parte independientes de la máquina objeto. Ahí normalmente se introducen los análisis léxicos y
sintácticos, la creación de la tabla de símbolos, el análisis semántico y la generación de código intermedio. La
etapa inicial también puede hacer cierta optimización de código e incluye además, el manejo de errores
correspondiente a cada una de esas fases.
La etapa final incluye aquellas partes del compilador que dependen de la máquina objeto y, en general,
esas partes no dependen del lenguaje fuente, sino sólo del lenguaje intermedio. En la etapa final, se
encuentran aspectos de la fase de optimización de código además de la generación de código, junto con el
manejo de errores necesario y las operaciones con la tabla de símbolos.
Se ha convertido en rutina el toma r la etapa inicial de un compilador y rehacer su etapa final asociada
para producir un compilador para el mismo lenguaje fuente en una máquina distinta. También resulta tentador
compilar varios lenguajes distintos en el mismo lenguaje intermedio y usar una etapa final común para las
distintas etapas iniciales, obteniéndose así varios compiladores para una máquina. Veamos ejemplos:
Preparado por Prof: Ing. Diego Casco
7
COMPILADORES I
Una función esencial de un compilador es registrar los identificadores utilizados en el programa fuente
y reunir información sobre los distintos atributos de cada identificador.
Preparado por Prof: Ing. Diego Casco
8
COMPILADORES I
Estos atributos pueden proporcionar información sobre la memoria asignada a un identificador, su
tipo, su ámbito (la parte del programa donde tiene validez),...
Tabla de símbolos : Posee información sobre los identificadores definidos por el usuario, ya sean constantes,
variables o tipos. Dado que puede contener información de diversa índole, debe hacerse de forma que no sea
uniforme. Hace funciones de diccionario de datos y su estructura puede ser una tabla hash, un árbol binario
de búsqueda, etc.
Esquema definitivo de un traductor
Fuente
ANÁLISIS
SÍNTESIS
Mensajes derror
E
Tabla de
Símbolos
Destino
Ejercicios
Capítulo de Introducción
Trabaja individualmente, y luego en forma grupal para completar el siguiente ejercitario.
1) Completa las definiciones de los siguientes términos
Traductores:
Compiladores:
Interpretes:
2) Menciona otros tipos de traductores y haz una breve explicación de cada uno.
Preparado por Prof: Ing. Diego Casco
9
COMPILADORES I
3) Escribe ejemplos conocidos de los traductores mencionados en el item 2
4) Explica el concepto de compilación incremental, con un programa fuente escrito en un hipotético
lenguaje Alfa.
5) Grafica estructuralmente las fases de un proceso de compilación, incluyendo una breve explicación
al costado de cada fase.
6) Las fases de un compilador pueden agruparse en dos etapas de construcción:
a)……………………………………. y b)……………………………………
La diferencia entre ambas etapas se refiere a que la etapa de …………………….., tiene en cuenta los
aspectos …………………………………………………………………………………..
…………………………....…………………………………………………………y
la
etapa
…………………………...., se basa en los aspectos,……………………….
……………………………………………………………………………………………………………………..
……………………………………………………………………………………………………………………..
Preparado por Prof: Ing. Diego Casco
10
COMPILADORES I
7) Si el siguiente gráfico se refiere a las etapas constructivas del compilador de un lenguaje de
programación, explica su significado.
Front-End 1
Front-End 2
Front-End 3
Back-End 1
Back-End 2
Back-End 3
Interpretación del gráfico:
8) Diferencia entre código fuente y código objeto.
9) En todos los casos los códigos objetos se encuentran en lenguaje de máquina. Justifica.
10) Qué utilidad ofrece la utilización de la tabla de símbolos en un proceso de compilación?
Preparado por Prof: Ing. Diego Casco
11
COMPILADORES I
2. Análisis Léxico
Este capítulo estudia la primera fase de un compilador, es decir su análisis lexicográfico, o más
concisamente análisis léxico. Las técnicas utilizadas para construir analizadores léxicos también se pueden
aplicar a otras áreas, como, por ejemplo, a lenguajes de consulta y sistemas de recuperación de información.
En cada aplicación, el problema de fondo es la especificación y diseño de programas que ejecuten las
acciones activadas por palabras que siguen ciertos patrones dentro de las cadenas a reconocer. Como la
programación dirigida por patrones es de mucha utilidad, se introduce un lenguaje de patrón-acción, llamado
LEX, para especificar los analizadores léxicos. En este lenguaje, los patrones se especifican por medio de
expresiones regulares, y un compilador de LEX puede generar un reconocedor de las expresiones regulares
mediante un autómata finito eficiente.
¿Que es un analizador léxico?
Se encarga de buscar los componentes léxicos o palabras que componen el programa fuente, según
unas reglas o patrones.
La entrada del analizador léxico podemos definirla como una secuencia de caracteres.
El analizador léxico tiene que dividir la secuencia de caracteres en palabras con significado propio y
después convertirlo a una secuencia de terminales desde el punto de vista del analizador sintáctico, que es la
entrada del analizador sintáctico.
El analizador léxico reconoce las palabras en función de una gramática regular de manera que sus
SENTENCIAS se convierten en los elementos de entrada de fases posteriores.
2.1 Funciones del analizador léxico
El analizador léxico es la primera fase de un compilador. Su principal función consiste en leer los
caracteres de entrada y elaborar como salida una secuencia de componentes léxicos que utiliza el analizador
sintáctico para hacer el análisis. Esta interacción, suele aplicarse convirtiendo al analizador léxico en una
subrutina o co-rutina del analizador sintáctico. Recibida la orden “Dame el siguiente componente léxico” del
analizador sintáctico, el analizador léxico lee los caracteres de entrada hasta que pueda identificar el siguiente
componente léxico.
Preparado por Prof: Ing. Diego Casco
12
COMPILADORES I
Fig. Interacción de la fase de Análisis Léxico con el Analizador Sintáctico
Otras funciones:
Como parte de la función de explorar el programa fuente, carácter por carácter, en esta fase también
es posible:
• Eliminar los comentarios del programa.
• Eliminar espacios en blanco, tabuladores, retorno de carro, etc, y en general, todo aquello que
carezca de significado según la sintaxis del lenguaje.
• Reconocer los identificadores de usuario, números, palabras reservadas del lenguaje, ..., y tratarlos
correctamente con respecto a la tabla de símbolos (solo en los casos que debe de tratar con la tabla de
símbolos).
• Llevar la cuenta del número de línea por la que va leyendo, por si se produce algún error, dar
información sobre donde se ha producido.
• Avisar de errores léxicos. Por ejemplo, si @ no pertenece al lenguaje, avisar de un error.
• Puede hacer funciones de pre-procesador.
2.2 Necesidad del Analizador Léxico
Un tema importante es el porqué se separan los dos análisis lexicográfico y sintáctico, en vez de
realizar sólo el análisis sintáctico, del programa fuente, cosa perfectamente posible aunque no plausible.
Algunas razones de esta separación son:
• Un diseño sencillo es quizás la consideración más importante. Separar el análisis léxico del análisis
sintáctico a menudo permite simplificar una u otra de dichas fases. El analizador léxico nos permite simplificar
el analizador sintáctico.
• Se mejora la eficiencia del compilador. Un analizador léxico independiente permite construir un
procesador especializado y potencialmente más eficiente para esa función.
Gran parte del tiempo se consume en leer el programa fuente y dividirlo en componentes léxicos. Con
técnicas especializadas de manejo de buffers para la lectura de caracteres de entrada y procesamiento de
componentes léxicos se puede mejorar significativamente el rendimiento de un compilador.
• Se mejora la portabilidad del compilador. Las peculiaridades del alfabeto de entrada y otras
anomalías propias de los dispositivos pueden limitarse al analizador léxico. La representación de símbolos
especiales o no estándares, como _ en Pascal, pueden ser aisladas en el analizador léxico.
• Otra razón por la que se separan los dos análisis es para que el analizador léxico se centre en el
reconocimiento de componentes básicos complejos. Por ejemplo en FORTRAN, existen el siguiente par de
proposiciones:
Preparado por Prof: Ing. Diego Casco
13
COMPILADORES I
DO 5 I = 2.5 (Asignación de 2.5 a la variable DO5I)
DO 5 I = 2,5 (Bucle que se repite para I = 2, 3, 4, 5)
En éste lenguaje los espacios en blancos no son significativos fuera de los comentarios y de un cierto
tipo de cadenas, de modo que supóngase que todos los espacios en blanco eliminables se suprimen antes
de comenzar el análisis léxico. En tal caso, las proposiciones anteriores aparecerían al analizador léxico como
DO5I = 2.5
DO5I = 2,5
El analizador léxico no sabe si DO es una palabra reservada o es el prefijo de una variable hasta que
llegue a la coma. El analizador ha tenido que mirar más allá de la propia palabra a reconocer haciendo lo que
se denomina lookahead (o prebúsqueda).
Componentes léxicos, patrones y lexemas
Cuando se menciona el análisis sintáctico, los términos “componente léxico”(token), “patrón” y “lexema” se
emplean con significados específicos. En el cuadro de abajo, aparecen ejemplos de dichos usos. En general,
hay un conjunto de cadenas en la entrada para el cual se produce como salida el mismo componente léxico.
Este conjunto de cadenas se describe mediante una regla llamada patrón asociado al componente léxico. Se
dice que el patrón concuerda con cada cadena del conjunto. Un lexema es una secuencia de caracteres en
el programa fuente con la que concuerda el patrón para un componente léxico. Por ejemplo, en la proposición
de Pascal const pi = 3.1416;
La subcadena pi es un lexema para el componente léxico “identificador”.
Componente Léxico
Lexemas de ejemplo
Descripción Informal del patrón
const
const
const palabra reservada
if
if
if palabra reservada
relación
<,<=,=,<>,>, >=
< o <= o = o <> o > o >=
id
pi, cuenta, D2
Letra seguida de letras y digitos
num
3.1416, 0 , 6.02E23
Cualquier constante numérica
literal
“vaciado de pila”
Cualquier carácter entre “ y “,
excepto
Los componentes léxicos se tratan como símbolos terminales de la gramática del lenguaje fuente. Los
lexemas para el componente léxico que concuerdan con el patrón representan cadenas de caracteres en el
programa fuente que se pueden tratar juntos como una unidad léxica.
En la mayoría de los lenguajes de programación, se consideran componentes léxicos las siguientes
construcciones: palabras clave, operadores, identificadores, constantes, cadenas literales y signos de
puntuación, como paréntesis, coma y punto y coma.
Un patrón es una regla que describe el conjunto de lexemas que pueden representar a un determinado
componente léxico en los programas fuente. El patrón para el componente léxico const, de la tabla anterior,
es simplemente la cadena sencilla const. El patrón para el componente léxico relación es el conjunto de los
seis operadores relacionales de Pascal. Para describir con precisión los patrones para componentes léxicos
más complejos, como id (para identificador) y num(para número), se utilizará la notación de expresiones
regulares.
Preparado por Prof: Ing. Diego Casco
14
COMPILADORES I
2.3 Atributos de los componentes léxicos
Cuando concuerda con un lexema más de un patrón, el analizador léxico debe proporcionar
información adicional sobre el lexema concreto que concordó con las siguientes fases del compilador. Por
ejemplo, el patrón núm concuerda con las cadenas 0 y 1, pero es indispensable que el generador de código
conozca qué cadena fue realmente la que se emparejó.
El analizador léxico recoge información sobre los componentes léxicos en sus atributos asociados.
Los componentes léxicos influyen en las decisiones del análisis sintáctico, y los atributos, en la traducción de
los componentes léxicos.
Ejemplo. Los componentes léxicos y los valores de atributos asociados para la proposición de FORTRAN.
E = M * C ** 2
Se escriben a continuación como una secuencia de parejas:
<id, apuntador a la entrada de la tabla de símbolos para E>
<op_asign>
<id, apuntador a la entrada de la tabla de símbolos para M>
<op_multip>
< id, apuntador a la entrada de la tabla de símbolos para C>
<op_exp>
<num, valo entero 2>
2.4 Errores léxicos
Son pocos los errores que se pueden detectar simplemente en el nivel léxico porque un analizador
léxico tiene una visión muy restringida de un programa fuente. Si aparece la cadena fi por primera vez en un
programa C en el contexto fi ( a == f(x))… un analizador léxico no puede distinguir si fi es un error de escritura
de la palabra clave if o si es un
identificador de función no declarado. Como fi es un identificador válido, el analizador léxico debe devolver el
componente léxico de un identificador y dejar que alguna otra fase del compilador se ocupe de los errores.
Pero, supóngase que surge una situación en la que el analizador léxico no puede continuar porque
ninguno de los patrones concuerda con un prefijo de la entrada restante.
2.5 Un lenguaje para la especificación de Analizadores Lexicos
Se han desarrollado algunas herramientas para construir analizadores léxicos a partir de notaciones
de propósito especial basadas en expresiones regulares. Ya se ha estudiado el uso de expresiones regulares
en la especificación de patrones de componentes léxicos. Antes de considerar los algoritmos para compilar
expresiones regulares en programas de concordancia de patrones, se da un ejemplo de una herramienta que
pueda ser utilizada por dicho algoritmo.
En esta sección se describe una herramienta concreta, llamada LEX, muy utilizada en la
especificación de analizadores léxicos para varios lenguajes. Esa herramienta se denomina compilador LEX,
y la especificación de su entrada, lenguaje LEX.
Preparado por Prof: Ing. Diego Casco
15
COMPILADORES I
Esquema para creación de un analizador léxico con LEX
Programa fuente en LEX
Prog1.l
Compilador
prog1. c
de LEX
prog1.c
prog1.exe
Archivo de entrada
Compilador de C
prog1.exe
Secuencia de componentes léxicos
Especificaciones en LEX
Un programa en LEX consta de tres partes: declaraciones
%%
reglas de traducción
%%
procedimientos auxiliares
La sección de declaraciones incluye declaraciones de variables, constantes manifiestas y definiciones
regulares ( Una constante manifiesta es un identificador que se declara para representar una constante).
Las reglas de traducción de un programa en LEX son proposiciones de la forma p1
{acción 1} p2
{acción 2}
…
…
pn
{acción n}
donde pi es una expresión regular y cada acción es un fragmento de programa que describe cuál ha de ser
la acción del analizador léxico cuando el patrón pi concuerda con un lexema. En LEX, las acciones se esriben
en C, en general, sin embargo, pueden estar en cualquier lenguaje de implantación.
La tercera sección contiene todos los procedimientos auxiliares que puedan necesitar las acciones. A veces,
estos procedimientos se pueden compilar por separado y cargar con el analizador léxico.
Un analizador léxico creado por LEX se comporta en sincronía con un analizador sintáctico como sigue.
Cuando es activado por el analizador sintáctico, el analizador léxico comienza a leer su entrada restante, un
carácter a la vez, hasta que encuentre el mayor prefijo de la entrada que concuerde con una de las
expresiones regulares pi. Entonces, ejecuta acción i. Generalmente, acción i devolverá el control al analizador
sintáctico. Sin embargo, si no lo hace, el analizador léxico se dispone a encontrar más lexemas, hasta que
una acción hace que el control regrese al analizador sintáctico. La búsqueda repetida de lexemas hasta
Preparado por Prof: Ing. Diego Casco
16
COMPILADORES I
encontrar una instrucción return explícita permite al analizador léxico procesar espacios en blanco y
comentarios de manera apropiada.
El analizador léxico devuelve una única cantidad, el componente léxico, al analizador sintáctico. Para pasar
un valor de atributo con la información del lexema, se puede asignar una variable global llamada yylval.
Expresiones del lex
Una expresión especifica un conjunto de literales que se van a comparar.
Esta contiene caracteres de texto (que coinciden con los caracteres correspondientes del literal que
se está comparando) y caracteres operador (estos especifican repeticiones, selecciones, y otras
características). Las letras del alfabeto y los dígitos son siempre caracteres de texto. Por lo tanto, la expresión
integer coincide con el literal “integer” siempre que éste aparezca y la expresión a57d busca el literal a57d.
Los caracteres operadores son:
“\[]^-?.*+|()$/{}%<>
Si cualquiera de estos caracteres se va a usar literalmente, es necesario incluirlos individualmente
entre caracteres barra invertida ( \ ) o como un grupo dentro de comillas ( “ ).
El operador comillas ( “ ) indica que siempre que esté incluido dentro de un par de comillas se va a
tomar como un carácter de texto. Por lo tanto xyz“++”
coincide con el literal xyz++ cuando aparezca. Nótese que una parte del literal puede estar entre
comillas. No produce ningún efecto y es innecesario poner entre comillas caracteres de texto normal; la
expresión
“xyz++”
es la misma que la anterior. Por lo tanto poniendo entre comillas cada carácter no alfanumérico que se está
usando como carácter de texto, no es necesario memorizar la lista anterior de caracteres operador. Un
carácter operador también se puede convertir en un carácter de texto poniéndole delante una barra invertida
( \ ) como en xyz\+\+ el cual, aunque menos legible, es otro equivalente de las expresiones anteriores.
Este mecanismo también se puede usar para incluir un espacio en blanco dentro de una expresión;
normalmente, según se explicaba anteriormente, los espacios en blanco y los tabuladores terminan una orden.
Cualquier carácter en blanco que no esté contenido entre corchete tiene que ponerse entre comillas.
Se reconocen varios escapes C normales con la barra invertida ( \ ):
\ n newline
\ t tabulador
\ b backspace
\ \ barra invertida
Puesto que el carácter newline es ilegal en una expresión, es necesario usar n; no se requiere dar escape al
carácter tabulador y el backspace. Cada carácter excepto el espacio en blanco, el tabulador y el newline y la
lista anterior es siempre un carácter de texto.
Especificación de clases de caracteres.
Las clases de caracteres se pueden especificar usando corchetes: [y]. La construcción
[ abc ]
coincide con cualquier carácter, que pueda ser una a, b, o c. Dentro de los corchetes, la mayoría de
los significados de los operadores se ignoran. Sólo tres caracteres son especiales: éstos son la barra invertida
( \ ), el guión ( - ), y el signo de intercalación ( ^ ). El carácter guión indica rangos, por ejemplo
[ a-z0-9<>_ ]
indica la clase de carácter que contiene todos los caracteres en minúsculas, los dígitos, los ángulos
Preparado por Prof: Ing. Diego Casco
17
COMPILADORES I
y el subrayado. Los rangos se pueden especificar en cualquier orden. Usando el guión entre cualquier par de
caracteres que ambos no sean letras mayúsculas, letras minúsculas, o dígitos, depende de la implementación
y produce un mensaje de aviso. Si se desea incluir el guión en una clase de
caracteres, éste deberá ser el primero o el último; por lo tanto
[ -+0-9 ] coincide con todos los dígitos y los signos más y
menos.
En las clases de caracteres, el operador ( ^ ) debe aparecer como el primer carácter después del
corchete izquierdo; esto indica que el literal resultante va a ser complementado con respecto al conjunto de
caracteres del ordenador. Por lo tanto
[ ^abc ]
coincide con todos los caracteres excepto a, b, o c, incluyendo todos los caracteres especiales o de
control; o [ ^a-zA-Z ] es cualquier carácter que no sea una letra. El carácter barra invertida ( \ ) proporciona un
mecanismo de escape dentro de los corchete de clases de caracteres, de forma que éstos se pueden
introducir literalmente precediéndolos con este carácter.
Especificar expresiones opcionales.
El operador signo de interrogación ( ? ) indica un elemento opcional de una expresión. Por lo tanto
ab?c coincide o con ac o con abc. Nótese que aquí el significado del signo de interrogación difiere
de su significado en la shell.
Especificación de expresiones repetidas.
Las repeticiones de clases se indican con los operadores asterisco ( * ) y el signo más ( + ). Por
ejemplo a*
coincide con cualquier número de caracteres consecutivos, incluyendo cero; mientras que a+
coincide con una o más apariciones de a. Por ejemplo,
[ a-z ]+ coincide con todos los literales de letras
minúsculas, y
[ A-Za-z ] [A-Za-z0-9 ]*
coincide con todos los literales alfanuméricos con un carácter alfabético al principio; ésta es una
expresión típica para reconocer identificadores en lenguajes informáticos.
Especificación de alternación y de agrupamiento.
El operador barra vertical ( | ) indica alternación. Por ejemplo
( ab|cd )
coincide con ab o con cd. Nótese que los paréntesis se usan para agrupar, aunque éstos no son
necesarios en el nivel exterior. Por ejemplo ab
| cd
hubiese sido suficiente en el ejemplo anterior. Los paréntesis se deberán usar para expresiones
más complejas, tales como ( ab | cd+ )?( ef )* la cual coincide con tales literales como abefef, efefef,
cdef, cddd, pero no abc, abcd, o abcdef.
Especificación de sensitividad de contexto
El lex reconoce una pequeña cantidad del contexto que le rodea. Los dos operadores más simples
para éstos son el signo de intercalación ( ^ ) y el signo de dólar ( $ ). Si el primer carácter de una expresión
es un signo ^, entonces la expresión sólo coincide al principio de la línea (después de un carácter newline, o
al principio del input). Esto nunca se puede confundir con el otro significado del signo ^, complementación de
las clases de caracteres, puesto que la complementación sólo se aplica dentro de corchetes. Si el último
carácter es el signo de dólar, la expresión sólo coincide al final de una línea (cuando va seguido
inmediatamente de un carácter newline). Este último operador es un caso especial del operador barra ( / ) , el
cual indica contexto al final.
La expresión ab/cd
coincide con el literal ab, pero sólo si va seguido de cd. Por lo tanto
ab$ es lo mismo que
ab/\n
Preparado por Prof: Ing. Diego Casco
18
COMPILADORES I
Especificación de repetición de expresiones.
Las llaves ( { y } ) especifican o bien repeticiones ( si éstas incluyen números) o definición de expansión (si
incluyen un nombre). Por ejemplo
{dígito} busca un literal predefinido llamado dígito y lo inserta en la expresión, en ese
punto.
Especificar definiciones.
Las definiciones se dan en la primera parte del input del lex, antes de las órdenes. En contraste,
a{1,5} busca de una a cinco apariciones del carácter “a”.
Finalmente, un signo de tanto por ciento inicial ( % ) es especial puesto que es el separador para los
segmentos fuente del lex.
Especificación de acciones.
Cuando una expresión coincide con un modelo de texto en el input el lex ejecuta la acción
correspondiente. Esta sección describe algunas características del lex, las cuales ayudan a escribir acciones.
Nótese que hay una acción por defecto, la cual consiste en copiar el input en el output. Esto se lleva a cabo
en todos los literales que de otro modo no coincidirían. Por lo tanto el usuario del lex que desee absorber el
input completo, sin producir ningún output, debe proporcionar órdenes para hacer que coincida todo. Cuando
se está usando el lex con el yacc, ésta es la situación normal. Se puede tener en cuenta qué acciones son las
que se hacen en vez de copiar el input en el output; por lo tanto, en general, una orden que simplemente copia
se puede omitir.
Una de las cosas más simples que se pueden hacer es ignorar el input.
Especificar una sentencia nula de C; como una acción produce este resultado.
La orden frecuente es
[ \ t \ n] ;
la cual hace que se ignoren tres caracteres de espaciado (espacio en blanco, tabulador, y newline).
Otra forma fácil de evitar el escribir acciones es usar el carácter de repetición de acción, | , el cual
indica que la acción de esta orden es la acción para la orden siguiente. El ejemplo previo también se podía
haber escrito:
“”|
“\ t” |
“\ n” ;
con el mismo resultado, aunque en un estilo diferente. Las comillas alrededor de
\ny\t
no son necesarias.
En acciones más complejas, a menudo se quiere conocer el texto actual que coincida con algunas expresiones
como:
[ a-z ] +
El lex deja este texto en una matriz de caracteres externos llamada yytext. Por lo tanto, para imprimir
el nombre localizado, una orden como [ a-z ] + printf (“%s” , yytext); imprime el literal de yytext. La función C
printf acepta un argumento de formato y datos para imprimir; en este caso , el formato es print literal donde el
signo de tanto por ciento ( % ) indica conversión de datos, y la s indica el tipo de literal, y los datos son los
caracteres de yytext. Por lo tanto esto simplemente coloca el literal que ha
coincidido en el output. Esta acción es tan común que se puede escribir como ECHO. Por ejemplo
[ a-z ]+ ECHO;
Preparado por Prof: Ing. Diego Casco
19
COMPILADORES I
es lo mismo que el ejemplo anterior. Puesto que la acción por defecto es simplemente imprimir los caracteres
que se han encontrado, uno se puede preguntar ¿Porqué especificar una orden, como ésta, la cual
simplemente especifica la acción por defecto? Tales órdenes se requieren a menudo para evitar la
coincidencia con algunas otras órdenes que no se desean. Por ejemplo, si hay una orden que coincide con
“read”, ésta normalmente coincidirá con las apariciones de “read” contenidas en “bread” o en “readjust”; para
evitar esto, una orden de la forma
[ a-z ] +
es necesaria. Esto se explica más ampliamente a continuación.
A veces es más conveniente conocer el final de lo que se ha encontrado; aquí el lex también
proporciona un total del número de caracteres que coinciden en la variable yyleng. Para contar el número de
palabras y el número de caracteres en las palabras del input, será necesario escribir
[ a-zA-Z ] + {words++ ; chars += yyleng;}
lo cual acumula en las variables chars el número de caracteres que hay en las
palabras reconocidas. Al último carácter del literal que ha coincidido se puede
acceder por medio de
yytext[ yyleng - 1]
La acción REJECT quiere decir, “ ve y ejecuta la siguiente alternativa”. Esto hace que se ejecute
cualquiera que fuese la segunda orden después de la orden en curso. La posición del puntero de input se
ajusta adecuadamente. Suponga que el usuario quiere realmente contar las apariciones incluidas en “she”:
she { s++; REJECT;}
he { h++; REJECT;}
\n|
.;
Estas órdenes son una forma de cambiar el ejemplo anterior para hacer justamente eso. Después de
contar cada expresión, ésta se desecha; siempre que sea apropiado, la otra expresión se contará. En este
ejemplo, naturalmente, el usuario podría tener en cuenta que she incluye a he, pero no viceversa, y omitir la
acción REJECT en he; en otros casos, no sería posible decir qué caracteres de input estaban en ambas
clases.
Considere las dos órdenes
a [ bc ] + { ... ; REJECT;} a
[ cd ] + { ... ; REJECT;}
Si el input es ab, sólo coincide la primera orden, y en ad sólo coincide la segunda. La cadena de
caracteres del input accb, cuatro caracteres coinciden con la primera orden, y después la segunda orden con
tres caracteres. En contrate con esto, el input accd coincide con la segunda orden en cuatro caracteres y
después la primera orden con tres.
En general, REJECT es muy útil cuando el propósito de lex no es dividir el input, sino detectar todos
los ejemplares de algunos items del input, y las apariciones de estos items pueden solaparse o incluirse uno
dentro de otro.
Suponga que se desea una tabla diagrama del input; normalmente los diagramas se solapan, es decir,
la palabra “the” se considera que contiene a th y a he.
Asumiendo una matriz bidimensional llamada digram que se va a incrementar, el fuente apropiado es
%%
Preparado por Prof: Ing. Diego Casco
20
COMPILADORES I
[ a-z ] [ a-z ] {digram[yytext[0]] [yytext[1]] ++;
REJECT; }
.;
\n;
donde el REJECT es necesario para tomar un par de letras que comienzan en cada carácter, en vez de en un
carácter si y otro no.
Recuerde que REJECT no vuelve a explorar el input. En vez de esto recuerda los resultados de la
exploración anterior. Esto quiere decir que si se encuentra una orden con un contexto, y se ejecuta REJECT,
no debería haber usado unput para cambiar los caracteres que vienen del input. Esta es la única restricción
de la habilidad de manipular el input que aún no ha sido manipulado.
Ejercicios de Programas Lex
1- Cuenta cantidad de letras y números encontrados
2- imprime OK cada vez que encuentre la palabra automata
3- imprime Cantidad de lineas que terminan con a o con o
4-imprime Cantidad de cadenas que tienen de 2 a 3 "a"
5- imprime Cantidad de palabras con letras minúsculas y números enteros*/
6- Reconoce tres palabras reservadas, identificadores y numeros enteros y reales*/
7- Reconoce tres palabras reservadas, identificadores y numeros enteros y reales. En cada caso imprime la
cadena encontrada. Ademas de la cadena, imprimir la línea en la que se encuentra*/
Ejercitario
Trabaja individualmente y luego en grupo, para completar lo siguiente :
Preparado por Prof: Ing. Diego Casco
21
Apuntes de Compiladores I
1) Describe la principal función de la fase de Análisis Léxico
2) Explica las demás funciones de del analizador Léxico
3) Por qué se afirma que el analizador léxico, es una subrutina del analizador sintáctico?
4) Con respecto al item anterior, grafica un esquema que represente este trabajo coordinado entre las
dos fases mencionadas.
5) Explica tres razones por los cuales es conveniente construir un analizador léxico, para el compilador.
6) Explica que diferencia existe entre los conceptos de lexemas y componentes léxicos o tokens
22
Compiladores I
7) Cómo se relacionan los componentes léxicos y los patrones?
8) Escribe un ejemplo de lexemas, patrones y componentes léxicos, para los siguientes casos:
identificadores, números enteros, una palabra reservada, un operador matemático.
9) Escribe y explica dos ejemplos de errores léxicos que puede tener un programa fuente.
Los siguientes ejercicios son para desarrollarlos en máquina
10) Modifica el programa prog1.l, del material, de tal manera que informe la cantidad de símbolos
encontrados, en el programa de entrada, correspondientes a cada uno de los digitos numéricos. Es
decir cantidad de 0, cantidad de 1, cantidad de 2, etc.
11) Escribe programas lex para :
a. Un Procesador de texto que corrija uso de mayúsculas después de un punto, cuente cantidad
de líneas del texto, separe con una línea en blanco dos párrafos, y otras 3 funciones más a
agregar.
b. Un scanner que lea archivos de textos con números romanos y los convierta al sistema
numérico decimal, ignorando los valores incorrectos. Los números romanos deben estar
separados por un espacio delante y detrás, con excepción de los inician una línea o los que
terminan la línea.
Preparado por Prof. Ing. Diego Casco
23
Compiladores I
c.
Un scanner que lea archivos de textos con numeros binarios de longitud 8. y convierta, al
sistema numérico decimal y hexadecimal. Los números binarios deben estar separados por
un espacio delante y detrás, con excepción de los inician una línea o los que terminan la línea.
d. Un scanner que lea archivos de textos y corrija errores ortograficos como : n antes de b, v
despues de m, uso de h intermedia en 5 palabras, 5 palabras que empiecen con h y que en
el texto no se haya escrito correctamente, puntuación de coma o punto y coma que debe
escribirse a continuación de la letra de la palabra anterior, sin espacio intermedio.
12) Escribe un programa lex que busque las palabras reservadas mientras, para, y numeros reales que
pueden no tener ningún digito como parte entero, pero si al menos un cero en la parte decimal,
utilizando la coma como separador entre ambos. Si se detecta una cadena que no corresponda a
ninguno de estos tokens, imprimir un mensaje de error, y continuar el análisis.
13) Con el uso de LEX escribe programas que generen los autómatas finitos para las siguientes
expresiones regulares :
13.1
13.2
( a+ ( b* | 2) + | b)
(a|b) (5* 4+ | abc) (d |e )
14) Con el uso de LEX escribe programas que generen los autómatas finitos para los lenguajes de las
siguientes gramáticas regulares :
G( Z )
Z -> Z 0 | Z 1 | P 0
P -> P 0 | T 1 | 0
T -> Z 1 | P 1 | T 1 | 0 | 1
VT= { 0, 1}
G( P )
P -> b | a | P a | F b
F -> F b | E b | E a | b
E -> E b | a | F a | F b
VT={a, b}
3. Análisis sintáctico
3.1 Introducción
El análisis sintáctico (parser en inglés) recibe la cadena de tokens, que le envía el analizador léxico y
comprueba si con ellos se puede formar alguna sentencia válida generada por la gramática del lenguaje
fuente.
La sintaxis de los lenguajes de programación habitualmente se describe mediante gramáticas libres
de contexto.
3.2 Funciones del analizador sintáctico
• Comprobar si la cadena de tokens proporcionada por el analizador léxico puede se generada por la
gramática que define el lenguaje fuente ( GLC)
•
Construir el árbol de análisis sintáctico que define la estructura jerárquica de un programa y obtener
la serie de derivaciones para generar la cadena de tokens. El árbol sintáctico, puede ser utilizado
como representación intermedia en la generación de código.
Preparado por Prof. Ing. Diego Casco
24
Compiladores I
•
Informar de los errores sintácticos de forma precisa y significativa. Deberá contener un mecanismo de
recuperación de errores para continuar con el análisis.
token
programa
fuente
Analizador
Léxico
Obtener
siguiente
componente
léxico
Analizador
Sintáctico
Árbol de
análisis
sintáctico
Resto del
Front-End
Representación
intermedia
Gestor de
errores
Tabla de
Símbolos
Fig. Posición del analizador sintáctico en el modelo del Compilador
El análisis sintáctico se puede considerar como una función que toma como entrada la secuencia de
componentes léxicos (tokens) producida por el análisis léxico y produce como salida el árbol sintáctico. En
realidad, el análisis sintáctico hace una petición al análisis léxico del token siguiente en la entrada (terminales)
conforme lo va necesitando en el proceso de análisis.
En la práctica, el analizador sintáctico también hace:
•
Acceder a la tabla de símbolos (para hacer parte del trabajo del analizador semántico).
•
Chequeo de tipos ( del analizador semántico).
•
Generar código intermedio.
•
Generar errores cuando se producen.
En definitiva, realiza casi todas las operaciones de la compilación. Este método de trabajo da
lugar a los métodos de compilación dirigidos por sintaxis.
3.3 Manejo de errores sintácticos
Si un compilador tuviera que procesar sólo programas correctos, su diseño e implantación se
simplificarían mucho.
•
El manejador de errores en un analizador sintáctico tiene objetivos fáciles de establecer:
•
Debe informar de la presencia de errores con claridad y exactitud
•
Se debe recuperar de cada error con la suficiente rapidez como para detectar errores posteriores.
•
No debe retrasar de manera significativa el procesamiento de programas correctos.
3.4 Estrategias de recuperación de errores
Preparado por Prof. Ing. Diego Casco
25
Compiladores I
Recuperación en modo de pánico: Es el método más sencillo de implantar. Consiste en ignorar el resto de
la entrada hasta llegar a una condición de seguridad. Una condición tal se produce cuando nos encontramos
un token especial (por ejemplo un ‘;’ o un ‘END’).A partir de este punto se sigue analizando normalmente.
Recuperación a nivel de frase: Intenta recuperar el error una vez descubierto. En el caso anterior, por
ejemplo, podría haber sido lo suficientemente inteligente como para insertar el token ‘;’ . Hay que tener cuidado
con este método, pues puede dar lugar a recuperaciones infinitas.
Reglas de producción adicionales para el control de errores: La gramática se puede aumentar con las
reglas que reconocen los errores más comunes. En el caso anterior, se podría haber puesto algo como:
Lo cual nos da mayor control en ciertas circunstancias
Corrección Global : Dada una secuencia completa de tokens a ser reconocida, si hay algún error por el que
no se puede reconocer, consiste en encontrar la secuencia completa más parecida que sí se pueda reconocer.
Es decir, el analizador sintáctico le pide toda la secuencia de tokens al léxico, y lo que hace es devolver lo
más parecido a la cadena de entrada pero sin errores, así como el árbol que lo reconoce.
3.4 Tipo de gramática que acepta un analizador sintáctico
Nosotros nos centraremos en el análisis sintáctico para lenguajes basados en gramáticas formales, ya
que de otra forma se hace muy difícil la comprensión del compilador, y se pueden corregir, quizás más
fácilmente, errores de muy difícil localización, como es la ambigüedad en el reconocimiento de ciertas
sentencias.
La gramática que acepta el analizador sintáctico es una gramática de contexto libre:
Gramática : G (N, T, P, S) N = No terminales.
T = Terminales.
P = Reglas de Producción.
S = Axioma Inicial.
Preparado por Prof. Ing. Diego Casco
26
Compiladores I
3.6 Tipos de Análisis
De la forma de construir el árbol sintáctico se desprenden dos tipos o clases de analizadores
sintácticos. Pueden ser descendentes o ascendentes.
Descendentes: Parten del axioma inicial, y van efectuando derivaciones a izquierda hasta obtener la
secuencia de derivaciones que reconoce a la sentencia.
Pueden ser:
_ Con retroceso. _
Con recursión.
_ LL(1)
Ascendentes: Parten de la sentencia de entrada, y van aplicando reglas de producción hacia atrás (desde el
consecuente hasta el antecedente), hasta llegar al axioma inicial.
Pueden ser:
_ Con retroceso.
_ LR(1)
4 Análisis Descendente Predictivo No-Recursivo
4.1 Consideraciones Previas
Como se mencionó anteriormente, el método de construcción de una analizador sintáctico
descendente, implica considerar que éste verificará la cadena de tokens, construyendo el árbol sintáctico en
forma descendente. Por los conocimientos de la teoría de Lenguajes y Autómatas, sabemos que esto implica
una secuencia de operaciones de derivaciones desde el Axioma de la gramática, raíz del árbol, hasta las hojas
del mismo (sentencia).
Preparado por Prof. Ing. Diego Casco
27
Compiladores I
En este proceso de construcción del árbol, pueden surgir los siguientes inconvenientes:
4.2 El problema del retroceso
El primer problema que se presenta con el análisis sintáctico descendente, es que a partir del nodo
raíz el analizador sintáctico no elija las producciones adecuadas para alcanzar la sentencia a reconocer.
Cuando el analizador se da cuenta de que se ha equivocado de producción, se tienen que deshacer las
producciones aplicadas hasta encontrar otras producciones alternativas, volviendo a tener que reconstruir
parte del árbol sintáctico. A este fenómeno se le denomina retroceso, vuelta a atrás o en inglés backtracking.
El proceso de retroceso puede afectar a otros módulos del compilador tales como la tabla de símbolos
, código generado, interpretación, etc. teniendo que deshacerse también los procesos desarrollados en estos
módulos.
Ejemplo de retroceso : Sea la gramática G(<PROGRAMA>)
Se desea analizar la sentencia module d ; d ; p ; p end
A continuación se construye el árbol sintáctico de forma descendente:
1. Se parte del símbolo inicial <PROGRAMA>
2. Aplicando la primera regla de producción de la gramática se obtiene:
3. Aplicando las derivaciones más a la izquierda, se tiene que :
3.1 module es un terminal, que coincide con el primero de la cadena a reconocer
3.2 se deriva <DECLARACIONES> con la primera alternativa (consecuente).
Preparado por Prof. Ing. Diego Casco
28
Compiladores I
Se observa que el siguiente terminal generado, tampoco coincide con el token de la cadena de entrada.
Entonces el analizador sintáctico debe volver atrás, hasta encontrar la última derivación de un no terminal, y
comprobar si tiene alguna alternativa más. En caso afirmativo se debe de elegir la siguiente y probar. En caso
negativo, volver más atrás para probar con el no terminal anterior. Este fenómeno de vuelta atrás es el que se
ha definido anteriormente como retroceso.
Llegamos a este punto, también se debe de retroceder en la cadena de entrada hasta la primera d, ya
que en el proceso de vuelta atrás lo único valido que nos ha quedado del árbol ha sido el primer token module.
Si se deriva <DECLARACIONES> nuevamente con la primera alternativa, se tiene
Preparado por Prof. Ing. Diego Casco
29
Compiladores I
En este momento se tienen reconocidos los primeros 5 tokens de la cadena.
Se deriva el no terminal <PROCEDIMIENTOS>, con la primera alternativa, y el árbol resultante se
muestra a continuación. Se ha reconocido el sexto token de la cadena.
El siguiente token de la cadena es ; mientras que en el árbol se tiene end, por lo tanto habrá de volver
atrás hasta el anterior no terminal, y mirar si tiene alguna otra alternativa.
El último no terminal derivado es <PROCEDIMIENTOS>, si se deriva con su otra alternativa se tiene
el árbol mostrado a continuación. Con esta derivación se ha reconocido la parte de la cadena de entrada
module d ; d; p;
Se deriva <PROCEDIMIENTOS> con la primera alternativa y se obtiene el siguiente árbol sintáctico,
reconociéndose module d; d; p; p …
El árbol sintáctico ya acepta el siguiente token de la cadena (‘end’) y por tanto la cadena completa.
Preparado por Prof. Ing. Diego Casco
30
Compiladores I
Puede concluirse, que los tiempos de reconocimiento de sentencias de un lenguaje pueden dispararse
a causa del retroceso, por lo tanto los analizadores sintácticos deben eliminar las causa que producen el
retroceso.
4.3 Análisis Descendente sin retroceso
Para eliminar el retroceso en el análisis descendente, se ha de elegir correctamente la regla
correspondiente a cada no terminal que se deriva. Es decir que el análisis descendente debe ser determinista,
y solo se debe tomar una opción en la derivación de cada no terminal.
A este tipo de análisis se le denomina LL(K). La primera L representa la forma de exploración de la
sentencia : de Izquierda a Derecha ( L : left to right). La segunda L por la forma de construcción del árbol
(descendente), utilizando la derivación del no terminal más a la izquierda, en cada paso (L : left most). El
símbolo K representa la cantidad de tokens de la sentencia, que se tendrán en cuenta, para que el analizador
sepa, predictivamente, que regla aplicar. Generalmente K = 1.
4.3.1
Gramáticas LL(1)
Las gramáticas LL(1) son un subconjunto de las gramáticas libres de contexto. Permiten un análisis
descendente determinista ( sin retroceso ).
Las gramáticas LL(1), como ya se mencionó, permiten construir un analizador determinista
descendente con tan solo examinar en cada momento el símbolo actual de la cadena de entrada ( símbolo de
preanálisis) para saber que producción aplicar.
Una gramática LL(1) debe cumplir con las siguientes condiciones :
Debe ser una gramática limpia ( como toda gramática formal )
No debe ser ambigua
No debe tener recursividades por izquierda, en sus reglas
Por lo tanto, previo a la construcción de un analizador sintáctico LL(1), deberá tratarse la gramática
libre de contexto de manera que cumpla con las condiciones señaladas.
4.3.2
Gramáticas limpias
Las gramáticas de los lenguajes de programación están formadas por un conjunto de reglas, cuyo
número suele ser bastante amplio, lo cual incide en la ocultación de distintos problemas que pueden
producirse, tales como tener reglas que produzcan símbolos que se usen después, o que nunca se llegue a
cadenas de terminales. Todo esto se puede solucionar transformando la gramática inicial “sucia” a una
gramática “limpia”.
Definiciones
• Símbolo muerto : es un Símbolo no terminal que no genera sentencia.
•
Símbolo inaccesible: es un símbolo no terminal al que no se puede llegar por medio de producciones
desde el símbolo inicial de la gramática.
•
Gramática sucia: es toda gramática que contiene símbolos muertos y/o inaccesibles.
•
Gramática limpia: es toda gramática que no contiene símbolos muertos y/o inaccesibles.
Preparado por Prof. Ing. Diego Casco
31
Compiladores I
•
Símbolo vivo: es un símbolo no terminal del cual se puede derivar una sentencia. Todos los
terminales son símbolos vivos. Es decir son símbolos vivos los que no son muertos
•
Símbolo accesible: es un símbolo que aparece en una cadena derivada el símbolo inicial. Es decir,
aquel símbolo que no es inaccesible.
Limpieza de Gramáticas
Como práctica constante, deberá procederse a la “limpieza” de las gramáticas, luego que las mismas
hayan sido diseñadas. Para el efecto, existen algoritmos para la depuración.
El método consiste en detectar y eliminar, en primer lugar, a los símbolos muertos de la gramática.
Posteriormente, se procederá a detectar y eliminar los símbolos inaccesibles.
Es importe respetar el orden en que se citaron los tipos de símbolos a eliminar, debido a que la
eliminación de símbolos muertos, puede generar símbolos inaccesibles en la gramática.
4.3.2.1 Teorema de los símbolos vivos
Si todos los símbolos de la parte derecha de una producción son vivos, entonces el símbolo de la
parte izquierda también lo es.
El procedimiento consiste en iniciar una lista de no terminales que sepamos que son símbolos vivos, y
aplicando el teorema anterior para detectar otros símbolos no terminales vivos para añadirlos a la lista. Dicho
de otra forma, los pasos del algoritmo son:
1. Hacer una lista de no terminales que tengan al menos una producción sin símbolos no terminales en
su consecuente.
2. Dada una producción, si todos los no-terminales de la parte derecha pertenecen a la lista, entonces
podemos incluir al no terminal del antecedente.
3. Cuando no se puedan incluir mas símbolos mediante la aplicación del paso 2, la lista contendrá todos
los símbolos vivos, el resto serán muertos.
Ejemplo
Sea la gramática escrito en la BNF G(<inicial>)
Determinamos los símbolos muertos :
Preparado por Prof. Ing. Diego Casco
32
Compiladores I
Paso 1: la lista empieza con los símbolos: <NOTER2> y <NOTER3>
Paso 2: En la lista se agregan los símbolos: <NOTER1> y <INICIAL>
Paso 3: Como ya no se pueden añadir nuevos símbolos a la lista, ésta contiene a los símbolos vivos,
y lo que no están incluidas en ella, son símbolos muertos: <NOTER4> y <NOTER5>
Entonces se procede a eliminar de la gramática los símbolos muertos <NOTER4> y <NOTER5>, y las
reglas que contienen a estos símbolos, quedando el conjunto de producciones como sigue :
<INICIAL> : := a <NOTER1><NOTER2><NOTER3>
<NOTER1> : := b <NOTER2> <NOTER3>
<NOTER2> : := e | d e
<NOTER3> : := g <NOTER2> | h
4.3. 2.2 Teorema de símbolos accesibles
Si el símbolo no terminal de la parte izquierda de una producción es accesible, entonces todos los
símbolos de la parte derecha también lo son.
Se hace una lista de símbolos accesibles, y aplicando el teorema para detectar nuevos símbolos accesibles
para añadir a la lista, hasta que no se pueden encontrar más.
Los pasos a seguir son:
1. Se comienza la lista con un único no terminal, el símbolo inicial de la gramática
2. Si la parte izquierda de la producción está en la lista, entonces se incluyen en la misma a todos los no
terminales que aparezcan en la parte derecha.
3. Cuando ya no se puedan incluir más símbolos mediante la aplicación del paso 2, la lista contendrá
todos los símbolos accesibles, y el resto será inaccesible.
Ejemplo
Sea la gramática en la BNF , G(<INICIAL>)
Preparado por Prof. Ing. Diego Casco
33
Compiladores I
Determinamos los símbolos inaccesibles :
Paso 1: la lista empieza con los símbolos: <INICIAL>
Paso 2: En la lista se agregan los símbolos: <NOTER1> y <NORTE2>
Paso 3: Como ya no se pueden añadir nuevos símbolos a la lista, ésta contiene a los símbolos
accesibles, y lo que no están incluidas en ella, son símbolos inaccesibles: <NOTER3> y <NOTER4>
Entonces se procede a eliminar de la gramática los símbolos inaccesibles <NOTER3> y <NOTER4>,
y las reglas que contienen a estos símbolos, quedando el conjunto de producciones como sigue :
<INICIAL> : := a <NOTER1><NOTER2>| <NOTER1>
<NOTER1> : := c <NOTER2> d
<NOTER2> : := e | f <INICIAL>
<NOTER3> : := g <NOTER2> | h
4.3.3
Recursividad
Las reglas de producción de una gramática están definidas de forma que al realizar derivaciones dan
lugar a recursividades. Entonces se dice que una regla de derivación es recursiva si es de la forma:
A
Aa
donde A pertenece a VN y a pertenece a (VT U VN )*
Los analizadores LL(1), deben evitar las gramáticas con recursividad por izquierda, debido a que estás
pueden producir ciclos infinitos en las derivaciones por izquierda.
Cabe aquí repasar el algoritmo para eliminar dichas recursividades.
Algoritmos para eliminación de recursividades:
Preparado por Prof. Ing. Diego Casco
34
Compiladores I
A lo largo del curso de Lenguajes y autómatas, hemos aprendido dos métodos distintos con idénticos
efectos.
En el primero que repasaremos, se presenta la generación de cadenas vacías como consecuentes de
no terminales de la gramática.
En el segundo método, los consecuentes equivalentes generan nuevos casos de factores comunes,
que deberán ser nuevamente factorizados (sobre la factorización veremos más adelante).
Método 1:
Por cada caso de recursividad de la forma :
A
A a1 | A a2 | ….| Aan | ß1 | ß2 | …| ßm
Donde A pertenece a VN, ai y ßj pertenecen a (VT U VN)* con la aclaración que ßj no empieza con A.
Se reemplazan estas reglas por las siguientes, con C es un nuevo no terminal en la gramática :
A
ß1 C | ß2 C | …| ßm C
C
a1 C | a2 C | ….| an C | e
Método 2 :
Por cada caso de recursividad de la forma :
A
A a1 | A a2 | ….| Aan | ß1 | ß2 | …| ßm
Donde A pertenece a VN, ai y ßj pertenecen a (VT U VN)* con la aclaración que ßj no empieza con A.
Se reemplazan estas reglas por las siguientes, con C es un nuevo no terminal en la gramática :
A ß1 | ß2 | …| ßm | ß1 C | ß2 C | …| ßm C
C
a1 | a2 | ….| an | a1 C | a2 C | ….| an C
Ejemplo
Dada la gramática G( S ) donde VT = {(,), a, “,”} y
P:{
S
L
(L)|a
L,S|S
}
La recursividad se presenta en L, entonces habrá que eliminarlo, antes de construir el analizador LL(1)
Por el método 1:
L
L,S|S
L
A
SA
,SA| e
se convierte en su equivalente, introduciendo el no terminal A :
Entonces las reglas de la gramática equivalente, sin recursividad por izquierda quedán:
Preparado por Prof. Ing. Diego Casco
35
Compiladores I
S
L
A
(L)|a
SA
,SA| e
Por el método 2:
L
L,S|S
L
B
S| SB
,S|,SB
se convierte en su equivalente, introduciendo el no terminal B :
Entonces las reglas de la gramática equivalente, sin recursividad por izquierda quedán:
S
L
B
4.3.4
(L)|a
S| SB
,S|,SB
Gramáticas Ambiguas
Una sentencia generada por una gramática es ambigua si existe más de un árbol sintáctico para ella.
Una gramática es ambigua si genera al menos una sentencia ambigua, en caso contrario es no ambigua. Hay
muchas gramáticas equivalentes que pueden generar el mismo lenguaje, algunas son ambiguas y otras no.
Sin embargo existen ciertos lenguajes para los cuales no pueden encontrarse gramáticas no ambiguas. A tales
lenguajes se les denomina ambiguos intrínsecos.
La ambigüedad de una gramática es una propiedad indecidible, lo que significa que no existe ningún algoritmo
que acepte una gramática y determine con certeza y en un tiempo finito si la gramática es ambigua o no.
Ejemplo de gramática ambigua : G
(<EXP>)
Las siguientes son derivaciones posibles de la cadena 5 – c * 6
Preparado por Prof. Ing. Diego Casco
36
Compiladores I
Definición de precedencia y asociatividad
Una las principales formas de evitar la ambigüedad es definiendo la precedencia y asociatividad de
los operadores. Lógicamente para las gramáticas con expresiones de cualquier tipo.
Se define el orden de precedencia de evaluación de las expresiones y los operadores. A continuación
se presenta este orden de precedencia de mayor a menor
1) ( ) , identificadores, constantes 2) operador unario de negación
3) ^ operador de potenciación
4) * /
5) + -
La asociatividad se define de forma diferente para el operador de potenciación que para el resto de los
operadores. El operador ^ es asociativo de derecha a izquierda
Mientras que el resto de los operadores binarios, son asociativos de izquierda a derecha, si hay casos de igual
precedencia.
Estas dos propiedades, precedencia y asociatividad, son suficientes para convertir la gramática
ambigua basada en operadores en no ambigua, es decir, que cada sentencia tenga sólo un árbol sintáctico.
Para introducir las propiedades anteriores de las operaciones en la gramática se tiene que escribir otra
gramática equivalente en la cual se introduce un símbolo no terminal por cada nivel de precedencia.
Ejemplo
Sea la gramática G(<EXP>
Con las reglas :
Preparado por Prof. Ing. Diego Casco
37
Compiladores I
Nivel 1 de precedencia:
Introducimos el no terminal <A>, para describir una expresión indivisible, con la máxima precedencia, entonces
las reglas quedan:
<A> ::= ( <EXP>) | identificador | constante
Nivel 2 de precedencia:
Se introduce un nuevo no terminal, <B>, que describe el operador unario de negación y el no terminal del nivel
anterior
<B> : : = - <B> | <A>
Nivel 3 de precedencia:
Agregamos el no terminal <C>, para representar al operador de potenciación, considerando su asociatividad;
y el no terminal del nivel anterior.
<C> : : = <B> ^ <C> | <B>
Como el operador ^ es binario, la regla de potenciación debe incluir dos operadores. Esta consideración se
tendrá igualmente con los operadores binarios siguientes.
Nivel 4 de precedencia:
Para este nivel, introducimos el no terminal <D>, y representamos las operaciones de producto y división; y
como en los casos anteriores, el no terminal del nivel inmediato superior.
<D> : : = <D> * <C> | <D> / <C> | <C>
Como puede verse, el nuevo no terminal aparece a la izquierda de los operadores, por la asociatividad de
izquierda a derecha de los mismos.
Nivel 5 de precedencia:
Para el último nivel de precedencia, se procede a utilizar el mismo no terminal de la gramática original, utilizada
para representar las operaciones de este nivel.
<EXP> ::= <EXP> + <D> | <EXP> - <D> | <D>
Preparado por Prof. Ing. Diego Casco
38
Compiladores I
La gramática queda entonces:
G(<EXP>)
S = <EXP>
VT= {+, -, *, /, ^, (, ), identificador, constante}
VN= {<EXP>, <D>, <C> ,<B> , <A>}
P:{
<EXP> ::= <EXP> + <D> | <EXP> - <D> | <D>
<D> : : = <D> * <C> | <D> / <C> | <C>
<C> : : = <B> ^ <C> | <B>
<B> : : = - <B> | <A>
<A> ::= ( <EXP>) | identificador | constante
}
Siendo ésta una gramática equivalente de la anterior, pero no ambigua
4.3.5 Factorización
Hay veces en las que una gramática no es recursiva por izquierda y sin embargo no es LL(1)
Veamos un caso:
<SENT> ::= if <COND> then <SENT> else <SENT> | if <COND> then <SENT>
Según estas producciones de <SENT>, no podemos optar por una alternativa con sólo el primer token leído if. Esta
situación se puede resolver usando la segunda técnica de transformación de gramáticas: la factorización.
En una forma general, las reglas con factor común tienen la forma:
<A>::= a ß | a d
donde a ,ß , d ? ( VT U VN )+
La transformación de las reglas, implicará la inserción en la gramática, de un nuevo no terminal por cada caso
de factor común existente en la misma:
<A> ::= a <C>
<C>::= ß | d
Ejemplo del if
<SENT> ::= if <COND> then <SENT> else <SENT> | if <COND> then <SENT>
Transformado a:
<SENT> ::= if <COND> then <SENT> <NUEVO>
<NUEVO> ::= else <SENT> | e
4.4 Esquema del Analizador Descendente Predictivo no Recursivo
Preparado por Prof. Ing. Diego Casco
39
Compiladores I
Entrada
a
X
b
$
Programa para análisis
sintáctico predictivo
Y
Pila
+
SALIDA
Z
$
Tabla de análisis sintáctico
El analizador descendente LL(1) utiliza una pila auxiliar para almacenar los símbolos de la gramática
libre de contexto, en base al cual se construyó el analizador sintáctico.
La tabla de análisis sintáctico, contiene las configuraciones de las reglas que predictivamente deberán
ser utilizadas en cada paso de derivación por izquierda.
La salida del análisis sintáctico, es la secuencia de reglas utilizadas en la derivación descendente ( o
por izquierda).
4.4.1 Algoritmo para el análisis LL(1)
La pila se carga, inicialmente con los símbolos $ y Z, donde $ indica el final de cadena de una
sentencia, y Z corresponde al símbolo inicial de la gramática.
El método consiste en seguir el algoritmo partiendo:
•
La cadena a reconocer
•
Una pila de símbolos ( terminales y no terminales)
•
Una tabla ( M ) asociada de forma unívoca a la gramática
La cadena de entrada acabará en el símbolo $ ( como ya se explicó )
Preparado por Prof. Ing. Diego Casco
40
Compiladores I
Sea X el elemento en la cima de la pila y a el terminal apuntado en la entrada. El algoritmo consiste en:
1- Si X = a = $ entonces aceptar la sentencia de entrada
2- Si X = a <> $ entonces
Se quita X de la pila y se avanza el apuntador de entrada
3- Si X es un terminal y X <> a entonces rechazar la sentencia de entrada
4- Si X es un no terminal entonces consultamos la tabla de acuerdo a la siguiente indexación: M(X,a)
Si M(X,a) es vacía entonces rechazar la sentencia de entrada
Si M(X,a) no es vacía entonces: se quita X de la pila y se inserta el consecuente de la regla cargada
dicha posición, en orden inverso.
en
5- Repetir desde el paso 1
4.4.2 Construcción de la Tabla de Análisis LL(1)
La tabla de análisis sintáctico LL(1), es un elemento fundamental, en el proceso de análisis descendente
predictivo. Esta contiene las reglas gramaticales que serán utilizadas en cada paso de un reconocimiento
sintáctico, por este método.
La tabla es una matriz, donde una de sus dimensiones se indexa de acuerdo a cada no terminal de la
gramática, y la otra de acuerdo a cada terminal a más del símbolo de fin de cadena ($).
Para la construcción de la tabla es necesario el cálculo de los conjuntos llamados de cabecera o primero, y
siguiente. Posterior a estos cálculos, se desarrolla el algoritmo de construcción de la tabla.
Preparado por Prof. Ing. Diego Casco
41
Compiladores I
4.4.2.1 Función Primero o Cabecera
Si a es un cadena de símbolos gramaticales, se considera PRIMERO(a) como el conjunto de
terminales que inician las cadenas derivadas de a. Si a =>*e, entonces e también está en PRIMERO(a).
Para calcular PRIMERO(X), para todos los símbolos gramaticales X, aplíquense las siguientes reglas,
hasta que no se puedan añadir más terminales o e a ningún conjunto PRIMERO.
1. Si X es terminal, entonces PRIMERO(X) es {X}. Un solo elemento en el conjunto
PRIMERO.
2. Si X
e es una producción, entonces añádase e a PRIMERO(X).
3. Si X es un no-terminal y X
consecuente)
Y1Y2...Yk , es una producción : ( cada Yi es un símbolo del
3.1 Agregar a a PRIMERO(X), si a está en algún Yi y e está en todos los
PRIMERO(Y1)...,PRIMERO(Yi-1), es decir en los elementos que están antes de Yi, en la
parte derecha de la regla.
3.2 Si e está en PRIMERO(Yj) para j=1,2,...k, agregar e a PRIMERO(X). Si Y1 no
deriva a un e, no se añade más que todo lo que esté en PRIMERO(Y 1) a PRIMERO(X).
Ejemplo:
PRIMERO(A) = {d, a, c}
PRIMERO(S)=PRIMERO(A) = {d, a, c}
PRIMERO(B)=PRIMERO(A) U {b} = {d, a, c, b}
4.4.2.2 Función Siguiente
Se define Siguiente (A), para el no Terminal A, como el conjunto de terminales a que pueden aparecer
inmediatamente a la derecha de A, en alguna forma de frase.
Para calcular SIGUIENTE(A), para todos los no-terminales A, aplíquense las siguientes reglas, hasta
que no se puedan añadir nada más a ningún conjunto SIGUIENTE
Preparado por Prof. Ing. Diego Casco
42
Compiladores I
1.
Póngase $ en SIGUIENTE(S), donde S es el símbolo inicial de la gramática, y $ el indicador de fin
de cadena (FDC).
2.
Si hay un producción A--> aBß, entonces todo lo que esté en PRIMERO(ß), excepto e, se pone en
SIGUIENTE(B) .
3.
Si hay una producción A-->aB o una producción A--> aBß, donde PRIMERO(ß) contenga e,
entonces todo lo que esté en SIGUIENTE(A) se pone en SIGUIENTE(B).
Ejemplo: G( S )
SIGUIENTE (S) = {$, d, a, c, b }
SIGUIENTE (A)= PRIMERO(B) U PRIMERO(S)= { d, a, c, b }
SIGUIENTE (B)= e U SIGUIENTE(A)= { d, a, c, b, e}
4.2.2.3 Algoritmo para construcción de tabla LL(1)
Método: Suponemos que la tabla se identifica con M
1. Para cada producción A-->a de la gramática, aplicar los pasos 2 y 3.
2. Para cada terminal a en PRIMERO(a), cargar A-->a, en la posición M[A,a]
3. Si e está en PRIMERO(a), cargar A-->a a M[A,b] para cada terminal b que pertenezca a SIGUIENTE(A).
Si e está en PRIMERO(a) y $ está en SIGUIENTE(A), cargar A-->a a M[A,$] .
4. Todas las posiciones de M, que no contengan una producción, serán consideradas como entradas de
ERROR.
Ejercicios
1) Dada la gramática G(S)
S (L)|a
Preparado por Prof. Ing. Diego Casco
43
Compiladores I
L
L,S|S
Gráfica árboles sintácticos, ascendentes y descendentes, para las siguientes sentencias :
i)
(a,a)
ii)
(a,(a,a))
iii)
(a,((a,a),(a,a)))
2) Eliminar recursividad por izquierda de la gramática G(S) del ejercicio anterior
3) Dada las gramáticas a) G ( S ) VT = {(,),;,x,a}, Verifica si son gramáticas limpias
S
(A)
A
CB
B
;A|e
C
x | S |Ca|Fax
D
xDa| Exa
F
F xa| EFax
E
(E)| ax
b) G ( K) VT = {a,e,c,m,n, d, j}
K
aZb| eMP
Z
cA|D|mm
A
M a | Bn
F
n
R
P
T|jBP
M
nd
cd R
4) Considera la gramática G( Y )
Y Y ‘|’ Y
donde ‘|’ es un Terminal de la gramática que indica separador de alternativa
Y YY
implica concatenación de dos Y
Y Y * | Y+
* es operador de cierre absoluto de una Y, + operador de cierre positivo
Y (Y)
Y a
Y b
* y + : tienen la mayor precedencia y son asociativos por la izquierda
Concatenación: tiene la segunda mayor precedencia y es asociativa por izquierda
| : tiene la menor precedencia y es asociativa por la izquierda
Construye una gramática no ambigua equivalente de acuerdo a las precedencias y las asociatividades.
5) Dada la siguiente gramática, modifica y adecua sus producciones, de manera que sea factible para un
análisis LL(1) :
G(F) VT={ >,<,=,id,*,num,numreal}
F
F
E
D
F >= F | F <= F | F <> F | T num * num
id | num | id * numreal | T id | F num T | F < T
num = num
T num | numreal * num
Preparado por Prof. Ing. Diego Casco
44
Compiladores I
6)
Dada la siguiente tabla de análisis LL(1), si las sentencias : bbdbaba$
,
bbdbdbaab$
son válidas sintácticamente. Construye el árbol descendente de
la(s) sentencia(s) válida(s). G(S)
a
b
d
$
S
S
bPA
A
Pb
A
B
B
P
B e
P
P
bC
A
A
aM
M
M
abB
C
C e
C e
C
dP
e
C e
7)
Dada la siguiente gramática, construye la tabla de análisis LL(1), para la misma VT
= { %, *, x , ( , ) , + , - }
G(S)
S A
A BE
E %A| C
B DF
F e | *B
D x | (C)
C +x | -x
8)
Contesta correctamente los siguientes:
a) Menciona tres funciones de la fase de análisis sintáctico, en un proceso de compilación.
b) Qué significa, que el análisis sintáctico descendente sea predictivo?
c) Qué importancia tienen los resultados de la función primero para el análisis LL(1)?
d) Qué condiciones deben cumplirse, para que una gramática pueda ser utilizada en la construcción de
un analizador sintáctico LL(1)?
Preparado por Prof. Ing. Diego Casco
45
Compiladores I
e) A qué se denomina símbolos no generativos, en una gramática?
f)
En que consiste la recursividad por izquierda, en una gramática? Cómo afecta a un análisis predictivo?
g) A qué se refiere la símbología LL(1)?
h) Qué tipos de símbolos se almacenan en la pila auxiliar de una analizador predictivo no recursivo?
9)
Desarrolla un software para un analizador sintáctico predictivo, de acuerdo a los
elementos y algoritmos vistos en este capítulo. Escoge una de las gramáticas presentadas en esta sección
de ejercicios y haz el esfuerzo en la construcción.
Preparado por Prof. Ing. Diego Casco
46
Compiladores I
5 ANÁLISIS SINTÁCTICO ASCENDENTE
5.1 Introducción
Se denominan analizadores sintácticos ascendentes (botton-up en inglés) porque procuran construir
el árbol de análisis sintáctico de una sentencia, a partir de esta, es decir desde las hojas del árbol hasta la raíz;
o símbolo inicial de la gramática.
Este proceso requiere la ejecución de la operación de reducción secuencial. Recordemos que una
reducción implica sustituir en la pila de análisis, un pivote o parte derecha de una regla de producción, por su
antecedente o parte izquierda
Reducir : es la inversa de la derivación. Sustituir el consecuente de una producción por su
antecedente.
Desplazar : se refiere a la acción de cargar elementos de la entrada que se analiza, a la Pila auxiliar
del analizador.
Pivote o Mango : de una cadena, es una subcadena que concuerda con el lado derecho de una
producción, y cuya reducción al no-terminal del lado izquierdo de la producción, representa un paso a lo largo
de la inversa de una derivación por la derecha.
Implantación por medio de una Pila del Análisis Ascendente por Desplazamiento - Reducción
Funcionamiento: se desplazan cero o más símbolos de la entrada a la pila hasta que un pivote β esté en su
cima. Entonces el analizador reduce β por el lado izquierdo de la producción adecuada. El analizador repite
este ciclo hasta que detecta un error o hasta que la Pila contiene el símbolo inicial de la gramática y la
entrada está vacía.
Ejemplo:
G(E), donde P: E--> E + E | E*E | ( E ) | id
Análisis para la sentencia id + id * id
Preparado por Prof. Ing. Diego Casco
47
Compiladores I
PILA
ENTRADA
$
id + id * id $
ACCION
Desplazar
$id
+ id * id $
Reducir E-->id
$E
+ id * id $
Desplazar
id * id $
Desplazar
$E+
$E+id
* id $
Reducir E-->id
$E+E
* id $
Desplazar
id $
Desplazar
$E+E*
$E+E*id
$
Reducir E-->id
$E+E*E
$
Reducir E-->E * E
$E+E
$
Reducir E-->E + E
Si se reduce aquí,
la traducción que
podría acompañar
al análisis, daría
resultado erróneo
$E
$
Aceptar
5.2 Conflictos durante el análisis sintáctico por desplazamiento – reducción
Existen gramáticas independientes del contexto para los cuales no se puede utilizar el analizador
sintáctico por desplazamiento-reducción.
El analizador puede alcanzar una configuración en la que el mismo, conociendo el contenido total de
la pila y el siguiente símbolo de la entrada, no puede decidir si Desplazar o Reducir ( conflicto Desplazar /
Reducir ), o no puede decidir qué tipo de reducción efectuar ( conflicto Reducir / Reducir ).
En general ninguna gramática ambigua, como la del ejemplo aplicarse a este método.
5.3 Analizadores Sintácticos LR
Es una técnica eficiente de análisis sintáctico ascendente que se puede utilizar para analizar una clase
más amplia de gramáticas independientes del contexto.
Los analizadores LR reconocen lenguajes realizando las dos operaciones vistas, desplazar y reducir
( shift/reduce en inglés). Lo que hacen es leer los tokens de la entrada e ir cargándolos en una pila, de forma
que se puedan explorar los n tokens superiores que contiene ésta y ver si se corresponden con la parte
derecha de alguna de las reglas de la gramática.
Si es así se realiza una reducción, consistente en sacar de la pila esos n tokens y en su lugar colocar
el símbolo que aparezca en la parte izquierda de esa regla. En caso contrario, se carga en la pila el siguiente
token y una vez hecho esto se vuelve a intentar una reducción.
La técnica se denomina LR(K); la “L” es por la lectura de la sentencia de izquierda a derecha, la “R”
por construir una derivación por la derecha en orden inverso, y la “K” por el número de símbolos de entrada
de examen por anticipado utilizados para tomar decisiones en el análisis sintáctico. Cuando se omite K = 1.
Una gramática que puede ser analizada por un analizador LR mirando hasta k símbolos de entrada
por delante (lookaheads), en cada movimiento, se dice que es una gramática LR (k).
5.4 Ventajas
•
Se pueden construir analizadores LR para reconocer prácticamente todas las construcciones de los
lenguajes de programación para los que se pueden escribir GLC.
•
El método de análisis sintáctico LR es el método de análisis por desplazamiento y reducción sin
retroceso más general y eficiente.
•
Los analizadores que utilizan la técnica LR pueden analizar un número mayor de gramáticas que los
analizadores descendentes.
Preparado por Prof. Ing. Diego Casco
48
Compiladores I
•
Un analizador sintáctico LR puede detectar un error sintáctico tan pronto como sea posible hacerlo
en un examen de izquierda a derecha de la entrada
5.5 Desventajas
•
La principal desventaja del método es el volumen de trabajo que supone construir un analizador
sintáctico LR, “a mano”.
•
Se necesita de una herramienta especializada ( un generador de análisis LR ), por ejemplo el YACC.
5.6 Estructura y funcionamiento de un Analizador LR
Un analizador LR consta de un programa analizador LR, una tabla de análisis, y una pila en la que se
van cargando los estados por los que pasa el analizador y los símbolos de la gramática que se van leyendo.
Se le da una entrada para que la analice y produce a partir de ella una salida
Lo único que cambia de un analizador a otro, es la tabla de análisis, que consta de dos partes: una
función de acción que se ocupa de las acciones a realizar y una función goto (Transición), que se ocupa de
decidir a qué estado se pasa a continuación.
5.6.1 Modelo del Analizador LR
Entrada
a1
...
ai
...
an
Programa para análisis
sintáctico LR
Sm
$
SALIDA
Xm
Pila
Sm-1
Xm-1
...
S0
Acción
Ir_a (Transición)
Preparado por Prof. Ing. Diego Casco
49
Tabla de Análisis
LR
Compiladores I
El programa lee caracteres de un buffer de entrada de uno en uno. Utiliza una pila para almacenar una cadena
de la forma S0X1S1X2S2...XmSm, donde Sm está en la cima. Cada Xi es un símbolo gramatical y cada Si
es un estado del autómata.
5.6.2 Acciones en el Análisis LR
El programa que maneja el analizador LR, se comporta como sigue :
Determina Sm, el estado de la cima de la pila y ai, el símbolo en curso de la entrada. Se consulta la
entrada Acción[Sm,ai] de la tabla de acciones, que puede tener uno de estos cuatro valores :
1. Desplazar S, donde S es un estado
2. Reducir por una producción gramatical A--> ß
3. Aceptar
4. Error
5.6.3 Función Ir-A en Análisis LR
La función Ir-A toma un estado y un símbolo gramatical como argumentos y produce un estado. Se
verá que la función IR-A de una tabla de análisis sintáctico construida a partir de la gramática G, utilizando
cualquiera de los métodos, que se verán, es la función de transiciones de un autómata finito determinista que
reconoce los prefijos viables de G.
5.6.4 Algoritmo de Análisis Sintáctico LR
Entrada : una cadena w y una tabla de análisis sintáctico LR con las funciones ACCION e IR-A para
la Gramática G.
Salida : si w ∈ L(G), un análisis ascendente de w, sino se indica error.
Método : Inicialmente, S0 está en la pila, donde S0 es el estado inicial, y w$ está en el buffer de
entrada. El analizador ejecuta entonces el siguiente algoritmo:
Apuntar al primer símbolo de w$
Repetir
Sea S el estado en la cima de la pila y a el símbolo apuntado
SI ACCION[S,a] = desplazar S’
apilar a y luego S’ en la cima de la pila. Avanzar al siguiente símbolo de w$
SINO
SI ACCION [S,a] = reducir A-->ß
Desapilar 2*| ß| . Donde ß, representa a la cadena de estados y símbolos pertenecientes al pivote,
desde la cima de la pila. Sea S’ el estado que ahora está en la cima de la pila; apilar A
y después IR-A[S’,A] en la cima de la pila.
Salida de la producción A--> ß
SINO
Preparado por Prof. Ing. Diego Casco
50
Compiladores I
SI ACCION[S,a] = Aceptar
Retornar
SINO
error( )
FINSI
FINSI
FINSI
Fin Repetir
Ejemplo: Sea la gramática G(E), donde P :
(1) E-->E+T
(2) E--> T
(3) T-->T*F
(4) T--> F
(5) F-->(E)
(6) F--> id
Su tabla de Análisis sintáctico LR, la sgt. :
Análisis de la sentencia id * id + id $
Preparado por Prof. Ing. Diego Casco
51
Compiladores I
PILA
0
ENTRADA
id * id + id $
0id5
* id + id $
reducir por F-->id
0F3
* id + id $
reducir por T-->F
0T2
* id + id $
Desplazar
id + id $
Desplazar
0T2*7
ACCION
Desplazar
0T2*7id5
+ id $
reducir F-->id
0T2*7F10
+ id $
reducir T-->T*F
0T2
+ id $
reducir E-->T
0E1
+ id $
Desplazar
id $
Desplazar
0E+6
0E+6id5
0E+6F3
0E+6T9
$
$
$
reducir F-->id
reducir T-->F
reducir E-->E+T
0E1
$
Aceptar
5.7 Cálculos previos para la construcción de las tablas de Análisis LR
ITEM LR(0) o simplemente item de una Gramática
CUALQUIER PRODUCCION CON UN PUNTO EN LA PARTE DERECHA DE LA REGLA
EJEMPLO : SEA LA REGLA S
aA
ITEM : S
a.A
COLECCIÓN DE ITEMS o Colección LR(0)
Conjunto de items. Cada conjunto de esta colección representa un estado AFD. Este autómata servirá
para construir la tabla de análisis SLR
Para construir la Colección LR(0) necesitamos definir lo que se conoce como gramática aumentada,
y las operaciones de CLAUSURA y GOTO
GRAMATICA AUMENTADA
Si G es una GLC con axioma S , la gramática aumentada G’ para G, se construye añadiendo S’ ->
S , siendo el nuevo axioma S’
Sea la gramática G(S): S->E$
Preparado por Prof. Ing. Diego Casco
52
Compiladores I
E-> E+T | E – T | T
T-> (E ) | a
Función CLAUSURA o CIERRE
Sobre un conjunto I de items, se define la función cierre(I) como el conjunto de items resultante de
aplicar las siguientes reglas :
Regla 1: todos los items pertenecientes a I pertenecen a cierre (I)
Regla 2: Si [A-> a . Bß] es un item que pertenece a cierre(I) y existe una regla de la forma B->µ, entonces el
item [B->.µ] pertenece a cierre(I)
Regla 3: Repetir la regla anterior hasta que no se añada ningún nuevo item al conjunto cierre(I)
Ejemplo si I={ S->.E$}
Cierre( I )={ S->.E$, E->.E+T, E->.E-T, E->.T , T-> .( E ), T-> .a}
Función GOTO o de Transición
Se define la función de transición d , que se aplica a un conjunto de ítems y a un símbolo (terminal
o no terminal) de la gramática, y da como resultado un nuevo conjunto de ítems.
a, ß pertenece(VN U VT)*
d (I,X) es igual al cierre del conjunto de todos los items de la forma [A-> a X.ß], tales que
[A-> a .Xß] ˛ a I
Ejemplo:
Sea I={ S->.E$, E->.E+T, E->.E-T, E->.T , T-> .( E ), T-> .a}
Si X = E
d (I,E) = cierre(S->E.$, E->E.+T, E->E.-T)={ S->E.$, E->E.+T, E->E.-T }
Si X= a
d (I,a)= cierre(T->a.)={T->a.}
COLECCIÓN DE CONJUNTOS DE ITEMS LR(0)
Sea C un conjunto de items LR(0) compuesto por los conjuntos de items
C={I0 , I1 , I2 , ......}
Paso 1:
I0 = cierre( item asociado al axioma de la gramática)
Paso 2:
Preparado por Prof. Ing. Diego Casco
53
Compiladores I
Para cada I perteneciente a C y para cada símbolo X ∈ ( N U T), se halla GOTO(I,X). Si este conjunto no es
vacio y no pertenece ya a la colección C, se añade a la misma.
Ejemplo de la gramática anterior:
I0 = cierre(S->.E$) = { S->.E$, E->.E+T, E->.E-T, E->.T , T-> .( E ), T-> .a}
Ahora para I0 ( único elemento de C) y para cada X (terminal o no terminal ) de la gramática se halla el conjunto
δ (I,X). Si este no es vacío y no pertenece a C, se añade a C ∈δ βα
I1=δ (I0,E)= cierre (S->E.$, E->E.+T, E->E.-T} = { S->E.$, E->E.+T, E->E.-T }
I2=δ (I0,T)=cierre(E->T.)={E->T.}
I3=δ (I0, a ) = cierre(T->a.) ={T->a.}
De la misma forma se obtienen los demas conjuntos de items I, y la colección completa será:
I0 = {[S -> ·E$],[E -> ·E+T],[E -> ·E-T],[E -> ·T], [T -> ·(E)],[T -> ·a]}
I1 = {[S -> E·$],[E -> E·+T],[E -> E·-T]}
I2 = {[E -> T·]}
I3 = {[T -> a·]}
I4 = {[T -> (·E)],[E -> ·E+T],[E -> ·E-T],[E -> ·T],[T -> ·(E)],[T -> ·a]}
I5 = {[S -> E$·]}
I6 = {[E -> E+·T],[T -> ·(E)],[T -> ·a]}
I7 = {[E -> E-·T],[T -> ·(E)],[T -> ·a]}
I8 = {[E -> E+T·]}
I9 = {[E -> E-T·]}
I10 = {[T -> (E·)],[E -> E·+T],[E -> E·-T]}
I11 = {[T -> (E)·]}
5.8 Algoritmo para la Construcción de la tabla de Análisis SLR
Preparado por Prof. Ing. Diego Casco
54
Compiladores I
Como práctica construimos en clase, la tabla de análisis SLR, según la colección anterior
5.9 Tabla de Análisis LR-Canónica
Preparado por Prof. Ing. Diego Casco
55
Compiladores I
El método LR-canónico es más general que el SLR, ya que en algunos casos resuelve algún conflicto
que puede darse al intentar construir una tabla de análisis SLR. La razón es que maneja una información más
precisa que ayuda decidir cuándo se deben aplicar reducciones
Llamamos item LR(1) de una gramática aumentada G’, a un elemento [A a.ß,a] donde A aß ? P y a ?
Vt U {$}.
Decimos que un item LR(1) [A
ß1 . ß2,a ] es válido para el prefijo viable aß1, si:
S’ =>* r.m. a A x => r.m. aß1ß2 x, x ? Vt*
Y ( a es el primer símbolo de x) o (x es ? y a= $).
Ahora, igual que se hizo antes, hay que construir una colección de ítems LR(1) válidos para cada
prefijo viable de la gramática. Para ello se modifican ligeramente las definiciones de clausura y goto
Preparado por Prof. Ing. Diego Casco
56
Compiladores I
Preparado por Prof. Ing. Diego Casco
57
Compiladores I
Preparado por Prof. Ing. Diego Casco
58
Compiladores I
Preparado por Prof. Ing. Diego Casco
59
Compiladores I
5.10 Tabla de Análisis LALR
El método LALR es menos potente que LR-Canónico (hay gramáticas que son LR-Canónicas pero no
LALR) y más potente que el SLR. Además el tamaño de la tabla de análisis LALR es igual que el de una SLR.
Esto hace que sea un método muy utilizado en la práctica.
Para construir una tabla de análisis LALR partimos de la colección de conjuntos de ítems LR(1) y del
AFD, como en el caso del método LR-Canónico. Después nos fijamos en los estados que tienen las mismas
producciones punteadas en los ítems y sólo se diferencian en los símbolos de anticipación del item.
Una vez agrupados los estados de esta forma y modificando las transiciones de forma conveniente,
se llegaría a otro AFD simplificado, a partir del cual se construye la tabla LALR, exactamente igual que la tabla
LR-Canónica.
Preparado por Prof. Ing. Diego Casco
60
Compiladores I
Los nuevos estados del AFD constituyen lo que se llama colección LALR.
Si la tabla resultante no tiene conflictos, entonces la gramática es LALR
TABLA ANALISIS LALR
ESTADO
0
ACCION
c
D36
d
D47
$
IR-A
S
1
C
2
Preparado por Prof. Ing. Diego Casco
61
Compiladores I
1
2
36
47
5
89
aceptar
D36
D36
R3
D47
D47
R3
R2
R2
5
89
R3
R1
R2
Ejercicios
1) CONTESTA EL SIGUIENTE CUESTIONARIO
a) Qué representa un item LR(1), en la construcción de tablas para el análisis SLR? Escribe un ejemplo.
b) Qué significan los términos Desplazar y Reducir en un análisis sintáctico ascendente?
c) Qué tipos de acciones se verifican en un análisis LR?
d) Por los métodos estudiados, cuántos estados finales puede tener una analizador sintáctico LR?
e) Cómo se determina el estado final para el autómata del analizador sintáctico LR?
f)
Qué ventaja encuentra al diseñar un analizador ascendente, en sustitución de un analizador
descendente?
Preparado por Prof. Ing. Diego Casco
62
Compiladores I
g) Explica el significado del item [ P
id + . P]
2) Dada la siguiente gramática G (S)
S
A
B
D
C
A
B%A|BC
D|D*B
x|(C)
+x | -x
VT= { %, *, x, (, ), +, - }
Obtener la tabla de análisis LALR
3) Construye la Colección de conjuntos de items LR(1) y la tabla de análisis LR-Canónica, para la siguiente
gramática:
(1) Z fi RdT
VN= {Z, R TB}
(2) Rfi dRd
VT= {d,H,b}
(3) Rfi h
(4) Tfi b
4) Escribe programas en Yacc/Lex para reconocer conjuntos de sentencias del lenguaje, separadas por un
retorno de carro. Correspondiente a la gramática del item anterior.
Imprimir al final del reconocimiento de un texto con sentencias, la cantidad de sentencias válidas analizadas.
Incluir el programa en LEX, que genere el analizador léxico correspondiente.
5) Considere la gramática :
i)
ii)
S
L
(L) |a
L,S | S
Construya una derivación de más a la derecha para (a,(a,a)) y muestre el handle de cada
forma sentencial derecha.
Muestre los pasos de un parser shift-reduce correspondiente a una derivación de más a la
derecha de (a). iii)
Muestre los pasos en la construcción bottom-up de un árbol de
parser, durante el parser shift-reduce de ii).
6) Considere la siguiente gramática escrita en formato estilo YACC (ignore las acciones por el momento):
decl
: lista_id ':' tipo_id { printf("DECL\n"); }
tipo_id : ID
{ printf("TIPO: %s\n", yytext); }
lista_id : id
{ printf("UNO\n"); }
| id ' , ' lista_id
{ printf { ("LISTA\n"; }
id
: ID
{ printf("VAR: %s\n", yytext); }
i)
Muestre una derivación de más a la derecha para la tira de entrada : a, b, c: int ii) Considere
ahora las acciones, y de la salida que un parser bottom-up imprimiría para la misma tira de
entrada.
7) Considere la siguiente gramática que genera el lenguaje de las expresiones aritméticas:
Preparado por Prof. Ing. Diego Casco
63
Compiladores I
E
E
B
EBE | (E)
id | num
+|*| /|-
i)
Usando YACC escriba la gramática para reconocer el lenguaje.
ii)
Agréguele el manejo de errores.
Preparado por Prof. Ing. Diego Casco
64
Descargar