Está en la página 1de 156

Universidad de Santiago de Chile

Facultad de Ingeniería
Departamento de Ingeniería Informática

APUNTES DE LA ASIGNATURA

COMPILADORES

Mag. Jacqueline Köhler C.


2
Compiladores

TABLA DE CONTENIDOS
1 INTRODUCCIÓN ................................................................................................................................. 7
1.1 ¿QUÉ ES UN PROGRAMA? ......................................................................................................... 8
1.2 DEFINICIÓN DE COMPILADOR ................................................................................................ 8
1.3 COMPILADORES E INTÉRPRETES ........................................................................................... 9
1.4 CONTEXTO EN QUE SE SITÚA EL COMPILADOR ................................................................ 9
1.5 FASES DE UN COMPILADOR .................................................................................................. 11
1.6 CLASIFICACIÓN DE LOS COMPILADORES.......................................................................... 13
1.7 HERRAMIENTAS ÚTILES ......................................................................................................... 13
2 ANÁLISIS LÉXICO ............................................................................................................................ 14
2.1 FUNCIÓN DEL ANALIZADOR LÉXICO.................................................................................. 14
2.2 COMPONENTES LÉXICOS, PATRONES Y LEXEMAS ......................................................... 15
2.3 ERRORES LÉXICOS ................................................................................................................... 16
2.4 IMPLEMENTACIÓN DE ANALIZADORES LÉXICOS .......................................................... 17
2.4.1 MÉTODOS GENERALES ..................................................................................................... 17
2.4.2 CONSTRUCCIÓN DE UN ANALIZADOR LÉXICO ......................................................... 17
2.5 EJERCICIOS ................................................................................................................................. 21
3. ANÁLISIS SINTÁCTICO .................................................................................................................. 23
3.1 CONCEPTOS PREVIOS .............................................................................................................. 23
3.1.1 RECURSIVIDAD POR LA IZQUIERDA ............................................................................. 23
3.1.2 FACTORIZACIÓN POR LA IZQUIERDA .......................................................................... 24
3.1.3 CONJUNTOS ANULABLE, PRIMERO Y SIGUIENTE ..................................................... 27
3.1.3.1 Conjunto Anulable ........................................................................................................... 27
3.1.3.2 Conjunto Primero ............................................................................................................. 27
3.1.3.3 Conjunto Siguiente........................................................................................................... 27
3.2 DESCRIPCIÓN GENERAL DEL ANÁLISIS SINTÁCTICO .................................................... 28
3.3 ALGUNOS ASPECTOS DEL MANEJO DE ERRORES SINTÁCTICOS................................. 29
3.4 ANÁLISIS SINTÁCTICO PREDICTIVO ................................................................................... 30
3.4.1 ASPECTOS GENERALES .................................................................................................... 30
3.4.2 CONSTRUCCIÓN Y FUNCIONAMIENTO DE ANALIZADORES SINTÁCTICOS LL(1)
......................................................................................................................................................... 31

Jacqueline Köhler C. - USACH


3
Compiladores

3.4.3 GRAMÁTICAS LL(1) ........................................................................................................... 35


3.4.4 RECUPERACIÓN DE ERRORES EN ANÁLISIS SINTÁCTICO LL(1) ........................... 35
3.4.4.1 Recuperación de errores en modo de pánico ................................................................... 35
3.4.4.2 Recuperación de errores a nivel de frase ......................................................................... 36
3.5 ANÁLISIS SINTÁCTICO DESCENDENTE (POR DESPLAZAMIENTO Y REDUCCIÓN).. 37
3.5.1 ASPECTOS GENERALES .................................................................................................... 37
3.5.2 GRAMÁTICAS LR(K) .......................................................................................................... 38
3.5.3 ANÁLISIS SINTÁCTICO SLR ............................................................................................. 38
3.5.3.1 Elemento LR(0)................................................................................................................ 38
3.5.3.2 Operación Clausura .......................................................................................................... 39
3.5.3.3 Construcción del autómata ............................................................................................... 40
3.5.3.4 Construcción y operación del analizador sintáctico SLR ................................................ 42
3.5.4 USO DE GRAMÁTICAS AMBIGUAS ................................................................................ 48
3.5.5 RECUPERACIÓN DE ERRORES ........................................................................................ 49
3.5.5.1 Recuperación en modo de pánico .................................................................................... 49
3.5.5.2 Recuperación a nivel de frase .......................................................................................... 50
3.5.6 ANÁLISIS SINTÁCTICO LR(1)........................................................................................... 51
3.5.6.1 Elemento LR(1)................................................................................................................ 51
3.5.6.2 Operación clausura ........................................................................................................... 52
3.5.6.3 Construcción del AFD ..................................................................................................... 53
3.5.6.4 Construcción de la tabla de análisis sintáctico LR(1) ...................................................... 53
3.5.7 ANÁLISIS SINTÁCTICO LALR .......................................................................................... 58
3.5.7.1 Notas preliminares ........................................................................................................... 58
3.5.7.2 Construcción del AFD a partir del analizador sintáctico LR(1) ...................................... 58
3.5.7.3 Construcción directa del AFD.......................................................................................... 63
3.6 EJERCICIOS ................................................................................................................................. 64
4 ANÁLISIS SEMÁNTICO ................................................................................................................... 66
4.1 DEFINICIONES DIRIGIDAS POR LA SINTAXIS .................................................................... 66
4.1.1 ATRIBUTOS SINTETIZADOS ............................................................................................ 67
4.1.2 ATRIBUTOS HEREDADOS ................................................................................................. 68
4.1.3 GRAFOS DE DEPENDENCIAS ........................................................................................... 69

Jacqueline Köhler C. - USACH


4
Compiladores

4.2 ÁRBOLES SINTÁCTICOS .......................................................................................................... 71


4.3 COMPROBACIÓN DE TIPOS .................................................................................................... 72
4.4 OTRAS COMPROBACIONES SEMÁNTICAS ......................................................................... 76
4.4.1 VALORES DEL LADO IZQUIERDO .................................................................................. 76
4.4.2 PARÁMETROS DE FUNCIÓN ............................................................................................ 77
4.4.3 PALABRA CLAVE return ..................................................................................................... 78
4.4.4 CASOS DUPLICADOS EN UN switch................................................................................. 79
4.4.5 ETIQUETAS goto .................................................................................................................. 79
4.5 EJERCICOS .................................................................................................................................. 79
5 AMBIENTES PARA EL MOMENTO DE EJECUCIÓN .................................................................. 82
5.1 ASPECTOS DEL LENGUAJE FUENTE .................................................................................... 82
5.1.1 PROCEDIMIENTOS ............................................................................................................. 82
5.1.2 ÁRBOLES DE ACTIVACIÓN .............................................................................................. 82
5.1.3 PILAS DE CONTROL ........................................................................................................... 83
5.1.4 ÁMBITO DE UNA DECLARACIÓN ................................................................................... 85
5.1.5 ENLACE DE NOMBRES ...................................................................................................... 85
5.2 ORGANIZACIÓN DE LA MEMORIA ....................................................................................... 86
5.2.1 SUBDIVISIÓN DE LA MEMORIA DURANTE LA EJECUCIÓN..................................... 86
5.2.2 REGISTROS DE ACTIVACIÓN .......................................................................................... 86
5.2.3 DISPOSICIÓN ESPACIAL DE LOS DATOS LOCALES EN EL MOMENTO DE LA
COMPILACIÓN.............................................................................................................................. 87
5.3 ESTRATEGIAS PARA LA ASIGNACIÓN DE MEMORIA ..................................................... 88
5.3.1 ASIGNACIÓN ESTÁTICA ................................................................................................... 88
5.3.2 ASIGNACIÓN POR MEDIO DE UNA PILA ....................................................................... 89
5.3.3 ASIGNACIÓN POR MEDIO DE UN MONTÍCULO .......................................................... 89
5.4 ACCESO A NOMBRES NO LOCALES ..................................................................................... 89
5.4.1 BLOQUES .............................................................................................................................. 90
5.4.2 ÁMBITO LÉXICO SIN PROCEDIMIENTOS ANIDADOS ................................................ 91
5.4.3 ÁMBITO LÉXICO CON PROCEDIMIENTOS ANIDADOS .............................................. 92
5.4.4 ÁMBITO DINÁMICO ........................................................................................................... 92
5.5 PASO DE PARÁMETROS .......................................................................................................... 93

Jacqueline Köhler C. - USACH


5
Compiladores

5.5.1 LLAMADA POR VALOR ..................................................................................................... 94


5.5.2 LLAMADA POR REFERENCIA .......................................................................................... 95
5.5.3 COPIA Y RESTAURACIÓN................................................................................................. 95
5.6 TABLA DE SÍMBOLOS .............................................................................................................. 96
5.6.1 ENTRADAS DE LA TABLA DE SÍMBOLOS .................................................................... 97
5.6.2 INFORMACIÓN SOBRE LA ASIGNACIÓN DE MEMORIA ........................................... 97
5.6.3 TIPOS DE TABLAS DE SÍMBOLOS ................................................................................... 98
5.6.4 IMPLEMENTACIÓN DE LA TABLA DE SÍMBOLOS ...................................................... 98
5.6.5 REPRESENTACIÓN DE LA INFORMACIÓN SOBRE EL ÁMBITO ............................... 99
5.7 ASIGNACIÓN DINÁMICA DE LA MEMORIA ...................................................................... 101
5.7.1 INSTRUMENTOS DE LOS LENGUAJES ......................................................................... 101
5.7.2 ASIGNACIÓN EXPLÍCITA DE BLOQUES DE TAMAÑO FIJO .................................... 102
5.7.3 ASIGNACIÓN EXPLÍCITA DE BLOQUES DE TAMAÑO VARIABLE ........................ 102
5.7.4 DESASIGNACIÓN IMPLÍCITA ......................................................................................... 103
5.7.4.1 Cuenta de referencias ..................................................................................................... 104
5.7.4.2 Técnicas de marca .......................................................................................................... 104
5.8 EJERCICIOS ............................................................................................................................... 104
6 GENERACIÓN DE CÓDIGO INTERMEDIO ................................................................................. 106
6.1 LENGUAJES INTERMEDIOS .................................................................................................. 106
6.1.1 REPRESENTACIONES GRÁFICAS .................................................................................. 106
6.1.2 CÓDIGO DE TRES DIRECCIONES .................................................................................. 107
6.1.2.1 Tipos de proposiciones de tres direcciones .................................................................... 108
6.1.2.2 Implementaciones de código de tres direcciones ........................................................... 109
6.1.2.3.1 Cuádruplos............................................................................................................... 109
6.1.2.3.2 Triples ...................................................................................................................... 110
6.1.2.3.3 Triples indirectos ..................................................................................................... 110
6.2 TRADUCCIÓN DIRIGIDA POR LA SINTAXIS ..................................................................... 111
6.2.1 DECLARACIONES ............................................................................................................. 111
6.2.1.1 Declaraciones dentro de un procedimiento .................................................................... 112
6.2.1.2 Nombres de campos dentro de registros ........................................................................ 114
6.2.3 PROPOSICIONES DE ASIGNACIÓN ............................................................................... 114

Jacqueline Köhler C. - USACH


6
Compiladores

6.2.3.1 Expresiones aritméticas.................................................................................................. 115


6.2.3.2 Expresiones booleanas ................................................................................................... 117
6.2.3.3 Acceso a elementos de matrices..................................................................................... 119
6.2.3.4 Acceso a elementos de registros .................................................................................... 121
6.2.4 SENTENCIAS DE FLUJO DE CONTROL ........................................................................ 121
6.2.4.1 Sentencia goto ................................................................................................................ 121
6.2.4.2 Sentencia if ..................................................................................................................... 121
6.2.4.3 Sentencia if-else ............................................................................................................. 122
6.2.4.4 Sentencia switch ............................................................................................................. 123
6.2.4.5 Sentencia while .............................................................................................................. 124
6.2.4.6 Sentencia do-while ......................................................................................................... 125
6.2.4.7 Sentencia repeat-until ..................................................................................................... 126
6.2.4.8 Sentencia for .................................................................................................................. 126
6.2.5 LLAMADAS A PROCEDIMIENTOS ................................................................................ 127
6.3 EJERCICIOS ............................................................................................................................... 127
BIBLIOGRAFÍA .................................................................................................................................. 129
ANEXO: NOCIONES DE LENGUAJES FORMALES ...................................................................... 130
A.1 JERARQUÍA DE LOS LENGUAJES ....................................................................................... 130
A.2 GRAMÁTICAS .......................................................................................................................... 131
A.2.1 GRAMÁTICAS REGULARES .......................................................................................... 135
A.2.2 GRAMÁTICAS LIBRES DE CONTEXTO ....................................................................... 136
A.2 EXPRESIONES REGULARES ................................................................................................. 138
A.3 AUTÓMATAS FINITOS........................................................................................................... 139
A.3.1 AUTÓMATA FINITO DETERMINÍSTICO (AFD) .......................................................... 141
A.3.2 AUTÓMATA FINITO NO DETERMINÍSTICO (AFND)................................................. 144
A.3.3 EQUIVALENCIA ENTRE AFD Y AFND ......................................................................... 145
A.3.4 MINIMIZACIÓN DE AFD ................................................................................................. 148
A.3.5 EQUIVALENCIA ENTRE AF Y ER: MÉTODO DE THOMPSON ................................. 149
A.4 AUTÓMATAS APILADORES ................................................................................................. 153

Jacqueline Köhler C. - USACH


7
Compiladores

1 INTRODUCCIÓN

Un lenguaje es una abstracción que permite la comunicación y coordinación mediante la creación de


conceptos que pueden ser entendidos de la misma forma por los distintos participantes. Así, un
lenguaje puede entenderse como una forma de hablar o de describir algo.

Para poder hablar de lenguaje es importante conocer algunos elementos. Por ejemplo, ¿cómo se crean
los conceptos? ¿Cómo hacer que todos entiendan lo mismo? El primer paso para responder esta
pregunta está en el concepto de símbolo. En el caso del castellano, se puede pensar en las letras como
símbolos elementales, que se emplean para construir conceptos sencillos denominados palabras. El
conjunto de todos los símbolos se conoce como alfabeto. Ahora bien, las palabras también pueden
formar parte de estructuras más complejas: frases y oraciones. A su vez, éstas pueden conformar
párrafos, etc.

A partir de los conceptos anteriores podría pensarse que cualquier secuencia de letras es una palabra o
que una oración podría ser una secuencia aleatoria de palabras. Pero de nuestra experiencia previa
sabemos que no es así. Existe una serie de reglas que limitan la forma de las palabras o la estructura de
un texto:
 Reglas léxicas: corresponden a las reglas de ortografía de un lenguaje, e indican la forma que deben
tener las palabras. Por ejemplo, en castellano no puede haber palabras que contengan una n seguida
de una b.
 Reglas sintácticas: definen la forma en que se debe estructurar un texto. Por ejemplo, una oración
debe tener sujeto y predicado. El predicado debe tener un núcleo que puede ir acompañado de
diversos complementos.
 Reglas semánticas: determinan el significado de lo que se dice en el texto sintácticamente correcto
y guardan relación con el contexto. Por ejemplo, la frase “ése árbol” puede referirse a un árbol de
un parque, o bien a una estructura de datos empleada en la resolución de un problema
computacional.

Con todo lo anterior, ya tenemos una primera noción de qué es un lenguaje. Ahora bien, sabemos que
existen muchos lenguajes y que en ocasiones es necesario pasar un mensaje o texto de un lenguaje a
otro. De aquí surgen los conceptos de traducción (generar una copia escrita del mensaje original en un
idioma distinto) e interpretación (repetir verbalmente un mensaje en un lenguaje diferente al empleado
por el emisor).

Existen diferentes tipos de lenguajes. Por ahora, nos basta distinguir entre:
 Lenguajes naturales: son aquellos que las personas utilizan para comunicarse entre ellas, como el
castellano, el inglés o el chino. Son muy complejos, tienen una gran cantidad de reglas y sin
embargo presentan situaciones de ambigüedad que los hablantes resuelven recurriendo al contexto
tanto de espacio como de tiempo.
 Lenguajes formales: son lenguajes artificiales, diseñados para lograr una comunicación
(unidireccional) entre personas y máquinas. Estas últimas deben comprender los mensajes y
ejecutarlos, por lo que las reglas de los lenguajes formales deben estar muy bien definidas y no
pueden dar lugar a ambigüedades.

Jacqueline Köhler C. - USACH


8
Compiladores

En ciencias de la computación, el estudio de los lenguajes formales se limita a sus características


intrínsecas e internas, sin tener en cuenta su significado ni su uso como herramienta de comunicación,
puesto que esto último corresponde a una atribución asignada por un observador externo al lenguaje.

En este curso daremos respuesta a una pregunta que se desprende de todo lo expuesto anteriormente:
¿cómo aplicar la teoría de lenguajes formales para poder comunicarnos con las máquinas?

1.1 ¿QUÉ ES UN PROGRAMA?


En cursos anteriores siempre hemos pensado en un programa como una secuencia estructurada de
sentencias escritas en algún lenguaje y que son ejecutadas por un computador para resolver un
problema. Sin embargo, antes de comenzar a hablar de compiladores tenemos que definir qué es un
programa desde otra perspectiva: como una cadena de caracteres construida sobre un alfabeto
determinado (habitualmente ASCII o un subconjunto de él) y almacenada en un archivo. En otras
palabras, un programa es una palabra del lenguaje, generalmente un lenguaje libre de contexto.

Sin embargo, también podemos considerar que esta gran palabra contiene subsecuencias que, a su vez,
son palabras pertenecientes a lenguajes más sencillos que conforman las denominadas categorías
léxicas. Cada una de estas categorías puede ser definida por un lenguaje más sencillo (casi siempre un
lenguaje regular) y da origen a un tipo específico de palabras. Entre los más habituales podemos
encontrar, por ejemplo:
 Identificadores predefinidos para elementos propios del lenguaje (palabras reservadas, operadores,
etc.).
 Identificadores definidos por el programador.
 Representación literal de valores.
 Delimitadores.
 Comentarios.

1.2 DEFINICIÓN DE COMPILADOR


Para comenzar con la definición de compilador, lo primero que hay que tener en cuenta es que un
compilador es un programa. Su función consiste en leer otro programa, denominado programa fuente y
escrito en un lenguaje llamado lenguaje fuente, para posteriormente traducirlo a un programa
equivalente (programa objeto) escrito en un segundo lenguaje denominado lenguaje objeto (ver figura
1.1). Además, antes de efectuar la traducción comprueba que el programa fuente esté correctamente
escrito e informa al usuario en caso de encontrar errores.

Existe una gran diversidad de lenguajes fuente, que van desde los más populares (Java, C/C++, Visual
Basic, etc.) hasta aquellos altamente especializados. De igual manera existen muchos lenguajes objeto,
que abarcan desde el lenguaje de máquina de cualquier computador hasta otros lenguajes de
programación.

No es difícil imaginar que la tarea de convertir un programa fuente escrito en un lenguaje de alto nivel
en un programa objeto cuyo lenguaje podría ser el de la máquina en que será ejecutado no es sencilla.

Jacqueline Köhler C. - USACH


9
Compiladores

En consecuencia, tampoco es sencillo escribir un buen compilador. Para ello se requieren


conocimientos en diferentes áreas: lenguajes de programación, arquitectura de computadores, teoría de
lenguajes, algoritmos e ingeniería de software. En este curso solo nos centraremos en la teoría de
lenguajes.

Pero, ¿para qué hacer esta traducción? Aho et al. afirman que: “el 90 % del tiempo de ejecución de un
programa se encuentra en el 10 % del mismo”. En consecuencia, una de las grandes ventajas de usar un
compilador es la optimización de código que realizan. Dichas optimizaciones no solo se traducen en un
menor tiempo de ejecución, sino también en una reducción del consumo de energía por parte del
procesador y en una menor cantidad de accesos a memoria.

FIGURA 1.1: Representación básica de un compilador.

1.3 COMPILADORES E INTÉRPRETES


Ahora que ya tenemos una primera definición para el término compilador es posible destacar la gran
diferencia que existe entre un compilador y un intérprete. Se señaló anteriormente que el primero
genera un programa equivalente al programa fuente, pero escrito en el lenguaje objeto. El intérprete, en
cambio, toma una sentencia del programa fuente, la traduce al lenguaje objeto y la ejecuta, pero no
guarda una copia de la traducción. Así pues, podemos asociar la idea de la compilación a la traducción
de un texto escrito, en la que generamos un documento equivalente escrito en otro idioma. En cambio,
la noción de interpretación se asemeja a la traducción simultánea que se realiza en conferencias o
eventos deportivos, en que una persona transmite un mensaje en forma verbal y otra emite un mensaje
equivalente en otra lengua. La tabla 1.1 muestra las ventajas que ofrecen la compilación y la
interpretación, respectivamente.

1.4 CONTEXTO EN QUE SE SITÚA EL COMPILADOR


Muchas veces no basta con un compilador para obtener un programa objeto ejecutable. Por ejemplo, un
programa fuente podría estar dividido en módulos almacenados en diferentes archivos. La tarea de
reunir los diversos fragmentos del programa fuente a menudo se confía a un programa distinto, llamado
preprocesador, el cual puede también expandir abreviaturas, llamadas macros y a proposiciones del
lenguaje fuente.

El programa objeto creado por el compilador puede requerir procesamiento adicional para poder ser
ejecutado. La figura 1.2 muestra, a modo de ejemplo, un compilador que crea código en lenguaje

Jacqueline Köhler C. - USACH


10
Compiladores

ensamblador, el cual debe ser traducido a código de máquina por un ensamblador y luego enlazado a
algunas rutinas de biblioteca para finalmente generar el código que podrá ser ejecutado en la máquina.

Ventajas de compilar Ventajas de interpretar


Se compila una vez y se ejecuta muchas Un intérprete requiere menos memoria
veces. que un compilador.
La ejecución del programa objeto es Permite mayor interacción con el código
mucho más rápida que la interpretación en tiempo de desarrollo.
del programa fuente.
El compilador tiene una visión más En algunos lenguajes incluso es posible
detallada del programa, por lo que la añadir código mientas otra parte del
información de los mensajes de error es código se está ejecutando (Smalltalk,
más detallada. Prolog, LISP).
Al usar lenguajes interpretados se tiene
una mayor portabilidad.

TABLA 1.1: Ventajas de compilar e interpretar.

El lenguaje ensamblador es una versión mnemotécnica del lenguaje de máquina, donde se usan
nombres para las operaciones en lugar de códigos binarios y también se usan nombres para las
direcciones de memoria. Además, un ensamblador es un compilador cuyo lenguaje fuente es el
lenguaje ensamblador.

FIGURA 1.2: Sistema para procesamiento de un lenguaje.

Jacqueline Köhler C. - USACH


11
Compiladores

Algunos elementos nuevos que aparecen en la figura 1.2 son:


1. Código de máquina relocalizable: es código que puede cargarse empezando en cualquier posición
L de la memoria. Es decir, si se suma L a todas las direcciones del código, entonces todas las
referencias serán correctas.

2. Proceso de carga: consiste en tomar el código de máquina relocalizable, modificar las direcciones
de memoria y colocar las instrucciones y los datos modificados en las posiciones apropiadas de la
memoria.

3. Editor de enlace: permite formar un único programa a partir de varios archivos de código de
máquina relocalizable.

1.5 FASES DE UN COMPILADOR


Antes de comenzar la descripción de las diferentes etapas del proceso de compilación, puede resultar
esclarecedor el modelo que separa estas etapas en dos grandes bloques: análisis y síntesis (figura 1.3).

FIGURA 1.3: Modelo de análisis y síntesis de la compilación.

El análisis separa al programa fuente en los diversos componentes léxicos (tokens) que lo componen y
luego crea una representación intermedia. Posteriormente la síntesis se sirve de la representación
intermedia generada durante el análisis para construir el programa objeto.

La figura 1.4 muestra las distintas fases de un compilador, donde las primeras cuatro etapas que recorre
el programa fuente constituyen el análisis y las siguientes, la síntesis.

A continuación se describen brevemente las diferentes etapas:


1. Análisis léxico: lee, de izquierda a derecha, la cadena de caracteres que constituye el programa
fuente y la agrupa en componentes léxicos, que son secuencias de caracteres que tienen un
significado colectivo. Normalmente durante esta etapa se eliminan los espacios en blanco y los
comentarios.

2. Análisis sintáctico: agrupa los componentes léxicos del programa fuente en frases gramaticales que
el compilador utiliza para sintetizar la salida.

3. Análisis semántico: se revisan las frases gramaticales que conforman el programa para detectar
errores semánticos, es decir, que guarden relación con el significado de los elementos, y reúne la

Jacqueline Köhler C. - USACH


12
Compiladores

información sobre los tipos para la posterior fase de generación de código. El análisis semántico
utiliza la estructura jerárquica determinada por la fase de análisis sintáctico para identificar los
operadores y operandos de expresiones y proposiciones. Un componente importante del análisis
semántico es la verificación de tipos, donde el compilador verifica que cada operador tenga
operandos permitidos por la especificación del lenguaje fuente. Sin embargo, la especificación del
lenguaje puede permitir ciertas conversiones.

FIGURA 1.4: Fases de un compilador.

4. Generación de código intermedio: algunos compiladores generan una representación intermedia


explícita del programa fuente. Ésta puede ser considerada como un programa para una máquina
abstracta.
5. Optimización de código: esta etapa intenta mejorar el código intermedio, de modo que resulte un
código de máquina más rápido de ejecutar.

6. Generación de código: corresponde a la fase final de un compilador y genera código objeto, que
habitualmente consiste en código de máquina relocalizable o código ensamblador. Aquí se
seleccionan las posiciones de memoria para cada una de las variables empleadas por el programa.
Un aspecto decisivo de esta etapa es la asignación de variables a registros.

7. Administrador de la tabla de símbolos: el registro de los identificadores utilizados en el programa


fuente y la recolección de información relativa a los diferentes atributos de cada identificador
conforman una de las funciones esenciales de un compilador. Dichos atributos pueden proporcionar
información sobre la memoria asignada a un identificador, su tipo, su ámbito y, en el caso de
nombres de procedimientos, datos como el número y el tipo de sus argumentos, el método con que
se debe pasar cada argumento y, en caso de existir, el tipo que se retorna.

Jacqueline Köhler C. - USACH


13
Compiladores

8. Tabla de símbolos: es una estructura de datos que contiene un registro por cada identificador, donde
los campos son llenados con los atributos de este último.

9. Manejador de errores: cada una de las fases descritas puede encontrar errores. No obstante, cada
fase debe tratar adecuadamente el error detectado para poder continuar la compilación y así permitir
la detección de más errores en el programa fuente.

1.6 CLASIFICACIÓN DE LOS COMPILADORES


Existen diferentes criterios para clasificar los compiladores, según la forma en que fueron construidos o
la tarea que realizan: de una pasada, de múltiples pasadas, de depuración, de optimización, etc. A pesar
de éstas diferencias, todos los compiladores llevan a cabo, en esencia, las mismas tareas básicas. Ahora
bien, existen algunos tipos de compiladores que pueden distinguirse de los demás:
 Ensamblador: compilador de estructura sencilla cuyo lenguaje fuente es el lenguaje ensamblador.
 Compilador cruzado: genera código en lenguaje objeto para una máquina distinta de la que se está
usando para compilar (por ejemplo, un compilador de pascal escrito en C++, que funcione en
LINUX y que genere código para MS-DOS). Es la única forma de construir un compilador para un
nuevo procesador.
 Compilador con montador: compila diferentes módulos en forma independiente y después es capaz
de enlazarlos.
 Autocompilador: compilador escrito en el mismo lenguaje que va a compilar. No puede ser usado la
primera vez, pero sirve entre otras cosas para hacer ampliaciones al lenguaje.
 Metacompilador: es un compilador de compiladores. Recibe como entrada las especificaciones del
lenguaje para el que se desea construir un compilador y genera como salida el compilador para
dicho lenguaje.
 Descompilador: recibe como entrada código escrito en lenguaje de máquina y lo traduce a un
lenguaje de alto nivel (no sirven en caso de existir optimización de código en el programa escrito en
lenguaje de máquina). En otras palabras, realiza el proceso inverso a la compilación. No obstante,
hasta ahora no se han construido buenos descompiladores (salvo desensambladores).

1.7 HERRAMIENTAS ÚTILES


Existen diversas herramientas que pueden ser de ayuda al momento de construir un compilador. Dos de
ellas, sin embargo, destacan por sobre las demás:
 Generadores de analizadores léxicos: generan analizadores léxicos en forma automática, por lo
general a partir de una especificación basada en expresiones regulares. La organización básica del
analizador léxico resultante es en realidad un autómata finito. Ejemplo: Lex.

 Generadores de analizadores sintácticos: generan analizadores sintácticos, normalmente a partir de


una entrada fundamentada en una gramática independiente del contexto. Ejemplo: Yacc.

Jacqueline Köhler C. - USACH


14
Compiladores

2 ANÁLISIS LÉXICO
El análisis léxico corresponde a la primera etapa del proceso de compilación. Puede pensarse como una
corrección ortográfica del programa fuente.

2.1 FUNCIÓN DEL ANALIZADOR LÉXICO


El analizador léxico es la primera etapa de un compilador y forma parte de la fase de análisis. Su
principal función consiste en leer los caracteres de entrada y elaborar como salida una secuencia de
componentes léxicos que será posteriormente utilizada para efectuar el análisis sintáctico.
Generalmente el analizador léxico es una subrutina del analizador sintáctico y su funcionamiento es el
siguiente (ver figura 2.1): el analizador sintáctico solicita el siguiente componente léxico. En
consecuencia, el analizador léxico lee caracteres del programa fuente hasta identificar un nuevo
componente léxico, que entregará al analizador sintáctico para así dar cumplimiento a su solicitud.

La figura 2.1 hace mención de la tabla de símbolos, encargada de almacenar información relativa a
cada uno de los nombres de variables y procedimientos declarados en el programa fuente. Será
estudiada con mayor profundidad en capítulos posteriores.

FIGURA 2.1: Interacción entre el analizador léxico y el analizador sintáctico.

Como el analizador léxico es la parte del compilador que lee el texto fuente, también puede realizar
ciertas funciones secundarias en la interfaz del usuario:
 Eliminar los comentarios del programa fuente.
 Eliminar espacios en blanco, tabulaciones y saltos de línea.
 Relacionar los mensajes de error entregados por el compilador con el programa fuente. Por
ejemplo, el analizador léxico suele contar los saltos de línea detectados, por lo que puede asociar
los errores encontrados al número de línea del programa fuente correspondiente. Incluso existen
compiladores en que el analizador léxico se encarga de hacer una copia del programa fuente donde
se marcan los mensajes de error.

Jacqueline Köhler C. - USACH


15
Compiladores

Se señaló anteriormente que el analizador léxico suele ser una subrutina del analizador sintáctico.
Existen varias razones para separar estos dos tipos de análisis en etapas diferentes:
 Quizá la más importante de las consideraciones sea la de lograr un diseño sencillo. Separar el
análisis léxico del análisis sintáctico a menudo permite simplificar una u otra de estas etapas. Por
ejemplo, un analizador sintáctico que incluya las convenciones de espacios en blanco y comentarios
resulta mucho más complejo que otro que solo deba comprobar que éstos ya hayan sido eliminados
por el analizador léxico. En el caso de la creación de un lenguaje nuevo, la separación de las
convenciones léxicas de las sintácticas puede traducirse en un diseño más claro del lenguaje.
 Mejorar la eficiencia del compilador. Un analizador léxico independiente permite construir un
procesador especializado y potencialmente más eficiente para esta 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.
 Mejorar la portabilidad del compilador. Las peculiaridades del alfabeto de entrada y otras
anomalías propias de los dispositivos pueden limitarse al analizador léxico. Por ejemplo, la
representación de símbolos especiales o no estándar (como ↑ en Pascal) puede ser aislada en esta
etapa.

2.2 COMPONENTES LÉXICOS, PATRONES Y LEXEMAS


Un componente léxico (token) es una secuencia de caracteres que puede ser tratada como una unidad en
la gramática del lenguaje fuente.

Un lenguaje clasifica los componentes léxicos en un conjunto finito de tipos. En la mayoría de los
lenguajes de programación se consideran como componentes léxicos las siguientes construcciones:
 Palabras clave.
 Operadores.
 Identificadores.
 Constantes.
 Cadenas literales.
 Signos de puntuación.

La tabla 2.1 muestra ejemplos para algunos de los tipos de componentes léxicos típicos de un lenguaje
de programación. Puede verse en estos ejemplos que en muchos casos existe un conjunto de cadenas
(strings) para las cuales se produce un mismo componente léxico. Dicho conjunto de cadenas se
describe mediante una regla llamada patrón, para cuya descripción precisa se utiliza la notación de
expresiones regulares. Se dice que el patrón concuerda con cada cadena del conjunto.

Una cadena de caracteres en el programa fuente con la que concuerde el patrón correspondiente a un
componente léxico dado recibe el nombre de lexema. La tabla 2.2 muestra ejemplos de componentes
léxicos, lexemas y patrones.

Jacqueline Köhler C. - USACH


16
Compiladores

Tipo Ejemplo
ID foo n14 sum
NUM 73 0 00 515 082
REAL 66.1 .5 10. 1e67 5.5e-10
IF If
COMMA ,
NOTEQ !=
LPAREN (
RPAREN )

TABLA 2.1: Ejemplos de cadenas de caracteres y componentes léxicos asociados.

En muchos lenguajes de programación existen ciertas cadenas de caracteres que son reservadas, es
decir, tienen un significado predefinido que no puede ser modificado por el usuario. Esto suele ser así
para las palabras clave (for, char, break, etc.). Si las palabras clave no son reservadas, corresponde al
analizador léxico la tarea de distinguir estas palabras de los identificadores, lo que puede resultar
bastante complejo. Por ejemplo, en PL/1 es permitida la siguiente sentencia:

IF THEN THEN THEN = ELSE; ELSE ELSE = THEN;

Componente Lexemas de ejemplo Descripción informal del patrón


léxico
const const const
if if if
relación <, <=, =, <>, >, >= < o <= o = o <> o >= o >
id pi, cuenta, D2 Letra seguida de letras y números
num 3.1416, 0, 6.02E23 Cualquier constante numérica
literal “vaciado de memoria” Cualquier carácter entre “ y ”, excepto
“.

TABLA 2.2: Componentes léxicos, lexemas y patrones.

2.3 ERRORES LÉXICOS


Son pocos los errores que se pueden detectar directamente durante el análisis léxico, puesto que el
analizador léxico tiene una visión muy restringida del programa fuente. Por ejemplo, si en el programa
fuente aparece:

fi(a == f(x)) …

El analizador léxico no puede distinguir si es un error de escritura de la palabra clave if o un


identificador de función que no ha sido previamente declarado. De hecho, al ser un identificador válido

Jacqueline Köhler C. - USACH


17
Compiladores

debe retornar el componente léxico correspondiente. La detección de este error dependerá, en


consecuencia, de otra fase del compilador.

Si surge una situación en la que el analizador léxico no puede continuar porque ninguno de los patrones
concuerda con el prefijo de la entrada restante se pueden efectuar diferentes acciones de recuperación
de error:
 Borrar caracteres hasta encontrar algún componente léxico bien formado.
 Insertar caracteres faltantes.
 Reemplazar un carácter incorrecto por otro correcto.
 Intercambiar caracteres adyacentes.

2.4 IMPLEMENTACIÓN DE ANALIZADORES LÉXICOS


2.4.1 MÉTODOS GENERALES

Existen tres métodos generales para implementar un analizador léxico (lexer):


 Utilizar un generador de analizadores léxicos para crear el analizador léxico deseado a partir de una
especificación basada en expresiones regulares. En este caso, el generador proporciona rutinas para
leer la entrada y manejarla con buffers.
 Escribir el analizador léxico en un lenguaje convencional de programación de sistemas, utilizando
las posibilidades de entrada y salida de este lenguaje para leer la entrada.
 Escribir el analizador léxico en lenguaje ensamblador y manejar explícitamente la lectura de la
entrada.

Las tres opciones han sido presentadas en orden de dificultad creciente para quien deba
implementarlas. Lamentablemente, muchas veces los enfoques más difíciles de implementar dan como
resultado analizadores léxicos más rápidos.

Independientemente del método empleado, las herramientas teóricas útiles para la construcción de
analizadores léxicos son las expresiones regulares, para expresar los componentes léxicos en forma
sencilla, y los autómatas finitos, para la implementación de analizadores léxicos.

2.4.2 CONSTRUCCIÓN DE UN ANALIZADOR LÉXICO

Muchos generadores de analizadores léxicos trabajan tomando como base las expresiones regulares
correspondientes a los patrones de los diversos componentes léxicos de un lenguaje.

Lo primero que debe hacerse es ordenar los diferentes patrones (representados como expresiones
regulares) de acuerdo a su prioridad. Esto debe hacerse cuidadosamente, puesto que si el analizador
léxico resultante detecta que un prefijo de la entrada se ajusta a dos o más patrones diferentes, asignará
a ese lexema el componente léxico de mayor prioridad.

Una vez asignadas las prioridades, se construyen los AFND-ε para cada patrón (una buena idea es usar
el método de construcción de Thompson), donde el estado final de cada autómata tendrá asociada una

Jacqueline Köhler C. - USACH


18
Compiladores

acción. Esta acción corresponde a lo que debe hacerse al momento de detectar un lexema que
concuerda con el patrón asociado al AFND-ε.

A continuación se unen los autómatas obtenidos para dar lugar a un único AFND-ε. Para efectuar esta
unión basta con crear un estado inicial con transiciones vacías hacia cada uno de los estados iniciales
de los AFND-ε de cada patrón. Los estados finales conservan sus acciones asociadas.

Cuando ya se ha construido el AFND-ε que reconoce todos los componentes léxicos del lenguaje, se
debe proceder a convertirlo en un AFD mínimo. Al momento de minimizar se debe recordar que
estados finales que tengan diferentes acciones asociadas no pueden ser equivalentes. El AFD mínimo
obtenido corresponde al analizador léxico para el lenguaje especificado.

Ejemplo 2.1:
Construir el léxico mínimo que reconoce los patrones dados en la tabla 2.3 y les asigna el
componente léxico correspondiente.

TABLA 2.3: Patrones y componentes léxicos para el ejemplo 2.1.

Se debe comenzar por ordenar jerárquicamente los patrones, según su precedencia. Se asigna
mayor precedencia a aquellos patrones que representen conjuntos más pequeños de palabras.
Esto es de vital importancia porque, en el caso de que dos patrones reconozcan un mismo
lexema, deberá ejecutarse la acción de mayor precedencia. En este caso, se tiene que el patrón 
solo coincide con el lexema a. Similarmente, el patrón  solo coincide con el lexema abb. El
patrón     , en cambio, coincide con un conjunto más amplio de lexemas: todos aquellos que
tengan cero o más a seguidas de una o más b. En este caso, podemos observar que el lexema
abb coincide con dos patrones:  y    . Como el patrón  es más limitado, debe tener
una precedencia más alta que    . El patrón , en cambio, no tiene conflictos con los demás
patrones, por lo que no importa qué lugar ocupe en la precedencia.

A continuación, necesitamos construir los AFND-ε reconocedores para los patrones dados,
siguiendo el método de Thompson (ver anexo A). Luego unimos estos tres AFND- ε con un
nuevo estado inicial que tenga transiciones vacías hacia los estados iniciales de nuestros tres
autómatas. El AFND- ε resultante se muestra en la figura 2.2.

Es importante señalar que el reconocimiento de un lexema válido va a estar dado por un estado
final, y que cada estado final corresponde a un patrón diferente. Así, a cada estado final se le
asocia el componente léxico para el patrón correspondiente. Como  reconoce lexemas que
coincidan con el patrón , se le asocia el componente léxico  . De manera similar, a  se le
asocia el componente léxico  y a 
, el componente léxico  .

Jacqueline Köhler C. - USACH


19
Compiladores

, Σ, ,  , , con:


   ,  ,  ,  , 
,  ,  ,  ,  ,  ,  ,  ,  ,  , 

 Σ , 
  
   ,  , 


FIGURA 2.2: AFND- ε para los patrones del ejemplo 2.1.

Como no es posible implementar un AFND-ε, necesitamos encontrar un AFD equivalente (ver


anexo A). No obstante, en la construcción de analizadores léxicos este proceso tiene una
diferencia con respecto al método formal que se utiliza en teoría de autómatas. Según esta
última, en caso de no existir transiciones desde uno de los nuevos estados del AFD para algún
símbolo, es decir, si la clausura es el conjunto vacío, se crea un nuevo estado para el AFD del
que es imposible salir una vez que ha sido alcanzado. En la construcción de analizadores
léxicos, sin embargo, resulta absurda la inclusión de este estado, puesto que impediría revisar el
resto del programa fuente. En consecuencia, lo que se hace es construir un AFD que no tenga la
transición en cuestión (por ejemplo en la figura 2.3 se ve que no existe transición desde el
estado  con el símbolo ). Si se alcanza un estado que no tiene transición saliente con el
próximo símbolo de la entrada, al implementar el analizador léxico se notifica un error léxico.
La figura 2.3 muestra el AFD resultante.

     ,  ,  ,  ,  ,   

,   , 
,      , 
,    , 
,  ,  ,     
,         ,  , 
   

Jacqueline Köhler C. - USACH


20
Compiladores

,         ,  ,   !


,   ,      ,    ,  ,  , 
 "  

,  #   # #
,         ,  , 
   

!,        !


!,        

",  #   # #
",   ,      ,    ,  ,  , 
 $  

,  #   # #
,        

$,  #   # #
$,        

Los estados finales del AFD también llevan asociada una acción. En caso de que uno de los
estrados finales comprenda más de una acción, se escoge aquella de mayor prioridad. Por
ejemplo, el estado $ del AFD comprende los estados finales correspondientes a las acciones 
y  , pero se le asocia solamente  por tener una precedencia más alta.

FIGURA 2.3: AFD equivalente al AFND- ε de la figura 2.2.

Es frecuente que el método empleado para obtener el AFD equivalente entregue estados
redundantes, por lo que se requiere minimizar el AFD obtenido. Si se considera el método de
las particiones para la minimización (ver anexo A), la teoría de autómatas indica que la
partición inicial viene dada por la separación de estados finales y no finales. No obstante, en el
caso de los analizadores léxicos los estados finales tienen asociadas diferentes acciones con
diferentes prioridades, por lo que es necesario particionar también de acuerdo a la acción a

Jacqueline Köhler C. - USACH


21
Compiladores

realizar. Así, aquellos estados finales en que se ejecute una acción no serán equivalentes a los
estados finales que ejecuten una acción diferente. La figura 2.4 muestra el AFD mínimo.

' ' $&' , ) , ' (


% &  , ! (  " 
  ) '

!$&*, *("
%   +
  ) ' *

% % ,-

FIGURA 2.4: AFD mínimo equivalente al AFND- ε de la figura 2.2.

2.5 EJERCICIOS
1. Dados los patrones de la tabla 2.4 y sus componentes léxicos asociados:
a. Asigne las prioridades a considerar para construir el analizador léxico correspondiente.
b. Indique, para cada uno de los siguientes lexemas, el componente léxico que debiera asociarle el
analizador léxico. En caso de no corresponder con ninguno, señale la existencia de un error
léxico.
 .
 .
 .
 .
 .

TABLA 2.4: Patrones y componentes léxicos para el ejercicio 1.

Jacqueline Köhler C. - USACH


22
Compiladores

2. Construya el analizador léxico mínimo para los siguientes patrones (El símbolo entre paréntesis
asociado a cada patrón corresponde al componente léxico asociado).
  .   ( ).
  ( ).
  ( ).

3. Construya el analizador léxico mínimo para los siguientes patrones (El símbolo entre paréntesis
asociado a cada patrón corresponde al componente léxico asociado).
 / . 0/ . 0 ( ).
 / . 0 ( ).
 // ( ).

Jacqueline Köhler C. - USACH


23
Compiladores

3. ANÁLISIS SINTÁCTICO
Así como en la construcción de analizadores léxicos trabajamos con lenguajes regulares, los
analizadores sintácticos se construyen sobre la base de una gramática libre de contexto o GLC (ver
anexo A). Aquí la gramática corresponde a la especificación del lenguaje de programación, y se usan
autómatas apiladores (ver anexo A) como reconocedores de programas sintácticamente correctos.

3.1 CONCEPTOS PREVIOS


Antes de comenzar a construir analizadores sintácticos es necesario conocer algunos conceptos y
algunas transformaciones que en ocasiones es necesario realizar sobre la gramática.

3.1.1 RECURSIVIDAD POR LA IZQUIERDA

Una gramática es recursiva por la izquierda si tiene un no terminal  tal que existe una derivación
 1 2 para alguna cadena 2. Para eliminar las recursiones directas, se agrupan las producciones del
no terminal A de la siguiente manera:
 1 2 | 2 | … | 25 | 6 | 6 | … | 67
Donde ninguna de las producciones 68 comienza por el no terminal . Luego se sustituyen las
producciones de  por:
 1 6 9 | 6 : | … | 67 :
9 1 2 9 | 2 : | … | 25 :

Muchas veces no basta con eliminar las recursiones directas. Pueden existir recursiones por la izquierda
que tarden más en aparecer. Para eliminar este tipo de recursiones se puede usar el algoritmo que se
muestra a continuación, siempre que la gramática no contenga ciclos (derivaciones de la forma
 1 ) ni producciones . Nótese, sin embargo, que la gramática resultante sí puede contener
producciones :
1. Ordenar los no terminales en un cierto orden fijo (cualquiera)  ,  , … , 7 .
2. Para (; 1 hasta =):
Para (> 1 hasta ;  1):
 Si las producciones de ? son ? 1  |… | @ , reemplazar cada producción 8 1 ? A por
8 1  A |… | @ A.
 Eliminar recursividad directa por la izquierda para 8 .

Ejemplo 3.1:
Elimine toda recursión por la izquierda para $ Σ, N, %, C, donde:
 Σ , , , .
 N C, D.
 P C 1 D | ,
D 1 DC | C.
 C C.

Jacqueline Köhler C. - USACH


24
Compiladores

Consideremos los no terminales en el orden C, D. Para cada no terminal, siguiendo el orden


asignado a ellos, ejecutar los siguientes pasos:
 Paso 1 (no aplica para el primer símbolo): en cada producción del no terminal actual  que
comience por un no terminal ya revisado , crear nuevas producciones para  en que se
reemplace  con sus respectivas producciones.
 Paso 2: eliminar la recursión directa por la izquierda para el no terminal actual.

C 1 D | : como C es el primer no terminal, solo se debe eliminar la recursión directa. En este
caso no existe recursión.

D 1 DC | C: en primer lugar, se busca cada producción de D que comience con algún no
terminal ya revisado (en este caso C) y se crean nuevas producciones para D en que C sea
sustituido por sus producciones. Así, se obtiene D 1 DC | D | .

D 1 DC | D | : una vez realizado el primer paso, se debe eliminar la recursión directa por la
izquierda, con lo que se obtiene D 1 D | ,  1 C | .

Así, la gramática sin recursión por la izquierda es $9 Σ, N9, %9, C, donde:
 Σ , , , .
 N9 C, D, .
 P9 C 1 D | ,
D 1 D | ,
 1 C | .
 C C.

3.1.2 FACTORIZACIÓN POR LA IZQUIERDA

La factorización por la izquierda se ocupa de agrupar producciones semejantes, descomponiéndolas en


un fragmento común y otro diferente. Esto es de utilidad al momento de construir algunos analizadores
sintácticos, pues sirve en aquellos casos en que no está claro cuál de dos o más producciones
alternativas utilizar para ampliar un no terminal . Así, las producciones de  pueden reescribirse para
postergar la decisión hasta haber visto lo suficiente de la entrada como para tomar la decisión correcta.
Tómense por ejemplo las producciones A 1 αβ | αβ. Como puede ser difícil saber si escoger αβ o
αβ , se pueden crear las siguientes producciones para postergar la decisión: A 1 αB y B 1 β | β.

El algoritmo para factorizar completamente una gramática es el siguiente:


1. Repetir:
a. Para cada no terminal A, encontrar el prefijo α más largo común a dos o más de sus
producciones.
b. Si α J ε, sustituir todas las producciones de A, A 1 αβ | αβ | … | αβL | γ (donde γ representa
a todas las producciones de A que no comienzan por α) por A 1 αB | γ y B 1 β | β | … | αβL ,
donde B es un nuevo no terminal.
Mientras haya dos producciones para un no terminal con un prefijo común.

Jacqueline Köhler C. - USACH


25
Compiladores

Ejemplo 3.2:
Factorice por la izquierda la gramática $ Σ, N, P, S:
 Σ a, b
 N A, B, C, D
 P   1  | ! |  | ! | ,
 1  | ! |  | ! | ,
 1  | ! |  | !,
! 1  | ! |  | ! |  |
| ! | !! | ! | !! | !
 S A.

En el caso de , podemos comenzar por las producciones de la forma  1 β, de donde se


obtiene:
 1 " |  | ! | 
" 1  | !
Ahora podemos factorizar aquellas producciones de la forma  1 β, quedando:
 1  |  | !
 1 " | 

Y terminar la factorización de este no terminal con las producciones de la forma  1 β, de


donde se obtiene:
 1  | "
" 1  | !

Nótese que no se creó un no terminal nuevo porque ya existía uno con exactamente las mismas
producciones.

Para el no terminal , podemos comenzar con las producciones de la forma  1 β:


 1 $ |  | ! | 
$ 1  | !

Continuar con las de la forma  1 β:


 1 T |  | !
T 1 $ | 

Y terminar con las de la forma  1 β:


 1 T | $

Para el no terminal , podemos comenzar con las producciones de la forma  1 β:


 1 U |  | !
U 1!|

Y terminar con las de la forma  1 β:


 1 U | U

Para el no terminal !, podemos comenzar con las producciones de la forma ! 1 β:

Jacqueline Köhler C. - USACH


26
Compiladores

! 1 U | ! |  | ! |  |


| !! | ! | !! | !

Continuar con aquellas de la forma ! 1 !β:


! 1 U | !U |  | ! |  | ! | !! | !

Seguir con aquellas de la forma ! 1 β:


! 1 U | !U |  | ! | U | ! | !!

Seguir con aquellas de la forma ! 1 β:


! 1 V |  | ! | U | ! | !!
V 1 U | !U

Y ahora tomar las de forma ! 1 β:


! 1 W |  | ! | ! | !!
W 1 V | U

Para seguir con aquellas de forma ! 1 β:


! 1 W | U | ! | !!

Y luego las de forma ! 1 !β:


! 1 W | U | !U

Para terminar con aquellas de la forma ! 1 β:


! 1 W | V

Así, la gramática factorizada por la izquierda es $9 Σ, N9, P9, S:


 Σ a, b
 N9 A, B, C, D, E, F, G, H, I, J, K
 P9   1  | " ,
 1 T | $,
 1 U | U,
! 1 W | V,
" 1  | !,
 1 " | ,
$ 1  | !,
T 1 $ | ,
U 1 ! | ,
V 1 U | !U,
W 1 V | U
 S A.

Jacqueline Köhler C. - USACH


27
Compiladores

3.1.3 CONJUNTOS ANULABLE, PRIMERO Y SIGUIENTE

Existen dos funciones asociadas a una gramática $ que facilitan la construcción de analizadores
sintácticos: Primero y Siguiente. Ambas aportan información relativa al contexto de un símbolo dado.

3.1.3.1 Conjunto Anulable

Antes de definir las dos funciones ya mencionadas es necesario introducir el conjunto anulable, _` ,
definido como el conjunto de todos aquellos no terminales que pueden, en uno o más pasos, derivar a .
Es decir, _`  a _:  1 .

3.1.3.2 Conjunto Primero

Dada una cadena de símbolos gramaticales 2, se define su Conjunto Primero, %2, como el conjunto
de terminales que pueden iniciar las secuencias derivadas a partir de 2.

Sean  a Σ; ,  ,  , … , 8 , … , 7 a N y 2 a Σ b N . Para calcular %c para todos los símbolos c


(terminales y no terminales) de la gramática, se deben aplicar las siguientes reglas:
1. % #.
2. %2 .
3. Si se tienen las producciones  1  |  | … | 7 , entonces % %  b %  b … b %7 .
4. Si se tiene la producción  1   … 7 , entonces:
 % % .
 Mientras 8 a _` , % % b %8 .

3.1.3.3 Conjunto Siguiente

Dado un no terminal , se define su Conjunto Siguiente, C, como el conjunto de terminales que
pueden aparecer inmediatamente a la derecha de  en alguna forma de frase, es decir, el conjunto de
terminales  tales que exista una derivación de la forma C 1 26 para algún 2 y algún 6.
Adicionalmente, si es posible que  sea el símbolo situado más a la derecha en alguna forma de frase, o
sea, no puede venir nada más a continuación de , entonces $ a C, donde $ indica el término de la
secuencia (puede pensarse en $ de una manera similar al carácter especial de fin de archivo, "e).

Sea C el símbolo inicial de la gramática; sean 2, 6 cadenas de símbolos gramaticales (terminales y no


terminales) y sean ,  a _. El cálculo de Ccfc a _requiere de la aplicación de las siguientes
reglas:
1. Agregar $ a CC.
2. Si se tiene la producción  1 26, con 6 J , entonces C %6.
3. Si existe la producción  1 2, entonces C C.
4. Si se tiene la producción  1 26, con 6 1. , entonces C %6 b C.

Jacqueline Köhler C. - USACH


28
Compiladores

Note que la regla 4 es una combinación de las dos reglas anteriores. En ella, 6 podría no anularse, en cuyo
caso debemos considerar la regla 2. Pero también puede darse que 6 se anule, en cuyo caso se cumple la regla 3.
En consecuencia, debemos considerar todas las posibilidades.

Ejemplo 3.3:
Determine el Conjunto Anulable y los conjuntos %c y Cc, c a _, para $ Σ, N, %, C,
donde:
 Σ .,, , , /.
 N , , , !, ".
 P  1 !,
 1 ",
 1  | /,
! 1 .! | ,
" 1 " | .
 C .

El conjunto anulable está conformado por todos aquellos terminales que, en una o más
derivaciones, pueden generar la secuencia vacía. Así, el conjunto anulable para $ está dado por
_` !, ".  g _` pues todas sus producciones contienen terminales.  g _` pues su única
producción comienza por , que tampoco lo es. Lo mismo ocurre para .

% %hi b %/ % b /  b / , /


% %" % , /
% %! % , /

%! %.! b % %. b # .


%" % !" b % % b # 

C $ b % $ b  $, 


C! C b C! C $, 
C %! b C b %! b C! . b $,  b $,  $, , .
C" C b C" C $, , .
C %" b C b %" b C"  b $, , . b $, , . $, , .,

3.2 DESCRIPCIÓN GENERAL DEL ANÁLISIS SINTÁCTICO

Según el diccionario de la Real Academia Española (2011), la sintaxis es la “parte de la gramática que
enseña a coordinar y unir las palabras para formar las oraciones y expresar conceptos.” En una segunda
acepción, define sintaxis como un “conjunto de reglas que definen las secuencias correctas de los
elementos de un lenguaje de programación”. Esta última definición implica que todo lenguaje de
programación tiene reglas que prescriben la estructura de programas bien formados. Generalmente, un
programa está formado por bloques. A su vez, los bloques están conformados por proposiciones, las
cuales se forman a partir de expresiones, y estas últimas se forman con componentes léxicos:

Programa 1 bloques 1 proposiciones 1 expresiones 1 componentes léxicos

Jacqueline Köhler C. - USACH


29
Compiladores

Anteriormente vimos que el analizador léxico y el analizador sintáctico trabajan en conjunto y que este
último obtiene como entrada una cadena de componentes léxicos entregada por el primero (ver figura
3.1). En este punto el analizador sintáctico comprueba que la cadena pueda ser generada por la
gramática del lenguaje fuente. También informa de cualquier error de sintaxis de manera inteligible, y
debería recuperarse de los errores que ocurren frecuentemente para poder continuar procesando el resto
de su entrada. Como resultado del análisis sintáctico se obtiene un árbol de derivación que genera la
secuencia de componentes léxicos a partir del símbolo inicial de la gramática.

Los analizadores sintácticos empleados generalmente en los compiladores se clasifican como


descendentes o ascendentes. Como sus nombres indican, los analizadores sintácticos descendentes
construyen el árbol desde arriba (la raíz, dada por el símbolo inicial de la gramática) hacia abajo (las
hojas, consistentes en los componentes léxicos), mientras que los analizadores sintácticos ascendentes
comienzan en las hojas y suben hacia la raíz. En ambos casos, se examina la entrada al analizador
sintáctico de izquierda a derecha, un símbolo a la vez.

Los métodos descendentes y ascendentes más eficientes trabajan sólo con subclases de las gramáticas
libres de contexto, pero varias de estas subclases, como las gramáticas LL y LR, son lo suficientemente
expresivas para describir la mayoría de las construcciones sintácticas de los lenguajes de programación.
Los analizadores sintácticos implementados a mano a menudo trabajan con gramáticas LL(1). Los
analizadores sintácticos para la clase más grande de gramáticas LR se construyen normalmente con
herramientas automatizadas.

FIGURA 3.1: Posición del analizador sintáctico en el modelo de un compilador.

Como ya se señaló, la salida del analizador sintáctico es una representación del árbol de análisis
sintáctico para la cadena de componentes léxicos producida por el analizador léxico. En la práctica, no
obstante, hay varias tareas que se pueden realizar durante el análisis sintáctico, como recoger
información sobre distintos componentes léxicos en una tabla de símbolos, realizar la verificación de
tipo y otras clases de análisis semántico, y generar código intermedio. No obstante, estas actividades se
verán más adelante.

3.3 ALGUNOS ASPECTOS DEL MANEJO DE ERRORES SINTÁCTICOS


Si un compilador tuviera que procesar sólo programas correctos, su diseño e implementación se
simplificarían mucho. Pero los programadores a menudo escriben programas incorrectos y un buen

Jacqueline Köhler C. - USACH


30
Compiladores

compilador debería ayudar al programador a identificar y localizar errores. Estos errores pueden ser de
diversos tipos, por ejemplo:
 Léxicos, como escribir mal un identificador, palabra clave u operador.
 Sintácticos, como una expresión aritmética con paréntesis no equilibrados.
 Semánticos, como un operador aplicado a un operando incompatible.
 Lógicos, como una llamada infinitamente recursiva.

A menudo, gran parte de la detección y recuperación de errores en un compilador se centra en la fase


de análisis sintáctico. Una razón es que muchos errores son de naturaleza sintáctica o se manifiestan
cuando la cadena de componentes léxicos que proviene del analizador léxico desobedece las reglas
gramaticales que definen al lenguaje de programación.

El manejador de errores en un analizador sintáctico tiene objetivos fáciles de establecer:


 Informar de la presencia de errores con claridad y exactitud.
 Recuperarse de cada error con la suficiente rapidez como para detectar errores posteriores.
 No retrasar de manera significativa el procesamiento de programas correctos.
Existen diferentes estrategias generales que puede emplear un analizador sintáctico para recuperarse de
un error:
 Recuperación en modo de pánico: al descubrir un error, el analizador sintáctico desecha símbolos
de entrada, de uno en uno, hasta que encuentra uno perteneciente a un conjunto designado de
componentes léxicos de sincronización. Estos componentes léxicos de sincronización suelen ser
elementos de los conjuntos Primero y Siguiente, como por ejemplo los delimitadores (punto y
coma, paréntesis derecho) o el indicador de término de la entrada ($).
 Recuperación a nivel de frase: al descubrir un error, el analizador sintáctico puede realizar una
corrección local de la entrada restante; es decir, puede sustituir un prefijo de ésta por alguna cadena
que permita continuar al analizador sintáctico (suprimir un punto y coma sobrante, reemplazar una
coma por un punto y coma, etc.).
 Producciones de error: si se tiene una buena idea de los errores comunes que pueden encontrarse,
se puede aumentar la gramática del lenguaje con producciones que generen las construcciones
erróneas. Entonces se usa esta gramática aumentada con las producciones de error para construir el
analizador sintáctico. Si el analizador sintáctico usa una producción de error, se pueden generar
diagnósticos de error apropiados para indicar la construcción errónea reconocida en la entrada.
 Corrección global: idealmente, sería deseable que un compilador hiciera el mínimo de cambios
posibles al procesar una cadena de entrada incorrecta. Existen algoritmos para elegir una secuencia
mínima de cambios para obtener una corrección global de menor costo. Por desgracia, la
implementación de estos métodos es, en general, demasiado costosa en términos de tiempo y
espacio, así que estas técnicas en la actualidad sólo son de interés teórico.

3.4 ANÁLISIS SINTÁCTICO PREDICTIVO


3.4.1 ASPECTOS GENERALES

El análisis sintáctico descendente puede verse como un intento de encontrar, usando las producciones
de una gramática dada y comenzando por el símbolo inicial, una derivación por la izquierda para una
cadena dada. Se estudiará en forma particular el análisis sintáctico predictivo (también denominado

Jacqueline Köhler C. - USACH


31
Compiladores

análisis sintáctico LL(1) o análisis sintáctico por descenso recursivo), que es el más utilizado gracias a
su eficiencia.

En el análisis sintáctico descendente se ejecuta un conjunto de procedimientos recursivos para procesar


la entrada. En el caso particular del análisis sintáctico predictivo, el símbolo de la entrada determina sin
ambigüedad el procedimiento seleccionado para cada no terminal. En otras palabras, para construir un
analizador sintáctico de este tipo es necesario conocer, dada la entrada  y el no terminal  a expandir,
cuál de todas las posibles producciones de  1 2 | 2 | … | 27 es la única alternativa que genera una
cadena que comience por . Es decir, debe ser posible detectar la alternativa apropiada con solo ver el
primer símbolo que genera.

De lo anterior se desprende que no cualquier gramática libre de contexto es apropiada para el análisis
sintáctico predictivo, sino que se requieren gramáticas que no contengan recursiones por la izquierda y
que estén factorizadas también por la izquierda.

3.4.2 CONSTRUCCIÓN Y FUNCIONAMIENTO DE ANALIZADORES SINTÁCTICOS LL(1)

Se señaló anteriormente que los analizadores sintácticos descendentes construyen el árbol de análisis
sintáctico comenzando desde la raíz, es decir, desde el símbolo inicial de $. Para este fin se sirve de
una tabla de análisis sintáctico que indica qué producción debe emplearse al momento de reemplazar
un no terminal. Para la construcción de dicha tabla se emplea el algoritmo siguiente:
1. Para cada producción  1 2, 2 J , hacer:
a. Para cada terminal  en %2, añadir la producción  1 2 en jk, l.
b. Si  a _` , para cada terminal  en C, añadir la producción  1  en jk, l.
3. Hacer que cada entrada no definida de la tabla corresponda a un error.

Si en alguna entrada de la tabla queda más de una producción, entonces se dice que no es posible
construir un analizador sintáctico LL(1).

Cabe destacar que el analizador sintáctico así construido es un autómata apilador. Inicialmente, la pila
contiene al símbolo inicial de la gramática al tope, seguido del delimitador de la entrada. Cada vez que
se tenga un no terminal  al tope de la pila y un terminal  al comienzo de la entrada, el no terminal 
de la pila es reemplazado por el lado derecho de la producción contenida en jk, l. Adicionalmente,
es necesario considerar transiciones que permitan eliminar un símbolo terminal de la pila si éste
coincide con el de la entrada. La aceptación solo ocurre si tanto la pila como la entrada quedan vacías
(solo con el delimitador de la entrada).

Ejemplo 3.4:
Construya la tabla de análisis sintáctico predictivo para $ Σ, N, %, C, donde:
 Σ .,, , , /.
 N , , , !, ".
 P  1 !,
 1 ",
 1  | /,
! 1 .! | ,

Jacqueline Köhler C. - USACH


32
Compiladores

" 1 " | .


 C .

Muestre la traza y el árbol sintáctico para las entradas m / . /  / y m /  /.

Sabemos que:
 _` !, ".
 % % % , /
 %! .
 %" 
 C C! $, 
 C C" $, , .
 C $, , .,

En primer lugar, para cada producción  1 2 no vacía, debemos añadir dicha producción en la
posición jk, l de la tabla para cada terminal  a %2:

TABLA 3.1: Incorporación de las producciones no vacías al analizador sintáctico LL(1).

A continuación, en aquellos casos en que  a _` , para cada terminal  a C, añadir la


producción  1  en jk, l. Nótese que en este paso no solo se agregan las producciones
vacías presentes explícitamente en la gramática, sino también aquellas que se pueden obtener en
varios pasos. Una vez incorporadas estas producciones, el analizador sintáctico predictivo ya
está terminado. El resultado se muestra en la tabla 3.2.

TABLA 3.2: Analizador sintáctico LL(1) completo.

Las figuras 3.2 y 3.3 muestran la traza para las dos entradas solicitadas con sus respectivos
árboles sintácticos.

Jacqueline Köhler C. - USACH


33
Compiladores

FIGURA 3.2: Traza y árbol sintáctico resultante para la entrada m / . /  /.

FIGURA 3.3: Traza y árbol sintáctico resultante para la entrada m /  /.

Jacqueline Köhler C. - USACH


34
Compiladores

Ejemplo 3.5:
Construya la tabla de análisis sintáctico LL(1) para $ Σ, N, %, C, donde:
 n k, l, o, p, .,, , #, /.
 N , , , !.
 %  1 kl |  ,
 1   ! | ,
 1 !   | ,
! 1 #! | o  p | /
 C .

$ No requiere eliminación de recursión ni factorización, Por lo que podemos trabajar con ella
tal como está.

Comenzamos por determinar los conjuntos Anulable, Primero y Siguiente:


_` , , 

% %kl b % k b % b % b % k b #, o, /,  b #, o, /
#, o, /, , k
% %  ! b % % b % b # #, o, / b  #, o, /, 
% %!   b % %! b # #, o, /
%! %#! b %o  p b %/ # b o b / #, o, /

C $ b %l $ b l $, l


C % b C b C b %p #, o, / b $, l b $, l,  b p $, l, , #, o, /, p
C C b %! $, l b  $, l, 
C! C b %  b C! $, l, , #, o, /, p b  $, l, , #, o, /, p,

Ahora, para cada producción  1 2, debemos añadir dicha producción en la posición jk, l de
la tabla para cada terminal  a %2, como muestra la tabla 3.3:

TABLA 3.3: Incorporación de las producciones no vacías al analizador sintáctico predictivo.

A continuación, en aquellos casos en que  a _` , para cada terminal  a C, añadir la


producción  1  en jk, l. Como se puede ver en la tabla 3.4, no es posible construir un
analizador sintáctico predictivo para $, pues la tabla presenta conflictos en jk, ol, jk, l,
jk, #l y jk, /l.

Jacqueline Köhler C. - USACH


35
Compiladores

TABLA 3.4: Analizador sintáctico LL(1) completo.

3.4.3 GRAMÁTICAS LL(1)

El algoritmo de construcción para analizadores sintácticos LL(1) puede ser aplicado a cualquier
gramática $. No obstante, si $ es ambigua o recursiva por la izquierda se tendrá al menos una entrada
de la tabla con más de una definición.

Como para el análisis sintáctico predictivo se requiere poder determinar sin ambigüedad qué
producción utilizar, será necesario utilizar un subconjunto de las gramáticas libres de contexto: las
gramáticas LL(1). La primera L del nombre hace referencia a que el análisis se efectúa de izquierda a
derecha. La segunda, a que se deriva por a izquierda. El 1 indica que se examina un único símbolo de la
entrada antes de decidir qué producción ocupar.

Se dice que una gramática es LL(1) cuando no es ambigua ni recursiva por la izquierda. Se puede
demostrar que una gramática es de este tipo si y solo si, cuando  1 2 | 6 sean dos producciones
diferentes de $, se cumplen las siguientes condiciones:
 Para ningún terminal , tanto 2 como 6 derivan a la vez cadenas que comiencen por .
 A lo sumo una de las producciones 2 o 6 pueden derivar la cadena vacía.
 Si 6 1 , 2 no genera ninguna cadena que comience con un terminal en C.

3.4.4 RECUPERACIÓN DE ERRORES EN ANÁLISIS SINTÁCTICO LL(1)

La pila de un analizador sintáctico no recursivo hace explícitos los terminales y no terminales que el
analizador espera emparejar con el resto de la tabla. En consecuencia, durante las siguientes
explicaciones se hará referencia a los símbolos de la pila.

La detección de un error se produce cuando el terminal al tope de la pila no concuerda con el siguiente
símbolo de la entrada o bien cuando en el tope de la pila se encuentra el no terminal , el siguiente
símbolo de la entrada es  y la entrada jk, l de la tabla no se encuentra definida.

3.4.4.1 Recuperación de errores en modo de pánico

Como se explicó con anterioridad, este tipo de recuperación de error consiste en eliminar símbolos de
la entrada hasta encontrar algún componente léxico que pertenezca a un conjunto de componentes
léxicos de sincronización. No obstante, se debe tener cuidado al elegir estos componentes de
sincronización. Algunas técnicas que se podrían emplear son:

Jacqueline Köhler C. - USACH


36
Compiladores

 Colocar dentro del conjunto de sincronización para el no terminal  todos los símbolos contenidos
en C. Probablemente el análisis sintáctico podrá continuar si se eliminan componentes de la
entrada hasta encontrar algún elemento de C y se elimina el no terminal  de la pila.
 En ocasiones puede ser necesario un conjunto de elementos de sincronización más grande. Por
ejemplo, podría ser útil añadir al conjunto de sincronización de una construcción de menor
jerarquía aquellos componentes que inician las construcciones de una jerarquía mayor.
 Pueden añadirse al conjunto de sincronización aquellos símbolos terminales contenidos en %.
 Si un no terminal puede generar , puede usarse esta producción por omisión.
 Si no se puede descartar un terminal de la pila, puede eliminarse dicho terminal.

Ejemplo 3.6:
Considere el analizador sintáctico LL(1) del ejemplo 3.4. Muestre la traza para la entrada
m /  / usando recuperación de errores en modo de pánico.

Usaremos Cc como conjunto de sincronización para cada no terminal:


 C C! $, 
 C C" $, , .
 C $, , .,

TABLA 3.5: Traza con recuperación de errores en modo de pánico para la entrada m /  / usando
el analizador sintáctico de la tabla 3.2.

3.4.4.2 Recuperación de errores a nivel de frase

Consiste en llenar las entradas en blanco (entradas de error) de la tabla de análisis sintáctico con
punteros a funciones de error. Estas funciones podrían cambiar, insertar o eliminar símbolos de la
entrada y enviar los mensajes de error apropiados. No obstante, no será tratada a fondo en este curso.

Jacqueline Köhler C. - USACH


37
Compiladores

3.5 ANÁLISIS SINTÁCTICO DESCENDENTE (POR DESPLAZAMIENTO Y


REDUCCIÓN)
3.5.1 ASPECTOS GENERALES

Este tipo de análisis sintáctico intenta construir un árbol de análisis sintáctico para una secuencia de
componentes léxicos comenzando desde las hojas y avanzando hacia la raíz. En otras palabras, se
reduce la secuencia de componentes léxicos de la entrada hasta tener solamente el símbolo inicial de la
gramática. En cada paso de reducción se sustituye una subcadena de la entrada que concuerde con el
lado derecho de una producción por el no terminal del lado izquierdo de la misma (en otras palabras, si
se tiene la producción  1 2, la reducción reemplaza la secuencia 2 por el no terminal ). Si en cada
paso se escoge correctamente la subcadena a reemplazar, el resultado final es la traza de una derivación
por la derecha en sentido inverso.

Ejemplo 3.7:
Sea $ Σ, N, %, C, donde:
 n , , ), ', *.
 N C, , .
 % C 1 *,
 1 ) | ,
 1 '
 C C.

La cadena m )'* puede reducirse en los siguientes pasos:


 )'* ( 1 )
 )'* ( 1 ))
 '* ( 1 ')
 * (C 1 *)
 C

Estas reducciones trazan, en sentido inverso, la derivación:


C 1 * 1 '* 1 )'* 1 )'*.

Una manera de implementar el análisis sintáctico por desplazamiento y reducción es mediante un AFD
con una pila asociada. Inicialmente, la pila solo contiene el estado inicial del AA, mientras que la
cadena m a analizar se encuentra en la entrada seguida del delimitador (es decir, m$). El analizador
sintáctico desplaza cero o más símbolos de la entrada a la pila hasta que se reconozca el lado derecho 2
de una producción  1 2 y entonces se reduce 2 reemplazándolo por el lado izquierdo de la
producción correspondiente (el no terminal ). Se repite este proceso hasta encontrar un error o hasta
que la pila solo contenga al símbolo inicial de la gramática y la entrada esté vacía.

Aunque las principales operaciones de este tipo de analizador sintáctico son el desplazamiento y la
reducción, existen en realidad cuatro acciones diferentes:
 Desplazar: se desplaza el siguiente símbolo de la entrada al tope de la pila, seguido del nuevo
estado del AFD.
 Reducir: se sustituye el lado derecho de una producción, contenido en la pila, por su lado izquierdo.

Jacqueline Köhler C. - USACH


38
Compiladores

 Aceptar: anuncia el término exitoso del análisis sintáctico.


 Error: llama a una rutina de recuperación de error.

Ahora bien, existen algunas GLC en que un analizador sintáctico por desplazamiento y reducción
puede alcanzar una configuración donde, conociendo el contenido de la pila y el siguiente símbolo de
la entrada, sea imposible decidir si efectuar un desplazamiento o una reducción (conflicto
desplazamiento/reducción), o bien qué reducción efectuar (conflicto reducción/reducción). Para evitar
este tipo de problemas se utilizará un subconjunto de las gramáticas independientes del contexto: la
clase de gramáticas LR(k). En consecuencia, el análisis sintáctico ascendente suele recibir el nombre de
análisis sintáctico LR, por left-to-right parse, rightmost derivation. Lee la entrada de izquierda a
derecha y construye una derivación por la derecha en orden inverso. Adicionalmente, la k entre
paréntesis corresponde al número de símbolos de la entrada que son considerados al momento de
decidir qué acción ejecutar. Cabe señalar que en la práctica no se usa r p 1 para la compilación, pues
se requieren tablas demasiado grandes.
La familia de métodos LR permite analizar un superconjunto de la clase de gramáticas LL(1), es decir,
DD1 s Dt. Es posible construir analizadores sintácticos LR para reconocer prácticamente todas las
construcciones de los lenguajes de programación definidos mediante GLC, por lo que este esquema es
el más utilizado.

3.5.2 GRAMÁTICAS LR(K)

Anteriormente se señaló que existen casos en que un analizador sintáctico por desplazamiento y
reducción tiene problemas para decidir qué acción ejecutar. Para que opere correctamente, este tipo de
analizador sintáctico debe ser capaz de decidir si reemplazar o reducir conociendo solamente los
símbolos de la pila y los siguientes k elementos de la entrada.

Sean dos producciones de  cuyos lados derechos sean  1 26m | 260, respectivamente. Se puede
observar que dichas producciones comparten el prefijo 26. Supóngase además que los primeros r
símbolos de ambas producciones son los mismos.

Como ambas producciones son iguales tanto en la pila como en la porción de la cadena visible para el
analizador, entonces la gramática será LR(k) si y solo si 26m 260.

3.5.3 ANÁLISIS SINTÁCTICO SLR

El primer método de la familia LR que estudiaremos recibe su nombre por Simple Left-to-Right parser.
No obstante, antes de construir un analizador sintáctico de esta clase es necesario definir algunos
conceptos.

3.5.3.1 Elemento LR(0)

Un elemento LR(0) es una producción de la gramática $ con un punto en algún lugar del lado derecho.
Por ejemplo, la producción  1 cuv produce cuatro elementos LR(0):

Jacqueline Köhler C. - USACH


39
Compiladores

  1w cuv
  1 c w uv
  1 cu w v
  1 cuv w

Es importante señalar que una producción  1  producirá solamente el elemento  1w.


Intuitivamente, un elemento LR(0) indica hasta dónde se ha leído una producción en un momento dado
del proceso de análisis sintáctico. El primer elemento del ejemplo anterior indica que se espera ver en
la entrada una cadena derivable a partir de cuv. El segundo elemento indica que se ha leído una cadena
derivable a partir de c y que a continuación se espera leer otra derivable de uv, etc.

3.5.3.2 Operación Clausura

Si  es un conjunto de elementos LR(0) para una gramática $, entonces la xyzy{ es el conjunto


de elementos LR(0) construido a partir de  según las siguientes reglas:
1. Inicialmente, hacer xyzy{ .
2. Si  1 2 w 6 a xyzy{ y 1A es una producción, hacer
xyzy{ xyzy{ b  1w A. Aplicar esta regla hasta que no sea posible añadir más
elementos a xyzy{.

Ejemplo 3.8:
Sea $ Σ, N, %, C, donde:
 n .,, , , /.
 N , , , !.
 %  1 ,
 1  .  | ,
 1   ! | !,
! 1  | /
 C .
Sea además   1w . Determine xyzy{.

Por regla 1:
xyzy{  1w 

Por regla 2, se deben agregar las producciones de :


xyzy{  1w ,  1w  . ,  1w 

Por regla 2, se deben añadir también las producciones de  :


xyzy{  1w ,  1w  . ,  1w ,  1w   !,  1w !

Por regla 2, se deben añadir ahora las producciones de !:


xyzy{  1w ,  1w  . ,  1w ,  1w   !,  1w !, ! 1w , ! 1w /

Jacqueline Köhler C. - USACH


40
Compiladores

3.5.3.3 Construcción del autómata

La idea central del método SLR es construir, a partir de $, un AFD (considerando la definición no
estricta, en que cada estado tiene a lo más una transición por cada símbolo del alfabeto) con una pila
asociada que permita reconocer los prefijos viables (es decir, que se pueden derivar a partir de alguna
producción). Para este fin se agrupan los elementos LR(0) en conjuntos que conforman los estados del
AFD. Además, el alfabeto del AFD está dado por todos los terminales y no terminales de la gramática.

El primer paso necesario para la construcción del AFD es aumentar la gramática $ con una nueva
producción C9 1 C, donde C9 es un nuevo símbolo inicial, a fin de asegurar que el símbolo inicial tenga
una única producción. Esta modificación tiene por objeto indicar en qué momento se debe detener el
análisis sintáctico y aceptar la cadena: es decir, cuando se está a punto de hacer la reducción de C9 1 C.
Nótese que esta nueva producción no pasa a formar parte de la gramática, sino que se usa
exclusivamente para la construcción del estado inicial del AFD.

El estado inicial del AFD está dado por  xyzy{ C9 1w C. A continuación se determinan
las transiciones con todos aquellos símbolos precedidos por un punto en algún elemento LR(0) de  .

Para los nuevos estados, se determina la xyzy{ de los elementos LR(0) que le dan origen, es
decir, aquellos del estado anterior con el punto desplazado en una posición hacia la derecha. Las
transiciones se determinan igual que para  . Si nos encontramos ante un grupo idéntico de elementos
LR(0) que avanzan con un mismo símbolo que en algún estado anterior, dicha transición avanza al
estado ya conocido.

Ejemplo 3.9:
Sea $ Σ, N, %, C, donde:
 n |,}, ~, , , , +
 N , , , !
 %  1  }  | ,
 1  |  | ,
 1 ~ |  |  | +
 C 
Construya el AFD asociado al analizador sintáctico SLR.

Comenzamos por agregar la producción C 1 . El estado inicial del AFD queda dado por:
 xyzy{ C 1w , es decir:
 C 1w 
 1w  } 
 1w 
 1w  | 
 1w 
 1w ~
 1w 
 1w 
 1w +

Jacqueline Köhler C. - USACH


41
Compiladores

Ahora debemos determinar las transiciones de  . Como solo podemos tener a lo más una
transición por cada símbolo del alfabeto, tenemos que:
 C 1w   1 1
 1w  }   1 1
 1w   1 2
 1w  |   1 2
 1w   1 3
 1w ~ ~1 4
 1w  1 5
 1w   1 6
 1w + + 1 7

Ahora es necesario determinar los elementos LR(0) que conforman cada uno de los nuevos
estados, así como las transiciones de estos últimos:
 xyzy{ C 1  w,  1  w} 

Así, se tiene que:


 C 1  w
 1  w}   }1 8   1  }w   1 12
 1w  |   1 12
  1  w  1w   1 3
 1  w|   |1 9  1w ~ ~1 4
 1w  1 5
  1  w  1w   1 6
 1w + + 1 7

 1 ~ w   1 10
 1w ~ ~1 4   1  |w   1 13
 1w  1 5  1w ~ ~1 4
 1w   1 6  1w  1 5
 1w + + 1 7  1w   1 6
 1w + + 1 7
  1 w   1 11
 1w  }   1 11   1 ~ w
 1w   1 2
 1w  |   1 2   1  w  1 14
 1w   1 3  1  w}   }1 8
 1w ~ ~1 4
 1w  1 5   1  }  w
 1w   1 6  1  w|   |1 9
 1w + + 1 7
  1  |  w
  1  w

 1  w
  1 + w

Jacqueline Köhler C. - USACH


42
Compiladores

La tabla 3.6 muestra las transiciones del AFD obtenido.

TABLA 3.6: Tabla de transiciones del AFD asociado al analizador sintáctico SLR.

3.5.3.4 Construcción y operación del analizador sintáctico SLR

Tomando como base la tabla de transiciones del AFD (tabla 3.6), se construye la tabla de análisis
sintáctico SLR de la siguiente forma:
1. Incorporar la operación de desplazamiento para cada transición con un terminal.
2. Incorporar la aceptación en el estado donde se tenga C9 1 C w con $.
3. Para cada estado en que se tenga algún elemento LR(0) de la forma  1 2 w, incorporar una reducción por
dicha producción en ese estado para cada símbolo en C.
4. Toda casilla no definida de la tabla corresponde a un error.

Ejemplo 3.10:
Construya la tabla de análisis sintáctico SLR para la gramática del ejemplo anterior. Muestre la
traza y el árbol sintáctico para m ~ } + | .

Comenzamos por incorporar los desplazamientos y la aceptación, como muestra la tabla 3.7.

Antes de incorporar las reducciones, necesitamos conocer los conjuntos siguientes para cada no
terminal. Así, tenemos que:
_` #

% %~ b %hi b % b %+ ~ b  b  b + ~, , , +
% % |  b % % b % % ~, , , +
% % }  b % % b % ~, , , +

Jacqueline Köhler C. - USACH


43
Compiladores

TABLA 3.7: Desplazamientos y configuración de aceptación para el analizador sintáctico SLR.

C $ b %}   b % $ b } b  $, ,}


C C b C b %|  $, ,} b | $, ,},|
C C b C b C $, ,},|

Ahora debemos asignar un nombre a cada producción a fin de poder identificarlas en la tabla de
análisis sintáctico:

%  1  }   |  ,  1  |  | 
,  1 ~  |  |   | +  

Como en  tenemos  1  w, debemos incorporar una reducción por la segunda producción en 


con cada símbolo en C. Lo mismo debe hacerse para cada estado en que se termine una
producción, como muestra la tabla 3.8, con lo que el analizador sintáctico SLR queda terminado.

TABLA 3.8: Analizador sintáctico SLR terminado.

Jacqueline Köhler C. - USACH


44
Compiladores

La figura 3.4 muestra el funcionamiento del analizador sintáctico SLR y muestra el árbol
sintáctico resultante para la entrada m ~ } + | .

FIGURA 3.4: Traza y árbol sintáctico resultante para la entrada m ~ } + | .

Ejemplo 3.11:
Construya un analizador sintáctico SLR para $ Σ, N, %, C, donde:
 n , @
 N , , 
 %  1 ,
 1 ,
 1 @ | 
 C 
Muestre la traza para m @.

Comenzamos por la construcción del AFD:

 C 1w   1 1
 1w   1 2   1  w   1 4
 1w   1 3  1w @ @ 1 5
 1w
 C 1  w
  1  w

Jacqueline Köhler C. - USACH


45
Compiladores


 1  w   1 @ w   1 7

  1 @ w   1 6
 1w   1 3
 1w @ @ 1 5   1 @ w
 1w

A continuación, necesitamos conocer C, C y C, y asignar nombres a las producciones:
C $ b % b C $ b %@ b % b C $ b @ b # b C
$, @ b C $, @
C C b C $, @ b C b C $, @

%  1   ,  1  ,  1 @ | 


Comenzamos por la construcción del AFD:


 C 1w   1 1
 1w   1 2   1  w
 1w   1 3

 1  w
 C 1  w
  1 @ w   1 6
  1  w   1 4  1w   1 3
 1w @ @ 1 5
 1w   1 @ w   1 7
 1w @ @ 1 5
 1w   1 @ w

A continuación, necesitamos conocer C, C y C, y asignar nombres a las producciones:

C $ b % b C $ b %@ b % b C $ b @ b # b C
$, @ b C $, @
C C b C $, @ b C b C $, @

%  1   ,  1  ,  1 @ | 


La tabla 3.9 muestra el analizador sintáctico SLR resultante, mientras que la traza 3.10 muestra
la traza para la entrada m @.

Ejemplo 3.12:
Construya un analizador sintáctico SLR para $ Σ, N, %, C, donde:
 n .,, , , /
 N "
 % " 1 " . " | "  " |" . "  " | " | /
 C "

Jacqueline Köhler C. - USACH


46
Compiladores

TABLA 3.9: Analizador sintáctico SLR del ejemplo 3.11 terminado.

TABLA 3.10: Traza para la entrada m @ con el analizador sintáctico SLR de la tabla 3.9.

Comenzamos por la construcción del AFD:


 C 1w " " 1 1
" 1w " . " " 1 1  " 1 / w
" 1w " . "  " " 1 1
" 1w "  " " 1 1 
" 1 " .w " " 1 7
" 1w " 1 2 " 1 " .w "  " " 1 7
" 1w / / 1 3 " 1w " . " " 1 7
" 1w " . "  " " 1 7
 C 1 " w " 1w "  " " 1 7
" 1 " w ." .1 4 " 1w " 1 2
" 1 " w ."  " .1 4 " 1w / / 1 3
" 1 " w "  1 5
 " 1 " w " " 1 8
 " 1 w " " 1 6 " 1w " . " " 1 8
" 1w " . " " 1 6 " 1w " . "  " " 1 8
" 1w " . "  " " 1 6 " 1w "  " " 1 8
" 1w "  " " 1 6 " 1w " 1 2
" 1w " 1 2 " 1w / / 1 3
" 1w / / 1 3

Jacqueline Köhler C. - USACH


47
Compiladores

 " 1 " w  1 9  " 1 " . " w " 1 


" 1 " w ." .1 4 " 1 " w "  1 11
" 1 " w ."  " .1 4 " 1w " . " " 1 11
" 1 " w "  1 5 " 1w " . "  "  " 1 
" 1w "  " " 1 11
 " 1 " . " w " 1w " 1 2
" 1 " . " w "  1 10 " 1w / / 1 3
" 1 " w ." .1 4
" 1 " w ."  " .1 4  " 1 " . "  " w
" 1 " w "  1 10 " 1""w
 " 1 "  " w " 1 " w ." .1 4
" 1 " w ." " 1 4 " 1 " w ."  "  .1 

" 1 " w ."  " " 1 4 " 1 " w "  1 5


" 1 " w " " 1 5

 " 1 " w

A continuación, necesitamos conocer C" y asignar nombres a las producciones:


C" $ b %." b C" b % " b C" b %."  " b % " b C" b %
$ b . b  b . b  b  $, .,, 

% " 1 " . " | "  "  |" . "  " | "


| /  

Ahora construimos la tabla del analizador sintáctico SLR, que se muestra en la tabla 3.11.

TABLA 3.11: Analizador sintáctico SLR del ejemplo 3.12.

Evidentemente, la gramática dada no es SLR pues la tabla presenta muchos conflictos. No


obstante, al ser una gramática de operadores, podemos resolverlos. Supongamos que  tiene
mayor precedencia que ., que la suma es asociativa por la izquierda y que la multiplicación es
asociativa por la derecha.

Jacqueline Köhler C. - USACH


48
Compiladores

3.5.4 USO DE GRAMÁTICAS AMBIGUAS

Existen casos en que es más cómodo trabajar con gramáticas ambiguas, pues estas ofrecen una
representación más corta y natural que una gramática no ambigua equivalente. Al usar este tipo de
gramáticas se pueden producir dos tipos de conflictos: desplazamiento/reducción y
reducción/reducción. Para que el analizador sintáctico pueda funcionar correctamente es necesario
reducir estos conflictos, a fin de que en cada entrada de la tabla figure a lo más una acción. Esta tarea
resulta muy sencilla al trabajar con gramáticas de operadores. Para todos los analizadores sintácticos de
la famila LR se usan los mismos principios.

Un conflicto reducción/reducción significa que, dado el estado del AFD que se encuentra al tope de la
pila y el siguiente símbolo de la entrada, es posible efectuar dos reducciones diferentes (podrían ser
más). Para eliminar este tipo de conflictos de la tabla de análisis sintáctico LR siempre se escoge la
reducción correspondiente a la producción más larga, pues las reducciones para las producciones más
cortas habrán aparecido ya en otros estados del AFD.

Similarmente, un conflicto desplazamiento/reducción significa que, dado el estado del AFD que se
encuentra al tope de la pila y el siguiente símbolo de la entrada, es posible efectuar tanto un
desplazamiento como una reducción (podrían ser más desplazamientos y reducciones). La resolución
de los conflictos desplazamiento/reducción se hará en base a la precedencia y la asociatividad de los
operadores de la siguiente manera:
 Si se tiene la configuración 2 Š‹ 6 w Š‹ A y Š‹ tiene mayor precedencia que Š‹ , entonces se
escoge la reducción. En caso contrario, se escoge el desplazamiento.
 Si se tiene la configuración 2 Š‹ 6 w Š‹ A es asociativo por la derecha, entonces se escoge el
desplazamiento. En caso contrario, se escoge la reducción.

Ejemplo 3.13:
Elimine conflictos en el analizador sintáctico del ejemplo 3.12.

Para los conflictos entre reducciones existentes en  , habíamos explicado que se conserva
únicamente la producción más larga. En consecuencia, descartamos t2 y conservamos
únicamente t3.

En jk7, .l nos encontramos ante el escenario " . " w .". En otras palabras, debemos decidir si
reducir la primera suma o terminar de leer la segunda. Como . es asociativo por la izquierda,
conservamos la reducción.

En jk7,l se tiene " . " w ". En otras palabras, debemos decidir si reducir suma o leer la
multiplicación. Como  tiene mayor precedencia, conservamos el desplazamiento.

En jk8, .l nos encontramos ante el escenario "  " w .". Como  tiene mayor precedencia,
conservamos la reducción.

En jk8,l se tiene "  " w ". En otras palabras, Como  es asociativo por la derecha,
conservamos el desplazamiento.

Jacqueline Köhler C. - USACH


49
Compiladores

Los conflictos restantes en jk11, .l y jk11,l son análogos a los de jk8, .l y jk8,l,
respectivamente, por lo que tomamos las mismas decisiones.

Así, la tabla 3.12 muestra el analizador sintáctico SLR libre de conflictos.

TABLA 3.12: Analizador sintáctico SLR del ejemplo 3.12 sin conflictos.

3.5.5 RECUPERACIÓN DE ERRORES

Al igual que en el caso del análisis sintáctico predictivo, existen dos grandes esquemas para la
recuperación de errores en la familia de analizadores sintácticos LR: en modo de pánico y a nivel de
frase.

3.5.5.1 Recuperación en modo de pánico

En el análisis sintáctico LR en general, la recuperación de errores en modo de pánico se implementa


como sigue:
 Se descartan elementos de la pila hasta encontrar un estado  que tenga una transición definida para
un no terminal  específico.
 Luego se descartan símbolos de la entrada hasta que se encuentra un terminal  a C.
 Incorporar  y el estado { dado por ,  { al tope de la pila.
 Se continúa normalmente con el análisis sintáctico.

Nótese que este esquema no necesariamente funciona en todos los casos.

Ejemplo 3.14:
Muestre la traza para la entrada m // . / . / con el analizador sintáctico obtenido en el
ejemplo 3.13. Considere recuperación de errores en modo de pánico.

La traza resultante se muestra en la tabla 3.13.

Jacqueline Köhler C. - USACH


50
Compiladores

TABLA 3.13: Traza para la entrada m // . / . / con el analizador sintáctico SLR de la


tabla 3.12, considerando recuperación de errores en modo de pánico.

3.5.5.2 Recuperación a nivel de frase

La idea de esta técnica es, basándose en el uso del lenguaje, determinar el error más probable que
pudiera cometer un programador para cada entrada de la tabla correspondiente a un error e implementar
una función que lo corrija.

Ejemplo 3.15:
Muestre la traza para la entrada m // . / . / con el analizador sintáctico obtenido en el
ejemplo 3.13. Considere recuperación de errores a nivel de frase.

El primer paso consiste en identificar los posibles errores e incorporarlos al analizador


sintáctico.
 *1: en este caso se encuentra en la entrada un operador (. o ) o bien el fin de la entrada
($), siendo que se esperaba el inicio de un operando, es decir, / o . Las acciones a realizar
para corregir este error y poder seguir adelante son:
• Insertar / en la pila.
• Insertar en la pila el estado 3 (transición que se produce cada vez que se desplaza una /).
• Notificar el error correspondiente: falta operando.

 *2: en este caso se encuentra en la entrada un paréntesis derecho no balanceado, . Las


acciones a realizar para corregir este error y poder seguir adelante son:
• Eliminar  de la entrada.
• Notificar el error correspondiente: paréntesis derecho no balanceado.

 *3: en este caso se encuentra en la entrada un operando o un paréntesis izquierdo, siendo


que se esperaba un operador o el término de la entrada. Esto se arregla mediante las
siguientes acciones:

Jacqueline Köhler C. - USACH


51
Compiladores

• Insertar un operador (. o ) en la entrada.


• Notificar el error correspondiente: falta operador.

 *4: se encuentra el fin de la entrada, siendo que se espera un paréntesis derecho. El


procedimiento a seguir en este caso es:
• Insertar  en la pila.
• Insertar en la pila el estado 9.
• Notificar el error correspondiente: paréntesis izquierdo no balanceado.

La tabla 3.14 muestra el analizador sintáctico del ejemplo 3.12 sin conflictos y con el esquema
de recuperación de errores a nivel de frase, mientras la tabla 3.15 muestra la traza con este
nuevo analizador sintáctico para la entrada m // . / . /.

TABLA 3.14: Analizador sintáctico SLR del ejemplo 3.12 sin conflictos.

3.5.6 ANÁLISIS SINTÁCTICO LR(1)

3.5.6.1 Elemento LR(1)

Un elemento LR(1) está conformado por un elemento LR(0), denominado núcleo, y un símbolo de
anticipación que puede ser un símbolo terminal o el delimitador de la entrada ($). Un mismo núcleo
puede ser común a varios elementos LR(1). Un ejemplo de elemento LR(1) puede ser  1 2 w 6, . El
símbolo de anticipación indica lo que debiera leerse en la entrada tras reducir por la producción
 1 26. En otras palabras, el símbolo de anticipación es un elemento de C que puede,
efectivamente, aparecer inmediatamente después de una producción en un contexto dado. Esta es la
característica que hace posible construir analizadores sintácticos LR(1) para un conjunto más grande
que SLR. En consecuencia, DD1 s CDt s Dt1 s $D$.

Sean los elementos LR(1)  1 2 w,  y  1 2 w, . En el caso del análisis sintáctico SLR estos
elementos podrían causar un conflicto reducción/reducción. No obstante, la incorporación del símbolo
de anticipación a los elementos permite decidir de manera más eficiente qué reducción ocupar. Dicho

Jacqueline Köhler C. - USACH


52
Compiladores

símbolo solo será utilizado directamente al momento de llevar a cabo una acción de reducción, como se
explica más adelante.

TABLA 3.15: Traza para la entrada m // . / . / con el analizador


sintáctico SLRde la tabla 3.14.

3.5.6.2 Operación clausura

Si  es un conjunto de elementos LR(1) para una gramática $, entonces la xyzy{ es el conjunto


de elementos LR(1) construido a partir de  según las siguientes reglas:
1. Inicialmente, hacer xyzy{ .
2. Si  1 2 w 6,  a xyzy{ y  1 A es una producción, hacer:
xyzy{ xyzy{ b  1w A, f a %6. Aplicar esta regla hasta que no sea
posible añadir más elementos a xyzy{.

Jacqueline Köhler C. - USACH


53
Compiladores

La definición de la clausura coincide en muchos aspectos con la utilizada en la construcción del AFD
SLR. La única diferencia radica en la determinación de los símbolos de anticipación.

3.5.6.3 Construcción del AFD

El proceso de construcción del AFD LR(1) es muy similar al SLR. También es necesario aumentar la
gramática $ con la producción C9 1 C. Luego se determina el estado inicial del AFD haciendo 
xyzy{C : 1w C, $. Nótese que se escoge $ como símbolo de anticipación porque C : genera al
símbolo inicial de la gramática. Hay que recordar que C representa cualquier secuencia válida que
pueda ser generada por la gramática. Y Para tener esta validez, no puede venir nada más al término de
la secuencia.

A continuación se determinan las transiciones con todos aquellos símbolos precedidos por un punto en
algún elemento LR(1) de  .

Para los nuevos estados, se determina la xyzy{ de los elementos LR(1) que le dan origen, es
decir, aquellos del estado anterior con el punto desplazado en una posición hacia la derecha. Las
transiciones se determinan igual que para  . Si nos encontramos ante un grupo idéntico de elementos
LR(1) que avanzan con un mismo símbolo que en algún estado anterior, dicha transición avanza al
estado ya conocido. Nótese que en LR(1) no basta con solo tener las mismas producciones que avancen
con el mismo símbolo para ir a un mismo estado. Los símbolos de anticipación también deben
coincidir.

En la práctica los analizadores sintácticos LR(1) no son muy utilizados, puesto que, en general, los
autómatas son demasiado grandes y requieren de un espacio excesivo de almacenamiento.

3.5.6.4 Construcción de la tabla de análisis sintáctico LR(1)

En la construcción de la tabla de análisis sintáctico LR(1) para una gramática $ se hará uso del AFD ya
obtenido de la siguiente manera:
1. Incorporar la operación de desplazamiento para cada transición con un terminal.
2. Incorporar la aceptación en el estado donde se tenga C : 1 C w, $ con $.
3. Para cada estado en que se tenga algún elemento LR(1) de la forma  1 2 w, , incorporar una
reducción por dicha producción en ese estado para el símbolo de anticipación .
4. Toda casilla no definida de la tabla corresponde a un error.

Si las reglas anteriores generan acciones contradictorias se dice que la gramática no es LR(1) y no se
puede construir el analizador sintáctico. Una vez más, no obstante, muchas veces es posible obtener un
analizador sintáctico LR(1) para una gramática ambigua mediante reducción de conflictos como ya se
explicó.

Jacqueline Köhler C. - USACH


54
Compiladores

Ejemplo 3.16:
Construya un analizador sintáctico LR(1) para $ Σ, N, %, C, donde:
 n , 
 N , 
 %   1 ,
 1  | 
 C 

Compare el analizador sintáctico LR(1) con otro SLR para la misma gramática.
Comenzamos por la construcción del AFD LR(1):
 C 1w , $  1 
 1w , $  1 
 1w ,   1 
 1w ,   1 
 1w ,   1 

 1w ,   1 

Nótese que en el caso de las producciones de  debemos considerar como símbolos de


anticipación todos los elementos de %$ , , lo que nos lleva a los siguientes pares de
elementos LR(1):  1w , ;  1w ,  y  1w , ;  1w , .

Por comodidad, podemos reescribir  de la siguiente forma:

 C 1w , $  1 
 1w , $  1 
 1w , /  1 
 1w , /  1 

Continuemos ahora con la construcción del AFD LR(1):


 C 1  w, $   1  w, $

  1  w , $  1    1  w , $  1 
 1w , $  1   1w , $  1 
 1w , $  1   1w , $  1 

  1  w , /  1    1  w, $
 1w , /  1 
 1w , /  1 
  1  w, /


 1  w, /   1  w, $

Asignamos un nombre a las producciones para poder identificarlas al hacer las reducciones:
Asignamos un nombre a las producciones para poder identificarlas al hacer las reducciones:
%   1  ,  1   |  

Ahora construimos la tabla LR(1), que se muestra en la tabla 3.16.

Jacqueline Köhler C. - USACH


55
Compiladores

TABLA 3.16: Analizador sintáctico LR(1) del ejemplo 3.16.

Construyamos ahora el AFD SLR:


 C 1w   1    1  w   1 
 1w   1   1w   1 
 1w   1   1w ,  1 

 1w ,  1 


 1  w,
 C 1  w
  1  w, $
  1  w , $  1 
 1w   1    1  w
 1w   1 

Para construir la tabla SLR necesitamos conocer C y C:


C $
C % b C b C % b % b $  b  b $ $, , 

Ahora construimos la tabla SLR:

TABLA 3.17: Analizador sintáctico SLR del ejemplo 3.16.

Se puede notar que, pese a la simplicidad de la gramática, el analizador sintáctico LR(1) es


significativamente más grande que el SLR. Extrapolando para una GLC que defina un lenguaje
de programación, esta diferencia podría ser del orden de miles de estados, con un conjunto de
símbolos muchísimo más grande.

Jacqueline Köhler C. - USACH


56
Compiladores

Otra observación importante es que, para este ejemplo, ninguna de las dos tablas presenta
conflictos, por lo que $ es SLR. Debemos recordar que al ser SLR es también LR(1), pues
CDt s Dt1.

Ejemplo 3.17:
Construya un analizador sintáctico LR(1) para $ Σ, N, %, C, donde:
 n  ,, /
 N C, D, t
 %  C 1 D t | t,
D 1 t | /,
t 1 D
 C C
Compare el analizador sintáctico LR(1) con otro SLR para la misma gramática.

Comenzamos por la construcción del AFD LR(1):


 C9 1w C, $ C 1   C 1 D w t, $ t 1 
C 1w D t, $ D 1  t 1w D, $ D 1 
C 1w t, $ t 1  D 1w t, $  1 
D 1w t, /$  1 
D 1w /, $ / 1 
D 1w /, /$ / 1 
t 1w D, $ D 1   D 1 t w, /$

 C9 1 C w, $  t 1 D w, /$

 C 1 D w t, $  1   C 1 D t w, $
t 1 D w, $
 t 1 D w, $
 C 1 t w, $
 D 1w t, $ t 1 
t 1w D, $ D 1 

D 1w t, /$ t 1  D 1w t, $  1 
t 1w D, /$ D 1  D 1w /, $ / 1 
D 1w t, /$  1 

D 1w /, /$ / 1   D 1 / w, $

 D 1 / w, /$  D 1 t w, $

Asignamos un nombre a las producciones para poder identificarlas al hacer las reducciones:
Asignamos un nombre a las producciones para poder identificarlas al hacer las reducciones:
% C 1 D t |t  , D 1 t | /
, t 1 D 

Ahora construimos la tabla LR(1), que se muestra en la tabla 3.18.

Jacqueline Köhler C. - USACH


57
Compiladores

TABLA 3.18: Analizador sintáctico LR(1) del ejemplo 3.17.

Construyamos ahora el AFD SLR:


 C9 1w C C 1  
D 1w t t 1 
C 1w D t D 1  t 1w D D 1 
C 1w t, t 1  D 1w t  1 

D 1w t  1 
D 1w / / 1 
D 1w / / 1 
t 1w D D 1   D 1 / w

 C9 1 C w  C 1 D w t t 1 
t 1w D D 1 
 C 1 D w t  1  D 1w t  1 

t1Dw D 1w / / 1 

 C 1 t w  D 1 t w

 t 1 D w

 C 1 D t w

Para construir la tabla SLR necesitamos conocer CC, CD y Ct:


CC $
CD % t b Ct   b Ct   b $,  $, 
Ct CC b CC b CD $ b   b Ct $, 

Ahora construimos la tabla SLR, que se muestra en la tabla 3.19. Nuevamente la tabla SLR es
más pequeña que la tabla LR(1). No obstante, podemos afirmar que $ no es SLR, puesto dicha
tabla presenta un conflicto, mientras que sí cumple con ser LR(1).

Jacqueline Köhler C. - USACH


58
Compiladores

TABLA 3.19: Analizador sintáctico SLR del ejemplo 3.17.

3.5.7 ANÁLISIS SINTÁCTICO LALR

3.5.7.1 Notas preliminares

El análisis sintáctico con anticipación o LALR (Lookahead Left to Right Parser) es de los más
utilizados en la práctica, pues sus tablas son del mismo tamaño que las SLR y significativamente más
pequeñas que las LR(1), y sin embargo puede manejar un conjunto de gramáticas más amplio que el
análisis sintáctico SLR: las gramáticas LALR(1), donde DD1 s CDt s DDt s Dt1 s $D.

Los analizadores LR(1) y LALR tienen igual funcionamiento en caso de comprobarse una entrada
correcta. Ante una entrada incorrecta, en cambio, el analizador LR(1) detecta el error de inmediato,
mientras que el analizador LALR puede efectuar algunas reducciones antes de detectarlo. No obstante,
el error será detectado sin necesidad de efectuar un desplazamiento.

Existen dos métodos para construir el AFD asociado a un analizador sintáctico LALR. El primero de
ellos comienza a partir del AFD LR(1) y funde en uno solo aquellos estados que son idénticos excepto
por los símbolos de anticipación. El segundo, en cambio, construye directamente el AFD de manera
similar al caso SLR, incorporando los símbolos de anticipación a medida en que éstos van apareciendo.
En consecuencia, se puede decir que un analizador sintáctico LALR es un analizador SLR con
símbolos de anticipación.

La tabla de análisis sintáctico LALR se construye de acuerdo a las mismas reglas que en el caso de
LALR.

3.5.7.2 Construcción del AFD a partir del analizador sintáctico LR(1)

Para comprender mejor este método, resulta más sencillo trabajar con un ejemplo. La idea es fusionar
en uno solo aquellos estados que contienen los mismos elementos LR(0), es decir, que solo se
diferencian por los símbolos de anticipación de los elementos LR(1).

Jacqueline Köhler C. - USACH


59
Compiladores

Ejemplo 3.18:
Construya un analizador léxico LALR para la gramática del ejemplo 3.27. Tome como base el
analizador sintáctico LR(1) obtenido en dicho ejemplo.

Podemos ver que 


es muy semejante a  :

D 1w t, /$ t 1   D 1w t, $ t 1 
t 1w D, /$ D 1  t 1w D, $ D 1 
D 1w t, /$  1 
D 1w t, $  1 
D 1w /, /$ / 1  D 1w /, $ / 1 

También  es muy semejante a  :


 D 1 / w, /$  D 1 / w, $

Lo mismo ocurre con  y  :


 D 1 t w, /$  D 1 t w, $

Así como con  y  :


 t 1 D w, /$  t 1 D w, $

Si juntamos los pares de estados semejantes, para cada elemento LR(1) tendremos los símbolos
de anticipación de ambos estados. En este caso particular, nos basta simplemente con conservar

,  ,  y  . Los estados que no se agrupan con otros quedan tal como están. Así, el AFD
resultante es:

Comenzamos por la construcción del AFD LR(1):


 C9 1w C, $ C 1   D 1 / w, /$
C 1w D t, $ D 1 
C 1w t, $ t 1   C 1 D w t, $ t 1 
D 1w t, /$  1 
t 1w D, $ D 1 
D 1w /, /$ / 1  D 1w t, $  1 
t 1w D, $ D 1  D 1w /, $ / 1 

 C9 1 C w, $  D 1 t w, /$

 C 1 D w t, $  1   t 1 D w, /$
t 1 D w, $
 C 1 D t w, $
 C 1 t w, $


D 1w t, /$ t 1 
t 1w D, /$ D 1 
D 1w t, /$  1 

D 1w /, /$ / 1 

Jacqueline Köhler C. - USACH


60
Compiladores

Ahora construimos la tabla LALR, como se ve en la tabla 3.20.

TABLA 3.20: Analizador sintáctico LALR del ejemplo 3.18.

Una observación importante es que, pese a que $ no es SLR, sí es LALR al no existir conflictos
en la tabla. También es interesante notar que el AFD es idéntico al SLR, y lo único que varía en
la tabla son las reducciones debidas a los símbolos de anticipación.

Ejemplo 3.19:
Construya los analizadores sintácticos LR(1) y LALR para $ Σ, N, %, C, con:
 n , , ), ', *
 N , , 
 %   1  |  |  |  ,
 1 ) | ',
 1 ) | *
 C 
¿Qué puede concluir acerca de $?

Comencemos por la construcción del analizador sintáctico LR(1):


 C 1w , $  1    1  w , $  1 
 1w , $  1   1  w , $  1 
 1w , $  1   1w ),  ) 1 
 1w , $  1   1w ',  ' 1 
 1w , $  1   1w ),  ) 1 
 1w *,  * 1 
 C 1  w, $

 1  w , $  1 

  1  w , $  1 

 1  w , $  1    1  w , $  1 
 1w ),  ) 1 
 1w ',  ' 1    1 ) w, 
 1w ),  ) 1   1 ) w, 
 1w *,  * 1 
  1 ' w, 

Jacqueline Köhler C. - USACH


61
Compiladores

  1 * w,    1 * w, 

  1  w , $  1  
 1  w, $

  1  w , $  1    1  w, $

  1 ) w,    1  w, $
 1 ) w, 
  1  w, $
  1 ' w, 

Ahora asignamos un nombre a las producciones y construimos la tabla LR(1), que se muestra en
la tabla 3.21.

%   1  |   |  | 


,  1 )  | ' ,  1 )  | *  

TABLA 3.21: Analizador sintáctico LR(1) del ejemplo 3.19.

Para construir el autómata LR(1) podemos encontrar las equivalencias que se muestran en la
tabla 3.22.

Jacqueline Köhler C. - USACH


62
Compiladores

TABLA 3.22: Estados equivalentes del analizador sintáctico LR(1).

La tabla LALR resultante se muestra en la tabla 3.23.

TABLA 3.23: Analizador sintáctico LALR del ejemplo 3.19.

Podemos concluir que $ es LR(1) pero no es LALR. Además, al no ser LALR, tampoco puede
ser SLR ni LL(1).

Jacqueline Köhler C. - USACH


63
Compiladores

3.5.7.3 Construcción directa del AFD

En este caso, se tiene que la construcción del AFD se realiza igual que en SLR, siendo necesario
además incorporar los símbolos de anticipación.

Ejemplo 3.20:
Construya el analizador sintáctico LALR, usando el método directo, para $ Σ, N, %, C, con:
 n , @, , , k, l
 N 
 %   1 @ | | kl | 
 C 
Resuelva conflictos considerando que @ es asociativo por la izquierda.

Comenzamos por la construcción del AFD LALR:


 C 1w , $  1 
 1w @, $/@  1  
 1w , $/@//l
 1w , $/@ 1 
 1w kl, $/@ k1    1 @ w , $/@//l  1 
 1w , $/@  1 
 1w @, $/@//l  1 
 1w , $/@//l 1 
 C 1  w, $  1w kl, $/@//l k1 
 1  w @, $/@ @ 1   1w , $/@//l  1 

 C 1 w , $/@//l  1   C 1  w, $/@//l  1 


 1w @, /@  1   1  w @, /@ @ 1 
 1w , /@ 1 
 1w kl, /@ k1   C 1 k wl, $/@//l l 1 
 1w , /@  1 
 1  w @, l/@ @ 1 
  1 @ w, $/@//l
 C 1 kw l, $/@//l  1   1  w @, $/@//l @ 1 
 1w @, l/@  1 
 1w , l/@ 1   C 1  w, $/@//l
 1w kl, l/@ k1 
 1w , l/@  1 
 C 1 kl w, $/@//l

Asignamos nombres a las producciones y construimos la tabla 3.24, correspondiente al


analizador sintáctico LALR.
%   1 @ | | kl | 


Jacqueline Köhler C. - USACH


64
Compiladores

TABLA 3.24: Analizador sintáctico LALR del ejemplo 3.20.

El conflicto existente en jk8, @l es de la forma @ w @. Como @ es asociativo por la


izquierda, se descarta el desplazamiento, quedando el analizador sintáctico LALR como
muestra la tabla 3.25.

TABLA 3.25: Analizador sintáctico LALR del ejemplo 3.20 tras la eliminación de conflictos.

3.6 EJERCICIOS

1. Determine los conjuntos Anulable, Primero y Siguiente para $ Σ, N, %, C, con:
 n , 
 N , , , !
 %  1 @ |   | @/ |,  1  .  | 0 | 0 |   ,  1 kl| !/ | 0, ! 1 # | 
 C 

2. Construya un analizador sintáctico predictivo para la gramática del ejercicio anterior. Modifíquela
en caso necesario.

Jacqueline Köhler C. - USACH


65
Compiladores

3. Construya analizadores sintácticos SLR, LR(1) y LALR para la gramática del ejercicio 1 y la
gramática modificada del ejercicio 2.

4. Sea $ Σ, N, %, C, con:


 n ,, . , ., , 
 N , 
 %  1 .  |  .  |   |  | 
 C 

a. Haga las modificaciones necesarias y construya un analizador sintáctico LL(1). ¿Es posible?
b. Construya analizadores sintácticos SLR, LR(1) y LALR para la gramática original. En caso de
existir conflictos, considere que las precedencias de los operadores, de más alta a más baja, son:
, ., .. Asuma que . y . son asociativos por la izquierda.
c. Muestre la traza y el árbol resultante para m   ..  con cada uno de los analizadores del
punto anterior.
d. Construya analizadores sintácticos SLR, LR(1) y LALR para la gramática obtenida en a.

5. Considere el analizador sintáctico SLR de la tabla 3.12 y la entrada m /  /. ¿Qué ocurre al
hacer la traza usando recuperación de errores en modo de pánico? ¿Por qué?

6. ¿En qué casos se tiene que las tablas SLR y LALR son iguales? ¿Por qué?

7. Compruebe la equivalencia de los dos métodos para construie analizadores sintácticos LALR
repitiendo el ejercicio del ejemplo 3.20 usando la construcción a partir del analizador sintáctico
LR(1).

Jacqueline Köhler C. - USACH


66
Compiladores

4 ANÁLISIS SEMÁNTICO
La palabra semántica, proveniente del griego semantikos (lo que tiene significado), se refiere a los
aspectos del significado o interpretación de un determinado código simbólico, lenguaje o
representación formal. Etapa del proceso de compilación se asocia información a una cierta
construcción del lenguaje de programación proporcionando atributos a los símbolos de la gramática
que la conforman.

A nivel conceptual, esta etapa está situada a continuación del análisis sintáctico, donde se construye el
árbol de análisis sintáctico. Durante el análisis semántico se recorre el árbol para evaluar las reglas
semánticas presentes en sus nodos (es decir, las reglas semánticas correspondientes a la producción
empleada en dicho nodo). Al evaluar una regla semántica se pueden efectuar diversas actividades, entre
ellas generar código, almacenar información en una tabla de símbolos y emitir mensajes de error. Al
finalizar esta etapa se obtiene como resultado la traducción de la cadena de componentes léxicos.

En la práctica, las tareas del análisis semántico se realizan simultáneamente con el análisis sintáctico,
en una dependencia similar a la que existe entre este último y el análisis léxico. Como las reglas
semánticas están asociadas a las producciones de la gramática, pueden ser ejecutadas una vez que se ha
reconocido la producción correspondiente.

4.1 DEFINICIONES DIRIGIDAS POR LA SINTAXIS

Una definición dirigida por la sintaxis es una generalización de una gramática independiente del
contexto en la que cada símbolo de la gramática tiene un conjunto de atributos asociado, que se divide
en dos subconjuntos: atributos sintetizados y atributos heredados. Un atributo puede representar
cualquier cosa: una cadena de caracteres, un número, un tipo, una posición de memoria, etc.

El valor de un atributo en un nodo del árbol de análisis sintáctico se define mediante una regla
semántica asociada a la producción empleada en dicho nodo. Para un atributo sintetizado, se calcula a
partir de los valores de los atributos de los hijos del nodo en cuestión. Para un atributo heredado, se
calcula a partir de los valores de los atributos de los hermanos y el padre del nodo.

Las reglas semánticas establecen las dependencias entre los atributos, que se representan mediante un
grafo de dependencias. De este grafo se obtiene un orden de evaluación de las reglas semánticas. La
evaluación de las reglas semánticas permite conocer el valor de los atributos, pero además puede tener
efectos colaterales como imprimir un valor o actualizar una variable global.

En una definición dirigida por la sintaxis, cada producción gramatical  1 2 tiene asociado un
conjunto de reglas semánticas, de la forma   +) , ) , … , )@ , donde:
 + es una función.
 )8 son atributos pertenecientes a los símbolos gramaticales de la producción.
  es un atributo sintetizado de  o un atributo heredado de los símbolos del lado derecho de la
producción. Se dice que  depende de los atributos )8 .

Jacqueline Köhler C. - USACH


67
Compiladores

Se asume que los símbolos terminales solo tienen atributos sintetizados, ya que la definición dirigida
por la sintaxis no proporciona reglas semánticas para estos símbolos. El valor de estos atributos es, en
general, proporcionado por el analizador léxico. También se asume que el símbolo inicial de la
gramática no tiene atributos heredados, a menos que se indique lo contrario.

Las funciones de las reglas semánticas a menudo se escriben como expresiones. No obstante, cuando el
único propósito de una regla semántica es crear un efecto colateral, la regla se escribe como una
llamada a un procedimiento o un fragmento de programa.

Ejemplo 4.1:
La tabla 4.1 muestra la definición dirigida por la sintaxis para un programa que efectúa las
operaciones de adición y multiplicación. A cada uno de los no terminales ", j y  se le asocia
un atributo sintetizado llamado x y la regla semántica correspondiente calcula el valor del
atributo x del no terminal del lado izquierdo de la producción a partir de los atributos x de
los no terminales del lado derecho. El componente léxico ‘í“”•– tiene un atributo sintetizado
x*/, cuyo valor es proporcionado por el analizador léxico. La regla semántica asociada a la
producción D 1 "— para el no terminal D es un procedimiento que imprime el valor de la
expresión aritmética generada por ". Obsérvese que — es simplemente un carácter de salto de
línea.

TABLA 4.1: Definición dirigida por la sintaxis para efectuar las operaciones de adición y
multiplicación.

4.1.1 ATRIBUTOS SINTETIZADOS

Este tipo de atributos es muy utilizado en la práctica. Una definición dirigida por la sintaxis que solo
emplee este tipo de atributos se denomina definición con atributos sintetizados y siempre es posible
generar un árbol de análisis sintáctico con anotaciones para este tipo de definiciones. La construcción
se efectúa evaluando en forma ascendente las reglas semánticas para los atributos en cada nodo.

Ejemplo 4.2:
La figura 4.1 muestra el árbol de análisis sintáctico con anotaciones para la entrada 3  5 . 4—
obtenido con la definición dirigida por la sintaxis de la tabla 4.1.

Para comprender cómo se calculan los atributos sintetizados, considere primero el nodo interior
más bajo de la izquierda, correspondiente al uso de la producción  1 ‘í“”•–. La regla

Jacqueline Köhler C. - USACH


68
Compiladores

semántica correspondiente, . x  ‘í“”•–. x*/, establece el atributo . x con un valor de
3, que es el valor de ‘í“”•–. x*/. De manera similar, se asigna también valor 3 al atributo
j. x. A continuación se procede del mismo modo con el siguiente subárbol izquierdo, de
donde se obtiene que . x  5. Al usar la producción j 1 j  , se tiene que j. x 
j . x ˜ . x 3 ˜ 5 15. Finalmente, la regla semántica asociada a D 1 "— imprime por
pantalla el valor de la expresión generada por ".

FIGURA 4.1: Árbol de análisis sintáctico con anotaciones para la entrada 3  5 . 4—.

4.1.2 ATRIBUTOS HEREDADOS

Son atributos cuyo valor en un nodo del árbol de análisis sintáctico se define a partir de los atributos
del padre y los hermanos de dicho nodo. Sirven para expresar la dependencia de una construcción de un
lenguaje de programación de acuerdo al contexto en que aparece. Por ejemplo, se puede determinar si
un identificador aparece en el lado izquierdo o derecho de una asignación para saber si se necesita la
dirección o el valor de dicho identificador.

Ejemplo 4.3:
La figura 4.2 muestra una definición dirigida por la sintaxis en que el no terminal ! genera una
declaración conformada por la palabra clave ™š›œ o ”—• seguida de una lista de identificadores.
El no terminal j tiene un atributo sintetizado tipo, cuyo valor se determina a partir de la palabra
clave de la declaración. La regla semántica D. *{  j. ;‹Š, asociada a la producción ! 1 jD,
asigna el tipo de la declaración al atributo heredado D. *{. Las reglas semánticas asociadas con
las producciones de D llaman a la función añadetipo para ingresar el tipo de cada identificador
a su entrada en la tabla de símbolos (apuntada por el atributo *={').

La figura 4.2 muestra el árbol de análisis sintáctico con anotaciones para la entrada
™š›œ ”‘ , ”‘ , ”‘ .

Jacqueline Köhler C. - USACH


69
Compiladores

TABLA 4.2: Definición dirigida por la sintaxis para declarar variables reales y enteras.

FIGURA 4.2: Árbol de análisis sintáctico con anotaciones para la entrada ™š›œ ”‘ , ”‘ , ”‘ .

4.1.3 GRAFOS DE DEPENDENCIAS

Si un atributo  en un nodo de un árbol de análisis sintáctico depende de un atributo ), entonces se


debe evaluar primero la regla semántica para ) y luego para . Antes de construir el árbol sintáctico,
cada regla semántica debe ser escrita en la forma   +) , ) , … , )@ .

Las dependencias se pueden representar mediante un grafo dirigido llamado grafo de dependencias, en
el que existe un nodo por cada atributo y una arista del nodo de ) al nodo de  si el atributo  depende
del atributo ). El algoritmo 4.1 muestra cómo se construye el grafo de dependencias.

ALGORITMO 4.1: Construcción del grafo de dependencias.


Para cada nodo = en el árbol de análisis sintáctico hacer:
Para cada atributo  del símbolo gramatical en el nodo = hacer:
Construir un nodo para  en el grafo de dependencias para .
Para cada nodo = en el árbol de análisis sintáctico hacer:
Para cada regla semántica   +) , ) , … , )@  asociada a la producción
empleada en el nodo = hacer:
Para todo ; desde 1 hasta r hacer:
En el grafo de dependencias, construir una arista desde el

Jacqueline Köhler C. - USACH


70
Compiladores

nodo de )8 hacia el nodo de .


Ejemplo 4.4:
La figura 4.3 muestra las aristas que s añaden al grafo de dependencias al usar la regla
semántica ". x 1 " . x . " . x, asociada a la producción " 1 " . " .

FIGURA 4.3: ". x se sintetiza a partir de " . x y " . x.

Ejemplo 4.5:
Considere el árbol de análisis sintáctico con anotaciones de la figura 4.2. Su grafo de
dependencias se muestra en la figura 4.4.

FIGURA 4.4: Grafo de dependencias para el árbol de análisis sintáctico de la figura 4.2.

Jacqueline Köhler C. - USACH


71
Compiladores

4.2 ÁRBOLES SINTÁCTICOS


El uso de árboles sintácticos permite que se separe la traducción del análisis sintáctico. Sin embargo,
este árbol contiene muchos detalles (paréntesis, comas, etc.) y depende de la estructura de la gramática.
En consecuencia, resulta interesante construir un árbol sintáctico más sencillo, que conserve solo
aquella información relevante y permita representar adecuadamente las construcciones del lenguaje.

Un árbol sintáctico abstracto (AST) es una forma condensada de un árbol sintáctico para representar
construcciones de un lenguaje en su forma más simple o esencial. Algunas simplificaciones que se
observan en los AST son:
 Operadores y palabras clave no aparecen ya como hojas, sino que son llevadas a un nodo interior
padre de los operadores asociados.
 Omitir cadenas de producciones simples, por ejemplo  1  1 ž 1 .
 Se omiten detalles sintácticos tales como paréntesis y signos de puntuación, entre otros.

Ejemplo 4.6:
La figura 4.5 muestra el árbol de análisis sintáctico y el AST para la estructura if–else.

FIGURA 4.5: Árbol de análisis sintáctico y AST para la estructura if – else.

Ejemplo 4.7:
El árbol de análisis sintáctico de la figura 4.1, sin considerar las anotaciones, puede
simplificarse bastante al ser llevado a un AST, como muestra la figura 4.6.

La construcción de un AST para una expresión es similar a la traducción de la expresión a una forma
postfija. Es decir, se construyen subárboles para cada subexpresión mediante la creación de un nodo
por cada operador y cada operando, donde los hijos de un nodo correspondiente a un operador son las
raíces de los nodos que representan las subexpresiones que conforman los operandos de dicho
operador.

Cada nodo puede implementarse como un registro con varios campos. En un nodo de un operador, el
primer campo identifica el operador mientras el resto contiene punteros a los nodos de los operandos. A
modo de ejemplo, se consideran aquí las siguientes funciones para crear los nodos del árbol sintáctico,
donde cada una de ellas devuelve un puntero a un nuevo nodo recién creado:
 crearNodo(Š‹, ;y;*{', '*{*)): crea un nodo para un operador con etiqueta Š‹ y dos
campos con punteros a ;y;*{' y '*{*).
 crearHoja(”‘, *={'): crea un nodo para un identificador con etiqueta ”‘ y un campo que
contiene *={', que es un puntero a la entrada de la tabla de símbolos correspondiente al
identificador.

Jacqueline Köhler C. - USACH


72
Compiladores

 crearHoja(—ú , x ): crea un nodo para un número con etiqueta —ú  y un campo que contiene
x, el valor del número.
Ejemplo 4.8:
Sea la expresión   4 . ). La construcción del AST se lleva a cabo en cinco pasos, en forma
ascendente (el árbol resultante se muestra en la figura 4.6):
1. ‹ crearHoja(”‘, *={'_ )
2. ‹ crearHoja(—ú , 4)
3. ‹ crearNodo(9  9, ‹ , ‹)
4. ‹
crearHoja(”‘, *={'_))
5. ‹ crearNodo(9 . 9, ‹ , ‹
)

Donde:
 ‹8 son punteros a nodos.
 *={'_ y *={'_) son punteros a las entradas de la tabla de símbolos para los
identificadores  y ) respectivamente.

FIGURA 4.6: AST para la expresión   4 . ).

Para el caso de sentencias que no corresponden a expresiones se procede en forma similar, como
muestra la tabla 4.3.

4.3 COMPROBACIÓN DE TIPOS


La comprobación de tipos es la principal tarea del analizador semántico. Su propósito es evaluar cada
operador y sus operandos o argumentos, y retornar las declaraciones pertinentes para incorporarlas al
AST. Con lo anterior, a continuación puede generar el código intermedio apropiado (figura 4.7). Nótese
que una función puede considerarse también como un operador. Cada argumento debe ser compatible
con el operador. Por ejemplo, si se desea sumar una variable de tipo int a otra de tipo char (guerdando
el resultado en esta última), los tipos no son compatibles y se debe efectuar una coerción explícita
(cast) para convertir el entero en un carácter antes de poder efectuar la operación.

Jacqueline Köhler C. - USACH


73
Compiladores

TABLA 4.3: Creación de nodos del AST para sentencias de flujo de control.

Se debe efectuar la comprobación en cada nodo del AST donde se utilice la información de los tipos
presentes en una expresión, e informar al usuario en aquellos casos en que no pueda darse una solución
a un conflicto. Este proceso consta de dos partes:
 Llenar el AST con los tipos correspondientes para los literales (variables o valores).
 Propagar los tipos por los nodos restantes del AST considerando:
• Tipos correctos para los operadores y operandos.
• Tipos correctos para los argumentos de función.
• Tipos correctos de retorno.
• Aplicar coerción en caso de que los tipos no coincidan.

FIGURA 4.7: Ubicación de la comprobación de tipos dentro del proceso de compilación.

Inicialmente se debe recorrer el AST para encontrar todos los identificadores literales o valores que se
encuentren presentes y recurrir a la tabla de símbolos para conocer sus tipos. Es recomendable efectuar
el recorrido con el algoritmo post-orden, que visita primero las hojas (identificadores literales y
valores) y luego los nodos intermedios (operadores), pues así se puede efectuar la comprobación de
tipos en una sola pasada.

Jacqueline Köhler C. - USACH


74
Compiladores

Ejemplo 4.9:
Considere el AST para la expresión   . 1.0, que se muestra en la figura 4.8 (a). El
recorrido post-orden visita primero las hojas del árbol incorporando su tipo, con lo que se
obtiene el AST de la figura 4.8 (b).

FIGURA 4.8: Comprobación de tipos para la expresión   . 1.0.


(a) AST original.
(b) AST con los tipos para valores e identificadores literales.

Una vez conocidos los tipos para las hojas del AST, se deben determinar los tipos asociados a los
nodos intermedios, es decir, a los operadores. Si todos los operandos son de un mismo tipo, esta tarea
resulta ser trivial. No obstante, se debe efectuar una coerción en caso de que los tipos no sean iguales.
Para el caso en que los operandos tienen tipos diferentes, una estrategia es construir una tabla de
prioridades de conversión. Así, para cada operador se determina qué tipo de datos tiene preferencia,
viéndose en la tabla como el primer tipo que figura para el operador. Así, en caso de existir un conflicto
de tipos, se escoge el del operando cuyo tipo tiene mayor prioridad.

Ejemplo 4.10:
Considere nuevamente la expresión   . 1.0 y sea la tabla de prioridades de conversión que
se muestra en la tabla 4.4.

TABLA 4.4: Tabla de prioridades de conversión.

Jacqueline Köhler C. - USACH


75
Compiladores

La figura 4.9 (a) muestra el AST con los tipos para los valores e identificadores literales
obtenido en el ejemplo anterior. Ahí se puede observar que el operador de adición tiene un
operando entero y otro real. De acuerdo a la tabla de prioridades de conversión, se determina
que el tipo float tiene mayor prioridad que el tipo int. En consecuencia, se utiliza la suma de
números reales para efectuar la operación. Como el resultado de la suma es de tipo float y la
variable a la que se asigna este valor es del mismo tipo, no es necesario efectuar ninguna
conversión para efectuar la asignación. La figura 4.9 (b) muestra el AST con los tipos para
operandos y operadores.

FIGURA 4.9: Comprobación de tipos para la expresión   . 1.0.


(a) AST con los tipos para valores e identificadores literales.
(b) AST con los tipos propagados a los nodos intermedios.

Si en el ejemplo 4.10 la variable  fuese de tipo float y la suma de tipo int, de acuerdo a la tabla de
prioridades de conversión se tiene que la suma debe ser llevada al tipo float. No ocurre lo mismo, sin
embargo, si la variable ha sido declarada con un tipo de menor prioridad que el valor a asignarle. En
este caso se produce un error que debe ser notificado al usuario, pues no es posible cambiar el tipo
original con que una variable fue declarada.

Ahora bien, aunque ya se ha determinado el tipo para los operadores, aún no es posible efectuar la
operación pues los operandos siguen teniendo tipos diferentes. Para igualar los tipos de los operandos
es necesario llevar a cabo una conversión de tipos, llamada coerción.

Para determinar la conversión automática de un tipo a otro se construye una tabla de coerción, que
indica qué nodo es necesario agregar al AST para llevar a cabo la conversión. Estos nodos que se
agregan se llaman nodos de coerción. La conversión de tipos, no obstante, no puede ser arbitraria. Debe
tener en consideración el rango de valores posibles (por ejemplo, char s int s float).

Ejemplo 4.11:
Continuando con la expresión   . 1.0, sea la tabla de coerciones que se muestra en la tabla
4.5. En la figura 4.10 (a) se puede observar la necesidad de llevar el tipo de la variable b a float
en lugar de int. Esta coerción es incorporada en la figura 4.10 (b).

Jacqueline Köhler C. - USACH


76
Compiladores

TABLA 4.5: Tabla de coerciones.

FIGURA 4.10: Comprobación de tipos para la expresión   . 1.0.


(a) AST con los tipos propagados a los nodos intermedios.
(b) AST con conversión de tipos.

4.4 OTRAS COMPROBACIONES SEMÁNTICAS


Aún cuando la comprobación de tipos se haya completado exitosamente, pueden existir otros errores
aún no detectados:
 Código inalcanzable.
 Retorno de funciones cuyo tipo es distinto de void.
 Chequeo de etiquetas case en switch.
 El lado izquierdo de una asignación no puede ser una función.
 Cuando un goto es encontrado, la etiqueta debe existir.
 Revisar parámetros de las funciones.

4.4.1 VALORES DEL LADO IZQUIERDO

En esta etapa se comprueba que las asignaciones efectuadas sean válidas. La tabla 4.6 muestra un
conjunto de asignaciones válidas e inválidas, donde f() es una función.

Jacqueline Köhler C. - USACH


77
Compiladores

Se define lvalue como la abreviación de left hand value, es decir, valor del lado izquierdo. Corresponde
a una expresión o referencia que puede ser puesta en el lado izquierdo de una asignación. Para que un
lvalue sea válido, debe ser una entidad modificable (como por ejemplo una variable).

TABLA 4.6: Asignaciones válidas e inválidas.

Una estrategia para determinar si una asignación es válida o no consiste en crear una lista de lvaues
válidos y después comprobar si cada lvalue presente en el AST es o no válido de acuerdo a la lista,
usando para ello el algoritmo 4.2.

ALGORITMO 4.2: Comprobar validez de asignaciones.


Situarse en la raíz del AST.
Para cada nodo en el AST hacer:
Si el nodo contiene un operador =: hacer:
Comprobar el lvalue del hijo más izquierdo.
Si es válido hacer:
Ir al siguiente nodo.
En otro caso hacer:
Reportar error.
En otro caso hacer:
Ir al siguiente nodo.

4.4.2 PARÁMETROS DE FUNCIÓN

Esta comprobación se encarga de verificar diferentes aspectos relacionados con las funciones:
 Que la cantidad de parámetros sea correcta.
 Que los tipos de los parámetros sean correctos.
 Que no existan múltiples main.

Para estas comprobaciones se utiliza una lista cuyos elementos son una estructura con los siguientes
elementos:
 Nombre de la función.
 Cantidad de parámetros.
 Lista con el nombre y tipo de cada parámetro.

La obtención de los encabezados de función y de la lista de parámetros se efectúa a partir del AST.

Ejemplo 4.12:
Considérense las siguientes funciones:
 function suma(int a, int b) {…}
 function minimo(int a, int b, int c) {…}
 function ordenar(float a, float b, float c, float d) {…}

Jacqueline Köhler C. - USACH


78
Compiladores

La figura 4.11 muestra la lista para efectuar la comprobación al momento de usar dichas
funciones.

FIGURA 4.11: Lista para comprobar parámetros de funciones

4.4.3 PALABRA CLAVE return

En el caso de aquellas funciones cuyo tipo de retorno sea distinto de void y puede, en consecuencia, ser
asignado a alguna variable, es necesario verificar que el tipo retornado sea correcto. En consecuencia,
es necesario comprobar que toda función que deba retornar algo finalice con una sentencia con la
palabra clave return. Si no se encuentra dicha sentencia, se debe notificar al usuario por medio de una
advertencia (warning).

Para evitar estos conflictos, se debe recorrer el bloque de código de la función y verificar la existencia
de una sentencia con la palabra clave return. Esta tarea se efectúa recorriendo el AST con el algoritmo
pre-orden, buscando la palabra return en todo el bloque de código de la función. Cuando la sentencia
de retorno se encuentre en un bloque if-then-else se debe comprobar la existencia de una sentencia de
retorno en ambos sub-bloques de código (o bien fuera del bloque), ya que se ejecutan
condicionalmente. Se debe proceder de manera similar para la sentencia switch.

Otra comprobación que se debe efectuar es verificar que, si corresponde, se retorne un valor que
indique que se produjo un error. El no señalar un error puede ocasionar consecuencias indeseadas a raíz
de una ejecución errónea del programa.

Jacqueline Köhler C. - USACH


79
Compiladores

Por último se debe verificar que no exista código inalcanzable, que corresponde al código situado
después de la sentencia de retorno.

4.4.4 CASOS DUPLICADOS EN UN switch

Una sentencia switch siempre contiene uno o más bloques de código: uno por cada caso definido y,
eventualmente, un bloque por defecto que se ejecutará en cualquier otro caso.

No obstante, también es sintácticamente correcto definir múltiples bloques para un mismo caso, es
decir, tener valores de casos duplicados. En tal caso no hay forma de determinar cuál de los bloques se
debe ejecutar, por lo que se debe advertir al usuario (warning). Generalmente, ante este conflicto se
genera el código correspondiente al primer bloque para el valor de caso en cuestión.

La comprobación para este error es muy sencilla y trabaja de manera recursiva. Se recorre el AST
comenzando por la raíz, en busca de todos los nodos switch. Por cada nodo switch que se encuentre, se
examinan sus nodos hijos (uno por cada bloque correspondiente a un caso) para ver si existe algún caso
duplicado. En caso afirmativo, se genera la advertencia correspondiente y se continúa con el análisis.

4.4.5 ETIQUETAS goto

Muchas veces el uso de la sentencia goto se traduce en programas con un comportamiento extraño y no
deseado. Al igual que ocurre con las variables, es necesario declarar una etiqueta (label) goto para
poder usarla.

Una implementación adecuada para comprobar que existan las etiquetas goto es incorporar dichas
etiquetas en la tabla de símbolos. A continuación se debe recorrer el AST y, por cada nodo goto,
comprobar si la etiqueta de destino se encuentra en la tabla de símbolos. En caso de no encontrarse la
etiqueta, se debe reportar el error.

4.5 EJERCICOS
1. Considere la tabla de prioridades y la tabla de coerción que se muestran en las tablas 4.7 y 4.8.
Suponga, para este ejercicio, que la precedencia de los operadores, de mayor a menor, es: ^, /, , ..
El operador ^ corresponde a la exponenciación. Sea la expresión : ^)/'  * . +/£  ^;,
donde  es de tipo Complex; , ), +, £ y  son de tipo Natural; ' e ; son de tipo Int; * es de tipo
Float.
a. Construya el AST para la expresión dada.
b. Construya el AST con coerciones y etiquetas de tipos.
c. Indique si existe algún tipo de error semántico en la expresión dada y justifique su respuesta.
d. ¿Qué ocurriría si ahora  es de tipo Int? ¿Por qué?
e. ¿Qué ocurriría si ahora  es de tipo Complex y ' es de tipo String? ¿Por qué?

Jacqueline Köhler C. - USACH


80
Compiladores

TABLA 4.7: Tabla de prioridades para los ejercicios 1 y 2.

TABLA 4.8: Tabla de coerciones para los ejercicios 1 y 2.

2. Considere la tabla de prioridades y la tabla de coerción que se muestran en las tablas 4.7 y 4.8.
Considere además las mismas operaciones y precedencia de operadores del ejercicio 1. Sea la
expresión /: /^)  ' . * . +^£/ . ;  >, donde + es de tipo Complex; , ; y / son de
tipo Float; , ), £ y > son de tipo Int; , ' y * son de tipo Natural.
a. Construya el AST para la expresión dada.
b. Construya el AST con coerciones y etiquetas de tipos.
c. Indique si existe algún tipo de error semántico en la expresión dada y justifique su respuesta.

Jacqueline Köhler C. - USACH


81
Compiladores

3. Dado el fragmento de código del listado 4.1, efectúe las siguientes comprobaciones semánticas:
a. Código inalcanzable.
b. Retorno de funciones cuyo tipo es distinto de void.
c. Cuando un goto es encontrado, la etiqueta debe existir.
d. Que no haya trazas para las que no haya un retorno.

LISTADO 4.1: Fragmento de código para el ejercicio 3.

int pvoiAgregarPaquete(listaPaquete *plpqLista, int pintPaquete) {


numeroPaquete *pnpqAuxiliar = (numeroPaquete *) malloc(sizeof(struct
numeroPaquete));
pnpqAuxiliar->intNumero = pintPaquete;

// si la lista esta vacia


if (fbooVaciaListaPaquete(plpqLista)) {
pnpqAuxiliar->anterior = NULL;
pnpqAuxiliar->siguiente = NULL;
plpqLista->primero = pnpqAuxiliar;
plpqLista->ultimo = pnpqAuxiliar;
return;
printf("La lista fue creada exitosamente.");
}

// no esta vacia
numeroPaquete *indice = plpqLista->primero;
numeroPaquete *antecesor = NULL;

while (indice) {
1: if (indice->intNumero < pintPaquete) {
antecesor = indice;
indice = indice->siguiente;
}

else {
pnpqAuxiliar->siguiente = indice;
plpqLista->primero = pnpqAuxiliar;
return;
}
}

pnpqAuxiliar->siguiente = NULL;
plpqLista->ultimo->siguiente = pnpqAuxiliar;
plpqLista->ultimo = plpqLista->ultimo->siguiente;
goto 1
}

4. Dadas las siguientes funciones, dibuje la estructura de datos que permite efectuar la comprobación
de sus parámetros.
 elipse(float eje1, float eje2, int x0, int y0, float orientacion).
 circulo(float radio, int x0, int y0) .

Jacqueline Köhler C. - USACH


82
Compiladores

5 AMBIENTES PARA EL MOMENTO DE EJECUCIÓN


Antes de generar el código objeto es necesario relacionar el código fuente estático de un programa con
la acciones que deben ocurrir en el momento de ejecución. Se debe tener también en consideración que
un mismo nombre puede tener distintos significados en fragmentos diferentes del código (por ejemplo,
tipos de datos diferentes en distintas funciones). En este capítulo se estudia la problemática de la
asignación y desasignación de objetos.

5.1 ASPECTOS DEL LENGUAJE FUENTE


En esta sección se distingue entre el código fuente de un procedimiento o función y las actividades que
éste realiza durante la ejecución. Se llamará función a cualquier procedimiento que devuelva valores.

5.1.1 PROCEDIMIENTOS

Una definición de un procedimiento es una declaración que, en su forma más básica, asocia un
identificador con una proposición. El identificador se denomina nombre del procedimiento, mientras la
proposición conforma su cuerpo.

Cuando aparece el nombre de un procedimiento dentro de una proposición ejecutable, se dice que el
procedimiento es llamado. Cuando esto ocurre, la llamada provoca que el procedimiento se ejecute. Es
importante recordar que las llamadas a procedimientos también pueden ocurrir dentro de expresiones.

En la definición de un procedimiento suelen aparecer diversos identificadores. Estos identificadores


son los parámetros formales del procedimiento. Los parámetros actuales, en cambio, son los
argumentos entregados a un procedimiento cuando es llamado. Estos valores son sustituidos por los
parámetros formales.

5.1.2 ÁRBOLES DE ACTIVACIÓN

Durante la ejecución de un programa rigen ciertos supuestos sobre el sobre el flujo de control:
1. El control fluye secuencialmente.
2. Cada ejecución de un procedimiento comienza al inicio del cuerpo de éste y en algún momento
devuelve el control al punto situado inmediatamente después de la llamada.

Cada ejecución del cuerpo de un procedimiento se denomina activación. Se denomina duración de la


activación a la secuencia de pasos entre el primer y el último paso de la ejecución del procedimiento,
incluyendo llamadas a otros procedimientos llamados por él. Si un procedimiento llama a otra instancia
de él mismo, se dice que es recursivo.

Es posible representar gráficamente el flujo de control de un programa mediante un árbol de


activación.

Jacqueline Köhler C. - USACH


83
Compiladores

En un árbol de activación:
 Cada nodo representa la activación de un procedimiento.
 La raíz representa la activación del programa principal.
 El nodo  es el padre del nodo  si y solo si el control fluye de la activación  a la .
 El nodo  está a la izquierda del nodo  si y sólo si la duración de  ocurre antes que la de .

Ejemplo 5.1:
La figura 5.1 muestra el árbol de activación correspondiente al programa en Pascal del listado
5.1.

LISTADO 5.1: Programa en Pascal que lee y ordena enteros.

Program ordenamiento(input, output);


var a: array[0..10] of integer;

procedure leemartriz;
var i: integer;
begin
for i:=1 to 9 do read(a[i])
end;

function particion(y, z: integer): integer;


var i, j, x, v: integer;
begin

end;

procedure clasificacion_por_particiones(m, n: integer);


var i: integer;
begin
if(n>m) then begin
i:=particion(m, n);
clasificacion_por_particiones(m, i-1);
clasificacion_por_particiones(i+1, n);
end
end;

begin
a[0]:=-9999; a[10]:=9999;
leematriz;
clasificacion_por_particiones(1, 9);
end.

5.1.3 PILAS DE CONTROL

El flujo de control del programa corresponde al recorrido en profundidad del árbol de activación. Se
comienza desde la raíz y se visita cada nodo antes que a sus hijos. Los hijos son visitados
recursivamente de izquierda a derecha.

La pila de control permite llevar un registro de las activaciones de los procedimientos en curso. Se
introduce el nodo a la pila cuando comienza su activación y se saca cuando ésta termina. Los

Jacqueline Köhler C. - USACH


84
Compiladores

contenidos de la pila se relacionan con los caminos hasta la raíz del árbol de activaciones: cuando un
nodo n está al tope de la pila de control, la pila contendrá a todos los nodos en el camino desde n hasta
la raíz.

FIGURA 5.1: Árbol de activación correspondiente a la salida de la ejecución del programa del
listado 5.1.

Ejemplo 5.2:
La figura 5.2 muestra la pila de control para un instante dado de la ejecución del programa del
listado 5.1, demarcado por las líneas continuas del árbol de activación.

FIGURA 5.2: Pila de control para el momento de ejecución demarcado con líneas
continuas en el árbol de activación.

Jacqueline Köhler C. - USACH


85
Compiladores

5.1.4 ÁMBITO DE UNA DECLARACIÓN

Una declaración es una construcción sintáctica que asocia información a un nombre. Por otra parte,
toda declaración tiene un ámbito, es decir, una parte del programa donde puede ser aplicada.

Pueden existir diferentes declaraciones de un mismo nombre en distintas partes de un programa. En


este caso, son las reglas de ámbito del lenguaje fuente las que determinan qué declaración utilizar al
encontrarse el nombre en el programa fuente. Se dice que un nombre es local a un procedimiento si ha
sido declarado al interior de éste. En caso contrario, se dice que no es local.

Al momento de compilar se puede usar la tabla de símbolos para encontrar la declaración que aplica a
un nombre. Al declararse un nombre, se crea la entrada correspondiente en la tabla de símbolos. Se
retornará dicha entrada al buscar el nombre mientras dure el ámbito de la declaración.

5.1.5 ENLACE DE NOMBRES

Aunque un nombre se declare solo una vez en el programa, ese nombre puede indicar diferentes objetos
de datos durante la ejecución. Un objeto de datos se refiere a una posición de memoria que puede
contener valores.

Se define como ambiente una función que transforma un nombre en una posición de memoria. Por otra
parte, estado es una función que transforma una posición de memoria en el valor en ella contenido.
Nótese que ambas funciones son diferentes: una asignación modifica el estado, pero no el ambiente.
Estos conceptos se ilustran en la figura 5.3.

Se dice que un nombre x está enlazado a una posición de memoria s cuando está asociado a dicha
posición de memoria. Un enlace es la contrapartida dinámica de una declaración (puede haber más de
una activación de un procedimiento recursivo), como se muestra en la tabla 5.1.

FIGURA 5.3: Nociones estáticas y dinámicas correspondientes.

TABLA 5.1: Nociones estáticas y dinámicas correspondientes.

Jacqueline Köhler C. - USACH


86
Compiladores

5.2 ORGANIZACIÓN DE LA MEMORIA


5.2.1 SUBDIVISIÓN DE LA MEMORIA DURANTE LA EJECUCIÓN

Un compilador trabaja con un bloque de memoria asignado por el sistema operativo. Esta memoria
debe ser subdividida para que pueda albergar diferentes elementos (ver figura 5.4):
 El código objeto generado.
 Los objetos de datos.
 Una contrapartida de la pila de control para registrar las activaciones de procedimientos.

FIGURA 5.4: Subdivisión típica de la memoria durante la ejecución.

El código objeto generado tiene un tamaño fijo al momento de la compilación, por lo que se puede
colocar estáticamente en una zona de la memoria. Lo mismo ocurre con algunos objetos de datos.
Resulta conveniente asignar en forma estática la mayor cantidad posible de datos, pues así éstos pueden
ser compilados al código objeto.

La pila de control se almacena en una porción diferente de memoria. Se deja además un bloque llamado
montículo, que almacena toda la información restante.

Tanto la pila como el montículo tienen tamaños variables, por lo que se sitúan en extremos opuestos de
la memoria a fin de que ambas puedan crecer según sea necesario.

5.2.2 REGISTROS DE ACTIVACIÓN

Los registros de activación son bloques contiguos de memoria que almacenan toda la información
necesaria para una sola ejecución de un cierto procedimiento. La figura 5.5 muestra los campos
habituales de un registro de activación, aunque no todos los lenguajes ni todos los compiladores hacen
uso de todos esos campos.

Los tamaños de cada uno de estos campos pueden ser determinados en el momento en que es llamado
un procedimiento o incluso durante la compilación. La única excepción se produce cuando el
procedimiento contiene una matriz local cuyo tamaño venga dado por un parámetro actual.

Jacqueline Köhler C. - USACH


87
Compiladores

Figura 5.5: Un típico registro de activación.

Los diferentes campos del registro de activación pueden describirse como sigue:
 Valor devuelto: sirve para devolver un valor al autor de la llamada. Para mayor eficiencia, se suele
trasladar este valor a un registro de la máquina.

 Parámetros actuales: este campo es utilizado por el autor de la llamada para proporcionar
parámetros al procedimiento llamado. Es habitual, no obstante, pasar los parámetros por medio de
un registro.

 Enlace de control opcional: apunta al registro de activación del autor de la llamada.

 Enlace de acceso opcional: sirve para hacer referencia a los datos no locales guardados en otros
registros de activación.

 Estado guardado de la máquina: mantiene la información del estado de la máquina justo antes de
que el procedimiento fuese llamado (valor del contador del programa, valores de los registros, etc.).
Estos valores deben reponerse cuando el control regresa al procedimiento llamador.

 Datos locales: guarda datos locales a la ejecución del procedimiento.

 Temporales: almacena valores temporales como los que surgen de la evaluación de expresiones.

5.2.3 DISPOSICIÓN ESPACIAL DE LOS DATOS LOCALES EN EL MOMENTO DE LA


COMPILACIÓN

Se considera el byte como la mínima unidad de memoria direccionable. En muchas máquinas se


considera también el concepto de palabras de máquina, donde cada una de estas palabras está
conformadas por un cierto número de bytes consecutivos. Estas palabras suelen usarse para almacenar
objetos de tamaño superior a un byte. En tal caso, la dirección asignada al objeto es la del primer byte.

Jacqueline Köhler C. - USACH


88
Compiladores

La cantidad de memoria que se asigna a un nombre viene dada por el tipo de éste. Los tipos de datos
elementales generalmente pueden ser almacenados en un número entero de bytes. En el caso de datos
agregados, como matrices y registros, se debe asignar un bloque de memoria lo suficientemente grande
como para almacenar todos los componentes. Lo más usual es que se asignen bloques contiguos de
bytes para facilitar el acceso a cada uno de los datos.

En la sección anterior se mostró el registro de activación. En él, el campo para los datos locales se
determina durante la compilación, a medida que se examinan las declaraciones al interior del
procedimiento (no se incluyen aquí los datos cuya longitud es variable). La dirección relativa o
desplazamiento de un valor local con respecto a una posición de memoria, como el primer byte del
registro de activación, es la diferencia entre la posición del objeto y la dirección considerada como
referencia.

5.3 ESTRATEGIAS PARA LA ASIGNACIÓN DE MEMORIA


Existen diferentes técnicas de asignación de memoria para cada una de las áreas de datos de la figura
5.5.

5.3.1 ASIGNACIÓN ESTÁTICA

Este tipo de asignación se encarga de disponer la memoria para todos los objetos de datos durante el
proceso de compilación. Esto se realiza enlazando los nombres a las posiciones de memoria.

La asignación de memoria se efectúa tal como se describió en la sección 5.2.3. El compilador debe
decidir la ubicación de los registros de activación con respecto al código objeto y a los demás registros
de activación, con lo que la posición de cada uno de dichos registros queda determinada y, en
consecuencia, quedan determinadas también las posiciones de cada nombre dentro del registro. Todo lo
anterior hace posible que sea posible entregar al código objeto, durante la compilación, las posiciones
de memoria donde se encuentran los valores que requiere para su operación y las direcciones donde se
almacena la información al producirse una llamada a un procedimiento.

Como los enlaces no cambian durante la ejecución, cada vez que se activa un procedimiento sus
nombres se enlazan a las mismas posiciones de memoria. Esta característica hace posible que los
valores de los nombres locales sean retenidos de una activación a otra. No obstante, esta técnica de
asignación tiene asociadas algunas limitaciones:
 El tamaño de un objeto de datos y sus limitaciones en cuanto a ubicación deben ser conocidos en el
momento de la compilación.
 Es muy difícil crear procedimientos recursivos, pues todas las activaciones hacen uso de los
mismos enlaces para los nombres locales.
 No es posible crear estructuras de datos dinámicamente porque no existe un mecanismo de
asignación de memoria durante la ejecución.

Jacqueline Köhler C. - USACH


89
Compiladores

5.3.2 ASIGNACIÓN POR MEDIO DE UNA PILA

Este tipo de asignación está basado en la idea de una pila de control. Se da a la memoria la
organización de una pila, y en ella se agregan y se retiran los registros de activación cuando las
llamadas a procedimientos comienzan y terminan, respectivamente. La figura 5.6 muestra un ejemplo
de este de asignación mediante una pila.

Este esquema de asignación permite que para cada activación las variables locales se enlacen a nueva
memoria, puesto que se introduce un nuevo registro de activación a la pila. Además, al terminar la
activación las variables locales son eliminadas pues se quita el registro de activación de la pila.

En este punto es importante definir un problema muy habitual: las referencias suspendidas, que se
producen cuando se hace referencia a memoria desasignada. Este tipo de error corresponde a un error
lógico y no puede ser detectado mediante ninguno de los analizadores que se emplean en un
compilador. Esto se debe a que los lenguajes no consideran en su semántica el valor de la memoria
desasignada y a que muchas veces la memoria puede asignarse posteriormente a otro dato y crear así
errores ocultos en el programa.

La asignación por medio de una pila no puede utilizarse cuando ocurre alguna de las siguientes
situaciones, en que la desasignación de memoria no tiene por qué ser de la forma último en entrar,
primero en salir:
 Se debe retener los valores de los nombres locales cuando finaliza una activación.
 Una activación llamada sobrevive al autor de la llamada. Este caso no es posible en aquellos
lenguajes en que los árboles de activación representan correctamente el flujo de control.

5.3.3 ASIGNACIÓN POR MEDIO DE UN MONTÍCULO

La asignación por medio de un montículo divide partes de memoria contigua, conforme las necesiten
los registros de activación u otros objetos. Las distintas partes se pueden desasignar en cualquier orden,
de modo que con el paso del tiempo el montículo constará de áreas libres y ocupadas. En consecuencia,
se debe tener mucho cuidado con el manejo del montículo. Más adelante se muestran algunas técnicas
adecuadas.

5.4 ACCESO A NOMBRES NO LOCALES


El enfoque para tratar las referencias a nombres no locales viene dado por las reglas de ámbito de un
lenguaje. Las reglas de ámbito léxico o ámbito estático, usadas por lenguajes como pascal, C y Ada,
determinan la declaración que se aplica a un nombre solo con examinar el texto del programa. Hacen
esto en conjunto con una estipulación de anidamiento más cercano, como se verá más adelante. Las
reglas de ámbito dinámico en cambio, usadas por lenguajes como Lisp y Snobol, determinan la
declaración que se aplica a un nombre durante la ejecución, tomando en consideración las actividades
en curso.

Jacqueline Köhler C. - USACH


90
Compiladores

FIGURA 5.6: Asignación de registros de activación por medio de una pila que crece hacia abajo.

5.4.1 BLOQUES

Un bloque es una proposición que contiene sus propias declaraciones de datos locales. En C, por
ejemplo, un bloque tiene la siguiente sintaxis:

{declaraciones proposiciones}

Jacqueline Köhler C. - USACH


91
Compiladores

Una característica de los bloques es su estructura de anidamiento. Los delimitadores marcan el inicio y
el fin de un bloque (llaves en C). Éstos garantizan que un bloque sea independiente de otro o bien que
esté anidado dentro de otro. Esta propiedad de anidamiento recibe el nombre de estructura de bloques.

En un lenguaje con estructura de bloques el ámbito de una declaración viene dado por la regla de
anidamiento más cercano. Ésta se ejemplifica en la figura 5.7, donde se muestra un programa en C y se
señalan claramente los bloques y el ámbito de cada variable. La regla de anidamiento más cercano es:
1. El ámbito de una declaración en un bloque B incluye B.
2. Si un nombre x no está declarado en un bloque B, entonces un caso de x en B está en el ámbito de
una declaración de x en un bloque abarcador B’ que cumple las siguientes condiciones:
a. B’ tiene una declaración x.
b. B’ está anidado más cerca alrededor de B que cualquier otro bloque con una declaración de x.

FIGURA 5.7: Bloques y ámbito de las variables para un programa en C.

Nótese que un bloque no es lo mismo que un procedimiento. Estos últimos son más simples, pues no
hay paso de parámetros y el control cumple las siguientes condiciones:
 Fluye a un bloque desde el punto inmediatamente anterior a él en el texto fuente.
 Fluye desde el bloque al punto inmediatamente posterior a él en el texto fuente.

Existen diferentes formas para implementar la estructura de bloques. Una de ellas es usar una pila.

5.4.2 ÁMBITO LÉXICO SIN PROCEDIMIENTOS ANIDADOS

Algunos lenguajes tienen reglas de ámbito léxico más complejas que otros. Por ejemplo, C no permite
anidar procedimientos, mientras que Pascal si lo permite. Esto significa que en C no es posible definir
un procedimiento dentro de otro y, en consecuencia, en caso de existir una referencia no local el

Jacqueline Köhler C. - USACH


92
Compiladores

nombre debe declararse fuera de cualquier procedimiento. En este caso, el ámbito de una declaración
hecha fuera de alguna función consta de los cuerpos de todas las funciones que aparezcan después de
dicho nombre, excepto aquellas funciones que contengan una declaración homónima.

En ausencia de procedimientos anidados es posible utilizar el esquema de asignación mediante pilas


para los nombres locales, pues es posible asignar estáticamente la memoria para todos los nombres
declarados fuera de cualquier procedimiento. Esto se debe a que la posición de cada uno de estos
nombres es conocida al momento de la compilación.

Una ventaja de este esquema es que los nombres no locales pueden pasarse como parámetros y
devolverse como resultado.

5.4.3 ÁMBITO LÉXICO CON PROCEDIMIENTOS ANIDADOS

Lenguajes como Pascal difieren de C en el hecho de que es posible declarar un procedimiento dentro de
otro. Un ejemplo de esto es el código que se muestra en el listado 5.1. Aquí, un caso no local de un
nombre a se encuentra dentro del alcance de la declaración anidada más cercana de a en el texto del
programa fuente. En este tipo de lenguajes se tiene también que la regla de anidamiento más cercano se
aplica también a los nombres de procedimientos.

Cuando se trabaja con procedimientos anidados se debe manejar un nuevo concepto: profundidad de
anidamiento. La profundidad del programa principal se define como 1, y cada vez que de un
procedimiento se pase a otro abarcado por él se suma 1 a la profundidad. Por ejemplo, en el listado 5.1
la función partición está a profundidad de anidamiento 3.

Una manera directa de implementar el ámbito léxico para procedimientos anidados se obtiene al
incorporar a cada registro de activación un puntero llamado enlace de acceso. Así, si un procedimiento
‹ está anidado inmediatamente dentro de c en el programa fuente, entonces el enlace de acceso de un
registro de activación para ‹ apunta al enlace de acceso del registro de activación más reciente de ).
Esto puede verse más claramente en la figura 5.8.

5.4.4 ÁMBITO DINÁMICO

En este caso, una nueva activación hereda los enlaces ya existentes entre nombres no locales y la
memoria. Existen dos enfoques para implementar el ámbito dinámico: acceso profundo y acceso
superficial.

Acceso profundo: en este caso se prescinde de los enlaces de acceso y se utiliza el control para buscar
el primer registro de activación que contenga el nombre no local. El nombre se refiere a que se efectúa
una búsqueda en profundidad al interior de la pila y no es posible determinar la profundidad durante la
compilación.

Acceso superficial: en este caso se conserva el valor en curso de cada nombre en memoria asignado
estáticamente. Así, cuando se lleva a cabo una nueva activación de un procedimiento, un nombre = al

Jacqueline Köhler C. - USACH


93
Compiladores

interior de él usará la memoria asignada estáticamente para dicho nombre. En este caso es necesario
guardar el valor previo de = para restaurarlo una vez terminada la ejecución del procedimiento.

FIGURA 5.8: Enlaces de acceso para encontrar las posiciones de memoria de los nombres no locales.

5.5 PASO DE PARÁMETROS


Cuando un procedimiento llama a otro, el método habitual de comunicación entre ellos es a través de
nombres no locales y de parámetros que se pasan al procedimiento llamado. Un ejemplo de esto
aparece en el listado 5.2, donde ; y > son parámetros, mientras  es un nombre no local.

LISTADO 5.2: Procedimiento en Pascal que opera con parámetros y nombres no locales.

procedure intercambio(i, j: integer)


var x: integer
begin
x := a[i]; a[i] := a[j]; a[j] := x
end.

Existen diferentes métodos para asociar parámetros actuales y formales, de los cuales se estudiarán solo
los tres primeros por ser de uso más frecuente:
 Llamada por valor.
 Llamada por referencia.
 Copia y restauración.
 Llamada por nombre o macroexpansión.

Jacqueline Köhler C. - USACH


94
Compiladores

Es importante conocer el método de paso de parámetros que utiliza un lenguaje (o compilador), pues el
resultado de un programa puede depender del método empleado.

5.5.1 LLAMADA POR VALOR

Es el método más sencillo para pasar parámetros. Aquí se evalúan los parámetros actuales y se pasan
sus valores de lado derecho al procedimiento llamado. Una posible implementación para la llamado por
valor es la siguiente:

1. Un parámetro formal se considera como un nombre local, de modo que las direcciones de memoria
para los parámetros formales se encuentran en el registro de activación del procedimiento llamado.
2. El procedimiento autor de la llamada evalúa los parámetros actuales y coloca sus valores de lado
derecho en las direcciones de memoria de los parámetros formales.

Un ejemplo para este esquema de paso de parámetros se muestra en el listado 5.3. La ejecución de la
llamada permuta(a, b) es equivalente a la siguiente secuencia de pasos:

x := a
y := b
temp := x
x := y
y := temp

LISTADO 5.3: Programa en Pascal con un procedimiento que recibe parámetros por valor.

program referencia(input, output);


var a, b: integer;
procedure permuta(var x, y: integer);
var temp: integer;
begin
temp := x;
x := y;
y := temp;
end;
begin
a := 1; b := 2;
permuta(a,b);
writeln(‘a = ‘,a); writeln(‘b =’,b);
end.

Una característica distintiva de la llamada por valor es que las operaciones sobre los parámetros
formales no afectan a los valores en el registro de activación del autor de la llamada.

El listado 5.4 muestra un segundo ejemplo, esta vez en C, donde se hace uso de punteros para pasar
parámetros por valor. En este caso se emula el comportamiento de la llamada por referencia.

Jacqueline Köhler C. - USACH


95
Compiladores

LISTADO 5.4: Programa en C con un procedimiento que usa punteros y llamada por valor.

void permuta(x,y)
{
int *x, *y;
int temp;
temp = *x;
*x = *y;
*y = temp;
}
main()
{
int a = 1, b = 2;
permuta(&a, &b);
printf(“a es ahora %d, b es ahora %d\n”, a, b);
}

5.5.2 LLAMADA POR REFERENCIA

Cuando se pasan parámetros por referencia, el autor de la llamada pasa al procedimiento llamado un
puntero a la dirección de memoria de cada parámetro actual:
1. Si un parámetro actual es un nombre o una expresión que tenga un valor de lado izquierdo
(dirección en memoria), entonces se pasa ese mismo valor de lado izquierdo.
2. Sin embargo, si el parámetro actual es una expresión, como a + b ó 2, que no tiene ningún valor de
lado izquierdo, entonces la expresión se evalúa en una nueva posición y se pasa la dirección de
dicha posición.

Para un ejemplo de la llamada por referencia, considérese la función en C del listado 5.5. En este caso,
al efectuar la llamada cuadrado(3, y) se almacena el valor 9 en la variable y. Este estilo de paso de
parámetros resulta muy útil para poder retornar valores de forma implícita.

LISTADO 5.5: Programa en C que pasa parámetros por referencia.

void cuadrado(int x, int& result) {


result = x*x;
}

5.5.3 COPIA Y RESTAURACIÓN

Este método de paso se parámetros es un híbrido entre las llamadas por valor y por referencia. Opera en
dos pasos, que se muestran a continuación:
1. Los parámetros actuales se evalúan antes de que el control fluya al procedimiento llamado. Los
valores del lado derecho de éstos se pasan a dicho procedimiento al igual que en la llamada por
valor. La diferencia radica en que, cuando es posible, los valores de lado izquierdo (posiciones de
memoria) de los parámetros actuales se determinan antes de la llamada.
2. Cuando el control retorna, se copian los valores de lado derecho en curso para los parámetros
formales en los valores de lado izquierdo de los parámetros actuales, para lo cual se utilizan los

Jacqueline Köhler C. - USACH


96
Compiladores

valores de lado izquierdo calculados antes de la llamada. Esta copia se efectúa solo para aquellos
parámetros actuales con valor de lado izquierdo (es decir, aquellos parámetros que son un nombre
y, por ende, tienen un enlace en memoria).

El listado 5.6 muestra un procedimiento en Pascal en que el resultado cambia según qué método de
paso de parámetros se emplee. En este caso, existen dos maneras de acceder a la posición de a en el
registro de activación de copiaafuera() cuando éste llama a inseguro(): como nombre no local o
mediante el parámetro formal x. Así, al usar las formas mencionadas de paso de parámetros se obtienen
los siguientes resultados:
 Llamada por referencia: en este caso, las asignaciones hechas a x y a a afectan de inmediato a a,
por lo que el valor final para este nombre que se muestra por pantalla es 0.
 Copia y restauración: aquí el valor 1 del parámetro actual a se copia en el parámetro formal x.
Justo antes de terminar la ejecución de inseguro(), se copia el valor final de x (que es 2) en el valor
de lado izquierdo de a, por lo que el valor para este nombre que se muestra por pantalla es 2.

LISTADO 5.6: Programa en Pascal para el cual cambian los resultados según si se pasan parámetros
por referencia o mediante copia y restauración.

program copiaafuera(input,output);
var a : integer;
procedure inseguro(var x : integer);
begin
x := 2;
a := 0
end
begin
a := 1;
inseguro(a);
writeln(a)
end.

5.6 TABLA DE SÍMBOLOS


Durante el proceso de compilación se requiere almacenar en memoria los diferentes nombres
(variables, funciones, etc.) declarados en un programa fuente, pues es necesario tener la capacidad de
identificarlos para poder efectuar acciones tales como asignaciones o llamadas a funciones.

En otras palabras, se necesita una estructura que permita llevar un registro de la información sobre el
ámbito y el enlace de los nombres; si corresponden a variables, conocer su valor, su tipo o dónde se
encuentran almacenadas; para funciones, determinar si han sido declaradas previamente, etc.

Esta estructura recibe el nombre de tabla de símbolos. Cada vez que se encuentra un nombre en el
programa fuente se debe examinar esta tabla y, si es un nombre nuevo, se crea una nueva entrada; si se
encuentra nueva información sobre un nombre ya existente, se debe incorporar dicha información a la
entrada correspondiente. En consecuencia, es necesario implementar la tabla de símbolos que permita
añadir nuevas entradas y encontrar las ya existentes de manera eficiente.

Jacqueline Köhler C. - USACH


97
Compiladores

5.6.1 ENTRADAS DE LA TABLA DE SÍMBOLOS

Como se puede desprender de la descripción anterior, cada entrada de la tabla de símbolos corresponde
a la declaración de un nombre. Ahora bien, los nombres pueden corresponder a distintos tipos de datos
o a funciones y procedimientos, por lo que no todos los nombres tendrán los mismos atributos. Esto
hace que sea difícil e ineficiente crear un registro de tamaño uniforme para poder almacenar la
información para cada tipo de datos, por lo que una buena alternativa es almacenar esta información en
algún lugar de la memoria fuera de la tabla de símbolos y en ésta mantener un puntero a dicha
información.

Otro problema importante en la construcción de la tabla de símbolos es que no todas las entradas se
agregan a la vez:
 Si el analizador léxico no reconoce palabras clave o reservadas, éstas deben estar presentes en la
tabla de símbolos antes de comenzar esta etapa de análisis.
 Una entrada de la tabla de símbolos solo puede establecerse cuando se conoce claramente el papel
que juega el nombre en el programa y los valores de los atributos solo se van agregando a medida
que se conoce esa información.
 Cuando un nombre puede ocuparse solo una vez, es posible crear la entrada de la tabla de símbolos
durante el análisis sintáctico. En otro caso, las diferentes entradas para un mismo nombre se van
creando a medida que se descubre el rol sintáctico de cada instancia del nombre.

Los atributos de los símbolos se introducen frecuentemente como consecuencia de su declaración, que
puede ser implícita. Además, es la sintaxis de las declaraciones de procedimientos la que especifica que
algunos identificadores corresponden a parámetros formales.

Un último problema a considerar es que los nombres, vistos como lexemas, pueden ser difíciles de
manejar debido a las diferentes longitudes de los nombres. En consecuencia, se debe implementar
alguna representación de un nombre de longitud fija que pueda ser incorporado en la tabla de símbolos.
No obstante, se debe tener cuidado de conservar en memoria, fuera de la tabla de símbolos, el
identificador completo a fin de poder comprobar si dicho lexema ya ha aparecido.

5.6.2 INFORMACIÓN SOBRE LA ASIGNACIÓN DE MEMORIA

La tabla de símbolos mantiene también la información acerca de la posición de memoria enlazada a


cada nombre durante la ejecución. Aquí es necesario considerar diferentes casos:
 Nombres con posición de memoria estática. Para este tipo de nombres se actúa diferente
dependiendo del tipo de código que genera el compilador (lenguaje objeto):
• Lenguaje ensamblador: basta con crear definiciones de datos en lenguaje ensamblador para
cada nombre y luego añadirlas al programa objeto. El ensamblador se encarga posteriormente
de las posiciones de memoria de cada nombre.
• Código de máquina: En este caso, se determina la posición de cada objeto de datos en relación a
una posición fija, como el inicio de un registro de activación o un bloque independiente del
programa.
 Nombres cuya memoria se encuentra asignada en una pila o montículo: en este caso, el compilador
solo puede organizar el registro de activación para cada procedimiento.

Jacqueline Köhler C. - USACH


98
Compiladores

5.6.3 TIPOS DE TABLAS DE SÍMBOLOS

Existen tablas de símbolos estáticas y dinámicas, y es importante poder determinar qué tipo de tabla se
debe ocupar:
 Estática: es útil cuando se debe acceder a los símbolos en múltiples ocasiones.
 Dinámica: se usa cuando la información de los símbolos es utilizada en solo una pasada.

La figura 5.9 muestra un pequeño programa en C y los elementos presentes en la tabla de símbolos
tanto para el caso estático como para el dinámico. Se puede observar que en el caso estático se
incorporan los nombres de todos los procedimientos a medida que van apareciendo, pero no se
eliminan. En el caso dinámico, en cambio, solo figuran aquellos elementos pertenecientes a los
procedimientos activos.

FIGURA 5.9: Comparación entre tablas de símbolos estática y dinámica.

5.6.4 IMPLEMENTACIÓN DE LA TABLA DE SÍMBOLOS

La tabla de símbolos puede construirse mediante diferentes estructuras de datos:


 Arreglos:
• desde el punto de vista del programador resultan muy convenientes porque son fáciles de
implementar.
• Tienen un tamaño fijo, lo que obliga a asignar memoria para la misma cantidad de atributos por
cada símbolo, se use o no. En muchos casos esto se traduce en un significativo desperdicio de
memoria.
• Se utilizan algoritmos de búsqueda lineales para acceder a los diferentes atributos.
• Si el arreglo se encuentra ordenado, es posible construir algoritmos de búsqueda binarios.
• No es posible manejar información relativa al alcance de las variables.
 Pilas:
• La variable al tope de la pila es la que tiene el alcance más reciente.
• Resulta fácil implementar múltiples niveles de alcance.
• Es la opción más adecuada para la construcción de una tabla de símbolos dinámica.

Jacqueline Köhler C. - USACH


99
Compiladores

 Listas enlazadas:
• Similares a las pilas.
• La implementación de operaciones resulta más sencilla.
• Al igual que en los arreglos, las búsquedas toman un tiempo lineal.
 Árboles binarios:
• Si los elementos se encuentran ordenados, se reduce el tiempo de búsqueda en forma
significativa.
• El manejo del alcance de los símbolos se vuelve más complejo.
• Las operaciones de inserción y eliminación de elementos son muy costosas en tiempo.
 Árboles n-arios:
• Todo nodo es un alcance, y todos los símbolos de ese nivel de alcance están contenidos en
dicho nodo.
• Cada nodo tiene n hijos, y cada hijo implica un nuevo alcance de su alcance padre.
 Tablas de dispersión (tablas hash): corresponden a una de las técnicas más utilizadas para la
implementación de tablas de símbolos y se ilustran en la figura 5.10. Se construye de la siguiente
manera:
• Se crea en primer lugar una tabla de dispersión, que es una matriz fija con ¤ punteros a
entradas de la tabla.
• Las entradas de la tabla se organizan en ¤ listas enlazadas, independientes entre sí, donde cada
registro de la tabla de símbolos aparece solo en una de estas listas. Para determinar en qué lista
debe ir la entrada para el nombre z se utiliza una función de dispersión z que devuelve un
entero entre 0 y ¤  1. Si s se encuentra en la tabla de símbolos, estará en la lista numerada con
z. En caso contrario, se debe crear una nueva entrada en dicha lista.

FIGURA 5.10: Tabla de dispersión de tamaño 211.

5.6.5 REPRESENTACIÓN DE LA INFORMACIÓN SOBRE EL ÁMBITO

Muchas veces resulta difícil manejar el ámbito de un nombre si se trabaja con una única tabla de
símbolos. Una buena manera de resolver este problema es construir una tabla de símbolos diferente
para cada procedimiento, con punteros para llegar a ellas y volver a la tabla anterior.

Jacqueline Köhler C. - USACH


100
Compiladores

Ejemplo 5.3:
Considere el fragmento de código con procedimientos anidados del listado 5.7 (se listan solo las
declaraciones y llamadas a procedimientos). En la tabla de símbolos dicho fragmento de código
(figura 5.11) se puede observar que cada tabla contiene al inicio un campo con un puntero al
lugar desde donde se hizo la llamada al procedimiento. Además, en las entradas
correspondientes a nombres de procedimientos, se tienen punteros que apuntan a la tabla de
símbolos correspondiente.

LISTADO 5.7: Programa en pseudo-Pascal para el ejemplo 5.3.

// Main
var nota;
calcular_promedio();

// calcular_promedio
var n_cat;
var n_lab;
calcular_n_cat();
calcular_n_lab();

// calcular n_cat
var n_peps;
var n_controles;
ponderar_peps();
promediar_controles();

// calcular n_lab
var i;
var j;

// ponderar_peps
var ponderacion;
var p1;
var p2;
var p3;
determinar_por();

// promediar_controles
var c1;
var c2;
var c3;
var c4;

// determinar_por
var aprueba;
var promedio;
var reprueba;

Jacqueline Köhler C. - USACH


101
Compiladores

FIGURA 5.11: Tabla de símbolos para el programa del listado 5.7.

5.7 ASIGNACIÓN DINÁMICA DE LA MEMORIA


5.7.1 INSTRUMENTOS DE LOS LENGUAJES

Algunos lenguajes proporcionan facilidades para la asignación dinámica de memoria para los datos,
para lo cual generalmente se utiliza un montículo. No obstante, la asignación puede ser de dos tipos:
 Explícita: se utilizan los métodos estándar para este fin. Por ejemplo, la ejecución de new(p) en
Pascal asigna memoria para el tipo de objeto señalado por p y p apunta al objeto recién asignado.
 Implícita: se produce cuando la evaluación de una expresión tiene como resultado la obtención de
memoria para guardar los valores de la expresión. Un ejemplo de esto se puede encontrar en
Snobol, que permite que la longitud de una cadena varíe durante la ejecución y administra el
espacio del montículo necesario para almacenarla.

Para la desasignación o liberación de memoria se tienen los mismos tipos:


 Explícita: en este caso, es el programador quien se preocupa de liberar la memoria que ya no se
necesita, por lo que el compilador no debe efectuar ninguna tarea al respecto.

Jacqueline Köhler C. - USACH


102
Compiladores

 Implícita: se debe determinar cuándo un bloque de memoria ya no es necesario.

Un concepto importante que se debe definir es el de basura. Corresponde a todas aquellas posiciones
de memoria asignadas que ya no pueden alcanzarse. Por ejemplo, considere una lista enlazada en C. Si
se asigna NULL a la cabeza de la lista, ya no es posible alcanzar ninguno de los nodos restantes aún
cuando éstos ya han sido asignados.

Algunos lenguajes, como Lisp y Java, realizan un proceso de recolección de basura y reclaman la
memoria inaccesible. Otros lenguajes, como C y Pascal, no tienen esta funcionalidad, por lo que el
programador debe liberar explícitamente la memoria que ya no se ocupará. Estos lenguajes permiten
reutilizar la memoria liberada, pero la basura se mantiene hasta el término de la ejecución del
programa.

Un concepto relacionado con el de basura es el de referencias suspendidas.


 Si se desasigna la memora antes de la última referencia a ella, se crea una referencia suspendida.
 Si se mantiene la memoria después de la última referencia, pasa a ser basura.

5.7.2 ASIGNACIÓN EXPLÍCITA DE BLOQUES DE TAMAÑO FIJO

Es la forma más sencilla de asignación explícita. La asignación y desasignación puede hacerse


rápidamente enlazando los bloques (de tamaño fijo) en una lista, con poca o nula pérdida de memoria.
Cada bloque tiene una porción que contiene un enlace al siguiente bloque. Cuando se lleva a cabo una
asignación, se elimina el bloque utilizado de la lista. Al liberarse la memoria, dicho bloque es
incorporado nuevamente a la lista (ver figura 5.12). El compilador se abstrae del tipo y tamaño del
dato.

FIGURA 5.12: Lista de bloques de tamaño fijo.


(a) El bloque 1 se encuentra asignado.
(b) Al ser liberado, el bloque 1 se añade una vez más a la lista.

5.7.3 ASIGNACIÓN EXPLÍCITA DE BLOQUES DE TAMAÑO VARIABLE

Al asignar y desasignar bloques puede producirse una fragmentación de la memoria, como se muestra
en la figura 5.13. Esta no causa problemas si se trabaja con bloques de tamaño fijo, pero si se tienen

Jacqueline Köhler C. - USACH


103
Compiladores

bloques de tamaño variable no es posible asignar una porción de memoria mayor que el más grande de
los bloques disponibles, aún cuando se cuente con el espacio necesario.

FIGURA 5.13: Bloques libres y ocupados en un montículo.

Existen diferentes métodos para asignar bloques de tamaño variable:


 Primer ajuste: se asigna el primer bloque de tamaño mayor o igual al requerido.
 Mejor ajuste: se asigna el menor bloque de tamaño mayor o igual al requerido.

Para las dos técnicas anteriores, si el bloque asignado es de mayor tamaño que el requerido se divide en
dos bloques de menor tamaño: uno del tamaño requerido, donde se hará la asignación; el otro, con el
espacio restante, que quedará libre para ser asignado posteriormente.

Una buena forma de evitar una fragmentación mayor que lo necesario es, cada vez que se desasigne un
bloque, comprobar si es adyacente a otro bloque libre. De ser así, se funden ambos bloques en uno solo.
Se debe considerar también que existe un compromiso entre tiempo, espacio y disponibilidad de
bloques.

5.7.4 DESASIGNACIÓN IMPLÍCITA

Para llevar a cabo esta tarea se requiere saber cuándo un bloque de memoria ha dejado de funcionar.
Esto puede lograrse fijando el formato de los bloques de memoria, como se muestra en la figura 5.14.

FIGURA 5.14: Formato de un bloque.

En primer lugar, se necesita reconocer las fronteras del bloque. Para bloques de tamaño fijo, se puede
determinar simplemente conociendo su posición (índice del bloque, como en la figura 5.12). Si se
trabaja con bloques de tamaño variable, en cambio se debe reservar un fragmento del bloque para
almacenar su tamaño y así poder determinar dónde comienza el bloque siguiente.

También es necesario saber si un bloque está en uso. Se dice que es así si el programa del usuario
puede hacer referencia a la información contenida en dicho bloque, ya sea mediante un puntero o una

Jacqueline Köhler C. - USACH


104
Compiladores

secuencia de ellos. Por ende, el compilador necesita conocer la posición donde se encuentra cada uno
de esos punteros. Estos pueden guardarse en una posición fija dentro del bloque. Se supone que el área
del bloque con información del usuario no contiene punteros.

Para llevar a cabo la desasignación implícita pueden emplearse dos enfoques diferentes, que se
describen en los puntos siguientes.

5.7.4.1 Cuenta de referencias

Se cuenta la cantidad de bloques que hacen referencia al bloque actual. Si la cuenta es igual a 0, este
último puede ser liberado.

5.7.4.2 Técnicas de marca

Esta técnica requiere que se conozcan todos los punteros del montículo. Consiste en detener por un
momento la ejecución del programa de usuario y hacer un seguimiento de todos los punteros para
determinar qué bloques pueden ser alcanzados y, en consecuencia, están en uso.

Una forma de implementarlo anterior es marcar inicialmente los bloques como no ocupados. A
continuación, se hace un seguimiento de cada uno de los punteros del montículo y los bloques
alcanzados se marcan como ocupados. Cuando ya no quedan punteros por revisar, se borran todos los
bloques marcados como no ocupados.

Al usar este enfoque con bloques de tamaño variable, es posible llevar a cabo una compactación de los
datos para eliminar la fragmentación de la memoria. Esta consiste en desplazar todos los bloques en
uso a un extremo del montículo, con la correspondiente actualización de todos los punteros.

5.8 EJERCICIOS
1. Dado el fragmento de código del listado 5.8:
a. Muestre su árbol de activación.
b. Muestre el estado de la pila de control cuando se efectúa la llamada a factorial(2).

2. Considere la porción de memoria que se muestra en la figura 5.15, donde los bloques más oscuros
corresponden a fragmentos utilizados:
a. Muestre el estado final de la memoria tras efectuar las siguientes asignaciones considerando los
métodos de primer ajuste y mejor ajuste.
 Un bloque de 6 bytes.
 Un bloque de 2 bytes.
 Un bloque de 4 bytes.
 Un bloque de 8 bytes.

Jacqueline Köhler C. - USACH


105
Compiladores

b. Muestre el estado final de la memoria (tras defragmentar por medio de compactación.


Considere para este fin el estado dado en la figura.

LISTADO 5.8: Fragmento de código para el ejercicio 1.

int fibonacci(int n) {
if(n == 0) {
return 0;
}
if(n == 1) {
return 1;
}
return fibonacci(n - 2) + fibonacci(n - 1);
}

int factorial(int n) {
if(n == 0) {
return 1;
}
return n * factorial(n - 1);
}

void main() {
unsigned long x = fibonacci(5);
unsigned long y = factorial(4);
}

FIGURA 5.15: Estado de un fragmento de memoria para el ejercicio 2.

Jacqueline Köhler C. - USACH


106
Compiladores

6 GENERACIÓN DE CÓDIGO INTERMEDIO


Desde la perspectiva del modelo estructurado de un compilador, una vez terminado el análisis
semántico se puede llevar a cabo la traducción del programa original a un nuevo lenguaje. En la
práctica, esta traducción puede ser efectuada también durante la etapa de análisis semántico.

Hasta ahora se había explicado que la traducción llevaba el programa fuente a un programa escrito en
el lenguaje objeto. Ahora bien, en muchas ocasiones es preferible traducir primero a un lenguaje
intermedio.

Considere, por ejemplo, la figura 6.1. En ella se puede observar la enorme cantidad de trabajo necesaria
para traducir un programa escrito en cada uno de los lenguajes de alto nivel a cada uno de los lenguajes
de máquina. En el caso del lado izquierdo se puede ver que, para cada lenguaje fuente, se debe
construir un compilador diferente por cada lenguaje objeto. El lado derecho, en cambio, muestra la gran
ventaja de la redestinación: se puede crear un compilador para una máquina distinta uniendo una fase
de análisis que genere código intermedio a una fase de síntesis que genere código objeto para la
máquina destino.

FIGURA 6.1: Una de las ventajas de usar un lenguaje intermedio es la redestinación.


(a) No se usa lenguaje intermedio.
(b) Se usa lenguaje intermedio.

Una segunda ventaja que otorga el uso de un lenguaje intermedio es que se puede aplicar un
optimizador de código independiente de la máquina en que va a ser usado el programa.

6.1 LENGUAJES INTERMEDIOS


6.1.1 REPRESENTACIONES GRÁFICAS

La primera de las representaciones gráficas para un lenguaje intermedio es el ya conocido árbol


sintáctico. Esta representación muestra la jerarquía natural del lenguaje. Similar en gran medida al
árbol sintáctico, una representación para los lenguajes intermedios son los grafos dirigidos acíclicos
(GDA). La diferencia entre éstos y los árbol sintáctico radica en que los GDA proporcionan una
representación más compacta, pues identifican las subexpresiones comunes.

Jacqueline Köhler C. - USACH


107
Compiladores

La figura 6.2 muestra ambas representaciones gráficas para la expresión    ) .   ).

FIGURA 6.2: Representaciones gráficas para la expresión    ) .   ).


(a) AST.
(b) GDA.

Los AST para las proposiciones de asignación de un lenguaje se pueden producir mediante las
definiciones dirigidas por la sintaxis de la tabla 6.1. Esta misma definición se puede usar para construir
los GDA, siempre y cuando las funciones )*{_Š'Š y )*{_Š'Š¥={;Š retornen un puntero a un
nodo ya existente siempre que sea posible.

TABLA 6.1: Definición dirigida por la sintaxis para construir AST para proposiciones de asignación.

Una tercera representación gráfica es la representación postfija, que es una representación lineal del
árbol sintáctico.

6.1.2 CÓDIGO DE TRES DIRECCIONES

El código de tres direcciones es una representación linealizada de un árbol sintáctico o de un GDA, y


tiene la forma de una secuencia de proposiciones de la forma general /: 0 –¦ , donde:
 /, 0 y  son nombres constantes o variables temporales.
 –¦ representa cualquier operador (aritmético, lógico).

Jacqueline Köhler C. - USACH


108
Compiladores

Nótese que no se permiten operaciones aritméticas compuestas, pues solo se puede tener un operador al
lado derecho de una proposición. En consecuencia, una expresión de la forma / . 0   se puede
traducir en una secuencia:
 : 0  
 : / . 

Donde  y  son variables temporales generadas por el compilador. Al hacer esto se debe tener
cuidado de respetar la precedencia de los operadores.

El listado 6.1 muestra el código de tres direcciones que se obtiene para el AST y el GDA
correspondientes a la expresión    ) .   ).

LISTADO 6.1: Código de tres direcciones.


(a) Para el AST de la figura 6.2.
(b) Para el GDA de la figura 6.2.

El código de tres direcciones recibe ese nombre porque en cada proposición suele contener tres
direcciones de memoria: una por cada operando y otra para el resultado.

6.1.2.1 Tipos de proposiciones de tres direcciones

Las proposiciones de tres direcciones son análogas al código ensamblador. Pueden tener etiquetas
simbólicas, y existen también proposiciones para el flujo de control. Las proposiciones de tres
direcciones comunes son:
1. Proposiciones de asignación de la forma /: 0 –¦ , donde –¦ es una operación lógica o
aritmética.

2. Instrucciones de asignación de la forma /: –¦ 0, donde –¦ es una operación unaria.

3. Proposiciones de copia de la forma /: 0, donde se asigna a / el valor de 0.

4. El salto incondicional “–•– ", donde " es la etiqueta de la proposición de tres direcciones que
debe ejecutarse a continuación.

5. Los saltos condicionales como ”§ / –¦™šœ 0 “–•– ".

6. ¦›™›  / y ¨›œœ ‹, = para llamadas a procedimientos y ™š•©™— 0, donde:

Jacqueline Köhler C. - USACH


109
Compiladores

 / es un parámetro.
 ‹ corresponde a un procedimiento o función.
 = es la cantidad de parámetros del procedimiento.
 0 representa el valor devuelto (es opcional).

Por ejemplo, la llamada al procedimiento %/ , / , … , /7  queda:


¦›™›  /
¦›™›  /

¦›™›  /7
¨›œœ ‹, =

7. Las asignaciones con índices de la forma /: 0k;l y /k;l: 0. La primera asigna a / el valor de la
posición en ; unidades de memoria más allá de la posición 0. La segunda asigna el contenido de la
posición en ; unidades de memoria más allá de la posición / al valor de 0.

8. Las asignaciones de direcciones y punteros de la forma /: &0, /:  0 y  /: 0. La primera de


estas proposiciones asigna a / la dirección de 0. La segunda asigna a / el valor contenido en la
dirección apuntada por 0. La tercera hace que el valor del objeto apuntado por / tenga igual valor
que 0.

6.1.2.2 Implementaciones de código de tres direcciones

Una proposición de 3 direcciones es una forma abstracta de código intermedio. En un compilador, estas
proposiciones pueden implementarse como registros con campos para el operador y los operandos. A
continuación se muestran tres implementaciones diferentes.

6.1.2.3.1 Cuádruplos

Corresponden a estructuras de tipo registro con cuatro campos: Š‹, que contiene un código interno para
el operador, {£ y {£ contienen los operandos y {*zyx'Š, como su nombre lo indica, contiene el
resultado de la operación. Por supuesto, solo se usan todos los campos en el paso de los operadores
binarios, por lo que se debe aclarar qué ocurre con los demás tipos de instrucciones:
 Operadores unarios: no se utiliza {£.
 param y similares: no se utilizan {£ ni {*zyx'Š.
 Saltos condicionales e incondicionales: se pone la etiqueta objeto en {*zyx'Š.

En general, los contenidos de los campos {£, {£ y {*zyx'Š son punteros a las entradas
correspondientes de la tabla de símbolos. Adicionalmente, se debe señalar que los nombres temporales
deben introducirse en la tabla de símbolos conforme van siendo creados.

La tabla 6.2 muestra la representación mediante cuádruplos del código de tres direcciones para la
expresión :   ) .   ).

Jacqueline Köhler C. - USACH


110
Compiladores

TABLA 6.2: Código de tres direcciones para :   ) .   ) representado mediante cuádruplos.

6.1.2.3.2 Triples

Esta representación tiene la ventaja de no introducir nombres temporales a la tabla de símbolos. Para
esto, los valores temporales son referenciados de acuerdo a la posición de la proposición en que son
calculados. Como consecuencia, las proposiciones de tres direcciones se pueden representar mediante
registros con solo tres campos: Š‹, {£ y {£. Los campos {£ y {£ contienen punteros a la
tabla de símbolos para las variables y constantes definidas por el programador o bien punteros dentro
de la misma estructura de triples para hacer referencia a los valores temporales.

La tabla 6.3 muestra la representación mediante triples del código de tres direcciones para la expresión
:   ) .   ).

TABLA 6.3: Código de tres direcciones para :   ) .   ) representado mediante triples.

En la práctica, la información necesaria para interpretar las distintas clases de entrada en los campos
{£ y {£ se puede codificar dentro del campo Š‹ o en campos adicionales.

Obsérvese que las operaciones ternarias, como /k;l: 0 requieren dos entradas en la estructura de
triples, como se puede ver en la tabla 6.4 (a). La figura 6.4 (b) muestra que la operación /  0k;l se
representa mediante dos entradas en forma natural.

6.1.2.3.3 Triples indirectos

En este caso, en lugar de hacer una lista de los triples mismos, se construye una lista de punteros a
triples. Así, por ejemplo, se puede usar una matriz proposición para listar los punteros a triples en el
orden que se desee. La tabla 6.5 muestra la representación mediante triples indirectos de los triples de
la tabla 6.3.

Jacqueline Köhler C. - USACH


111
Compiladores

TABLA 6.4: Otras representaciones por medio de triples.


(a) /k;l: 0.
(b) /  0k;l.

Si bien la utilización de triples indirectas requiere más espacio de almacenamiento que el uso de triples,
tiene la ventaja de que la reubicación de código se vuelve mucho más sencilla. Esto resulta útil, por
ejemplo, para la optimización.

TABLA 6.5: Código de tres direcciones para :   ) .   ) representado mediante triples


indirectos.

6.2 TRADUCCIÓN DIRIGIDA POR LA SINTAXIS


Conceptualmente, el proceso de traducción comienza cuando se ha determinado que el programa fuente
no contiene errores, sean éstos léxicos, sintácticos o semánticos. No obstante, esta tarea se lleva a cabo
simultáneamente con el análisis semántico y, en consecuencia, en interacción con el proceso de análisis
sintáctico.
Este proceso no se ocupa solamente de generar una secuencia de código, sino que también debe tener
en consideración algunos aspectos de la asignación de memoria y de la construcción de la tabla de
símbolos.

6.2.1 DECLARACIONES

Conforme se examina la secuencia de declaraciones dentro de un procedimiento (o función), un


registro o un bloque, se puede distribuir la memoria para los nombres locales. Para cada uno de ellos se
crea una entrada en la tabla de símbolos con información, por ejemplo, referente al tipo y la dirección
relativa de la memoria que le corresponde. La dirección relativa consiste en un desplazamiento desde la
base del área de datos estática o del campo para los datos locales en un registro de activación.

Jacqueline Köhler C. - USACH


112
Compiladores

6.2.1.1 Declaraciones dentro de un procedimiento

La sintaxis de lenguajes como C y Pascal permite que todas las declaraciones en un mismo
procedimiento se procesen como un grupo. Para este fin se utiliza una variable global, por ejemplo
'*z‹x¤;*=Š, que indica la siguiente dirección relativa de memoria disponible.

La tabla 6.6 muestra una gramática que permite efectuar declaraciones junto a un esquema de
traducción. La variable '*z‹x¤;*=Š es inicializada en 0 antes de considerar la primera
declaración. Los nuevos nombres son incorporados a la tabla de símbolos a medida que van
apareciendo, en una posición relativa igual a '*z‹x¤;*=Š. Al incorporar un nuevo símbolo, es
necesario aumentar '*z‹x¤;*=Š en el ancho (usualmente en bytes) del objeto de datos indicado
por el nombre.

El esquema de traducción de la tabla 6.6 hace uso de las siguientes funciones:


 ;={Š'y);{=Š¤{*, ;‹Š, '*z‹x¤;*=Š: crea una nueva entrada en la tabla de símbolos para
=Š¤{* y le asocia su tipo de dato (;‹Š) y su posición relativa en la memoria ('*z‹x¤;*=Š).
En el caso de arreglos y punteros, ;‹Š corresponde a una expresión de tipos construida a partir de
los tipos básicos, enteros y reales en este caso.
 {{0x, ;‹Š: expresión de tipo que indica que indica que la variable a la que se asocia esta
expresión es un arreglo de tamaño x cuyos elementos son de tipo ;‹Š.
 ‹Š;=*{;‹Š: expresión de tipo que indica que indica que la variable a la que se asocia esta
expresión es un puntero a un objeto de tipo ;‹Š.

TABLA 6.6: Cálculo de tipos y direcciones relativas de nombres declarados.

Jacqueline Köhler C. - USACH


113
Compiladores

En un lenguaje con procedimientos anidados, se pueden asignar direcciones relativas a los nombres
locales a cada procedimiento. Se puede suponer que existe una tabla de símbolos diferente para cada
procedimiento, mecanismo que puede ser implementado, por ejemplo, mediante una lista enlazada.

La figura 6.3 muestra el esquema de procedimientos anidados, mientras que la tabla 6.7 muestra un
esquema de traducción (que se agrega al de la tabla 6.6) que permite crear las diferentes tablas de
símbolos y sus punteros. El esquema hace uso de las siguientes funciones:
 ){*{jx‹{*;: crea una nueva tabla de símbolos y retorna un puntero a la nueva tabla. El
argumento ‹{*; corresponde a un puntero a la tabla de símbolos del procedimiento que abarca al
nuevo procedimiento declarado. En la figura 6.3, ‹{*; podría ser, por ejemplo, el puntero desde
‹{;);ó= hacia *=)*'Š.
 ;={Š'y);{x, =Š¤{*, ;‹Š, '*z‹x¤;*=Š: crea en la tabla de símbolos apuntada por
x una nueva entrada para =Š¤{*, con su tipo ;‹Š y su posición relativa '*z‹x¤;*=Š.
 ñ';{=)Šx, =)Š: registra el ancho acumulado de todas las entradas de tabla en el
encabezamiento asociado a dicha tabla de símbolos.
 ;={Š'y);{%{Š)x, =Š¤{*, x_y*: crea en la tabla de símbolos apuntada por x
una nueva entrada para el procedimiento =Š¤{*. El puntero x_y* apunta a la tabla de
símbolos del procedimiento nombre.

FIGURA 6.3: Tablas de símbolos para procedimientos anidados.

También es interesante señalar que:


 x%{ es una pila que almacena punteros a procedimientos exteriores.

Jacqueline Köhler C. - USACH


114
Compiladores

 '*z‹x¤;*=Š es una pila que almacena la siguiente posición relativa disponible para un
nombre local del procedimiento en curso. Convertir '*z‹x¤;*=Š en una pila es la extensión
natural para ajustar el esquema de la tabla 6.6 para procedimientos anidados.

TABLA 6.7: Proceso de declaraciones en procedimientos anidados.

6.2.1.2 Nombres de campos dentro de registros

Otro tipo habitual de declaraciones son los registros o estructuras. Para poder tenerlo en cuenta, es
necesario incorporar al esquema de traducción de la tabla 6.7 la producción j 1 ™š¨–™‘ D! š—‘, que
permite generar registros. La tabla 6.8 muestra cómo se construye la tabla de símbolos para los
nombres de los campos de un registro.

6.2.3 PROPOSICIONES DE ASIGNACIÓN

Para esta sección se asume que las expresiones pueden contener elementos de tipo entero, real, matriz,
registro y puntero. Como parte de la generación de código de tres direcciones, se explica además cómo
buscar los nombres en la tabla de símbolos y acceder a los elementos de matrices y registros.

Jacqueline Köhler C. - USACH


115
Compiladores

6.2.3.1 Expresiones aritméticas

Al generar código de tres direcciones se crean nombres temporales para los nodos interiores del árbol
sintáctico. El valor del no terminal " al lado izquierdo de la producción " 1 " . " se calcula en un
temporal . Se creará un nuevo temporal cada vez que sea necesario.

En el caso de que una expresión contenga solamente un identificador, por ejemplo 0, entonces 0
contiene el valor de la expresión.

TABLA 6.8: Creación de la tabla de símbolos para los nombres de los campos de un registro.

La tabla 6.9 muestra las reglas semánticas que permiten crear código de tres direcciones para
proposiciones de asignación. Si se toma la entrada    ) .   ), el código resultante es el del
listado 6.1 (a).

Volviendo a la tabla 6.9 se tiene que:


 El atributo sintetizado C. )Š';£Š representa el código de tres direcciones para la asignación de C.
 ". xy£{ corresponde al nombre que contendrá el valor de ".
 ". )Š';£Š es la secuencia de proposiciones de tres direcciones que evalúan ".
 La función *¤‹_y*Š retorna el nombre para un nuevo temporal.
 La notación £*=/ ‘: ’ 0 ‘ . ’  representa la proposición de tres direcciones /: 0 . .

Hasta ahora se habían formado las proposiciones de tres direcciones utilizando los nombres de las
variables considerados como punteros hacia sus entradas en la tabla de símbolos. El esquema de
traducción de la tabla 6.10 muestra cómo se puede efectuar la búsqueda de las entradas en la tabla de
símbolos. En dicha tabla:
 El atributo ”‘. =Š¤{* corresponde al lexema del nombre.
 La operación yz){=Š¤{* verifica la existencia de una entrada en la tabla de símbolos para
=Š¤{*. En caso afirmativo retorna un puntero a la entrada, mientras que en caso contrario retorna
un puntero nulo.
 La función *¤;;{)'*= escribe las proposiciones de tres direcciones generadas (representadas
por )'*=) a un archivo de salida. Esto elimina la necesidad del atributo )Š';£Š de la tabla 6.9.

Se había señalado que la función *¤‹_y*Š crea un nuevo temporal cada vez que sea necesario. Esto
resulta especialmente útil en compiladores optimizadores. No obstante, los temporales que almacenan
valores intermedios requieren espacio de almacenamiento y tienden a obstruir la tabla de símbolos.

Jacqueline Köhler C. - USACH


116
Compiladores

Esta función puede ser modificada para que utilice una pequeña matriz en un área de datos del
procedimiento como si fuera una pila para guardar los temporales. Como la mayoría de los temporales
solo se ocupan una vez, se puede crear un contador ), inicializado en 0, que se decrementa en 1 cada
vez que se utilice como operando un nombre temporal. Cuando se genera un temporal nuevo, se utiliza
la posición $) y se incrementa ) en 1. A aquellos poco frecuentes temporales que se usan más de una
vez se les puede asignar un nombre propio.

TABLA 6.9: Definición dirigida por la sintaxis que permite producir código de tres direcciones para
las asignaciones.

Ejemplo 6.1:
Genere código intermedio para la expresión / 15  43 .   3  /2 . 5.

El código intermedio para la expresión dada se muestra en el listado 6.2.

LISTADO 6.2: Código de tres direcciones para la expresión / 15  43 .   3 


/2 . 5.

  ¤*=Šzy 43
   . 
  15  
  /2

 3  
    

/   . 5

Jacqueline Köhler C. - USACH


117
Compiladores

6.2.3.2 Expresiones booleanas

Las expresiones booleanas tienen dos grandes propósitos en los lenguajes de programación. Se utilizan
para calcular valores lógicos y como expresiones condicionales para alterar el flujo de control.

Las expresiones booleanas pueden ser generadas por una gramática con las siguientes producciones:
" 1 " –™ " | " ›—‘ " | —–• " | " | ”‘ –¦™šœ ”‘ | •™©š | §›œ¯š, donde se considera un atributo Š‹
para determinar el operador de comparación representado por –¦™šœ. Se asume que tanto ›—‘ como
–™ asocian por la izquierda, y que la precedencia de operadores (de mayor a menor) es —–•, ›—‘ y –™.

Es habitual usar una representación numérica para los valores booleanos. En este caso se considera el
valor 1 como •™©š y el valor 0 como §›œ¯š. La evaluación de las expresiones se efectúa de izquierda
a derecha y siguiendo la precedencia de operadores, de manera similar a las expresiones aritméticas.

TABLA 6.10: Esquema de traducción para generar código de tres direcciones para asignaciones.

Ejemplo 6.2:
Genere código intermedio para la expresión /  –™ —–•  ›—‘ —–• ) –™ '.

El código de tres direcciones para la expresión dada se muestra en el listado 6.3.

Jacqueline Köhler C. - USACH


118
Compiladores

LISTADO 6.3: Código intermedio para la expresión /  –™ —–•  ›—‘ —–• ) –™ '.

  —–• 
  ) –™ '
  —–• 
   ›—‘ 
/   –™ 

Una expresión relacional como a<b es equivalente a la proposición condicional


”§  o  •°š— 1 šœ¯š 0, que se puede traducir al siguiente código de tres direcciones (los números del
lado izquierdo son etiquetas para las diferentes instrucciones):

100: ”§  o  “–•– 103


101:   0
102: “–•– 104
103:   1

La tabla 6.11 muestra un esquema de traducción a código de tres direcciones que usa una
representación numérica para los valores booleanos y utiliza el esquema condicional para evaluar
operaciones relacionales.

TABLA 6.11: Esquema de traducción para expresiones booleanas.

Jacqueline Köhler C. - USACH


119
Compiladores

En el esquema de la tabla 6.11 se tiene, una vez más, que *¤;;{ escribe proposiciones de código de
tres direcciones a un archivo de salida. Además, z£* da el índice de la siguiente proposición de tres
direcciones en la secuencia de salida (*¤;;{ debe incrementar z£* después de generar una
proposición de tres direcciones).

Ejemplo 6.3:
La expresión —–•  o  –™ ) p  ›—‘ ! ) se traduce a código de tres direcciones,
usando el esquema de la tabla 6.11, como muestra el listado 6.4.

LISTADO 6.4: Código intermedio para la expresión —–•  o  –™ ) p  ›—‘ ! ).

100: ”§  o  “–•– 103 108:    –™ 


101:   0 109: 
 —–• 
102: “–•– 104 110: ”§ ! ) “–•– 113
103:   1 111:   0
104: ”§ ) p  “–•– 107 112: “–•– 114
105:   0 113:   1
106: “–•– 108 114:   
›—‘ 
107:   1

6.2.3.3 Acceso a elementos de matrices

Se puede acceder rápidamente a los elementos de una matriz si éstos se guardan en un bloque de
posiciones consecutivas. Si el ancho de cada elemento de la matriz es , entonces el ;-ésimo elemento
de la matriz  comienza en la posición z* . ;  ;=+ ˜ , donde ;=+ es el límite inferior de los
subíndices y z* es la dirección relativa de la posición de memoria asignada a la matriz.

En el caso de una matriz bidimensional almacenada por filas, la dirección relativa de k; lk; l se puede
calcular como z* . h;  ;=+  ˜ = . ;  ;=+ i ˜ , donde ;=+ e ;=+ son los límites inferiores
para los valores de ; e ; y = corresponde a la cantidad de valores que puede tomar ; (cantidad de
columnas de la matriz).

El principal problema de generar código para referencias de matrices es relacionar los cálculos con una
gramática para referencias de matrices. La tabla 6.12 muestra una de estas gramáticas con sus acciones
semánticas, donde:
 D. '*z‹x¤;*=Š —©œœ significa que D corresponde a un ”‘ simple.
 Para las expresiones aritméticas se usa el esquema de la tabla 6.10.
 Cuando se hace la reducción " 1 D, siendo D un arreglo, se usa la indización para obtener el
contenido de D. xy£{kD. '*z‹x¤;*=Šl.
 La función =)Šx;z". ¤{; devuelve el tamaño de los elementos de la matriz, mientras que
)x;z". ¤{; entrega la base de la matriz.

Jacqueline Köhler C. - USACH


120
Compiladores

TABLA 6.12: Esquema de traducción para matrices.

Ejemplo 6.4:
Sea  una matriz de 10 ˜ 20 almacenada a partir de la posición 1000 de la memoria. Además,
sean ;=+ ;=+ 1 y  4. El código de tres direcciones para la asignación /  k0lkl se
muestra en el listado 6.5.

Jacqueline Köhler C. - USACH


121
Compiladores

LISTADO 6.5: Código intermedio para la expresión /  k0lkl.

0:   1000 // base
1:   0  1 // y - ;=+
2:     20 // ;  ;=+  ˜ =
3:    .  // ;  ;=+  ˜ = . ;
4: 
   1 // ;  ;=+  ˜ = . ;  ;=+
5:   
 4 // h;  ;=+  ˜ = . ;  ;=+ i ˜ 
6: /   k
l

6.2.3.4 Acceso a elementos de registros

Se usan los esquemas anteriores, considerando los nombres de los campos como entradas de la tabla de
símbolos pertinente.

6.2.4 SENTENCIAS DE FLUJO DE CONTROL

En general, los programas están no solo por expresiones y asignaciones, sino que tienen también
proposiciones de flujo de control. Estas últimas también deben ser traducidas a código intermedio. En
consecuencia, se deben incorporar las reglas semánticas necesarias a la definición dirigida por la
sintaxis construida hasta ahora. El ejercicio de diseñar las reglas semánticas queda para los alumnos,
por lo que en esta sección se muestra cómo debe quedar el código generado para diferentes sentencias
de esta clase.

6.2.4.1 Sentencia goto

Se copia casi textualmente, realizando el salto al comienzo de la sentencia fuente etiquetada.

6.2.4.2 Sentencia if

Esta sentencia, en primer lugar, debe verificar el cumplimiento de una condición. Si es verdadera, se
ejecutan las sentencias anidadas. En caso contrario, se salta a la primera sentencia fuera del bloque Ӥ.
En el ejemplo 6.5, las sentencias etiquetadas de 0 a 3 evalúan la condición. La sentencia 4 determina si
la condición es verdadera, y en caso de ser así salta al código contenido en el bloque (sentencia 6),
continuando luego en forma secuencial con el código posterior (sentencia 7). En caso contrario, salta a
la primera instrucción posterior al bloque ”§.

Ejemplo 6.5:
Considere el fragmento de código en C del listado 6.6 y tradúzcalo a código de tres direcciones.

Jacqueline Köhler C. - USACH


122
Compiladores

LISTADO 6.6: Un fragmento de código en C.

if(x!=0) {
y=3;
}

z=x*y;

El listado 6.7 muestra el código intermedio resultante.

LISTADO 6.7: Código de tres direcciones para una sentencia ”§.

0: ”§ /! 0 “–•– 3 4: ”§  “–•– 6
1:   0 5: “–•– 7
2: “–•– 4 6: 0  3
3:   1 7:   /  0

6.2.4.3 Sentencia if-else

Similar a la sentencia if, ”§  šœ¯š también comienza por verificar el cumplimiento de una condición.
Si es verdadera, se ejecutan las sentencias anidadas dentro del bloque Ӥ para luego saltar a la primera
instrucción fuera del bloque šœ¯š. En caso contrario, se salta a la primera sentencia del bloque šœ¯š
para luego continuar secuencialmente con el código posterior.

Ejemplo 6.6:
Considere el fragmento de código en C del listado 6.8 y tradúzcalo a código de tres direcciones.

LISTADO 6.8: Un fragmento de código en C.

if(x!=0) {
y=3;
}
else{
y=5;
}

z=x*y;

El listado 6.9 muestra el código intermedio resultante.

LISTADO 6.9: Código de tres direcciones para una sentencia ”§  šœ¯š.

0: ”§ /! 0 “–•– 3 5: “–•– 8
1:   0 6: 0  3
2: “–•– 4 7: “–•– 9
3:   1 8: 0  5
4: ”§  “–•– 6 9:   /  0

Jacqueline Köhler C. - USACH


123
Compiladores

6.2.4.4 Sentencia switch

Existen diversas maneras de implementar el código de tres direcciones para esta sentencia, y algunas de
ellas dependen de las especificaciones del lenguaje fuente. Un mecanismo sencillo, no obstante, puede
ser el de evaluar la expresión de prueba e ir evaluando el resultado: si es distinto del caso comprobado,
saltar al siguiente, hasta llegar al valor buscado, ejecutando entonces su código asociado y saltando
luego fuera del bloque ¯²”•¨°, o al final del bloque ¯²”•¨°. Si existe un valor por defecto, el código
asociado a él se ejecuta siempre que no se haya encontrado un valor coincidente.

Ejemplo 6.7:
Considere el fragmento de código en C del listado 6.10 y tradúzcalo a código de tres
direcciones.

LISTADO 6.10: Un fragmento de código en C.

c=2*y-1;

switch(c) {
case 1:
z=5;
case 3:
z=36;
case 5:
z=8;
default:
z=0;
}

x=y+z;

El listado 6.11 muestra el código intermedio resultante.

LISTADO 6.11: Código de tres direcciones para una sentencia ¯²”•¨°.

0:   2  0 7: “–•– 12
1: )    1 8: ”§ )! 5 “–•– 11
2: ”§ )! 1 “–•– 5 9:   8
3:   5 10: “–•– 12
4: “–•– 12 11:   0
5: ”§ )! 3 “–•– 8 12:   /  0
6:   36

En el caso real del lenguaje C, la traducción anterior es incorrecta. En C, se ejecuta el caso válido y
todos los casos siguientes a menos que se encuentre un salto al exterior del bloque switch, señalado por
la palabra reservada ³™š›´.

Ejemplo 6.8:
Considere el fragmento de código en C del listado 6.12 y tradúzcalo a código de tres
direcciones.

Jacqueline Köhler C. - USACH


124
Compiladores

LISTADO 6.12: Un fragmento de código en C.

Z=0;
c=2*y-1;

switch(c) {
case 1:
z=5;
case 3:
z=z+2;
break;
case 5:
z=8;
}

x=y+z;

El listado 6.13 muestra el código intermedio resultante.

LISTADO 6.13: Código de tres direcciones para una sentencia ¯²”•¨° según el
funcionamiento de esta sentencia en C.

0:   0 7:    . 1
1:   2  0 8: “–•– 12 
2: )    1 9: ”§ )! 5 “–•– 12
3: ”§ )! 1 “–•– 6 10:   8
4:   5 11: “–•– 12
5: “–•– 7 12: 13:   /  0
6: ”§ )! 3 “–•– 8

6.2.4.5 Sentencia while

En este caso, se evalúa una condición. Si ésta se cumple, se ejecutan las sentencias anidadas y se
vuelve a evaluar la condición. Las repeticiones continúan mientras la condición se siga cumpliendo,
para finalmente saltar fuera del bloque ²°”œš. Si dentro del bloque aparece la palabra reservada
³™š›´, se traduce como un salto fuera del ²°”œš.

Jacqueline Köhler C. - USACH


125
Compiladores

Ejemplo 6.9:
Considere el fragmento de código en C del listado 6.14 y tradúzcalo a código de tres
direcciones.

LISTADO 6.14: Un fragmento de código en C.

x=0;
i=0;

while(i<10){
x=x*i;
i++;
}
El listado 6.15 muestra el código intermedio resultante.

LISTADO 6.15: Código de tres direcciones para una sentencia ²°”œš.

0: /  0 4: /  /  ;
1: ;  0 5: ;  ; . 1
2: ”§ ; o 10 “–•– 4 6: “–•– 2
3: “–•– 7

6.2.4.6 Sentencia do-while

Muy similar a la sentencia anterior, solo difiere en que la condición se evalúa al final, por lo que el
código anidado se ejecuta a lo menos una vez. La palabra reservada ³™š›´ actúa igual que en la
sentencia anterior.

Ejemplo 6.10:
Considere el fragmento de código en C del listado 6.16 y tradúzcalo a código de tres
direcciones.

LISTADO 6.16: Un fragmento de código en C.

x=0;
i=0;

do{
x=x*i;
i++;
} while(i<10);

El listado 6.16 muestra el código intermedio resultante.

Jacqueline Köhler C. - USACH


126
Compiladores

LISTADO 6.16: Código de tres direcciones para una sentencia ‘–  ²°”œš.

0: /  0 3: ;  ; . 1
1: ;  0 4: ”§ ; o 10 “–•– 2
2: /  /  ; 5: “–•– 6

6.2.4.7 Sentencia repeat-until

Análoga a ‘–  ²°”œš, la sentencia ™š¦š›•  ©—•”œ solo difiere de la anterior en que el bloque
anidado se repite mientras la condición continúe siendo falsa. Este mecanismo existe en lenguajes
como Pascal. La palabra reservada ³™š›´ actúa igual que en los demás bucles.

Ejemplo 6.11:
Considere el fragmento de código en pseudo-C del listado 6.18 y tradúzcalo a código de tres
direcciones.
LISTADO 6.18: Un fragmento de código en pseudo-C.

x=0;
i=0;

repeat{
x=x*i;
i++;
} until(i==10);

El listado 6.18 muestra el código intermedio resultante.

LISTADO 6.18: Código de tres direcciones para una sentencia ™š¦š›•  ©—•”œ.

0: /  0 3: ;  ; . 1
1: ;  0 4: ”§ ; 10 “–•– 6
2: /  /  ; 5: “–•– 2

6.2.4.8 Sentencia for

La sentencia §–™ es diferente en su funcionamiento a los bucles anteriores. En primer lugar se


inicializa el contador con el valor especificado, para luego evaluar la condición. Además, al término de
las instrucciones correspondientes al bloque anidado del §–™, es necesario incorporar las instrucciones
necesarias para incrementar el contador antes de volver a evaluar la condición. La palabra reservada
³™š›´ actúa igual que en los demás bucles.

Ejemplo 6.12:
Considere el fragmento de código en C del listado 6.20 y tradúzcalo a código de tres
direcciones.

Jacqueline Köhler C. - USACH


127
Compiladores

LISTADO 6.20: Un fragmento de código en C.

x=0;

for(i=0; i<10; i++){


x=x+x*i;
}

El listado 6.21 muestra el código intermedio resultante.

LISTADO 6.21: Código de tres direcciones para una sentencia §–™.

0: /  0 4:   /  ;
1: ;  0 5: /  / . 
2: ”§ ; o 10 “–•– 4 6: ;  ; . 1
3: “–•– 8 7: “–•– 2

6.2.5 LLAMADAS A PROCEDIMIENTOS

Un procedimiento es una construcción de programación tan importante y utilizada tan a menudo que es
fundamental que un compilador genere buen código para llamarlo.

Considere, por ejemplo, una gramática sencilla para llamar procedimientos que contenga las siguientes
producciones: C 1 ¨›œœ ”‘ D;z", x;z" 1 x;z", " | ". Una posible implementación puede ser
mediante una cola de argumentos (parámetros), pues no siempre se tiene la misma cantidad de
argumentos. La tabla 6.13 muestra un posible esquema de traducción.

TABLA 6.13: Esquema de traducción para una llamada sencilla a procedimientos.

6.3 EJERCICIOS

1. Sea la expresión aritmética    . ). Tradúzcala a:


a. Un AST.
b. Código de tres direcciones.

Jacqueline Köhler C. - USACH


128
Compiladores

2. Considere la asignación /  .   ) . ' . h/  )i. Tradúzcala a:


a. Un AST.
b. Código de tres direcciones.
c. Cuádruplos.
d. Triples.
e. Triples indirectos.

3. Proponga reglas semánticas para traducir estructuras y punteros de C.

4. Determine las reglas semánticas para traducir las sentencias de flojo de control.

5. Genere código de tres direcciones para el fragmento de código del listado 6.22.

LISTADO 6.22: Un fragmento de código en C.

y=25;
z=26;
x=(5+4*z)-(y/2+x*y-z);

switch(c) {
case 0:
y=56*x/2;
break;
case 1:
do{
x=z*(x-4);
} while(y>200 && (3-5*x!=z);
case 1:
z=0;
default:
x=z*z-4*z+9;
}

for(i=0; i<10; i++) {


N[2*i]=(x+y)*M[i];
N[2*i+1]=x/3+y*M[i];
}

if(!(x>y) || y!=z && z>=x-2*y) {


repeat {
z=(4+(x*y+2*z)-(7-x/98))+y/5;
if(z<786) {
y=y-1;
break;
}
x=x+1;
} until(x==23);
}
else {
x=0;
}

y=x+z*2-y;

Jacqueline Köhler C. - USACH


129
Compiladores

BIBLIOGRAFÍA
1. AHO, A. V.; LAM, M. S.; SETHI, R.; ULLMAN, J. D. (2008) Compiladores. Principios, técnicas
y herramientas (2ª ed.), Pearson Educación, México. ISBN 978-970-26-1133-2.

2. ÁLVAREZ, J. (2005). Apuntes de la Asignatura Programación de Lenguajes Formales,


Departamento de Ingeniería Informática, Facultad de Ingeniería, Universidad de Santiago de Chile.

3. CAMPOS, A. E. (1995). Teoría de Autómatas y Lenguajes Formales, Departamento de Ciencia de


la Computación, Escuela de Ingeniería, Pontificia Universidad Católica de Chile.

4. HOPCROFT, J. E.; ULLMAN, J. D. (1993). INTRODUCCIÓN A LA TEORÍA DE AUTÓMATAS,


LENGUAJES Y COMPUTACIÓN, Compañía Editorial Continental S. A., México. ISBN 968-26-
1222-5.

5. KOLMAN, B.; BUSBY, R. C. (1986). Estructuras de Matemáticas Discretas para la


Computación, Prentice-Hall Hispanoamericana S. A., México. ISBN 968-880-080-5.

Jacqueline Köhler C. - USACH


130
Compiladores

ANEXO: NOCIONES DE LENGUAJES FORMALES


Este apéndice no pretende ser más que una breve introducción a aquellos aspectos de la teoría de
autómatas y lenguajes formales que resultan indispensables para la construcción de compiladores. Para
estudiar estos contenidos con mayor profundidad, una buena referencia es el libro de Hopcroft, J. E. y
Ullman, J. D.: Introducción a la teoría de autómatas, lenguajes y computación.

A.1 JERARQUÍA DE LOS LENGUAJES


Un lenguaje es un conjunto, normalmente infinito, de palabras. En consecuencia, no es posible
procesarlo directamente mediante un algoritmo, sino que hay que hacerlo indirectamente mediante una
representación o descripción finita (por comprensión en vez de por extensión). De este modo, podemos
tener un algoritmo para el cual podemos afirmar que termina.

No obstante, el problema de la representación de un lenguaje es, a su vez, el problema de su


generación. Así, el algoritmo representativo debe generar todas las palabras del lenguaje y ninguna que
no pertenezca a éste. Para este fin existen dos modelos finitos:
 Expresiones regulares: corresponden a combinaciones de operandos y operadores para generar las
palabras de un lenguaje.
 Gramáticas: son un conjunto de reglas de reescritura o reemplazo que permiten la generación de
palabras pertenecientes a un lenguaje.

Un problema importante a este respecto es el de la capacidad de estos mecanismos de representación


para abarcar el universo de lenguajes. Para comprender este problema, es necesario tener en
consideración lo siguiente:
 El universo de palabras Σ  que se puede construir sobre un alfabeto Σ es infinito contable (Σ  µ N).
 El conjunto de todos los lenguajes que se pueden crear sobre un alfabeto Σ, es decir, el conjunto

potencia de Σ  , es infinito incontable (2¶ µ 2· ).
 Como tenemos una cantidad finita de representaciones y un número infinito de lenguajes, quedan
infinitos lenguajes sin representación.

En Ciencias de la Computación solo nos interesan aquellos lenguajes que podemos representar. Este
universo puede además ser dividido en clases o familias que comparten alguna propiedad. La principal
clasificación es la jerarquía de Chomsky, que establece 4 tipos de lenguajes representables de acuerdo a
los tipos de gramáticas que las generan:
 Generales o tipo 0.
 Sensibles al contexto (recursivamente enumerables) o tipo 1.
 Libres de contexto o tipo 2.
 Regulares o tipo 3.

Entre los tipos de lenguajes de esta clasificación existe además una jerarquía de inclusión, como se
puede apreciar en la figura A.1, donde j;‹Š 3 s j;‹Š 2 s j;‹Š 1 s j;‹Š 0.

Jacqueline Köhler C. - USACH


131
Compiladores

FIGURA A.1: Jerarquía de Chomsky para los tipos de lenguajes representables.

A.2 GRAMÁTICAS
Un mecanismo para representar lenguajes de manera finita son las gramáticas. Éstas pueden definirse
como un sistema o algoritmo para la generación de palabras basado en el reemplazo de subsecuencias
de acuerdo a determinadas reglas o producciones.

Una producción está conformada por un lado izquierdo o antecedente y un lado derecho o consecuente.
El mecanismo de funcionamiento es simple: cada vez que se encuentre el antecedente en alguna
palabra parcialmente generada, éste debe ser reemplazado por el consecuente, formando así una nueva
palabra total o parcialmente generada.

Formalmente, una gramática $ es una tupla de 4 elementos, $ Σ, N, P, S, donde:


 Σ es un conjunto finito no vacío que contiene los símbolos que conforman el alfabeto del lenguaje
generado. También se conoce como conjunto de terminales.
 N es un conjunto finito no vacío que contiene símbolos auxiliares variables o de reemplazo
denominados no terminales, que facilitan la escritura de las producciones. Σ b N conforman el
vocabulario de la gramática.
 P es un conjunto finito no vacío de producciones.
 S a N es el símbolo inicial a partir del cual, mediante la aplicación sucesiva de producciones, se
puede generar cualquier palabra válida del lenguaje.

Ejemplo A.1:
La siguiente gramática genera palíndromos sobre el alfabeto binario. Es importante destacar que
la palabra vacía no pertenece al vocabulario de $.

$ Σ, N, P, S:
 Σ 0, 1
 N A
 P A 1 0A0 | 1A1 | 0 | 1 | ε
 S A

Jacqueline Köhler C. - USACH


132
Compiladores

A continuación se muestra una notación alternativa para el mismo ejemplo.


$ Σ, N, P, S:
 Σ 0, 1
 N ¸secuencia¿
 P ¸secuencia¿ À 0¸secuencia¿0 | 1¸secuencia¿1 | 0 | 1 | ε
 S ¸secuencia¿

Es importante destacar algunos aspectos de notación, pues es fundamental en la escritura de gramáticas


poder distinguir claramente entre símbolos terminales y no terminales. Las convenciones que se
emplean son las siguientes:
1. Los terminales o elementos de Σ se denotan por:
 Letras latinas minúsculas (, , ), …).
 Palabras en negrita o subrayado (”§, m;x* , … .
 Palabras encerradas entre comillas simples o dobles (‘ab’, “0110”, …).

2. Los no terminales o elementos de N se denotan por:


 Letras latinas mayúsculas (, , , …).
 Palabras encerradas entre paréntesis triangulares (¸secuencia¿, ¸entero¿, …).

3. Se usan letras griegas minúsculas (2, 6, A, …) para designar indistintamente símbolos terminales o
no terminales, o incluso secuencias de ellos.

4. Una producción de % se denota por 2 1 6 ó bien por 2 À 6. Tanto el antecedente como el


consecuente pueden estar conformados por cualquier secuencia no vacía de terminales y no
terminales. En el caso del consecuente, puede aparecer también la palabra vacía.

5. Las reglas 2 1 6, 2 1 A, 2 1  se denotan también por 2 1 6 | A | .

Ahora que está bien definida la idea de gramática, es importante estudiar cómo se generan las palabras.
Para una gramática $, se dice que una secuencia 2 : 2 22 a Σ b N genera o deriva otra
secuencia 6 : 2 62 a Σ b N , operación denotada por 29 Ä 69, si y solo si Å2 1 6 a % para
2 , 2 , 2, 6 a Σ b N .

Observación:
1. ÄL es una derivación de n pasos y Ä corresponde a la clausura reflexiva y transitiva de Ä.
2. El conjunto de las palabras generadas sobre Σ b N a partir del símbolo inicial C se denota por
C$.

Ejemplo A.2:
Retomemos la gramática del ejemplo anterior, $ 0, 1, A, A 1 0A0 | 1A1 | 0 | 1 | ε, A.

Para generar la secuencia 001101100, se tienen los siguientes pasos:


A Ä 0A0 Ä 00A00 Ä 001A100 Ä 0011A1100 Ä 001101100.

Las derivaciones para la palabra 11, en cambio, son las siguientes:


A Ä 1A1 Ä 11.

Jacqueline Köhler C. - USACH


133
Compiladores

En este caso, para la segunda derivación se hace uso de la producción A 1 ε.

También es posible representar las derivaciones en forma gráfica mediante un árbol de análisis
sintáctico, el cual tiene las siguientes propiedades:
 La raíz está etiquetada con el símbolo inicial.
 Cada hoja está etiquetada con un componente léxico o con ε.
 Cada nodo interior está etiquetado con un no terminal.
 Si A es el no terminal que etiqueta a algún nodo interior y X , X , ... XL son las etiquetas de los
hijos de ese nodo, de izquierda a derecha, entonces  1 X1 X2 … Xn es una producción.

Si bien en ocasiones este mecanismo resulta más cómodo y claro, puede ser inadecuado en ocasiones
porque no muestra el orden en que se realizan las sustituciones. La figura A.2 muestra esta
representación para las secuencias generadas en el ejemplo anterior.

FIGURA A.2: Árboles de derivación para las palabras (a) 001101100 y (b) 11 con
$ 0, 1, A, A 1 0A0 | 1A1 | 0 | 1 | ε, A.

Los ejemplos A.1 y A.2 son bastante sencillos, puesto que se trata de una gramática con un único no
terminal. Pero podemos tener una gran cantidad de ellos, como ocurre al crear la gramática de un
lenguaje de programación. Así pues, el ejemplo A.3 muestra una gramática que genera los números
naturales.

Ejemplo A.3:
La gramática $ genera el lenguaje de todos los números naturales, respetando las convenciones
de notación habituales al no permitir ceros a la izquierda. La figura A.3 muestra los árboles de
derivación para generar varios números.

Jacqueline Köhler C. - USACH


134
Compiladores

$ Σ, N, P, S:
 Σ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
 N ¸signiÈicativo¿, ¸digito¿, ¸secuencia¿, ¸natural¿
 P
¸signiÈicativo¿ 1 1 | 2 | 3 | 4 | 5 |6 | 7 |8 | 9, ¸digito¿ 1 0 |1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9,
secuencia1digitosecuencia ε, natural10 | significativosecuencia
 S ¸natural¿

La noción de derivación de palabras conduce a la definición de lenguaje generado por una gramática $,
denotado por D$. Corresponde al conjunto de palabras generadas a partir del símbolo inicial C y que
solo contienen símbolos terminales. Matemáticamente: D$ C$ Í Σ  m a Σ  : C Ä w.

En general, pueden existir varias gramáticas que generen un mismo lenguaje, lo que las hace
equivalentes. Así, $ - $ : zz; D$ D$9.

FIGURA A.3: Árboles de derivación para las palabras (a) 0, (b) 5 y (c) 3907.

Jacqueline Köhler C. - USACH


135
Compiladores

A.2.1 GRAMÁTICAS REGULARES

Un lenguaje es regular si y solo si puede ser generado constructivamente por composición de


operaciones regulares y operandos de lenguajes regulares a partir de los lenguajes básicos.

Los lenguajes básicos son:


 Ï: lenguaje nulo o conjunto vacío.
 : lenguaje que solo contiene la palabra vacía.
 ÐfÐ a Σ: lenguajes que contienen solo una palabra de longitud 1 para cada símbolo del alfabeto.
Las operaciones regulares, ordenadas según su precedencia, son:
 Clausura o estrella de Kleen: operación unaria, denotada por  como superíndice, que indica que el
operando puede repetirse cero o más veces. En ocaciones, se usa también la variante denotada por
un . como superíndice, que indica una o más repeticiones del operando.
 Concatenación: operación binaria que indica que el segundo operando debe ir a continuación del
primero en cualquier palabra válida del lenguaje. Se denota por la ausencia de operador o, en
ocasiones, con un punto.
 Unión: operación binaria denotada por . que indica que se debe escoger uno de entre sus
operandos.

Se dice que una gramática $ Σ, N, P, S es lineal si y solo si en cada regla de producción existe a lo
más un no terminal en el lado derecho. En otras palabras, todas las producciones son de la forma
A 1 uBv o bien A 1 w, con A, B a _ y y, , m a Σ  . Hay dos casos interesantes a considerar en esta
definición:
 y ε: las producciones son de la forma A 1 B o bien A 1 w. En este caso, se dice que $ es
lineal izquierda.
  ε: las producciones son de la forma A 1 uB o bien A 1 w. En este caso, se dice que $ es lineal
derecha.

Las gramáticas lineales derechas se denominan también gramáticas regulares, y son las que generan los
lenguajes regulares.

Ejemplo A.4:
$ a, b, S, A, B, C, S 1 aC, A 1 aA | b, B 1 bB | b, C 1 A | B, S

Esta gramática genera el lenguaje sobre el alfabeto Σ a, b cuyas palabras comienzan con  y
terminan con , y entre medio pueden contener solo  o solo :

D , , , , , , , … 

Podemos ver que, en este ejemplo, están presentes las tres operaciones regulares. Los no
terminales A, B y C tienen dos producciones cada uno, separadas por |, con lo que con cada uno
de ellos tenemos la unión entre dos subsecuencias.

La concatenación está presente en toda producción que contenga dos o más símbolos en su lado
derecho. En el caso de la producción de S tenemos una a concatenada con alguna subsecuencia
generada a partir de C.

Jacqueline Köhler C. - USACH


136
Compiladores

En el caso de A 1 aA | b tenemos presentes la clausura y la concatenación, puesto que A genera


todas las subsecuencias que comienzan por cero o más a y terminan con una b.

A.2.2 GRAMÁTICAS LIBRES DE CONTEXTO

La sintaxis de los lenguajes de programación puede ser descrita por medio de gramáticas libres de
contexto (GLC). Éstas ofrecen ventajas significativas al momento de diseñar un lenguaje o escribir un
compilador:
 Especificación sencilla y fácil de entender para lenguajes de programación.
 Facilitar la tarea de construir analizadores sintácticos. Para algunas clases de gramáticas incluso
pueden ser generados automáticamente.
 Una gramática adecuada da al lenguaje de programación una estructura útil para la traducción de
código fuente a código objeto y para la detección de errores.
 Resulta más fácil añadir nuevas construcciones a un lenguaje de programación ya existente
(evolución).

Se define una GLC como una cuádrupla $ Σ, N, %, C, donde:


 Σ: conjunto finito de símbolos terminales.
 N: conjunto finito de no-terminales.
 P: conjunto de producciones.
 S: símbolo inicial, que debe ser no-terminal.

Una gramática es un modelo que permite generar secuencias sintácticamente válidas en algún lenguaje,
aunque sin tener en cuenta su significado. Por ejemplo, en castellano podemos decir que una oración
simple tiene un artículo, un sustantivo, un verbo y un punto. De acuerdo a esta definición, son
oraciones “El niño corre.” y “Unos planeta piensa.” También podríamos pensar en un párrafo como una
secuencia de una o más oraciones.

Tomemos el ejemplo anterior del castellano para explicar la definición de GLC:


 Σ corresponde al conjunto de elementos que puede aparecer en secuencias válidas, que en nuestro
ejemplo serían los artículos, los verbos, los sustantivos y el punto.
 Los no terminales pueden pensarse como variables temporales que no pueden aparecer en una
secuencia completamente generada, pero que pueden ser reemplazadas por alguna secuencia de
terminales y no terminales. En el ejemplo anterior, los no terminales estarían dados por los
conceptos de oración, artículo, sustantivo y verbo.
 El conjunto de producciones indica cómo pueden ser reemplazados los no terminales. En nuestro
ejemplo, tenemos que oración es reemplazado por la secuencia artículo-sustantivo-verbo-punto.
 El símbolo inicial es un no terminal específico a partir del cual se construyen las secuencias válidas.
En nuestro ejemplo sería párrafo.

Para llevar esta noción al modelo anterior, digamos que el símbolo A corresponde a párrafo; B, a
oración; C a artículo, D a sustantivo, y E, a verbo. Así, tendríamos que:
 Σ . , El, La, … , Unos, niño, planeta, casa, … , lápiz, corre, salta, piensa, … , vuela.
 N A, B, C, D, E.
 P  1  | ,

Jacqueline Köhler C. - USACH


137
Compiladores

 1 !".,
 1 El | La | … | Unos,
! 1 niño | planeta | casa | … | lápiz,
" 1 corre | salta | piensa | … | vuela.
 S A.

Las gramáticas libres de contexto (GLC) son aquellas en que todas las producciones tienen la forma
 1 2, con  a _ y 2 a Σ b _ . Así, el lado izquierdo de cada producción contiene únicamente un
símbolo no terminal. Esta restricción no es trivial, puesto que no todos los lenguajes pueden ser
generados por una gramática independiente del contexto. En consecuencia, los lenguajes generados por
este tipo de gramáticas se denominan lenguajes independientes del contexto. Además se tiene que las
producciones de los no terminales pueden tener cualquier secuencia de terminales y no terminales,
dejando fuera la restricción de tener a lo más un no terminal al extremo derecho de la producción. Cabe
destacar que el conjunto de los lenguajes regulares es un subconjunto de los lenguajes libres de
contexto. Las gramáticas de los ejemplos A.1 y A.3 son, en consecuencia, libres de contexto.

En el caso de las gramáticas lineales (y en particular de las gramáticas regulares), en que toda
producción tiene a lo más un no terminal, no es necesario preocuparse del orden en que se realicen las
derivaciones o de si existen árboles distintos para generar una misma palabra, pues hay uno solo por
cada secuencia. En el caso de las GLC, como podemos tener más no terminales en una producción, sí
es necesario tomar en consideración el orden en que se efectúan las derivaciones.
 Por la derecha: siempre se reemplaza el no terminal de más a la derecha.
 Por la izquierda: siempre se reemplaza el no terminal de más a la izquierda.

Ejemplo A.5:
Dada la GLC $ /, , , .,, ", " 1 " . " | "  " |"| /, ", muestre una derivación
por la derecha y otra por la izquierda para la palabra / . /  /. Muestre además sus árboles de
derivación.

Derivación por la derecha:


" 1"." 1"."" 1"."/ 1".// 1/.//

Derivación por la izquierda:


" 1"." 1/." 1/."" 1/./" 1/.//

Las dos derivaciones anteriores pueden representarse por medio del mismo árbol de derivación,
que se muestra en la figura A.4.

IGURA A.4: Árbol de derivación para la palabras / . /  /.

Jacqueline Köhler C. - USACH


138
Compiladores

Ahora que está clara la idea de derivación por la derecha y por la izquierda, es importante introducir el
concepto de gramática ambigua. Una gramática $ aGLC es ambigua Ö Åm a D$ × m tiene dos
derivaciones por la izquierda } m tiene dos derivaciones por la derecha m } tiene dos árboles de
derivación.

Ejemplo A.6:
Consideremos como base el ejemplo A.5. Ahora podemos buscar nuevas derivaciones por la
izquierda y por la derecha para la misma palabra, así como un nuevo árbol de derivación, y así
probar que $ es ambigua.

Derivación por la derecha:


" 1"" 1"/ 1"."/ 1".// 1/.//

Derivación por la izquierda:


" 1 "" 1"."" 1 /."" 1 /./" 1/.//

Al igual que en el ejemplo A.5, las dos derivaciones anteriores pueden representarse por medio
del mismo árbol de derivación (figura A.5).

FIGURA A.5: Otro árbol de derivación para la palabras / . /  /.

En consecuencia, $ es ambigua porque existen dos árboles de derivación diferentes para la


misma palabra.

A.2 EXPRESIONES REGULARES


Las expresiones regulares (ER) se utilizan para especificar un lenguaje regular, de una manera finita
más sencilla que la gramática. Se construyen considerando los siguientes elementos:
 Símbolos σÙ tomados de un alfabeto Σ.
 La palabra vacía, denotada por ε. Si bien este símbolo no forma parte del alfabeto, se usa para
indicar una palabra que existe, pero que no contiene símbolos. Se puede asociar esta idea a string en
programación que ha sido declarado pero que está inicializado como el string nulo.

Los elementos anteriores se relacionan entre sí por medio de las operaciones regulares:
 Clausura o estrella de Kleen, denotada por  como superíndice. En ocaciones, se usa también la
variante denotada por un . como superíndice, que indica una o más repeticiones del operando.

Jacqueline Köhler C. - USACH


139
Compiladores

 Concatenación, que se denota por la ausencia de operador o, en ocasiones, con un punto.


 Unión, denotada por ..

Ejemplo A.7: Algunas expresiones regulares sencillas.


Algunas expresiones regulares sencillas son:
  : representa el lenguaje de todas las palabras que contengan cero o más  y que no
contengan otros símbolos, es decir, D , , , , , … .
   : representa el lenguaje de todas las palabras que contengan una o más  y que no
contengan otros símbolos, es decir, D , , , , … .
  . : representa el lenguaje de todas las palabras de longitud 1 sobre el alfabeto Σ , ,
es decir, D , .
 : representa el lenguaje que contiene solamente la palabra conformada por una  seguida
de una , es decir, D .
  .   .  : denota el lenguaje de todas las palabras sobre el alfabeto Σ , 
que tienen a lo menos un par de  consecutivas. Algunos ejemplos de palabras que
pertenecen a este lenguaje son: , , , , etc.
 ) .  ): denota el lenguaje de todas las palabras sobre el alfabeto Σ , , ) que
comienzan con una ), tienen cualquier cantidad de pares consecutivos de  y  y terminan
con otra ). Algunos ejemplos de palabras que pertenecen a este lenguaje son: )), )),
)), )), )), etc.

A.3 AUTÓMATAS FINITOS


Son mecanismos para el reconocimiento de lenguajes regulares que nos permiten automatizar esta
tarea. Un autómata finito (AF) es una máquina discreta de estados que tiene las siguientes
características:
 Es sensible al medio, es decir, puede leer (recibir entradas).
 Cuenta con un número finito de estados internos.
 Puede reaccionar frente a las entradas cambiando su estado.
 En general, no tiene mecanismos explícitos para emitir una salida, es decir, No tiene salidas; es
decir, no es capaz de modificar su medio.

El esquema general de funcionamiento es que, en todo momento, el autómata se encuentra en algún


estado o (conjunto de estados) activo o actual, a la espera de recibir una nueva entrada. Cuando esto
ocurre, reacciona cambiando su estado actual de acuerdo a transiciones bien definidas y espera una
nueva entrada, y así sucesivamente. A cada instante, el AF solo conoce su estado actual, y no lleva un
registro de sus estados anteriores. No obstante, el estado actual actúa como síntesis de esa trayectoria,
pues el alcanzar un estado depende de los estados anteriores.

Un ejemplo adecuado para comprender esta idea es el funcionamiento de un ascensor: Cada piso
corresponde a un estado, y en todo momento el ascensor sabe dónde se encuentra. Al apretarse un
botón, el ascensor debe dirigirse a un nuevo piso de acuerdo a la entrada recibida.

Además de los AF descritos, existen máquinas semejantes que incorporan la capacidad de proporcionar
una salida. Éstas últimas reciben el nombre de transductores finitos.

Jacqueline Köhler C. - USACH


140
Compiladores

Desde una perspectiva de implementación física, un AF puede verse como un dispositivo conformado
por los siguientes elementos, algunos de los cuales se ilustran en la figura A.6:
 Una cinta infinita que contiene símbolos pertenecientes a un alfabeto, formando una secuencia de
entrada.
 Un cabezal Un cabezal capaz de leer un símbolo de la cinta y desplazarse automáticamente hacia la
derecha.
 Un control finito, compuesto de una cantidad, también finita, de estados y una especificación
(transición) que permite determinar el estado siguiente de acuerdo a la lectura realizada en la cinta.
 Algunos estados distinguibles dentro del conjunto de estados:
• Un estado inicial.
• Uno o más estados finales o de aceptación (que indican que la secuencia pertenece a un
lenguaje dado).

FIGURA A.6: modelo de un autómata finito.

Una vez conocidos los elementos de un AF, estamos en condiciones de describir el proceso de
aceptación o reconocimiento de una palabra como parte de un lenguaje regular dado, aunque no se
tocará aún el tema de la construcción de estas máquinas.

Inicialmente, el cabezal lector debe estar situado en el primer símbolo de la cadena de entrada y el AF
debe encontrarse en su estado inicial. A continuación, el cabezal lee el símbolo y se desplaza hacia la
derecha en una posición, y el AF modifica su estado de acuerdo a la entrada recibida y a una
especificación de transición. La lectura de símbolos y el cambio de estado se repiten sucesivamente
hasta que no queden símbolos por leer en la entrada y, en consecuencia, el AF no realice nuevos
cambios de estado. Una vez alcanzado este punto, es el observador quien debe determinar si la palabra
leída pertenece o no al lenguaje regular dado. Para este fin, basta observar el estado en que se encuentra
el autómata. Si es uno de los distinguidos como final o de aceptación, entonces la palabra de la entrada
pertenece al lenguaje. En caso contrario, la palabra no se acepta.

Más formalmente, un AF M es una tupla de 5 elementos, , Σ, ,  , , donde:


  J # es un conjunto finito no vacío de estados.
 Σ es el alfabeto del lenguaje reconocido y contiene los símbolos que conforman las palabras de la
entrada.
  corresponde a la especificación de transición entre estados en base al estado actual y al símbolo
leído desde la entrada.
  a  es el estado inicial.

Jacqueline Köhler C. - USACH


141
Compiladores

 F Ú Q es el conjunto de estados finales.

Podemos distinguir dos grandes clases de AF, dependiendo de la forma que tenga la definición de
transición : determinísticos y no determinísticos.

A.3.1 AUTÓMATA FINITO DETERMINÍSTICO (AFD)

Su función de transición  es de la forma :  ˜ Σ 1 Q. En otras palabras, dado un estado actual y un


símbolo de entrada, retorna el nuevo estado al que debe pasar el AFD. Así, la transición , Ð 9
indica que, al estar en el estado  y leer el símbolo Ð, el AFD pasa al siguiente estado 9. El cabezal
pasa automáticamente al símbolo siguiente.

Cabe destacar que de esta definición de  se desprenden las siguientes conclusiones:


 Al encontrarse el AFD en un estado y leer un símbolo de entrada, hay uno y solo un estado al que
puede avanzar.
 No puede haber un cambio de estado si no se ha leído un símbolo desde la entrada.
 Para todo estado y todo símbolo del alfabeto debe existir una transición definida, por lo que la
cantidad de transiciones de un AFD es de || ˜ |Σ|.

Ejemplo A.8:
Considere el AFD , Σ, ,  , , donde:
   ,  ,  ,  
 Σ , 
   ,   ,  ,   ,  ,   ,  ,   ,  ,   ,
2,  2, 3,  3, 3,  3
  
   

Otra manera de representar la función de transición es mediante una tabla, como se muestra en
la tabla A.1.

\Ð  
  
  
  
  

TABLA A.1: Función de transición para el AFD M.

Gráficamente, se puede mostrar M como en la figura A.7. Resulta interesante destacar que la
figura A.7 ilustra todos los elementos presentes en la definición formal de M:
 El conjunto de estados  está denotado por el conjunto de círculos rotulados.
 El alfabeto Σ aparece tácitamente reflejado en las transiciones.

Jacqueline Köhler C. - USACH


142
Compiladores

 Cada transición de  tiene un estado de origen, señalado por la base de la flecha; un estado
de destino, señalado por la cabeza de la flecha, y un símbolo asociado.
 El estado inicial  está señalado con una flecha no rotulada que incide en él.
 Cada estado final de , en este caso solo  , se denota por un doble círculo.

FIGURA A.7: Representación gráfica de M.

Como punto de partida para algunas definiciones debemos considerar que el concepto básico necesario
para describir la operación de un AFD es la configuración o descripción instantánea, es decir, el estado
en que se encuentra el sistema completo en un instante de tiempo. Así, la configuración del AFD en un
instante dado está determinada por dos variables: el estado del AFD en que se encuentra el control
finito y la posición del cabezal de lectura, dada por el sufijo aún no leído de la palabra de entrada
(incluido el símbolo en que se encuentra el cabezal). Así, una configuración es un par ordenado
, m a  ˜ Σ  , donde  es el estado actual y m es el sufijo de la entrada que aún no ha sido leído,
incluyendo el símbolo señalado por el cabezal. La configuración inicial siempre está dada por el estado
inicial del autómata y el primer símbolo de la entrada.

La siguiente noción importante es la de paso de computación, correspondiente a un cambio elemental


de configuración. En otras palabras, es una relación binaria entre dos configuraciones sucesivas de un
AFD M, denotada por el símbolo Ü. Estas dos configuraciones están relacionadas mediante una
transición, es decir:

, m Ü 9, m9 Ö ÅÐ a Σ × m Ðm : | , Ð 9

Cabe destacar que Ü7 denota = pasos de computación y que Ü corresponde a la clausura reflexiva y
transitiva de Ü.

En tercer lugar, es necesario definir el concepto de aceptación de una palabra m a Σ  . Se dice que es
aceptada por un AFD M si y solo si, comenzando desde la configuración inicial, al terminar de leer m
el AFD se encuentra en un estado final, es decir:

m a Σ  es aceptada por M Ö  , m Ü ,  |  a 

Ejemplo A.9:
Consideremos el AFD del ejemplo A.8 y la entrada m abbaab. La configuración inicial de M
está dada por  , abbaab. Tras el primer paso de computación, se tiene:

Jacqueline Köhler C. - USACH


143
Compiladores

 , abbaab Ü  , bbaab

La secuencia de pasos de computación siguientes corresponde a:

 , bbaab Ü  , baab Ü  , aab Ü  , ab Ü  , b Ü  , 

Así,  , abbaab Ü  ,  y además  a , por lo que M acepta la palabra m.

Ejemplo A.10:
Consideremos ahora el AFD del ejemplo A.8 y la entrada m bab. La configuración inicial de
M está dada por  , bab. La secuencia de pasos de computación para determinar la aceptación
de m es:

 , bab Ü  , ab Ü  , b Ü  , 

Así,  , bab Ü  ,  y además  g , por lo que M no acepta la palabra m.

La noción de aceptación de una palabra conduce a la noción de lenguaje aceptado por un AFD M,
denotado por D , que corresponde al conjunto de todas las palabras aceptadas por M, es decir
D  m a Σ  : m es aceptada por M.

Ejemplo A.11:
Si estudiamos el AFD del ejemplo A.8, podemos notar que D  corresponde al lenguaje
representado por la ER {  .   .

Una vez conocido D , es posible definir la equivalencia entre dos AF: se dice que dos AF  y 
son equivalentes si aceptan el mismo lenguaje, es decir:

 -  Ö D   D  

Otra definición importante es la de alcanzabilidad de un estado, para la cual se requiere previamente la


noción de transición extendida. Una función de transición extendida 9 entre dos estados distantes (no
vecinos)  y 9 y que involucra la lectura de una secuencia de símbolos puede ser definida como una
función  : :  ˜ Σ  1 Q tal que:

 : , m 9 Ö Åm a Σ  × , m Ü 9, 

Así, se tiene que un estado 9 es alcanzable desde otro estado  si existe una palabra m a Σ  tal que la
secuencia de pasos de computación comenzando desde  y el primer símbolo de m culmina en 9
cuando no quedan símbolos por leer. Cabe destacar que  : tiene las siguientes propiedades:
  : ,  
  : , Ð , Ð
  : , Ðm  : , Ð, m
  : , m  : 9, , m

Jacqueline Köhler C. - USACH


144
Compiladores

A.3.2 AUTÓMATA FINITO NO DETERMINÍSTICO (AFND)

Habíamos estudiado que el estado al que va un AFD al leer un símbolo de la entrada está
completamente determinado, puesto que cada estado cuenta con una y solo una transición por cada
símbolo del alfabeto. En el caso del AFND, en cambio, el cambio de estado solo está parcialmente
especificado, puesto que puede ocurrir lo siguiente:
 Las transiciones entre estados están dadas por palabras más que por símbolos individuales,
pudiendo incluso haber transiciones con , denominadas transiciones vacías, en que el AF puede
cambiar de estado sin necesidad de haber leído algo desde la entrada.
 Puede haber más de una transición definida desde un estado determinado con alguna lectura dada
(transiciones múltiples). En consecuencia, el AFND debe ir a alguno cualquiera de los estados de
destino, de manera no determinada.

De lo anterior podemos concluir que, en realidad, el AFD estudiado no es más que un caso particular
del AFND. En estos últimos, la especificación de transición  es una relación  Ú  ˜ Σ  ˜ ,
conformada por un conjunto de tríos , m, 9 donde cada trío indica que, estando en el estado , al
leer en la entrada una secuencia de símbolos m, el AFND puede quedar en el estado 9. No obstante,
como pueden existir dos o más transiciones que compartan los dos primeros componentes, por ejemplo
, m, 9, , m, 99, esto puede reformularse de modo tal que el tercer elemento del trío sea un
conjunto de estados: , m,  : , 99.

En el caso de los AFND, las nociones de configuración, paso de computación, aceptación de palabra,
aceptación de lenguaje y equivalencia son semejantes al caso de los AFD. No obstante, la existencia de
transiciones vacías da origen a una nueva definición: la clausura- . Corresponde al conjunto de estados
alcanzables desde el estado  mediante transiciones vacías, es decir:
   z a : ,  Ü z, 

Se puede extender la noción de clausura-  para un conjunto de estados j, que corresponde


simplemente a la unión de las clausuras-  de cada estado en j:

  j Þ    f a j

Ejemplo A.12:
Considere el AFND , Σ, ,  , , donde:
   ,  ,  ,  , 

 Σ , 
 
 , ,  ,  , ,  ,  , ,  ,  , ,  ,  , ,  ,
1, ,3, 2, ,0, 2, ,3, 3, ,2,3, ,3, 3, ,4,4, ,1,4, ,3
  
   ,  

Otra manera de representar la función de transición es mediante una tabla, como se muestra en
la tabla A.2.

Jacqueline Köhler C. - USACH


145
Compiladores

\m    
  ,     Ï Ï
 Ï   Ï 

 Ï Ï    
    , 
 Ï Ï

Ï Ï    

TABLA A.2: Función de transición para el AFND M.

Gráficamente, se puede mostrar M como en la figura A.8.

FIGURA A.8: Representación gráfica de M.

Además, las clausuras-  para los diferentes estados son:


      
      ,  , 

      ,  
      
   
  , 


A.3.3 EQUIVALENCIA ENTRE AFD Y AFND

La incorporación del no determinismo, a pesar de facilitar la capacidad de abstracción al diseñar un AF,


no aumenta el poder de aceptación de estos reconocedores, es decir, no incrementa el conjunto de
lenguajes que pueden ser reconocidos usando máquinas de estados finitos. Así pues, es posible
demostrar que para cada AFND existe un AFD equivalente.

Para construir un AFD equivalente a un AFND dado, se deben seguir dos grandes pasos:
1. Eliminar aquellas transiciones que avanzan con secuencias cuya longitud es mayor que 1.
2. Eliminar transiciones vacías y transiciones múltiples.

Jacqueline Köhler C. - USACH


146
Compiladores

Para eliminar las transiciones con secuencias de longitud mayor a 1, se incorporan nuevos estados al
AFND a fin de descomponer las transiciones para que solo tengan un símbolo.

Sea , Σ, ,  ,  un AFND tal que sus transiciones son de la forma , y, 9, donde , 9 a  y
u a Σ  . Entonces, existe un AFND 9 9, Σ, 9,  ,  tal que:
 9 Ú 9 ˜  b Σ ˜ 9.
  :  b %8 , con %8 ‹ , ‹ , … , ‹@ß f, y, 9 a  tal que |y| r p 1.
 Note que y 9 comparten Σ,  y .

La nueva relación de transición 9 se obtiene como


 , y, 9 a : |y| à 1 b , Ð , ‹ , ‹ , Ð , ‹ , … , ‹@ß , Ð@ , 9 para cada , y, 9 a  tal
:

que y Ð Ð … Ð@ y |y| r p 1, como se ilustra en la figura 2.6.

El segundo paso, entonces, consiste en eliminar las transiciones vacías y las transiciones múltiples.
Para este fin, creamos un conjunto de estados para el AFD :: 9, Σ, 99,  99, 99 tal que cada uno
de sus estados corresponde a un conjunto de estados del AFND 9 9, Σ, 9,  , , que sintetiza
todos los caminos posibles para reconocer una determinada secuencia. Se puede garantizar que es
posible realizar esta tarea, ya que para un conjunto de estados de tamaño = se tienen 27 subconjuntos.

El primer estado del AFD 99 que debemos determinar es el inicial,  99 dado por la clausura- de  ,
el estado inicial de 9.

Los nuevos estados de 99 se determinan junto con las transiciones. Para cada símbolo de Σ se
determina el conjunto de estados de 9 que es posible alcanzar desde  99 y se les incorporan sus
respectivas clausuras-. Cada nuevo conjunto forma un nuevo estado del AFD. Se repite este proceso
hasta que no queden estados sin transiciones definidas. Los estados finales de 99 serán todos aquellos
conjuntos de estados que contengan algún estado final de 9.

Ejemplo A.13:
Consideremos el AFND 9 de la figura A.9 y determinemos su AFD equivalente 99. El estado
inicial de 99 está dado por la clausura- del estado inicial de 9:

    

Ahora debemos determinar las transiciones de  y sus respectivas clausuras:


 , 0 , $   , $ , , !, ", $ 
 , 1 Ï   Ï Ï 

Repetimos el proceso anterior para los nuevos estados:


 , 0 , , $   , , $ , , , !, ", $ 
 , 1 , T   , T , T 

 , 0 Ï   Ï 
 , 1 Ï   Ï 

Jacqueline Köhler C. - USACH


147
Compiladores

 , 0 , , $   , , $ , , , !, ", $ 


 , 1 , , T   , , T , , T 

FIGURA A.9: Eliminación de transiciones con secuencias de longitud mayor a


1 para un AFND M.


, 0 !   ! !, " 

, 1      

 , 0 !   ! 


 , 1 ",    ",  ",  

 , 0 Ï   Ï 
 , 1      

 , 0 Ï   Ï 
 , 1 "   " " 

 , 0 , "   , " , " 


 , 1 Ï   Ï 

 , 0 Ï   Ï 
 , 1     

 , 0 Ï   Ï 
 , 1     

Jacqueline Köhler C. - USACH


148
Compiladores

 , 0 Ï   Ï 
 , 1 ,    ,  ,  

 , 0 Ï   Ï 
 , 1 ",    ",  

Note que  es el conjunto vacío, por lo que al haber leído una secuencia no válida se convierte
en un estado trampa.

El conjunto de estados finales del AFD es  ::  ,  , 


,  ,  ,  ,  ,  ,  ,  

A.3.4 MINIMIZACIÓN DE AFD

En muchas ocasiones, al construir un AFD obtenemos una cantidad de estados superior a la necesaria,
por lo que resulta útil determinar el AFD equivalente más pequeño. Para este fin, debemos determinar
si hay estados que tengan igual significado.

Uno de los métodos existentes para minimizar un AFD es el de las particiones, en que se van creando
grupos cada vez más pequeños de estados potencialmente equivalentes. Para explicar este método,
tomaremos como ejemplo el AFD obtenido en el ejemplo A.13.

Para la partición, sabemos que un estado final no puede ser equivalente a otro no final, por lo que
creamos un grupo para los estados finales y otro para los no finales:

%  ,  ,   ,  , 
,  ,  ,  ,  ,  ,  ,  

Ahora asignamos un nombre a cada grupo y estudiamos el comportamiento de cada estado para cada
uno de los símbolos del alfabeto. Llamemos 1 al grupo que contiene a los estados no finales y 2 al
grupo de estados finales.  va a  al leer un 0, y  está en el grupo b. Además,  va a  al leer un
0, y  está en el grupo a.

            
á , , , , , , , , , â
%  ,  ,  â   
      
á
 

Para la siguiente partición, podemos descomponer los grupos de acuerdo a su comportamiento. Son
potencialmente equivalentes aquellos estados de un mismo grupo que vayan a las mismas particiones
con los mismos símbolos. Es importante destacar que lo que ya estaba separado no puede juntarse
nuevamente. Así, tenemos que todos los estados se comportan de manera diferente en el grupo a,
mientras que hay algunos del grupo b que siguen siendo potencialmente equivalentes. Ahora, tras la
nueva partición, repetimos el proceso para ver si hay más grupos que se puedan separar:

'* '' +* £ £ + *
   á , , âá+), +âá  ,  ,  ,  â 
%    
   
  ) £
' * +

Jacqueline Köhler C. - USACH


149
Compiladores

De acuerdo al resultado anterior, la nueva partición queda:

x x  
      
 á , â   
%     
  ) ' * + £  > r x
;

Tras el análisis de comportamiento de los grupos, se tiene que % % , por lo que  - . En
consecuencia, podemos tener el AFD mínimo quitando  y redirigiendo sus transiciones entrantes a
 .

A.3.5 EQUIVALENCIA ENTRE AF Y ER: MÉTODO DE THOMPSON

Resulta lógico que si las ER representan lenguajes regulares y los AF los reconocen, existe una
equivalencia entre ellos. Existen diversos métodos para pasar de AF a ER y viceversa. No obstante,
para este curso solo nos interesa poder construir un AF a partir de una ER, para lo cual utilizaremos el
método de Thompson.

El método de Thompson opera de manera constructiva, creando AFNDs para subexpresiones sencillas
y combinándolos luego de acuerdo a ciertas reglas. Una ventaja de este método es que resulta sencillo
de implementar computacionalmente, y facilita las operaciones de unión, concatenación y clausura al
garantizar que cada AFND tiene un único estado final. De más está decir que este método se
complementa con otros ya estudiados a fin de obtener el AFD mínimo reconocedor.

Sea Ð a Σ y sean ã y ä AFNDs reconocedores de los lenguajes representados por las expresiones
regulares {ã y {ä , respectivamente. Ambos AF han sido construidos mediante el método de Thompson,
por lo que solo tienen un estado final.

Los esquemas básicos que usa este método son:

{ : el AFND reconocedor para la palabra vacía se construye creando dos estados, uno de ellos
inicial que no es de aceptación y el otro final. Para llegar desde el estado inicial al final, se crea una
transición vacía. La figura A.10 muestra este esquema.

FIGURA A.10: AFND reconocedor de la palabra vacía según el método de Thompson.

{ Ð: análogo al anterior, cuando la expresión regular consiste en un solo símbolo, basta con que la
transición del estado inicial al estado final tenga asociado el mismo símbolo (figura A.11).

FIGURA A.11: AFND reconocedor para palabras de largo 1 según el método de Thompson.

Jacqueline Köhler C. - USACH


150
Compiladores

{ {ã . {ä : el mecanismo para construir un autómata reconocedor correspondiente a la unión de dos


subexpresiones regulares es bastante sencillo. Basta con tomar los dos autómatas reconocedores, crear
un nuevo estado inicial que vaya con transiciones vacías a los que fueran los estados iniciales y crear
un nuevo estado final alcanzable con transiciones vacías desde el que fuera el estado final (figura
A.12).

FIGURA A.12: Esquema para construir la unión según el método de Thompson.

{ {ã {ä : para la concatenación, basta con hacer que el estado final del primer autómata se fusione con
el estado inicial del segundo en un estado no final (figura A.13).

FIGURA A.13: Esquema para construir la concatenación según el método de Thompson.

{ {ã  : para la clausura reflexiva y transitiva, el mecanismo consiste en incorporar un nuevo estado


inicial y un nuevo estado final, a fin de poder reconocer la secuencia vacía. Para dar la opción de
reconocer el lenguaje representado por la subexpresión una única vez, basta con pasar desde el nuevo
estado inicial al estado inicial previo con una transición vacía, y del estado final previo al nuevo con
otra. Por último, para establecer la repetición de la clausura, basta con pasar con una transición vacía
desde el estado final previo al estado inicial previo. La figura A.14 ilustra esta operación.

FIGURA A.14: Esquema para construir la estrella de Kleene según el método de Thompson.

Ejemplo A.14:
Construir un AF reconocedor para el lenguaje representado por la siguiente expresión regular:
{ 01100 . 11 . 01 . 0.

Jacqueline Köhler C. - USACH


151
Compiladores

La figura A.15 muestra los pasos seguidos para construir el AFND reconocedor para la ER
dada.

{ 

{ 0

{ 1

{
{ {

{ {
{

{ { {

{ { {

FIGURA A.15: Esquema para construir la estrella de Kleene según el método de Thompson.

Jacqueline Köhler C. - USACH


152
Compiladores

{ { . {

{ { 

{ { {

{ { . {

FIGURA A.15: Continuación.

Jacqueline Köhler C. - USACH


153
Compiladores

{ {
{

{ { { . {

FIGURA A.15: Continuación.

A.4 AUTÓMATAS APILADORES

Los lenguajes que no son regulares no pueden ser reconocidos usando los AF que ya conocemos. Por lo
tanto, para el reconocimiento de lenguajes libres de contexto no regulares (recuerde que Dt s DD) y
que representan estructuras con anidamientos se requiere de un mecanismo que tenga la capacidad de
recordar la historia de la entrada leída, en forma explícita y accesible, para tomar una decisión del
movimiento a efectuar.

Esta capacidad de memoria se logra dotando al AF de una memoria de pila, en la cual se van
almacenando símbolos de la entrada que en algún momento son recuperados para su utilización. Este
modelo de autómata se denomina autómata apilador (AA) o Push-Down Automaton (PDA), pues al
momento de leer un símbolo de la entrada, puede empujarlo a la pila (push) o bien extraer información
de esta última (pop), además de efectuar una transición de un estado a otro. La figura A.16 muestra el
modelo de AA como dispositivo físico.

Jacqueline Köhler C. - USACH


154
Compiladores

FIGURA A.16: Modelo de un autómata apilador.

Formalmente, un AA es una 7-tupla , Σ, Γ, ,  ,  , , donde:


  es un conjunto finito de estados.
 Σ es el alfabeto de entrada.
 Γ es el alfabeto de la pila.
 :  ˜ Σ ˜ Γ  1  ˜ Γ .
  a  es el estado inicial.
  a Γ es el símbolo inicial de la pila.
  Ú  es el conjunto de estados finales.

Para comprender adecuadamente este modelo es necesario explicar con detenimiento la relación de
transición . Por definición, un AA corresponde a un AFND con una pila asociada. Además, si se tiene
8 , m, 2 h? , 6i, entonces:
 El estado actual del AA es 8 , debe leer desde la entrada la secuencia m y la secuencia 2 se
encuentra al tope de la pila.
 Una vez efectuada la lectura, pasa al estado ? y reemplaza 2 por 6 al tope de la pila.
Cabe destacar como casos particulares a dos operaciones especiales:
 Push(a): 8 , m,  h? , i, inserta el símbolo  al tope de la pila.
 Pop: 8 , m,  h? , i, quita el símbolo que se encuentre al tope de la pila.

Se define como configuración de un AA al estado en que se encuentra, la porción no leída de la entrada


y el contenido de su pila, es decir, a un elemento de  ˜ Σ ˜ Γ .

Un paso de computación, denotado por Ü, corresponde a un cambio en la configuración del AA:


8 , m, 62 Ü h? , m, A2i. Además, Ü corresponde a la clausura reflexiva y transitiva de Ü.

Se dice que un AA acepta o reconoce una entrada m a Σ si y solo si, comenzando desde el estado
inicial y al momento de terminar de leer m, cumple con a lo menos uno de los siguientes criterios:
1. se encuentra en un estado final. Es decir,  , m,  Ü 8 , , 2, con 8 a .
2. tiene su pila vacía, es decir,  , m,  Ü 8 , , .

Resulta interesante la conclusión de que, al bastar con uno solo de los criterios para la aceptación,
puede darse que para un AA se tenga  Ï.

Jacqueline Köhler C. - USACH


155
Compiladores

Otra observación interesante es que, puesto que Dt s DD, se tiene que un AF corresponde a un caso
especial de AA en que no se realizan operaciones sobre la pila.

El lenguaje aceptado por un AA , D , está dado por: D  m: *z )*‹' ‹Š{ .

Dos AA  y  son equivalentes si y solo sí D   D  .

Ejemplo A.15:
Sea el AA , Σ, Γ, ,  ,  , , donde:
   ,  .
 Σ 0, 1, ).
 Γ t, , $.
   .
  t.
  #.
 :
•  , 0, t  , t
•  , 0,   , 
•  , 0, $  , $
•  , 1, t  , $t
•  , 1,   , $
•  , 1, $  , $$
•  , ), t  , t
•  , ),   , 
•  , ), $  , $
•  , 0,   , 
•  , 1, $  , 
•  , , t  , 

D  m)m å : m a 0, 1 .

Nótese que este AA solo puede aceptar una palabra por medio de la pila vacía. Para ver su
funcionamiento, veamos en primer lugar la traza para la entrada m 011)110, que se muestra
en la tabla A.3.

Jacqueline Köhler C. - USACH


156
Compiladores

TABLA A.3: Traza para m 011)110.

La tabla A.4 muestra que la entrada m 01)11 genera un error, por lo que finalmente
tenemos que m a D , pero m g D .

TABLA A.4: Traza para m 01)11.

Jacqueline Köhler C. - USACH