Uploaded by Sara Victoria “LadyBlaack” Morales Candia

Compiladores, principios, técnicas y herramientas

advertisement
Esta nueva edición del clásico libro del “Dragón” se ha revisado
Los nuevos capítulos incluyen:
Capítulo 7: Entornos en tiempo de ejecución
Capítulo 10: Paralelismo a nivel de instrucción
Capítulo 11: Optimización para el paralelismo y la localidad
Capítulo 12: Análisis interprocedural
Gradiance es un recurso de evaluación de tareas
y ejercicios de laboratorio para estudiantes y
profesores. Para Compiladores ofrece una colección de
conjuntos de tareas, utilizando una técnica especial
conocida como preguntas de raíz (root questions), donde los estudiantes
trabajan en un problema como si fuera una tarea normal. Después, el
conocimiento que obtengan con base en la solución se muestrea mediante
una pregunta de opción múltiple, elegida al azar. Cuando se selecciona una
respuesta incorrecta, reciben una sugerencia y un consejo inmediato que
explica por qué la respuesta está equivocada, o sugiere un método general
para solucionar el problema. Más tarde se permite a los estudiantes tratar
de resolver la misma tarea otra vez. Este método proporciona un refuerzo
inmediato de los conceptos sobre compiladores. El uso de Gradiance permite
a los profesores asignar, evaluar y rastrear las tareas de manera automática, y
mejora la experiencia de aprendizaje general de sus estudiantes.
Para mayor información visite:
www.pearsoneducacion.net/aho
Port. Compiladores.indd 1
Compiladores
principios, técnicas y herramientas
Segunda edición
Segunda
edición
ISBN: 978-970-26-1133-2
Visítenos en:
www.pearsoneducacion.net
Compiladores
por completo para incluir los desarrollos más recientes en la compilación. El
libro ofrece una introducción detallada al diseño de compiladores y continúa
haciendo énfasis en la capacidad de aplicar la tecnología de compiladores a
una amplia gama de problemas en el diseño y desarrollo de software.
Aho
Lam
Sethi
Ullman
Alfred V. Aho
Monica S. Lam
Ravi Sethi
Jeffrey D. Ullman
11/15/07 8:38:48 AM
Compiladores
principios, técnicas y herramientas
Segunda edición
Compiladores
principios, técnicas y herramientas
Segunda edición
Alfred V. Aho
Columbia University
Monica S. Lam
Stanford University
Ravi Sethi
Avaya
Jeffrey D. Ullman
Stanford University
TRADUCCIÓN
Alfonso Vidal Romero Elizondo
Ingeniero en Electrónica y Comunicaciones
REVISIÓN TÉCNICA
Sergio Fuenlabrada Velázquez
Edna M. Miranda Chávez
Adalberto Robles Valadez
Oskar A. Gómez Coronel
Academia de Computación UPIICSA
Instituto Politécnico Nacional
Norma F. Roffe Samaniego
Elda Quiroga González
Departamento de Computación
Instituto Tecnológico y de Estudios Superiores de Monterrey
Campus Monterrey
Jesús Alfonso Esparza
Departamento de Computación
Instituto Tecnológico y de Estudios Superiores de Monterrey
Campus Estado de México
AHO, ALFRED V.
COMPILADORES. PRINCIPIOS, TÉCNICAS Y
HERRAMIENTAS. Segunda edición
PEARSON EDUCACIÓN, México, 2008
ISBN: 978-970-26-1133-2
Área: Computación
Formato: 18.5 3 23.5 cm
Páginas: 1040
Authorized translation from the English language edition, entitled Compilers: Principles, techniques and tools, 2nd edition
by Alfred V. Aho, Monica S. Lam Ravi Sethi, Jeffrey D. Ullman, published by Pearson Education, Inc., publishing as
Addison-Wesley, Copyright ©2007. All rights reserved.
ISBN: 0321486811
Traducción autorizada de la edición en idioma inglés, Compilers: Principles, techniques and tools, 2nd edition de Alfred
V. Aho, Monica S. Lam Ravi Sethi, Jefrey D. Ullman, publicada por Pearson Education, Inc., publicada como AddisonWesley, Copyright ©2007. Todos los derechos reservados.
Esta edición en español es la única autorizada.
Edición en español
Editor:
Luis Miguel Cruz Castillo
e-mail: luis.cruz@pearsoned.com
Editor de desarrollo:
Bernardino Gutiérrez Hernández
Supervisor de producción:
Enrique Trejo Hernández
Edición en inglés
Publisher
Greg Tobin
Executive Editor
Michael Hirsch
Acquisitions Editor
Matt Goldstein
Project Editor
Katherine Harutunian
Associate Managing Editor
Jeffrey Holcomb
Cover Designer
Joyce Cosentino Wells
Digital Assets Manager
Marianne Groth
Media Producer
Bethany Tidd
Senior Marketing Manager
Michelle Brown
Marketing Assistant
Sarah Milmore
Senior Author Support/
Technology Specialist
Joe Vetere
Senior Manufacturing Buyer Carol Melville
Cover Image
Scott Ullman of Strange Tonic Productions
(www.stragetonic.com)
SEGUNDA EDICIÓN, 2008
D.R. © 2008 por Pearson Educación de México, S.A. de C.V.
Atlacomulco 500-5o. piso
Col. Industrial Atoto
53519, Naucalpan de Juárez, Edo. de México
Cámara Nacional de la Industria Editorial Mexicana. Reg. Núm. 1031.
Addison-Wesley es una marca registrada de Pearson Educación de México, S.A. de C.V.
Reservados todos los derechos. Ni la totalidad ni parte de esta publicación pueden reproducirse, registrarse o
transmitirse, por un sistema de recuperación de información, en ninguna forma ni por ningún medio, sea electrónico,
mecánico, fotoquímico, magnético o electroóptico, por fotocopia, grabación o cualquier otro, sin permiso previo por
escrito del editor.
El préstamo, alquiler o cualquier otra forma de cesión de uso de este ejemplar requerirá también la autorización del
editor o de sus representantes.
ISBN 10: 970-26-1133-4
ISBN 13: 978-970-26-1133-2
Impreso en México. Printed in Mexico.
1 2 3 4 5 6 7 8 9 0 - 11 10 09 08
®
Prefacio
Desde la primera edición de este libro, en 1986, el mundo ha cambiado en forma considerable.
Los lenguajes de programación han evolucionado para presentar nuevos problemas de compilación. Las arquitecturas computacionales ofrecen una variedad de recursos, de los cuales el
diseñador de compiladores debe sacar ventaja. Tal vez lo más interesante sea que la venerable
tecnología de la optimización de código ha encontrado un uso fuera de los compiladores. Ahora
se utiliza en herramientas que buscan errores en el software, y lo que es más importante, buscan
brechas de seguridad en el código existente. Y gran parte de la tecnología de punta (gramática, expresiones regulares, analizadores y traductores orientados a la sintaxis) tiene todavía un
amplio uso.
Por ende, la filosofía que manejamos en la edición anterior de este libro no ha cambiado.
Reconocemos que sólo unos cuantos lectores llegarán a crear (o inclusive a mantener) un compilador para un lenguaje de programación importante. Sin embargo, los modelos, la teoría y los
algoritmos asociados con un compilador pueden aplicarse a una gran variedad de problemas en
el diseño y desarrollo de software. Por lo tanto, destacamos los problemas que se encuentran
con más frecuencia durante el diseño de un procesador de lenguaje, sin importar el lenguaje
origen ni la máquina de destino.
Cómo utilizar este libro
Se requieren cuando menos dos trimestres, o incluso dos semestres, para cubrir todo el material de este libro (o al menos la mayor parte). Lo ideal es cubrir la primera mitad del texto
en un curso universitario y la segunda mitad (que hace énfasis en la optimización de código) en
otro curso, a nivel posgrado o maestría. He aquí una descripción general de los capítulos:
El capítulo 1 contiene material de motivación, y también presenta algunos puntos sobre los
antecedentes de la arquitectura computacional y los principios de los lenguajes de programación.
El capítulo 2 desarrolla un compilador en miniatura y presenta muchos de los conceptos importantes, que se desarrollarán en los capítulos siguientes. El compilador en sí aparece en el
apéndice.
v
vi
Prefacio
El capítulo 3 cubre el análisis léxico, las expresiones regulares, las máquinas de estado finito y
las herramientas para generar exploradores. Este material es fundamental para el procesamiento de texto de todo tipo.
El capítulo 4 cubre los principales métodos de análisis, de arriba-abajo (recursivo descendente,
LL) y de abajo-arriba (LR y sus variantes).
El capítulo 5 introduce las ideas principales en las definiciones y las traducciones orientadas a
la sintaxis.
El capítulo 6 toma la teoría del capítulo 5 y muestra cómo usarla para generar código intermedio para un lenguaje de programación ordinario.
El capítulo 7 cubre los entornos en tiempo de ejecución, en especial la administración de la pila
en tiempo de ejecución y la recolección de basura.
El capítulo 8 trata de la generación de código objeto. Cubre la construcción de los bloques
fundamentales, la generación de código a partir de las expresiones y los bloques básicos, así
como las técnicas de asignación de registros.
El capítulo 9 introduce la tecnología de la optimización de código, incluyendo los diagramas
de flujo, los frameworks de flujo de datos y los algoritmos iterativos para resolver estos frameworks.
El capítulo 10 cubre la optimización a nivel de instrucciones. Se destaca aquí la extracción del
paralelismo de las secuencias pequeñas de instrucciones, y cómo programarlas en procesadores
individuales que puedan realizar más de función a la vez.
El capítulo 11 habla de la detección y explotación del paralelismo a mayor escala. Aquí, se
destacan los códigos numéricos que cuentan con muchos ciclos estrechos que varían a través de
los arreglos multidimensionales.
El capítulo 12 explica el análisis entre procedimientos. Cubre el análisis y uso de alias en los
apuntadores, y el análisis de los flujos de datos que toma en cuenta la secuencia de llamadas a
procedimientos que llegan a un punto dado en el código.
En Columbia, Harvard y Stanford se han impartido cursos basados en el material de este
libro. En Columbia se ofrece de manera regular un curso a estudiantes universitarios del último
año o estudiantes postgraduados sobre los lenguajes de programación y los traductores, usando
el material de los primeros ocho capítulos. Algo que destaca de este curso es un proyecto que
dura todo un semestre, en el cual los estudiantes trabajan en pequeños equipos para crear e
implementar un pequeño lenguaje que ellos mismos diseñan. Los lenguajes que crean los estudiantes han abarcado diversos dominios de aplicaciones, incluyendo el cálculo de cuantos,
la síntesis de música, los gráficos de computadora, juegos, operaciones con matrices y muchas
otras áreas más. Los estudiantes utilizan generadores de componentes de compiladores como
ANTLR, Lex y Yacc, y las técnicas de traducción orientadas a la sintaxis que se describen en
los capítulos 2 y 5 para construir sus compiladores. También se ofrece un curso de seguimiento
para graduados, que se enfoca en el material que viene en los capítulos 9 a 12, en donde se
enfatiza la generación y optimización de código para las máquinas contemporáneas, incluyendo
los procesadores de redes y las arquitecturas con múltiples procesadores.
En Stanford, un curso introductorio de un tetramestre apenas cubre el material que viene en
los capítulos 1 a 8, aunque hay una introducción a la optimización de código global del capítulo 9.
En el segundo curso de compiladores se cubren los capítulos 9 a 12, además del material avanzado
Prefacio
vii
sobre la recolección de basura del capítulo 7. Los estudiantes utilizan un sistema llamado Joeq,
basado en Java y desarrollado por la comunidad local, para implementar los algoritmos de análisis
de los flujos de datos.
Requisitos previos
El lector debe poseer cierto “conocimiento orientado a las ciencias computacionales”, lo que
incluye por lo menos un segundo curso sobre programación, además de cursos sobre estructuras de datos y matemáticas discretas. Es útil tener un conocimiento sobre varios lenguajes de
programación.
Ejercicios
Este libro contiene gran cantidad de ejercicios para casi todas las secciones. Para indicar los
ejercicios difíciles, o partes de ellos, utilizamos un signo de admiración. Los ejercicios todavía
más difíciles tienen doble signo de admiración.
Tareas en línea de Gradiance
Una nueva característica de la segunda edición es que hay un conjunto complementario de
tareas en línea, las cuales utilizan una tecnología desarrollada por Gradiance Corp. Los instructores pueden asignar estas tareas a su clase; los estudiantes que no estén inscritos en una clase
pueden inscribirse en una “clase ómnibus” que les permita realizar las tareas como tutorial (sin
una clase creada por un instructor). Las preguntas de Gradiance tienen la misma apariencia
que las preguntas ordinarias, sólo que se muestrean sus soluciones. Si realiza una elección incorrecta, recibe asesoría, o retroalimentación, específica para ayudarle a corregir su error. Los profesores usuarios de este libro pueden utilizar Gradiance como apoyo a sus clases, y cada alumno
que compre el libro puede obtener un código para acceder a los ejercicios de su profesor. Para
mayor información consulte a su representante de Pearson Educación o visite el sitio Web de
este libro. Cabe aclarar que esta información está en inglés.
Soporte en World Wide Web
En el sitio Web de este libro:
www.pearsoneducacion.net/aho
Encontrará una fe de erratas, que actualizaremos a medida que vayamos detectando los errores,
y materiales de respaldo. Esperamos tener disponibles los apuntes para cada curso relacionado con compiladores a medida que lo impartamos, incluyendo tareas, soluciones y exámenes.
Planeamos publicar también descripciones de los compiladores importantes escritos por sus
implementadores. Cabe destacar que todo el material y este sitio se encuentran en inglés.
Agradecimientos
El arte de la portada fue realizado por S. D. Ullman de Strange Tonic Productions.
John Bentley nos proporcionó muchos comentarios sobre varios capítulos desde un borrador
anterior de este libro. Recibimos valiosos comentarios y fe de erratas de: Domenico Bianculli,
viii
Prefacio
Peter Bosch, Marcio Buss, Marc Eaddy, Stephen Edwards, Vibhav Garg, Kim Hazelwood,
Gaurav Kc, Wei Li, Mike Smith, Art Stamness, Krysta Svore, Olivier Tardieu y Jia Zeng. Agradecemos en gran medida la ayuda de todas estas personas. Desde luego, los errores restantes
son nuestros.
Además, Monica quisiera agradecer a sus colegas en el equipo del compilador SUIF por
una lección de 18 años sobre el proceso de compilación: Gerald Aigner, Dzintars Avots, Saman
Amarasinghe, Jennifer Anderson, Michael Carbin, Gerald Cheong, Amer Diwan, Robert French,
Anwar Ghuloum, Mary Hall, John Hennessy, David Heine, Shih-Wei Liao, Amy Lim, Benjamin
Livshits, Michael Martin, Dror Maydan, Todd Mowry, Brian Murphy, Jeffrey Oplinger, Karen Pieper, Martin Rinard, Olatunji Ruwase, Constantine Sapuntzakis, Patrick Sathyanathan, Michael
Smith, Steven Tjiang, Chau-Wen Tseng, Christopher Unkel, John Whaley, Robert Wilson,
Christopher Wilson, y Michael Wolf.
A. V. A., Chatham NJ
M. S. L., Menlo Park CA
R. S., Far Hills NJ
J. D. U., Stanford CA
Junio, 2006
Tabla de contenido
Prefacio
1
v
Introducción
1.1
Procesadores de lenguaje . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.1.1
Ejercicios para la sección 1.1. . . . . . . . . . . . . . . . . . . . . . .
1.2
La estructura de un compilador . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.1
Análisis de léxico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.2
Análisis sintáctico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.3
Análisis semántico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.4
Generación de código intermedio . . . . . . . . . . . . . . . . . . . .
1.2.5
Optimización de código . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.6
Generación de código . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.2.7
Administración de la tabla de símbolos . . . . . . . . . . . . . . .
1.2.8
El agrupamiento de fases en pasadas . . . . . . . . . . . . . . . . .
1.2.9
Herramientas de construcción de compiladores . . . . . . . . . .
1.3
La evolución de los lenguajes de programación . . . . . . . . . . . . . . . .
1.3.1
El avance a los lenguajes de alto nivel . . . . . . . . . . . . . . . .
1.3.2
Impactos en el compilador . . . . . . . . . . . . . . . . . . . . . . . .
1.3.3
Ejercicios para la sección 1.3. . . . . . . . . . . . . . . . . . . . . . .
1.4
La ciencia de construir un compilador . . . . . . . . . . . . . . . . . . . . . .
1.4.1
Modelado en el diseño e implementación de compiladores . .
1.4.2
La ciencia de la optimización de código . . . . . . . . . . . . . . .
1.5
Aplicaciones de la tecnología de compiladores . . . . . . . . . . . . . . . .
1.5.1
Implementación de lenguajes de programación de alto nivel .
1.5.2
Optimizaciones para las arquitecturas de computadoras . . .
1.5.3
Diseño de nuevas arquitecturas de computadoras . . . . . . . .
1.5.4
Traducciones de programas . . . . . . . . . . . . . . . . . . . . . . . .
1.5.5
Herramientas de productividad de software . . . . . . . . . . . .
1.6
Fundamentos de los lenguajes de programación . . . . . . . . . . . . . . .
1.6.1
La distinción entre estático y dinámico . . . . . . . . . . . . . . .
1.6.2
Entornos y estados . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.6.3
Alcance estático y estructura de bloques . . . . . . . . . . . . . .
1.6.4
Control de acceso explícito . . . . . . . . . . . . . . . . . . . . . . . .
1.6.5
Alcance dinámico. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
1.6.6
Mecanismos para el paso de parámetros. . . . . . . . . . . . . . .
ix
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
1
1
3
4
5
8
8
9
10
10
11
11
12
12
13
14
14
15
15
15
17
17
19
21
22
23
25
25
26
28
31
31
33
x
Tabla de contenido
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
35
35
36
38
Un traductor simple orientado a la sintaxis
2.1
Introducción . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.2
Definición de sintaxis . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.2.1
Definición de gramáticas . . . . . . . . . . . . . . . . . . .
2.2.2
Derivaciones . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.2.3
Árboles de análisis sintáctico . . . . . . . . . . . . . . . .
2.2.4
Ambigüedad . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.2.5
Asociatividad de los operadores . . . . . . . . . . . . . .
2.2.6
Precedencia de operadores . . . . . . . . . . . . . . . . . .
2.2.7
Ejercicios para la sección 2.2. . . . . . . . . . . . . . . . .
2.3
Traducción orientada a la sintaxis . . . . . . . . . . . . . . . . . . .
2.3.1
Notación postfija . . . . . . . . . . . . . . . . . . . . . . . . .
2.3.2
Atributos sintetizados . . . . . . . . . . . . . . . . . . . . .
2.3.3
Definiciones simples orientadas a la sintaxis . . . . . .
2.3.4
Recorridos de los árboles . . . . . . . . . . . . . . . . . . .
2.3.5
Esquemas de traducción . . . . . . . . . . . . . . . . . . . .
2.3.6
Ejercicios para la sección 2.3. . . . . . . . . . . . . . . . .
2.4
Análisis sintáctico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.4.1
Análisis sintáctico tipo arriba-abajo . . . . . . . . . . .
2.4.2
Análisis sintáctico predictivo. . . . . . . . . . . . . . . . .
2.4.3
Cuándo usar las producciones . . . . . . . . . . . . . .
2.4.4
Diseño de un analizador sintáctico predictivo . . . . .
2.4.5
Recursividad a la izquierda . . . . . . . . . . . . . . . . . .
2.4.6
Ejercicios para la sección 2.4. . . . . . . . . . . . . . . . .
2.5
Un traductor para las expresiones simples . . . . . . . . . . . . .
2.5.1
Sintaxis abstracta y concreta . . . . . . . . . . . . . . . .
2.5.2
Adaptación del esquema de traducción . . . . . . . . .
2.5.3
Procedimientos para los no terminales . . . . . . . . . .
2.5.4
Simplificación del traductor . . . . . . . . . . . . . . . . .
2.5.5
El programa completo . . . . . . . . . . . . . . . . . . . . .
2.6
Análisis léxico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.6.1
Eliminación de espacio en blanco y comentarios . . .
2.6.2
Lectura adelantada . . . . . . . . . . . . . . . . . . . . . . .
2.6.3
Constantes . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.6.4
Reconocimiento de palabras clave e identificadores .
2.6.5
Un analizador léxico . . . . . . . . . . . . . . . . . . . . . .
2.6.6
Ejercicios para la sección 2.6. . . . . . . . . . . . . . . . .
2.7
Tablas de símbolos . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
2.7.1
Tabla de símbolos por alcance. . . . . . . . . . . . . . . .
2.7.2
El uso de las tablas de símbolos . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
39
40
42
42
44
45
47
48
48
51
52
53
54
56
56
57
60
60
61
64
65
66
67
68
68
69
70
72
73
74
76
77
78
78
79
81
84
85
86
89
1.7
1.8
2
1.6.7
Uso de alias . . . . . . . . . . . .
1.6.8
Ejercicios para la sección 1.6.
Resumen del capítulo 1 . . . . . . . . . .
Referencias para el capítulo 1 . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
xi
Tabla de contenido
2.8
2.9
3
Generación de código intermedio. . . . . . . . . . . . .
2.8.1
Dos tipos de representaciones intermedias
2.8.2
Construcción de árboles sintácticos . . . . .
2.8.3
Comprobación estática . . . . . . . . . . . . . .
2.8.4
Código de tres direcciones . . . . . . . . . . .
2.8.5
Ejercicios para la sección 2.8. . . . . . . . . .
Resumen del capítulo 2 . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Análisis léxico
3.1
La función del analizador léxico . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.1.1
Comparación entre análisis léxico y análisis sintáctico . . . . . . . .
3.1.2
Tokens, patrones y lexemas . . . . . . . . . . . . . . . . . . . . . . . . . .
3.1.3
Atributos para los tokens . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.1.4
Errores léxicos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.1.5
Ejercicios para la sección 3.1. . . . . . . . . . . . . . . . . . . . . . . . . .
3.2
Uso de búfer en la entrada . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.2.1
Pares de búferes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.2.2
Centinelas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.3
Especificación de los tokens . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.3.1
Cadenas y lenguajes. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.3.2
Operaciones en los lenguajes . . . . . . . . . . . . . . . . . . . . . . . . . .
3.3.3
Expresiones regulares . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.3.4
Definiciones regulares . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.3.5
Extensiones de las expresiones regulares . . . . . . . . . . . . . . . . .
3.3.6
Ejercicios para la sección 3.3. . . . . . . . . . . . . . . . . . . . . . . . . .
3.4
Reconocimiento de tokens . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.4.1
Diagramas de transición de estados . . . . . . . . . . . . . . . . . . . . .
3.4.2
Reconocimiento de las palabras reservadas y los identificadores .
3.4.3
Finalización del bosquejo . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.4.4
Arquitectura de un analizador léxico basado en diagramas
de transición de estados . . . . . . . . . . . . . . . . . . . . . . . . . .
3.4.5
Ejercicios para la sección 3.4. . . . . . . . . . . . . . . . . . . . . . . . . .
3.5
El generador de analizadores léxicos Lex . . . . . . . . . . . . . . . . . . . . . . .
3.5.1
Uso de Lex . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.5.2
Estructura de los programas en Lex . . . . . . . . . . . . . . . . . . . .
3.5.3
Resolución de conflictos en Lex . . . . . . . . . . . . . . . . . . . . . . . .
3.5.4
El operador adelantado . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.5.5
Ejercicios para la sección 3.5. . . . . . . . . . . . . . . . . . . . . . . . . .
3.6
Autómatas finitos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.6.1
Autómatas finitos no deterministas . . . . . . . . . . . . . . . . . . . . .
3.6.2
Tablas de transición . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.6.3
Aceptación de las cadenas de entrada mediante los autómatas . .
3.6.4
Autómatas finitos deterministas . . . . . . . . . . . . . . . . . . . . . . .
3.6.5
Ejercicios para la sección 3.6. . . . . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
. 91
. 91
. 92
. 97
. 99
. 105
. 105
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
109
109
110
111
112
113
114
115
115
116
116
117
119
120
123
124
125
128
130
132
133
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
134
136
140
140
141
144
144
146
147
147
148
149
149
151
xii
Tabla de contenido
3.7
3.8
3.9
3.10
3.11
4
De las expresiones regulares a los autómatas . . . . . . . . . . . . . . . . . . . .
3.7.1
Conversión de un AFN a AFD . . . . . . . . . . . . . . . . . . . . . . . .
3.7.2
Simulación de un AFN . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.7.3
Eficiencia de la simulación de un AFN. . . . . . . . . . . . . . . . . . .
3.7.4
Construcción de un AFN a partir de una expresión regular . . . .
3.7.5
Eficiencia de los algoritmos de procesamiento de cadenas . . . . .
3.7.6
Ejercicios para la sección 3.7. . . . . . . . . . . . . . . . . . . . . . . . . .
Diseño de un generador de analizadores léxicos . . . . . . . . . . . . . . . . . .
3.8.1
La estructura del analizador generado . . . . . . . . . . . . . . . . . . .
3.8.2
Coincidencia de patrones con base en los AFNs . . . . . . . . . . . .
3.8.3
AFDs para analizadores léxicos . . . . . . . . . . . . . . . . . . . . . . . .
3.8.4
Implementación del operador de preanálisis . . . . . . . . . . . . . . .
3.8.5
Ejercicios para la sección 3.8. . . . . . . . . . . . . . . . . . . . . . . . . .
Optimización de los buscadores por concordancia de patrones basados
en AFD. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.9.1
Estados significativos de un AFN . . . . . . . . . . . . . . . . . . . . . .
3.9.2
Funciones calculadas a partir del árbol sintáctico . . . . . . . . . . .
3.9.3
Cálculo de anulable, primerapos y ultimapos. . . . . . . . . . . . . . .
3.9.4
Cálculo de siguientepos . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
3.9.5
Conversión directa de una expresión regular a un AFD . . . . . . .
3.9.6
Minimización del número de estados de un AFD . . . . . . . . . . .
3.9.7
Minimización de estados en los analizadores léxicos . . . . . . . . .
3.9.8
Intercambio de tiempo por espacio en la simulación de un AFD .
3.9.9
Ejercicios para la sección 3.9. . . . . . . . . . . . . . . . . . . . . . . . . .
Resumen del capítulo 3 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Referencias para el capítulo 3 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Análisis sintáctico
4.1
Introducción . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.1.1
La función del analizador sintáctico . . . . . . . . . . . . . . .
4.1.2
Representación de gramáticas . . . . . . . . . . . . . . . . . . .
4.1.3
Manejo de los errores sintácticos . . . . . . . . . . . . . . . . .
4.1.4
Estrategias para recuperarse de los errores . . . . . . . . . .
4.2
Gramáticas libres de contexto . . . . . . . . . . . . . . . . . . . . . . . . .
4.2.1
La definición formal de una gramática libre de contexto
4.2.2
Convenciones de notación . . . . . . . . . . . . . . . . . . . . . .
4.2.3
Derivaciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.2.4
Árboles de análisis sintáctico y derivaciones . . . . . . . . .
4.2.5
Ambigüedad . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.2.6
Verificación del lenguaje generado por una gramática . .
4.2.7
Comparación entre gramáticas libres de contexto
y expresiones regulares . . . . . . . . . . . . . . . . . . . . .
4.2.8
Ejercicios para la sección 4.2. . . . . . . . . . . . . . . . . . . .
4.3
Escritura de una gramática . . . . . . . . . . . . . . . . . . . . . . . . . .
4.3.1
Comparación entre análisis léxico y análisis sintáctico . .
4.3.2
Eliminación de la ambigüedad . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
152
152
156
157
159
163
166
166
167
168
170
171
172
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
173
173
175
176
177
179
180
184
185
186
187
189
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
191
192
192
193
194
195
197
197
198
199
201
203
204
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
205
206
209
209
210
xiii
Tabla de contenido
4.4
4.5
4.6
4.7
4.8
4.9
4.10
4.11
4.3.3
Eliminación de la recursividad por la izquierda. . . . . . . . . . . . . . . .
4.3.4
Factorización por la izquierda . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.3.5
Construcciones de lenguajes que no son libres de contexto . . . . . . . .
4.3.6
Ejercicios para la sección 4.3. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Análisis sintáctico descendente . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.4.1
Análisis sintáctico de descenso recursivo . . . . . . . . . . . . . . . . . . . .
4.4.2
PRIMERO y SIGUIENTE . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.4.3
Gramáticas LL(1) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.4.4
Análisis sintáctico predictivo no recursivo . . . . . . . . . . . . . . . . . . .
4.4.5
Recuperación de errores en el análisis sintáctico predictivo . . . . . . .
4.4.6
Ejercicios para la sección 4.4. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Análisis sintáctico ascendente . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.5.1
Reducciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.5.2
Poda de mangos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.5.3
Análisis sintáctico de desplazamiento-reducción . . . . . . . . . . . . . . .
4.5.4
Conflictos durante el análisis sintáctico de desplazamiento-reducción
4.5.5
Ejercicios para la sección 4.5. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Introducción al análisis sintáctico LR: SLR (LR simple) . . . . . . . . . . . . . . .
4.6.1
¿Por qué analizadores sintácticos LR? . . . . . . . . . . . . . . . . . . . . . .
4.6.2
Los elementos y el autómata LR(0) . . . . . . . . . . . . . . . . . . . . . . . .
4.6.3
El algoritmo de análisis sintáctico LR . . . . . . . . . . . . . . . . . . . . . .
4.6.4
Construcción de tablas de análisis sintáctico SLR . . . . . . . . . . . . . .
4.6.5
Prefijos viables . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.6.6
Ejercicios para la sección 4.6. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Analizadores sintácticos LR más poderosos . . . . . . . . . . . . . . . . . . . . . . . .
4.7.1
Elementos LR(1) canónicos. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.7.2
Construcción de conjuntos de elementos LR(1) . . . . . . . . . . . . . . . .
4.7.3
Tablas de análisis sintáctico LR(1) canónico. . . . . . . . . . . . . . . . . .
4.7.4
Construcción de tablas de análisis sintáctico LALR . . . . . . . . . . . .
4.7.5
Construcción eficiente de tablas de análisis sintáctico LALR . . . . . .
4.7.6
Compactación de las tablas de análisis sintáctico LR . . . . . . . . . . .
4.7.7
Ejercicios para la sección 4.7. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Uso de gramáticas ambiguas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.8.1
Precedencia y asociatividad para resolver conflictos . . . . . . . . . . . .
4.8.2
La ambigüedad del “else colgante” . . . . . . . . . . . . . . . . . . . . . . . .
4.8.3
Recuperación de errores en el análisis sintáctico LR . . . . . . . . . . . .
4.8.4
Ejercicios para la sección 4.8. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Generadores de analizadores sintácticos . . . . . . . . . . . . . . . . . . . . . . . . . . .
4.9.1
El generador de analizadores sintácticos Yacc. . . . . . . . . . . . . . . . .
4.9.2
Uso de Yacc con gramáticas ambiguas . . . . . . . . . . . . . . . . . . . . . .
4.9.3
Creación de analizadores léxicos de Yacc con Lex . . . . . . . . . . . . . .
4.9.4
Recuperación de errores en Yacc . . . . . . . . . . . . . . . . . . . . . . . . . .
4.9.5
Ejercicios para la sección 4.9. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Resumen del capítulo 4 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Referencias para el capítulo 4 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
212
214
215
216
217
219
220
222
226
228
231
233
234
235
236
238
240
241
241
242
248
252
256
257
259
260
261
265
266
270
275
277
278
279
281
283
285
287
287
291
294
295
297
297
300
xiv
5
6
Tabla de contenido
Traducción orientada por la sintaxis
5.1 Definiciones dirigidas por la sintaxis . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.1.1
Atributos heredados y sintetizados . . . . . . . . . . . . . . . . . . . . . .
5.1.2
Evaluación de una definición dirigida por la sintaxis en los
nodos de un árbol de análisis sintáctico . . . . . . . . . . . . . . . .
5.1.3
Ejercicios para la sección 5.1. . . . . . . . . . . . . . . . . . . . . . . . . . .
5.2
Órdenes de evaluación para las definiciones dirigidas por la sintaxis . . . .
5.2.1
Gráficos de dependencias . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.2.2
Orden de evaluación . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.2.3
Definiciones con atributos sintetizados . . . . . . . . . . . . . . . . . . . .
5.2.4
Definiciones con atributos heredados . . . . . . . . . . . . . . . . . . . . .
5.2.5
Reglas semánticas con efectos adicionales controlados . . . . . . . . .
5.2.6
Ejercicios para la sección 5.2. . . . . . . . . . . . . . . . . . . . . . . . . . .
5.3
Aplicaciones de la traducción orientada por la sintaxis . . . . . . . . . . . . . .
5.3.1
Construcción de árboles de análisis sintáctico . . . . . . . . . . . . . . .
5.3.2
La estructura de tipos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.3.3
Ejercicios para la sección 5.3. . . . . . . . . . . . . . . . . . . . . . . . . . .
5.4
Esquemas de traducción orientados por la sintaxis . . . . . . . . . . . . . . . . .
5.4.1
Esquemas de traducción postfijos . . . . . . . . . . . . . . . . . . . . . . .
5.4.2
Implementación de esquemas de traducción orientados
a la sintaxis postfijo con la pila del analizador sintáctico . . . .
5.4.3
Esquemas de traducción orientados a la sintaxis con acciones
dentro de las producciones . . . . . . . . . . . . . . . . . . . . . . . . .
5.4.4
Eliminación de la recursividad por la izquierda de los esquemas
de traducción orientados a la sintaxis . . . . . . . . . . . . . . . . .
5.4.5
Esquemas de traducción orientados a la sintaxis para definiciones
con atributos heredados por la izquierda . . . . . . . . . . . . . . .
5.4.6
Ejercicios para la sección 5.4. . . . . . . . . . . . . . . . . . . . . . . . . . .
5.5
Implementación de definiciones dirigidas por la sintaxis con atributos
heredados por la izquierda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.5.1
Traducción durante el análisis sintáctico de descenso recursivo. . .
5.5.2
Generación de código al instante . . . . . . . . . . . . . . . . . . . . . . . .
5.5.3
Las definiciones dirigidas por la sintaxis con atributos heredados
por la izquierda y el análisis sintáctico LL . . . . . . . . . . . . . .
5.5.4
Análisis sintáctico ascendente de las definiciones dirigidas por
la sintaxis con atributos heredados por la izquierda . . . . . . . .
5.5.5
Ejercicios para la sección 5.5. . . . . . . . . . . . . . . . . . . . . . . . . . .
5.6
Resumen del capítulo 5 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5.7
Referencias para el capítulo 5 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Generación de código intermedio
6.1
Variantes de los árboles sintácticos . . . . . . . . . . . . . . . . .
6.1.1
Grafo dirigido acíclico para las expresiones . . . . .
6.1.2
El método número de valor para construir GDAs .
6.1.3
Ejercicios para la sección 6.1. . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
303
. . . 304
. . . 304
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
306
309
310
310
312
312
313
314
317
318
318
321
323
323
324
. . . 325
. . . 327
. . . 328
. . . 331
. . . 336
. . . 337
. . . 338
. . . 340
. . . 343
.
.
.
.
.
.
.
.
.
.
.
.
348
352
353
354
.
.
.
.
.
.
.
.
.
.
.
.
357
358
359
360
362
xv
Tabla de contenido
6.2
6.3
6.4
6.5
6.6
6.7
6.8
Código de tres direcciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.2.1
Direcciones e instrucciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.2.2
Cuádruplos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.2.3
Tripletas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.2.4
Forma de asignación individual estática . . . . . . . . . . . . . . . . . . . . .
6.2.5
Ejercicios para la sección 6.2. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Tipos y declaraciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.3.1
Expresiones de tipos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.3.2
Equivalencia de tipos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.3.3
Declaraciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.3.4
Distribución del almacenamiento para los nombres locales . . . . . . . .
6.3.5
Secuencias de las declaraciones . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.3.6
Campos en registros y clases . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.3.7
Ejercicios para la sección 6.3. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Traducción de expresiones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.4.1
Operaciones dentro de expresiones . . . . . . . . . . . . . . . . . . . . . . . .
6.4.2
Traducción incremental . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.4.3
Direccionamiento de los elementos de un arreglo. . . . . . . . . . . . . . .
6.4.4
Traducción de referencias a arreglos . . . . . . . . . . . . . . . . . . . . . . .
6.4.5
Ejercicios para la sección 6.4. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Comprobación de tipos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.5.1
Reglas para la comprobación de tipos . . . . . . . . . . . . . . . . . . . . . .
6.5.2
Conversiones de tipos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.5.3
Sobrecarga de funciones y operadores . . . . . . . . . . . . . . . . . . . . . .
6.5.4
Inferencia de tipos y funciones polimórficas . . . . . . . . . . . . . . . . . .
6.5.5
Un algoritmo para la unificación . . . . . . . . . . . . . . . . . . . . . . . . . .
6.5.6
Ejercicios para la sección 6.5. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Flujo de control. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.6.1
Expresiones booleanas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.6.2
Código de corto circuito . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.6.3
Instrucciones de f lujo de control . . . . . . . . . . . . . . . . . . . . . . . . . .
6.6.4
Traducción del flujo de control de las expresiones booleanas . . . . . .
6.6.5
Evitar gotos redundantes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.6.6
Valores boleanos y código de salto . . . . . . . . . . . . . . . . . . . . . . . . .
6.6.7
Ejercicios para la sección 6.6. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Parcheo de retroceso (backpatch) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.7.1
Generación de código de una pasada, mediante parcheo de retroceso
6.7.2
Técnica de parcheo de retroceso para las expresiones booleanas . . . .
6.7.3
Instrucciones de flujo de control . . . . . . . . . . . . . . . . . . . . . . . . . .
6.7.4
Instrucciones break, continue y goto . . . . . . . . . . . . . . . . . . . . . . .
6.7.5
Ejercicios para la sección 6.7. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Instrucciones switch . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
6.8.1
Traducción de las instrucciones switch . . . . . . . . . . . . . . . . . . . . . .
6.8.2
Traducción orientada por la sintaxis de las instrucciones switch . . . .
6.8.3
Ejercicios para la sección 6.8. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
363
364
366
367
369
370
370
371
372
373
373
376
376
378
378
378
380
381
383
384
386
387
388
390
391
395
398
399
399
400
401
403
405
408
408
410
410
411
413
416
417
418
419
420
421
xvi
Tabla de contenido
6.9
6.10
6.11
7
Código intermedio para procedimientos . . . . . . . . . . . . . . . . . . . . . . . . . . . . 422
Resumen del capítulo 6 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 424
Referencias para el capítulo 6 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 425
Entornos en tiempo de ejecución
7.1
Organización del almacenamiento . . . . . . . . . . . . . . . . . . . . . . . .
7.1.1
Asignación de almacenamiento estática y dinámica . . . . . .
7.2
Asignación de espacio en la pila . . . . . . . . . . . . . . . . . . . . . . . . .
7.2.1
Árboles de activación . . . . . . . . . . . . . . . . . . . . . . . . . . .
7.2.2
Registros de activación . . . . . . . . . . . . . . . . . . . . . . . . . .
7.2.3
Secuencias de llamadas. . . . . . . . . . . . . . . . . . . . . . . . . .
7.2.4
Datos de longitud variable en la pila . . . . . . . . . . . . . . . .
7.2.5
Ejercicios para la sección 7.2. . . . . . . . . . . . . . . . . . . . . .
7.3
Acceso a los datos no locales en la pila . . . . . . . . . . . . . . . . . . . .
7.3.1
Acceso a los datos sin procedimientos anidados . . . . . . . .
7.3.2
Problemas con los procedimientos anidados . . . . . . . . . . .
7.3.3
Un lenguaje con declaraciones de procedimientos anidados
7.3.4
Profundidad de anidamiento . . . . . . . . . . . . . . . . . . . . . .
7.3.5
Enlace de acceso . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
7.3.6
Manipulación de los enlaces de acceso . . . . . . . . . . . . . . .
7.3.7
Enlaces de acceso para los parámetros de procedimientos .
7.3.8
Estructura de datos Display . . . . . . . . . . . . . . . . . . . . . .
7.3.9
Ejercicios para la sección 7.3. . . . . . . . . . . . . . . . . . . . . .
7.4
Administración del montículo . . . . . . . . . . . . . . . . . . . . . . . . . . .
7.4.1
El administrador de memoria . . . . . . . . . . . . . . . . . . . . .
7.4.2
La jerarquía de memoria de una computadora . . . . . . . . .
7.4.3
Localidad en los programas . . . . . . . . . . . . . . . . . . . . . .
7.4.4
Reducción de la fragmentación . . . . . . . . . . . . . . . . . . . .
7.4.5
Solicitudes de desasignación manual . . . . . . . . . . . . . . . .
7.4.6
Ejercicios para la sección 7.4. . . . . . . . . . . . . . . . . . . . . .
7.5
Introducción a la recolección de basura . . . . . . . . . . . . . . . . . . . .
7.5.1
Metas de diseño para los recolectores de basura . . . . . . . .
7.5.2
Capacidad de alcance . . . . . . . . . . . . . . . . . . . . . . . . . . .
7.5.3
Recolectores de basura con conteo de referencias . . . . . . .
7.5.4
Ejercicios para la sección 7.5. . . . . . . . . . . . . . . . . . . . . .
7.6
Introducción a la recolección basada en el rastreo . . . . . . . . . . . . .
7.6.1
Un recolector básico “marcar y limpiar” . . . . . . . . . . . . .
7.6.2
Abstracción básica . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
7.6.3
Optimización de “marcar y limpiar” . . . . . . . . . . . . . . . .
7.6.4
Recolectores de basura “marcar y compactar” . . . . . . . . .
7.6.5
Recolectores de copia . . . . . . . . . . . . . . . . . . . . . . . . . . .
7.6.6
Comparación de costos . . . . . . . . . . . . . . . . . . . . . . . . . .
7.6.7
Ejercicios para la sección 7.6. . . . . . . . . . . . . . . . . . . . . .
7.7
Recolección de basura de pausa corta . . . . . . . . . . . . . . . . . . . . .
7.7.1
Recolección de basura incremental . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
427
427
429
430
430
433
436
438
440
441
442
442
443
443
445
447
448
449
451
452
453
454
455
457
460
463
463
464
466
468
470
470
471
473
475
476
478
482
482
483
483
xvii
Tabla de contenido
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
485
487
488
490
493
494
495
497
498
498
499
500
502
Generación de código
8.1
Cuestiones sobre el diseño de un generador de código . . . . . . . . . .
8.1.1
Entrada del generador de código . . . . . . . . . . . . . . . . . . .
8.1.2
El programa destino. . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.1.3
Selección de instrucciones . . . . . . . . . . . . . . . . . . . . . . . .
8.1.4
Asignación de registros. . . . . . . . . . . . . . . . . . . . . . . . . .
8.1.5
Orden de evaluación . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.2
El lenguaje destino . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.2.1
Un modelo simple de máquina destino . . . . . . . . . . . . . . .
8.2.2
Costos del programa y las instrucciones . . . . . . . . . . . . . .
8.2.3
Ejercicios para la sección 8.2. . . . . . . . . . . . . . . . . . . . . .
8.3
Direcciones en el código destino . . . . . . . . . . . . . . . . . . . . . . . . .
8.3.1
Asignación estática . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.3.2
Asignación de pila . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.3.3
Direcciones para los nombres en tiempo de ejecución . . . .
8.3.4
Ejercicios para la sección 8.3. . . . . . . . . . . . . . . . . . . . . .
8.4
Bloques básicos y grafos de flujo . . . . . . . . . . . . . . . . . . . . . . . . .
8.4.1
Bloques básicos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.4.2
Información de siguiente uso . . . . . . . . . . . . . . . . . . . . . .
8.4.3
Grafos de flujo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.4.4
Representación de los grafos de flujo . . . . . . . . . . . . . . . .
8.4.5
Ciclos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.4.6
Ejercicios para la sección 8.4. . . . . . . . . . . . . . . . . . . . . .
8.5
Optimización de los bloques básicos . . . . . . . . . . . . . . . . . . . . . .
8.5.1
La representación en GDA de los bloques básicos . . . . . . .
8.5.2
Búsqueda de subexpresiones locales comunes . . . . . . . . . .
8.5.3
Eliminación del código muerto . . . . . . . . . . . . . . . . . . . .
8.5.4
El uso de identidades algebraicas . . . . . . . . . . . . . . . . . .
8.5.5
Representación de referencias a arreglos . . . . . . . . . . . . . .
8.5.6
Asignaciones de apuntadores y llamadas a procedimientos .
8.5.7
Reensamblado de los bloques básicos a partir de los GDAs
8.5.8
Ejercicios para la sección 8.5. . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
505
506
507
507
508
510
511
512
512
515
516
518
518
520
522
524
525
526
528
529
530
531
531
533
533
534
535
536
537
539
539
541
7.8
7.9
7.10
8
7.7.2
Análisis de capacidad alcance incremental . . . . . . .
7.7.3
Fundamentos de la recolección parcial . . . . . . . . . .
7.7.4
Recolección de basura generacional . . . . . . . . . . . .
7.7.5
El algoritmo del tren . . . . . . . . . . . . . . . . . . . . . .
7.7.6
Ejercicios para la sección 7.7. . . . . . . . . . . . . . . . .
Temas avanzados sobre la recolección de basura . . . . . . . . .
7.8.1
Recolección de basura paralela y concurrente . . . . .
7.8.2
Reubicación parcial de objetos . . . . . . . . . . . . . . .
7.8.3
Recolección conservadora para lenguajes inseguros .
7.8.4
Referencias débiles . . . . . . . . . . . . . . . . . . . . . . . .
7.8.5
Ejercicios para la sección 7.8. . . . . . . . . . . . . . . . .
Resumen del capítulo 7 . . . . . . . . . . . . . . . . . . . . . . . . . .
Referencias para el capítulo 7 . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
xviii
8.6
8.7
8.8
8.9
8.10
8.11
8.12
8.13
9
Tabla de contenido
Un generador de código simple . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.6.1
Descriptores de registros y direcciones . . . . . . . . . . . . . . . . . . . . . . .
8.6.2
El algoritmo de generación de código . . . . . . . . . . . . . . . . . . . . . . . .
8.6.3
Diseño de la función obtenReg. . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.6.4
Ejercicios para la sección 8.6. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Optimización de mirilla (peephole) . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.7.1
Eliminación de instrucciones redundantes de carga y almacenamiento
8.7.2
Eliminación de código inalcanzable . . . . . . . . . . . . . . . . . . . . . . . . .
8.7.3
Optimizaciones del flujo de control . . . . . . . . . . . . . . . . . . . . . . . . .
8.7.4
Simplificación algebraica y reducción por fuerza . . . . . . . . . . . . . . . .
8.7.5
Uso de características específicas de máquina . . . . . . . . . . . . . . . . . .
8.7.6
Ejercicios para la sección 8.7. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Repartición y asignación de registros . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.8.1
Repartición global de registros . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.8.2
Conteos de uso . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.8.3
Asignación de registros para ciclos externos . . . . . . . . . . . . . . . . . . .
8.8.4
Asignación de registros mediante la coloración de grafos . . . . . . . . . .
8.8.5
Ejercicios para la sección 8.8. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Selección de instrucciones mediante la rescritura de árboles . . . . . . . . . . . . . .
8.9.1
Esquemas de traducción de árboles . . . . . . . . . . . . . . . . . . . . . . . . .
8.9.2
Generación de código mediante el revestimiento de un árbol
de entrada . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.9.3
Coincidencias de los patrones mediante el análisis sintáctico . . . . . . .
8.9.4
Rutinas para la comprobación semántica . . . . . . . . . . . . . . . . . . . . .
8.9.5
Proceso general para igualar árboles . . . . . . . . . . . . . . . . . . . . . . . .
8.9.6
Ejercicios para la sección 8.9. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Generación de código óptimo para las expresiones . . . . . . . . . . . . . . . . . . . .
8.10.1 Números de Ershov . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.10.2 Generación de código a partir de árboles de expresión etiquetados . . .
8.10.3 Evaluación de expresiones con un suministro insuficiente de registros .
8.10.4 Ejercicios para la sección 8.10 . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Generación de código de programación dinámica . . . . . . . . . . . . . . . . . . . . .
8.11.1 Evaluación contigua . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
8.11.2 El algoritmo de programación dinámica . . . . . . . . . . . . . . . . . . . . . .
8.11.3 Ejercicios para la sección 8.11 . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Resumen del capítulo 8 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Referencias para el capítulo 8 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Optimizaciones independientes de la máquina
9.1
Las fuentes principales de optimización . . . . . . . . . . .
9.1.1
Causas de redundancia . . . . . . . . . . . . . . . . .
9.1.2
Un ejemplo abierto: Quicksort . . . . . . . . . . .
9.1.3
Transformaciones que preservan la semántica .
9.1.4
Subexpresiones comunes globales . . . . . . . . .
9.1.5
Propagación de copias . . . . . . . . . . . . . . . . .
9.1.6
Eliminación de código muerto . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
542
543
544
547
548
549
550
550
551
552
552
553
553
553
554
556
556
557
558
558
560
563
565
565
567
567
567
568
570
572
573
574
575
577
578
579
583
584
584
585
586
588
590
591
Tabla de contenido
9.2
9.3
9.4
9.5
9.6
9.1.7
Movimiento de código . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.1.8
Variables de inducción y reducción en fuerza . . . . . . . . . . . . . . . . . .
9.1.9
Ejercicios para la sección 9.1. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Introducción al análisis del flujo de datos . . . . . . . . . . . . . . . . . . . . . . . . . .
9.2.1
La abstracción del flujo de datos . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.2.2
El esquema del análisis del flujo de datos . . . . . . . . . . . . . . . . . . . . .
9.2.3
Esquemas del flujo de datos en bloques básicos . . . . . . . . . . . . . . . .
9.2.4
Definiciones de alcance . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.2.5
Análisis de variables vivas . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.2.6
Expresiones disponibles . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.2.7
Resumen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.2.8
Ejercicios para la sección 9.2. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Fundamentos del análisis del flujo de datos . . . . . . . . . . . . . . . . . . . . . . . . .
9.3.1
Semi-lattices . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.3.2
Funciones de transferencia . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.3.3
El algoritmo iterativo para los marcos de trabajo generales . . . . . . . .
9.3.4
Significado de una solución de un flujo de datos . . . . . . . . . . . . . . . .
9.3.5
Ejercicios para la sección 9.3. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Propagación de constantes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.4.1
Valores del flujo de datos para el marco de trabajo de propagación
de constantes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.4.2
La reunión para el marco de trabajo de propagación de constantes . .
9.4.3
Funciones de transferencia para el marco de trabajo de propagación
de constantes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.4.4
Monotonía en el marco de trabajo de propagación de constantes . . . .
9.4.5
La distributividad del marco de trabajo de propagación de constantes
9.4.6
Interpretación de los resultados . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.4.7
Ejercicios para la sección 9.4. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Eliminación de redundancia parcial . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.5.1
Los orígenes de la redundancia . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.5.2
¿Puede eliminarse toda la redundancia? . . . . . . . . . . . . . . . . . . . . . .
9.5.3
El problema del movimiento de código diferido . . . . . . . . . . . . . . . . .
9.5.4
Anticipación de las expresiones . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.5.5
El algoritmo de movimiento de código diferido . . . . . . . . . . . . . . . . .
9.5.6
Ejercicios para la sección 9.5. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Ciclos en los grafos de flujo . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.6.1
Dominadores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.6.2
Ordenamiento “primero en profundidad” . . . . . . . . . . . . . . . . . . . . .
9.6.3
Aristas en un árbol de expansión con búsqueda en profundidad . . . . .
9.6.4
Aristas posteriores y capacidad de reducción . . . . . . . . . . . . . . . . . .
9.6.5
Profundidad de un grafo de flujo . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.6.6
Ciclos naturales . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.6.7
Velocidad de convergencia de los algoritmos de flujos de datos
iterativos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.6.8
Ejercicios para la sección 9.6. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
xix
592
592
596
597
597
599
600
601
608
610
614
615
618
618
623
626
628
631
632
633
633
634
635
635
637
637
639
639
642
644
645
646
655
655
656
660
661
662
665
665
667
669
xx
Tabla de contenido
9.7
9.8
9.9
9.10
Análisis basado en regiones . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.7.1
Regiones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.7.2
Jerarquías de regiones para grafos de flujo reducibles . . . .
9.7.3
Generalidades de un análisis basado en regiones . . . . . . . .
9.7.4
Suposiciones necesarias sobre las funciones transformación
9.7.5
Un algoritmo para el análisis basado en regiones . . . . . . .
9.7.6
Manejo de grafos de flujo no reducibles . . . . . . . . . . . . . .
9.7.7
Ejercicios para la sección 9.7. . . . . . . . . . . . . . . . . . . . . .
Análisis simbólico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
9.8.1
Expresiones afines de las variables de referencia . . . . . . . .
9.8.2
Formulación del problema de flujo de datos . . . . . . . . . . .
9.8.3
Análisis simbólico basado en regiones . . . . . . . . . . . . . . .
9.8.4
Ejercicios para la sección 9.8. . . . . . . . . . . . . . . . . . . . . .
Resumen del capítulo 9 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Referencias para el capítulo 9 . . . . . . . . . . . . . . . . . . . . . . . . . . .
10 Paralelismo a nivel de instrucción
10.1 Arquitecturas de procesadores . . . . . . . . . . . . . . . . . . . . . . . . .
10.1.1 Canalizaciones de instrucciones y retrasos de bifurcación .
10.1.2 Ejecución canalizada . . . . . . . . . . . . . . . . . . . . . . . . . .
10.1.3 Emisión de varias instrucciones . . . . . . . . . . . . . . . . . . .
10.2 Restricciones de la programación del código . . . . . . . . . . . . . . . .
10.2.1 Dependencia de datos . . . . . . . . . . . . . . . . . . . . . . . . .
10.2.2 Búsqueda de dependencias entre accesos a memoria . . . .
10.2.3 Concesiones entre el uso de registros y el paralelismo . . .
10.2.4 Ordenamiento de fases entre la asignación de registros
y la programación de código . . . . . . . . . . . . . . . . . .
10.2.5 Dependencia del control . . . . . . . . . . . . . . . . . . . . . . . .
10.2.6 Soporte de ejecución especulativa . . . . . . . . . . . . . . . . .
10.2.7 Un modelo de máquina básico. . . . . . . . . . . . . . . . . . . .
10.2.8 Ejercicios para la sección 10.2 . . . . . . . . . . . . . . . . . . . .
10.3 Programación de bloques básicos . . . . . . . . . . . . . . . . . . . . . . .
10.3.1 Grafos de dependencia de datos . . . . . . . . . . . . . . . . . .
10.3.2 Programación por lista de bloques básicos . . . . . . . . . . .
10.3.3 Órdenes topológicos priorizados . . . . . . . . . . . . . . . . . .
10.3.4 Ejercicios para la sección 10.3 . . . . . . . . . . . . . . . . . . . .
10.4 Programación de código global . . . . . . . . . . . . . . . . . . . . . . . . .
10.4.1 Movimiento de código primitivo . . . . . . . . . . . . . . . . . .
10.4.2 Movimiento de código hacia arriba . . . . . . . . . . . . . . . .
10.4.3 Movimiento de código hacia abajo. . . . . . . . . . . . . . . . .
10.4.4 Actualización de las dependencias de datos . . . . . . . . . .
10.4.5 Algoritmos de programación global . . . . . . . . . . . . . . . .
10.4.6 Técnicas avanzadas de movimiento de código . . . . . . . . .
10.4.7 Interacción con los programadores dinámicos . . . . . . . . .
10.4.8 Ejercicios para la sección 10.4 . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
672
672
673
676
678
680
684
686
686
687
689
694
699
700
703
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
707
708
708
709
710
710
711
712
713
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
716
716
717
719
720
721
722
723
725
726
727
728
730
731
732
732
736
737
737
xxi
Tabla de contenido
10.5
10.6
10.7
Canalización por software. . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
10.5.1 Introducción . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
10.5.2 Canalización de los ciclos mediante software. . . . . . . . . . .
10.5.3 Asignación de recursos y generación de código . . . . . . . . .
10.5.4 Ciclos de ejecución cruzada . . . . . . . . . . . . . . . . . . . . . .
10.5.5 Objetivos y restricciones de la canalización por software . .
10.5.6 Un algoritmo de canalización por software . . . . . . . . . . . .
10.5.7 Programación de grafos de dependencia de datos acíclicos .
10.5.8 Programación de grafos de dependencia cíclicos . . . . . . . .
10.5.9 Mejoras a los algoritmos de canalización . . . . . . . . . . . . .
10.5.10 Expansión modular de variables . . . . . . . . . . . . . . . . . . .
10.5.11 Instrucciones condicionales . . . . . . . . . . . . . . . . . . . . . . .
10.5.12 Soporte de hardware para la canalización por software . . .
10.5.13 Ejercicios para la sección 10.5 . . . . . . . . . . . . . . . . . . . . .
Resumen del capítulo 10 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Referencias para el capítulo 10 . . . . . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
11 Optimización para el paralelismo y la localidad
11.1 Conceptos básicos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.1.1 Multiprocesadores . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.1.2 Paralelismo en las aplicaciones . . . . . . . . . . . . . . . . . . . . . . . . .
11.1.3 Paralelismo a nivel de ciclo . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.1.4 Localidad de los datos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.1.5 Introducción a la teoría de la transformación afín . . . . . . . . . . . .
11.2 Multiplicación de matrices: un ejemplo detallado . . . . . . . . . . . . . . . . . .
11.2.1 El algoritmo de multiplicación de matrices . . . . . . . . . . . . . . . . .
11.2.2 Optimizaciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.2.3 Interferencia de la caché . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.2.4 Ejercicios para la sección 11.2 . . . . . . . . . . . . . . . . . . . . . . . . . .
11.3 Espacios de iteraciones . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.3.1 Construcción de espacios de iteraciones a partir de anidamientos
de ciclos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.3.2 Orden de ejecución para los anidamientos de ciclos . . . . . . . . . . .
11.3.3 Formulación de matrices de desigualdades . . . . . . . . . . . . . . . . .
11.3.4 Incorporación de constantes simbólicas . . . . . . . . . . . . . . . . . . .
11.3.5 Control del orden de ejecución . . . . . . . . . . . . . . . . . . . . . . . . .
11.3.6 Cambio de ejes . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.3.7 Ejercicios para la sección 11.3 . . . . . . . . . . . . . . . . . . . . . . . . . .
11.4 Índices de arreglos afines . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.4.1 Accesos afines . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.4.2 Accesos afines y no afines en la práctica . . . . . . . . . . . . . . . . . .
11.4.3 Ejercicios para la sección 11.4 . . . . . . . . . . . . . . . . . . . . . . . . . .
11.5 Reutilización de datos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.5.1 Tipos de reutilización . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.5.2 Reutilización propia . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
738
738
740
743
743
745
749
749
751
758
758
761
762
763
765
766
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
769
771
772
773
775
777
778
782
782
785
788
788
788
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
788
791
791
793
793
798
799
801
802
803
804
804
805
806
xxii
Tabla de contenido
11.5.3 Reutilización espacial propia . . . . . . . . . . . . . . . . . . . . . . . . .
11.5.4 Reutilización de grupo . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.5.5 Ejercicios para la sección 11.5 . . . . . . . . . . . . . . . . . . . . . . . .
11.6 Análisis de dependencias de datos de arreglos . . . . . . . . . . . . . . . . . .
11.6.1 Definición de la dependencia de datos de los accesos a arreglos
11.6.2 Programación lineal entera . . . . . . . . . . . . . . . . . . . . . . . . . .
11.6.3 La prueba del GCD . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.6.4 Heurística para resolver programas lineales enteros . . . . . . . . .
11.6.5 Solución de programas lineales enteros generales. . . . . . . . . . .
11.6.6 Resumen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.6.7 Ejercicios para la sección 11.6 . . . . . . . . . . . . . . . . . . . . . . . .
11.7 Búsqueda del paralelismo sin sincronización . . . . . . . . . . . . . . . . . . . .
11.7.1 Un ejemplo introductorio . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.7.2 Particionamientos de espacio afín . . . . . . . . . . . . . . . . . . . . .
11.7.3 Restricciones de partición de espacio . . . . . . . . . . . . . . . . . . .
11.7.4 Resolución de restricciones de partición de espacio . . . . . . . . .
11.7.5 Un algoritmo simple de generación de código . . . . . . . . . . . . .
11.7.6 Eliminación de iteraciones vacías. . . . . . . . . . . . . . . . . . . . . .
11.7.7 Eliminación de las pruebas de los ciclos más internos . . . . . . .
11.7.8 Transformaciones del código fuente . . . . . . . . . . . . . . . . . . . .
11.7.9 Ejercicios para la sección 11.7 . . . . . . . . . . . . . . . . . . . . . . . .
11.8 Sincronización entre ciclos paralelos . . . . . . . . . . . . . . . . . . . . . . . . .
11.8.1 Un número constante de sincronizaciones . . . . . . . . . . . . . . . .
11.8.2 Grafos de dependencias del programa . . . . . . . . . . . . . . . . . .
11.8.3 Tiempo jerárquico . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.8.4 El algoritmo de paralelización . . . . . . . . . . . . . . . . . . . . . . . .
11.8.5 Ejercicios para la sección 11.8 . . . . . . . . . . . . . . . . . . . . . . . .
11.9 Canalizaciones. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.9.1 ¿Qué es la canalización? . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.9.2 Sobrerrelajación sucesiva (Succesive Over-Relaxation, SOR):
un ejemplo. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.9.3 Ciclos completamente permutables . . . . . . . . . . . . . . . . . . . .
11.9.4 Canalización de ciclos completamente permutables . . . . . . . . .
11.9.5 Teoría general . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.9.6 Restricciones de partición de tiempo . . . . . . . . . . . . . . . . . . .
11.9.7 Resolución de restricciones de partición de tiempo mediante
el Lema de Farkas . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.9.8 Transformaciones de código . . . . . . . . . . . . . . . . . . . . . . . . .
11.9.9 Paralelismo con sincronización mínima . . . . . . . . . . . . . . . . .
11.9.10 Ejercicios para la sección 11.9 . . . . . . . . . . . . . . . . . . . . . . . .
11.10 Optimizaciones de localidad . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.10.1 Localidad temporal de los datos calculados . . . . . . . . . . . . . .
11.10.2 Contracción de arreglos . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11.10.3 Intercalación de particiones . . . . . . . . . . . . . . . . . . . . . . . . .
11.10.4 Reunión de todos los conceptos . . . . . . . . . . . . . . . . . . . . . . .
11.10.5 Ejercicios para la sección 11.10 . . . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
809
811
814
815
816
817
818
820
823
825
826
828
828
830
831
835
838
841
844
846
851
853
853
854
857
859
860
861
861
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
863
864
864
867
868
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
872
875
880
882
884
885
885
887
890
892
xxiii
Tabla de contenido
11.11 Otros usos de las transformaciones afines. . . . . . . . . . . .
11.11.1 Máquinas con memoria distribuida . . . . . . . . . .
11.11.2 Procesadores que emiten múltiples instrucciones .
11.11.3 Instrucciones con vectores y SIMD . . . . . . . . . .
11.11.4 Preobtención . . . . . . . . . . . . . . . . . . . . . . . . . .
11.12 Resumen del capítulo 11 . . . . . . . . . . . . . . . . . . . . . . .
11.13 Referencias para el capítulo 11 . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
893
894
895
895
896
897
899
12 Análisis interprocedural
12.1 Conceptos básicos . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
12.1.1 Grafos de llamadas . . . . . . . . . . . . . . . . . . . . . . .
12.1.2 Sensibilidad al contexto . . . . . . . . . . . . . . . . . . . .
12.1.3 Cadenas de llamadas . . . . . . . . . . . . . . . . . . . . . .
12.1.4 Análisis sensible al contexto basado en la clonación
12.1.5 Análisis sensible al contexto basado en el resumen .
12.1.6 Ejercicios para la sección 12.1 . . . . . . . . . . . . . . . .
12.2 ¿Por qué análisis interprocedural? . . . . . . . . . . . . . . . . . . .
12.2.1 Invocación de métodos virtuales . . . . . . . . . . . . . .
12.2.2 Análisis de alias de apuntadores . . . . . . . . . . . . . .
12.2.3 Paralelización . . . . . . . . . . . . . . . . . . . . . . . . . . .
12.2.4 Detección de errores de software y vulnerabilidades
12.2.5 Inyección de código SQL . . . . . . . . . . . . . . . . . . .
12.2.6 Desbordamiento de búfer . . . . . . . . . . . . . . . . . . .
12.3 Una representación lógica del flujo de datos . . . . . . . . . . . .
12.3.1 Introducción a Datalog. . . . . . . . . . . . . . . . . . . . .
12.3.2 Reglas de Datalog . . . . . . . . . . . . . . . . . . . . . . . .
12.3.3 Predicados intensionales y extensionales . . . . . . . .
12.3.4 Ejecución de programas en Datalog . . . . . . . . . . . .
12.3.5 Evaluación incremental de programas en Datalog . .
12.3.6 Reglas problemáticas en Datalog . . . . . . . . . . . . . .
12.3.7 Ejercicios para la sección 12.3 . . . . . . . . . . . . . . . .
12.4 Un algoritmo simple de análisis de apuntadores . . . . . . . . .
12.4.1 Por qué es difícil el análisis de apuntadores . . . . . .
12.4.2 Un modelo para apuntadores y referencias . . . . . . .
12.4.3 Insensibilidad al flujo . . . . . . . . . . . . . . . . . . . . . .
12.4.4 La formulación en Datalog . . . . . . . . . . . . . . . . . .
12.4.5 Uso de la información de los tipos . . . . . . . . . . . . .
12.4.6 Ejercicios para la sección 12.4 . . . . . . . . . . . . . . . .
12.5 Análisis interprocedural insensible al contexto . . . . . . . . . .
12.5.1 Efectos de la invocación a un método . . . . . . . . . .
12.5.2 Descubrimiento del grafo de llamadas en Datalog . .
12.5.3 Carga dinámica y reflexión . . . . . . . . . . . . . . . . . .
12.5.4 Ejercicios para la sección 12.5 . . . . . . . . . . . . . . . .
12.6 Análisis de apuntadores sensible al contexto . . . . . . . . . . .
12.6.1 Contextos y cadenas de llamadas . . . . . . . . . . . . .
12.6.2 Agregar contexto a las reglas de Datalog . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
903
904
904
906
908
910
911
914
916
916
917
917
917
918
920
921
921
922
924
927
928
930
932
933
934
935
936
937
938
939
941
941
943
944
945
945
946
949
xxiv
12.7
12.8
12.9
A
B
Tabla de contenido
12.6.3 Observaciones adicionales acerca de la sensibilidad .
12.6.4 Ejercicios para la sección 12.6 . . . . . . . . . . . . . . . .
Implementación en Datalog mediante BDDs . . . . . . . . . . .
12.7.1 Diagramas de decisiones binarios. . . . . . . . . . . . . .
12.7.2 Transformaciones en BDDs . . . . . . . . . . . . . . . . . .
12.7.3 Representación de las relaciones mediante BDDs . .
12.7.4 Operaciones relacionales como operaciones BDD . .
12.7.5 Uso de BDDs para el análisis tipo “apunta a” . . . .
12.7.6 Ejercicios para la sección 12.7 . . . . . . . . . . . . . . . .
Resumen del capítulo 12 . . . . . . . . . . . . . . . . . . . . . . . . .
Referencias para el capítulo 12 . . . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
949
950
951
951
953
954
954
957
958
958
961
Un front-end completo
A.1
El lenguaje de código fuente . . . . . . . . . . . . . .
A.2
Main . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
A.3
Analizador léxico . . . . . . . . . . . . . . . . . . . . . .
A.4
Tablas de símbolos y tipos . . . . . . . . . . . . . . .
A.5
Código intermedio para las expresiones . . . . . .
A.6
Código de salto para las expresiones booleanas .
A.7
Código intermedio para las instrucciones . . . . .
A.8
Analizador sintáctico . . . . . . . . . . . . . . . . . . .
A.9
Creación del front-end . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
965
965
966
967
970
971
974
978
981
986
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
Búsqueda de soluciones linealmente independientes
989
Índice
993
Capítulo 1
Introducción
Los lenguajes de programación son notaciones que describen los cálculos a las personas y las
máquinas. Nuestra percepción del mundo en que vivimos depende de los lenguajes de programación, ya que todo el software que se ejecuta en todas las computadoras se escribió en
algún lenguaje de programación. Pero antes de poder ejecutar un programa, primero debe traducirse a un formato en el que una computadora pueda ejecutarlo.
Los sistemas de software que se encargan de esta traducción se llaman compiladores.
Este libro trata acerca de cómo diseñar e implementar compiladores. Aquí descubriremos
que podemos utilizar unas cuantas ideas básicas para construir traductores para una amplia
variedad de lenguajes y máquinas. Además de los compiladores, los principios y las técnicas
para su diseño se pueden aplicar a tantos dominios distintos aparte, que es probable que un
científico computacional las reutilice muchas veces en el transcurso de su carrera profesional.
El estudio de la escritura de los compiladores se relaciona con los lenguajes de programación, la arquitectura de las máquinas, la teoría de lenguajes, los algoritmos y la ingeniería de
software.
En este capítulo preliminar presentaremos las distintas formas de los traductores de lenguaje, proporcionaremos una descripción general de alto nivel sobre la estructura de un compilador
ordinario, y hablaremos sobre las tendencias en los lenguajes de programación y la arquitectura
de máquinas que dan forma a los compiladores. Incluiremos algunas observaciones sobre la
relación entre el diseño de los compiladores y la teoría de las ciencias computacionales, y un
esquema de las aplicaciones de tecnología sobre los compiladores que van más allá de la compilación. Terminaremos con una breve descripción de los conceptos clave de los lenguajes de
programación que necesitaremos para nuestro estudio de los compiladores.
1.1
Procesadores de lenguaje
Dicho en forma simple, un compilador es un programa que puede leer un programa en un lenguaje (el lenguaje fuente) y traducirlo en un programa equivalente en otro lenguaje (el lenguaje
destino); vea la figura 1.1. Una función importante del compilador es reportar cualquier error
en el programa fuente que detecte durante el proceso de traducción.
1
2
Capítulo 1. Introducción
programa fuente
Compilador
programa destino
Figura 1.1: Un compilador
Si el programa destino es un programa ejecutable en lenguaje máquina, entonces el usuario
puede ejecutarlo para procesar las entradas y producir salidas (resultados); vea la figura 1.2.
entrada
Programa destino
salida
Figura 1.2: Ejecución del programa de destino
Un intérprete es otro tipo común de procesador de lenguaje. En vez de producir un programa destino como una traducción, el intérprete nos da la apariencia de ejecutar directamente
las operaciones especificadas en el programa de origen (fuente) con las entradas proporcionadas
por el usuario, como se muestra en la figura 1.3.
programa fuente
Intérprete
salida
entrada
Figura 1.3: Un intérprete
El programa destino en lenguaje máquina que produce un compilador es, por lo general, más
rápido que un intérprete al momento de asignar las entradas a las salidas. No obstante, por
lo regular, el intérprete puede ofrecer mejores diagnósticos de error que un compilador, ya que
ejecuta el programa fuente instrucción por instrucción.
Ejemplo 1.1: Los procesadores del lenguaje Java combinan la compilación y la interpretación,
como se muestra en la figura 1.4. Un programa fuente en Java puede compilarse primero en un
formato intermedio, llamado bytecodes. Después, una máquina virtual los interpreta. Un beneficio de este arreglo es que los bytecodes que se compilan en una máquina pueden interpretarse
en otra, tal vez a través de una red.
Para poder lograr un procesamiento más rápido de las entradas a las salidas, algunos compiladores de Java, conocidos como compiladores just-in-time (justo a tiempo), traducen los
bytecodes en lenguaje máquina justo antes de ejecutar el programa intermedio para procesar
la entrada.
2
3
1.1 Procesadores de lenguaje
programa fuente
Traductor
programa intermedio
entrada
Máquina
virtual
salida
Figura 1.4: Un compilador híbrido
Además de un compilador, pueden requerirse otros programas más para la creación de un
programa destino ejecutable, como se muestra en la figura 1.5. Un programa fuente puede dividirse en módulos guardados en archivos separados. La tarea de recolectar el programa de origen se confía algunas veces a un programa separado, llamado preprocesador. El preprocesador
también puede expandir algunos fragmentos de código abreviados de uso frecuente, llamados
macros, en instrucciones del lenguaje fuente.
Después, el programa fuente modificado se alimenta a un compilador. El compilador puede
producir un programa destino en ensamblador como su salida, ya que es más fácil producir
el lenguaje ensamblador como salida y es más fácil su depuración. A continuación, el lenguaje
ensamblador se procesa mediante un programa llamado ensamblador, el cual produce código
máquina relocalizable como su salida.
A menudo, los programas extensos se compilan en partes, por lo que tal vez haya que
enlazar (vincular) el código máquina relocalizable con otros archivos objeto relocalizables y
archivos de biblioteca para producir el código que se ejecute en realidad en la máquina. El enlazador resuelve las direcciones de memoria externas, en donde el código en un archivo puede
hacer referencia a una ubicación en otro archivo. Entonces, el cargador reúne todos los archivos
objeto ejecutables en la memoria para su ejecución.
1.1.1
Ejercicios para la sección 1.1
Ejercicio 1.1.1: ¿Cuál es la diferencia entre un compilador y un intérprete?
Ejercicio 1.1.2: ¿Cuáles son las ventajas de (a) un compilador sobre un intérprete, y (b) las
de un intérprete sobre un compilador?
Ejercicio 1.1.3: ¿Qué ventajas hay para un sistema de procesamiento de lenguajes en el cual
el compilador produce lenguaje ensamblador en vez de lenguaje máquina?
Ejercicio 1.1.4: A un compilador que traduce un lenguaje de alto nivel a otro lenguaje de
alto nivel se le llama traductor de source-to-source. ¿Qué ventajas hay en cuanto al uso de C
como lenguaje destino para un compilador?
Ejercicio 1.1.5: Describa algunas de las tareas que necesita realizar un ensamblador.
4
Capítulo 1. Introducción
programa fuente
Preprocesador
programa fuente modificado
Compilador
programa destino en ensamblador
Ensamblador
código máquina relocalizable
Enlazador/Cargador
archivos de librería
archivos objeto relocalizables
código máquina destino
Figura 1.5: Un sistema de procesamiento de lenguaje
1.2
La estructura de un compilador
Hasta este punto, hemos tratado al compilador como una caja simple que mapea un programa
fuente a un programa destino con equivalencia semántica. Si abrimos esta caja un poco, podremos ver que hay dos procesos en esta asignación: análisis y síntesis.
La parte del análisis divide el programa fuente en componentes e impone una estructura
gramatical sobre ellas. Después utiliza esta estructura para crear una representación intermedia
del programa fuente. Si la parte del análisis detecta que el programa fuente está mal formado
en cuanto a la sintaxis, o que no tiene una semántica consistente, entonces debe proporcionar
mensajes informativos para que el usuario pueda corregirlo. La parte del análisis también
recolecta información sobre el programa fuente y la almacena en una estructura de datos llamada tabla de símbolos, la cual se pasa junto con la representación intermedia a la parte de la
síntesis.
La parte de la síntesis construye el programa destino deseado a partir de la representación
intermedia y de la información en la tabla de símbolos. A la parte del análisis se le llama comúnmente el front-end del compilador; la parte de la síntesis (propiamente la traducción) es
el back-end.
Si examinamos el proceso de compilación con más detalle, podremos ver que opera como
una secuencia de fases, cada una de las cuales transforma una representación del programa
fuente en otro. En la figura 1.6 se muestra una descomposición típica de un compilador en
fases. En la práctica varias fases pueden agruparse, y las representaciones intermedias entre las
5
1.2 La estructura de un compilador
flujo de caracteres
Analizador léxico
flujo de tokens
Analizador sintáctico
árbol sintáctico
Analizador semántico
árbol sintáctico
Tabla de
símbolos
Generador de código intermedio
representación intermedia
Optimizador de código
independiente de la máquina
representación intermedia
Generador de código
código máquina destino
Optimizador de código
independiente de la máquina
código máquina destino
Figura 1.6: Fases de un compilador
fases agrupadas no necesitan construirse de manera explícita. La tabla de símbolos, que almacena información sobre todo el programa fuente, se utiliza en todas las fases del compilador.
Algunos compiladores tienen una fase de optimización de código independiente de la máquina,
entre el front-end y el back-end. El propósito de esta optimización es realizar transformaciones sobre
la representación intermedia, para que el back-end pueda producir un mejor programa destino
de lo que hubiera producido con una representación intermedia sin optimizar. Como la optimización es opcional, puede faltar una de las dos fases de optimización de la figura 1.6.
1.2.1
Análisis de léxico
A la primera fase de un compilador se le llama análisis de léxico o escaneo. El analizador de léxico
lee el flujo de caracteres que componen el programa fuente y los agrupa en secuencias signifi-
6
Capítulo 1. Introducción
cativas, conocidas como lexemas. Para cada lexema, el analizador léxico produce como salida
un token de la forma:
nombre-token, valor-atributo
que pasa a la fase siguiente, el análisis de la sintaxis. En el token, el primer componente nombre-token es un símbolo abstracto que se utiliza durante el análisis sintáctico, y el segundo
componente valor-atributo apunta a una entrada en la tabla de símbolos para este token. La
información de la entrada en la tabla de símbolos se necesita para el análisis semántico y la
generación de código.
Por ejemplo, suponga que un programa fuente contiene la instrucción de asignación:
posicion = inicial + velocidad * 60
(1.1)
Los caracteres en esta asignación podrían agruparse en los siguientes lexemas y mapearse a los
siguientes tokens que se pasan al analizador sintáctico:
1. posicion es un lexema que se asigna a un token id, 1, en donde id es un símbolo abstracto que representa la palabra identificador y 1 apunta a la entrada en la tabla de símbolos para posicion. La entrada en la tabla de símbolos para un identificador contiene
información acerca de éste, como su nombre y tipo.
2. El símbolo de asignación = es un lexema que se asigna al token =. Como este token
no necesita un valor-atributo, hemos omitido el segundo componente. Podríamos haber
utilizado cualquier símbolo abstracto como asignar para el nombre-token, pero por
conveniencia de notación hemos optado por usar el mismo lexema como el nombre para
el símbolo abstracto.
3. inicial es un lexema que se asigna al token id, 2, en donde 2 apunta a la entrada en
la tabla de símbolos para inicial.
4. + es un lexema que se asigna al token +.
5. velocidad es un lexema que se asigna al token id, 3, en donde 3 apunta a la entrada
en la tabla de símbolos para velocidad.
6. * es un lexema que se asigna al token *.
7. 60 es un lexema que se asigna al token 60.1
El analizador léxico ignora los espacios en blanco que separan a los lexemas.
La figura 1.7 muestra la representación de la instrucción de asignación (1.1) después del
análisis léxico como la secuencia de tokens.
id, 1 = id, 2 + id, 3 * 60
(1.2)
En esta representación, los nombres de los tokens =, + y * son símbolos abstractos para los
operadores de asignación, suma y multiplicación, respectivamente.
1
Hablando en sentido técnico, para el lexema 60 formaríamos un token como número, 4, en donde 4 apunta a la
tabla de símbolos para la representación interna del entero 60, pero dejaremos la explicación de los tokens para los números hasta el capítulo 2. En el capítulo 3 hablaremos sobre las técnicas para la construcción de analizadores léxicos.
7
1.2 La estructura de un compilador
posicion = inicial + velocidad * 60
Analizador léxico
id, 1 = id, 2 + id, 3 * 60
Analizador sintáctico
1
2
3
posicion
inicial
velocidad
...
...
...
=
id, 1
+
id, 2
*
id, 3
60
Analizador semántico
TABLA DE SÍMBOLOS
=
id, 1
+
*
id, 2
id, 3 inttofloat
60
Generador de código intermedio
t1 = inttofloat(60)
t2 = id3 * t1
t3 = id2 + t2
id1 = t3
Optimizador de código
t1 = id3 * 60.0
id1 = id2 + t1
Generador de código
LDF
MULF
LDF
ADDF
STF
R2, id3
R2, R2, #60.0
R1, id2
R1, R1, R2
id1, R1
Figura 1.7: Traducción de una instrucción de asignación
8
1.2.2
Capítulo 1. Introducción
Análisis sintáctico
La segunda fase del compilador es el análisis sintáctico o parsing. El parser (analizador sintáctico) utiliza los primeros componentes de los tokens producidos por el analizador de léxico para
crear una representación intermedia en forma de árbol que describa la estructura gramatical
del flujo de tokens. Una representación típica es el árbol sintáctico, en el cual cada nodo interior representa una operación y los hijos del nodo representan los argumentos de la operación.
En la figura 1.7 se muestra un árbol sintáctico para el flujo de tokens (1.2) como salida del
analizador sintáctico.
Este árbol muestra el orden en el que deben llevarse a cabo las operaciones en la siguiente
asignación:
posicion = inicial + velocidad * 60
El árbol tiene un nodo interior etiquetado como *, con id, 3 como su hijo izquierdo, y el
entero 60 como su hijo derecho. El nodo id, 3 representa el identificador velocidad. El nodo
etiquetado como * hace explicito que primero debemos multiplicar el valor de velocidad por
60. El nodo etiquetado como + indica que debemos sumar el resultado de esta multiplicación
al valor de inicial. La raíz del árbol, que se etiqueta como =, indica que debemos almacenar
el resultado de esta suma en la ubicación para el identificador posicion. Este ordenamiento de
operaciones es consistente con las convenciones usuales de la aritmética, las cuales nos indican
que la multiplicación tiene mayor precedencia que la suma y, por ende, debe realizarse antes que
la suma.
Las fases siguientes del compilador utilizan la estructura gramatical para ayudar a analizar
el programa fuente y generar el programa destino. En el capítulo 4 utilizaremos gramáticas
libres de contexto para especificar la estructura gramatical de los lenguajes de programación,
y hablaremos sobre los algoritmos para construir analizadores sintácticos eficientes de manera
automática, a partir de ciertas clases de gramáticas. En los capítulos 2 y 5 veremos que las
definiciones orientadas a la sintaxis pueden ayudar a especificar la traducción de las construcciones del lenguaje de programación.
1.2.3
Análisis semántico
El analizador semántico utiliza el árbol sintáctico y la información en la tabla de símbolos para
comprobar la consistencia semántica del programa fuente con la definición del lenguaje. También recopila información sobre el tipo y la guarda, ya sea en el árbol sintáctico o en la tabla
de símbolos, para usarla más tarde durante la generación de código intermedio.
Una parte importante del análisis semántico es la comprobación (verificación) de tipos, en
donde el compilador verifica que cada operador tenga operandos que coincidan. Por ejemplo,
muchas definiciones de lenguajes de programación requieren que el índice de un arreglo sea
entero; el compilador debe reportar un error si se utiliza un número de punto flotante para
indexar el arreglo.
La especificación del lenguaje puede permitir ciertas conversiones de tipo conocidas como
coerciones. Por ejemplo, puede aplicarse un operador binario aritmético a un par de enteros o
a un par de números de punto flotante. Si el operador se aplica a un número de punto flotante
9
1.2 La estructura de un compilador
y a un entero, el compilador puede convertir u obligar a que se convierta en un número de
punto flotante.
Dicha conversión aparece en la figura 1.7. Suponga que posicion, inicial y velocidad se
han declarado como números de punto flotante, y que el lexema 60 por sí solo forma un entero. El comprobador de tipo en el analizador semántico de la figura 1.7 descubre que se aplica
el operador * al número de punto flotante velocidad y al entero 60. En este caso, el entero
puede convertirse en un número de punto flotante. Observe en la figura 1.7 que la salida del
analizador semántico tiene un nodo adicional para el operador inttofloat, que convierte de
manera explícita su argumento tipo entero en un número de punto flotante. En el capítulo 6
hablaremos sobre la comprobación de tipos y el análisis semántico.
1.2.4
Generación de código intermedio
En el proceso de traducir un programa fuente a código destino, un compilador puede construir
una o más representaciones intermedias, las cuales pueden tener una variedad de formas. Los
árboles sintácticos son una forma de representación intermedia; por lo general, se utilizan durante el análisis sintáctico y semántico.
Después del análisis sintáctico y semántico del programa fuente, muchos compiladores generan un nivel bajo explícito, o una representación intermedia similar al código máquina, que
podemos considerar como un programa para una máquina abstracta. Esta representación intermedia debe tener dos propiedades importantes: debe ser fácil de producir y fácil de traducir
en la máquina destino.
En el capítulo 6, consideramos una forma intermedia llamada código de tres direcciones, que
consiste en una secuencia de instrucciones similares a ensamblador, con tres operandos por instrucción. Cada operando puede actuar como un registro. La salida del generador de código intermedio en la figura 1.7 consiste en la secuencia de código de tres direcciones.
t1 = inttofloat(60)
t2 = id3 * t1
t3 = id2 + t2
id1 = t3
(1.3)
Hay varios puntos que vale la pena mencionar sobre las instrucciones de tres direcciones. En
primer lugar, cada instrucción de asignación de tres direcciones tiene, por lo menos, un operador
del lado derecho. Por ende, estas instrucciones corrigen el orden en el que se van a realizar las
operaciones; la multiplicación va antes que la suma en el programa fuente (1.1). En segundo
lugar, el compilador debe generar un nombre temporal para guardar el valor calculado por
una instrucción de tres direcciones. En tercer lugar, algunas “instrucciones de tres direcciones”
como la primera y la última en la secuencia (1.3) anterior, tienen menos de tres operandos.
En el capítulo 6 hablaremos sobre las representaciones intermedias principales que se utilizan en los compiladores. El capítulo 5 introduce las técnicas para la traducción dirigida por la
sintaxis, las cuales se aplican en el capítulo 6 para la comprobación de tipos y la generación de
código intermedio en las construcciones comunes de los lenguajes de programación, como las
expresiones, las construcciones de control de flujo y las llamadas a procedimientos.
10
1.2.5
Capítulo 1. Introducción
Optimización de código
La fase de optimización de código independiente de la máquina trata de mejorar el código intermedio, de manera que se produzca un mejor código destino. Por lo general, mejor significa
más rápido, pero pueden lograrse otros objetivos, como un código más corto, o un código de
destino que consuma menos poder. Por ejemplo, un algoritmo directo genera el código intermedio (1.3), usando una instrucción para cada operador en la representación tipo árbol que
produce el analizador semántico.
Un algoritmo simple de generación de código intermedio, seguido de la optimización de código, es una manera razonable de obtener un buen código de destino. El optimizador puede deducir que la conversión del 60, de entero a punto flotante, puede realizarse de una vez por todas
en tiempo de compilación, por lo que se puede eliminar la operación inttofloat sustituyendo
el entero 60 por el número de punto flotante 60.0. Lo que es más, t3 se utiliza sólo una vez
para transmitir su valor a id1, para que el optimizador pueda transformar (1.3) en la siguiente
secuencia más corta:
t1 = id3 * 60.0
id1 = id2 + t1
(1.4)
Hay una gran variación en la cantidad de optimización de código que realizan los distintos
compiladores. En aquellos que realizan la mayor optimización, a los que se les denomina como
“compiladores optimizadores”, se invierte mucho tiempo en esta fase. Hay optimizaciones
simples que mejoran en forma considerable el tiempo de ejecución del programa destino, sin
reducir demasiado la velocidad de la compilación. Los capítulos 8 en adelante hablan con más
detalle sobre las optimizaciones independientes y dependientes de la máquina.
1.2.6
Generación de código
El generador de código recibe como entrada una representación intermedia del programa fuente y la asigna al lenguaje destino. Si el lenguaje destino es código máquina, se seleccionan
registros o ubicaciones (localidades) de memoria para cada una de las variables que utiliza el
programa. Después, las instrucciones intermedias se traducen en secuencias de instrucciones
de máquina que realizan la misma tarea. Un aspecto crucial de la generación de código es la
asignación juiciosa de los registros para guardar las variables.
Por ejemplo, usando los registros R1 y R2, el código intermedio en (1.4) podría traducirse
en el siguiente código de máquina:
LDF
MULF
LDF
ADDF
STF
R2,
R2,
R1,
R1,
id1,
id3
R2, #60.0
id2
R1, R2
R1
(1.5)
El primer operando de cada instrucción especifica un destino. La F en cada instrucción
nos indica que trata con números de punto flotante. El código en (1.5) carga el contenido de
1.2 La estructura de un compilador
11
la dirección id3 en el registro R2, y después lo multiplica con la constante de punto flotante
60.0. El # indica que el número 60.0 se va a tratar como una constante inmediata. La tercera
instrucción mueve id2 al registro R1 y la cuarta lo suma al valor que se había calculado antes
en el registro R2. Por último, el valor en el registro R1 se almacena en la dirección de id1, por
lo que el código implementa en forma correcta la instrucción de asignación (1.1). El capítulo 8
trata acerca de la generación de código.
En esta explicación sobre la generación de código hemos ignorado una cuestión importante: la asignación de espacio de almacenamiento para los identificadores en el programa fuente.
Como veremos en el capítulo 7, la organización del espacio de almacenamiento en tiempo
de ejecución depende del lenguaje que se esté compilando. Las decisiones sobre la asignación de
espacio de almacenamiento se realizan durante la generación de código intermedio, o durante
la generación de código.
1.2.7
Administración de la tabla de símbolos
Una función esencial de un compilador es registrar los nombres de las variables que se utilizan
en el programa fuente, y recolectar información sobre varios atributos de cada nombre. Estos
atributos pueden proporcionar información acerca del espacio de almacenamiento que se asigna
para un nombre, su tipo, su alcance (en qué parte del programa puede usarse su valor), y en
el caso de los nombres de procedimientos, cosas como el número y los tipos de sus argumentos, el método para pasar cada argumento (por ejemplo, por valor o por referencia) y el tipo
devuelto.
La tabla de símbolos es una estructura de datos que contiene un registro para cada nombre
de variable, con campos para los atributos del nombre. La estructura de datos debe diseñarse de tal forma que permita al compilador buscar el registro para cada nombre, y almacenar
u obtener datos de ese registro con rapidez. En el capítulo 2 hablaremos sobre las tablas de
símbolos.
1.2.8
El agrupamiento de fases en pasadas
El tema sobre las fases tiene que ver con la organización lógica de un compilador. En una implementación, las actividades de varias fases pueden agruparse en una pasada, la cual lee un
archivo de entrada y escribe en un archivo de salida. Por ejemplo, las fases correspondientes
al front-end del análisis léxico, análisis sintáctico, análisis semántico y generación de código
intermedio podrían agruparse en una sola pasada. La optimización de código podría ser una
pasada opcional. Entonces podría haber una pasada de back-end, consistente en la generación
de código para una máquina de destino específica.
Algunas colecciones de compiladores se han creado en base a representaciones intermedias
diseñadas con cuidado, las cuales permiten que el front-end para un lenguaje específico se
interconecte con el back-end para cierta máquina destinto. Con estas colecciones, podemos
producir compiladores para distintos lenguajes fuente para una máquina destino, mediante la
combinación de distintos front-end con sus back-end para esa máquina de destino. De manera
similar, podemos producir compiladores para distintas máquinas destino, mediante la combinación de un front-end con back-end para distintas máquinas destino.
12
Capítulo 1. Introducción
1.2.9
Herramientas de construcción de compiladores
Al igual que cualquier desarrollador de software, el desarrollador de compiladores puede utilizar para su beneficio los entornos de desarrollo de software modernos que contienen herramientas como editores de lenguaje, depuradores, administradores de versiones, profilers, ambientes
seguros de prueba, etcétera. Además de estas herramientas generales para el desarrollo de
software, se han creado otras herramientas más especializadas para ayudar a implementar las
diversas fases de un compilador.
Estas herramientas utilizan lenguajes especializados para especificar e implementar componentes específicos, y muchas utilizan algoritmos bastante sofisticados. Las herramientas más
exitosas son las que ocultan los detalles del algoritmo de generación y producen componentes
que pueden integrarse con facilidad al resto del compilador. Algunas herramientas de construcción de compiladores de uso común son:
1. Generadores de analizadores sintácticos (parsers), que producen de manera automática
analizadores sintácticos a partir de una descripción gramatical de un lenguaje de programación.
2. Generadores de escáneres, que producen analizadores de léxicos a partir de una descripción de los tokens de un lenguaje utilizando expresiones regulares.
3. Motores de traducción orientados a la sintaxis, que producen colecciones de rutinas para
recorrer un árbol de análisis sintáctico y generar código intermedio.
4. Generadores de generadores de código, que producen un generador de código a partir
de una colección de reglas para traducir cada operación del lenguaje intermedio en el
lenguaje máquina para una máquina destino.
5. Motores de análisis de flujos de datos, que facilitan la recopilación de información de
cómo se transmiten los valores de una parte de un programa a cada una de las otras partes. El análisis de los flujos de datos es una parte clave en la optimización de código.
6. Kits (conjuntos) de herramientas para la construcción de compiladores, que proporcionan
un conjunto integrado de rutinas para construir varias fases de un compilador.
A lo largo de este libro, describiremos muchas de estas herramientas.
1.3
La evolución de los lenguajes de programación
Las primeras computadoras electrónicas aparecieron en la década de 1940 y se programaban
en lenguaje máquina, mediante secuencias de 0’s y 1’s que indicaban de manera explícita a la
computadora las operaciones que debía ejecutar, y en qué orden. Las operaciones en sí eran
de muy bajo nivel: mover datos de una ubicación a otra, sumar el contenido de dos registros,
comparar dos valores, etcétera. Está demás decir, que este tipo de programación era lenta,
tediosa y propensa a errores. Y una vez escritos, los programas eran difíciles de comprender y
modificar.
1.3 La evolución de los lenguajes de programación
1.3.1
13
El avance a los lenguajes de alto nivel
El primer paso hacia los lenguajes de programación más amigables para las personas fue el
desarrollo de los lenguajes ensambladores a inicios de la década de 1950, los cuales usaban mnemónicos. Al principio, las instrucciones en un lenguaje ensamblador eran sólo representaciones
mnemónicas de las instrucciones de máquina. Más adelante, se agregaron macro instrucciones a los lenguajes ensambladores, para que un programador pudiera definir abreviaciones parametrizadas para las secuencias de uso frecuente de las instrucciones de máquina.
Un paso importante hacia los lenguajes de alto nivel se hizo en la segunda mitad de la
década de 1950, con el desarrollo de Fortran para la computación científica, Cobol para el procesamiento de datos de negocios, y Lisp para la computación simbólica. La filosofía de estos
lenguajes era crear notaciones de alto nivel con las que los programadores pudieran escribir con
más facilidad los cálculos numéricos, las aplicaciones de negocios y los programas simbólicos.
Estos lenguajes tuvieron tanto éxito que siguen en uso hoy en día.
En las siguientes décadas se crearon muchos lenguajes más con características innovadoras
para facilitar que la programación fuera más natural y más robusta. Más adelante, en este capítulo, hablaremos sobre ciertas características clave que son comunes para muchos lenguajes
de programación modernos.
En la actualidad existen miles de lenguajes de programación. Pueden clasificarse en una
variedad de formas. Una de ellas es por generación. Los lenguajes de primera generación son
los lenguajes de máquina, los de segunda generación son los lenguajes ensambladores, y los de
tercera generación son los lenguajes de alto nivel, como Fortran, Cobol, Lisp, C, C++, C#
y Java. Los lenguajes de cuarta generación son diseñados para aplicaciones específicas como
NOMAD para la generación de reportes, SQL para las consultas en bases de datos, y Postscript
para el formato de texto. El término lenguaje de quinta generación se aplica a los lenguajes
basados en lógica y restricciones, como Prolog y OPS5.
Otra de las clasificaciones de los lenguajes utiliza el término imperativo para los lenguajes
en los que un programa especifica cómo se va a realizar un cálculo, y declarativo para los lenguajes en los que un programa especifica qué cálculo se va a realizar. Los lenguajes como C,
C++, C# y Java son lenguajes imperativos. En los lenguajes imperativos hay una noción de
estado del programa, junto con instrucciones que modifican ese estado. Los lenguajes funcionales como ML y Haskell, y los lenguajes de lógica de restricción como Prolog, se consideran a
menudo como lenguajes declarativos.
El término lenguaje von Neumann se aplica a los lenguajes de programación cuyo modelo se
basa en la arquitectura de computadoras descrita por von Neumann. Muchos de los lenguajes
de la actualidad, como Fortran y C, son lenguajes von Neumann.
Un lenguaje orientado a objetos es uno que soporta la programación orientada a objetos,
un estilo de programación en el que un programa consiste en una colección de objetos que
interactúan entre sí. Simula 67 y Smalltalk son de los primeros lenguajes orientados a objetos
importantes. Los lenguajes como C++, C#, Java y Ruby son los lenguajes orientados a objetos más recientes.
Los lenguajes de secuencias de comandos (scripting) son lenguajes interpretados con operadores de alto nivel diseñados para “unir” cálculos. Estos cálculos se conocían en un principio
como “secuencias de comandos (scripts)”. Awk, JavaScript, Perl, PHP, Python, Ruby y Tcl
son ejemplos populares de lenguajes de secuencias de comandos. Los programas escritos en
14
Capítulo 1. Introducción
lenguajes de secuencias de comandos son a menudo más cortos que los programas equivalentes
escritos en lenguajes como C.
1.3.2
Impactos en el compilador
Desde su diseño, los lenguajes de programación y los compiladores están íntimamente relacionados; los avances en los lenguajes de programación impusieron nuevas demandas sobre
los escritores de compiladores. Éstos tenían que idear algoritmos y representaciones para
traducir y dar soporte a las nuevas características del lenguaje. Desde la década de 1940, la
arquitectura de computadoras ha evolucionado también. Los escritores de compiladores no
sólo tuvieron que rastrear las nuevas características de un lenguaje, sino que también tuvieron que idear algoritmos de traducción para aprovechar al máximo las nuevas características
del hardware.
Los compiladores pueden ayudar a promover el uso de lenguajes de alto nivel, al minimizar la sobrecarga de ejecución de los programas escritos en estos lenguajes. Los compiladores también son imprescindibles a la hora de hacer efectivas las arquitecturas computacionales
de alto rendimiento en las aplicaciones de usuario. De hecho, el rendimiento de un sistema
computacional es tan dependiente de la tecnología de compiladores, que éstos se utilizan como
una herramienta para evaluar los conceptos sobre la arquitectura antes de crear una computadora.
Escribir compiladores es un reto. Un compilador por sí solo es un programa extenso. Además, muchos sistemas modernos de procesamiento de lenguajes manejan varios lenguajes fuente y máquinas destino dentro del mismo framework; es decir, sirven como colecciones de
compiladores, y es probable que consistan en millones de líneas de código. En consecuencia, las
buenas técnicas de ingeniería de software son esenciales para crear y evolucionar los procesadores modernos de lenguajes.
Un compilador debe traducir en forma correcta el conjunto potencialmente infinito de programas que podrían escribirse en el lenguaje fuente. El problema de generar el código destino
óptimo a partir de un programa fuente es indecidible; por ende, los escritores de compiladores
deben evaluar las concesiones acerca de los problemas que se deben atacar y la heurística que
se debe utilizar para lidiar con el problema de generar código eficiente.
Un estudio de los compiladores es también un estudio sobre cómo la teoría se encuentra con
la práctica, como veremos en la sección 1.4.
El propósito de este libro es enseñar la metodología y las ideas fundamentales que se utilizan en el diseño de los compiladores. Este libro no tiene la intención de enseñar todos los algoritmos y técnicas que podrían usarse para construir un sistema de procesamiento de lenguajes
de vanguardia. No obstante, los lectores de este texto adquirirán el conocimiento básico y la
comprensión para aprender a construir un compilador con relativa facilidad.
1.3.3
Ejercicios para la sección 1.3
Ejercicio 1.3.1: Indique cuál de los siguientes términos:
a) imperativo
b) declarativo
d) orientado a objetos
e) funcional
g) de cuarta generación h) secuencias de comandos
c) von Neumann
f) de tercera generación
15
1.4 La ciencia de construir un compilador
se aplican a los siguientes lenguajes:
1) C
6) Lisp
1.4
2) C++
7) ML
3) Cobol
8) Perl
4) Fortran
9) Python
5) Java
10) VB.
La ciencia de construir un compilador
El diseño de compiladores está lleno de bellos ejemplos, en donde se resuelven problemas complicados del mundo real mediante la abstracción de la esencia del problema en forma matemática. Éstos sirven como excelentes ilustraciones de cómo pueden usarse las abstracciones para
resolver problemas: se toma un problema, se formula una abstracción matemática que capture
las características clave y se resuelve utilizando técnicas matemáticas. La formulación del problema debe tener bases y una sólida comprensión de las características de los programas de
computadora, y la solución debe validarse y refinarse en forma empírica.
Un compilador debe aceptar todos los programas fuente conforme a la especificación del
lenguaje; el conjunto de programas fuente es infinito y cualquier programa puede ser muy
largo, posiblemente formado por millones de líneas de código. Cualquier transformación que
realice el compilador mientras traduce un programa fuente debe preservar el significado del
programa que se está compilando. Por ende, los escritores de compiladores tienen influencia no
sólo sobre los compiladores que crean, sino en todos los programas que compilan sus compiladores. Esta capacidad hace que la escritura de compiladores sea en especial gratificante; no
obstante, también hace que el desarrollo de los compiladores sea todo un reto.
1.4.1
Modelado en el diseño e implementación de compiladores
El estudio de los compiladores es principalmente un estudio de la forma en que diseñamos los modelos matemáticos apropiados y elegimos los algoritmos correctos, al tiempo que logramos equilibrar la necesidad de una generalidad y poder con la simpleza y la eficiencia.
Algunos de los modelos más básicos son las máquinas de estados finitos y las expresiones
regulares, que veremos en el capítulo 3. Estos modelos son útiles para describir las unidades de
léxico de los programas (palabras clave, identificadores y demás) y para describir los algoritmos
que utiliza el compilador para reconocer esas unidades. Además, entre los modelos esenciales se
encuentran las gramáticas libres de contexto, que se utilizan para describir la estructura sintáctica de los lenguajes de programación, como el anidamiento de los paréntesis o las instrucciones
de control. En el capítulo 4 estudiaremos las gramáticas. De manera similar, los árboles son un
modelo importante para representar la estructura de los programas y su traducción a código
objeto, como veremos en el capítulo 5.
1.4.2
La ciencia de la optimización de código
El término “optimización” en el diseño de compiladores se refiere a los intentos que realiza un
compilador por producir código que sea más eficiente que el código obvio. Por lo tanto, “optimización” es un término equivocado, ya que no hay forma en que se pueda garantizar que el
código producido por un compilador sea tan rápido o más rápido que cualquier otro código que
realice la misma tarea.
16
Capítulo 1. Introducción
En los tiempos modernos, la optimización de código que realiza un compilador se ha vuelto
tanto más importante como más compleja. Es más compleja debido a que las arquitecturas de
los procesadores se han vuelto más complejas, con lo que ofrecen más oportunidades de mejorar
la forma en que se ejecuta el código. Es más importante, ya que las computadoras paralelas
masivas requieren de una optimización considerable, pues de lo contrario su rendimiento sufre
por grados de magnitud. Con la posible prevalencia de las máquinas multinúcleo (computadoras con chips que contienen grandes números de procesadores), todos los compiladores tendrán
que enfrentarse al problema de aprovechar las máquinas con múltiples procesadores.
Es difícil, si no es que imposible, construir un compilador robusto a partir de “arreglos”.
Por ende, se ha generado una teoría extensa y útil sobre el problema de optimizar código. El
uso de una base matemática rigurosa nos permite mostrar que una optimización es correcta y
que produce el efecto deseable para todas las posibles entradas. Empezando en el capítulo 9,
veremos de qué manera son necesarios los modelos como grafos, matrices y programas lineales
si el compilador debe producir un código bien optimizado.
Por otro lado, no es suficiente sólo con la pura teoría. Al igual que muchos problemas del
mundo real, no hay respuestas perfectas. De hecho, la mayoría de las preguntas que hacemos
en la optimización de un compilador son indecidibles Una de las habilidades más importantes en
el diseño de los compiladores es la de formular el problema adecuado a resolver. Para empezar,
necesitamos una buena comprensión del comportamiento de los programas, junto con un proceso extenso de experimentación y evaluación para validar nuestras intuiciones.
Las optimizaciones de compiladores deben cumplir con los siguientes objetivos de diseño:
• La optimización debe ser correcta; es decir, debe preservar el significado del programa
compilado.
• La optimización debe mejorar el rendimiento de muchos programas.
• El tiempo de compilación debe mantenerse en un valor razonable.
• El esfuerzo de ingeniería requerido debe ser administrable.
Es imposible hacer mucho énfasis en la importancia de estar en lo correcto. Es trivial escribir un compilador que genere código rápido, ¡si el código generado no necesita estar correcto!
Es tan difícil optimizar los compiladores en forma apropiada, que nos atrevemos a decir que
¡ningún compilador optimizador está libre de errores! Por ende, el objetivo más importante en
la escritura de un compilador es que sea correcto.
El segundo objetivo es que el compilador debe tener efectividad a la hora de mejorar el
rendimiento de muchos programas de entrada. Por lo general, el rendimiento indica la velocidad de ejecución del programa. En especial en las aplicaciones incrustadas, también puede ser
conveniente minimizar el tamaño del código generado. Y en el caso de los dispositivos móviles,
también es conveniente que el código reduzca el consumo de energía. Por lo general, las mismas
optimizaciones que agilizan el tiempo de ejecución también ahorran energía. Además del rendimiento, los aspectos de capacidad de uso como el reporte de errores y la depuración también
son importantes.
En tercer lugar, debemos lograr que el tiempo de compilación sea corto para poder soportar un ciclo rápido de desarrollo y depuración. Este requerimiento se ha vuelto más fácil de
1.5 Aplicaciones de la tecnología de compiladores
17
cumplir a medida que las máquinas se hacen más rápidas. A menudo, un programa primero se
desarrolla y se depura sin optimizaciones. No sólo se reduce el tiempo de compilación, sino que,
lo más importante, los programas no optimizados son más fáciles de depurar, ya que las optimizaciones que introduce un compilador, por lo común, oscurecen la relación entre el código fuente
y el código objeto. Al activar las optimizaciones en el compilador, algunas veces se exponen
nuevos problemas en el programa fuente; en consecuencia, hay que probar otra vez el código
optimizado. Algunas veces, la necesidad de prueba adicional impide el uso de optimizaciones
en aplicaciones, en especial si su rendimiento no es crítico.
Por último, un compilador es un sistema complejo; debemos mantener este sistema simple,
para asegurar que los costos de ingeniería y de mantenimiento del compilador sean manejables.
Existe un número infinito de optimizaciones de programas que podríamos implementar, y se
requiere de muy poco esfuerzo para crear una optimización correcta y efectiva. Debemos dar
prioridad a las optimizaciones, implementando sólo las que conlleven a los mayores beneficios
en los programas fuente que encontremos en la práctica.
Por lo tanto, al estudiar los compiladores no sólo aprendemos a construir uno, sino también
la metodología general para resolver problemas complejos y abiertos. El método que se utilice
en el desarrollo de los compiladores implica tanto teoría como experimentación. Por lo general,
empezamos formulando el problema según nuestras intuiciones acerca de cuáles son las cuestiones importantes.
1.5
Aplicaciones de la tecnología de compiladores
El diseño de compiladores no es sólo acerca de los compiladores; muchas personas utilizan
la tecnología que aprenden al estudiar compiladores en la escuela y nunca, hablando en sentido estricto, han escrito (ni siquiera parte de) un compilador para un lenguaje de programación importante. La tecnología de compiladores tiene también otros usos importantes.
Además, el diseño de compiladores impacta en otras áreas de las ciencias computacionales. En
esta sección veremos un repaso acerca de las interacciones y aplicaciones más importantes
de esta tecnología.
1.5.1
Implementación de lenguajes de programación
de alto nivel
Un lenguaje de programación de alto nivel define una abstracción de programación: el programador expresa un algoritmo usando el lenguaje, y el compilador debe traducir el programa en
el lenguaje de destino. Por lo general, es más fácil programar en los lenguajes de programación
de alto nivel, aunque son menos eficientes; es decir, los programas destino se ejecutan con más
lentitud. Los programadores que utilizan un lenguaje de bajo nivel tienen más control sobre
un cálculo y pueden, en principio, producir código más eficiente. Por desgracia, los programas
de menor nivel son más difíciles de escribir y (peor aún) menos portables, más propensos a
errores y más difíciles de mantener. La optimización de los compiladores incluye técnicas para
mejorar el rendimiento del código generado, con las cuales se desplaza la ineficiencia que dan
las abstracciones de alto nivel.
18
Capítulo 1. Introducción
Ejemplo 1.2: La palabra clave registro en el lenguaje de programación C es uno de los primeros ejemplos de la interacción entre la tecnología de los compiladores y la evolución del lenguaje. Cuando el lenguaje C se creó a mediados de la década de 1970, era necesario dejar que un
programador controlara qué variables del programa debían residir en los registros. Este control
ya no fue necesario cuando se desarrollaron las técnicas efectivas de asignación de recursos, por
lo cual la mayoría de los programas modernos ya no utilizan esta característica del lenguaje.
De hecho, los programas que utilizan la palabra clave registro (register) pueden perder eficiencia, debido a que, por lo general, los programadores no son los mejores jueces en las
cuestiones de bajo nivel, como la asignación de registros. La elección óptima en la asignación
de registros depende en gran parte de las cuestiones específicas sobre la arquitectura de una
máquina. Las decisiones de la administración de recursos como cablear (distribuir) a bajo nivel
podrían de hecho afectar el rendimiento, en especial si el programa se ejecuta en máquinas
distintas a la máquina para la cual se escribió. 2
Los diversos cambios en cuanto a la elección popular de lenguajes de programación han
sido orientados al aumento en los niveles de abstracción. C fue el lenguaje de programación
de sistemas predominante en la década de 1980; muchos de los nuevos proyectos que empezaron en la década de 1990 eligieron a C++; Java, que se introdujo en 1995, ganó popularidad
con rapidez a finales de la década de 1990. Las características nuevas de los lenguajes de programación que se introdujeron en cada generación incitaron a realizar nuevas investigaciones
en la optimización de los compiladores. A continuación veremos las generalidades acerca de
las principales características de los lenguajes que han estimulado avances considerables en la
tecnología de los compiladores.
Prácticamente todos los lenguajes de programación comunes, incluyendo a C, Fortran y
Cobol, soportan los tipos de datos de conjuntos definidos por el usuario, como los arreglos y las
estructuras, y el flujo de control de alto nivel, como los ciclos y las invocaciones a procedimientos. Si sólo tomamos cada instrucción de alto nivel u operación de acceso a datos y la traducimos directamente a código máquina, el resultado sería muy ineficiente. Se ha desarrollado un
cuerpo de optimizaciones de compilador, conocido como optimizaciones de flujo de datos, para
analizar el flujo de datos a través del programa y eliminar las redundancias en estas instrucciones. Son efectivas para generar código que se asemeje al código que escribe un programador
experimentado a un nivel más bajo.
La orientación a objetos se introdujo por primera vez en Simula en 1967, y se ha incorporado
en lenguajes como Smalltalk, C++, C# y Java. Las ideas claves de la orientación a objetos son:
1. La abstracción de datos, y
2. La herencia de propiedades,
de las cuales se ha descubierto que facilitan el mantenimiento de los programas, y los hacen
más modulares. Los programas orientados a objetos son diferentes de los programas escritos en
muchos otros lenguajes, ya que consisten de muchos más procedimientos, pero más pequeños
(a los cuales se les llama métodos, en términos de orientación a objetos). Por ende, las optimizaciones de los compiladores deben ser capaces de realizar bien su trabajo a través de los límites
de los procedimientos del programa fuente. El uso de procedimientos en línea, que viene siendo
cuando se sustituye la llamada a un procedimiento por su cuerpo, es muy útil en este caso.
También se han desarrollado optimizaciones para agilizar los envíos de los métodos virtuales.
1.5 Aplicaciones de la tecnología de compiladores
19
Java tiene muchas características que facilitan la programación, muchas de las cuales se
han introducido anteriormente en otros lenguajes. El lenguaje Java ofrece seguridad en los
tipos; es decir, un objeto no puede usarse como objeto de un tipo que no esté relacionado.
Todos los accesos a los arreglos se verifican para asegurar que se encuentren dentro de los límites del arreglo. Java no tiene apuntadores y no permite la aritmética de apuntadores. Tiene
una herramienta integrada para la recolección de basura, la cual libera de manera automática
la memoria de variables que ya no se encuentran en uso. Mientras que estas características
facilitan el proceso de la programación, provocan una sobrecarga en tiempo de ejecución. Se
han desarrollado optimizaciones en el compilador para reducir la sobrecarga, por ejemplo, mediante la eliminación de las comprobaciones de rango innecesarias y la asignación de objetos
a los que no se puede acceder más allá de un procedimiento en la pila, en vez del heap. También se han desarrollado algoritmos efectivos para minimizar la sobrecarga de la recolección
de basura.
Además, Java está diseñado para dar soporte al código portable y móvil. Los programas se
distribuyen como bytecodes de Java, el cual debe interpretarse o compilarse en código nativo
en forma dinámica, es decir, en tiempo de ejecución. La compilación dinámica también se ha
estudiado en otros contextos, en donde la información se extrae de manera dinámica en tiempo
de ejecución, y se utiliza para producir un código más optimizado. En la optimización dinámica, es importante minimizar el tiempo de compilación, ya que forma parte de la sobrecarga en
la ejecución. Una técnica de uso común es sólo compilar y optimizar las partes del programa
que se van a ejecutar con frecuencia.
1.5.2
Optimizaciones para las arquitecturas de computadoras
La rápida evolución de las arquitecturas de computadoras también nos ha llevado a una insaciable demanda de nueva tecnología de compiladores. Casi todos los sistemas de alto rendimiento
aprovechan las dos mismas técnicas básicas: paralelismo y jerarquías de memoria. Podemos
encontrar el paralelismo en varios niveles: a nivel de instrucción, en donde varias operaciones
se ejecutan al mismo tiempo y a nivel de procesador, en donde distintos subprocesos de la
misma aplicación se ejecutan en distintos hilos. Las jerarquías de memoria son una respuesta a
la limitación básica de que podemos construir un almacenamiento muy rápido o muy extenso,
pero no un almacenamiento que sea tanto rápido como extenso.
Paralelismo
Todos los microprocesadores modernos explotan el paralelismo a nivel de instrucción. Sin embargo, este paralelismo puede ocultarse al programador. Los programas se escriben como si
todas las instrucciones se ejecutaran en secuencia; el hardware verifica en forma dinámica las
dependencias en el flujo secuencial de instrucciones y las ejecuta en paralelo siempre que sea
posible. En algunos casos, la máquina incluye un programador (scheduler) de hardware que
puede modificar el orden de las instrucciones para aumentar el paralelismo en el programa.
Ya sea que el hardware reordene o no las instrucciones, los compiladores pueden reordenar las
instrucciones para que el paralelismo a nivel de instrucción sea más efectivo.
El paralelismo a nivel de instrucción también puede aparecer de manera explícita en el conjunto de instrucciones. Las máquinas VLIW (Very Long Instruction Word, Palabra de instrucción
20
Capítulo 1. Introducción
muy larga) tienen instrucciones que pueden ejecutar varias operaciones en paralelo. El Intel
IA64 es un ejemplo de tal arquitectura. Todos los microprocesadores de alto rendimiento y
propósito general incluyen también instrucciones que pueden operar sobre un vector de datos
al mismo tiempo. Las técnicas de los compiladores se han desarrollado para generar código de
manera automática para dichas máquinas, a partir de programas secuenciales.
Los multiprocesadores también se han vuelto frecuentes; incluso es común que hasta las
computadoras personales tengan múltiples procesadores. Los programadores pueden escribir
código multihilo para los multiprocesadores, o código en paralelo que un compilador puede
generar de manera automática, a partir de programas secuenciales. Dicho compilador oculta
de los programadores los detalles sobre cómo encontrar el paralelismo en un programa, distribuir los cálculos en la máquina y minimizar la sincronización y los cálculos entre cada procesador. Muchas aplicaciones de computación científica y de ingeniería hacen un uso intensivo de
los cálculos y pueden beneficiarse mucho con el procesamiento en paralelo. Se han desarrollado
técnicas de paralelización para traducir de manera automática los programas científicos en
código para multiprocesadores.
Jerarquías de memoria
Una jerarquía de memoria consiste en varios niveles de almacenamiento con distintas velocidades y tamaños, en donde el nivel más cercano al procesador es el más rápido, pero también el
más pequeño. El tiempo promedio de acceso a memoria de un programa se reduce si la mayoría
de sus accesos se satisfacen a través de los niveles más rápidos de la jerarquía. Tanto el paralelismo como la existencia de una jerarquía de memoria mejoran el rendimiento potencial de una
máquina, pero el compilador debe aprovecharlos de manera efectiva para poder producir un
rendimiento real en una aplicación.
Las jerarquías de memoria se encuentran en todas las máquinas. Por lo general, un procesador tiene un pequeño número de registros que consisten en cientos de bytes, varios niveles
de caché que contienen desde kilobytes hasta megabytes, memoria física que contiene desde
megabytes hasta gigabytes y, por último, almacenamiento secundario que contiene gigabytes
y mucho más. De manera correspondiente, la velocidad de los accesos entre los niveles adyacentes de la jerarquía puede diferir por dos o tres órdenes de magnitud. A menudo, el rendimiento
de un sistema se limita no sólo a la velocidad del procesador, sino también por el rendimiento del subsistema de memoria. Aunque desde un principio los compiladores se han enfocado en
optimizar la ejecución del procesador, ahora se pone más énfasis en lograr que la jerarquía de
memoria sea más efectiva.
El uso efectivo de los registros es quizá el problema individual más importante en la optimización de un programa. A diferencia de los registros que tienen que administrarse de manera explícita en el software, las cachés y las memorias físicas están ocultas del conjunto de
instrucciones y el hardware se encarga de administrarlas. La experiencia nos ha demostrado
que las directivas de administración de caché que implementa el hardware no son efectivas en
algunos casos, en especial con el código científico que tiene estructuras de datos extensas (por
lo general, arreglos). Es posible mejorar la efectividad de la jerarquía de memoria, cambiando
la distribución de los datos, o cambiando el orden de las instrucciones que acceden a los datos.
También podemos cambiar la distribución del código para mejorar la efectividad de las cachés
de instrucciones.
1.5 Aplicaciones de la tecnología de compiladores
1.5.3
21
Diseño de nuevas arquitecturas de computadoras
En los primeros días del diseño de arquitecturas de computadoras, los compiladores se desarrollaron después de haber creado las máquinas. Eso ha cambiado. Desde que la programación en
lenguajes de alto nivel es la norma, el rendimiento de un sistema computacional se determina
no sólo por su velocidad en general, sino también por la forma en que los compiladores pueden explotar sus características. Por ende, en el desarrollo de arquitecturas de computadoras
modernas, los compiladores se desarrollan en la etapa de diseño del procesador, y se utiliza el
código compilado, que se ejecuta en simuladores, para evaluar las características propuestas
sobre la arquitectura.
RISC
Uno de los mejores ejemplos conocidos sobre cómo los compiladores influenciaron el diseño
de la arquitectura de computadoras fue la invención de la arquitectura RISC (Reduced Instruction-Set Computer, Computadora con conjunto reducido de instrucciones). Antes de esta
invención, la tendencia era desarrollar conjuntos de instrucciones cada vez más complejos,
destinados a facilitar la programación en ensamblador; estas arquitecturas se denominaron
CISC (Complex Instruction-Set Computer, Computadora con conjunto complejo de instrucciones). Por ejemplo, los conjuntos de instrucciones CISC incluyen modos de direccionamiento de
memoria complejos para soportar los accesos a las estructuras de datos, e instrucciones para
invocar procedimientos que guardan registros y pasan parámetros en la pila.
A menudo, las optimizaciones de compiladores pueden reducir estas instrucciones a un pequeño número de operaciones más simples, eliminando las redundancias presentes en las instrucciones
complejas. Por lo tanto, es conveniente crear conjuntos simples de instrucciones; los compiladores pueden utilizarlas con efectividad y el hardware es mucho más sencillo de optimizar.
La mayoría de las arquitecturas de procesadores de propósito general, como PowerPC,
SPARC, MIPS, Alpha y PA-RISC, se basan en el concepto RISC. Aunque la arquitectura x86
(el microprocesador más popular) tiene un conjunto de instrucciones CISC, muchas de las
ideas desarrolladas para las máquinas RISC se utilizan en la implementación del procesador.
Además, la manera más efectiva de usar una máquina x86 de alto rendimiento es utilizar sólo
sus instrucciones simples.
Arquitecturas especializadas
En las últimas tres décadas se han propuesto muchos conceptos sobre la arquitectura, entre los
cuales se incluyen las máquinas de flujo de datos, las máquinas VLIW (Very Long Instruction
Word, Palabra de instrucción muy larga), los arreglos SIMD (Single Instruction, Multiple Data,
Una sola instrucción, varios datos) de procesadores, los arreglos sistólicos, los multiprocesadores con memoria compartida y los multiprocesadores con memoria distribuida. El desarrollo de
cada uno de estos conceptos arquitectónicos se acompañó por la investigación y el desarrollo
de la tecnología de compiladores correspondiente.
Algunas de estas ideas han incursionado en los diseños de las máquinas enbebidas. Debido
a que pueden caber sistemas completos en un solo chip, los procesadores ya no necesitan ser
unidades primarias preempaquetadas, sino que pueden personalizarse para lograr una mejor
efectividad en costo para una aplicación específica. Por ende, a diferencia de los procesadores de propósito general, en donde las economías de escala han llevado a las arquitecturas
22
Capítulo 1. Introducción
computacionales a convergir, los procesadores de aplicaciones específicas exhiben una diversidad de arquitecturas computacionales. La tecnología de compiladores se necesita no sólo para
dar soporte a la programación para estas arquitecturas, sino también para evaluar los diseños
arquitectónicos propuestos.
1.5.4
Traducciones de programas
Mientras que, por lo general, pensamos en la compilación como una traducción de un lenguaje
de alto nivel al nivel de máquina, la misma tecnología puede aplicarse para realizar traducciones entre distintos tipos de lenguajes. A continuación se muestran algunas de las aplicaciones
más importantes de las técnicas de traducción de programas.
Traducción binaria
La tecnología de compiladores puede utilizarse para traducir el código binario para una máquina al código binario para otra máquina distinta, con lo cual se permite a una máquina ejecutar
los programas que originalmente eran compilados para otro conjunto de instrucciones. Varias
compañías de computación han utilizado la tecnología de la traducción binaria para incrementar la disponibilidad de software en sus máquinas. En especial, debido al dominio en el mercado
de la computadora personal x86, la mayoría de los títulos de software están disponibles como
código x86. Se han desarrollado traductores binarios para convertir código x86 en código Alpha y Sparc. Además, Transmeta Inc. utilizó la traducción binaria en su implementación del
conjunto de instrucciones x86. En vez de ejecutar el complejo conjunto de instrucciones x86
directamente en el hardware, el procesador Transmeta Crusoe es un procesador VLIW que se
basa en la traducción binaria para convertir el código x86 en código VLIW nativo.
La traducción binaria también puede usarse para ofrecer compatibilidad inversa. Cuando el
procesador en la Apple Macintosh se cambió del Motorola MC 68040 al PowerPC en 1994, se
utilizó la traducción binaria para permitir que los procesadores PowerPC ejecutaran el código
heredado del MC 68040.
Síntesis de hardware
No sólo la mayoría del software está escrito en lenguajes de alto nivel; incluso hasta la mayoría
de los diseños de hardware se describen en lenguajes de descripción de hardware de alto nivel,
como Verilog y VHDL (Very High-Speed Integrated Circuit Hardware Description Lenguaje,
Lenguaje de descripción de hardware de circuitos integrados de muy alta velocidad). Por lo
general, los diseños de hardware se describen en el nivel de transferencia de registros (RTL), en
donde las variables representan registros y las expresiones representan la lógica combinacional.
Las herramientas de síntesis de hardware traducen las descripciones RTL de manera automática en compuertas, las cuales a su vez se asignan a transistores y, en un momento dado, a un
esquema físico. A diferencia de los compiladores para los lenguajes de programación, estas herramientas a menudo requieren horas para optimizar el circuito. También existen técnicas para
traducir diseños a niveles más altos, como al nivel de comportamiento o funcional.
Intérpretes de consultas de bases de datos
Además de especificar el software y el hardware, los lenguajes son útiles en muchas otras aplicaciones. Por ejemplo, los lenguajes de consulta, en especial SQL (Structured Query Lenguage,
1.5 Aplicaciones de la tecnología de compiladores
23
Lenguaje de consulta estructurado), se utilizan para realizar búsquedas en bases de datos. Las
consultas en las bases de datos consisten en predicados que contienen operadores relacionales
y booleanos. Pueden interpretarse o compilarse en comandos para buscar registros en una base
de datos que cumplan con ese predicado.
Simulación compilada
La simulación es una técnica general que se utiliza en muchas disciplinas científicas y de ingeniería para comprender un fenómeno, o para validar un diseño. Por lo general, las entradas
de los simuladores incluyen la descripción del diseño y los parámetros específicos de entrada
para esa ejecución específica de la simulación. Las simulaciones pueden ser muy costosas. Por
lo general, necesitamos simular muchas alternativas de diseño posibles en muchos conjuntos
distintos de entrada, y cada experimento puede tardar días en completarse, en una máquina
de alto rendimiento. En vez de escribir un simulador para interpretar el diseño, es más rápido
compilar el diseño para producir código máquina que simule ese diseño específico en forma
nativa. La simulación compilada puede ejecutarse muchos grados de magnitud más rápido que
un método basado en un intérprete. La simulación compilada se utiliza en muchas herramientas
de alta tecnología que simulan diseños escritos en Verilog o VHDL.
1.5.5
Herramientas de productividad de software
Sin duda, los programas son los artefactos de ingeniería más complicados que se hayan producido jamás; consisten en muchos, muchos detalles, cada uno de los cuales debe corregirse para
que el programa funcione por completo. Como resultado, los errores proliferan en los programas; éstos pueden hacer que un sistema falle, producir resultados incorrectos, dejar un sistema
vulnerable a los ataques de seguridad, o incluso pueden llevar a fallas catastróficas en sistemas
críticos. La prueba es la técnica principal para localizar errores en los programas.
Un enfoque complementario interesante y prometedor es utilizar el análisis de flujos de
datos para localizar errores de manera estática (es decir, antes de que se ejecute el programa).
El análisis de flujos de datos puede buscar errores a lo largo de todas las rutas posibles de ejecución, y no sólo aquellas ejercidas por los conjuntos de datos de entrada, como en el caso del
proceso de prueba de un programa. Muchas de las técnicas de análisis de flujos de datos, que se
desarrollaron en un principio para las optimizaciones de los compiladores, pueden usarse para
crear herramientas que ayuden a los programadores en sus tareas de ingeniería de software.
El problema de encontrar todos los errores en los programas es indicidible. Puede diseñarse
un análisis de flujos de datos para advertir a los programadores acerca de todas las posibles
instrucciones que violan una categoría específica de errores. Pero si la mayoría de estas advertencias son falsas alarmas, los usuarios no utilizarán la herramienta. Por ende, los detectores prácticos de errores en general no son sólidos ni están completos. Es decir, tal vez no
encuentren todos los errores en el programa, y no se garantiza que todos los errores reportados
sean errores verdaderos. Sin embargo, se han desarrollado diversos análisis estáticos que han
demostrado ser efectivos para buscar errores, como el realizar referencias a apuntadores nulos
o previamente liberados, en programas reales. El hecho de que los detectores de errores puedan ser poco sólidos los hace considerablemente distintos a las optimizaciones de código. Los
optimizadores deben ser conservadores y no pueden alterar la semántica del programa, bajo
ninguna circunstancia.
24
Capítulo 1. Introducción
Para dar equilibrio a esta sección, vamos a mencionar varias formas en las que el análisis de
los programas, basado en técnicas que se desarrollaron originalmente para optimizar el código
en los compiladores, ha mejorado la productividad del software. De especial importancia son
las técnicas que detectan en forma estática cuando un programa podría tener una vulnerabilidad de seguridad.
Comprobación (verificación) de tipos
La comprobación de tipos es una técnica efectiva y bien establecida para captar las inconsistencias en los programas. Por ejemplo, puede usarse para detectar errores en donde se aplique
una operación al tipo incorrecto de objeto, o si los parámetros que se pasan a un procedimiento
no coinciden con su firma. El análisis de los programas puede ir más allá de sólo encontrar
los errores de tipo, analizando el flujo de datos a través de un programa. Por ejemplo, si a un
apuntador se le asigna null y se desreferencia justo después, es evidente que el programa tiene
un error.
La misma tecnología puede utilizarse para detectar una variedad de huecos de seguridad,
en donde un atacante proporciona una cadena u otro tipo de datos que el programa utiliza sin
cuidado. Una cadena proporcionada por el usuario podría etiquetarse con el tipo de “peligrosa”. Si no se verifica el formato apropiado de la cadena, entonces se deja como “peligrosa”, y
si una cadena de este tipo puede influenciar el flujo de control del código en cierto punto del
programa, entonces hay una falla potencial en la seguridad.
Comprobación de límites
Es más fácil cometer errores cuando se programa en un lenguaje de bajo nivel que en uno de
alto nivel. Por ejemplo, muchas brechas de seguridad en los sistemas se producen debido a los
desbordamientos en las entradas y salidas de de los programas escritos en C. Como C no comprueba los límites de los arreglos, es responsabilidad del usuario asegurar que no se acceda a
los arreglos fuera de los límites. Si no se comprueba que los datos suministrados por el usuario
pueden llegar a desbordar un elemento, el programa podría caer en el truco de almacenar los
datos del usuario fuera del espacio asociado a este elemento. Un atacante podría manipular
los datos de entrada que hagan que el programa se comporte en forma errónea y comprometa
la seguridad del sistema. Se han desarrollado técnicas para encontrar los desbordamientos de
búfer en los programas, pero con un éxito limitado.
Si el programa se hubiera escrito en un lenguaje seguro que incluya la comprobación automática de los rangos, este problema no habría ocurrido. El mismo análisis del flujo de datos que
se utiliza para eliminar las comprobaciones de rango redundantes podría usarse también para
localizar los desbordamientos probables de un elemento. Sin embargo, la principal diferencia es
que al no poder eliminar una comprobación de rango sólo se produciría un pequeño incremento
en el tiempo de ejecución, mientras que el no identificar un desbordamiento potencial del búfer
podría comprometer la seguridad del sistema. Por ende, aunque es adecuado utilizar técnicas
simples para optimizar las comprobaciones de rangos, los análisis sofisticados, como el rastreo
de los valores de los apuntadores entre un procedimiento y otro, son necesarios para obtener
resultados de alta calidad en las herramientas para la detección de errores.
1.6 Fundamentos de los lenguajes de programación
25
Herramientas de administración de memoria
La recolección de basura es otro excelente ejemplo de la concesión entre la eficiencia y una
combinación de la facilidad de uso y la confiabilidad del software. La administración automática de la memoria elimina todos los errores de administración de memoria (por ejemplo, las
“fugas de memoria”), que son una fuente importante de problemas en los programas en C y
C++. Se han desarrollado varias herramientas para ayudar a los programadores a detectar los
errores de administración de memoria. Por ejemplo, Purify es una herramienta que se emplea
mucho, la cual capta en forma dinámica los errores de administración de memoria, a medida
que ocurren. También se han desarrollado herramientas que ayudan a identificar algunos de
estos problemas en forma estática.
1.6
Fundamentos de los lenguajes de programación
En esta sección hablaremos sobre la terminología más importante y las distinciones que aparecen en el estudio de los lenguajes de programación. No es nuestro objetivo abarcar todos
los conceptos o todos los lenguajes de programación populares. Asumimos que el lector está
familiarizado por lo menos con uno de los lenguajes C, C++, C# o Java, y que tal vez conozca
otros.
1.6.1
La distinción entre estático y dinámico
Una de las cuestiones más importantes a las que nos enfrentamos al diseñar un compilador
para un lenguaje es la de qué decisiones puede realizar el compilador acerca de un programa.
Si un lenguaje utiliza una directiva que permite al compilador decidir sobre una cuestión, entonces decimos que el lenguaje utiliza una directiva estática, o que la cuestión puede decidirse
en tiempo de compilación. Por otro lado, se dice que una directiva que sólo permite realizar
una decisión a la hora de ejecutar el programa es una directiva dinámica, o que requiere una
decisión en tiempo de ejecución.
Una de las cuestiones en las que nos debemos de concentrar es en el alcance de las declaraciones. El alcance de una declaración de x es la región del programa en la que los usos de x se
refieren a esta declaración. Un lenguaje utiliza el alcance estático o alcance léxico si es posible
determinar el alcance de una declaración con sólo ver el programa. En cualquier otro caso, el
lenguaje utiliza un alcance dinámico. Con el alcance dinámico, a medida que se ejecuta el programa, el mismo uso de x podría referirse a una de varias declaraciones distintas de x.
La mayoría de los lenguajes, como C y Java, utilizan el alcance estático. En la sección 1.6.3
hablaremos sobre éste.
Ejemplo 1.3: Como otro ejemplo de la distinción entre estático y dinámico, considere el
uso del término “static” según se aplica a los datos en la declaración de una clase en Java. En
Java, una variable es un nombre para una ubicación en memoria que se utiliza para almacenar
un valor de datos. Aquí, “static” no se refiere al alcance de la variable, sino a la habilidad del
compilador para determinar la ubicación en memoria en la que puede encontrarse la variable
declarada. Una declaración como:
public static int x;
26
Capítulo 1. Introducción
hace de x una variable de clase e indica que sólo hay una copia de x, sin importar cuántos
objetos se creen de esta clase. Lo que es más, el compilador puede determinar una ubicación
en memoria en la que se almacene este entero x. En contraste, si se hubiera omitido la palabra
“static” de esta declaración, entonces cada objeto de la clase tendría su propia ubicación en la
que se guardara x, y el compilador no podría determinar todos estos lugares antes de ejecutar
el programa. 2
1.6.2
Entornos y estados
Otra distinción importante que debemos hacer al hablar sobre los lenguajes de programación
es si los cambios que ocurren a medida que el programa se ejecuta afectan a los valores de los
elementos de datos, o si afectan a la interpretación de los nombres para esos datos. Por ejemplo,
la ejecución de una asignación como x = y + 1 cambia el valor denotado por el nombre x. Dicho
en forma más específica, la asignación cambia el valor en cualquier ubicación denotada por x.
Tal vez sea menos claro que la ubicación denotada por x puede cambiar en tiempo de ejecución. Por ejemplo, como vimos en el ejemplo 1.3, si x no es una variable estática (o de “clase”),
entonces cada objeto de la clase tiene su propia ubicación para una instancia de la variable x. En
este caso, la asignación para x puede cambiar cualquiera de esas variables de “instancia”, dependiendo del objeto en el que se aplique un método que contenga esa asignación.
estado
entorno
nombres
ubicaciones
(variables)
valores
Figura 1.8: Asignación de dos etapas, de nombres a valores
La asociación de nombres con ubicaciones en memoria (el almacén) y después con valores
puede describirse mediante dos asignaciones que cambian a medida que se ejecuta el programa
(vea la figura 1.8):
1. El entorno es una asignación de nombres a ubicaciones de memoria. Como las variables
se refieren a ubicaciones (“l-values” en la terminología de C), podríamos definir de manera alternativa un entorno como una asignación de nombres a variables.
2. El estado es una asignación de las ubicaciones en memoria a sus valores. Es decir, el
estado asigna l-values a sus correspondientes r-values, en la terminología de C.
Los entornos cambian de acuerdo a las reglas de alcance de un lenguaje.
Ejemplo 1.4: Considere el fragmento de un programa en C en la figura 1.9. El entero i se
declara como una variable global, y también se declara como una variable local para la función f.
Cuando f se ejecuta, el entorno se ajusta de manera que el nombre i se refiera a la ubicación reservada para la i que es local para f, y cualquier uso de i, como la asignación i = 3 que
27
1.6 Fundamentos de los lenguajes de programación
...
int i;
...
void f(...) {
int i;
...
i = 3;
...
}
...
x = i + 1;
/* i global
*/
/* i local
*/
/* uso de la i local */
/* uso de la i global */
Figura 1.9: Dos declaraciones del nombre i
se muestra en forma explícita, se refiere a esa ubicación. Por lo general, a la i local se le otorga
un lugar en la pila en tiempo de ejecución.
Cada vez que se ejecuta una función g distinta de f, los usos de i no pueden referirse a la i
que es local para f. Los usos del nombre i en g deben estar dentro del alcance de alguna otra
declaración de i. Un ejemplo es la instrucción x = i+1 mostrada en forma explícita, la cual se
encuentra dentro de un procedimiento cuya definición no se muestra. La i en i + 1 se refiere
supuestamente a la i global. Al igual que en la mayoría de los lenguajes, las declaraciones en C
deben anteponer su uso, por lo que una función que esté antes de la i global no puede hacer
referencia a ella. 2
Las asignaciones de entorno y de estado en la figura 1.8 son dinámicas, pero hay unas
cuantas excepciones:
1. Comparación entre enlace estático y dinámico de los nombres con las ubicaciones. La
mayoría de la vinculación de los nombres con las ubicaciones es dinámica, y veremos
varios métodos para esta vinculación a lo largo de esta sección. Algunas declaraciones,
como la i global en la figura 1.9, pueden recibir una ubicación en memoria una sola vez,
a medida que el compilador genera el código objeto.2
2. Comparación entre enlace estático y dinámico de las ubicaciones con los valores. Por lo
general, el enlace de las ubicaciones con los valores (la segunda etapa en la figura 1.8) es
dinámica también, ya que no podemos conocer el valor en una ubicación sino hasta ejecutar el programa. Las constantes declaradas son una excepción. Por ejemplo, la siguiente
definición en C:
#define ARRAYSIZE 1000
2
Técnicamente, el compilador de C asignará una ubicación en memoria virtual para la i global, dejando la responsabilidad al cargador y al sistema operativo de determinar en qué parte de la memoria física de la máquina se ubicará i.
Sin embargo, no debemos preocuparnos por las cuestiones de “reubicación” tales como éstas, que no tienen impacto
sobre la compilación. En vez de ello, tratamos el espacio de direcciones que utiliza el compilador para su código de
salida como si otorgara las ubicaciones de memoria física.
28
Capítulo 1. Introducción
Nombres, identificadores y variables
Aunque los términos “nombre” y “variable” a menudo se refieren a lo mismo, los utilizamos con cuidado para diferenciar entre los nombres en tiempo de compilación y las
ubicaciones en tiempo de ejecución denotadas por los nombres.
Un identificador es una cadena de caracteres, por lo general letras o dígitos, que se
refiere a (identifica) una entidad, como un objeto de datos, un procedimiento, una clase
o un tipo. Todos los identificadores son nombres, pero no todos los nombres son identificadores. Los nombres también pueden ser expresiones. Por ejemplo, el nombre x.y podría
denotar el campo y de una estructura denotada por x. Aquí, x y y son identificadores,
mientras que x.y es un nombre, pero no un identificador. A los nombres compuestos como
x.y se les llama nombres calificados.
Una variable se refiere a una ubicación específica en memoria. Es común que el mismo
identificador se declare más de una vez; cada una de esas declaraciones introduce una
nueva variable. Aun cuando cada identificador se declara sólo una vez, un identificador
que sea local para un procedimiento recursivo hará referencia a las distintas ubicaciones
de memoria, en distintas ocasiones.
enlaza el nombre ARRAYSIZE con el valor 1000 en forma estática. Podemos determinar
este enlace analizando la instrucción, y sabemos que es imposible que esta vinculación
cambie cuando se ejecute el programa.
1.6.3
Alcance estático y estructura de bloques
La mayoría de los lenguajes, incluyendo a C y su familia, utilizan el alcance estático. Las reglas
de alcance para C se basan en la estructura del programa; el alcance de una declaración se determina en forma implícita, mediante el lugar en el que aparece la declaración en el programa.
Los lenguajes posteriores, como C++, Java y C#, también proporcionan un control explícito
sobre los alcances, a través del uso de palabras clave como public, private y protected.
En esta sección consideramos las reglas de alcance estático para un lenguaje con bloques,
en donde un bloque es una agrupación de declaraciones e instrucciones. C utiliza las llaves { y
} para delimitar un bloque; el uso alternativo de begin y end para el mismo fin se remonta
hasta Algol.
Ejemplo 1.5: Para una primera aproximación, la directiva de alcance estático de C es la
siguiente:
1. Un programa en C consiste en una secuencia de declaraciones de nivel superior de variables y funciones.
2. Las funciones pueden contener declaraciones de variables, en donde las variables incluyen
variables locales y parámetros. El alcance de una declaración de este tipo se restringe a
la función en la que aparece.
1.6 Fundamentos de los lenguajes de programación
29
Procedimientos, funciones y métodos
Para evitar decir “procedimientos, funciones o métodos” cada vez que queremos hablar
sobre un subprograma que pueda llamarse, nos referiremos, por lo general, a todos ellos
como “procedimientos”. La excepción es que al hablar en forma explícita de los programas en lenguajes como C, que sólo tienen funciones, nos referiremos a ellos como “funciones”. O, si hablamos sobre un lenguaje como Java, que sólo tiene métodos, utilizaremos
ese término.
Por lo general, una función devuelve un valor de algún tipo (el “tipo de retorno”),
mientras que un procedimiento no devuelve ningún valor. C y los lenguajes similares, que
sólo tienen funciones, tratan a los procedimientos como funciones con un tipo de retorno
especial “void”, para indicar que no hay valor de retorno. Los lenguajes orientados a objetos como Java y C++ utilizan el término “métodos”. Éstos pueden comportarse como
funciones o procedimientos, pero se asocian con una clase específica.
3. El alcance de una declaración de nivel superior de un nombre x consiste en todo el programa que le sigue, con la excepción de las instrucciones que se encuentran dentro de una
función que también tiene una declaración de x.
Los detalles adicionales en relación con la directiva de alcance estático de C tratan con las
declaraciones de variables dentro de instrucciones. Examinaremos dichas declaraciones a continuación y en el ejemplo 1.6. 2
En C, la sintaxis de los bloques se da en base a lo siguiente:
1. Un tipo de instrucción es un bloque. Los bloques pueden aparecer en cualquier parte en
la que puedan aparecer otros tipos de instrucciones, como las instrucciones de asignación.
2. Un bloque es una secuencia de declaraciones que va seguida de una secuencia de instrucciones, todas rodeadas por llaves.
Observe que esta sintaxis permite anidar bloques, uno dentro de otro. Esta propiedad de
anidamiento se conoce como estructura de bloques. La familia C de lenguajes tiene estructura
de bloques, con la excepción de que una función tal vez no se defina dentro de otra.
Decimos que una declaración D “pertenece” a un bloque B, si B es el bloque anidado más
cercano que contiene a D; es decir, D se encuentra dentro de B, pero no dentro de cualquier
bloque que esté anidado dentro de B.
La regla de alcance estático para las declaraciones de variables en un lenguaje estructurado
por bloques es la siguiente: Si la declaración D del nombre x pertenece al bloque B, entonces
el alcance de D es todo B, excepto para cualquier bloque B anidado a cualquier profundidad
dentro de B, en donde x se vuelve a declarar. Aquí, x se vuelve a declarar en B si alguna
otra declaración D del mismo nombre x pertenece a B .
30
Capítulo 1. Introducción
Una forma equivalente de expresar esta regla es enfocándose en un uso de un nombre x.
Digamos que B1, B2,…, Bk sean todos los bloques que rodean este uso de x, en donde Bk es el
más pequeño, anidado dentro de Bk–1, que a su vez se anida dentro de Bk–2, y así en lo sucesivo.
Hay que buscar la i más grande de tal forma que haya una declaración de x que pertenezca a Bi.
Este uso de x se refiere a la declaración en Bi. De manera alternativa, este uso de x se encuentra
dentro del alcance de la declaración en Bi.
Figura 1.10: Bloques en un programa en C++
Ejemplo 1.6: El programa en C++ de la figura 1.10 tiene cuatro bloques, con varias definiciones de las variables a y b. Como ayuda de memoria, cada declaración inicializa su variable
con el número del bloque al que pertenece.
Por ejemplo, considere la declaración int a = 1 en el bloque B1. Su alcance es todo B1,
excepto los bloques anidados (tal vez con más profundidad) dentro de B1 que tengan su propia
declaración de a. B2, que está anidado justo dentro de B1, no tiene una declaración de a, pero
B3 sí. B4 no tiene una declaración de a, por lo que el bloque B3 es el único lugar en todo el
programa que se encuentra fuera del alcance de la declaración del nombre a que pertenece a B1.
Esto es, el alcance incluye a B4 y a todo B2, excepto la parte de B2 que se encuentra dentro de
B3. Los alcances de las cinco declaraciones se sintetizan en la figura 1.11.
Desde otro punto de vista, vamos a considerar la instrucción de salida en el bloque B4 y
a enlazar las variables a y b que se utilizan ahí con las declaraciones apropiadas. La lista de
bloques circundantes, en orden de menor a mayor tamaño, es B4, B2, B1. Observe que B3 no
rodea al punto en cuestión. B4 tiene una declaración de b, por lo que es a esta declaración a la
que este uso de b hace referencia, y el valor de b que se imprime es 4. Sin embargo, B4 no tiene
una declaración de a, por lo que ahora analizamos B2. Ese bloque no tiene una declaración de
31
1.6 Fundamentos de los lenguajes de programación
DECLARACIÓN
ALCANCE
Figura 1.11: Alcances de las declaraciones en el ejemplo 1.6
a tampoco, por lo que continuamos con B1. Por fortuna, hay una declaración int a = 1 que
pertenece a ese bloque, por lo que el valor de a que se imprime es 1. Si no hubiera dicha declaración, el programa tendría error.
2
1.6.4
Control de acceso explícito
Las clases y las estructuras introducen un nuevo alcance para sus miembros. Si p es un objeto
de una clase con un campo (miembro) x, entonces el uso de x en p.x se refiere al campo x en
la definición de la clase. En analogía con la estructura de bloques, el alcance de la declaración
de un miembro x en una clase C se extiende a cualquier subclase C , excepto si C tiene una declaración local del mismo nombre x.
Mediante el uso de palabras clave como public, private y protected, los lenguajes
orientados a objetos como C++ o Java proporcionan un control explícito sobre el acceso a los
nombres de los miembros en una superclase. Estas palabras clave soportan el encapsulamiento
mediante la restricción del acceso. Por ende, los nombres privados reciben de manera intencional un alcance que incluye sólo las declaraciones de los métodos y las definiciones asociadas con
esa clase, y con cualquier clase “amiga” (friend: el término de C++). Los nombres protegidos
son accesibles para las subclases. Los nombres públicos son accesibles desde el exterior de la
clase.
En C++, la definición de una clase puede estar separada de las definiciones de algunos o de
todos sus métodos. Por lo tanto, un nombre x asociado con la clase C puede tener una región
del código que se encuentra fuera de su alcance, seguida de otra región (la definición de un método) que se encuentra dentro de su alcance. De hecho, las regiones dentro y fuera del alcance
pueden alternar, hasta que se hayan definido todos los métodos.
1.6.5
Alcance dinámico
Técnicamente, cualquier directiva de alcance es dinámica si se basa en un factor o factores que
puedan conocerse sólo cuando se ejecute el programa. Sin embargo, el término alcance dinámico se refiere, por lo general, a la siguiente directiva: el uso de un nombre x se refiere a la declaración de x en el procedimiento que se haya llamado más recientemente con dicha declaración.
El alcance dinámico de este tipo aparece sólo en situaciones especiales. Vamos a considerar
dos ejemplos de directivas dinámicas: la expansión de macros en el preprocesador de C y la
resolución de métodos en la programación orientada a objetos.
32
Capítulo 1. Introducción
Declaraciones y definiciones
Los términos aparentemente similares “declaración” y “definición” para los conceptos de
los lenguajes de programación, son en realidad bastante distintos. Las declaraciones nos
hablan acerca de los tipos de cosas, mientras que las definiciones nos hablan sobre sus valores. Por ende, int i es una declaración de i, mientras que i = 1 es una definición de i.
La diferencia es más considerable cuando tratamos con métodos u otros procedimientos. En C++, un método se declara en la definición de una clase, proporcionando los
tipos de los argumentos y el resultado del método (que a menudo se le conoce como la
firma del método). Después el método se define (es decir, se proporciona el código para
ejecutar el método) en otro lugar. De manera similar, es común definir una función de C
en un archivo y declararla en otros archivos, en donde se utiliza esta función.
Ejemplo 1.7: En el programa en C de la figura 1.12, el identificador a es una macro que
representa la expresión (x + 1). Pero, ¿qué es x? No podemos resolver x de manera estática, es
decir, en términos del texto del programa.
Figura 1.12: Una macro para la cual se debe calcular el alcance de sus nombres en forma
dinámica
De hecho, para poder interpretar a x, debemos usar la regla de alcance dinámico ordinaria.
Examinamos todas las llamadas a funciones que se encuentran activas, y tomamos la función
que se haya llamado más recientemente y que tenga una declaración de x. A esta declaración es
a la que se refiere x.
En el ejemplo de la figura 1.12, la función main primero llama a la función b. A medida
que b se ejecuta, imprime el valor de la macro a. Como debemos sustituir (x + 1) por a, resolvemos este uso de x con la declaración int x = 1 en la función b. La razón es que b tiene una
declaración de x, por lo que el término (x + 1) en printf en b se refiere a esta x. Por ende, el
valor que se imprime es 1.
Una vez que b termina y que se hace la llamada a c, debemos imprimir de nuevo el valor de
la macro a. No obstante, la única x accesible para c es la x global. Por lo tanto, la instrucción
printf en c se refiere a esta declaración de x, y se imprime el valor de 2.
2
La resolución dinámica del alcance también es esencial para los procedimientos polimórficos, aquellos que tienen dos o más definiciones para el mismo nombre, dependiendo sólo de los
1.6 Fundamentos de los lenguajes de programación
33
Analogía entre el alcance estático y dinámico
Aunque podría haber cualquier número de directivas estáticas o dinámicas para el alcance, hay una interesante relación entre la regla de alcance estático normal (estructurado
por bloques) y la directiva dinámica normal. En cierto sentido, la regla dinámica es para
el tiempo lo que la regla estática es para el espacio. Mientras que la regla estática nos
pide buscar la declaración cuya unidad (bloque) sea la más cercana en rodear la ubicación física del uso, la regla dinámica nos pide que busquemos la declaración cuya unidad
(invocación a un procedimiento) sea la más cercana en rodear el momento del uso.
tipos de los argumentos. En algunos lenguajes como ML (vea la sección 7.3.3), es posible determinar los tipos en forma estática para todos los usos de los nombres, en cuyo caso el compilador
puede sustituir cada uso del nombre de un procedimiento p por una referencia al código para
el procedimiento apropiado. No obstante, en otros lenguajes como Java y C++, hay momentos
en los que el compilador no puede realizar esa determinación.
Ejemplo 1.8: Una característica distintiva de la programación orientada a objetos es la habilidad de cada objeto de invocar el método apropiado, en respuesta a un mensaje. En otras palabras, el procedimiento al que se llama cuando se ejecuta x.m() depende de la clase del objeto
denotado por x en ese momento. A continuación se muestra un ejemplo típico:
1. Hay una clase C con un método llamado m().
2. D es una subclase de C, y D tiene su propio método llamado m().
3. Hay un uso de m de la forma x.m(), en donde x es un objeto de la clase C.
Por lo general, es imposible saber en tiempo de compilación si x será de la clase C o de la
subclase D. Si la aplicación del método ocurre varias veces, es muy probable que algunas se
realicen con objetos denotados por x, que estén en la clase C pero no en D, mientras que otras
estarán en la clase D. No es sino hasta el tiempo de ejecución que se puede decidir cuál definición de m es la correcta. Por ende, el código generado por el compilador debe determinar la
clase del objeto x, y llamar a uno de los dos métodos llamados m.
2
1.6.6
Mecanismos para el paso de parámetros
Todos los lenguajes de programación tienen una noción de un procedimiento, pero pueden diferir en cuanto a la forma en que estos procedimientos reciben sus argumentos. En esta sección
vamos a considerar cómo se asocian los parámetros actuales (los parámetros que se utilizan
en la llamada a un procedimiento) con los parámetros formales (los que se utilizan en la definición del procedimiento). El mecanismo que se utilice será el que determine la forma en que
el código de secuencia de llamadas tratará a los parámetros. La gran mayoría de los lenguajes
utilizan la “llamada por valor”, la “llamada por referencia”, o ambas. Vamos a explicar estos
términos, junto con otro método conocido como “llamada por nombre”, que es principalmente
de interés histórico.
34
Capítulo 1. Introducción
Llamada por valor
En la llamada por valor, el parámetro actual se evalúa (si es una expresión) o se copia (si es una
variable). El valor se coloca en la ubicación que pertenece al correspondiente parámetro formal
del procedimiento al que se llamó. Este método se utiliza en C y en Java, además de ser una
opción común en C++, así como en la mayoría de los demás lenguajes. La llamada por valor
tiene el efecto de que todo el cálculo que involucra a los parámetros formales, y que realiza el
procedimiento al que se llamó, es local para ese procedimiento, y los parámetros actuales en sí
no pueden modificarse.
Sin embargo, observe que en C podemos pasar un apuntador a una variable para permitir
que el procedimiento al que se llamó modifique la variable. De igual forma, los nombres de
arreglos que se pasan como parámetros en C, C++ o Java, ofrecen al procedimiento que se
llamó lo que es en efecto un apuntador, o una referencia al mismo arreglo. Por ende, si a es el
nombre de un arreglo del procedimiento que hace la llamada, y se pasa por valor al correspondiente parámetro formal x, entonces una asignación tal como x[i] = 2 en realidad modifica el
elemento del arreglo a[2]. La razón es que, aunque x obtiene una copia del valor de a, ese valor
es en realidad un apuntador al inicio del área de memoria en donde se encuentra el arreglo
llamado a.
De manera similar, en Java muchas variables son en realidad referencias, o apuntadores, a
las cosas que representan. Esta observación se aplica a los arreglos, las cadenas y los objetos de
todas las clases. Aún y cuando Java utiliza la llamada por valor en forma exclusiva, cada vez
que pasamos el nombre de un objeto a un procedimiento al que se llamó, el valor que recibe ese
procedimiento es, en efecto, un apuntador al objeto. En consecuencia, el procedimiento al que
se llamó puede afectar al valor del objeto en sí.
Llamada por referencia
En la llamada por referencia, la dirección del parámetro actual se pasa al procedimiento al que
se llamó como el valor del correspondiente parámetro formal. Los usos del parámetro formal en
el código del procedimiento al que se llamó se implementan siguiendo este apuntador hasta la
ubicación indicada por el procedimiento que hizo la llamada. Por lo tanto, las modificaciones
al parámetro formal aparecen como cambios para el parámetro actual.
No obstante, si el parámetro actual es una expresión, entonces ésta se evalúa antes de la
llamada y su valor se almacena en su propia ubicación. Los cambios al parámetro formal cambian esta ubicación, pero no pueden tener efecto sobre los datos del procedimiento que hizo la
llamada.
La llamada por referencia se utiliza para los parámetros “ref” en C++, y es una opción en
muchos otros lenguajes. Es casi esencial cuando el parámetro formal es un objeto, arreglo o
estructura grande. La razón es que la llamada estricta por valor requiere que el procedimiento
que hace la llamada copie todo el parámetro actual en el espacio que pertenece al correspondiente parámetro formal. Este proceso de copiado se vuelve extenso cuando el parámetro es
grande. Como dijimos al hablar sobre la llamada por valor, los lenguajes como Java resuelven el
problema de pasar arreglos, cadenas u otros objetos copiando sólo una referencia a esos objetos.
El efecto es que Java se comporta como si usara la llamada por referencia para cualquier cosa
que no sea un tipo básico como entero o real.
1.6 Fundamentos de los lenguajes de programación
35
Llamada por nombre
Hay un tercer mecanismo (la llamada por nombre) que se utilizó en uno de los primeros lenguajes de programación: Algol 60. Este mecanismo requiere que el procedimiento al que se llamó
se ejecute como si el parámetro actual se sustituyera literalmente por el parámetro formal en
su código, como si el procedimiento formal fuera una macro que representa al parámetro actual
(cambiando los nombres locales en el procedimiento al que se llamó, para que sean distintos).
Cuando el parámetro actual es una expresión en vez de una variable, se producen ciertos comportamientos no intuitivos, razón por la cual este mecanismo no es popular hoy en día.
1.6.7
Uso de alias
Hay una consecuencia interesante del paso por parámetros tipo llamada por referencia o de su
simulación, como en Java, en donde las referencias a los objetos se pasan por valor. Es posible
que dos parámetros formales puedan referirse a la misma ubicación; se dice que dichas variables
son alias una de la otra. Como resultado, dos variables cualesquiera, que dan la impresión de
recibir sus valores de dos parámetros formales distintos, pueden convertirse en alias una de la
otra, también.
Ejemplo 1.9: Suponga que a es un arreglo que pertenece a un procedimiento p, y que p llama
a otro procedimiento q(x,y) con una llamada q(a,a). Suponga también que los parámetros se
pasan por valor, pero que los nombres de los arreglos son en realidad referencias a la ubicación
en la que se almacena el arreglo, como en C o los lenguajes similares. Ahora, las variables x y
y se han convertido en alias una de la otra. El punto importante es que si dentro de q hay una
asignación x[10] = 2, entonces el valor de y[10] también se convierte en 2.
2
Resulta que es esencial comprender el uso de los alias y los mecanismos que los crean si un
compilador va a optimizar un programa. Como veremos al inicio del capítulo 9, hay muchas
situaciones en las que sólo podemos optimizar código si podemos estar seguros de que ciertas
variables no son alias. Por ejemplo, podríamos determinar que x = 2 es el único lugar en el
que se asigna la variable x. De ser así, entonces podemos sustituir el uso de x por el uso de 2;
por ejemplo, sustituimos a = x+3 por el término más simple a = 5. Pero suponga que hay otra
variable y que sirve como alias para x. Entonces una asignación y = 4 podría tener el efecto
inesperado de cambiar a x. Esto también podría significar que sería un error sustituir a = x+3
por a = 5, ya que el valor correcto de a podría ser 7.
1.6.8
Ejercicios para la sección 1.6
Ejercicio 1.6.1: Para el código en C estructurado por bloques de la figura 1.13(a), indique
los valores asignados a w, x, y y z.
Ejercicio 1.6.2: Repita el ejercicio 1.6.1 para el código de la figura 1.13(b).
Ejercicio 1.6.3: Para el código estructurado por bloques de la figura 1.14, suponiendo el
alcance estático usual de las declaraciones, dé el alcance para cada una de las doce declaraciones.
36
Capítulo 1. Introducción
(a) Código para el ejercicio 1.6.1
(b) Código para el ejercicio 1.6.2
Figura 1.13: Código estructurado por bloques
Figura 1.14: Código estructurado por bloques para el ejercicio 1.6.3
Ejercicio 1.6.4: ¿Qué imprime el siguiente código en C?
1.7
Resumen del capítulo 1
♦ Procesadores de lenguaje. Un entorno de desarrollo integrado de software incluye muchos
tipos distintos de procesadores de lenguaje, como compiladores, intérpretes, ensambladores, enlazadores, cargadores, depuradores, profilers.
♦ Fases del compilador. Un compilador opera como una secuencia de fases, cada una de las
cuales transforma el programa fuente de una representación intermedia a otra.
1.7 Resumen del capítulo 1
37
♦ Lenguajes máquina y ensamblador. Los lenguajes máquina fueron los lenguajes de programación de la primera generación, seguidos de los lenguajes ensambladores. La programación en estos lenguajes requería de mucho tiempo y estaba propensa a errores.
♦ Modelado en el diseño de compiladores. El diseño de compiladores es una de las fases en
las que la teoría ha tenido el mayor impacto sobre la práctica. Entre los modelos que se
han encontrado de utilidad se encuentran: autómatas, gramáticas, expresiones regulares,
árboles y muchos otros.
♦ Optimización de código. Aunque el código no puede verdaderamente “optimizarse”, esta
ciencia de mejorar la eficiencia del código es tanto compleja como muy importante. Constituye una gran parte del estudio de la compilación.
♦ Lenguajes de alto nivel. A medida que transcurre el tiempo, los lenguajes de programación se encargan cada vez más de las tareas que se dejaban antes al programador, como
la administración de memoria, la comprobación de consistencia en los tipos, o la ejecución del código en paralelo.
♦ Compiladores y arquitectura de computadoras. La tecnología de compiladores ejerce una
influencia sobre la arquitectura de computadoras, así como también se ve influenciada por
los avances en la arquitectura. Muchas innovaciones modernas en la arquitectura dependen de la capacidad de los compiladores para extraer de los programas fuente las oportunidades de usar con efectividad las capacidades del hardware.
♦ Productividad y seguridad del software. La misma tecnología que permite a los compiladores optimizar el código puede usarse para una variedad de tareas de análisis de
programas, que van desde la detección de errores comunes en los programas, hasta el
descubrimiento de que un programa es vulnerable a uno de los muchos tipos de intrusiones que han descubierto los “hackers”.
♦ Reglas de alcance. El alcance de una declaración de x es el contexto en el cual los usos
de x se refieren a esta declaración. Un lenguaje utiliza el alcance estático o alcance léxico
si es posible determinar el alcance de una declaración con sólo analizar el programa. En
cualquier otro caso, el lenguaje utiliza un alcance dinámico.
♦ Entornos. La asociación de nombres con ubicaciones en memoria y después con los valores puede describirse en términos de entornos, los cuales asignan los nombres a las ubicaciones en memoria, y los estados, que asignan las ubicaciones a sus valores.
♦ Estructura de bloques. Se dice que los lenguajes que permiten anidar bloques tienen estructura de bloques. Un nombre x en un bloque anidado B se encuentra en el alcance
de una declaración D de x en un bloque circundante, si no existe otra declaración de x
en un bloque intermedio.
♦ Paso de parámetros. Los parámetros se pasan de un procedimiento que hace la llamada
al procedimiento que es llamado, ya sea por valor o por referencia. Cuando se pasan objetos grandes por valor, los valores que se pasan son en realidad referencias a los mismos
objetos, lo cual resulta en una llamada por referencia efectiva.
38
Capítulo 1. Introducción
♦ Uso de alias. Cuando los parámetros se pasan (de manera efectiva) por referencia, dos
parámetros formales pueden referirse al mismo objeto. Esta posibilidad permite que un
cambio en una variable cambie a la otra.
1.8
Referencias para el capítulo 1
Para el desarrollo de los lenguajes de programación que se crearon y han estado en uso desde
1967, incluyendo Fortran, Algol, Lisp y Simula, vea [7]. Para los lenguajes que se crearon para
1982, incluyendo C, C++, Pascal y Smalltalk, vea [1].
La Colección de compiladores de GNU, gcc, es una fuente popular de compiladores de
código fuente abierto para C, C++, Fortran, Java y otros lenguajes [2]. Phoenix es un kit
de herramientas para construir compiladores que proporciona un framework integrado para
construir las fases de análisis del programa, generación de código y optimización de código de
los compiladores que veremos en este libro [3].
Para obtener más información acerca de los conceptos de los lenguajes de programación,
recomendamos [5,6]. Para obtener más información sobre la arquitectura de computadoras y el
impacto que tiene en la compilación, sugerimos [4].
1. Bergin, T. J. y R. G. Gibson, History of Programming Languages, ACM Press, Nueva
York, 1996.
2. http://gcc.gnu.org/.
3. http://research.microsoft.com/phoenix/default.aspx.
4. Hennessy, J. L. y D. A. Patterson, Computer Organization and Design: The Hardware/
Software Interface, Morgan-Kaufmann, San Francisco, CA, 2004.
5. Scott M. L., Programming Language Pragmatics, segunda edición, Morgan-Kaufmann,
San Francisco, CA, 2006.
6. Sethi, R., Programming Languages: Concepts and Constructs, Addison-Wesley, 1996.
7. Wexelblat, R. L., History of Programming Languages, Academic Press, Nueva York,
1981.
Capítulo 2
Un traductor simple orientado
a la sintaxis
Este capítulo es una introducción a las técnicas de compilación que veremos en los capítulos
3 a 6 de este libro. Presenta las técnicas mediante el desarrollo de un programa funcional en
Java que traduce instrucciones representativas de un lenguaje de programación en un código
de tres direcciones, una representación intermedia. En este capítulo haremos énfasis en el frontend de un compilador, en especial en el análisis de léxico, el análisis sintáctico (parsing), y la
generación de código intermedio. En los capítulos 7 y 8 veremos cómo generar instrucciones de
máquina a partir del código de tres direcciones.
Empezaremos con algo pequeño, creando un traductor orientado a la sintaxis que asigna
expresiones aritméticas infijo a expresiones postfijo. Después extenderemos este traductor para
que asigne fragmentos de código (como se muestra en la figura 2.1) a un código de tres direcciones de la forma que se presenta en la figura 2.2.
El traductor funcional en Java aparece en el apéndice A. El uso de Java es conveniente,
pero no esencial. De hecho, las ideas en este capítulo son anteriores a la creación de Java y
de C.
Figura 2.1: Un fragmento de código que se va a traducir
39
40
Capítulo 2. Un traductor simple orientado a la sintaxis
Figura 2.2: Código intermedio simplificado para el fragmento del programa de la figura 2.1
2.1
Introducción
La fase de análisis de un compilador descompone un programa fuente en piezas componentes y
produce una representación interna, a la cual se le conoce como código intermedio. La fase de
síntesis traduce el código intermedio en el programa destino.
El análisis se organiza de acuerdo con la “sintaxis” del lenguaje que se va a compilar. La
sintaxis de un lenguaje de programación describe el formato apropiado de sus programas, mientras que la semántica del lenguaje define lo que sus programas significan; es decir, lo que hace
cada programa cuando se ejecuta. Para especificar la sintaxis, en la sección 2.2 presentamos
una notación de uso popular, llamada gramáticas sin contexto o BNF (Forma de Backus-Naur).
Con las notaciones que se tienen disponibles, es mucho más difícil describir la semántica de un
lenguaje que la sintaxis. Para especificar la semántica, deberemos, por lo tanto, usar descripciones informales y ejemplos sugerentes.
Además de especificar la sintaxis de un lenguaje, se puede utilizar una gramática libre de
contexto para ayudar a guiar la traducción de los programas. En la sección 2.3 presentamos
una técnica de compilación orientada a la gramática, conocida como traducción orientada a la
sintaxis. En la sección 2.4 presentamos el análisis sintáctico (parsing).
El resto de este capítulo es una guía rápida a través del modelo de un front-end de un compilador en la figura 2.3. Empezamos con el analizador sintáctico. Por cuestión de simplicidad,
consideramos la traducción orientada a la sintaxis de las expresiones infijas al formato postfijo,
una notación en la cual los operadores aparecen después de sus operandos. Por ejemplo, el
formato postfijo de la expresión 9 − 5 + 2 es 95 − 2 +. La traducción al formato postfijo es lo
bastante completa como para ilustrar el análisis sintáctico, y a la vez lo bastante simple como
para que se pueda mostrar el traductor por completo en la sección 2.5. El traductor simple
maneja expresiones como 9 − 5 + 2, que consisten en dígitos separados por signos positivos
y negativos. Una razón para empezar con dichas expresiones simples es que el analizador sintáctico puede trabajar directamente con los caracteres individuales para los operadores y los
operandos.
41
2.1 Introducción
Analizador
Analizador tokens
sintáctico
léxico
(parser)
programa
fuente
árbol
sintáctico
Generado código de tres
de código
direcciones
intermedio
Tabla de
símbolos
Figura 2.3: Un modelo de una front-end de un compilador
Un analizador léxico permite que un traductor maneje instrucciones de varios caracteres
como identificadores, que se escriben como secuencias de caracteres, pero se tratan como unidades conocidas como tokens durante el análisis sintáctico; por ejemplo, en la expresión cuen−
ta+1, el identificador cuenta se trata como una unidad. El analizador léxico en la sección 2.6
permite que aparezcan números, identificadores y “espacio en blanco” (espacios, tabuladores y
caracteres de nueva línea) dentro de las expresiones.
A continuación, consideramos la generación de código intermedio. En la figura 2.4 se ilustran dos formas de código intermedio. Una forma, conocida como árboles sintácticos abstractos
o simplemente árboles sintácticos, representa la estructura sintáctica jerárquica del programa
fuente. En el modelo de la figura 2.3, el analizador sintáctico produce un árbol sintáctico, que
se traduce posteriormente en código de tres direcciones. Algunos compiladores combinan el
análisis sintáctico y la generación de código intermedio en un solo componente.
1: i = i + 1
2: t1 = a [ i ]
3: if t1 < v goto 1
do-while
>
cuerpo
(b)
[]
asignación
i
a
+
i
v
i
1
(a)
Figura 2.4: Código intermedio para “do i=i+1; while (a[i] <v);”
La raíz del árbol sintáctico abstracto en la figura 2.4(a) representa un ciclo do-while completo. El hijo izquierdo de la raíz representa el cuerpo del ciclo, que consiste sólo de la asignación i=i+1;. El hijo derecho de la raíz representa la condición a[i]<v. En la sección 2.8(a)
aparece una implementación de los árboles sintácticos.
42
Capítulo 2. Un traductor simple orientado a la sintaxis
La otra representación intermedia común, que se muestra en la figura 2.4(b), es una secuencia de instrucciones de “tres direcciones”; en la figura 2.2 aparece un ejemplo más completo.
Esta forma de código intermedio recibe su nombre de las instrucciones de la forma x = y op z,
en donde op es un operador binario, y y z son las direcciones para los operandos, y x es la
dirección del resultado de la operación. Una instrucción de tres direcciones lleva a cabo cuando
menos una operación, por lo general, un cálculo, una comparación o una bifurcación.
En el apéndice A reunimos las técnicas descritas en este capítulo para crear un front-end
de un compilador en Java. La interfaz traduce las instrucciones en instrucciones en lenguaje
ensamblador.
2.2
Definición de sintaxis
En esta sección presentamos una notación (la “gramática libre de contexto”, o simplemente
“gramática”) que se utiliza para especificar la sintaxis de un lenguaje. Utilizaremos las gramáticas a lo largo de este libro para organizar los front-ends de los compiladores.
Una gramática describe en forma natural la estructura jerárquica de la mayoría de las instrucciones de un lenguaje de programación. Por ejemplo, una instrucción if-else en Java puede
tener la siguiente forma:
if ( expr ) instr else instr
Esto es, una instrucción if-else es la concatenación de la palabra clave if, un paréntesis
abierto, una expresión, un paréntesis cerrado, una instrucción, la palabra clave else y otra
instrucción. Mediante el uso de la variable expr para denotar una expresión y la variable instr
para denotar una instrucción, esta regla de estructuración puede expresarse de la siguiente
manera:
instr
→ if ( expr ) instr else instr
en donde la flecha se lee como “puede tener la forma”. A dicha regla se le llama producción.
En una producción, los elementos léxicos como la palabra clave if y los paréntesis se llaman
terminales. Las variables como expr e instr representan secuencias de terminales, y se llaman no terminales.
2.2.1
Definición de gramáticas
Una gramática libre de contexto tiene cuatro componentes:
1. Un conjunto de símbolos terminales, a los que algunas veces se les conoce como “tokens”.
Los terminales son los símbolos elementales del lenguaje definido por la gramática.
2. Un conjunto de no terminales, a las que algunas veces se les conoce como “variables
sintácticas”. Cada no terminal representa un conjunto de cadenas o terminales, de una
forma que describiremos más adelante.
3. Un conjunto de producciones, en donde cada producción consiste en un no terminal,
llamada encabezado o lado izquierdo de la producción, una flecha y una secuencia de
43
2.2 Definición de sintaxis
Comparación entre tokens y terminales
En un compilador, el analizador léxico lee los caracteres del programa fuente, los agrupa
en unidades con significado léxico llamadas lexemas, y produce como salida tokens que
representan estos lexemas. Un token consiste en dos componentes, el nombre del token
y un valor de atributo. Los nombres de los tokens son símbolos abstractos que utiliza el
analizador sintáctico para su análisis. A menudo, a estos tokens les llamamos terminales,
ya que aparecen como símbolos terminales en la gramática para un lenguaje de programación. El valor de atributo, si está presente, es un apuntador a la tabla de símbolos
que contiene información adicional acerca del token. Esta información adicional no forma
parte de la gramática, por lo que en nuestra explicación sobre el análisis sintáctico, a
menudo nos referimos a los tokens y los terminales como sinónimos.
terminales y no terminales, llamada cuerpo o lado derecho de la producción. La intención
intuitiva de una producción es especificar una de las formas escritas de una instrucción;
si el no terminal del encabezado representa a una instrucción, entonces el cuerpo representa una forma escrita de la instrucción.
4. Una designación de una de los no terminales como el símbolo inicial.
Para especificar las gramáticas presentamos sus producciones, en donde primero se listan
las producciones para el símbolo inicial. Suponemos que los dígitos, los signos como < y <=,
y las cadenas en negritas como while son terminales. Un nombre en cursiva es un no terminal, y se puede asumir que cualquier nombre o símbolo que no esté en cursiva es un terminal.1
Por conveniencia de notación, las producciones con el mismo no terminal que el encabezado
pueden agrupar sus cuerpos, con los cuerpos alternativos separados por el símbolo |, que leemos
como “o”.
Ejemplo 2.1: Varios ejemplos en este capítulo utilizan expresiones que consisten en dígitos y
signos positivos y negativos; por ejemplo, las cadenas como 9−5+2, 3−1 o 7. Debido a que debe
aparecer un signo positivo o negativo entre dos dígitos, nos referimos a tales expresiones como
“listas de dígitos separados por signos positivos o negativos”. La siguiente gramática describe
la sintaxis de estas expresiones. Las producciones son:
lista
lista
lista
dígito
1
→ lista + dígito
→ lista − dígito
→ dígito
→ 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
(2.1)
(2.2)
(2.3)
(2.4)
Utilizaremos letras individuales en cursiva para fines adicionales, en especial cuando estudiemos las gramáticas
con detalle en el capítulo 4. Por ejemplo, vamos a usar X, Y y Z para hablar sobre un símbolo que puede ser o no
terminal. No obstante, cualquier nombre en cursiva que contenga dos o más caracteres seguirá representando un
no terminal.
44
Capítulo 2. Un traductor simple orientado a la sintaxis
Los cuerpos de las tres producciones con la lista no terminal como encabezado pueden agruparse de la siguiente manera equivalente:
lista
→
lista + dígito | lista − dígito | dígito
De acuerdo con nuestras convenciones, los terminales de la gramática son los siguientes
símbolos:
+ − 0 1 2 3 4 5 6 7 8 9
Los no terminales son los nombres en cursiva lista y dígito, en donde lista es el símbolo inicial,
ya que sus producciones se dan primero.
2
Decimos que una producción es para un no terminal, si el no terminal es el encabezado de
la producción. Una cadena de terminales es una secuencia de cero o más terminales. La cadena
de cero terminales, escrita como , se llama cadena vacía.2
2.2.2
Derivaciones
Una gramática deriva cadenas empezando con el símbolo inicial y sustituyendo en forma repetida un no terminal, mediante el cuerpo de una producción para ese no terminal. Las cadenas
de terminales que pueden derivarse del símbolo inicial del lenguaje definido por la gramática.
Ejemplo 2.2: El lenguaje definido por la gramática del ejemplo 2.1 consiste en listas de dígitos separadas por signos positivos y negativos. Las diez producciones para el dígito no terminal
le permiten representar a cualquiera de los terminales 0, 1,..., 9. De la producción (2.3), un
dígito por sí solo es una lista. Las producciones (2.1) y (2.2) expresan la regla que establece
que cualquier lista seguida de un signo positivo o negativo, y después de otro dígito, forma una
nueva lista.
Las producciones (2.1) a (2.4) son todo lo que necesitamos para definir el lenguaje deseado.
Por ejemplo, podemos deducir que 9−5+2 es una lista de la siguiente manera:
a) 9 es una lista por la producción (2.3), ya que 9 es un dígito.
b) 9−5 es una lista por la producción (2.2), ya que 9 es una lista y 5 es un dígito.
c) 9−5+2 es una lista por la producción (2.1), ya que 9−5 es una lista y 2 es un dígito.
2
Ejemplo 2.3: Un tipo de lista algo distinto es la lista de parámetros en la llamada a una
función. En Java, los parámetros se encierran entre paréntesis, como en la llamada max(x,y)
de la función max con los parámetros x y y. Un matiz de dichas listas es que una lista vacía de
parámetros puede encontrarse entre los terminales ( y ). Podemos empezar a desarrollar una
gramática para dichas secuencias con las siguientes producciones:
2
Técnicamente, puede ser una cadena de cero símbolos de cualquier alfabeto (colección de símbolos).
45
2.2 Definición de sintaxis
llamada
paramsopc
params
→
→
→
id ( paramsopc )
params | params , param | param
Observe que el segundo cuerpo posible para paramsopc (“lista de parámetros opcionales”) es ,
que representa la cadena vacía de símbolos. Es decir, paramsopc puede sustituirse por la cadena
vacía, por lo que una llamada puede consistir en un nombre de función, seguido de la cadena de
dos terminales (). Observe que las producciones para params son análogas para las de lista en
el ejemplo 2.1, con una coma en lugar del operador aritmético + o −, y param en vez de dígito.
No hemos mostrado las producciones para param, ya que los parámetros son en realidad expresiones arbitrarias. En breve hablaremos sobre las producciones apropiadas para las diversas
construcciones de los lenguajes, como expresiones, instrucciones, etcétera.
2
El análisis sintáctico (parsing) es el problema de tomar una cadena de terminales y averiguar cómo derivarla a partir del símbolo inicial de la gramática, y si no puede derivarse a partir
de este símbolo, entonces hay que reportar los errores dentro de la cadena. El análisis sintáctico es uno de los problemas más fundamentales en todo lo relacionado con la compilación; los
principales métodos para el análisis sintáctico se describen en el capítulo 4. En este capítulo,
por cuestión de simplicidad, empezamos con programas fuente como 9−5+2 en los que cada carácter es un terminal; en general, un programa fuente tiene lexemas de varios caracteres que el
analizador léxico agrupa en tokens, cuyos primeros componentes son los terminales procesados
por el analizador sintáctico.
2.2.3
Árboles de análisis sintáctico
Un árbol de análisis sintáctico muestra, en forma gráfica, la manera en que el símbolo inicial
de una gramática deriva a una cadena en el lenguaje. Si el no terminal A tiene una producción
A → XYZ, entonces un árbol de análisis sintáctico podría tener un nodo interior etiquetado
como A, con tres hijos llamados X, Y y Z, de izquierda a derecha:
De manera formal, dada una gramática libre de contexto, un árbol de análisis sintáctico de
acuerdo con la gramática es un árbol con las siguientes propiedades:
1. La raíz se etiqueta con el símbolo inicial.
2. Cada hoja se etiqueta con un terminal, o con .
3. Cada nodo interior se etiqueta con un no terminal.
4. Si A es el no terminal que etiqueta a cierto nodo interior, y X1, X2, . . ., Xn son las etiquetas de los hijos de ese nodo de izquierda a derecha, entonces debe haber una producción
A → X1X2 · · · Xn. Aquí, cada una de las etiquetas X1, X2, . . ., Xn representa a un símbolo
46
Capítulo 2. Un traductor simple orientado a la sintaxis
Terminología de árboles
Las estructuras de datos tipo árbol figuran de manera prominente en la compilación.
• Un árbol consiste en uno o más nodos. Los nodos pueden tener etiquetas, que en
este libro, por lo general, serán símbolos de la gramática. Al dibujar un árbol, con
frecuencia representamos los nodos mediante estas etiquetas solamente.
• Sólo uno de los nodos es la raíz. Todos los nodos, excepto la raíz, tienen un padre
único; la raíz no tiene padre. Al dibujar árboles, colocamos el padre de un nodo
encima de ese nodo y dibujamos una línea entre ellos. Entonces, la raíz es el nodo
más alto (superior).
• Si el nodo N es el padre del nodo M, entonces M es hijo de N. Los hijos de nuestro
nodo se llaman hermanos. Tienen un orden, partiendo desde la izquierda, por lo que
al dibujar árboles, ordenamos los hijos de un nodo dado en esta forma.
• Un nodo sin hijos se llama hoja. Los otros nodos (los que tienen uno o más hijos)
son nodos interiores.
• Un descendiente de un nodo N es ya sea el mismo N, un hijo de N, un hijo de un
hijo de N, y así en lo sucesivo, para cualquier número de niveles. Decimos que el
nodo N es un ancestro del nodo M, si M es descendiente de N.
que puede ser o no un terminal. Como un caso especial, si A → es una producción,
entonces un nodo etiquetado como A puede tener un solo hijo, etiquetado como .
Ejemplo 2.4: La derivación de 9−5+2 en el ejemplo 2.2 se ilustra mediante el árbol en la
figura 2.5. Cada nodo en el árbol se etiqueta mediante un símbolo de la gramática. Un nodo
interior y su hijo corresponden a una producción; el nodo interior corresponde al encabezado de la producción, el hijo corresponde al cuerpo.
En la figura 2.5, la raíz se etiqueta como lista, el símbolo inicial de la gramática en el ejemplo 2.1. Los hijos de la raíz se etiquetan, de izquierda a derecha, como lista, + y dígito. Observe
que
lista
→ lista + dígito
es una producción en la gramática del ejemplo 2.1. El hijo izquierdo de la raíz es similar a la
raíz, con un hijo etiquetado como − en vez de +. Los tres nodos etiquetados como dígito tienen
cada uno un hijo que se etiqueta mediante un dígito.
2
De izquierda a derecha, las hojas de un árbol de análisis sintáctico forman la derivación del
árbol: la cadena que se genera o deriva del no terminal en la raíz del árbol de análisis sintáctico. En la figura 2.5, la derivación es 9−5+2; por conveniencia, todas las hojas se muestran en
el nivel inferior. Por lo tanto, no es necesario alinear las hojas de esta forma. Cualquier árbol
47
2.2 Definición de sintaxis
lista
lista
lista
dígito
dígito
dígito
Figura 2.5: Árbol de análisis sintáctico para 9−5+2, de acuerdo con la gramática
en el ejemplo 2.1
imparte un orden natural de izquierda a derecha a sus hojas, con base en la idea de que si X y Y
son dos hijos con el mismo padre, y X está a la izquierda de Y, entonces todos los descendientes
de X están a la izquierda de los descendientes de Y.
Otra definición del lenguaje generado por una gramática es como el conjunto de cadenas
que puede generar cierto árbol de análisis sintáctico. Al proceso de encontrar un árbol de análisis sintáctico para una cadena dada de terminales se le llama analizar sintácticamente esa
cadena.
2.2.4
Ambigüedad
Tenemos que ser cuidadosos al hablar sobre la estructura de una cadena, de acuerdo a una gramática. Una gramática puede tener más de un árbol de análisis sintáctico que genere una cadena
dada de terminales. Se dice que dicha gramática es ambigua. Para mostrar que una gramática es
ambigua, todo lo que debemos hacer es buscar una cadena de terminales que sea la derivación
de más de un árbol de análisis sintáctico. Como una cadena con más de un árbol de análisis
sintáctico tiene, por lo general, más de un significado, debemos diseñar gramáticas no ambiguas
para las aplicaciones de compilación, o utilizar gramáticas ambiguas con reglas adicionales para
resolver las ambigüedades.
Ejemplo 2.5: Suponga que utilizamos una sola cadena no terminal y que no diferenciamos
entre los dígitos y las listas, como en el ejemplo 2.1. Podríamos haber escrito la siguiente gramática:
cadena
→
cadena + cadena | cadena − cadena | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
Mezclar la noción de dígito y lista en la cadena no terminal tiene sentido superficial, ya que un
dígito individual es un caso especial de una lista.
No obstante, la figura 2.6 muestra que una expresión como 9−5+2 tiene más de un árbol
de análisis sintáctico con esta gramática. Los dos árboles para 9−5+2 corresponden a las dos
formas de aplicar paréntesis a la expresión: (9−5)+2 y 9−(5+2). Este segundo uso de los paréntesis proporciona a la expresión el valor inesperado de 2, en vez del valor ordinario de 6. La
gramática del ejemplo 2.1 no permite esta interpretación.
2
48
Capítulo 2. Un traductor simple orientado a la sintaxis
cadena
cadena
cadena
cadena
cadena
cadena
cadena
cadena
cadena
cadena
Figura 2.6: Dos árboles de análisis sintáctico para 9−5+2
2.2.5
Asociatividad de los operadores
Por convención, 9+5+2 es equivalente a (9+5)+2 y 9−5−2 es equivalente a (9−5)−2. Cuando
un operando como 5 tiene operadores a su izquierda y a su derecha, se requieren convenciones
para decidir qué operador se aplica a ese operando. Decimos que el operador + se asocia por la
izquierda, porque un operando con signos positivos en ambos lados de él pertenece al operador
que está a su izquierda. En la mayoría de los lenguajes de programación, los cuatro operadores
aritméticos (suma, resta, multiplicación y división) son asociativos por la izquierda.
Algunos operadores comunes, como la exponenciación, son asociativos por la derecha. Como
otro ejemplo, el operador de asignación = en C y sus descendientes es asociativo por la derecha;
es decir, la expresión a=b=c se trata de la misma forma que la expresión a=(b=c).
Las cadenas como a=b=c con un operador asociativo por la derecha se generan mediante la
siguiente gramática:
derecha → letra = derecha | letra
letra → a | b | . . . | z
El contraste entre un árbol de análisis sintáctico para un operador asociativo por la izquierda como −, y un árbol de análisis sintáctico para un operador asociativo por la derecha como =,
se muestra en la figura 2.7. Observe que el árbol de análisis sintáctico para 9−5−2 crece hacia
abajo y a la izquierda, mientras que el árbol de análisis sintáctico para a=b=c crece hacia abajo
y a la derecha.
2.2.6
Precedencia de operadores
Considere la expresión 9+5*2. Hay dos posibles interpretaciones de esta expresión: (9+5)*2 o
9+(5*2). Las reglas de asociatividad para + y * se aplican a las ocurrencias del mismo operador, por lo que no resuelven esta ambigüedad. Las reglas que definen la precedencia relativa de
los operadores son necesarias cuando hay más de un tipo de operador presente.
Decimos que * tiene mayor precedencia que +, si * recibe sus operandos antes que +. En la
aritmética ordinaria, la multiplicación y la división tienen mayor precedencia que la suma y
la resta. Por lo tanto, * recibe el 5 tanto en 9+5*2 como en 9*5+2; es decir, las expresiones son
equivalentes a 9+(5*2) y (9*5)+2, respectivamente.
49
2.2 Definición de sintaxis
lista
lista
lista
derecha
dígito
letra
dígito
derecha
letra
dígito
derecha
letra
Figura 2.7: Árboles de análisis sintáctico para las gramáticas asociativas por la izquierda y
por la derecha
Ejemplo 2.6: Podemos construir una gramática para expresiones aritméticas a partir de una
tabla que muestre la asociatividad y la precedencia de los operadores. Empezamos con los
cuatro operadores aritméticos comunes y una tabla de precedencia, mostrando los operadores
en orden de menor a mayor precedencia. Los operadores en la misma línea tienen la misma
asociatividad y precedencia:
asociativo por la izquierda:
asociativo por la derecha:
+ −
* /
Creamos dos no terminales llamadas expr y term para los dos niveles de precedencia, y un
no terminal adicional llamado factor para generar unidades básicas en las expresiones. Las
unidades básicas en las expresiones son dígitos y expresiones entre paréntesis.
factor
→
dígito | ( expr )
Ahora consideremos los operadores binarios, * y /, que tienen la mayor precedencia. Como
estos operadores asocian por la izquierda, las producciones son similares a las de las listas que
asocian por la izquierda.
De manera similar, expr genera listas de términos separados por los operadores aditivos:
Por lo tanto, la gramática resultante es:
dígito
50
Capítulo 2. Un traductor simple orientado a la sintaxis
Generalización de la gramática de expresiones del ejemplo 2.6
Podemos considerar un factor como una expresión que no puede “separarse” mediante
ningún operador. Al usar el término “separar”, queremos indicar que al colocar un operador enseguida de cualquier factor, en cualquier lado, ninguna pieza del factor, excepto en
su totalidad, se convertirá en un operando de ese operador. Si el factor es una expresión
con paréntesis, éstos la protegen contra dicha “separación”, mientras que si el factor es
un solo operando, no puede separarse.
Un término (que no sea también un factor) es una expresión que puede separarse
mediante los operadores de la mayor precedencia: * y /, pero no mediante los operadores
de menor precedencia. Una expresión (que no sea un término o factor) puede separarse
mediante cualquier operador.
Podemos generalizar esta idea con cualquier número n de niveles de precedencia. Necesitamos n + 1 no terminales. El primero, como factor en el ejemplo 2.6, nunca podrá
separarse. Por lo general, los cuerpos de producciones para esta no terminal son sólo
operandos individuales y expresiones con paréntesis. Entonces, para cada nivel de precedencia hay un no terminal que representa a las expresiones que pueden separarse sólo
mediante operadores en ese nivel, o en uno superior. Por lo general, las producciones para
esta no terminal tienen cuerpos que representan los usos de los operadores en ese nivel,
más un cuerpo que sólo es la no terminal para el siguiente nivel superior.
Con esta gramática, una expresión es una lista de términos separados por los signos + o −,
y un término es una lista de factores separados por los signos * o /. Observe que cualquier
expresión con paréntesis es un factor, así que con los paréntesis podemos desarrollar expresiones que tengan un anidamiento con profundidad arbitraria (y árboles con una profundidad
arbitraria). 2
Ejemplo 2.7: Las palabras clave nos permiten reconocer instrucciones, ya que la mayoría
de éstas empiezan con una palabra clave o un carácter especial. Las excepciones a esta regla
incluyen las asignaciones y las llamadas a procedimientos. Las instrucciones definidas por
la gramática (ambigua) en la figura 2.8 son legales en Java.
En la primera producción para instr, el terminal id representa a cualquier identificador. Las
producciones para expresión no se muestran. Las instrucciones de asignación especificadas por
la primera producción son legales en Java, aunque Java trata a = como un operador de asignación que puede aparecer dentro de una expresión. Por ejemplo, Java permite a=b=c, mientras
que esta gramática no.
El no terminal instrs genera una lista posiblemente vacía de instrucciones. La segunda producción para instrs genera la lista vacía . La primera producción genera una lista posiblemente
vacía de instrucciones seguidas por una instrucción.
La colocación de los signos de punto y coma es sutil; aparecen al final de cada cuerpo que
no termina en instr. Este enfoque evita la acumulación de puntos y comas después de instrucciones como if y while, que terminan con subinstrucciones anidadas. Cuando la subinstrucción
anidada sea una instrucción o un do-while, se generará un punto y coma como parte de la
subinstrucción.
2
51
2.2 Definición de sintaxis
instr
id = expresión ;
if ( expresión ) instr
if = expresión ; instr else instr
while ( expresión ) instr
do instr while ( expresión ) ;
{ instrs }
instrs
instrs instr
Figura 2.8: Una gramática para un subconjunto de instrucciones de Java
2.2.7
Ejercicios para la sección 2.2
Ejercicio 2.2.1: Considere la siguiente gramática libre de contexto:
a) Muestre cómo puede generarse la cadena aa+a* mediante esta gramática.
b) Construya un árbol de análisis sintáctico para esta cadena.
c) ¿Qué lenguaje genera esta gramática? Justifique su respuesta.
Ejercicio 2.2.2: ¿Qué lenguaje se genera mediante las siguientes gramáticas? En cada caso,
justifique su respuesta.
Ejercicio 2.2.3: ¿Cuáles de las gramáticas en el ejercicio 2.2.2 son ambiguas?
Ejercicio 2.2.4: Construya gramáticas libres de contexto no ambiguas para cada uno de los
siguientes lenguajes. En cada caso muestre que su gramática es correcta.
a) Expresiones aritméticas en notación prefija.
b) Listas asociativas por la izquierda de identificadores separados por comas.
c) Listas asociativas por la derecha de identificadores separados por comas.
d) Expresiones aritméticas de enteros e identificadores con los cuatro operadores binarios
+, −, *, /.
52
Capítulo 2. Un traductor simple orientado a la sintaxis
! e) Agregue el operador unario de suma y de resta a los operadores aritméticos de (d).
Ejercicio 2.2.5:
a) Muestre que todas las cadenas binarias generadas por la siguiente gramática tienen valores que pueden dividirse entre 3. Sugerencia: Use la inducción en el número de nodos
en un árbol de análisis sintáctico.
num →
11
|
1001
|
num 0
|
num num
b) ¿La gramática genera todas las cadenas binarias con valores divisibles entre 3?
Ejercicio 2.2.6: Construya una gramática libre de contexto para los números romanos.
2.3
Traducción orientada a la sintaxis
La traducción orientada a la sintaxis se realiza uniendo reglas o fragmentos de un programa
a las producciones en una gramática. Por ejemplo, considere una expresión expr generada por
la siguiente producción:
expr
→ expr1 + term
Aquí, expr es la suma de las dos subexpresiones expr1 y term. El subíndice en expr1 se utiliza
sólo para diferenciar la instancia de expr en el cuerpo de la producción, del encabezado de la
producción. Podemos traducir expr explotando su estructura, como en el siguiente seudocódigo:
traducir expr1;
traducir term;
manejar +;
Mediante una variante de este seudocódigo, vamos a construir un árbol sintáctico para expr
en la sección 2.8, creando árboles sintácticos para expr1 y term, y después manejaremos el +
construyéndole un nodo. Por conveniencia, el ejemplo en esta sección es la traducción de las
expresiones infijas a la notación postfijo.
Esta sección introduce dos conceptos relacionados con la traducción orientada a la sintaxis:
• Atributos. Un atributo es cualquier cantidad asociada con una construcción de programación. Algunos ejemplos de atributos son los tipos de datos de las expresiones, el número
de instrucciones en el código generado, o la ubicación de la primera instrucción en el código generado para una construcción, entre muchas otras posibilidades. Como utilizamos
símbolos de la gramática (no terminales y terminales) para representar las construcciones
de programación, extendemos la noción de los atributos, de las construcciones a los símbolos que las representan.
2.3 Traducción orientada a la sintaxis
53
• Esquemas de traducción (orientada a la sintaxis). Un esquema de traducción es una notación para unir los fragmentos de un programa a las producciones de una gramática. Los
fragmentos del programa se ejecutan cuando se utiliza la producción durante el análisis
sintáctico. El resultado combinado de todas estas ejecuciones de los fragmentos, en el
orden inducido por el análisis sintáctico, produce la traducción del programa al cual se
aplica este proceso de análisis/síntesis.
Utilizaremos las traducciones orientadas a la sintaxis a lo largo de este capítulo para traducir las expresiones infijas en notación postfija, para evaluar expresiones y para construir árboles
sintácticos para las construcciones de programación. En el capítulo 5 aparece una discusión
más detallada sobre los formalismos orientados a la sintaxis.
2.3.1
Notación postfija
Los ejemplos en esta sección manejan la traducción a la notación postfija. La notación postfija
para una expresión E puede definirse de manera inductiva, como se muestra a continuación:
1. Si E es una variable o constante, entonces la notación postfija para E es la misma E.
2. Si E es una expresión de la forma E1 op E2, en donde op es cualquier operador binario,
entonces la notación postfija para E es E 1 E 2 op, en donde E 1 y E 2 son las notaciones
postfija para E1 y E2, respectivamente.
3. Si E es una expresión con paréntesis de la forma (E1), entonces la notación postfija para
E es la misma que la notación postfija para E1.
Ejemplo 2.8: La notación postfija para (9−5)+2 es 95−2+. Es decir, las traducciones de 9,
5 y 2 son las mismas constantes, en base a la regla (1). Entonces, la traducción de 9−5 es 95−
en base a la regla (2). La traducción de (9−5) es la misma en base a la regla (3), Habiendo
traducido la subexpresión con paréntesis, podemos aplicar la regla (2) a toda la expresión, con
(9−5) en el papel de E1 y 2 en el papel de E2, para obtener el resultado 95−2+.
Como otro ejemplo, la notación postfija para 9−(5+2) es 952+−. Es decir, primero se traduce 5+2 a 52+, y esta expresión se convierte en el segundo argumento del signo negativo. 2
No se necesitan paréntesis en la notación postfija, debido a que la posición y la aridad
(número de argumentos) de los operadores sólo permiten una decodificación de una expresión
postfija. El “truco” es explorar varias veces la cadena postfija partiendo de la izquierda, hasta
encontrar un operador. Después, se busca a la izquierda el número apropiado de operandos,
y se agrupa este operador con sus operandos. Se evalúa el operador con los operandos y se
sustituyen por el resultado. Después se repite el proceso, continuando a la derecha y buscando
otro operador.
Ejemplo 2.9: Considere la expresión postfija 952+−3*. Si exploramos partiendo de la izquierda, primero encontramos el signo positivo. Si buscamos a su izquierda encontramos los
operandos 5 y 2. Su suma, 7, sustituye a 52+ y tenemos la cadena 97−3*. Ahora, el operador
54
Capítulo 2. Un traductor simple orientado a la sintaxis
de más a la izquierda es el signo negativo, y sus operandos son 9 y 7. Si los sustituimos por el
resultado de la resta nos queda 23*. Por último, el signo de multiplicación se asigna a 2 y 3, lo
cual produce el resultado de 6. 2
2.3.2
Atributos sintetizados
La idea de asociar cantidades con construcciones de programación (por ejemplo, valores y tipos
con expresiones) puede expresarse en términos de gramáticas. Asociamos los atributos con los
no terminales y terminales. Después, unimos reglas a las producciones de la gramática; estas
reglas describen la forma en que se calculan los atributos en esos nodos del árbol de análisis
sintáctico en donde la producción en cuestión se utiliza para relacionar un nodo con sus hijos.
Una definición orientada a la sintaxis se asocia:
1. Con cada símbolo de gramática, un conjunto de atributos.
2. Con cada producción, un conjunto de reglas semánticas para calcular los valores de los
atributos asociados con los símbolos que aparecen en la producción.
Los atributos pueden evaluarse de la siguiente forma. Para una cadena de entrada x dada,
se construye un árbol de análisis sintáctico para x. Después, se aplican las reglas semánticas
para evaluar los atributos en cada nodo del árbol de análisis sintáctico, como se indica a continuación.
Suponga que un nodo N en un árbol de análisis sintáctico se etiqueta mediante el símbolo de
gramática X. Escribimos X.a para denotar el valor del atributo a de X en ese nodo. A un árbol
de análisis sintáctico que muestra los valores de los atributos en cada nodo se le conoce como
árbol de análisis sintáctico anotado. Por ejemplo, la figura 2.9 muestra un árbol de análisis
sintáctico anotado para 9−5+2 con un atributo t asociado con los no terminales expr y term. El
valor 95−2+ del atributo en la raíz es la notación postfija para 9−5+2. En breve veremos cómo
se calculan estas expresiones.
Figura 2.9: Valores de los atributos en los nodos de un árbol de análisis sintáctico
Se dice que un atributo está sintetizado si su valor en el nodo N de un árbol de análisis
sintáctico se determina mediante los valores de los atributos del hijo de N y del mismo N.
Los atributos sintetizados tienen la atractiva propiedad de que pueden evaluarse durante un
55
2.3 Traducción orientada a la sintaxis
recorrido de abajo hacia arriba transversal de un árbol de análisis sintáctico. En la sección
5.1.1 hablaremos sobre otro tipo importante de atributo: el atributo “heredado”. De manera
informal, los atributos heredados tienen su valor en un nodo de un árbol de análisis sintáctico
que se determina mediante los valores de los atributos en el mismo nodo, en su padre y en sus
hermanos del árbol.
Ejemplo 2.10: El árbol de análisis sintáctico anotado en la figura 2.9 se basa en la definición
orientada a la sintaxis de la figura 2.10 para traducir expresiones que consisten en dígitos separados por los signos positivo o negativo, a la notación postfija. Cada no terminal tiene un
atributo t con valor de cadena, el cual representa a la notación postfija para la expresión generada por esa no terminal en un árbol de análisis sintáctico. El símbolo || en la regla semántica
es el operador para la concatenación de cadenas.
PRODUCCIÓN
REGLAS SEMÁNTICAS
Figura 2.10: Definición orientada a la sintaxis para la traducción de infija a postfija
La forma postfija de un dígito es el mismo dígito; por ejemplo, la regla semántica asociada
con la producción term → 9 define a term.t como el mismo 9, cada vez que se utiliza esta producción en un nodo de un árbol de análisis sintáctico. Los otros dígitos se traducen de manera
similar. Como otro ejemplo, cuando se aplica la producción expr → term, el valor de term.t se
convierte en el valor de expr.t.
La producción expr → expr1 + term deriva a una expresión que contiene un operador de
suma.3 El operando izquierdo del operador de suma se proporciona mediante expr1, y el operando derecho mediante term. La regla semántica
asociada con esta producción construye el valor del atributo expr.t mediante una concatenación
de las formas postfijas expr1.t y term.t de los operandos izquierdo y derecho, respectivamente,
y después adjunta el signo de suma. Esta regla es una formalización de la definición de “expresión postfija”.
2
3
En ésta y en muchas otras reglas, el mismo no terminal (en este caso, expr) aparece varias veces. El propósito del
subíndice 1 en expr1 es diferenciar las dos ocurrencias de expr en la producción; el “1” no forma parte del no terminal.
Para obtener más detalles consulte el cuadro titulado “Usos de un no terminal para diferenciar convenciones”.
56
Capítulo 2. Un traductor simple orientado a la sintaxis
Usos de un no terminal para diferenciar convenciones
A menudo, en las reglas tenemos la necesidad de diferenciar entre varios usos del mismo
no terminal en el encabezado y cuerpo de una producción; para ilustrar esto, vea el ejemplo 2.10. La razón es que en el árbol de análisis sintáctico, distintos nodos etiquetados
por el mismo no terminal, por lo general, tienen distintos valores para sus traducciones.
Nosotros vamos a adoptar la siguiente convención: el no terminal aparece sin subíndice
en el encabezado y con distintos subíndices en el cuerpo. Todas son ocurrencias del mismo no terminal, y el subíndice no forma parte de su nombre. No obstante, hay que alertar
al lector sobre la diferencia entre los ejemplos de traducciones específicas, en donde se
utiliza esta convención, y las producciones genéricas como A → X1 X2, … , Xn, en donde
las X con subíndices representan una lista arbitraria de símbolos de gramática, y no son
instancias de un no terminal específico llamado X.
2.3.3
Definiciones simples orientadas a la sintaxis
La definición orientada a la sintaxis en el ejemplo 2.10 tiene la siguiente propiedad importante:
la cadena que representa la traducción del no terminal en el encabezado de cada producción
es la concatenación de las traducciones de los no terminales en el cuerpo de la producción, en
el mismo orden que en la producción, con algunas cadenas adicionales opcionales entrelazadas.
Una definición orientada a la sintaxis con esta propiedad se denomina como simple.
Ejemplo 2.11: Considere la primera producción y regla semántica de la figura 2.10:
PRODUCCIÓN
expr → expr1 + term
REGLA SEMÁNTICA
expr.t = expr1.t || term.t || +
(2.5)
Aquí, la traducción expr.t es la concatenación de las traducciones de expr1 y term, seguida por
el símbolo +. Observe que expr1 y term aparecen en el mismo orden, tanto en el cuerpo de la
producción como en la regla semántica. No hay símbolos adicionales antes o entre sus traducciones. En este ejemplo, el único símbolo adicional ocurre al final.
2
Cuando hablemos sobre los esquemas de traducción, veremos que puede implementarse una
definición simple orientada a la sintaxis con sólo imprimir las cadenas adicionales, en el orden
en el que aparezcan en la definición.
2.3.4
Recorridos de los árboles
Utilizaremos los recorridos de los árboles para describir la evaluación de los atributos y especificar la ejecución de los fragmentos de código en un esquema de traducción. Un recorrido de
un árbol empieza en la raíz y visita cada nodo del árbol en cierto orden.
2.3 Traducción orientada a la sintaxis
57
Un recorrido del tipo primero en profundidad empieza en la raíz y visita en forma recursiva
los hijos de cada nodo en cualquier orden, no necesariamente de izquierda a derecha. Se llama
“primero en profundidad” debido a que visita cuando pueda a un hijo de un nodo que no haya
sido visitado, de manera que visita a los nodos que estén a cierta distancia (“profundidad”) de
la raíz lo más rápido que pueda.
El procedimiento visitar(N) en la figura 2.11 es un recorrido primero en profundidad, el cual
visita a los hijos de un nodo en el orden de izquierda a derecha, como se muestra en la figura
2.12. En este recorrido hemos incluido la acción de evaluar las traducciones en cada nodo, justo
antes de terminar con el nodo (es decir, después de que se hayan calculado las traducciones
en el hijo). En general, las acciones asociadas con un recorrido pueden ser cualquier cosa que
elijamos, o ninguna acción en sí.
procedure visitar (nodo N) {
for ( cada hijo (C de N, de izquierda a derecha ) {
visitar (C );
}
evaluar las reglas semánticas en el nodo N;
}
Figura 2.11: Un recorrido tipo “primero en profundidad” de un árbol
Figura 2.12: Ejemplo de un recorrido tipo “primero en profundidad” de un árbol
Una definición orientada a la sintaxis no impone un orden específico para la evaluación
de los atributos en un árbol de análisis sintáctico; cualquier orden de evaluación que calcule
un atributo a después de todos los demás atributos de los que a dependa es aceptable. Los
atributos sintetizados pueden evaluarse durante cualquier recorrido de abajo-arriba; es decir,
un recorrido que evalúe los atributos en un nodo después de haber evaluado los atributos de
sus hijos. En general, tanto con los atributos sintetizados como con los heredados, la cuestión
acerca del orden de evaluación es bastante compleja; vea la sección 5.2.
2.3.5
Esquemas de traducción
La definición orientada a la sintaxis en la figura 2.10 genera una traducción uniendo cadenas
como atributos para los nodos en el árbol de análisis sintáctico. Ahora consideraremos un método alternativo que no necesita manipular cadenas; produce la misma traducción en forma
incremental, mediante la ejecución de fragmentos del programa.
58
Capítulo 2. Un traductor simple orientado a la sintaxis
Recorridos preorden y postorden
Los recorridos en preorden y postorden son dos casos especiales de recorridos tipo “primero en profundidad”, en los cuales se visitan los hijos de cada nodo de izquierda a
derecha.
A menudo, recorremos un árbol para realizar cierta acción específica en cada nodo.
Si la acción se realiza cuando visitamos a un nodo por primera vez, entonces podemos
referirnos al recorrido como un recorrido en preorden. De manera similar, si la acción se
realiza justo antes de dejar un nodo por última vez, entonces decimos que es un recorrido
en postorden del árbol. El procedimiento visitar(N) en la figura 2.11 es un ejemplo de un
recorrido postorden.
Los recorridos en preorden y postorden definen los ordenamientos correspondientes en
los nodos, con base en el momento en el que se va a realizar una acción en un nodo. El
recorrido en preorden de un (sub)árbol con raíz en el nodo N consiste en N, seguido por
los recorridos preorden de los subárboles de cada uno de sus hijos, si existen, empezando
desde la izquierda. El recorrido en postorden de un (sub)árbol con raíz en N consiste en
los recorridos en postorden de cada uno de los subárboles de los hijos de N, si existen,
empezando desde la izquierda, seguidos del mismo N.
Un esquema de traducción orientado a la sintaxis es una notación para especificar una
traducción, uniendo los fragmentos de un programa a las producciones en una gramática. Un
esquema de traducción es como una definición orientada a la sintaxis, sólo que el orden de
evaluación de las reglas semánticas se especifica en forma explícita.
Los fragmentos de un programa incrustados dentro de los cuerpos de las producciones se
llaman acciones semánticas. La posición en la que debe ejecutarse una acción se muestra encerrada entre llaves y se escribe dentro del cuerpo de producción, como en el siguiente ejemplo:
resto
→
+ term {print(+)} resto1
Veremos esas reglas a la hora de considerar una forma alternativa de gramática para las expresiones, en donde el no terminal llamada resto representa “todo excepto el primer término
de una expresión”. En la sección 2.4.5 hablaremos sobre esta forma de gramática. De nuevo, el
subíndice en resto1 diferencia esta instancia del no terminal resto en el cuerpo de la producción,
de la instancia de resto en el encabezado de la producción.
Al dibujar un árbol de análisis sintáctico para un esquema de traducción, para indicar una
acción le construimos un hijo adicional, conectado mediante una línea punteada al nodo que
corresponde a la cabeza de la producción. Por ejemplo, la porción del árbol de análisis sintáctico para la producción y acción anteriores se muestra en la figura 2.13. El nodo para una acción semántica no tiene hijos, por lo que la acción se realiza la primera vez que se ve el nodo.
Ejemplo 2.12: El árbol de análisis sintáctico en la figura 2.14 tiene instrucciones “print” en
hojas adicionales, las cuales se adjuntan mediante líneas punteadas a los nodos interiores del
árbol de análisis sintáctico. El esquema de traducción aparece en la figura 2.15. La gramática
subyacente genera expresiones que consisten en dígitos separados por los signos positivo y ne-
59
2.3 Traducción orientada a la sintaxis
resto
resto1
Figura 2.13: Se construye una hoja adicional para una acción semántica
gativo. Las acciones incrustadas en los cuerpos de producción traducen dichas expresiones a la
notación postfija, siempre y cuando realicemos un recorrido del tipo “primero en profundidad”
de izquierda a derecha del árbol, y ejecutemos cada instrucción “print” a medida que visitemos
sus hojas.
Figura 2.14: Acciones para traducir 9−5+2 a 95−2+
Figura 2.15: Acciones para traducir a la notación postfija
La raíz de la figura 2.14 representa la primera producción en la figura 2.15. En un recorrido
en postorden, primero realizamos todas las acciones en el subárbol de más a la izquierda de
la raíz, para el operando izquierdo, también etiquetado expr como la raíz. Después visitamos la
hoja + en la cual no hay acción. Después realizamos las acciones en el subárbol para el operando derecho term y, por último, la acción semántica {print(+)} en el nodo adicional.
Como las producciones para term sólo tienen un dígito en el lado derecho, las acciones para
las producciones imprimen ese dígito. No es necesario ningún tipo de salida para la producción
expr → term, y sólo se necesita imprimir el operador en la acción para cada una de las pri-
60
Capítulo 2. Un traductor simple orientado a la sintaxis
meras dos producciones. Al ejecutarse durante un recorrido en postorden del árbol de análisis
sintáctico, las acciones en la figura 2.14 imprimen 95−2+. 2
Observe que, aunque los esquemas en las figuras 2.10 y 2.15 producen la misma traducción,
la construyen en forma distinta; la figura 2.10 adjunta cadenas como atributos a los nodos en
el árbol de análisis sintáctico, mientras que el esquema en la figura 2.15 imprime la traducción
en forma incremental, a través de acciones semánticas.
Las acciones semánticas en el árbol de análisis sintáctico en la figura 2.14 traducen la expresión infija 9−5+2 a 95−2+, imprimiendo cada carácter en 9−5+2 sólo una vez, sin utilizar espacio
de almacenamiento para la traducción de las subexpresiones. Cuando se crea la salida en forma
incremental de este modo, el orden en el que se imprimen los caracteres es importante.
La implementación de un esquema de traducción debe asegurar que las acciones semánticas
se realicen en el orden en el que aparecerían durante un recorrido postorden de un árbol de
análisis sintáctico. La implementación en realidad no necesita construir un árbol de análisis
sintáctico (a menudo no lo hace), siempre y cuando asegure que las acciones semánticas se
realizan como si construyéramos un árbol de análisis sintáctico y después ejecutáramos las
acciones durante un recorrido postorden.
2.3.6
Ejercicios para la sección 2.3
Ejercicio 2.3.1: Construya un esquema de traducción orientado a la sintaxis, que traduzca
expresiones aritméticas de la notación infija a la notación prefija, en la cual un operador aparece antes de sus operandos; por ejemplo, –xy es la notación prefija para x – y. Proporcione los
árboles de análisis sintáctico anotados para las entradas 9−5+2 y 9−5*2.
Ejercicio 2.3.2: Construya un esquema de traducción orientado a la sintaxis, que traduzca
expresiones aritméticas de la notación postfija a la notación infija. Proporcione los árboles de
análisis sintáctico anotados para las entradas 95−2* y 952*−.
Ejercicio 2.3.3: Construya un esquema de traducción orientado a la sintaxis, que traduzca
enteros a números romanos.
Ejercicio 2.3.4: Construya un esquema de traducción orientado a la sintaxis, que traduzca
números romanos a enteros.
Ejercicio 2.3.5: Construya un esquema de traducción orientado a la sintaxis, que traduzca
expresiones aritméticas postfijo a sus expresiones aritméticas infijas equivalentes.
2.4
Análisis sintáctico
El análisis sintáctico (parsing) es el proceso de determinar cómo puede generarse una cadena
de terminales mediante una gramática. Al hablar sobre este problema, es más útil pensar en
que se va a construir un árbol de análisis sintáctico, aun cuando un compilador tal vez no lo
construya en la práctica. No obstante, un analizador sintáctico debe ser capaz de construir el
árbol en principio, o de lo contrario no se puede garantizar que la traducción sea correcta.
2.4 Análisis sintáctico
61
Esta sección introduce un método de análisis sintáctico conocido como “descenso recursivo”, el cual puede usarse tanto para el análisis sintáctico, como para la implementación de
traductores orientados a la sintaxis. En la siguiente sección aparece un programa completo en
Java, que implementa el esquema de traducción de la figura 2.15. Una alternativa viable es
usar una herramienta de software para generar un traductor directamente de un esquema de
traducción. La sección 4.9 describe dicha herramienta: Yacc; puede implementar el esquema
de traducción de la figura 2.15 sin necesidad de modificarlo.
Para cualquier gramática libre de contexto, hay un analizador sintáctico que se tarda, como
máximo, un tiempo O(n3) en analizar una cadena de n terminales. Pero, por lo general, el
tiempo cúbico es demasiado costoso. Por fortuna, para los lenguajes de programación reales podemos diseñar una gramática que pueda analizarse con rapidez. Los algoritmos de tiempo lineal
bastan para analizar en esencia todos los lenguajes que surgen en la práctica. Los analizadores
sintácticos de los lenguajes de programación casi siempre realizan un escaneo de izquierda a
derecha sobre la entrada, buscando por adelantado un terminal a la vez, y construyendo las
piezas del árbol de análisis sintáctico a medida que avanzan.
La mayoría de los métodos de análisis sintáctico se adaptan a una de dos clases, llamadas
métodos descendente y ascendente. Estos términos se refieren al orden en el que se construyen
los nodos en el árbol de análisis sintáctico. En los analizadores tipo descendente, la construcción empieza en la raíz y procede hacia las hojas, mientras que en los analizadores tipo
ascendente, la construcción empieza en las hojas y procede hacia la raíz. La popularidad de
los analizadores tipo arriba-abajo se debe a que pueden construirse analizadores eficientes con
más facilidad a mano, mediante métodos descendentes. No obstante, el análisis sintáctico tipo
ascendente puede manejar una clase más extensa de gramáticas y esquemas de traducción, por
lo que las herramientas de software para generar analizadores sintácticos directamente a partir
de las gramáticas utilizan con frecuencia éste método.
2.4.1
Análisis sintáctico tipo arriba-abajo
Para introducir el análisis sintáctico tipo arriba-abajo, consideremos una gramática que se
adapta bien a esta clase de métodos. Más adelante en esta sección, consideraremos la construcción de los analizadores sintácticos descendentes en general. La gramática en la figura 2.16
genera un subconjunto de las instrucciones de C o Java. Utilizamos los terminales en negrita if
y for para las palabras clave “if” y “for”, respectivamente, para enfatizar que estas secuencias de caracteres se tratan como unidades, es decir, como símbolos terminales individuales.
Además, el terminal expr representa expresiones; una gramática más completa utilizaría un
no terminal expr y tendría producciones para el no terminal expr. De manera similar, otras es
un terminal que representa a otras construcciones de instrucciones.
La construcción descendente de un árbol de análisis sintáctico como el de la figura 2.17 se
realiza empezando con la raíz, etiquetada con el no terminal inicial instr, y realizando en forma
repetida los siguientes dos pasos:
1. En el nodo N, etiquetado con el no terminal A, se selecciona una de las producciones
para A y se construyen hijos en N para los símbolos en el cuerpo de la producción.
2. Se encuentra el siguiente nodo en el que se va a construir un subárbol, por lo general, el
no terminal no expandido de más a la izquierda del árbol.
62
Capítulo 2. Un traductor simple orientado a la sintaxis
instr
→
|
|
|
expr ;
if ( expr ) instr
for ( expropc ; expropc ; expropc ) instr
otras
expropc
→
|
expr
Figura 2.16: Una gramática para algunas instrucciones en C y Java
instr
for
(
expropc ;
expropc ;
expr
expropc
expr
)
instr
otras
Figura 2.17: Un árbol de análisis sintáctico, de acuerdo con la gramática en la figura 2.16
Para algunas gramáticas, los pasos anteriores pueden implementarse durante un solo escaneo de izquierda a derecha de la cadena de entrada. El terminal actual escaneo en la entrada
se conoce con frecuencia como el símbolo de preanálisis. En un principio, el símbolo preanálisis
es el primer (es decir, el de más a la izquierda) terminal de la cadena de entrada. La figura
2.18 ilustra la construcción del árbol de análisis sintáctico en la figura 2.17 para la siguiente
cadena de entrada:
for ( ; expr ; expr ) otras
En un principio, el terminal for es el símbolo de preanálisis, y la parte conocida del árbol de
análisis sintáctico consiste en la raíz, etiquetada con el no terminal inicial instr en la figura
2.18(a). El objetivo es construir el resto del árbol de análisis sintáctico de tal forma que la
cadena generada por el árbol de análisis sintáctico coincida con la cadena de entrada.
Para que ocurra una coincidencia, el no terminal instr en la figura 2.18(a) debe derivar una
cadena que empiece con el símbolo de preanálisis for. En la gramática de la figura 2.16, sólo
hay una producción para instr que puede derivar dicha cadena, por lo que la seleccionamos y
construimos los hijos de la raíz, etiquetados con los símbolos en el cuerpo de la producción.
Esta expansión del árbol de análisis sintáctico se muestra en la figura 2.18(b).
Cada una de las tres instantáneas en la figura 2.18 tiene flechas que marcan el símbolo de
preanálisis en la entrada y el nodo en el árbol de análisis sintáctico que se está considerando.
Una vez que se construyen los hijos en un nodo, a continuación consideramos el hijo de más a la
izquierda. En la figura 2.18(b), los hijos acaban de construirse en la raíz, y se está considerando
el hijo de más a la izquierda, etiquetado con for.
Cuando el nodo que se está considerando en el árbol de análisis sintáctico es para un terminal, y éste coincide con el símbolo de preanálisis, entonces avanzamos tanto en el árbol de
análisis sintáctico como en la entrada. El siguiente terminal en la entrada se convierte en el
63
2.4 Análisis sintáctico
ÁRBOL DE
instr
ANÁLISIS
SINTÁCTICO
ENTRADA
otras
instr
ÁRBOL DE
ANÁLISIS
SINTÁCTICO
expropc
expropc
expropc
ENTRADA
otras
instr
ÁRBOL DE
ANÁLISIS
SINTÁCTICO
ENTRADA
instr
expropc
expropc
expropc
instr
otras
Figura 2.18: Análisis sintáctico tipo descendente mientras se explora la entrada de izquierda
a derecha
nuevo símbolo de preanálisis, y se considera el siguiente hijo en el árbol de análisis sintáctico.
En la figura 2.18(c), la flecha en el árbol de análisis sintáctico ha avanzado al siguiente hijo
de la raíz y la flecha en la entrada ha avanzado al siguiente terminal, que es (. Un avance más
llevará la flecha en el árbol de análisis sintáctico al hijo etiquetado con el no terminal expropc,
y llevará la flecha en la entrada al terminal ;.
En el nodo no terminal etiquetado como expropc, repetimos el proceso de seleccionar una
producción para un no terminal. Las producciones con como el cuerpo (“producciones ”)
requieren un tratamiento especial. Por el momento, las utilizaremos como una opción predeterminada cuando no puedan usarse otras producciones; regresaremos a ellas en la sección 2.4.3.
Con el no terminal expropc y el símbolo de preanálisis;, se utiliza la producción , ya que ; no
coincide con la otra única producción para expropc, que tiene la terminal expr como cuerpo.
En general, la selección de una producción para un no terminal puede requerir del proceso
de prueba y error; es decir, tal vez tengamos que probar una producción y retroceder para
probar otra, si la primera no es adecuada. Una producción es inadecuada si, después de usarla,
64
Capítulo 2. Un traductor simple orientado a la sintaxis
no podemos completar el árbol para que coincida con la cadena de entrada. Sin embargo, no es
necesario retroceder en un caso especial importante conocido como análisis sintáctico predictivo, el cual veremos a continuación.
2.4.2
Análisis sintáctico predictivo
El análisis sintáctico de descenso recursivo es un método de análisis sintáctico descendente, en
el cual se utiliza un conjunto de procedimientos recursivos para procesar la entrada. Un procedimiento se asocia con cada no terminal de una gramática. Aquí consideraremos una forma
simple de análisis sintáctico de descenso recursivo, conocido como análisis sintáctico predictivo,
en el cual el símbolo de preanálisis determina sin ambigüedad el flujo de control a través del
cuerpo del procedimiento para cada no terminal. La secuencia de llamadas a procedimientos
durante el análisis de una entrada define en forma implícita un árbol de análisis sintáctico para
la entrada, y puede usarse para crear un árbol de análisis sintáctico explícito, si se desea.
El analizador sintáctico predictivo en la figura 2.19 consiste en procedimientos para las
no terminales instr y expropc de la gramática en la figura 2.16, junto con un procedimiento
adicional llamado coincidir, el cual se utiliza para simplificar el código para instr y expropc.
El procedimiento coincidir(t) compara su argumento t con el símbolo de preanálisis y avanza
al siguiente terminal de entrada si coinciden. Por ende, coincidir cambia el valor de la variable
preanálisis, una variable global que contiene el terminal de entrada que se acaba de explorar.
El análisis sintáctico empieza con una llamada del procedimiento para el no terminal inicial,
instr. Con la misma entrada que en la figura 2.18 preanálisis es en un principio el primer for
terminal. El procedimiento instr ejecuta el código correspondiente a la siguiente producción:
instr → for ( expropc ; expropc ; expropc ) instr
En el código para el cuerpo de la producción (es decir, el caso for del procedimiento instr),
cada terminal se relaciona con el símbolo de preanáilisis, y cada no terminal conduce a una
llamada de su procedimiento, en la siguiente secuencia de llamadas:
coincidir (for); coincidir (();
expropc (); coincidir (;); expropc (); coincidir (;); expropc ();
coincidir ()); instr ();
El análisis sintáctico predictivo se basa en información acerca de los primeros símbolos que
pueden generarse mediante el cuerpo de una producción. Dicho en forma más precisa, vamos
a suponer que α es una cadena de símbolos de la gramática (terminales y no terminales). Definimos PRIMERO(α) como el conjunto de terminales que aparecen como los primeros símbolos
de una o más cadenas de terminales generadas a partir de α. Si α es o puede generar a ,
entonces α también está en PRIMERO(α).
Los detalles de cómo se calcula PRIMERO(α) se encuentran en la sección 4.4.2. Aquí vamos
a usar el razonamiento “ad hoc” para deducir los símbolos en PRIMERO(α); por lo general, α
empieza con un terminal, que por ende es el único símbolo en PRIMERO(α), o empieza con un
no terminal cuyos cuerpos de producciones empiezan con terminales, en cuyo caso estos terminales son los únicos miembros de PRIMERO(α).
Por ejemplo, con respecto a la gramática de la figura 2.16, los siguientes son cálculos correctos de PRIMERO.
65
2.4 Análisis sintáctico
void instr () {
switch ( preanálisis ) {
case expr:
coincidir (expr); coincidir (;); break;
case if:
coincidir (if); coincidir ((); coincidir (expr); coincidir ()); instr ();
break;
case for;
coincidir (for); coincidir (();
expropc (); coincidir (;); expropc (); coincidir (;); expropc ();
coincidir ()); instr (); break;
case otras;
coincidir (otras); break;
default:
reportar ("error de sintaxis");
}
}
void expropc () {
if ( preanálisis == expr ) coincidir (expr);
}
void coincidir (terminal t) {
if ( preanálisis == t ) preanálisis = siguienteTerminal;
else reportar ("error de sintaxis");
}
Figura 2.19: Seudocódigo para un analizador sintáctico predictivo
PRIMERO(instr)
PRIMERO(expr ;)
=
=
{expr, if, for, otras}
{expr}
Los PRIMEROS conjuntos deben considerarse si hay dos producciones A → α y A → β.
Si ignoramos las producciones por el momento, el análisis sintáctico predictivo requiere que
PRIMERO(α) y PRIMERO(β) estén separados. Así, puede usarse el símbolo de preanálisis para
decidir qué producción utilizar; si el símbolo de preanálisis está en PRIMERO(α), se utiliza α.
En caso contrario, si el símbolo de preanálisis está en PRIMERO(β),se utiliza β.
2.4.3
Cuándo usar las producciones Nuestro analizador sintáctico predictivo utiliza una producción como valor predeterminado si
no se puede utilizar otra producción. Con la entrada de la figura 2.18, después de relacionar los
terminales for y (, el símbolo de preanálisis es ;. En este punto se hace una llamada a expropc,
y se ejecuta el código
66
Capítulo 2. Un traductor simple orientado a la sintaxis
if ( preanálisis == expr ) coincidir (expr);
en su cuerpo. El no terminal expropc tiene dos producciones, con los cuerpos expr y . El
símbolo de preanálisis “;” no coincide con el terminal expr, por lo que no se puede aplicar la
producción con el cuerpo expr. De hecho, el procedimiento regresa sin cambiar el símbolo de
preanálisis o hacer cualquier otra cosa. Hacer nada corresponde a aplicar una producción .
En forma más general, considere una variante de las producciones en la figura 2.16, en donde expropc genera un no terminal de una expresión en vez del terminal expr:
expropc
expr
Por ende, expropc genera una expresión usando el no terminal expr o genera . Mientras se
analiza expropc, si el símbolo de preanálisis no se encuentra en PRIMERO(expr), entonces
se utiliza la producción .
Para obtener más información sobre cuándo usar las producciones , vea la explicación de
las gramáticas LL(1) en la sección 4.4.3.
2.4.4
Diseño de un analizador sintáctico predictivo
Podemos generalizar la técnica introducida de manera informal en la sección 2.4.2 para aplicarla a cualquier gramática que tenga un conjunto PRIMERO separado para los cuerpos de las
producciones que pertenezcan a cualquier no terminal. También veremos que al tener un esquema de traducción (es decir, una gramática con acciones embebidas) es posible ejecutar esas
acciones como parte de los procedimientos diseñados para el analizador sintáctico.
Recuerde que un analizador sintáctico predictivo es un programa que consiste en un procedimiento para cada no terminal. El procedimiento para el no terminal A realiza dos cosas:
1. Decide qué producción A debe utilizar, examinando el símbolo de preanálisis. La producción con el cuerpo α (en donde α no es , la cadena vacía) se utiliza si el símbolo
de preanálisis está en PRIMERO(α). Si hay un conflicto entre dos cuerpos no vacíos
por cualquier símbolo de preanálisis, entonces no podemos usar este método de análisis
sintáctico en esta gramática. Además, la producción para A, si existe, se utiliza si el
símbolo de preanálisis no se encuentra en el conjunto PRIMERO para cualquier otro cuerpo de producción para A.
2. Después, el procedimiento imita el cuerpo de la producción elegida. Es decir, los símbolos del cuerpo se “ejecutan” en turno, empezando desde la izquierda. Un no terminal
se “ejecuta” mediante una llamada al procedimiento para ese no terminal, y un terminal que coincide con el símbolo de preanálisis se “ejecuta” leyendo el siguiente símbolo
de entrada. Si en cualquier punto el terminal en el cuerpo no coincide con el símbolo de
preanálisis, se reporta un error de sintaxis.
La figura 2.19 es el resultado de aplicar estas reglas a la gramática de figura 2.16.
67
2.4 Análisis sintáctico
Así como un esquema de traducción se forma extendiendo una gramática, un traductor
orientado a la sintaxis puede formarse mediante la extensión de un analizador sintáctico predictivo. En la sección 5.4 se proporciona un algoritmo para este fin. La siguiente construcción
limitada es suficiente por ahora:
1. Se construye un analizador sintáctico predictivo, ignorando las acciones en las producciones.
2. Se copian las acciones del esquema de traducción al analizador sintáctico. Si una acción
aparece después del símbolo de la gramática X en la producción p, entonces se copia
después de la implementación de X en el código para p. En caso contrario, si aparece al
principio de la producción, entonces se copia justo antes del código para el cuerpo de la
producción.
En la sección 2.5 construiremos un traductor de este tipo.
2.4.5
Recursividad a la izquierda
Es posible que un analizador sintáctico de descenso recursivo entre en un ciclo infinito. Se produce un problema con las producciones “recursivas por la izquierda” como
expr →
expr + term
en donde el símbolo más a la izquierda del cuerpo es el mismo que el no terminal en el encabezado de la producción. Suponga que el procedimiento para expr decide aplicar esta producción.
El cuerpo empieza con expr, de manera que el procedimiento para expr se llama en forma recursiva. Como el símbolo de preanálisis cambia sólo cuando coincide un terminal en el cuerpo,
no se realizan cambios en la entrada entre las llamadas recursivas de expr. Como resultado, la
segunda llamada a expr hace exactamente lo que hizo la primera llamada, lo cual significa que
se realiza una tercera llamada a expr y así sucesivamente, por siempre.
Se puede eliminar una producción recursiva por la izquierda, reescribiendo la producción
problemática. Considere un terminal A con dos producciones:
A
→
Aα | β
en donde α y β son secuencias de terminales y no terminales que no empiezan con A. Por
ejemplo, en
expr →
expr + term | term
del no terminal A = expr, la cadena α = + term, y la cadena β = term.
Se dice que el no terminal A y su producción son recursivas por la izquierda, ya que la
producción A → Aα tiene a la misma A como el símbolo de más a la izquierda en el lado derecho.4 La aplicación repetida de esta producción genera una secuencia de α’s a la derecha de
A, como en la figura 2.20(a). Cuando por fin A se sustituye por β, tenemos una β seguida por
una secuencia de cero o más α’s.
En una gramática recursiva a la izquierda general, en vez de la producción A→ Aα, el no terminal A puede
derivar en Aα a través de producciones intermedias.
4
68
Capítulo 2. Un traductor simple orientado a la sintaxis
Figura 2.20: Formas recursivas por la izquierda y por la derecha para generar una cadena
Podemos lograr el mismo efecto que en la figura 2.20(b) si reescribimos las producciones
para A de la siguiente manera, usando un nuevo no terminal R:
El no terminal R y su producción R → αR son recursivos por la derecha, ya que esta producción para R tiene al mismo R como el último símbolo del lado derecho. Las producciones
recursivas por la derecha conducen a árboles que crecen hacia abajo y a la derecha, como en la
figura 2.20(b). Los árboles que crecen hacia abajo y a la derecha dificultan más la traducción de
expresiones que contienen operadores asociativos por la izquierda, como el signo menos resta.
No obstante, en la sección 2.5.2 veremos que de todas formas puede obtenerse la traducción
apropiada de expresiones a la notación postfijo mediante un diseño cuidadoso del esquema de
traducción.
En la sección 4.3.3 consideraremos formas más generales de recursividad por la izquierda, y
mostraremos cómo puede eliminarse toda la recursividad por la izquierda de una gramática.
2.4.6
Ejercicios para la sección 2.4
Ejercicio 2.4.1: Construya analizadores sintácticos de descenso recursivo, empezando con las
siguientes gramáticas:
2.5
Un traductor para las expresiones simples
Ahora vamos a construir un traductor orientado a la sintaxis usando las técnicas de las últimas
tres secciones, en forma de un programa funcional en Java, que traduce expresiones aritméticas
a la forma postfija. Para mantener el programa inicial lo bastante pequeño como para poder
2.5 Un traductor para las expresiones simples
69
administrarlo, empezaremos con expresiones que consisten en dígitos separados por los signos
binarios de suma y resta. En la sección 2.6 extenderemos el programa para traducir expresiones que incluyan números y otros operadores. Es conveniente estudiar la traducción de
expresiones con detalle, ya que aparecen como un constructor en muchos lenguajes.
A menudo, un esquema de traducción orientado a la sintaxis sirve como la especificación
para un traductor. El esquema en la figura 2.21 (repetido de la figura 2.15) define la traducción que se va a realizar aquí.
Figura 2.21: Acciones para traducir a la notación postfija
A menudo, la gramática subyacente de un esquema dado tiene que modificarse antes de poder analizarlo con un analizador sintáctico predictivo. En especial, la gramática subyacente del
esquema de la figura 2.21 es recursiva por la izquierda, y como vimos en la última sección, un
analizador sintáctico predictivo no puede manejar una gramática recursiva por la izquierda.
Parece que tenemos un conflicto: por una parte, necesitamos una gramática que facilite la
traducción, por otra parte necesitamos una gramática considerablemente distinta que facilite
el análisis sintáctico. La solución es empezar con la gramática para facilitar la traducción y
transformarla con cuidado para facilitar el análisis sintáctico. Al eliminar la recursividad por
la izquierda en la figura 2.21, podemos obtener una gramática adecuada para usarla en un
traductor predictivo de descenso recursivo.
2.5.1
Sintaxis abstracta y concreta
Un punto inicial útil para diseñar un traductor es una estructura de datos llamada árbol sintáctico abstracto. En un árbol sintáctico abstracto para una expresión, cada nodo interior representa a un operador; los hijos del nodo representan a los operandos del operador. De manera
más general, cualquier construcción de programación puede manejarse al formar un operador
para la construcción y tratar como operandos a los componentes con significado semántico de
esa construcción.
En el árbol sintáctico abstracto para 9−5+2 en la figura 2.22, la raíz representa al operador +.
Los subárboles de la raíz representan a las subexpresiones 9−5 y 2. El agrupamiento de 9−5
como un operando refleja la evaluación de izquierda a derecha de los operadores en el mismo nivel de precedencia. Como − y + tienen la misma precedencia, 9−5+2 es equivalente a (9−5)+2.
Los árboles sintácticos abstractos, conocidos simplemente como árboles sintácticos, se parecen en cierto grado a los árboles de análisis sintáctico. No obstante, en el árbol sintáctico
los nodos interiores representan construcciones de programación mientras que, en el árbol de
70
Capítulo 2. Un traductor simple orientado a la sintaxis
Figura 2.22: Árbol sintáctico para 9−5+2
análisis sintáctico, los nodos interiores representan no terminales. Muchos no terminales de una
gramática representan construcciones de programación, pero otros son “ayudantes” de un tipo
distinto, como los que representan términos, factores o demás variaciones de expresiones. En
el árbol sintáctico, estos ayudantes, por lo general, no son necesarios y por ende se descartan.
Para enfatizar el contraste, a un árbol de análisis sintáctico se le conoce algunas veces como
árbol sintáctico concreto, y a la gramática subyacente se le llama sintaxis completa para el
lenguaje.
En el árbol sintáctico de la figura 2.22, cada nodo interior se asocia con un operador, sin
nodos “ayudantes” para producciones individuales (una producción cuyo cuerpo consiste en
un no terminal individual, y nada más) como expr → term o para producciones como resto
→ .
Es conveniente que un esquema de traducción se base en una gramática cuyos árboles de
análisis sintáctico estén lo más cerca de los árboles sintácticos que sea posible. El agrupamiento
de subexpresiones mediante la gramática en la figura 2.21 es similar a su agrupamiento en los
árboles sintácticos. Por ejemplo, las subexpresiones para el operador de suma se proporcionan
mediante expr y term en el cuerpo de la producción expr + term.
2.5.2
Adaptación del esquema de traducción
La técnica de eliminación de recursividad por la izquierda trazada en la figura 2.20 también
se puede aplicar a las producciones que contengan acciones semánticas. En primer lugar, la
técnica se extiende a varias producciones para A. En nuestro ejemplo, A es expr y hay dos producciones recursivas a la izquierda para expr, y una que no es recursiva. La técnica transforma
las producciones A → Aα | Aβ | γ en lo siguiente:
En segundo lugar, debemos transformar producciones que hayan incrustado acciones, no
sólo terminales y no terminales. Las acciones semánticas incrustadas en las producciones simplemente se transportan en la transformación, como si fueran terminales.
Ejemplo 2.13: Considere el esquema de traducción de la figura 2.21. Suponga que:
71
2.5 Un traductor para las expresiones simples
Entonces, la transformación con eliminación de recursividad por la izquierda produce el esquema de traducción de la figura 2.23. Las producciones expr en la figura 2.21 se han transformado
en las producciones para expr, y un nuevo no terminal resto desempeña el papel de R. Las
producciones para term se repiten de la figura 2.21. La figura 2.24 muestra cómo se traduce
9−5+2, mediante la gramática de la figura 2.23.
2
term resto
resto
resto
resto
Figura 2.23: Esquema de traducción, después eliminar la recursividad por la izquierda
resto
Figura 2.24: Traducción de 9−5+2 a 95−2+
La eliminación de la recursividad por la izquierda debe realizarse con cuidado, para asegurar que se preserve el orden de las acciones semánticas. Por ejemplo, el esquema transformado
en la figura 2.23 tiene las acciones { print(+) } y { print(−) } en medio del cuerpo de una
producción, en cada caso entre los no terminales term y resto. Si las acciones se desplazaran
al final, después de resto, entonces las traducciones serían incorrectas. Dejamos como ejercicio
al lector demostrar que 9−5+2 se traduciría entonces en forma incorrecta a 952+−, la notación
postfija para 9−(5+2), en vez del término 95−2+ deseado, la notación postfija para (9−5)+2.
72
2.5.3
Capítulo 2. Un traductor simple orientado a la sintaxis
Procedimientos para los no terminales
Las funciones expr, resto y term en la figura 2.25 implementan el esquema de traducción orientada a la sintaxis de la figura 2.23. Estas funciones imitan los cuerpos de las producciones de
los no terminales correspondientes. La función expr implementa la producción expr → term
resto, mediante la llamada a term() seguida de resto().
void expr () {
term(); resto();
}
void resto() {
if ( preanálisis == + ) {
coincidir(+); term(); print(+); resto();
}
else if ( preanálisis == − ) {
coincidir(−); term(); print(−); resto();
}
else { } /* no hace nada con la entrada */ ;
}
void term() {
if ( preanálisis es un dígito ) {
t = preanálisis; coincidir(preanálisis); print(t);
}
else reportar(“error de sintaxis”);
}
Figura 2.25: Seudocódigo para los no terminales expr, resto y term
La función resto implementa las tres producciones para el no terminal resto en la figura
2.23. Aplica la primera producción si el símbolo de preanálisis es un signo positivo, la segunda
producción si el símbolo de preanálisis es negativo, y la producción resto → en todos los
demás casos. Las primeras dos producciones para resto se implementan mediante las primeras
dos bifurcaciones de la instrucción if en el procedimiento resto. Si el símbolo de preanálisis es +,
el signo positivo se relaciona mediante la llamada coincidir(+). Después de la llamada term(),
la acción semántica se implementa escribiendo un carácter de signo positivo. La segunda producción es similar, con − en vez de +. Como la tercera producción para resto tiene a del lado
derecho, la última cláusula else en la función resto no hace nada.
Las diez producciones para term generan los diez dígitos. Como cada una de estas producciones genera un dígito y lo imprime, el mismo código en la figura 2.25 las implementa todas.
Si la prueba tiene éxito, la variable t guarda el dígito representado por preanálisis, para poder
2.5 Un traductor para las expresiones simples
73
escribirlo después de la llamada a coincidir. Observe que coincidir cambia el símbolo de preanálisis, por lo que el dígito necesita guardarse para imprimirlo después.5
2.5.4
Simplificación del traductor
Antes de mostrar un programa completo, haremos dos transformaciones de simplificación al
código de la figura 2.25. Las simplificaciones incorporarán el procedimiento resto al procedimiento expr. Cuando se traducen expresiones con varios niveles de precedencia, dichas simplificaciones reducen el número de procedimientos necesarios.
En primer lugar, ciertas llamadas recursivas pueden sustituirse por iteraciones. Cuando la
última instrucción que se ejecuta en el cuerpo de un procedimiento es una llamada recursiva al
mismo procedimiento, se dice que la llamada es recursiva por la cola. Por ejemplo, en la función
resto, las llamadas de resto() con los símbolos de preanálisis + y − son recursivas por la cola,
ya que en cada una de estas bifurcaciones, la llamada recursiva a resto es la última instrucción
ejecutada por esa llamada de resto.
Para un procedimiento sin parámetros, una llamada recursiva por la cola puede sustituirse
simplemente por un salto al inicio del procedimiento. El código para resto puede reescribirse
como el seudocódigo de la figura 2.26. Siempre y cuando el símbolo de preanálisis sea un signo
positivo o negativo, el procedimiento resto relaciona el signo, llama a term para relacionar un
dígito y continúa el proceso. En caso contrario, sale del ciclo while y regresa de resto.
void resto() {
while( true ) {
if ( preanálisis == + ) {
coincidir(+); term(); print(+); continue;
}
else if preanálisis == − ) {
coincidir(−); term(); print(−); continue;
}
break ;
}
}
Figura 2.26: Eliminación de la recursividad por la cola en el procedimiento resto de
la figura 2.25
En segundo lugar, el programa Java completo incluirá un cambio más. Una vez que las
llamadas recursivas por la cola a resto en la figura 2.25 se sustituyan por iteraciones, la única llamada restante a resto es desde el interior del procedimiento expr. Por lo tanto, los dos
procedimientos pueden integrarse en uno, sustituyendo la llamada resto() por el cuerpo del procedimiento resto.
5
Como optimización menor, podríamos imprimir antes de llamar a coincidir para evitar la necesidad de guardar
el dígito. En general, es riesgoso cambiar el orden de las acciones y los símbolos de la gramática, ya que podríamos
modificar lo que hace la traducción.
74
2.5.5
Capítulo 2. Un traductor simple orientado a la sintaxis
El programa completo
En la figura 2.27 aparece el programa Java completo para nuestro traductor. La primera línea
de la figura 2.27, empezando con import, proporciona acceso al paquete java.io para la entrada y salida del sistema. El resto del código consiste en las dos clases Analizador y Postfijo.
La clase Analizador contiene la variable preanálisis y las funciones Analizador, expr, term
y coincidir.
La ejecución empieza con la función main, que se define en la clase Postfijo. La función
main crea una instancia analizar de la clase Analizador y llama a su función expr para
analizar una expresión.
La función Analizador, que tiene el mismo nombre que su clase, es un constructor; se
llama de manera automática cuando se crea un objeto de la clase. De la definición al principio de la clase Analizador, podemos ver que el constructor Analizador inicializa la variable
preanálisis leyendo un token. Los tokens, que consisten en caracteres individuales, los suministra la rutina de entada del sistema read, la cual lee el siguiente carácter del archivo de
entrada. Observe que preanálisis se declara como entero, en vez de carácter, para anticipar
el hecho de que en secciones posteriores introduciremos tokens adicionales además de los caracteres individuales.
La función expr es el resultado de la simplificación que vimos en la sección 2.5.4; implementa a los no terminales expr y rest de la figura 2.23. El código para expr en la figura 2.27 llama
a term y después tiene un ciclo while que evalúa infinitamente si preanálisis coincide con ’+’
o con ’−’. El control sale de este ciclo while al llegar a la instrucción return. Dentro del ciclo,
se utilizan las herramientas de entrada/salida de la clase System para escribir un carácter.
La función term utiliza la rutina isDigit de la clase Character de Java para probar si el
símbolo de preanálisis es un dígito. La rutina isDigit espera ser aplicada sobre un carácter;
no obstante, preanálisis se declara como entero, anticipando las extensiones a futuro. La
construcción (char) preanálisis convierte u obliga a preanálisis a ser un carácter. En una
pequeña modificación de la figura 2.25, la acción semántica de escribir el carácter preanálisis
ocurre antes de la llamada a coincidir.
La función coincidir comprueba los terminales; lee el siguiente terminal de la entrada si
hay una coincidencia con el símbolo preanálisis, e indica un error en cualquier otro caso,
mediante la ejecución de la siguiente instrucción:
throw new Error("error de sintaxis");
Este código crea una nueva excepción de la clase Error y proporciona la cadena error de
sintaxis como mensaje de error. Java no requiere que las excepciones Error se declaren en
una cláusula throws, ya que su uso está destinado sólo para los eventos anormales que nunca
deberían ocurrir.6
6
El manejo de errores puede simplificarse con las herramientas para manejo de excepciones de Java. Un método
sería definir una nueva excepción, por decir ErrorSintaxis, que extiende a la clase Exception del sistema. Después, se
lanza ErrorSintaxis en vez de Error al detectar un error, ya sea en term o en coincidir. Más adelante, se maneja la
excepción en main, encerrando la llamada analizar.expr() dentro de una instrucción try que atrape a la excepción
ErrorSintaxis, se escribe un mensaje y termina el programa. Tendríamos que agregar una clase ErrorSintaxis al
programa en la figura 2.27. Para completar la extensión, además de IOException, las funciones coincidir y term
ahora deben declarar que pueden lanzar a ErrorSintaxis. La función expr, que llama a estas dos funciones, también
debe declarar que puede lanzar a ErrorSintaxis.
2.5 Un traductor para las expresiones simples
import java.io.*;
class Analizador {
static int preanálisis;
public Analizador() throws IOException {
preanálisis = System.in.read();
}
void expr() throws IOException {
term();
while(true) {
if( preanálisis == ’+’ ) {
coincidir(’+’); term(); System.out.write(’+’);
}
else if( preanálisis == ’−’ ) {
coincidir(’−’); term(); System.out.write(’−’);
}
else return;
}
}
void term() throws IOException {
if( Character.isDigit((char) preanálisis) ) {
System.out.write((char) preanálisis); coincidir(preanálisis);
}
else throw new Error("error de sintaxis");
}
void coincidir(int t) throws IOException {
if(preanálisis == t ) preanálisis = System.in.read();
else throw new Error("error de sintaxis");
}
}
public class Postfijo {
public static void main(String[] args) throws IOException {
Analizador analizar = new Analizador();
analizar.expr(); System.out.write(’\n’);
}
}
Figura 2.27: Programa en Java para traducir expresiones infijas al formato postfijo
75
76
Capítulo 2. Un traductor simple orientado a la sintaxis
Unas cuantas características importantes sobre Java
Las siguientes notas pueden ser de utilidad para aquellos que no estén familiarizados con
Java, a la hora de leer el código de la figura 2.27:
• Una clase en Java consiste en una secuencia de definiciones de variables y funciones.
• Es necesario utilizar paréntesis para encerrar las listas de parámetros de las funciones, aun cuando no haya parámetros; por ende, escribimos expr() y term().
Estas funciones son en realidad procedimientos, ya que no devuelven valores; esto
se indica mediante la palabra clave void antes del nombre de la función.
• Las funciones se comunican enviando parámetros “por valor” o accediendo a los datos compartidos. Por ejemplo, las funciones expr() y term() examinan el símbolo
de preanálisis usando la variable preanálisis de la clase, a la que todos pueden
acceder, ya que todos pertenecen a la misma clase Analizador.
• Al igual que C, Java usa = para la asignación, == para la igualdad y != para la
desigualdad.
• La cláusula “throws IOException” en la definición de term() declara que se puede producir una excepción llamada IOException. Dicha excepción ocurre si no hay
entrada que leer cuando la función coincidir usa la rutina read. Cualquier función que llame a coincidir debe también declarar que puede ocurrir una excepción
IOException durante su propia ejecución.
2.6
Análisis léxico
Un analizador léxico lee los caracteres de la entrada y los agrupa en “objetos token”. Junto
con un símbolo de terminal que se utiliza para las decisiones de análisis sintáctico, un objeto
token lleva información adicional en forma de valores de atributos. Hasta ahora, no hemos tenido la necesidad de diferenciar entre los términos “token” y “terminal”, ya que el analizador
sintáctico ignora los valores de los atributos que lleva un token. En esta sección, un token es
un terminal junto con información adicional.
A una secuencia de caracteres de entrada que conforman un solo token se le conoce como
lexema. Por ende, podemos decir que el analizador léxico aísla a un analizador sintáctico de la
representación tipo lexema de los tokens.
El analizador léxico en esta sección permite que aparezcan números, identificadores y “espacio en blanco” (espacios, tabuladores y nuevas líneas) dentro de las expresiones. Puede
usarse para extender el traductor de expresiones de la sección anterior. Como la gramática
de expresiones de la figura 2.21 debe extenderse para permitir números e identificadores, apro-
77
2.6 Análisis léxico
vecharemos esta oportunidad para permitir también la multiplicación y la división. El esquema
de traducción extendido aparece en la figura 2.28.
{ print(num.valor) }
{ print(id.lexema) }
Figura 2.28: Acciones para traducir a la notación postfijo
En la figura 2.28, se asume que el terminal num tiene un atributo num.valor, el cual nos
da el valor entero correspondiente a esta ocurrencia de num. El terminal id tiene un atributo
con valor de cadena escrito como id.lexema; asumimos que esta cadena es el lexema actual que
comprende esta instancia del id del token.
Al final de esta sección, ensamblaremos en código Java los fragmentos de seudocódigo que
se utilizan para ilustrar el funcionamiento de un analizador léxico. El método en esta sección es
adecuado para los analizadores léxicos escritos a mano. La sección 3.5 describe una herramienta
llamada Lex que genera un analizador léxico a partir de una especificación. En la sección 2.7
consideraremos las tablas de símbolos o las estructuras de datos para guardar información
acerca de los identificadores.
2.6.1
Eliminación de espacio en blanco y comentarios
El traductor de expresiones en la sección 2.5 puede ver cada carácter en la entrada, por lo que
los caracteres extraños, como los espacios en blanco, harán que falle. La mayoría de los lenguajes permiten que aparezcan cantidades arbitrarias de espacio en blanco entre los tokens. Los
comentarios se ignoran de igual forma durante el análisis sintáctico, por lo que también pueden
tratarse como espacio en blanco.
Si el analizador léxico elimina el espacio en blanco, el analizador sintáctico nunca tendrá
que considerarlo. La alternativa de modificar la gramática para incorporar el espacio en blanco
en la sintaxis no es tan fácil de implementar.
El seudocódigo en la figura 2.29 omite el espacio en blanco, leyendo los caracteres de entrada hasta llegar a un espacio en blanco, un tabulador o una nueva línea. La variable vistazo
contiene el siguiente carácter de entrada. Los números de línea y el contexto son útiles dentro
de los mensajes de error para ayudar a señalar los errores; el código usa la variable línea para
contar los caracteres de nueva línea en la entrada.
78
Capítulo 2. Un traductor simple orientado a la sintaxis
for ( ; ; vistazo = siguiente carácter de entrada ) {
if ( vistazo es un espacio en blanco o un tabulador ) no hacer nada;
else if ( vistazo es una nueva línea ) línea = línea+1;
else break;
}
Figura 2.29: Omisión del espacio en blanco
2.6.2
Lectura adelantada
Tal vez un analizador léxico tenga que leer algunos caracteres de preanálisis, antes de que
pueda decidir sobre el token que va a devolver al analizador sintáctico. Por ejemplo, un analizador léxico para C o Java debe leer por adelantado después de ver el carácter >. Si el siguiente
carácter es =, entonces > forma parte de la secuencia de caracteres >=, el lexema para el token
del operador “mayor o igual que”. En caso contrario, > por sí solo forma el operador “mayor
que”, y el analizador léxico ha leído un carácter de más.
Un método general para leer por adelantado en la entrada es mantener un búfer de entrada,
a partir del cual el analizador léxico puede leer y devolver caracteres. Los búferes de entrada
pueden justificarse tan sólo por cuestión de eficiencia, ya que por lo general es más eficiente
obtener un bloque de caracteres que obtener un carácter a la vez. Un apuntador lleva el registro
de la porción de la entrada que se ha analizado; para regresar un carácter se mueve el apuntador hacia atrás. En la sección 3.2 veremos las técnicas para el uso de búfer en la entrada.
Por lo general, basta con leer un carácter de preanálisis, así que una solución simple es utilizar una variable, por decir vistazo, para guardar el siguiente carácter de entrada. El analizador
léxico en esta sección lee un carácter de preanálisis mientras recolecta dígitos para números, o
caracteres para identificadores; por ejemplo, lee más allá del 1 para diferenciar entre 1 y 10, y
lee más allá de t para diferenciar entre t y true.
El analizador léxico lee de preanálisis sólo cuando debe hacerlo. Un operador como * puede
identificarse sin necesidad de leer por adelantado. En tales casos, vistazo se establece a un espacio en blanco, que se omitirá cuando se llame al analizador léxico para buscar el siguiente token.
La aserción invariante en esta sección es que, cuando el analizador léxico devuelve un token, la
variable vistazo contiene el carácter que está más allá del lexema para el token actual, o contiene
un espacio en blanco.
2.6.3
Constantes
Cada vez que aparece un solo dígito en una gramática para expresiones, parece razonable permitir una constante entera arbitraria en su lugar. Las constantes enteras pueden permitirse al
crear un símbolo terminal, por decir num, para éstas, o al incorporar la sintaxis de constantes
enteras en la gramática. El trabajo de recolectar caracteres en enteros y calcular su valor numérico colectivo se asigna por lo general a un analizador léxico, para que los números puedan
tratarse como unidades individuales durante el análisis sintáctico y la traducción.
Cuando aparece una secuencia de dígitos en el flujo de entrada, el analizador léxico pasa
al analizador sintáctico un token que consiste en la terminal num, junto con un atributo con
79
2.6 Análisis léxico
valor de entero, el cual se calcula a partir de los dígitos. Si escribimos tokens como n-uplas
encerradas entre los signos y , la entrada 31 + 28 + 59 se transforma en la siguiente secuencia:
num, 31 + num, 28 + num, 59
Aquí, el símbolo terminal + no tiene atributos, por lo que su n-upla es simplemente +. El
seudocódigo en la figura 2.30 lee los dígitos en un entero y acumula el valor del entero, usando
la variable v.
if ( vistazo contiene un dígito ) {
v = 0;
do {
v = v * 10 + valor entero del dígito vistazo;
vistazo = siguiente carácter de entrada;
} while ( vistazo contiene un dígito );
return token num, v;
}
Figura 2.30: Agrupamiento de dígitos en enteros
2.6.4
Reconocimiento de palabras clave e identificadores
La mayoría de los lenguajes utilizan cadenas de caracteres fijas tales como for, do e if, como
signos de puntuación o para identificar las construcciones. A dichas cadenas de caracteres se
les conoce como palabras clave.
Las cadenas de caracteres también se utilizan como identificadores para nombrar variables,
arreglos, funciones y demás. Las gramáticas tratan de manera rutinaria a los identificadores
como terminales para simplificar el analizador sintáctico, el cual por consiguiente puede esperar
el mismo terminal, por decir id, cada vez que aparece algún identificador en la entrada. Por
ejemplo, en la siguiente entrada:
cuenta = cuenta + incremento;
(2.6)
el analizador trabaja con el flujo de terminales id = id + id. El token para id tiene un atributo
que contiene el lexema. Si escribimos los tokens como n-uplas, podemos ver que las n-uplas
para el flujo de entrada (2.6) son:
id, "cuenta" = id, "cuenta" + id, "incremento" ;
Por lo general, las palabras clave cumplen con las reglas para formar identificadores, por lo
que se requiere un mecanismo para decidir cuándo un lexema forma una palabra clave y cuándo
un identificador. El problema es más fácil de resolver si las palabras clave son reservadas; es
decir, si no pueden utilizarse como identificadores. Entonces, una cadena de caracteres forma
a un identificador sólo si no es una palabra clave.
80
Capítulo 2. Un traductor simple orientado a la sintaxis
El analizador léxico en esta sección resuelve dos problemas, utilizando una tabla para guardar cadenas de caracteres:
• Representación simple. Una tabla de cadenas puede aislar al resto del compilador de la
representación de las cadenas, ya que las fases del compilador pueden trabajar con referencias o apuntadores a la cadena en la tabla. Las referencias también pueden manipularse con más eficiencia que las mismas cadenas.
• Palabras reservadas. Las palabras reservadas pueden implementarse mediante la inicialización de la tabla de cadenas con las cadenas reservadas y sus tokens. Cuando el analizador léxico lee una cadena o lexema que podría formar un identificador, primero verifica
si el lexema se encuentra en la tabla de cadenas. De ser así, devuelve el token de la tabla;
en caso contrario, devuelve un token con el terminal id.
En Java, una tabla de cadenas puede implementarse como una hash table, usando una clase
llamada Hashtable. La siguiente declaración:
Hashtable palabras = new Hashtable();
establece a palabras como una hash table predeterminada, que asigna claves a valores. La utilizaremos para asignar los lexemas a los tokens. El seudocódigo en la figura 2.31 utiliza la operación
get para buscar palabras reservadas.
if ( vistazo contiene una letra ) {
recolectar letras o dígitos en un búfer b;
s = cadena formada a partir de los caracteres en b;
w = token devuelto por palabras.get(s);
if ( w no es null ) return w;
else {
Introducir el par clave-valor (s, id, s) en palabras
return token id, s;
}
}
Figura 2.31: Distinción entre palabras clave e identificadores
Este seudocódigo recolecta de la entrada una cadena s, la cual consiste en letras y dígitos,
empezando con una letra. Suponemos que s se hace lo más larga posible; es decir, el analizador
léxico continuará leyendo de la entrada mientras siga encontrando letras y dígitos. Cuando
encuentra algo distinto a una letra o dígito, por ejemplo, espacio en blanco, el lexema se copia
a un búfer b. Si la tabla tiene una entrada para s, entonces se devuelve el token obtenido por
palabras.get. Aquí, s podría ser una palabra clave, con la que se sembró inicializado la tabla palabras, o podría ser un identificador que se introdujo antes en la tabla. En caso contrario, el
token id y el atributo s se instalan en la tabla y se devuelven.
81
2.6 Análisis léxico
2.6.5
Un analizador léxico
Hasta ahora en esta sección, los fragmentos de seudocódigo se juntan para formar una función
llamada escanear que devuelve objetos token, de la siguiente manera:
Token escanear () {
omitir espacio en blanco, como en la sección 2.6.1;
manejar los números, como en la sección 2.6.3;
manejar las palabras reservadas e identificadores, como en la sección 2.6.4;
/* Si llegamos aquí, tratar el carácter de lectura de preanálisis vistazo como token */
Token t = new Token(vistazo);
vistazo = espacio en blanco /* inicialización, como vimos en la sección 2.6.2 */;
return t;
}
El resto de esta sección implementa la función explorar como parte de un paquete de Java
para el análisis léxico. El paquete, llamado analizador lexico tiene clases para tokens y una
clase AnalizadorLexico que contiene la función escanear.
Las clases para los tokens y sus campos se ilustran en la figura 2.32; sus métodos no se
muestran. La clase Token tiene un campo llamado etiqueta, el cual se usa para las decisiones
sobre el análisis sintáctico. La subclase Num agrega un campo valor para un valor de entero.
La subclase Palabra agrega un campo lexema, el cual se utiliza para las palabras reservadas
y los identificadores.
clase Token
int etiqueta
clase Palabra
string lexema
clase Num
int valor
Figura 2.32: La clase Token y las subclases Num y Palabra
Cada clase se encuentra en un archivo por sí sola. A continuación se muestra el archivo
para la clase Token:
1) package analizadorlexico;
// Archivo Token.java
2) public class Token {
3)
public final int etiqueta;
4)
public Token(int t) {etiqueta = t; }
5) }
La línea 1 identifica el paquete analizadorlexico. El campo etiqueta se declara en la
línea 3 como final, de forma que no puede modificarse una vez establecido. El constructor
Token en la línea 4 se utiliza para crear objetos token, como en
new Token(’+’)
lo cual crea un nuevo objeto de la clase Token y establece su campo etiqueta a una representación entera de ’+’. Por cuestión de brevedad, omitimos el habitual método toString, que
devolvería una cadena adecuada para su impresión.
82
Capítulo 2. Un traductor simple orientado a la sintaxis
En donde el seudocódigo tenga terminales como num e id, el código de Java utiliza constantes enteras. La clase Etiqueta implementa a dichas constantes:
1) package analizadorlexico;
// Archivo Etiqueta.java
2) public class Etiqueta {
3)
public final static int
4)
NUM = 256, ID = 257, TRUE = 258, FALSE = 259;
5) }
Además de los campos con valor de entero NUM e ID, esta clase define dos campos adicionales,
TRUE y FALSE, para un uso futuro; se utilizarán para ilustrar el tratamiento de las palabras
clave reservadas.7
Los campos en la clase Etiqueta son public, por lo que pueden usarse fuera del paquete.
Son static, por lo que sólo hay una instancia o copia de estos campos. Los campos son final,
por lo que pueden establecerse sólo una vez. En efecto, estos campos representan constantes.
En C se logra un efecto similar mediante el uso de instrucciones de definición, para permitir
que nombres como NUM se utilicen como constantes simbólicas, por ejemplo:
#define NUM 256
El código en Java se refiere a Etiqueta.NUM y Etiqueta.ID en lugares en donde el seudocódigo se refiere a los terminales num e id. El único requerimiento es que Etiqueta.NUM y
Etiqueta.ID deben inicializarse con valores distintos, que difieran uno del otro y de las constantes que representan tokens de un solo carácter, como ’+’ o ’*’.
1) package analizadorlexico;
// Archivo Num.java
2) public class Num extends Token {
3)
public final int valor;
4)
public Num(int v) { super(Etiqueta.NUM); valor = v; }
5) }
1) package analizadorlexico;
// Archivo Palabra.java
2) public class Palabra extends Token {
3)
public final String lexema;
4)
public Palabra(int t, String s) {
5)
super(t); lexema = new String(s);
6)
}
7) }
Figura 2.33: Las subclases Num y Palabra de Token
Las clases Num y Palabra aparecen en la figura 2.33. La clase Num extiende a Token mediante la declaración de un campo entero valor en la línea 3. El constructor Num en la línea 4
llama a super(Etiqueta.NUM), que establece el campo etiqueta en la superclase Token a
Etiqueta.NUM.
7
Por lo general, los caracteres ASCII se convierten en enteros entre 0 y 255. Por lo tanto, utilizaremos enteros
mayores de 255 para los terminales.
83
2.6 Análisis léxico
1) package analizadorlexico;
// Archivo AnalizadorLexico.java
2) import java.io.*; import java.util.*;
3) public class AnalizadorLexico {
4)
public int linea = 1;
5)
private char vistazo = ’ ’;
6)
private Hashtable palabras = new Hashtable();
7)
void reservar(Palabra t) { palabras.put(t.lexema, t); }
8)
public AnalizadorLexico() {
9)
reservar( new Palabra(Etiqueta.TRUE, "true") );
10)
reservar( new Palabra(Etiqueta.FALSE, "false") );
11)
}
12)
public Token explorar() throws IOException {
13)
for( ; ; vistazo = (char)System.in.read() ) {
14)
if( vistazo == ’ ’ || vistazo == ’\t’ ) continue;
15)
else if( vistazo == ’\n’ ) linea = linea + 1;
16)
else break;
17)
}
/* continúa en la figura 2.35 */
Figura 2.34: Código para un analizador léxico, parte 1 de 2
La clase Palabra se utiliza para palabras reservadas e identificadores, por lo que el constructor Palabra en la línea 4 espera dos parámetros: un lexema y un valor entero correspondiente para etiqueta. Podemos crear un objeto para la palabra reservada true ejecutando lo
siguiente:
new Palabra(Etiqueta.TRUE, "true")
lo cual crea un Nuevo objeto con el campo etiqueta establecido en etiqueta.TRUE y el campo
lexema establecido a la cadena "true".
La clase AnalizadorLexico para el análisis léxico aparece en las figuras 2.34 y 2.35. La
variable entera linea en la línea 4 cuenta las líneas de entrada, y la variable carácter vistazo
en la línea 5 contiene el siguiente carácter de entrada.
Las palabras reservadas se manejan en las líneas 6 a 11. La tabla palabras se declara en
la línea 6. La función auxiliar reservar en la línea 7 coloca un par cadena-palabra en la tabla.
Las líneas 9 y 10 en el constructor AnalizadorLexico inicializan la tabla. Utilizan el constructor Palabra para crear objetos tipo palabra, los cuales se pasan a la función auxiliar reservar.
Por lo tanto, la tabla se inicializa con las palabras reservadas "true" y "false" antes de la
primera llamada de explorar.
El código para escanear en las figuras 2.34-2.35 implementa los fragmentos de seudocódigo
en esta sección. La instrucción for en las líneas 13 a 17 omite los caracteres de espacio en blanco, tabuladores y de nueva línea. Cuando el control sale de la instrucción for, vistazo contiene
un carácter que no es espacio en blanco.
El código para leer una secuencia de dígitos está en las líneas 18 a 25. La función isDigit
es de la clase Character integrada en Java. Se utiliza en la línea 18 para comprobar si vistazo
84
Capítulo 2. Un traductor simple orientado a la sintaxis
18)
19)
20)
21)
22)
23)
24)
25)
26)
27)
28)
29)
30)
31)
32)
33)
34)
35)
36)
37)
38)
39)
40)
41)
42)
43) }
if( Character.isDigit(vistazo) ) {
int v = 0;
do {
v = 10*v + Character.digit(vistazo, 10);
vistazo = (char)System.in.read();
} while( Character.isDigit(vistazo) );
return new Num(v);
}
if( Character.isLetter(vistazo) ) {
StringBuffer b = new StringBuffer();
do {
b.append(vistazo);
peek = (char)System.in.read();
} while( Character.isLetterOrDigit(vistazo) );
String s = b.toString();
Palabra w = (Palabra)palabras.get(s);
if (w != null ) return w;
w = new Palabra(Etiqueta.ID, s);
palabras.put(s, w);
return w;
}
Token t = new Token(vistazo);
vistazo = ’ ’;
return t;
}
Figura 2.35: Código para un analizador léxico, parte 2 de 2
es un dígito. De ser así, el código en las líneas 19 a 24 acumula el valor entero de la secuencia
de dígitos en la entrada y devuelve un nuevo objeto Num.
Las líneas 26 a 38 analizan palabras reservadas e identificadores. Las palabras clave true y
false ya se han reservado en las líneas 9 y 10. Así, si llegamos a la línea 35 entonces la cadena
s no está reservada, por lo cual debe ser el lexema para un identificador. La línea 35, por lo
tanto, devuelve un nuevo objeto palabra con lexema establecido a s y etiqueta establecida
a Etiqueta.ID. Por último, las líneas 39 a 41 devuelven el carácter actual como un token y
establecen vistazo a un espacio en blanco, que se eliminará la próxima vez que se llame a
escanear.
2.6.6
Ejercicios para la sección 2.6
Ejercicio 2.6.1: Extienda el analizador léxico de la sección 2.6.5 para eliminar los comentarios, que se definen de la siguiente manera:
a) Un comentario empieza con // e incluye a todos los caracteres hasta el fin de esa línea.
85
2.7 Tablas de símbolos
b) Un comentario empieza con /* e incluye a todos los caracteres hasta la siguiente ocurrencia de la secuencia de caracteres */.
Ejercicio 2.6.2: Extienda el analizador léxico en la sección 2.6.5 para que reconozca los operadores relacionales <, <=, ==, !=, >=, >.
Ejercicio 2.6.3: Extienda el analizador léxico en la sección 2.6.5 para que reconozca los valores de punto flotante como 2., 3.14 y .5.
2.7
Tablas de símbolos
Las tablas de símbolos son estructuras de datos que utilizan los compiladores para guardar
información acerca de las construcciones de un programa fuente. La información se recolecta
en forma incremental mediante las fases de análisis de un compilador, y las fases de síntesis la
utilizan para generar el código destino. Las entradas en la tabla de símbolos contienen información acerca de un identificador, como su cadena de caracteres (o lexema), su tipo, su posición
en el espacio de almacenamiento, y cualquier otra información relevante. Por lo general, las
tablas de símbolos necesitan soportar varias declaraciones del mismo identificador dentro de
un programa.
En la sección 1.6.1 vimos que el alcance de una declaración es la parte de un programa a la
cual se aplica esa declaración. Vamos a implementar los alcances mediante el establecimiento
de una tabla de símbolos separada para cada alcance. Un bloque de programa con declaraciones8 tendrá su propia tabla de símbolos, con una entrada para cada declaración en el bloque.
Este método también funciona para otras construcciones que establecen alcances; por ejemplo,
una clase tendría su propia tabla, con una entrada para cada campo y cada método.
Esta sección contiene un módulo de tabla de símbolos, adecuado para usarlo con los fragmentos del traductor en Java de este capítulo. El módulo se utilizará como está, cuando
ensamblemos el traductor completo en el apéndice A. Mientras tanto, por cuestión de simplicidad, el ejemplo principal de esta sección es un lenguaje simplificado, que sólo contiene las
construcciones clave que tocan las tablas de símbolos; en especial, los bloques, las declaraciones
y los factores. Omitiremos todas las demás construcciones de instrucciones y expresiones, para
poder enfocarnos en las operaciones con la tabla de símbolos. Un programa consiste en bloques
con declaraciones opcionales e “instrucciones” que consisten en identificadores individuales.
Cada instrucción de este tipo representa un uso del identificador. He aquí un programa de
ejemplo en este lenguaje:
{ int x; char y; { bool y; x; y; } x; y; }
(2.7)
Los ejemplos de la estructura de bloques en la sección 1.6.3 manejaron las definiciones y usos
de nombres; la entrada (2.7) consiste únicamente de definiciones y usos de nombres.
La tarea que vamos a realizar es imprimir un programa revisado, en el cual se han eliminado
las declaraciones y cada “instrucción” tiene su identificador, seguido de un signo de punto y
coma y de su tipo.
8
En C, por ejemplo, los bloques de programas son funciones o secciones de funciones que se separan mediante
llaves, y que tienen una o más declaraciones en su interior.
86
Capítulo 2. Un traductor simple orientado a la sintaxis
¿Quién crea las entradas en la tabla de símbolos?
El analizador léxico, el analizador sintáctico y el analizador semántico son los que crean
y utilizan las entradas en la tabla de símbolos durante la fase de análisis. En este capítulo, haremos que el analizador sintáctico cree las entradas. Con su conocimiento de la
estructura sintáctica de un programa, por lo general, un analizador sintáctico está en una
mejor posición que el analizador léxico para diferenciar entre las distintas declaraciones
de un identificador.
En algunos casos, un analizador léxico puede crear una entrada en la tabla de símbolos, tan pronto como ve los caracteres que conforman un lexema. Más a menudo, el analizador léxico sólo puede devolver un token al analizador sintáctico, por decir id, junto con
un apuntador al lexema. Sin embargo, sólo el analizador sintáctico puede decidir si debe
utilizar una entrada en la tabla de símbolos que se haya creado antes, o si debe crear una
entrada nueva para el identificador.
Ejemplo 2.14: En la entrada anterior (2.7), el objetivo es producir lo siguiente:
{
{
x:int; y:bool; } x:int; y:char; }
Las primeras x y y son del bloque interno de la entrada (2.7). Como este uso de x se refiere a la
declaración de x en el bloque externo, va seguido de int, el tipo de esa declaración. El uso de y
en el bloque interno se refiere a la declaración de y en ese mismo bloque y, por lo tanto, tiene el
tipo booleano. También vemos los usos de x y y en el bloque externo, con sus tipos, según los
proporcionan las declaraciones del bloque externo: entero y carácter, respectivamente. 2
2.7.1
Tabla de símbolos por alcance
El término “alcance del identificador x” en realidad se refiere al alcance de una declaración
específica de x. El término alcance por sí solo se refiere a una parte del programa que es el
alcance de una o más declaraciones.
Los alcances son importantes, ya que el mismo identificador puede declararse para distintos
fines en distintas partes de un programa. A menudo, los nombres comunes como i y x tienen
varios usos. Como otro ejemplo, las subclases pueden volver a declarar el nombre de un método
para redefinirlo de una superclase.
Si los bloques pueden anidarse, varias declaraciones del mismo identificador pueden aparecer dentro de un solo bloque. La siguiente sintaxis produce bloques anidados cuando instrs
puede generar un bloque:
bloque → { decls instrs }
Colocamos las llaves entre comillas simples en la sintaxis para diferenciarlas de las llaves para
las acciones semánticas. Con la gramática en la figura 2.38, decls genera una secuencia opcional
de declaraciones e instrs genera una secuencia opcional de instrucciones.
2.7 Tablas de símbolos
87
Optimización de las tablas de símbolos para los bloques
Las implementaciones de las tablas de símbolos para los bloques pueden aprovechar la
regla del bloque anidado más cercano. El anidamiento asegura que la cadena de tablas
de símbolos aplicables forme una pila. En la parte superior de la pila se encuentra la
tabla para el bloque actual. Debajo de ella en la pila están las tablas para los bloques
circundantes. Por ende, las tablas de símbolos pueden asignarse y desasignarse en forma
parecida a una pila.
Algunos compiladores mantienen una sola hash table de entradas accesibles; es decir,
de entradas que no se ocultan mediante una declaración en un bloque anidado. Dicha
hash table soporta búsquedas esenciales en tiempos constantes, a expensas de insertar y
eliminar entradas al entrar y salir de los bloques. Al salir de un bloque B, el compilador
debe deshacer cualquier modificación a la hash table debido a las declaraciones en el
bloque B. Para ello puede utilizar una pila auxiliar, para llevar el rastro de las modificaciones a la tabla hash mientras se procesa el bloque B.
Inclusive, una instrucción puede ser un bloque, por lo que nuestro lenguaje permite bloques
anidados, en donde puede volver a declararse un identificador.
La regla del bloque anidado más cercano nos indica que un identificador x se encuentra
en el alcance de la declaración anidada más cercana de x; es decir, la declaración de x que se
encuentra al examinar los bloques desde adentro hacia fuera, empezando con el bloque en el
que aparece x.
Ejemplo 2.15: El siguiente seudocódigo utiliza subíndices para diferenciar entre las distintas
declaraciones del mismo identificador:
1)
2)
3)
4)
5)
6)
{
int x 1; int y 1;
{ int w 2; bool y 2; int z 2;
… w 2 …; … x 1 …; … y 2 …; … z 2 …;
}
… w 0 …; … x 1 …; … y 1 …;
}
El subíndice no forma parte de un identificador; es, de hecho, el número de línea de la declaración que se aplica al identificador. Por ende, todas las ocurrencias de x están dentro del alcance
de la declaración en la línea 1. La ocurrencia de y en la línea 3 está en el alcance de la declaración de y en la línea 2, ya que y se volvió a declarar dentro del bloque interno. Sin embargo, la
ocurrencia de y en la línea 5 está dentro del alcance de la declaración de y en la línea 1.
La ocurrencia de w en la línea 5 se encuentra supuestamente dentro del alcance de una
declaración de w fuera de este fragmento del programa; su subíndice 0 denota una declaración
que es global o externa para este bloque.
Por último, z se declara y se utiliza dentro del bloque anidado, pero no puede usarse en la
línea 5, ya que la declaración anidada se aplica sólo al bloque anidado.
2
88
Capítulo 2. Un traductor simple orientado a la sintaxis
La regla del bloque anidado más cercano puede implementarse mediante el encadenamiento
de las tablas de símbolos. Es decir, la tabla para un bloque anidado apunta a la tabla para el
bloque circundante.
Ejemplo 2.16: La figura 2.36 muestra tablas de símbolos para el seudocódigo del ejemplo
2.15. B1 es para el bloque que empieza en la línea 1 y B2 es para el bloque que empieza en la
línea 2. En la parte superior de la figura hay una tabla de símbolos adicional B0 para cualquier
declaración global o predeterminada que proporcione el lenguaje. Durante el tiempo que analizamos las líneas 2 a 4, el entorno se representa mediante una referencia a la tabla de símbolos
inferior (la de B2). Cuando avanzamos a la línea 5, la tabla de símbolos para B2 se vuelve inaccesible, y el entorno se refiere en su lugar a la tabla de símbolos para B1, a partir de la cual
podemos llegar a la tabla de símbolos global, pero no a la tabla para B2.
2
Figura 2.36: Tablas de símbolos encadenadas para el ejemplo 2.15
La implementación en Java de las tablas de símbolos encadenadas en la figura 2.37 define a
una clase Ent, abreviación de entorno.9 La clase Ent soporta tres operaciones:
• Crear una nueva tabla de símbolos. El constructor Ent(p) en las líneas 6 a 8 de la figura
2.37 crea un objeto Ent con una hash table llamada tabla. El objeto se encadena al
parámetro con valor de entorno p, estableciendo el campo siguiente a p. Aunque los
objetos Ent son los que forman una cadena, es conveniente hablar de las tablas que se van
a encadenar.
• put una nueva entrada en la tabla actual. La hash table contiene pares clave-valor, en
donde:
– La clave es una cadena, o más bien una referencia a una cadena. Podríamos usar de
manera alternativa referencias a objetos token para identificadores como las claves.
– El valor es una entrada de la clase Simbolo. El código en las líneas 9 a 11 no necesita conocer la estructura de una entrada; es decir, el código es independiente de los
campos y los métodos en la clase Simbolo.
9
“Entorno” es otro término para la colección de tablas de símbolos que son relevantes en un punto dado en el
programa.
89
2.7 Tablas de símbolos
1) package sı́mbolos;
2) import java.util.*;
3) public class Ent {
4)
private Hashtable tabla;
5)
protected Ent ant;
// Archivo Ent.java
public Ent(Ent p) {
tabla = new Hashtable(); ant = p;
}
6)
7)
8)
9)
10)
11)
public void put(String s, Simbolo sim) {
tabla.put(s, sim);
}
12)
13)
14)
15)
16)
17)
18)
19) }
public Simbolo get(String s) {
for( Ent e = this; e != null; e = e.ant ) {
Simbolo encontro = (Simbolo)(e.tabla.get(s));
if( encontro != null ) return encontro;
}
return null;
}
Figura 2.37: La clase Ent implementa a las tablas de símbolos encadenadas
• get una entrada para un identificador buscando en la cadena de tablas, empezando con la
tabla para el bloque actual. El código para esta operación en las líneas 12 a 18 devuelve
una entrada en la tabla de símbolos o null.
El encadenamiento de las tablas de símbolos produce una estructura tipo árbol, ya que puede anidarse más de un bloque dentro de un bloque circundante. Las líneas punteadas en la figura
2.36 son un recordatorio de que las tablas de símbolos encadenadas pueden formar un árbol.
2.7.2
El uso de las tablas de símbolos
En efecto, la función de una tabla de símbolos es pasar información de las declaraciones a los
usos. Una acción semántica “coloca” (put) información acerca de un identificador x en la tabla
de símbolos, cuando se analiza la declaración de x. Posteriormente, una acción semántica asociada con una producción como factor → id “obtiene” (get) información acerca del identificador, de la tabla de símbolos. Como la traducción de una expresión E1 op E2, para un operador
op ordinario, depende sólo de las traducciones de E1 y E2, y no directamente de la tabla de
símbolos, podemos agregar cualquier número de operadores sin necesidad de cambiar el flujo
básico de información de las declaraciones a los usos, a través de la tabla de símbolos.
Ejemplo 2.17: El esquema de traducción en la figura 2.38 ilustra cómo puede usarse la clase
Ent. El esquema de traducción se concentra en los alcances, las declaraciones y los usos. Implementa la traducción descrita en el ejemplo 2.14. Como dijimos antes, en la entrada
90
Capítulo 2. Un traductor simple orientado a la sintaxis
programa
→
bloque
→
{ sup = null; }
bloque
{
decls instrs }
decls
→
|
decls decl
decl
→
tipo id ;
instrs
→
|
instrs instr
instr
→
|
bloque
factor ;
→
id
factor
{ guardado = sup;
sup = new Ent(sup);
print("{ "); }
{ sup = guardado;
print("} "); }
{ s = new Símbolo;
s.type = tipo.lexema
sup.put(id.lexema, s); }
{ print("; "); }
{ s = sup.get(id.lexema);
print(id.lexema);
print(":"); }
print(s.tipo);
Figura 2.38: El uso de las tablas de símbolos para traducir un lenguaje con bloques
{ int x;
char y;
{ bool y; x; y; } x; y; }
el esquema de traducción elimina las declaraciones y produce
{
{
x:int;
y:bool;
}
x:int;
y:char; }
Observe que los cuerpos de las producciones se alinearon en la figura 2.38, para que todos
los símbolos de gramática aparezcan en una columna y todas las acciones en una segunda
columna. Como resultado, por lo general, los componentes del cuerpo se esparcen a través de
varias líneas.
Ahora, consideremos las acciones semánticas. El esquema de traducción crea y descarta las
tablas de símbolos al momento de entrar y salir de los bloques, respectivamente. La variable
sup denota la tabla superior, en el encabezado de una cadena de tablas. La primera producción
de la gramática subyacente es programa → bloque. La acción semántica antes de bloque inicializa sup a null, sin entradas.
91
2.8 Generación de código intermedio
La segunda producción, bloque → { decls instrs }, tiene acciones al momento en que se
entra y se sale del bloque. Al entrar al bloque, antes de decls, una acción semántica guarda una
referencia a la tabla actual, usando una variable local llamada guardado. Cada uso de esta producción tiene su propia variable local guardado, distinta de la variable local para cualquier otro
uso de esta producción. En un analizador sintáctico de descenso recursivo, guardado sería local
para el bloque for del procedimiento. En la sección 7.2 veremos el tratamiento de las variables
locales de una función recursiva. El siguiente código:
sup = new Ent(sup);
establece la variable sup a una tabla recién creada que está encadenada al valor anterior de sup,
justo antes de entrar al bloque. La variable sup es un objeto de la clase Ent; el código para el
constructor Ent aparece en la figura 2.37.
Al salir del bloque, después de }, una acción semántica restaura sup al valor que tenía
guardado al momento de entrar al bloque. En realidad, las tablas forman una pila; al restaurar
sup a su valor guardado, se saca el efecto de las declaraciones en el bloque.10 Por ende, las
declaraciones en el bloque no son visibles fuera del mismo.
Una declaración, decls → tipo id produce una nueva entrada para el identificador declarado. Asumimos que los tokens tipo e id tienen cada uno un atributo asociado, que es el
tipo y el lexema, respectivamente, del identificador declarado. En vez de pasar por todos los
campos de un objeto de símbolo s, asumiremos que hay un campo tipo que proporciona el tipo
del símbolo. Creamos un nuevo objeto de símbolo s y asignamos su tipo de manera apropiada,
mediante s.tipo = tipo.lexema. La entrada completa se coloca en la tabla de símbolos superior
mediante sup.put(id.lexema, s).
La acción semántica en la producción factor → id utiliza la tabla de símbolos para obtener
la entrada para el identificador. La operación get busca la primera entrada en la cadena de
tablas, empezando con sup. La entrada que se obtiene contiene toda la información necesaria
acerca del identificador, como su tipo. 2
2.8
Generación de código intermedio
El front-end de un compilador construye una representación intermedia del programa fuente,
a partir de la cual el back-end genera el programa destino. En esta sección consideraremos
representaciones intermedias para expresiones e instrucciones, y veremos ejemplos de cómo
producir dichas representaciones.
2.8.1
Dos tipos de representaciones intermedias
Como sugerimos en la sección 2.1 y especialmente en la figura 2.4, las dos representaciones
intermedias más importantes son:
10
En vez de guardar y restaurar tablas en forma explícita, una alternativa podría ser agregar las operaciones
estáticas push y pop a la clase Ent.
92
Capítulo 2. Un traductor simple orientado a la sintaxis
• Los árboles, incluyendo los de análisis sintáctico y los sintácticos (abstractos).
• Las representaciones lineales, en especial el “código de tres direcciones”.
Los árboles sintácticos abstractos, o simplemente sintácticos, se presentaron en la sección
2.5.1, y en la sección 5.3.1 volveremos a examinarlos de una manera más formal. Durante el
análisis sintáctico, se crean los nodos del árbol sintáctico para representar construcciones de
programación importantes. A medida que avanza el análisis, se agrega información a los nodos
en forma de atributos asociados con éstos. La elección de atributos depende de la traducción
a realizar.
Por otro lado, el código de tres direcciones es una secuencia de pasos de programa elementales, como la suma de dos valores. A diferencia del árbol, no hay una estructura jerárquica.
Como veremos en el capítulo 9, necesitamos esta representación si vamos a realizar algún tipo
de optimización importante de código. En ese caso, dividimos la extensa secuencia de instrucciones de tres direcciones que forman un programa en “bloques básicos”, que son secuencias de
instrucciones que siempre se ejecutan una después de la otra, sin bifurcaciones.
Además de crear una representación intermedia, el front-end de un compilador comprueba
que el programa fuente siga las reglas sintácticas y semánticas del lenguaje fuente. A esta
comprobación se le conoce como comprobación estática; en general, “estático” significa “realizado por el compilador”.11 La comprobación estática asegura que se detecten ciertos tipos
de errores de programación, incluyendo los conflictos de tipos, y que se reporten durante la
compilación.
Es posible que un compilador construya un árbol sintáctico al mismo tiempo que emite los
pasos del código de tres direcciones. No obstante, es común que los compiladores emitan el
código de tres direcciones mientras el analizador sintáctico “avanza en el proceso” de construir
un árbol sintáctico, sin construir en realidad la estructura de datos tipo árbol completa. En
vez de ello, el compilador almacena los nodos y sus atributos necesarios para la comprobación
semántica o para otros fines, junto con la estructura de datos utilizada para el análisis sintáctico. Al hacer esto, las partes del árbol sintáctico que son necesarias para construir el código
de tres direcciones están disponibles cuando se necesitan, pero desaparecen cuando ya no son
necesarias. En el capítulo 5 veremos los detalles relacionados con este proceso.
2.8.2
Construcción de árboles sintácticos
Primero vamos a proporcionar un esquema de traducción para construir árboles sintácticos,
y después, en la sección 2.8.4, mostraremos cómo puede modificarse este esquema para emitir
código de tres direcciones, junto con, o en vez de, el árbol sintáctico.
En la sección 2.5.1 vimos que el árbol sintáctico
11
Su contraparte, “dinámico”, significa “mientras el programa se ejecuta”. Muchos lenguajes también realizan
ciertas comprobaciones dinámicas. Por ejemplo, un lenguaje orientado a objetos como Java algunas veces debe comprobar los tipos durante la ejecución de un programa, ya que el método que se aplique a un objeto puede depender de
la subclase específica de ese objeto.
93
2.8 Generación de código intermedio
representa una expresión que se forma al aplicar el operador op a las subexpresiones representadas por E1 y E2. Pueden crearse árboles sintácticos para cualquier construcción, no solo
expresiones. Cada construcción se representa mediante un nodo, con hijos para los componentes con significado semántico de la construcción. Por ejemplo, los componentes con significado
semántico de una instrucción while en C:
while ( expr ) instr
son la expresión expr y la instrucción instr.12 El nodo del árbol sintáctico para una instrucción
while de este tipo tiene un operador, al cual llamamos while, y dos hijos: los árboles sintácticos para expr y para instr.
El esquema de traducción en la figura 2.39 construye árboles sintácticos para un lenguaje
representativo, pero bastante limitado, de expresiones e instrucciones. Todos los no terminales
en el esquema de traducción tienen un atributo n, que es un nodo del árbol sintáctico. Los
nodos se implementan como objetos de la clase Nodo.
La clase Nodo tiene dos subclases intermedias: Expr para todo tipo de expresiones, e Instr
para todo tipo de instrucciones. Cada tipo de instrucción tiene una subclase correspondiente de
Instr; por ejemplo, el operador while corresponde a la subclase While. Un nodo del árbol sintáctico para el operador while, con los hijos x y y se crea mediante el siguiente seudocódigo:
new While(x,y)
el cual crea un objeto de la clase While mediante una llamada al constructor constructora
While, con el mismo nombre que la clase. Al igual que los constructores corresponden a los
operadores, los parámetros de los constructores corresponden a los operandos en la sintaxis
abstracta.
Cuando estudiemos el código detallado en el apéndice A, veremos cómo se colocan los
métodos en donde pertenecen en esta jerarquía de clases. En esta sección hablaremos sólo de
algunos métodos, de manera informal.
Vamos a considerar cada una de las producciones y reglas de la figura 2.39, una a la vez.
Primero explicaremos las producciones que definen distintos tipos de instrucciones, y después
las producciones que definen nuestros tipos limitados de expresiones.
Árboles sintácticos para las instrucciones
Para cada construcción de una instrucción, definimos un operador en la sintaxis abstracta. Para
las construcciones que empiezan con una palabra clave, vamos a usar la palabra clave para el
operador. Por ende, hay un operador while para las instrucciones while y un operador do para
12
El paréntesis derecho sólo sirve para separar la expresión de la instrucción. El paréntesis izquierdo en realidad no
tiene significado; está ahí sólo para facilitar la legibilidad, ya que sin él, C permitiría paréntesis desbalanceados.
94
Capítulo 2. Un traductor simple orientado a la sintaxis
programa
→
bloque
{ return bloque.n; }
bloque
→
{ instrs }
{ bloque.n = instrs.n; }
instrs
→
|
instrs1 instr
{ instrs.n = new Sec(instrs1.n, instr.n); }
{ instrs.n = null; }
instr
→
|
expr ;
{ instr.n = new Eval(expr.n); }
if ( expr ) instr1
{ instr.n = new If(expr.n, instr1.n); }
|
while ( expr ) instr1
{ instr.n = new While(expr.n, instr1.n); }
|
do instr1 while ( expr );
{ instr.n = new Do(instr1.n, expr.n); }
bloque
{ instr.n = bloque.n; }
|
expr
→
|
rel = expr1
rel
{ expr.n = new Asigna(=, rel.n, expr1.n); }
{ expr.n = rel.n; }
rel
→
|
|
rel1 < adic
rel1 <= adic
adic
{ rel.n = new Rel(<, rel1.n, adic.n); }
{ rel.n = new Rel(¥, rel1.n, adic.n); }
{ rel.n = adic.n; }
adic
→
|
adic1 + term
term
{ adic.n = new Op(+, adic1.n, term.n); }
{ adic.n = term.n; }
term
→
|
term1 * factor
factor
{ term.n = new Op(*, term1.n, factor.n); }
{ term.n = factor.n; }
factor
→
|
( expr )
num
{ factor.n = expr.n; }
{ factor.n = new Num(num.valor); }
Figura 2.39: Construcción de árboles sintácticos para expresiones e instrucciones
95
2.8 Generación de código intermedio
las instrucciones do-while. Las instrucciones condicionales pueden manejarse mediante la definición de dos operadores ifelse e if para las instrucciones if con y sin una parte else, respectivamente. En nuestro lenguaje de ejemplo simple, no utilizamos else, por lo cual sólo tenemos una
instrucción if. Agregar else implicaría ciertos problemas de análisis sintáctico, lo cual veremos
en la sección 4.8.2.
Cada operador de instrucción tiene una clase correspondiente con el mismo nombre, con
la primera letra en mayúscula; por ejemplo, la clase If corresponde a if. Además, definimos la
subclase Sec, que representa a una secuencia de instrucciones. Esta subclase corresponde a la no
terminal instrs de la gramática. Cada una de estas clases es subclase de Instr, que a su vez es
una subclase de Nodo.
El esquema de traducción en la figura 2.39 ilustra la construcción de los nodos de árboles
sintácticos. Una regla típica es la de las instrucciones if:
instr
→
if ( expr ) instr1
{ instr.n = new If(expr.n, instr1.n); }
Los componentes importantes de la instrucción if son expr e instr1. La acción semántica define
el nodo instr.n como un nuevo objeto de la subclase If. El código para el constructor de If no se
muestra. Crea un nuevo nodo etiquetado como if, con los nodos expr.n e instr1.n como hijos.
Las instrucciones de expresiones no empiezan con una palabra clave, por lo que definimos
un nuevo operador eval y la clase Eval, que es una subclase de Instr, para representar las
expresiones que son instrucciones. La regla relevante es:
instr
→
expr ; { instr.n = new Eval (expr.n); }
Representación de los bloques en los árboles sintácticos
La construcción de la instrucción restante en la figura 2.39 es el bloque, el cual consiste en una
secuencia de instrucciones. Considere las siguientes reglas:
instr → bloque
bloque → { instrs }
{ instr.n = bloque.n; }
{ bloque.n = instrs.n; }
La primera dice que cuando una instrucción es un bloque, tiene el mismo árbol sintáctico que el
bloque. La segunda regla dice que el árbol sintáctico para el no terminal bloque es simplemente
el árbol sintáctico para la secuencia de instrucciones en el bloque.
Por cuestión de simplicidad, el lenguaje en la figura 2.39 no incluye declaraciones. Aun
cuando las declaraciones se incluyen en el apéndice A, veremos que el árbol sintáctico para
un bloque sigue siendo el árbol sintáctico para las instrucciones en el bloque. Como la información de las declaraciones está incorporada en la tabla de símbolos, no se necesita en el árbol
sintáctico. Por lo tanto, los bloques (con o sin declaraciones) parecen ser sólo otra construcción
de instrucción en el código intermedio.
Una secuencia de instrucciones se representa mediante el uso de una hoja null para una
instrucción vacía, y un operador sec para una secuencia de instrucciones, como se muestra a
continuación:
instrs → instrs1 instr
{ instrs.n = new Sec (instrs1.n, instr.n); }
96
Capítulo 2. Un traductor simple orientado a la sintaxis
Ejemplo 2.18: En la figura 2.40, vemos parte de un árbol sintáctico que representa a un bloque, o lista de instrucciones. Hay dos instrucciones en la lista, siendo la primera una instrucción
if y la segunda una instrucción while. No mostramos la parte del árbol que está por encima
de esta lista de instrucciones, y sólo mostramos como triángulo a cada uno de los subárboles
necesarios: dos árboles de expresiones para las condiciones de las instrucciones if y while, y
dos árboles de instrucciones para sus subinstrucciones.
2
sec
sec
while
if
sec
árbol
para una
expression
null
árbol
para una
expression
árbol
para una
instrucción
árbol
para una
instrucción
Figura 2.40: Parte de un árbol sintáctico para una lista de instrucciones que consiste en una
instrucción if y una instrucción while
Árboles sintácticos para las expresiones
Anteriormente manejamos la precedencia más alta de * sobre + mediante el uso de los tres no
terminales expr, term y factor. El número de no terminales es precisamente uno más el número
de niveles de precedencia en las expresiones, como sugerimos en la sección 2.2.6. En la figura
2.39, tenemos dos operadores de comparación, < y <= en un nivel de precedencia, así como
los operadores + y * comunes, por lo que hemos agregado un no terminal adicional, llamado
adic.
La sintaxis abstracta nos permite agrupar operadores “similares” para reducir el número de
casos y subclases de nodos en una implementación de expresiones. En este capítulo, “similar”
significa que las reglas de comprobación de tipos y generación de código para los operadores
son similares. Por ejemplo, comúnmente los operadores + y * pueden agruparse, ya que pueden
manejarse de la misma forma; sus requerimientos en relación con los tipos de operandos son los
mismos, y cada uno produce una instrucción individual de tres direcciones que aplica un operador a dos valores. En general, el agrupamiento de operadores en la sintaxis abstracta se basa
en las necesidades de las fases posteriores del compilador. La tabla en la figura 2.41 especifica la
correspondencia entre la sintaxis concreta y abstracta para varios de los operadores de Java.
En la sintaxis concreta, todos los operadores son asociativos por la izquierda, excepto el
operador de asignación =, el cual es asociativo por la derecha. Los operadores en una línea
97
2.8 Generación de código intermedio
SINTAXIS CONCRETA
SINTAXIS ABSTRACTA
asigna
menos
acceso
Figura 2.41: Sintaxis concreta y abstracta para varios operadores de Java
tienen la misma precedencia; es decir, == y != tienen la misma precedencia. Las líneas están en
orden de menor a mayor precedencia; por ejemplo, == tiene mayor precedencia que los operadores && y =. El subíndice unario en −unario es sólo para distinguir un signo de menos unario a la
izquierda, como en −2, de un signo de menos binario, como en 2−a. El operador [ ] representa
el acceso a los arreglos, como en a[i].
La columna de sintaxis abstracta especifica el agrupamiento de los operadores. El operador
de asignación = está en un grupo por sí solo. El grupo cond contiene los operadores booleanos
condicionales && y ||. El grupo rel contiene los operadores de comparación relacionales en las
líneas para == y <. El grupo op contiene los operadores aritméticos como + y *. El signo menos
unario, la negación booleana y el acceso a los arreglos se encuentran en grupos por sí solos.
La asignación entre la sintaxis concreta y abstracta en la figura 2.41 puede implementarse
escribiendo un esquema de traducción. Las producciones para los no terminales expr, rel, adic,
term y factor en la figura 2.39 especifican la sintaxis concreta para un subconjunto representativo de los operadores en la figura 2.41. Las acciones semánticas en estas producciones crean
nodos de árboles sintácticos. Por ejemplo, la regla
term
→ term1 * factor { term.n = new Op(*,term1.n, factor.n); }
crea un nodo de la clase Op, que implementa a los operadores agrupados bajo op en la figura
2.41. El constructor Op tiene un parámetro * para identificar al operador actual, además de
los nodos term1.n y factor.n para las subexpresiones.
2.8.3
Comprobación estática
Las comprobaciones estáticas son comprobaciones de consistencia que se realizan durante la
compilación. No solo aseguran que un programa pueda compilarse con éxito, sino que también
tienen el potencial para atrapar los errores de programación en forma anticipada, antes de
ejecutar un programa. La comprobación estática incluye lo siguiente:
• Comprobación sintáctica. Hay más en la sintaxis que las gramáticas. Por ejemplo, las restricciones como la de que un identificador se declare cuando menos una vez en un alcance
98
Capítulo 2. Un traductor simple orientado a la sintaxis
o que una instrucción break debe ir dentro de un ciclo o de una instrucción switch, son
sintácticas, aunque no están codificadas en, o implementadas por, una gramática que se
utiliza para el análisis sintáctico.
• Comprobación de tipos. Las reglas sobre los tipos de un lenguaje aseguran que un operador o función se aplique al número y tipo de operandos correctos. Si es necesaria la
conversión entre tipos, por ejemplo, cuando se suma un entero a un número de punto flotante, entonces el comprobador de tipos puede insertar un operador en el árbol sintáctico
para representar esa conversión. A continuación hablaremos sobre la conversión de tipos,
usando el término común “coerción”.
L-value y R-value
Ahora consideraremos algunas comprobaciones estáticas simples que pueden realizarse durante
la construcción de un árbol sintáctico para un programa fuente. En general, tal vez haya que
realizar comprobaciones estáticas complejas, para lo cual primero hay que construir una representación intermedia y después analizarla.
Hay una diferencia entre el significado de los identificadores a la izquierda y el lado derecho
de una asignación. En cada una de las siguientes asignaciones:
i = 5;
i = i + 1;
el lado derecho especifica un valor entero, mientras que el lado izquierdo especifica en dónde
se va a almacenar el valor. Los términos l-value y r-value se refieren a los valores que son
apropiados en los lados izquierdo y derecho de una asignación, respectivamente. Es decir, los
r-value son lo que generalmente consideramos como “valores”, mientras que los l-value son
las ubicaciones.
La comprobación estática debe asegurar que el lado izquierdo de una asignación denote a
un l-value. Un identificador como i tiene un l-value, al igual que un acceso a un arreglo como
a[2]. Pero una constante como 2 no es apropiada en el lado izquierdo de la asignación, ya que
tiene un r-value, pero no un l-value.
Comprobación de tipos
La comprobación de tipos asegura que el tipo de una construcción coincida con lo que espera
su contexto. Por ejemplo, en la siguiente instrucción if :
if ( expr ) instr
se espera que la expresión expr tenga el tipo boolean.
Las reglas de comprobación de tipos siguen la estructura operador/operando de la sintaxis
abstracta. Suponga que el operador rel representa a los operadores relacionales como <=. La
regla de tipos para el grupo de operadores rel es que sus dos operandos deben tener el mismo
tipo, y el resultado tiene el tipo booleano. Utilizando el atributo tipo para el tipo de una expresión, dejemos que E consista de rel aplicado a E1 y E2. El tipo de E puede comprobarse al
momento de construir su nodo, mediante la ejecución de código como el siguiente:
99
2.8 Generación de código intermedio
if ( E1.tipo == E2.tipo ) E.tipo = boolean;
else error;
La idea de relacionar los tipos actuales con los esperados se sigue aplicando, aún en las
siguientes situaciones:
• Coerciones. Una coerción ocurre cuando el tipo de un operando se convierte en forma
automática al tipo esperado por el operador. En una expresión como 2 * 3.14, la transformación usual es convertir el entero 2 en un número de punto flotante equivalente, 2.0,
y después realizar una operación de punto flotante con el par resultante de operandos
de punto flotante. La definición del lenguaje especifica las coerciones disponibles. Por
ejemplo, la regla actual para rel que vimos antes podría ser que E1.tipo y E2.tipo puedan
convertirse al mismo tipo. En tal caso, sería legal comparar, por decir, un entero con un
valor de punto flotante.
• Sobrecarga. El operador + en Java representa la suma cuando se aplica a enteros; significa
concatenación cuando se aplica a cadenas. Se dice que un símbolo está sobrecargado si
tiene distintos significados, dependiendo de su contexto. Por ende, + está sobrecargado en
Java. Para determinar el significado de un operador sobrecargado, hay que considerar los
tipos conocidos de sus operandos y resultados. Por ejemplo, sabemos que el + en z = x + y
es concatenación si sabemos que cualquiera de las variables x, y o z es de tipo cadena. No
obstante, si también sabemos que alguna de éstas es de tipo entero, entonces tenemos un
error en los tipos y no hay significado para este uso de +.
2.8.4
Código de tres direcciones
Una vez que se construyen los árboles de sintaxis, se puede realizar un proceso más detallado
de análisis y síntesis mediante la evaluación de los atributos, y la ejecución de fragmentos de
código en los nodos del árbol. Para ilustrar las posibilidades, vamos a recorrer árboles sintácticos para generar código de tres direcciones. En específico, le mostraremos cómo escribir
funciones para procesar el árbol sintáctico y, como efecto colateral, emitir el código de tres
direcciones necesario.
Instrucciones de tres direcciones
El código de tres direcciones es una secuencia de instrucciones de la forma
x = y op z
en donde x, y y z son nombres, constantes o valores temporales generados por el compilador; y
op representa a un operador.
Manejaremos los arreglos usando las siguientes dos variantes de instrucciones:
x[y]=z
x=y[z]
100
Capítulo 2. Un traductor simple orientado a la sintaxis
La primera coloca el valor de z en la ubicación x[y], y la segunda coloca el valor de y[z] en la
ubicación x.
Las instrucciones de tres direcciones se ejecutan en secuencia numérica, a menos que se les
obligue a hacer lo contrario mediante un salto condicional o incondicional. Elegimos las siguientes instrucciones para el flujo de control:
ifFalse x goto L
ifTrue x goto L
goto L
si x es falsa, ejecutar a continuación la instrucción etiquetada como L
si x es verdadera, ejecutar a continuación la instrucción etiquetada
como L
ejecutar a continuación la instrucción etiquetada como L
Para unir una etiqueta L a cualquier instrucción, se le antepone el prefijo L:. Una instrucción
puede tener más de una etiqueta.
Por último, necesitamos instrucciones para copiar un valor. La siguiente instrucción de tres
direcciones copia el valor de y a x:
x=y
Traducción de instrucciones
Las instrucciones se traducen en código de tres direcciones mediante el uso de instrucciones
de salto, para implementar el flujo de control a través de la instrucción. La distribución de la
figura 2.42 ilustra la traducción de if expr then instr1. La instrucción de salto en la siguiente
distribución:
ifFalse x goto después
salta sobre la traducción de instr1 si expr se evalúa como false. Las demás construcciones de
instrucciones se traducen de manera similar, usando saltos apropiados alrededor del código
para sus componentes.
código para calcular
expr en x
ifFalse x goto después
código para instr1
después
Figura 2.42: Distribución de código para las instrucciones if
Para fines concretos, mostramos el seudocódigo para la clase If en la figura 2.43. La clase If
es una subclase de Instr, al igual que las clases para las demás construcciones de instrucciones.
Cada subclase de Instr tiene un constructor (en este caso, If ) y una función gen, a la cual llamamos para generar el código de tres direcciones para este tipo de instrucción.
2.8 Generación de código intermedio
101
class If extends Instr {
Expr E; Instr S;
public If(Expr x, Instr y) { E = x; S = y; despues = nuevaetiqueta(); }
public void gen() {
Expr n = E.r_value();
emitir( “ifFalse” + n.toString() + “ goto ” + despues);
S.gen();
emitir (despues + “:”);
}
}
Figura 2.43: La función gen en la clase If genera código de tres direcciones
El constructor de If en la figura 2.43 crea nodos de árbol sintáctico para las instrucciones
if. Se llama con dos parámetros, un nodo de expresión x y un nodo de instrucción y, los cuales
guarda como atributos E y S. El constructor también asigna al atributo despues una nueva
etiqueta única, llamando a la función nuevaetiqueta(). La etiqueta se utilizará de acuerdo con
la distribución en la figura 2.42.
Una vez que se construye el árbol sintáctico completo para un programa fuente, se hace una
llamada a la función gen en la raíz del árbol sintáctico. Como un programa es un bloque en
nuestro lenguaje simple, la raíz del árbol sintáctico representa la secuencia de instrucciones en el
bloque. Todas las clases de instrucciones contienen una función gen.
El seudocódigo para la función gen de la clase If en la figura 2.43 es representativo. Llama a
E. r_value() para traducir la expresión E (la expresión con valor booleano que forma parte de
las instrucciones if) y guarda el nodo de resultado devuelto por E. En breve hablaremos sobre
la traducción de las expresiones. Después, la función gen emite un salto condicional y llama a
S.gen() para traducir la subinstrucción S.
Traducción de expresiones
Ahora ilustraremos la traducción de las expresiones, para lo cual consideraremos expresiones
que contengan operadores binarios op, accesos a arreglos y asignaciones, además de constantes
e identificadores. Por cuestión de simplicidad, en un acceso a un arreglo y[z], requerimos que y
sea un identificador.13 Para una explicación detallada sobre la generación de código intermedio
para las expresiones, vea la sección 6.4.
Vamos a usar el enfoque sencillo de generar una instrucción de tres direcciones para cada
nodo de operador en el árbol sintáctico para una expresión. No se genera código para los identificadores y las constantes, ya que éstos pueden aparecer como direcciones en las instrucciones.
Si un nodo x de la clase Expr tiene un operador op, entonces se emite una instrucción para
calcular el valor en el nodo x y convertirlo en un nombre “temporal” generado por el compilador, por decir, t. Por ende, i−j+k se traduce en dos instrucciones:
13
Este lenguaje simple soporta a[a[n]], pero no a[m][n]. Observe que a[a[n]] tiene la forma a[E], en donde
E es a[n].
102
Capítulo 2. Un traductor simple orientado a la sintaxis
t1 = i − j
t2 = t1 + k
Con los accesos a arreglos y las asignaciones surge la necesidad de diferenciar entre los
l-values y los r-values. Por ejemplo, 2*a[i] puede traducirse convirtiendo el r-value de a[i]
en un nombre temporal, como en:
t1 = a [ i ]
t2 = 2 * t1
Pero no podemos simplemente usar un nombre temporal en lugar de a[i], si a[i] aparece en
el lado izquierdo de una asignación.
El método simple utiliza las dos funciones l-value y r-value, que aparecen en las figuras
2.44 y 2.45, respectivamente. Cuando la función r-value se aplica a un nodo x que no es hoja,
genera instrucciones para convertir a x en un nombre temporal, y devuelve un nuevo nodo
que representa a este temporal. Cuando se aplica la función l-value a un nodo que no es hoja,
también genera instrucciones para calcular los subárboles debajo de x, y devuelve un nodo que
representa la “dirección” para x.
Describiremos primero la función l-value, ya que tiene menos casos. Al aplicarse a un nodo x,
la función l-value simplemente devuelve x si es el nodo para un identificador (es decir, si x es
de la clase Id). En nuestro lenguaje simple, el único otro caso en donde una expresión tiene un
l-value ocurre cuando x representa un acceso a un arreglo, como a[i]. En este caso, x tendrá la
forma Acceso(y, z), en donde la clase Acceso es una subclase de Expr, y representa el nombre del
arreglo al que se va a acceder y z representa el desplazamiento (índice) del elemento elegido en
ese arreglo. Del seudocódigo en la figura 2.44, la función l-value llama a r-value(z) para generar
instrucciones, si es necesario, para calcular el r-value de z. Después construye y devuelve un
nuevo nodo Acceso, con hijos para el nombre del arreglo y y el r-value de z.
Expr l-value(x : Expr) {
if ( x es un nodo Id ) return x;
else if ( x es un nodo Acceso(y, z) y y es un nodo Id ) {
return new Acceso(y, r-value(z));
}
else error;
}
Figura 2.44: Seudocódigo para la función l-value
Ejemplo 2.19: Cuando el nodo x representa el acceso al arreglo a[2*k], la llamada a l-value(x)
genera una instrucción
t = 2 * k
y devuelve un nuevo nodo x que representa el l-value a[t], en donde t es un nuevo nombre
temporal.
En detalle, se llega al fragmento de código
2.8 Generación de código intermedio
103
return new Acceso(y, r-value(z));
en donde y es el nodo para a y z es el nodo para la expresión 2*k. La llamada a r-value(z)
genera código para la expresión 2*k (es decir, la instrucción de tres direcciones t = 2 * k) y
devuelve el nuevo nodo z , que representa al nombre temporal t. Ese nodo z se convierte en el
valor del segundo campo en el nuevo nodo Acceso llamado x que se crea.
2
Expr r-value(x : Expr) {
if ( x es un nodo Id o Constante ) return x;
else if ( x es un nodo Op (op, y, z) o Rel (op, y, z) ) {
t = nuevo temporal;
emitir cadena para t = r-value(y) op r-value(z);
return un nuevo nodo para t;
}
else if ( x es un nodo Acceso (y, z) ) {
t = nuevo temporal;
llamar a l-value(x), que devuelve Acceso (y, z );
emitir cadena para t = Acceso (y, z );
return un nuevo nodo para t;
}
else if ( x es un nodo Asigna (y, z) ) {
z = r-value(z);
emitir cadena para l-value(y) = z ;
return z ;
}
}
Figura 2.45: Seudocódigo para la función r-value
La función r-value en la figura 2.45 genera instrucciones y devuelve un nodo que posiblemente es nuevo. Cuando x representa a un identificador o a una constante, r-value devuelve la
misma x. En todos los demás casos, devuelve un nodo Id para un nuevo nombre temporal t.
Los casos son los siguientes:
• Cuando x representa a y op z, el código primero calcula y = r-value(y) y z = r-value(z).
Crea un nuevo nombre temporal t y genera una instrucción t = y op z (dicho en forma
más precisa, una instrucción que se forma a partir de las representaciones de cadena de
t, y , op y z ). Devuelve un nodo para el identificador t.
• Cuando x representa un acceso a un arreglo y[z], podemos reutilizar la función l-value. La
llamada a l-value(x) devuelve un acceso y[z ], en donde z representa a un identificador
que contiene el desplazamiento para el acceso al arreglo. El código crea un nuevo nombre
temporal t, genera una instrucción basada en t = y[z ] y devuelve un nodo para t.
104
Capítulo 2. Un traductor simple orientado a la sintaxis
• Cuando x representa a y = z, entonces el código primero calcula z = r-value(z). Genera
una instrucción con base en l-value(y) = z y devuelve el nodo z .
Ejemplo 2.20: Cuando se aplica al árbol sintáctico para
a[i] = 2*a[j−k]
la función r-value genera
t3 = j − k
t2 = a [ t3 ]
t1 = 2 * t2
a [ i ] = t1
Es decir, la raíz es un nodo Asigna con el primer argumento a[i] y el segundo argumento
2*a[j−k]. Por ende, se aplica el tercer caso y la función r-value evalúa en forma recursiva
a 2*a[j−k]. La raíz de este subárbol es el nodo Op para *, que produce la creación de un nuevo
nombre temporal t1, antes de que se evalúe el operando izquierdo 2, y después el operando
derecho. La constante 2 no genera un código de tres direcciones, y su r-value se devuelve como nodo Constante con el valor 2.
El operando derecho a[j−k] es un nodo Acceso, el cual provoca la creación de un nuevo
nombre temporal t2, antes de que se haga una llamada a la función l-value en este nodo.
Después se hace una llamada recursiva a r-value sobre la expresión j−k. Como efecto colateral
de esta llamada se genera la instrucción de tres direcciones t3 = j − k, una vez que se crea
el nuevo nombre temporal t3. Después, al regresar a la llamada de l-value sobre a[j−k], al
nombre temporal t2 se le asigna el r-value de la expresión de acceso completa, es decir, t2 =
a [ t3 ].
Ahora regresamos a la llamada de r-value sobre el nodo Op 2*a[j−k], que anteriormente
creó el nombre temporal t1. Una instrucción de tres direcciones t1 = 2 * t2 se genera como un
efecto colateral, para evaluar esta expresión de multiplicación. Por último, la llamada a r-value
en toda la expresión se completa llamando a l-value sobre el lado izquierdo a[i] y después
generando una instrucción de tres direcciones a [ i ] = t1, en donde el lado derecho de la
asignación se asigna al lado izquierdo. 2
Mejor código para las expresiones
Podemos mejorar nuestra función r-value en la figura 2.45 y generar menos instrucciones de
tres direcciones, de varias formas:
• Reducir el número de instrucciones de copia en una fase de optimización subsiguiente.
Por ejemplo, el par de instrucciones t = i+1 y i = t pueden combinarse en i = i+1, si
no hay usos subsiguientes de t.
• Generar menos instrucciones en primer lugar, tomando en cuenta el contexto. Por ejemplo, si el lado izquierdo de una asignación de tres direcciones es un acceso a un arreglo
a[t], entonces el lado derecho debe ser un nombre, una constante o un nombre temporal,
cada uno de los cuales sólo utiliza una dirección. Pero si el lado izquierdo es un nombre x,
entonces el lado derecho puede ser una operación y op z que utilice dos direcciones.
105
2.9 Resumen del capítulo 2
Podemos evitar ciertas instrucciones de copia modificando las funciones de traducción para
generar una instrucción parcial que calcule, por ejemplo j+k, pero que no se comprometa a
indicar en dónde se va a colocar el resultado, lo cual se indica mediante una dirección null
para el resultado:
null = j + k
(2.8)
La dirección de resultado nula se sustituye después por un identificador o un nombre temporal,
según sea apropiado. Se sustituye por un identificador si j+k está en el lado derecho de una
asignación, como en i=j+k;, en cuyo caso la expresión (2.8) se convierte en
i = j + k
Pero, si j+k es una subexpresión, como en j+k+1, entonces la dirección de resultado nula en
(2.8) se sustituye por un nuevo nombre temporal t, y se genera una nueva instrucción parcial:
t = j + k
null = t + l
Muchos compiladores realizan todo el esfuerzo posible por generar código que sea tan bueno
o mejor que el código ensamblador escrito a mano que producen los expertos. Si se utilizan las
técnicas de optimización de código como las del capítulo 9, entonces una estrategia efectiva
podría ser utilizar un método simple para la generación de código intermedio, y depender del
optimizador de código para eliminar las instrucciones innecesarias.
2.8.5
Ejercicios para la sección 2.8
Ejercicio 2.8.1: Las instrucciones for en C y Java tienen la forma:
for ( expr1 ; expr2 ; expr3 ) instr
La primera expresión se ejecuta antes del ciclo; por lo general se utiliza para inicializar el índice
del ciclo. La segunda expresión es una prueba que se realiza antes de cada iteración del ciclo; se
sale del ciclo si la expresión se convierte en 0. El ciclo en sí puede considerarse como la instrucción { instr expr3; }. La tercera expresión se ejecuta al final de cada iteración; por lo general se
utiliza para incrementar el índice del ciclo. El significado de la instrucción for es similar a:
expr1 ; while ( expr2 ) {instr expr3; }
Defina una clase For para las instrucciones for, de manera similar a la clase If en la figura
2.43.
Ejercicio 2.8.2: El lenguaje de programación C no tiene un tipo booleano. Muestre cómo un
compilador de C podría traducir una instrucción if en código de tres direcciones.
2.9
Resumen del capítulo 2
Las técnicas orientadas a la sintaxis que vimos en este capítulo pueden usarse para construir
interfaces de usuario (front-end) de compiladores, como las que se muestran en la figura 2.46.
106
Capítulo 2. Un traductor simple orientado a la sintaxis
if ( vistazo == '\n' ) linea = linea + 1;
Analizador léxico
hifi h(i hid, "vistazo"i heqi hconst, '\n'i h)i
hid, "linea"i hasignai hid, "linea"i h+i hnum, 1i h;i
Traductor orientado a la sintaxis
o
asigna
vistazo
ifFalse vistazo == t1 goto 4
linea = linea + 1
linea
linea
Figura 2.46: Dos posibles traducciones de una instrucción
♦ El punto inicial para un traductor orientado a la sintaxis es una gramática para el
lenguaje fuente. Una gramática describe la estructura jerárquica de los programas. Se
define en términos de símbolos elementales, conocidos como terminales, y de símbolos
variables llamados no terminales. Estos símbolos representan construcciones del lenguaje.
Las reglas o producciones de una gramática consisten en un no terminal conocido como
el encabezado o lado izquierdo de una producción, y de una secuencia de terminales y no
terminales, conocida como el cuerpo o lado derecho de la producción. Un no terminal se
designa como el símbolo inicial.
♦ Al especificar un traductor, es conveniente adjuntar atributos a la construcción de programación, en donde un atributo es cualquier cantidad asociada con una construcción.
Como las construcciones se representan mediante símbolos de la gramática, el concepto
de los atributos se extiende a los símbolos de la gramática. Algunos ejemplos de atributos
incluyen un valor entero asociado con un terminal num que representa números, y una
cadena asociada con un terminal id que representa identificadores.
♦ Un analizador léxico lee la entrada un carácter a la vez, y produce como salida un flujo
de tokens, en donde un token consiste en un símbolo terminal, junto con información adicional en la forma de valores de atributos. En la figura 2.46, los tokens se escriben como
n-uplas encerradas entre . El token id, "vistazo" consiste en la terminal id y en
un apuntador a la entrada en la tabla de símbolos que contiene la cadena "vistazo". El
2.9 Resumen del capítulo 2
107
traductor utiliza la tabla para llevar la cuenta de las palabras reservadas e identificadores
que ya se han analizado.
♦ El análisis sintáctico es el problema de averiguar cómo puede derivarse una cadena de
terminales del símbolo de inicio de la gramática, sustituyendo en forma repetida un no
terminal por el cuerpo de una de sus producciones. En general, un analizador sintáctico
construye un árbol de análisis sintáctico, en el cual la raíz se etiqueta con el símbolo inicial, cada nodo que no es hoja corresponde a una producción y cada hoja se etiqueta con
un terminal o con la cadena vacía, . El árbol de análisis sintáctico deriva la cadena de
terminales en las hojas, y se lee de izquierda a derecha.
♦ Los analizadores sintácticos eficientes pueden crearse a mano, mediante un método tipo
descendente (de la raíz hasta las hojas de un árbol de análisis sintáctico) llamado análisis
sintáctico predictivo. Un analizador sintáctico predictivo tiene un procedimiento para
cada no terminal; los cuerpos de los procedimientos imitan las producciones para los no
terminales; y, el flujo de control a través de los cuerpos de los procedimientos puede determinarse sin ambigüedades, analizando un símbolo de preanálisis en el flujo de entrada.
En el capítulo 4 podrá ver otros métodos para el análisis sintáctico.
♦ La traducción orientada a la sintaxis se realiza adjuntando reglas o fragmentos de programa a las producciones en una gramática. En este capítulo hemos considerado sólo
los atributos sintetizados: el valor de un atributo sintetizado en cualquier nodo x puede
depender sólo de los atributos en los hijos de x, si los hay. Una definición orientada a la
sintaxis adjunta reglas a las producciones; las reglas calculan los valores de los atributos.
Un esquema de traducción incrusta fragmentos de programa llamados acciones semánticas en los cuerpos de las producciones. Las acciones se ejecutan en el orden en el que se
utilizan las producciones durante el análisis sintáctico.
♦ El resultado del análisis sintáctico es una representación del programa fuente, conocido
como código intermedio. En la figura 2.46 se ilustran dos formas principales de código intermedio. Un árbol sintáctico abstracto tiene nodos para las construcciones de programación; los hijos de un nodo proporcionan las subconstrucciones significativas. De manera
alternativa, el código de tres direcciones es una secuencia de instrucciones, en la cual cada
instrucción lleva a cabo una sola operación.
♦ Las tablas de símbolos son estructuras de datos que contienen información acerca de los
identificadores. La información se coloca en la tabla de símbolos cuando se analiza la
declaración de un identificador. Una acción semántica obtiene información de la tabla de
símbolos cuando el identificador se vuelve a utilizar, por ejemplo, como factor en una
expresión.
Capítulo 3
Análisis léxico
En este capítulo le mostraremos cómo construir un analizador léxico. Para implementar un
analizador léxico a mano, es útil empezar con un diagrama o cualquier otra descripción de los
lexemas de cada token. De esta manera, podemos escribir código para identificar cada ocurrencia de cada lexema en la entrada, y devolver información acerca del token identificado.
Podemos producir también un analizador léxico en forma automática, especificando los
patrones de los lexemas a un generador de analizadores léxicos, y compilando esos patrones en
código que funcione como un analizador léxico. Este método facilita la modificación de un analizador léxico, ya que sólo tenemos que reescribir los patrones afectados, y no todo el programa.
Agiliza también el proceso de implementar el analizador, ya que el programador especifica el
software en el nivel más alto de los patrones y se basa en el generador para producir el código
detallado. En la sección 3.5 presentaremos un generador de analizadores léxicos llamado Lex
(o Flex, en una presentación más reciente).
Empezaremos el estudio de los generadores de analizadores léxicos mediante la presentación
de las expresiones regulares, una notación conveniente para especificar los patrones de lexemas.
Mostraremos cómo esta notación puede transformarse, primero en autómatas no deterministas
y después en autómatas determinista. Estas últimas dos notaciones pueden usarse como entrada para un “controlador”, es decir, código que simula a estos autómatas y los utiliza como
guía para determinar el siguiente token. Este control y la especificación del autómata forman
el núcleo del analizador léxico.
3.1
La función del analizador léxico
Como la primera fase de un compilador, la principal tarea del analizador léxico es leer los caracteres de la entrada del programa fuente, agruparlos en lexemas y producir como salida una
secuencia de tokens para cada lexema en el programa fuente. El flujo de tokens se envía al analizador sintáctico para su análisis. Con frecuencia el analizador léxico interactúa también con
la tabla de símbolos. Cuando el analizador léxico descubre un lexema que constituye a un iden109
Capítulo 3. Análisis léxico
110
tificador, debe introducir ese lexema en la tabla de símbolos. En algunos casos, el analizador
léxico puede leer la información relacionada con el tipo de información de la tabla de símbolos,
como ayuda para determinar el token apropiado que debe pasar al analizador sintáctico.
En la figura 3.1 se sugieren estas interacciones. Por lo regular, la interacción se implementa
haciendo que el analizador sintáctico llame al analizador léxico. La llamada, sugerida por el
comando obtenerSiguienteToken, hace que el analizador léxico lea los caracteres de su entrada
hasta que pueda identificar el siguiente lexema y producirlo para el siguiente token, el cual
devuelve al analizador sintáctico.
token
programa
fuente
Analizador
sintáctico
Analizador
léxico
al análisis
semántico
obtenerSiguienteToken
Tabla de
símbolos
Figura 3.1: Interacciones entre el analizador léxico y el analizador sintáctico
Como el analizador léxico es la parte del compilador que lee el texto de origen, debe realizar
otras tareas aparte de identificar lexemas. Una de esas tareas es eliminar los comentarios y el
espacio en blanco (caracteres de espacio, nueva línea, tabulador y tal vez otros caracteres que
se utilicen para separar tokens en la entrada). Otra de las tareas es correlacionar los mensajes
de error generados por el compilador con el programa fuente. Por ejemplo, el analizador léxico
puede llevar el registro del número de caracteres de nueva línea vistos, para poder asociar un
número de línea con cada mensaje de error. En algunos compiladores, el analizador léxico crea
una copia del programa fuente con los mensajes de error insertados en las posiciones apropiadas. Si el programa fuente utiliza un preprocesador de macros, la expansión de las macros
también pueden formar parte de las tareas del analizador léxico.
Algunas veces, los analizadores léxicos se dividen en una cascada de dos procesos:
a) El escaneo consiste en los procesos simples que no requieren la determinación de tokens
de la entrada, como la eliminación de comentarios y la compactación de los caracteres de
espacio en blanco consecutivos en uno solo.
b) El propio análisis léxico es la porción más compleja, en donde el escanear produce la
secuencia de tokens como salida.
3.1.1
Comparación entre análisis léxico y análisis sintáctico
Existen varias razones por las cuales la parte correspondiente al análisis de un compilador se
separa en fases de análisis léxico y análisis sintáctico (parsing).
3.1 La función del analizador léxico
111
1. La sencillez en el diseño es la consideración más importante. La separación del análisis
léxico y el análisis sintáctico a menudo nos permite simplificar por lo menos una de estas
tareas. Por ejemplo, un analizador sintáctico que tuviera que manejar los comentarios y
el espacio en blanco como unidades sintácticas sería mucho más complejo que uno que
asumiera que el analizador léxico ya ha eliminado los comentarios y el espacio en blanco.
Si vamos a diseñar un nuevo lenguaje, la separación de las cuestiones léxicas y sintácticas
puede llevar a un diseño más limpio del lenguaje en general.
2. Se mejora la eficiencia del compilador. Un analizador léxico separado nos permite aplicar
técnicas especializadas que sirven sólo para la tarea léxica, no para el trabajo del análisis
sintáctico. Además, las técnicas de búfer especializadas para leer caracteres de entrada
pueden agilizar la velocidad del compilador en forma considerable.
3. Se mejora la portabilidad del compilador. Las peculiaridades específicas de los dispositivos de entrada pueden restringirse al analizador léxico.
3.1.2
Tokens, patrones y lexemas
Al hablar sobre el análisis léxico, utilizamos tres términos distintos, pero relacionados:
• Un token es un par que consiste en un nombre de token y un valor de atributo opcional.
El nombre del token es un símbolo abstracto que representa un tipo de unidad léxica; por
ejemplo, una palabra clave específica o una secuencia de caracteres de entrada que denotan un identificador. Los nombres de los tokens son los símbolos de entrada que procesa
el analizador sintáctico. A partir de este momento, en general escribiremos el nombre de
un token en negrita. Con frecuencia nos referiremos a un token por su nombre.
• Un patrón es una descripción de la forma que pueden tomar los lexemas de un token. En
el caso de una palabra clave como token, el patrón es sólo la secuencia de caracteres que
forman la palabra clave. Para los identificadores y algunos otros tokens, el patrón es una
estructura más compleja que se relaciona mediante muchas cadenas.
• Un lexema es una secuencia de caracteres en el programa fuente, que coinciden con el
patrón para un token y que el analizador léxico identifica como una instancia de ese
token.
Ejemplo 3.1: La figura 3.2 proporciona algunos tokens comunes, sus patrones descritos de
manera informal y algunos lexemas de ejemplo. La siguiente instrucción en C nos servirá para
ver cómo se utilizan estos conceptos en la práctica:
printf("Total = %d\n", puntuacion);
tanto printf como puntuacion son lexemas que coinciden con el patrón para el token id, y
"Total = %d\n" es un lexema que coincide con literal.
2
En muchos lenguajes de programación, las siguientes clases cubren la mayoría, si no es que
todos, los tokens:
Capítulo 3. Análisis léxico
112
TOKEN
DESCRIPCIÓN INFORMAL
if
else
comparacion
id
numero
literal
caracteres i, f
caracteres e, l, s, e
< o > o <= o >= o == o !=
letra seguida por letras y dígitos
cualquier constante numérica
cualquier cosa excepto ", rodeada por "’s
LEXEMAS DE EJEMPLO
if
Else
<=, !=
pi, puntuacion, D2
3.14159, 0, 6.02e23
"core dumped"
Figura 3.2: Ejemplos de tokens
1. Un token para cada palabra clave. El patrón para una palabra clave es el mismo que para
la palabra clave en sí.
2. Los tokens para los operadores, ya sea en forma individual o en clases como el token
comparacion, mencionado en la figura 3.2.
3. Un token que representa a todos los identificadores.
4. Uno o más tokens que representan a las constantes, como los números y las cadenas de
literales.
5. Tokens para cada signo de puntuación, como los paréntesis izquierdo y derecho, la coma
y el signo de punto y coma.
3.1.3
Atributos para los tokens
Cuando más de un lexema puede coincidir con un patrón, el analizador léxico debe proporcionar a las subsiguientes fases del compilador información adicional sobre el lexema específico
que coincidió. Por ejemplo, el patrón para el token numero coincide con 0 y con 1, pero es en
extremo importante para el generador de código saber qué lexema se encontró en el programa
fuente. Por ende, en muchos casos el analizador léxico devuelve al analizador sintáctico no sólo
el nombre de un token, sino un valor de atributo que describe al lexema que representa ese
token; el nombre del token influye en las decisiones del análisis sintáctico, mientras que el valor
del atributo influye en la traducción de los tokens después del análisis sintáctico.
Vamos a suponer que los tokens tienen cuando menos un atributo asociado, aunque este
atributo tal vez tenga una estructura que combine varias piezas de información. El ejemplo
más importante es el token id, en donde debemos asociar con el token una gran cantidad de
información. Por lo general, la información sobre un identificador (por ejemplo, su lexema, su
tipo y la ubicación en la que se encontró por primera vez, en caso de que haya que emitir un
mensaje de error sobre ese identificador) se mantiene en la tabla de símbolos. Por lo tanto, el
valor de atributo apropiado para un identificador es un apuntador a la entrada en la tabla de
símbolos para ese identificador.
113
3.1 La función del analizador léxico
Problemas difíciles durante el reconocimiento de tokens
Por lo general, dado el patrón que describe a los lexemas de un token, es muy sencillo reconocer los lexemas que coinciden cuando ocurren en la entrada. No obstante, en algunos
lenguajes no es tan evidente cuando hemos visto una instancia de un lexema que corresponde a un token. El siguiente ejemplo se tomó de Fortran, en el formato fijo todavía
permitido en Fortran 90. En la siguiente instrucción:
DO 5 I = 1.25
no es evidente que el primer lexema es DO5I, una instancia del token identificador, hasta
que vemos el punto que va después del 1. Observe que los espacios en blanco en el lenguaje Fortran de formato fijo se ignoran (una convención arcaica). Si hubiéramos visto
una coma en vez del punto, tendríamos una instrucción do:
DO 5 I = 1,25
en donde el primer lexema es la palabra clave DO.
Ejemplo 3.2: Los nombres de los tokens y los valores de atributo asociados para la siguiente
instrucción en Fortran:
E = M * C ** 2
se escriben a continuación como una secuencia de pares.
<id, apuntador a la entrada en la tabla de símbolos para E>
<asigna–op>
<id, apuntador a la entrada en la tabla de símbolos para M>
<mult–op>
<id, apuntador a la entrada en la tabla de símbolos para C>
<exp–op>
<numero, valor entero 2>
Observe que en ciertos pares en especial en los operadores, signos de puntuación y palabras
clave, no hay necesidad de un valor de atributo. En este ejemplo, el token numero ha recibido un atributo con valor de entero. En la práctica, un compilador ordinario almacenaría en
su lugar una cadena de caracteres que represente a la constante, y que utilice como valor de
atributo para numero un apuntador a esa cadena. 2
3.1.4
Errores léxicos
Sin la ayuda de los demás componentes es difícil para un analizador léxico saber que hay un
error en el código fuente. Por ejemplo, si la cadena fi se encuentra por primera vez en un programa en C en el siguiente contexto:
Capítulo 3. Análisis léxico
114
fi ( a == f(x)) ...
un analizador léxico no puede saber si fi es una palabra clave if mal escrita, o un identificador de una función no declarada. Como fi es un lexema válido para el token id, el analizador
léxico debe regresar el token id al analizador sintáctico y dejar que alguna otra fase del compilador (quizá el analizador sintáctico en este caso) mande un error debido a la transposición
de las letras.
Sin embargo, suponga que surge una situación en la cual el analizador léxico no puede proceder, ya que ninguno de los patrones para los tokens coincide con algún prefijo del resto de
la entrada. La estrategia de recuperación más simple es la recuperación en “modo de pánico”.
Eliminamos caracteres sucesivos del resto de la entrada, hasta que el analizador léxico pueda
encontrar un token bien formado al principio de lo que haya quedado de entrada. Esta técnica
de recuperación puede confundir al analizador sintáctico, pero en un entorno de computación
interactivo, puede ser bastante adecuado.
Otras de las posibles acciones de recuperación de errores son:
1. Eliminar un carácter del resto de la entrada.
2. Insertar un carácter faltante en el resto de la entrada.
3. Sustituir un carácter por otro.
4. Transponer dos caracteres adyacentes.
Las transformaciones como éstas pueden probarse en un intento por reparar la entrada. La
estrategia más sencilla es ver si un prefijo del resto de la entrada puede transformarse en un
lexema válido mediante una transformación simple. Esta estrategia tiene sentido, ya que en la
práctica la mayoría de los errores léxicos involucran a un solo carácter. Una estrategia de corrección más general es encontrar el menor número de transformaciones necesarias para convertir
el programa fuente en uno que consista sólo de lexemas válidos, pero este método se considera
demasiado costoso en la práctica como para que valga la pena realizarlo.
3.1.5
Ejercicios para la sección 3.1
Ejercicio 3.1.1: Divida el siguiente programa en C++:
float cuadradoLimitado(x) float x {
/* devuelve x al cuadrado, pero nunca más de 100 */
return (x<=−10.0||x>=10.0)?100:x*x;
}
en lexemas apropiados, usando la explicación de la sección 3.1.2 como guía. ¿Qué lexemas deberían obtener valores léxicos asociados? ¿Cuáles deberían ser esos valores?
! Ejercicio 3.1.2: Los lenguajes Indecidibles como HTML o XML son distintos de los de programación convencionales, en que la puntuación (etiquetas) es muy numerosa (como en HTML)
o es un conjunto definible por el usuario (como en XML). Además, a menudo las etiquetas
pueden tener parámetros. Sugiera cómo dividir el siguiente documento de HTML:
115
3.2 Uso de búfer en la entrada
He aquı́ una foto de <B>mi casa</B>:
<P><IMG SRC = "casa.gif"><BR>
Vea <A HREF = "masImgs.html">Más Imágenes</A> si
le gustó ésa.<P>
en los lexemas apropiados. ¿Qué lexemas deberían obtener valores léxicos asociados, y cuáles
deberían ser esos valores?
3.2
Uso de búfer en la entrada
Antes de hablar sobre el problema de reconocer lexemas en la entrada, vamos a examinar algunas formas en las que puede agilizarse la simple pero importante tarea de leer el programa
fuente. Esta tarea se dificulta debido a que a menudo tenemos que buscar uno o más caracteres
más allá del siguiente lexema para poder estar seguros de que tenemos el lexema correcto. El
recuadro titulado “Problemas difíciles durante el reconocimiento de tokens” en la sección 3.1
nos dio un ejemplo extremo, pero hay muchas situaciones en las que debemos analizar por
lo menos un carácter más por adelantado. Por ejemplo, no podemos estar seguros de haber
visto el final de un identificador sino hasta ver un carácter que no es letra ni dígito, y que, por
lo tanto, no forma parte del lexema para id. En C, los operadores de un solo carácter como
−, = o < podrían ser también el principio de un operador de dos caracteres, como −>, == o <=.
Por ende, vamos a presentar un esquema de dos búferes que se encarga de las lecturas por
adelantado extensas sin problemas. Después consideraremos una mejora en la que se utilizan
“centinelas” para ahorrar tiempo al verificar el final de los búferes.
3.2.1
Pares de búferes
Debido al tiempo requerido para procesar caracteres y al extenso número de caracteres que
se deben procesar durante la compilación de un programa fuente extenso, se han desarrollado
técnicas especializadas de uso de búferes para reducir la cantidad de sobrecarga requerida en
el procesamiento de un solo carácter de entrada. Un esquema importante implica el uso de dos
búferes que se recargan en forma alterna, como se sugiere en la figura 3.3.
E
=
M
*
C
*
*
2 eof
avance
inicioLexema
Figura 3.3: Uso de un par de búferes de entrada
Cada búfer es del mismo tamaño N, y por lo general N es del tamaño de un bloque de disco
(es decir, 4 096 bytes). Mediante el uso de un comando de lectura del sistema podemos leer N
caracteres y colocarlos en un búfer, en vez de utilizar una llamada al sistema por cada carácter. Si quedan menos de N caracteres en el archivo de entrada, entonces un carácter especial,
Capítulo 3. Análisis léxico
116
representado por eof, marca el final del archivo fuente y es distinto a cualquiera de los posibles
caracteres del programa fuente.
Se mantienen dos apuntadores a la entrada:
1. El apuntador inicioLexema marca el inicio del lexema actual, cuya extensión estamos
tratando de determinar.
2. El apuntador avance explora por adelantado hasta encontrar una coincidencia en el
patrón; durante el resto del capítulo cubriremos la estrategia exacta mediante la cual se
realiza esta determinación.
Una vez que se determina el siguiente lexema, avance se coloca en el carácter que se encuentra
en su extremo derecho. Después, una vez que el lexema se registra como un valor de atributo
de un token devuelto al analizador sintáctico, inicioLexema se coloca en el carácter que va
justo después del lexema que acabamos de encontrar. En la figura 3.3 vemos que avance ha
pasado del final del siguiente lexema, ** (el operador de exponenciación en Fortran), y debe
retractarse una posición a su izquierda.
Para desplazar a avance hacia delante primero tenemos que probar si hemos llegado al final
de uno de los búferes, y de ser así, debemos recargar el otro búfer de la entrada, y mover avan−
ce al principio del búfer recién cargado. Siempre y cuando no tengamos que alejarnos tanto del
lexema como para que la suma de su longitud más la distancia que nos alejamos sea mayor que N,
nunca habrá peligro de sobrescribir el lexema en su búfer antes de poder determinarlo.
3.2.2
Centinelas
Si utilizamos el esquema de la sección 3.2.1 en la forma descrita, debemos verificar, cada vez
que movemos el apuntador avance, que no nos hayamos salido de uno de los búferes; si esto
pasa, entonces también debemos recargar el otro búfer. Así, por cada lectura de caracteres
hacemos dos pruebas: una para el final del búfer y la otra para determinar qué carácter se lee
(esta última puede ser una bifurcación de varias vías). Podemos combinar la prueba del final
del búfer con la prueba del carecer actual si extendemos cada búfer para que contenga un valor
centinela al final. El centinela es un carácter especial que no puede formar parte del programa
fuente, para lo cual una opción natural es el carácter eof.
La figura 3.4 muestra el mismo arreglo que la figura 3.3, pero con los centinelas agregados.
Observe que eof retiene su uso como marcador del final de toda la entrada. Cualquier eof
que aparezca en otra ubicación distinta al final de un búfer significa que llegamos al final de la
entrada. La figura 3.5 sintetiza el algoritmo para mover avance hacia delante. Observe cómo
la primera prueba, que puede formar parte de una bifurcación de varias vías con base en el
carácter al que apunta avance, es la única prueba que hacemos, excepto en el caso en el que
en realidad nos encontramos al final de un búfer o de la entrada.
3.3
Especificación de los tokens
Las expresiones regulares son una notación importante para especificar patrones de lexemas.
Aunque no pueden expresar todos los patrones posibles, son muy efectivas para especificar
los tipos de patrones que en realidad necesitamos para los tokens. En esta sección estudiaremos
117
3.3 Especificación de los tokens
¿Se nos puede acabar el espacio de los búferes?
En la mayoría de los lenguajes modernos, los lexemas son cortos y basta con uno o dos
caracteres de lectura adelantada. Por ende, un tamaño de búfer N alrededor de los miles
es más que suficiente, y el esquema de doble búfer de la sección 3.2.1 funciona sin problemas. No obstante, existen ciertos riesgos. Por ejemplo, si las cadenas de caracteres
pueden ser muy extensas, que pueden extenderse en varias líneas, entonces podríamos
enfrentarnos a la posibilidad de que un lexema sea más grande que N. Para evitar problemas con las cadenas de caracteres extensas, podemos tratarlas como una concatenación
de componentes, uno de cada línea sobre la cual se escribe la cadena. Por ejemplo, en
Java es convencional representar las cadenas extensas escribiendo una parte en cada línea
y concatenando las partes con un operador + al final de cada parte.
Un problema más difícil ocurre cuando pueda ser necesario un proceso de lectura
adelantada arbitrariamente extenso. Por ejemplo, algunos lenguajes como PL/I no tratan
a las palabras clave como reservadas; es decir, podemos usar identificadores con el mismo nombre que una palabra clave como DECLARE. Si presentamos al analizador léxico el
texto de un programa en PL/I que empiece como DECLARE ( ARG1, ARG2,... no puede
estar seguro si DECLARE es una palabra clave y ARG1, etcétera son variables que se están
declarando, o si DECLARE es el nombre de un procedimiento con sus argumentos. Por esta
razón, los lenguajes modernos tienden a reservar sus palabras clave. Pero si no lo hacen,
podemos tratar a una palabra clave como DECLARE como un identificador ambiguo, y dejar que el analizador sintáctico resuelva esta cuestión, tal vez en conjunto con la búsqueda
en la tabla de símbolos.
la notación formal para las expresiones regulares, y en la sección 3.5 veremos cómo se utilizan
estas expresiones en un generador de analizadores léxicos. Después, en la sección 3.7 veremos
cómo construir el analizador léxico, convirtiendo las expresiones regulares en un autómata que
realice el reconocimiento de los tokens especificados.
3.3.1
Cadenas y lenguajes
Un alfabeto es un conjunto finito de símbolos. Algunos ejemplos típicos de símbolos son las
letras, los dígitos y los signos de puntuación. El conjunto {0, 1} es el alfabeto binario. ASCII
es un ejemplo importante de un alfabeto; se utiliza en muchos sistemas de software. Unicode,
E
=
M
* eof C
*
*
2 eof
avance
inicioLexema
Figura 3.4: Centinelas al final de cada búfer
eof
118
Capítulo 3. Análisis léxico
switch ( *avance++) {
case eof :
if (avance está al final del primer búfer ) {
recargar el segundo búfer;
avance = inicio del segundo búfer;
}
else if (avance está al final del segundo búfer ) {
recargar el primer búfer;
avance = inicio del primer búfer;
}
else /* eof dentro de un búfer marca el final de la entrada */
terminar el análisis léxico;
break;
Casos para los demás caracteres
}
Figura 3.5: Código de lectura por adelantado con centinelas
Implementación de bifurcaciones de varias vías
Podríamos imaginar que la instrucción switch en la figura 3.5 requiere muchos pasos para
ejecutarse, y que colocar el caso eof primero no es una elección inteligente. En realidad,
no importa en qué orden presentemos los casos para cada carácter. En la práctica, una
bifurcación de varias vías, dependiendo del carácter de entrada, se realiza en un paso,
saltando a una dirección encontrada en un arreglo de direcciones, indexado mediante
caracteres.
que incluye aproximadamente 10 000 caracteres de los alfabetos alrededor del mundo, es otro
ejemplo importante de un alfabeto.
Una cadena sobre un alfabeto es una secuencia finita de símbolos que se extraen de ese
alfabeto. En la teoría del lenguaje, los términos “oración” y “palabra” a menudo se utilizan
como sinónimos de “cadena”. La longitud de una cadena s, que por lo general se escribe como
|s|, es el número de ocurrencias de símbolos en s. Por ejemplo, banana es una cadena con una
longitud de seis. La cadena vacía, representada por , es la cadena de longitud cero.
Un lenguaje es cualquier conjunto contable de cadenas sobre algún alfabeto fijo. Esta
definición es demasiado amplia. Los lenguajes abstractos como ∅, el conjunto vacío, o {}, el
conjunto que contiene sólo la cadena vacía, son lenguajes bajo esta definición. También lo son
el conjunto de todos los programas en C que están bien formados en sentido sintáctico, y el
conjunto de todas las oraciones en inglés gramáticalmente correctas, aunque estos últimos dos
lenguajes son difíciles de especificar con exactitud. Observe que la definición de “lenguaje” no
requiere que se atribuya algún significado a las cadenas en el lenguaje. En el capítulo 5 veremos
los métodos para definir el “significado” de las cadenas.
3.3 Especificación de los tokens
119
Términos para partes de cadenas
Los siguientes términos relacionados con cadenas son de uso común:
1. Un prefijo de la cadena s es cualquier cadena que se obtiene al eliminar cero o más
símbolos del final de s. Por ejemplo, ban, banana y son prefijos de banana.
2. Un sufijo de la cadena s es cualquier cadena que se obtiene al eliminar cero o más
símbolos del principio de s. Por ejemplo, nana, banana y son sufijos de banana.
3. Una subcadena de s se obtiene al eliminar cualquier prefijo y cualquier sufijo de s.
Por ejemplo, banana, nan y son subcadenas de banana.
4. Los prefijos, sufijos y subcadenas propios de una cadena s son esos prefijos, sufijos
y subcadenas, respectivamente, de s que no son ni son iguales a la misma s.
5. Una subsecuencia de s es cualquier cadena que se forma mediante la eliminación de
cero o más posiciones no necesariamente consecutivas de s. Por ejemplo, baan es
una subsecuencia de banana.
Si x y y son cadenas, entonces la concatenación de x y y, denotada por xy, es la cadena
que se forma al unir y con x. Por ejemplo, si x = super y = mercado, entonces xy = super−
mercado. La cadena vacía es la identidad en la concatenación; es decir, para cualquier cadena
s, s = s = s.
Si pensamos en la concatenación como un producto, podemos definir la “exponenciación”
de cadenas de la siguiente forma. Defina a s 0 para que sea , y para todas las i > 0, defina a
si para que sea si−1s. Como s = s, resulta que s1 = s. Entonces s2 = ss, s3 = sss, y así sucesivamente.
3.3.2
Operaciones en los lenguajes
En el análisis léxico, las operaciones más importantes en los lenguajes son la unión, la concatenación y la cerradura, las cuales se definen de manera formal en la figura 3.6. La unión es
la operación familiar que se hace con los conjuntos. La concatenación de lenguajes es cuando
se concatenan todas las cadenas que se forman al tomar una cadena del primer lenguaje y
una cadena del segundo lenguaje, en todas las formas posibles. La cerradura (Kleene) de un
lenguaje L, que se denota como L*, es el conjunto de cadenas que se obtienen al concatenar
L cero o más veces. Observe que L0, la “concatenación de L cero veces”, se define como {},
y por inducción, Li es Li−1L. Por último, la cerradura positiva, denotada como L+, es igual
que la cerradura de Kleene, pero sin el término L0. Es decir, no estará en L+ a menos que
esté en el mismo L.
Capítulo 3. Análisis léxico
120
OPERACIÓN
DEFINICIÓN Y NOTACIÓN
Unión de L y M
L ∪ M = { s | s está en L o s está en M }
Concatenación de L y M
Cerradura de Kleene de L
Cerradura positivo de L
LM = {st | s está en L y t está en M }
i
L* = U∞
i=0 L
i
L+ = U∞
i=1 L
Figura 3.6: Definiciones de las operaciones en los lenguajes
Ejemplo 3.3: Sea L el conjunto de letras {A, B, ..., Z, a, b, ..., z} y sea D el conjunto de
dígitos {0, 1,...9}. Podemos pensar en L y D de dos formas, en esencia equivalentes. Una
forma es que L y D son, respectivamente, los alfabetos de las letras mayúsculas y minúsculas,
y de los dígitos. La segunda forma es que L y D son lenguajes cuyas cadenas tienen longitud
de uno. He aquí algunos otros lenguajes que pueden construirse a partir de los lenguajes L y
D, mediante los operadores de la figura 3.6:
1. L ∪ D es el conjunto de letras y dígitos; hablando en sentido estricto, el lenguaje con 62
cadenas de longitud uno, y cada una de las cadenas es una letra o un dígito.
2. LD es el conjunto de 520 cadenas de longitud dos, cada una de las cuales consiste en una
letra, seguida de un dígito.
3. L4 es el conjunto de todas las cadenas de 4 letras.
4. L* es el conjunto de todas las cadenas de letras, incluyendo , la cadena vacía.
5. L(L ∪ D)* es el conjunto de todas las cadenas de letras y dígitos que empiezan con una
letra.
6. D+ es el conjunto de todas las cadenas de uno o más dígitos.
2
3.3.3
Expresiones regulares
Suponga que deseamos describir el conjunto de identificadores válidos de C. Es casi el mismo
lenguaje descrito en el punto (5) anterior; la única diferencia es que se incluye el guión bajo
entre las letras.
En el ejemplo 3.3, pudimos describir los identificadores proporcionando nombres a los
conjuntos de letras y dígitos, y usando los operadores de unión, concatenación y cerradura del
lenguaje. Este proceso es tan útil que se ha empezado a utilizar comúnmente una notación conocida como expresiones regulares, para describir a todos los lenguajes que puedan construirse
a partir de estos operadores, aplicados a los símbolos de cierto alfabeto. En esta notación, si
letra_ se establece de manera que represente a cualquier letra o al guión bajo, y dígito_ se
121
3.3 Especificación de los tokens
establece de manera que represente a cualquier dígito, entonces podríamos describir el lenguaje
de los identificadores de C mediante lo siguiente:
letra_( letra_ | dígito )*
La barra vertical de la expresión anterior significa la unión, los paréntesis se utilizan para
agrupar las subexpresiones, el asterisco indica “cero o más ocurrencias de”, y la yuxtaposición
de letra_ con el resto de la expresión indica la concatenación.
Las expresiones regulares se construyen en forma recursiva a partir de las expresiones regulares más pequeñas, usando las reglas que describiremos a continuación. Cada expresión
regular r denota un lenguaje L(r), el cual también se define en forma recursiva, a partir de los
lenguajes denotados por las subexpresiones de r. He aquí las reglas que definen las expresiones
regulares sobre cierto alfabeto Σ, y los lenguajes que denotan dichas expresiones.
BASE: Hay dos reglas que forman la base:
1. es una expresión regular, y L() es {}; es decir, el lenguaje cuyo único miembro es la
cadena vacía.
2. Si a es un símbolo en Σ, entonces a es una expresión regular, y L(a) = {a}, es decir, el
lenguaje con una cadena, de longitud uno, con a en su única posición. Tenga en cuenta
que por convención usamos cursiva para los símbolos, y negrita para su correspondiente
expresión regular.1
INDUCCIÓN: Hay cuatro partes que constituyen la inducción, mediante la cual las expresio-
nes regulares más grandes se construyen a partir de las más pequeñas. Suponga que r y s son
expresiones regulares que denotan a los lenguajes L(r) y L(s), respectivamente.
1. (r)|(s) es una expresión regular que denota el lenguaje L(r) ∪ L(s).
2. (r)(s) es una expresión regular que denota el lenguaje L(r)L(s).
3. (r)* es una expresión regular que denota a (L(r))*.
4. (r) es una expresión regular que denota a L(r). Esta última regla dice que podemos agregar pares adicionales de paréntesis alrededor de las expresiones, sin cambiar el lenguaje
que denotan.
Según su definición, las expresiones regulares a menudo contienen pares innecesarios de paréntesis. Tal vez sea necesario eliminar ciertos pares de paréntesis, si adoptamos las siguientes
convenciones:
a) El operador unario * tiene la precedencia más alta y es asociativo a la izquierda.
b) La concatenación tiene la segunda precedencia más alta y es asociativa a la izquierda.
1
Sin embargo, al hablar sobre los caracteres específicos del conjunto de caracteres ASCII, por lo general, usaremos
la fuente de teletipo para el carácter y su expresión regular.
Capítulo 3. Análisis léxico
122
c) | tiene la precedencia más baja y es asociativo a la izquierda.
Por ejemplo, bajo estas convenciones podemos sustituir la expresión regular (a)|((b)*(c)) por
a|b*c. Ambas expresiones denotan el conjunto de cadenas que son una sola a o cero o más bs
seguidas por una c.
Ejemplo 3.4: Sea Σ = {a, b}.
1. La expresión regular a|b denota el lenguaje {a, b}.
2. (a|b)(a|b) denota a {aa, ab, ba, bb}, el lenguaje de todas las cadenas de longitud dos
sobre el alfabeto Σ. Otra expresión regular para el mismo lenguaje sería aa|ab|ba|bb.
3. a* denota el lenguaje que consiste en todas las cadenas de cero o más as, es decir, {, a,
aa, aaa, …}.
4. (a|b)* denota el conjunto de todas las cadenas que consisten en cero o más instancias de
a o b, es decir, todas las cadenas de as y bs: {, a, b, aa, ab, ba, bb, aaa, …}. Otra expresión
regular para el mismo lenguaje es (a*b*)*.
5. a|a*b denota el lenguaje {a, b, ab, aab, aaab, …}, es decir, la cadena a y todas las cadenas
que consisten en cero o más as y que terminan en b.
2
A un lenguaje que puede definirse mediante una expresión regular se le llama conjunto
regular. Si dos expresiones regulares r y a denotan el mismo conjunto regular, decimos que
son equivalentes y escribimos r = s. Por ejemplo, (a|b) = (b|a). Hay una variedad de leyes
algebraicas para las expresiones regulares; cada ley afirma que las expresiones de dos formas
distintas son equivalentes. La figura 3.7 muestra parte de las leyes algebraicas para las expresiones regulares arbitrarias r, s y t.
LEY
r|s = s|r
r|(s|t) = (r|s)|t
r(st) = (rs)t
r(s|t) = rs|rt; (s|t)r = sr|tr
r = r = r
r * = (r|)*
r ** = r *
DESCRIPCIÓN
| es conmutativo
| es asociativo
La concatenación es asociativa
La concatenación se distribuye sobre |
es la identidad para la concatenación
se garantiza en un cerradura
* es idempotente
Figura 3.7: Leyes algebraicas para las expresiones regulares
123
3.3 Especificación de los tokens
3.3.4
Definiciones regulares
Por conveniencia de notación, tal vez sea conveniente poner nombres a ciertas expresiones
regulares, y utilizarlos en las expresiones subsiguientes, como si los nombres fueran símbolos
por sí mismos. Si Σ es un alfabeto de símbolos básicos, entonces una definición regular es una
secuencia de definiciones de la forma:
d1
d2
dn
→ r1
→ r2
···
→ rn
en donde:
1. Cada di es un nuevo símbolo, que no está en Σ y no es el mismo que cualquier otro d.
2. Cada ri es una expresión regular sobre el alfabeto Σ ∪ {d1, d2, …, di−1}.
Al restringir ri a Σ y a las d s definidas con anterioridad, evitamos las definiciones recursivas y
podemos construir una expresión regular sobre Σ solamente, para cada ri. Para ello, primero
sustituimos los usos de d1 en r2 (que no puede usar ninguna de las d s, excepto d1), después sustituimos los usos de d1 y d2 en r3 por r1 y (la sustitución de) r2, y así en lo sucesivo. Por último,
en rn sustituimos cada di, para i = 1, 2, …, n − 1, por la versión sustituida de ri, cada una de
las cuales sólo tiene símbolos de Σ.
Ejemplo 3.5: Los identificadores de C son cadenas de letras, dígitos y guiones bajos. He aquí
una definición regular para el lenguaje de los identificadores de C. Por convención, usaremos
cursiva para los símbolos definidos en las definiciones regulares.
letra_
dígito
id
2
→
→
→
A | B |· · ·| Z | a | b |· · ·| z | _
0 | 1 |· · ·| 9
letra_ ( letra_ | dígito )*
Ejemplo 3.6: Los números sin signo (enteros o de punto flotante) son cadenas como 5280,
0.01234, 6.336E4 o 1.89E−4. La definición regular
digito
digitos
fraccionOpcional
exponenteOpcional
numero
→
→
→
→
→
0 | 1 |· · ·| 9
digito digito*
. digitos | ( E ( + | − | ) digitos ) | digitos fraccionOpcional exponenteOpcional
es una especificación precisa para este conjunto de cadenas. Es decir, una fraccionOpcional es
un punto decimal (punto) seguido de uno o más dígitos, o se omite (la cadena vacía). Un exponenteOpcional, si no se omite, es la letra E seguida de un signo opcional + o −, seguido de
uno o más dígitos. Observe que por lo menos un dígito debe ir después del punto, de manera
que numero no coincide con 1., pero sí con 1.0. 2
Capítulo 3. Análisis léxico
124
3.3.5
Extensiones de las expresiones regulares
Desde que Kleene introdujo las expresiones regulares con los operadores básicos para la unión,
la concatenación y la cerradura de Kleene en la década de 1950, se han agregado muchas extensiones a las expresiones regulares para mejorar su habilidad al especificar los patrones de
cadenas. Aquí mencionamos algunas extensiones rotacionales que se incorporaron por primera
vez en las herramientas de Unix como Lex, que son muy útiles en la especificación de los analizadores léxicos. Las referencias a este capítulo contienen una explicación de algunas variantes
de expresiones regulares en uso hoy en día.
1. Una o más instancias. El operador unario postfijo + representa la cerradura positivo de
una expresión regular y su lenguaje. Es decir, si r es una expresión regular, entonces (r)+
denota el lenguaje (L(r))+. El operador + tiene la misma precedencia y asociatividad
que el operador *. Dos leyes algebraicas útiles, r * = r+| y r+ = rr * = r *r relacionan la
cerradura de Kleene y la cerradura positiva.
2. Cero o una instancia. El operador unario postfijo ? significa “cero o una ocurrencia”. Es
decir, r? es equivalente a r|, o dicho de otra forma, L(r?) = L(r) ∪ {}. El operador ?
tiene la misma precedencia y asociatividad que * y +.
3. Clases de caracteres. Una expresión regular a1|a2|· · ·|an, en donde las ais son cada una
símbolos del alfabeto, puede sustituirse mediante la abreviación [a1a2 · · ·an ]. Lo que
es más importante, cuando a1, a2, …, an forman una secuencia lógica, por ejemplo, letras
mayúsculas, minúsculas o dígitos consecutivos, podemos sustituirlos por a1-an; es decir,
sólo la primera y última separadas por un guión corto. Así, [abc] es la abreviación para
a|b|c, y [a-z] lo es para a|b|· · ·|z.
Ejemplo 3.7: Mediante estas abreviaciones, podemos rescribir la definición regular del ejemplo 3.5 como;
letra_
digito
id
→
→
→
[A−Za−z_]
[0−9]
letra_ (letra | digito )*
La definición regular del ejemplo 3.6 también puede simplificarse:
digito
digitos
numero
→
→
→
[0−9]
digito+
digitos (. digitos)? (E [+−]? digitos )?
3.3 Especificación de los tokens
3.3.6
125
Ejercicios para la sección 3.3
Ejercicio 3.3.1: Consulte los manuales de referencia del lenguaje para determinar (i) los conjuntos de caracteres que forman el alfabeto de entrada (excluyendo aquellos que sólo puedan
aparecer en cadenas de caracteres o comentarios), (ii) la forma léxica de las constantes numéricas, y (iii) la forma léxica de los identificadores, para cada uno de los siguientes lenguajes:
(a) C (b) C++ (c) C# (d) Fortran (e) Java (f) Lisp (g) SQL.
! Ejercicio 3.3.2: Describa los lenguajes denotados por las siguientes expresiones regulares:
a) a(a|b)*a.
b) ((|a)b*)*.
c) (a|b)*a(a|b)(a|b).
d) a*ba*ba*ba*.
!! e) (aa|bb)*((ab|ba)(aa|bb)*(ab|ba)(aa|bb)*)*.
Ejercicio 3.3.3: En una cadena de longitud n, ¿cuántos de los siguientes hay?
a) Prefijos.
b) Sufijos.
c) Prefijos propios.
! d) Subcadenas.
! e) Subsecuencias.
Ejercicio 3.3.4: La mayoría de los lenguajes son sensibles a mayúsculas y minúsculas, por
lo que las palabras clave sólo pueden escribirse de una forma, y las expresiones regulares que
describen su lexema son muy simples. No obstante, algunos lenguajes como SQL son insensibles a mayúsculas y minúsculas, por lo que una palabra clave puede escribirse en minúsculas
o en mayúsculas, o en cualquier mezcla de ambas. Por ende, la palabra clave SELECT de SQL
también puede escribirse como select, Select o sElEcT. Muestre cómo escribir una expresión
regular para una palabra clave en un lenguaje insensible a mayúsculas y minúsculas. Ilustre la
idea escribiendo la expresión para “select” en SQL.
! Ejercicio 3.3.5: Escriba definiciones regulares para los siguientes lenguajes:
a) Todas las cadenas de letras en minúsculas que contengan las cinco vocales en orden.
b) Todas las cadenas de letras en minúsculas, en las que las letras se encuentren en orden
lexicográfico ascendente.
c) Comentarios, que consistan de una cadena rodeada por /* y */, sin un */ entre ellos, a
menos que se encierre entre dobles comillas (").
Capítulo 3. Análisis léxico
126
!! d) Todas las cadenas de dígitos sin dígitos repetidos. Sugerencia: Pruebe este problema
primero con unos cuantos dígitos, como {0, 1, 2}.
!! e) Todas las cadenas de dígitos que tengan por lo menos un dígito repetido.
!! f) Todas las cadenas de as y bs con un número par de as y un número impar de bs.
g) El conjunto de movimientos de Ajedrez, en la notación informal, como p−k4 o kbp×qn.
!! h) Todas las cadenas de as y bs que no contengan la subcadena abb.
i) Todas las cadenas de as y bs que no contengan la subsecuencia abb.
Ejercicio 3.3.6: Escriba clases de caracteres para los siguientes conjuntos de caracteres:
a) Las primeras diez letras (hasta “j”), ya sea en mayúsculas o en minúsculas.
b) Las consonantes en minúsculas.
c) Los “dígitos” en un número hexadecimal (elija mayúsculas o minúsculas para los “dígitos” mayores a 9).
d) Los caracteres que pueden aparecer al final de una oración legítima en inglés (por ejemplo, el signo de admiración).
Los siguientes ejercicios, hasta e incluyendo el ejercicio 3.3.10, tratan acerca de la notación
de expresiones regulares extendidas de Lex (el generador de analizadores léxicos que veremos
con detalle en la sección 3.5). La notación extendida se presenta en la figura 3.8.
Ejercicio 3.3.7: Observe que estas expresiones regulares proporcionan a todos los siguientes
símbolos (caracteres de operadores) un significado especial:
\ " . ^ $ [ ] * + ? { } | /
Su significado especial debe desactivarse si se necesitan para representarse a sí mismos en una
cadena de caracteres. Para ello, debemos colocar el carácter entre comillas, dentro de una cadena de longitud uno o más; por ejemplo, la expresión regular "**" coincide con la cadena **.
También podemos obtener el significado literal de un carácter de operador si le anteponemos
una barra diagonal inversa. Por ende, la expresión regular \*\* también coincide con la cadena
**. Escriba una expresión regular que coincida con la cadena "\.
Ejercicio 3.3.8: En Lex, una clase de carácter complementado representa a cualquier carácter, excepto los que se listan en la clase de carácter. Denotamos a un carácter complementado
mediante el uso de ^ como el primer carácter; este símbolo no forma en sí parte de la clase
que se está complementando, a menos que se liste dentro de la misma clase. Así, [^A−Za−z]
coincide con cualquier carácter que no sea una letra mayúscula o minúscula, y [^\^] representa
a cualquier carácter excepto ˆ (o nueva línea, ya que el carácter nueva línea no puede estar
en ninguna clase de caracteres). Muestre que para cualquier expresión regular con clases de
caracteres complementados, hay una expresión regular equivalente sin clases de caracteres
complementados.
127
3.3 Especificación de los tokens
EXPRESIÓN
COINCIDE CON
c
\c
"s"
.
ˆ
$
un carácter c que no sea operador
el carácter c, literalmente
la cadena s, literalmente
cualquier carácter excepto nueva línea
el inicio de una línea
el final de una línea
[s]
[^s]
r*
r+
r?
r{m, n}
r1r2
cualquiera de los caracteres en la cadena s
cualquier carácter que no esté en la cadena s
cero o más cadenas que coincidan con r
una o más cadenas que coincidan con r
cero o una r
entre m y n ocurrencias de r
una r1 seguida de una r2
una r1 o una r2
igual que r
r1 cuando va seguida de r2
r1 | r2
(r)
r1/r2
EJEMPLO
a
\*
"**"
a.*b
^abc
abc$
[abc]
[^abc]
a*
a+
a?
a[1,5]
ab
a|b
(a|b)
abc/123
Figura 3.8: Expresiones regulares de Lex
! Ejercicio 3.3.9: La expresión regular r{m, n} coincide con las ocurrencias entre m y n del
patrón r. Por ejemplo, a[1,5] coincide con una cadena de una a cinco as. Muestre que para
cada expresión regular que contiene operadores de repetición de esta forma, hay una expresión
regular equivalente sin operadores de repetición.
! Ejercicio 3.3.10: El operador ^ coincide con el extremo izquierdo de una línea, y $ coincide
con el extremo derecho de una línea. El operador ^ también se utiliza para introducir las clases
de caracteres complementados, pero el contexto siempre deja en claro cuál es el significado deseado. Por ejemplo, ^[^aeiou]*$ coincide con cualquier línea completa que no contiene una
vocal en minúscula.
a) ¿Cómo podemos saber cuál es el significado deseado de ^?
b) ¿Podemos sustituir siempre una expresión regular, usando los operadores ^ y $, por una
expresión equivalente que no utilice ninguno de estos operadores?
! Ejercicio 3.3.11: El comando del intérprete (shell) de UNIX sh utiliza los operadores de la
figura 3.9 en expresiones de nombres de archivo para describir conjuntos de nombres de archivos. Por ejemplo, la expresión de nombre de archivo *.o coincide con todos los nombres de
archivo que terminen en .o; orden1.? coincide con todos los nombres de archivo de la forma
Capítulo 3. Análisis léxico
128
COINCIDE CON
EJEMPLO
EXPRESIÓN
s
\c
*
?
la cadena s, literalmente
el carácter c, literalmente
cualquier cadena
cualquier carácter
[s]
cualquier carácter en s
’\’
\’
*.o
orden1.?
orden1.[cso]
Figura 3.9: Expresiones de nombres de archivo utilizadas por el comando sh del intérprete
de UNIX
orden.c, en donde c es cualquier carácter. Muestre cómo pueden sustituirse las expresiones de
nombres de archivo de sh por expresiones regulares equivalentes, usando sólo los operadores
básicos de unión, concatenación y cerradura.
! Ejercicio 3.3.12: SQL permite una forma rudimentaria de patrones, en los cuales dos caracteres tienen un significado especial: el guión bajo (_) representa a cualquier carácter y el signo
de por ciento (%) representa a cualquier cadena de 0 o más caracteres. Además, el programador
puede definir cualquier carácter, por decir e, para que sea el carácter de escape, de manera que
si colocamos a e antes de e antes de _, % o cualquier e, obtenemos el carácter que va después
de su significado literal. Muestre cómo expresar cualquier patrón de SQL como una expresión
regular, dado que sabemos cuál es el carácter de escape.
3.4
Reconocimiento de tokens
En la sección anterior, aprendimos a expresar los patrones usando expresiones regulares. Ahora
debemos estudiar cómo tomar todos los patrones para todos los tokens necesarios y construir
una pieza de código para examinar la cadena de entrada y buscar un prefijo que sea un lexema que coincida con uno de esos patrones. En nuestra explicación haremos uso del siguiente
bosquejo:
instr
expr
term
→
|
|
→
|
→
|
if expr then instr
if expr then instr else instr
term oprel term
term
id
numero
Figura 3.10: Una gramática para las instrucciones de bifurcación
Ejemplo 3.8: El fragmento de gramática de la figura 3.10 describe una forma simple de instrucciones de bifurcación y de expresiones condicionales. Esta sintaxis es similar a la del lenguaje Pascal porque then aparece en forma explícita después de las condiciones.
129
3.4 Reconocimiento de tokens
Para oprel, usamos los operadores de comparación de lenguajes como Pascal o SQL, en
donde = es “es igual a” y <> es “no es igual a”, ya que presenta una estructura interesante de
lexemas.
Las terminales de la gramática, que son if, then, else, oprel, id y numero, son los
nombres de tokens en lo que al analizador léxico respecta. Los patrones para estos tokens se
describen mediante el uso de definiciones regulares, como en la figura 3.11. Los patrones para
id y número son similares a lo que vimos en el ejemplo 3.7.
digito
digitos
numero
letra
id
if
then
else
oprel
→
→
→
→
→
→
→
→
→
[0−9]
digito+
digitos (. digitos)? ( E [+−]? digitos )?
[A−Za−z]
letra ( letra | digito )*
if
then
else
< | > | <= | >= | = | <>
Figura 3.11: Patrones para los tokens del ejemplo 3.8
Para este lenguaje, el analizador léxico reconocerá las palabras clave if, then y else, así
como los lexemas que coinciden con los patrones para oprel, id y numero. Para simplificar las
cosas, vamos a hacer la suposición común de que las palabras clave también son palabras reservadas; es decir, no son identificadores, aun cuando sus lexemas coinciden con el patrón para
identificadores.
Además, asignaremos al analizador léxico el trabajo de eliminar el espacio en blanco,
reconociendo el “token” ws definido por:
ws
→
( blanco | tab | nuevalinea )+
Aquí, blanco, tab y nuevalinea son símbolos abstractos que utilizamos para expresar los
caracteres ASCII de los mismos nombres. El token ws es distinto de los demás tokens porque
cuando lo reconocemos, no lo regresamos al analizador sintáctico, sino que reiniciamos el analizador léxico a partir del carácter que va después del espacio en blanco. El siguiente token es
el que se devuelve al analizador sintáctico.
Nuestro objetivo para el analizador léxico se resume en la figura 3.12. Esa tabla muestra,
para cada lexema o familia de lexemas, qué nombre de token se devuelve al analizador sintáctico y qué valor de atributo se devuelve, como vimos en la sección 3.1.3. Observe que para los
seis operadores relacionales, se utilizan las constantes simbólicas LT, LE, y demás como el valor
del atributo, para poder indicar qué instancia del token oprel hemos encontrado. El operador específico que se encuentre influirá en el código que genere el compilador de salida. 2
Capítulo 3. Análisis léxico
130
LEXEMAS
Cualquier ws
if
Then
else
Cualquier id
Cualquier numero
<
<=
=
<>
>
>=
NOMBRE DEL TOKEN
VALOR DEL ATRIBUTO
–
if
then
else
id
numero
oprel
oprel
oprel
oprel
oprel
oprel
–
–
–
–
Apuntador a una entrada en la tabla
Apuntador a una entrada en la tabla
LT
LE
EQ
NE
GT
GE
Figura 3.12: Tokens, sus patrones y los valores de los atributos
3.4.1
Diagramas de transición de estados
Como paso intermedio en la construcción de un analizador léxico, primero convertimos los patrones en diagramas de flujo estilizados, a los cuales se les llama “diagramas de transición de estados”. En esta sección, realizaremos la conversión de los patrones de expresiones regulares a los
diagramas de transición de estados en forma manual, pero en la sección 3.6 veremos que hay una
forma mecánica de construir estos diagramas, a partir de colecciones de expresiones regulares.
Los diagramas de transición de estados tienen una colección de nodos o círculos, llamados estados. Cada estado representa una condición que podría ocurrir durante el proceso de
explorar la entrada, buscando un lexema que coincida con uno de varios patrones. Podemos
considerar un estado como un resumen de todo lo que debemos saber acerca de los caracteres
que hemos visto entre el apuntador inicioLexema y el apuntador avance (como en la situación
de la figura 3.3).
Las líneas se dirigen de un estado a otro del diagrama de transición de estados. Cada línea
se etiqueta mediante un símbolo o conjunto de símbolos. Si nos encontramos en cierto estado s,
y el siguiente símbolo de entrada es a, buscamos una línea que salga del estado s y esté etiquetado por a (y tal vez por otros símbolos también). Si encontramos dicha línea, avanzamos el apuntador avance y entramos al estado del diagrama de transición de estados al que nos
lleva esa línea. Asumiremos que todos nuestros diagramas de transición de estados son deterministas, lo que significa que nunca hay más de una línea que sale de un estado dado, con un
símbolo dado de entre sus etiquetas. A partir de la sección 3.5, relajaremos la condición del
determinismo, facilitando en forma considerable la vida del diseñador de un analizador léxico,
aunque esto se hará más difícil para el implementador. Algunas convenciones importantes de
los diagramas de transición de estados son:
1. Se dice que ciertos estados son de aceptación, o finales. Estos estados indican que se
ha encontrado un lexema, aunque el lexema actual tal vez no consista de todas las posiciones entre los apuntadores inicioLexema y avance. Siempre indicamos un estado de
131
3.4 Reconocimiento de tokens
aceptación mediante un círculo doble, y si hay que realizar una acción (por lo general,
devolver un token y un valor de atributo al analizador sintáctico), la adjuntaremos al
estado de aceptación.
2. Además, si es necesario retroceder el apuntador avance una posición (es decir, si el
lexema no incluye el símbolo que nos llevó al estado de aceptación), entonces deberemos
colocar de manera adicional un * cerca del estado de aceptación. En nuestro ejemplo,
nunca es necesario retroceder a avance más de una posición, pero si lo fuera, podríamos
adjuntar cualquier número de *s al estado de aceptación.
3. Un estado se designa como el estado inicial; esto se indica mediante una línea etiquetada como “inicio”, que no proviene de ninguna parte. El diagrama de transición siempre
empieza en el estado inicial, antes de leer cualquier símbolo de entrada.
Ejemplo 3.9: La figura 3.13 es un diagrama de transición de estados que reconoce los lexemas que coinciden con el token oprel. Empezamos en el estado 0, el estado inicial. Si vemos <
como el primer símbolo de entrada, entonces de entre los lexemas que coinciden con el patrón
para oprel sólo podemos ver a <, <> o <=. Por lo tanto, pasamos al estado 1 y analizamos el
siguiente carácter. Si es =, entonces reconocemos el lexema <=, pasamos al estado 2 y devolvemos el token oprel con el atributo LE, la constante simbólica que representa a este operador de
comparación específico. Si en el estado 1 el siguiente carácter es >, entonces tenemos el lexema
<> y pasamos al estado 3 para devolver una indicación de que se ha encontrado el operador
“no es igual a”. En cualquier otro carácter, el lexema es <, y pasamos al estado 4 para devolver
esa información. Sin embargo, observe que el estado 4 tiene un * para indicar que debemos
retroceder la entrada una posición.
<
inicio
0
=
1
2
return ( oprel, LE )
3
return ( oprel, NE )
4
*
return ( oprel, LT )
>
=
otro
5
>
return ( oprel, EQ )
=
6
otro
7
return ( oprel, GE )
8
*
return ( oprel, GT )
Figura 3.13: Diagrama de transición de estados para oprel
Por otro lado, si en el estado 0 el primer carácter que vemos es =, entonces este carácter
debe ser el lexema. De inmediato devolvemos ese hecho desde el estado 5. La posibilidad restante es que el primer carácter sea >. Entonces, debemos pasar al estado 6 y decidir, en base
Capítulo 3. Análisis léxico
132
al siguiente carácter, si el lexema es >= (si vemos a continuación el signo =), o sólo > (con cualquier otro carácter). Observe que, si en el estado 0 vemos cualquier carácter además de <, = o
>, no es posible que estemos viendo un lexema oprel, por lo que no utilizaremos este diagrama
de transición de estados.
2
3.4.2
Reconocimiento de las palabras reservadas
y los identificadores
El reconocimiento de las palabras reservadas y los identificadores presenta un problema. Por lo
general, las palabras clave como if o then son reservadas (como en nuestro bosquejo), por
lo que no son identificadores, aun cuando lo parecen. Así, aunque por lo general usamos un
diagrama de transición de estados como el de la figura 3.14 para buscar lexemas de identificadores, este diagrama también reconocerá las palabras clave if, then y else de nuestro bosquejo.
letra o dígito
inicio
letra
9
*
otro
10
11
return (obtenerToken ( ), instalarID( ))
Figura 3.14: Un diagrama de transición de estados para identificadores (id) y palabras clave
Hay dos formas en las que podemos manejar las palabras reservadas que parecen identificadores:
1. Instalar las palabras reservadas en la tabla de símbolos desde el principio. Un campo de
la entrada en la tabla de símbolos indica que estas cadenas nunca serán identificadores
ordinarios, y nos dice qué token representan. Hemos supuesto que este método es el que
se utiliza en la figura 3.14. Al encontrar un identificador, una llamada a instalarID lo
coloca en la tabla de símbolos, si no se encuentra ahí todavía, y devuelve un apuntador a
la entrada en la tabla de símbolos para el lexema que se encontró. Desde luego que cualquier identificador que no se encuentre en la tabla de símbolos durante el análisis léxico
no puede ser una palabra reservada, por lo que su token es id. La función obtenerToken
examina la entrada en la tabla de símbolos para el lexema encontrado, y devuelve el
nombre de token que la tabla de símbolos indique que representa este lexema; ya sea id
o uno de los tokens de palabra clave que se instaló en un principio en la tabla.
2. Crear diagramas de transición de estados separados para cada palabra clave; en la figura
3.15 se muestra un ejemplo para la palabra clave then. Observe que dicho diagrama de
transición de estado consiste en estados que representan la situación después de ver cada
letra sucesiva de la palabra clave, seguida de una prueba para un “no letra ni dígito”,
es decir, cualquier carácter que no pueda ser la continuación de un identificador. Es
necesario verificar que el identificador haya terminado, o de lo contrario devolveríamos
el token then en situaciones en las que el token correcto era id, con un lexema como
thenextValue que tenga a then como un prefijo propio. Si adoptamos este método, entonces debemos dar prioridad a los tokens, para que los tokens de palabra reservada se
133
3.4 Reconocimiento de tokens
reconozcan de preferencia en vez de id, cuando el lexema coincida con ambos patrones.
No utilizaremos este método en nuestro ejemplo, que explica por qué los estados en la
figura 3.15 no están enumerados.
t
inicio
h
e
n
no letr/dig
*
Figura 3.15: Diagrama de transición hipotético para la palabra clave then
3.4.3
Finalización del bosquejo
El diagrama de transición para los tokens id que vimos en la figura 3.14 tiene una estructura
simple. Empezando en el estado 9, comprueba que el lexema empiece con una letra y que pase
al estado 10 en caso de ser así. Permanecemos en el estado 10 siempre y cuando la entrada
contenga letras y dígitos. Al momento de encontrar el primer carácter que no sea letra ni
dígito, pasamos al estado 11 y aceptamos el lexema encontrado. Como el último carácter no
forma parte del identificador, debemos retroceder la entrada una posición, y como vimos en la
sección 3.4.2, introducimos lo que hemos encontrado en la tabla de símbolos y determinamos
si tenemos una palabra clave o un verdadero identificador.
El diagrama de transición de estados para el token numero se muestra en la figura 3.16,
y es hasta ahora el diagrama más complejo que hemos visto. Empezando en el estado 12, si
vemos un dígito pasamos al estado 13. En ese estado podemos leer cualquier número de dígitos
adicionales. No obstante, si vemos algo que no sea un dígito o un punto, hemos visto un número
en forma de entero; 123 es un ejemplo. Para manejar ese caso pasamos al estado 20, en donde
devolvemos el token numero y un apuntador a una tabla de constantes en donde se introduce
el lexema encontrado. Esta mecánica no se muestra en el diagrama, pero es análoga a la forma
en la que manejamos los identificadores.
dígito
dígito
inicio
12
dígito
13
.
14
dígito
15
dígito
E
16
+ o −
E
otro
20
*
17
dígito
18
otro
19
*
dígito
otro
21
*
Figura 3.16: Un diagrama de transición para los números sin signo
Si en vez de ello vemos un punto en el estado 13, entonces tenemos una “fracción opcional”.
Pasamos al estado 14, y buscamos uno o más dígitos adicionales; el estado 15 se utiliza para
este fin. Si vemos una E, entonces tenemos un “exponente opcional”, cuyo reconocimiento es
trabajo de los estados 16 a 19. Si en el estado 15 vemos algo que no sea una E o un dígito,
entonces hemos llegado al final de la fracción, no hay exponente y devolvemos el lexema encontrado, mediante el estado 21.
Capítulo 3. Análisis léxico
134
El diagrama de transición de estados final, que se muestra en la figura 3.17, es para el
espacio en blanco. En ese diagrama buscamos uno o más caracteres de “espacio en blanco”,
representados por delim en ese diagrama; por lo general estos caracteres son los espacios,
tabuladores, caracteres de nueva línea y tal vez otros caracteres que el diseño del lenguaje no
considere como parte de algún token.
delim
inicio
22
delim
23
otro
24
*
Figura 3.17: Un diagrama de transición para el espacio en blanco
Observe que, en el estado 24, hemos encontrado un bloque de caracteres de espacio en
blanco consecutivos, seguidos de un carácter que no es espacio en blanco. Regresemos la entrada para que empiece en el carácter que no es espacio en blanco, pero no regresamos nada
al analizador sintáctico, sino que debemos reiniciar el proceso del análisis léxico después del
espacio en blanco.
3.4.4
Arquitectura de un analizador léxico basado en diagramas
de transición de estados
Hay varias formas en las que pueden utilizarse los diagramas de transición de estados para
construir un analizador léxico. Sin importar la estrategia general, cada estado se representa
mediante una pieza de código. Podemos imaginar una variable estado que contiene el número
del estado actual para un diagrama de transición de estados. Una instrucción switch con base
en el valor de estado nos lleva al código para cada uno de los posibles estados, en donde encontramos la acción de ese estado. A menudo, el código para un estado es en sí una instrucción
switch o una bifurcación de varias vías que determina el siguiente estado mediante el proceso
de leer y examinar el siguiente carácter de entrada.
Ejemplo 3.10: En la figura 3.18 vemos un bosquejo de obtenerOpRel(), una función en
C++ cuyo trabajo es simular el diagrama de transición de la figura 3.13 y devolver un objeto
de tipo TOKEN; es decir, un par que consiste en el nombre del token (que debe ser oprel en este
caso) y un valor de atributo (el código para uno de los seis operadores de comparación en
este caso). Lo primero que hace obtenerOpRel() es crear un nuevo objeto tokenRet e inicializar su primer componente con OPREL, el código simbólico para el token oprel.
Podemos ver el comportamiento típico de un estado en el caso 0, el caso en donde el estado
actual es 0. Una función llamada sigCar() obtiene el siguiente carácter de la entrada y lo
asigna a la variable local c. Después verificamos si c es uno de los tres caracteres que esperamos
encontrar, y realizamos la transición de estado que indica el diagrama de transición de la figura
3.13 en cada caso. Por ejemplo, si el siguiente carácter de entrada es =, pasamos al estado 5.
Si el siguiente carácter de entrada no es uno que pueda empezar un operador de comparación, entonces se hace una llamada a la función fallo(). Lo que haga ésta dependerá de la
3.4 Reconocimiento de tokens
135
TOKEN obtenerOpRel()
{
TOKEN tokenRet = new (OPREL);
while(1) { /* repite el procesamiento de caracteres hasta que
ocurre un retorno o un fallo */
switch(estado) {
case 0: c = sigCar();
if ( c == ’<’ ) estado = 1;
else if ( c == ’=’ ) estado = 5;
else if ( c == ’>’ ) estado = 6;
else fallo(); /* el lexema no es un oprel */
break;
case 1: ...
...
case 8: retractar();
tokenRet.atributo = GT;
return(tokenRet);
}
}
}
Figura 3.18: Bosquejo de la implementación del diagrama de transición oprel
estrategia global de recuperación de errores del analizador léxico. Debe reiniciar el apuntador
avance para colocarlo en la misma posición que inicioLexema, de manera que pueda aplicarse
otro diagrama de transición de estados al verdadero inicio de la entrada sin procesar. Entonces
podría cambiar el valor de estado para que sea el estado inicial para otro diagrama de transición, el cual buscará otro token. De manera alternativa, si no hay otro diagrama de transición de
estados que esté sin uso, fallo() podría iniciar una fase de corrección de errores que tratará
de reparar la entrada y encontrar un lexema, como vimos en la sección 3.1.4.
También mostramos la acción para el estado 8 en la figura 3.18. Como el estado 8 lleva un *,
debemos regresar el apuntador de entrada una posición (es decir, colocar a c de vuelta en el flujo de entrada). Esta tarea se lleva a cabo mediante la función retractar(). Como el estado 8
representa el reconocimiento del lexema >=, establecemos el segundo componente del objeto
devuelto (el cual suponemos se llama atributo) con GT, el código para este operador.
2
Para poner en perspectiva la simulación de un diagrama de transición de estados, vamos
a considerar las formas en que el código como el de la figura 3.18 podría ajustarse a todo el
analizador léxico.
1. Podríamos arreglar que los diagramas de transición para cada token se probaran en
forma secuencial. Después, la función fallo() del ejemplo 3.10 restablece el apuntador
avance e inicia el siguiente diagrama de transición de estados, cada vez que se le llama.
Este método nos permite usar diagramas de transición de estados para las palabras clave
individuales, como el que se sugiere en la figura 3.15. Sólo tenemos que usarlos antes que
el diagrama para id, de manera que las palabras clave sean palabras reservadas.
Capítulo 3. Análisis léxico
136
2. Podríamos ejecutar los diversos diagramas de transición de estados “en paralelo”, alimentando el siguiente carácter de entrada a todos ellos y permitiendo que cada uno realice las transiciones requeridas. Si utilizamos esta estrategia, debemos tener cuidado de
resolver el caso en el que un diagrama encuentra a un lexema que coincide con su patrón,
mientras uno o varios de los otros diagramas pueden seguir procesando la entrada. La
estrategia normal es tomar el prefijo más largo de la entrada que coincida con cualquier
patrón. Esa regla nos permite por ejemplo, dar preferencia al identificador siguiente
sobre la palabra clave then, o al operador −> sobre −.
3. El método preferido, y el que vamos a usar en las siguientes secciones, es combinar todos
los diagramas de transición de estados en uno solo. Permitimos que el diagrama de transición de estados lea la entrada hasta que no haya un siguiente estado posible, y después
tomamos el lexema más largo que haya coincidido con algún patrón, como dijimos en
el punto (2) anterior. En nuestro bosquejo, esta combinación es fácil, ya que no puede
haber dos tokens que empiecen con el mismo carácter; es decir, el primer carácter nos
indica de inmediato el token que estamos buscando. Por ende, podríamos simplemente
combinar los estados 0, 9, 12 y 22 en un estado inicial, dejando las demás transiciones
intactas. No obstante, en general, el problema de combinar diagramas de transición de
estados para varios tokens es más complejo, como veremos en breve.
3.4.5
Ejercicios para la sección 3.4
Ejercicio 3.4.1: Proporcione los diagramas de transición de estados para reconocer los mismos lenguajes que en cada una de las expresiones regulares en el ejercicio 3.3.2.
Ejercicio 3.4.2: Proporcione los diagramas de transición para reconocer los mismos lenguajes
que en cada una de las expresiones regulares en el ejercicio 3.3.5.
Los siguientes ejercicios, hasta el 3.4.12, presentan el algoritmo de Aho-Corasick para reconocer una colección de palabras clave en una cadena de texto, en un tiempo proporcional
a la longitud del texto y la suma de la longitud de las palabras clave. Este algoritmo utiliza
una forma especial de diagrama de transición, conocido como trie. Un trie es un diagrama de
transición de estados con estructura de árbol y distintas etiquetas en las líneas que nos llevan
de un nodo a sus hijos. Las hojas del trie representan las palabras clave reconocidas.
Knuth, Morris y Pratt presentaron un algoritmo para reconocer una palabra clave individual b1b2 · · · bn en una cadena de texto. Aquí, el trie es un diagrama de transición de estados
con n estados, de 0 a n. El estado 0 es el estado inicial, y el estado n representa la aceptación,
es decir, el descubrimiento de la palabra clave. De cada estado s, desde 0 hasta n − 1, hay una
transición al estado s + 1, que se etiqueta mediante el símbolo bs+1. Por ejemplo, el trie para
la palabra clave ababaa es:
a
0
b
1
a
2
b
3
a
4
a
5
6
Para poder procesar las cadenas de texto con rapidez y buscar una palabra clave en esas
cadenas, es útil definir, para la palabra clave b1b2 · · · bn y la posición s en esa palabra clave
(correspondiente al estado s de su trie), una función de fallo, f (s), que se calcula como en la
3.4 Reconocimiento de tokens
137
figura 3.19. El objetivo es que b1b2 · · · bf (s) sea el prefijo propio más largo de b1b2 · · · bs, que
también sea sufijo de b1b2 · · · bs. La razón de la importancia de f (s) es que, si tratamos de
encontrar una coincidencia en una cadena de texto para b1b2 · · · bn, y hemos relacionado las
primeras s posiciones, pero después fallamos (es decir, que la siguiente posición de la cadena de
texto no contenga a bs+1), entonces f (s) es el prefijo más largo de b1b2 · · · bn que podría quizá
coincidir con la cadena de texto hasta el punto en el que nos encontramos. Desde luego que el
siguiente carácter de la cadena de texto debe ser bf (s)+1, o de lo contrario seguiríamos teniendo
problemas y tendríamos que considerar un prefijo aún más corto, el cual será bf (f (s )).
1)
2)
3)
4)
5)
6)
7)
8)
t = 0;
f (1) = 0;
for (s = 1; s < n; s + +) {
while (t > 0 && bs+1 ! = bt+1) t = f (t);
if (bs+1 == bt+1) {
t = t + 1;
f (s + 1) = t;
}
else f (s + 1) = 0;
}
Figura 3.19: Algoritmo para calcular la función de fallo para la palabra clave b1b2 · · · bn
Como ejemplo, la función de fallo para el trie que construimos antes para ababaa es:
Por ejemplo, los estados 3 y 1 representan a los prefijos aba y a, respectivamente. f (3) = 1 ya
que a es el prefijo propio más largo de aba, que también es un sufijo de aba. Además, f (2) = 0,
debido a que el prefijo propio más largo de ab, que también es un sufijo, es la cadena vacía.
Ejercicio 3.4.3: Construya la función de fallo para las siguientes cadenas:
a) abababaab.
b) aaaaaa.
c) abbaabb.
! Ejercicio 3.4.4: Demuestre, por inducción en s, que el algoritmo de la figura 3.19 calcula en
forma correcta la función de fallo.
!! Ejercicio 3.4.5: Muestre que la asignación t = f (t) en la línea (4) de la figura 3.19 se ejecuta
cuando mucho n veces. Muestre que, por lo tanto, todo el algoritmo requiere sólo un tiempo
O(n) para una palabra clave de longitud n.
Capítulo 3. Análisis léxico
138
Habiendo calculado la función de fallo para una palabra clave b1b2 · · · bn, podemos explorar
una cadena a1a2 · · · am en un tiempo O(m) para saber si hay una ocurrencia de la palabra clave
en la cadena. El algoritmo, que se muestra en la figura 3.20, desliza la palabra clave a lo largo de la cadena, tratando de progresar en busca de la coincidencia del siguiente carácter de la
palabra clave con el siguiente carácter de la cadena. Si no puede hacerlo después de relacionar
s caracteres, entonces “desliza” la palabra clave a la derecha s − f(s) posiciones, de manera
que sólo los primeros f (s) caracteres de la palabra clave se consideren como coincidencias con
la cadena.
1)
2)
3)
4)
5)
6)
s = 0;
for (i = 1; i ≤ m; i++) {
while (s > 0 && ai ! = bs+1) s = f (s);
if (ai == bs+1) s = s + 1;
if (s == n) return “si”;
}
return “no”;
Figura 3.20: El algoritmo KMP evalúa si la cadena a1a2 · · · am contiene una palabra clave
individual b1b2 · · · bn como una subcadena, en un tiempo O(m + n)
Ejercicio 3.4.6: Aplique el algoritmo KMP para evaluar si la palabra clave ababaa es una
subcadena de:
a) abababaab.
b) abababbaa.
!! Ejercicio 3.4.7: Muestre que el algoritmo de la figura 3.20 nos indica en forma correcta si
la palabra clave es una subcadena de la cadena dada. Sugerencia: proceda por inducción en i.
Muestre que para todas las i, el valor de s después de la línea (4) es la longitud del prefijo más
largo de la palabra clave que es un sufijo de a1a2 · · · ai.
!! Ejercicio 3.4.8: Muestre que el algoritmo de la figura 3.20 se ejecuta en un tiempo O(m + n),
suponiendo que la función f ya se ha calculado y que sus valores se almacenaron en un arreglo
indexado por s.
Ejercicio 3.4.9: Las cadenas de Fibonacci se definen de la siguiente manera:
1. s1 = b.
2. s2 = a.
3. sk = sk−1sk−2 para k > 2.
Por ejemplo, s3 = ab, s4 = aba y s5 = abaab.
a) ¿Cuál es la longitud de sn?
139
3.4 Reconocimiento de tokens
b) Construya la función de fallo para s6.
c) Construya la función de fallo para s7.
!! d) Muestre que la función de fallo para cualquier sn puede expresarse mediante f (1) = f (2)
= 0, y que para 2 < j ≤ |sn|, f (j) es j − |sk−1|, en donde k es el entero más largo, de
forma que |sk| ≤ j +1.
!! e) En el algoritmo KMP, ¿cuál es el número más grande de aplicaciones consecutivas de
la función de fallo, cuando tratamos de determinar si la palabra clave sk aparece en la
cadena de texto sk+1?
Aho y Corasick generalizaron el algoritmo KMP para reconocer cualquiera de un conjunto
de palabras clave en una cadena de texto. En este caso el trie es un verdadero árbol, con ramificaciones desde la raíz. Hay un estado para cada cadena que sea un prefijo (no necesariamente
propio) de cualquier palabra clave. El padre de un estado correspondiente a la cadena b1b2 · · ·
bk es el estado que corresponde a b1b2 · · · bk−1. Un estado de aceptación si corresponde a una
palabra clave completa. Por ejemplo, la figura 3.21 muestra el trie para las palabras clave he,
she, his y hers.
h
e
0
r
1
2
s
8
9
i
s
6
s
h
3
7
e
4
5
Figura 3.21: Trie para las palabras clave he, she, his, hers
La función de fallo para el trie general se define de la siguiente forma. Suponga que s es el
estado que corresponde a la cadena b1b2 · · · bn. Entonces, f (s) es el estado que corresponde al
sufijo propio más largo de b1b2 · · · bn que también es un prefijo de alguna palabra clave. Por
ejemplo, la función de fallo para el trie de la figura 3.21 es:
! Ejercicio 3.4.10: Modifique el algoritmo de la figura 3.19 para calcular la función de fallo
para tries generales. Sugerencia: La principal diferencia es que no podemos simplificar la prueba de igualdad o desigualdad de bs+1 y bt+1 en las líneas (4) y (5) de la figura 3.19. En vez de
Capítulo 3. Análisis léxico
140
ello, desde cualquier estado puede haber varias transiciones sobre diversos caracteres, así como
hay transiciones tanto en e como en i desde el estado 1 en la figura 3.21. Cualquiera de esas
transiciones podría conducirnos a un estado que represente el sufijo más largo que sea también
un prefijo.
Ejercicio 3.4.11: Construya los tries y calcule la función de fallo para los siguientes conjuntos
de palabras clave:
a) aaa, abaaa y ababaaa.
b) all, fall, fatal, llama y lame.
c) pipe, pet, item, temper y perpetual.
! Ejercicio 3.4.12: Muestre que su algoritmo del ejercicio 3.4.10 se sigue ejecutando en un
tiempo que es lineal, en la suma de las longitudes de las palabras clave.
3.5
El generador de analizadores léxicos Lex
En esta sección presentaremos una herramienta conocida como Lex, o Flex en una implementación más reciente, que nos permite especificar un analizador léxico mediante la especificación
de expresiones regulares para describir patrones de los tokens. La notación de entrada para la
herramienta Lex se conoce como el lenguaje Lex, y la herramienta en sí es el compilador Lex.
El compilador Lex transforma los patrones de entrada en un diagrama de transición y genera
código, en un archivo llamado lex.yy.c, que simula este diagrama de transición. La mecánica
de cómo ocurre esta traducción de expresiones regulares a diagramas de transición es el tema de
las siguientes secciones; aquí sólo aprenderemos acerca del lenguaje Lex.
3.5.1
Uso de Lex
La figura 3.22 sugiere cómo se utiliza Lex. Un archivo de entrada, al que llamaremos lex.l,
está escrito en el lenguaje Lex y describe el analizador léxico que se va a generar. El compilador
Lex transforma a lex.l en un programa en C, en un archivo que siempre se llama lex.yy.c.
El compilador de C compila este archivo en un archivo llamado a.out, como de costumbre.
La salida del compilador de C es un analizador léxico funcional, que puede recibir un flujo de
caracteres de entrada y producir una cadena de tokens.
El uso normal del programa compilado en C, denominado a.out en la figura 3.22, es como
una subrutina del analizador sintáctico. Es una función en C que devuelve un entero, el cual
representa un código para uno de los posibles nombres de cada token. El valor del atributo,
ya sea otro código numérico, un apuntador a la tabla de símbolos, o nada, se coloca en una
variable global llamada yylval,2 la cual se comparte entre el analizador léxico y el analizador
sintáctico, con lo cual se simplifica el proceso de devolver tanto el nombre como un valor de
atributo de un token.
2
A propósito, la yy que aparece en yylval y lex.yy.c se refiere al generador de analizadores sintácticos Yacc,
que describiremos en la sección 4.9, el cual se utiliza, por lo regular, en conjunto con Lex.
141
3.5 El generador de analizadores léxicos Lex
Programa fuente en Lex
lex.l
Compilador
Lex
lex.yy.c
lex.yy.c
Compilador
de C
a.out
a.out
Flujo de entrada
Secuencia de tokens
Figura 3.22: Creación de un analizador léxico con Lex
3.5.2
Estructura de los programas en Lex
Un programa en Lex tiene la siguiente forma:
declaraciones
%%
reglas de traducción
%%
funciones auxiliares
La sección de declaraciones incluye las declaraciones de variables, constantes de manifiesto
(identificadores que se declaran para representar a una constante; por ejemplo, el nombre de
un token) y definiciones regulares, en el estilo de la sección 3.3.4.
Cada una de las reglas de traducción tiene la siguiente forma:
Patrón
{ Acción }
Cada patrón es una expresión regular, la cual puede usar las definiciones regulares de la sección
de declaraciones. Las acciones son fragmentos de código, por lo general, escritos en C, aunque
se han creado muchas variantes de Lex que utilizan otros lenguajes.
La tercera sección contiene las funciones adicionales que se utilizan en las acciones. De manera alternativa, estas funciones pueden compilarse por separado y cargarse con el analizador
léxico.
El analizador léxico que crea Lex trabaja en conjunto con el analizador sintáctico de la siguiente manera. Cuando el analizador sintáctico llama al analizador léxico, éste empieza a leer
el resto de su entrada, un carácter a la vez, hasta que encuentra el prefijo más largo de la
entrada que coincide con uno de los patrones Pi. Después ejecuta la acción asociada Ai. Por lo
general, Ai regresará al analizador sintáctico, pero si no lo hace (tal vez debido a que Pi describe espacio en blanco o comentarios), entonces el analizador léxico procede a buscar lexemas
adicionales, hasta que una de las acciones correspondientes provoque un retorno al analizador
sintáctico. El analizador léxico devuelve un solo valor, el nombre del token, al analizador sintáctico, pero utiliza la variable entera compartida yylval para pasarle información adicional
sobre el lexema encontrado, si es necesario.
142
Capítulo 3. Análisis léxico
Ejemplo 3.11: La figura 3.23 es un programa en Lex que reconoce los tokens de la figura 3.12
y devuelve el token encontrado. Unas cuantas observaciones acerca de este código nos darán
una idea de varias de las características importantes de Lex.
En la sección de declaraciones vemos un par de llaves especiales, %{ y %}. Cualquier cosa
dentro de ellas se copia directamente al archivo lex.yy.c, y no se trata como definición regular. Es común colocar ahí las definiciones de las constantes de manifiesto, usando instrucciones
#define de C para asociar códigos enteros únicos con cada una de las constantes de manifiesto.
En nuestro ejemplo, hemos listado en un comentario los nombres de las constantes de manifiesto LT, IF, etcétera, pero no las hemos mostrado definidas para ser enteros específicos.3
Además, en la sección de declaraciones hay una secuencia de definiciones regulares. Éstas
utilizan la notación extendida para expresiones regulares que describimos en la sección 3.3.5.
Las definiciones regulares que se utilizan en definiciones posteriores o en los patrones de las
reglas de traducción, van rodeadas de llaves. Así, por ejemplo, delim se define como abreviación
para la clase de caracteres que consiste en el espacio en blanco, el tabulador y el carácter de
nueva línea; estos últimos dos se representan, como en todos los comandos de UNIX, por una
barra diagonal inversa seguida de t o de n, respectivamente. Entonces, ws se define para que
sea uno o más delimitadores, mediante la expresión regular {delim}+.
Observe que en la definición de id y de numero se utilizan paréntesis como meta símbolos
de agrupamiento, por lo que no se representan a sí mismos. En contraste, la E en la definición de
numero se representa a sí misma. Si deseamos usar uno de los meta símbolos de Lex, como
cualquiera de los paréntesis, +, * o ? para que se representen a sí mismos, podemos colocar
una barra diagonal inversa antes de ellos. Por ejemplo, vemos \. en la definición de numero
para representar el punto, ya que ese carácter es un meta símbolo que representa a “cualquier
carácter”, como es costumbre en las expresiones regulares de UNIX.
En la sección de funciones auxiliares vemos dos de esas funciones, instalarID() e ins−
talarNum(). Al igual que la porción de la sección de declaración que aparece entre %{...%},
todo lo que hay en la sección auxiliar se copia directamente al archivo lex.yy.c, pero puede
usarse en las acciones.
Por último, vamos a examinar algunos de los patrones y reglas en la sección media de la
figura 3.23. En primer lugar tenemos a ws, un identificador declarado en la primera sección,
el cual tiene una acción vacía asociada. Si encontramos espacio en blanco, no regresamos al
analizador sintáctico, sino que buscamos otro lexema. El segundo token tiene el patrón de expresión regular simple if. Si vemos las dos letras if en la entrada, y éstas no van seguidas de
otra letra o dígito (que haría que el analizador léxico buscara un prefijo más largo de la entrada
que coincida con el patrón para id), entonces el analizador léxico consume estas dos letras de
la entrada y devuelve el nombre de token IF, es decir, el entero que la constante de manifiesto
IF representa. Las palabras clave then y else se tratan de manera similar.
El quinto token tiene el patrón definido por id. Observe que, aunque las palabras clave como
if coinciden con este patrón así como con un patrón anterior, Lex elije el patrón que aparezca
3
Si se utiliza Lex junto con Yacc, entonces sería normal definir las constantes de manifiesto en el programa Yacc
y usarlas sin definición en el programa Lex. Como lex.yy.c se compila con la salida de Yacc, por consiguiente las
constantes estarían disponibles para las acciones en el programa en Lex.
3.5 El generador de analizadores léxicos Lex
%{
/* definiciones de las constantes de manifiesto
LT, LE, EQ, NE, GT, GE,
IF, THEN, ELSE, ID, NUMERO, OPREL */
%}
/* definiciones regulares */
delim
[ \t\n]
ws
{delim}+
letra
{A−Za−z]
digito
[0−9]
id
{letra}({letra}|{digito})*
numero
{digito}+(\.{digito}+)?(E[+−]?(digito)+)?
%%
{ws}
if
then
else
{id}
{numero}
"<"
"<="
"="
"<>"
">"
">="
{/* no hay accion y no hay retorno */}
{return(IF);}
{return(THEN);}
{return(ELSE);}
{yylval = (int) instalarID(); return(ID);}
{yylval = (int) instalarNum(); return(NUMERO);}
{yylval = LT; return(OPREL);}
{yylval = LE; return(OPREL);}
{yylval = EQ; return(OPREL);}
{yylval = NE; return(OPREL);}
{yylval = GT; return(OPREL);}
{yylval = GE; return(OPREL);}
%%
int instalarID() {/* funcion para instalar el lexema en
la tabla de sı́mbolos y devolver un
apuntador a esto; yytext apunta al
primer caracter y yylent es
la longitud */
}
int instalarNum() {/* similar a instalarID, pero coloca las
constantes numericas en una tabla separada */
}
Figura 3.23: Programa en Lex para los tokens de la figura 3.12
143
Capítulo 3. Análisis léxico
144
primero en la lista, en situaciones en las que el prefijo más largo coincide con dos o más patrones. La acción que se realiza cuando hay una coincidencia con id consiste en tres pasos:
1. Se hace una llamada a la función instalarID() para colocar el lexema encontrado en la
tabla de símbolos.
2. Esta función devuelve un apuntador a la tabla de símbolos, el cual se coloca en la variable global yylval, en donde el analizador sintáctico o un componente posterior del
compilador puedan usarla. Observe que instalarID() tiene dos variables disponibles,
que el analizador léxico generado por Lex establece de manera automática:
(a) yytext es un apuntador al inicio del lexema, análogo a inicioLexema en la figura 3.3.
(b) yyleng es la longitud del lexema encontrado.
3. El nombre de token ID se devuelve al analizador sintáctico.
La acción que se lleva a cabo cuando un lexema que coincide con el patrón numero es similar,
sólo que se usa la función auxiliar instalarNum().
2
3.5.3
Resolución de conflictos en Lex
Nos hemos referido a las dos reglas que utiliza Lex para decidir acerca del lexema apropiado a
seleccionar, cuando varios prefijos de la entrada coinciden con uno o más patrones:
1. Preferir siempre un prefijo más largo a uno más corto.
2. Si el prefijo más largo posible coincide con dos o más patrones, preferir el patrón que se
lista primero en el programa en Lex.
Ejemplo 3.12: La primera regla nos dice que debemos continuar leyendo letras y dígitos para
buscar el prefijo más largo de estos caracteres y agruparlos como un identificador. También nos
dice que debemos tratar a <= como un solo lexema, en vez de seleccionar < como un lexema y =
como el siguiente lexema. La segunda regla hace a las palabras clave reservadas, si listamos las
palabras clave antes que id en el programa. Por ejemplo, si se determina que then es el prefijo
más largo de la entrada que coincide con algún patrón, y el patrón then va antes que {id},
como en la figura 3.23, entonces se devuelve el token THEN, en vez de ID.
2
3.5.4
El operador adelantado
Lex lee de manera automática un carácter adelante del último carácter que forma el lexema
seleccionado, y después regresa la entrada para que sólo se consuma el propio lexema de
la entrada. No obstante, algunas veces puede ser conveniente que cierto patrón coincida con la
entrada, sólo cuando vaya seguido de ciertos caracteres más. De ser así, tal vez podamos utilizar la barra diagonal en un patrón para indicar el final de la parte del patrón que coincide
3.5 El generador de analizadores léxicos Lex
145
con el lexema. Lo que va después de / es un patrón adicional que se debe relacionar antes de
poder decidir que vimos el token en cuestión, pero que lo que coincide con este segundo patrón
no forma parte del lexema.
Ejemplo 3.13: En Fortran y en algunos otros lenguajes, las palabras clave no son reservadas.
Esa situación crea problemas, como por ejemplo la siguiente instrucción:
IF(I,J) = 3
en donde IF es el nombre de un arreglo, no una palabra clave. Esta instrucción contrasta con
las instrucciones de la forma
IF( condición ) THEN ...
en donde IF es una palabra clave. Por fortuna, podemos asegurarnos de que la palabra clave IF
siempre vaya seguida de un paréntesis izquierdo, cierto texto (la condición) que puede contener
paréntesis, un paréntesis derecho y una letra. Así, podríamos escribir una regla de Lex para la
palabra clave IF, como:
IF / \( .* \) {letra}
Esta regla dice que el patrón con el que coincide el lexema es sólo las dos letras IF. La barra
diagonal dice que le sigue un patrón adicional, pero que no coincide con el lexema. En este
patrón, el primer carácter es el paréntesis izquierdo. Como ese carácter es un meta símbolo
de Lex, debe ir precedido por una barra diagonal inversa para indicar que tiene su significado
literal. El punto y el asterisco coinciden con “cualquier cadena sin un carácter de nueva línea”.
Observe que el punto es un meta símbolo de Lex que significa “cualquier carácter excepto nueva línea”. Va seguido de un paréntesis derecho, de nuevo con una barra diagonal inversa para
dar a ese carácter su significado literal. El patrón adicional va seguido por el símbolo letra, que
es una definición regular que representa a la clase de caracteres de todas las letras.
Observe que, para que este patrón pueda ser a prueba de errores, debemos preprocesar la
entrada para eliminar el espacio en blanco. No hay provisión en el patrón para el espacio en
blanco, ni podemos tratar con la posibilidad de que la condición se extienda en varias líneas,
ya que el punto no coincidirá con un carácter de nueva línea.
Por ejemplo, suponga que a este patrón se le pide que coincida con un prefijo de entrada:
IF(A<(B+C)*D)THEN...
los primeros dos caracteres coinciden con IF, el siguiente carácter coincide con \(, los siguiente nueve caracteres coinciden con .*, y los siguientes dos coinciden con \) y letra. Observar
el hecho de que el primer paréntesis derecho (después de C) no vaya seguido de una letra es
irrelevante; sólo necesitamos encontrar alguna manera de hacer que la entrada coincida con el
patrón. Concluimos que las letras IF constituyen el lexema, y que son una instancia del token
if.
2
Capítulo 3. Análisis léxico
146
3.5.5
Ejercicios para la sección 3.5
Ejercicio 3.5.1: Describa cómo hacer las siguientes modificaciones al programa en Lex de la
figura 3.23:
a) Agregar la palabra clave while.
b) Cambiar los operadores de comparación para que sean los operadores en C de ese
tipo.
c) Permitir el guión bajo (_) como una letra adicional.
! d) Agregar un nuevo patrón con el token STRING. El patrón consiste en una doble comilla
("), cualquier cadena de caracteres y una doble comilla final. No obstante, si aparece
una doble comilla en la cadena debe ser carácter de escape, para lo cual le anteponemos una barra diagonal inversa (\) y, por lo tanto, una barra diagonal inversa en la
cadena debe representarse mediante dos barras diagonales inversas. El valor léxico es
la cadena sin las dobles comillas circundantes, y sin las barras diagonales inversas que se
usan como carácter de escape. Las cadenas deben instalarse en una tabla de cadenas.
Ejercicio 3.5.2: Escriba un programa en Lex que copie un archivo, sustituyendo cada secuencia no vacía de espacios en blanco por un solo espacio.
Ejercicio 3.5.3: Escriba un programa en Lex que copie un programa en C, sustituyendo cada
instancia de la palabra clave float por double.
! Ejercicio 3.5.4: Escriba un programa en Lex que convierta un archivo a “Pig latín”. En específico, suponga que el archivo es una secuencia de palabras (grupos de letras) separadas por
espacio en blanco. Cada vez que se encuentre con una palabra:
1. Si la primera letra es una consonante, desplácela hasta el final de la palabra y después
agregue ay.
2. Si la primera letra es una vocal, sólo agregue ay al final de la palabra.
Todos los caracteres que no sean letras deben copiarse intactos a la salida.
! Ejercicio 3.5.5: En SQL, las palabras clave y los identificadores son sensibles a mayúsculas
y minúsculas. Escriba un programa en Lex que reconozca las palabras clave SELECT, FROM y
WHERE (en cualquier combinación de letras mayúsculas y minúsculas), y el token ID, que para
los fines de este ejercicio puede considerar como cualquier secuencia de letras y dígitos, empezando con una letra. No tiene que instalar los identificadores en una tabla de símbolos, pero
debe indicar cuál sería la diferencia en la función “install” de la que describimos para los identificadores sensibles a mayúsculas y minúsculas, como en la figura 3.23.
3.6 Autómatas finitos
3.6
147
Autómatas finitos
Ahora vamos a descubrir cómo Lex convierte su programa de entrada en un analizador léxico.
En el corazón de la transición se encuentra el formalismo conocido como autómatas finitos. En
esencia, estos consisten en gráfos como los diagramas de transición de estados, con algunas
diferencias:
1. Los autómatas finitos son reconocedores; sólo dicen “sí” o “no” en relación con cada posible cadena de entrada.
2. Los autómatas finitos pueden ser de dos tipos:
(a) Los autómatas finitos no deterministas (AFN) no tienen restricciones en cuanto a
las etiquetas de sus líneas. Un símbolo puede etiquetar a varias líneas que surgen del
mismo estado, y , la cadena vacía, es una posible etiqueta.
(b) Los autómatas finitos deterministas (AFD) tienen, para cada estado, y para cada
símbolo de su alfabeto de entrada, exactamente una línea con ese símbolo que sale
de ese estado.
Tanto los autómatas finitos deterministas como los no deterministas son capaces de reconocer los mismos lenguajes. De hecho, estos lenguajes son exactamente los mismos lenguajes,
conocidos como lenguajes regulares, que pueden describir las expresiones regulares.4
3.6.1
Autómatas finitos no deterministas
Un autómata finito no determinista (AFN) consiste en:
1. Un conjunto finito de estados S.
2. Un conjunto de símbolos de entrada Σ, el alfabeto de entrada. Suponemos que , que
representa a la cadena vacía, nunca será miembro de Σ.
3. Una función de transición que proporciona, para cada estado y para cada símbolo en Σ
∪ {}, un conjunto de estados siguientes.
4. Un estado s0 de S, que se distingue como el estado inicial.
5. Un conjunto de estados F, un subconjunto de S, que se distinguen como los estados aceptantes (o estados finales).
Podemos representar un AFN o AFD mediante un gráfico de transición, en donde los nodos
son estados y los flancos Indecidibles representan a la función de transición. Hay un flanco
Indecidible a, que va del estado s al estado t si, y sólo si t es uno de los estados siguientes para
el estado s y la entrada a. Este gráfico es muy parecido a un diagrama de transición, excepto
que:
4
Hay una pequeña laguna: según la definición que les dimos, las expresiones regulares no pueden describir el
lenguaje vacío, ya que no es conveniente utilizar este patrón en la práctica. No obstante, los autómatas finitos pueden
definir el lenguaje vacío. En teoría, ∅ se trata como una expresión regular adicional, para el único fin de definir el
lenguaje vacío.
Capítulo 3. Análisis léxico
148
a) El mismo símbolo puede etiquetar flancos de un estado hacia varios estados distintos.
b) Un flanco puede etiquetarse por , la cadena vacía, en vez de, o además de, los símbolos
del alfabeto de entrada.
Ejemplo 3.14: El gráfo de transición para un AFN que reconoce el lenguaje de la expresión
regular (a|b)*abb se muestra en la figura 3.24. Utilizaremos este ejemplo abstracto, que
describe a todas las cadenas de as y bs que terminan en la cadena específica abb, a lo largo de
esta sección. No obstante, es similar a las expresiones regulares que describen lenguajes
de verdadero interés. Por ejemplo, una expresión que describe a todos los archivos cuyo nombre termina en .o es cualquiera*.o, en donde cualquiera representa a cualquier carácter
imprimible.
a
inicio
0
a
1
b
2
b
3
b
Figura 3.24: Un autómata finito no determinista
Siguiendo nuestra convención para los diagramas de transición, el doble círculo alrededor
del estado 3 indica que este estado es de estados. Observe que la única forma de llegar del
estado inicial 0 al estado de aceptación es seguir cierto camino que permanezca en el estado 0
durante cierto tiempo, y después vaya a los estados 1, 2 y 3 leyendo abb de la entrada. Por ende,
las únicas cadenas que llegan al estado de aceptación son las que terminan en abb.
2
3.6.2
Tablas de transición
También podemos representar a un AFN mediante una tabla de transición, cuyas filas corresponden a los estados, y cuyas columnas corresponden a los símbolos de entrada y a . La entrada para un estado dado y la entrada es el valor de la función de transición que se aplica a esos
argumentos. Si la función de transición no tiene información acerca de ese par estado-entrada,
colocamos ∅ en la tabla para ese estado.
Ejemplo 3.15: La tabla de transición para el AFN de la figura 3.24 se muestra en la figura
3.25. 2
La tabla de transición tiene la ventaja de que podemos encontrar con facilidad las transiciones en cierto estado y la entrada. Su desventaja es que ocupa mucho espacio, cuando el alfabeto
de entrada es extenso, aunque muchos de los estados no tengan movimientos en la mayoría de
los símbolos de entrada.
149
3.6 Autómatas finitos
ESTADO
Figura 3.25: Tabla de transición para el AFN de la figura 3.24
3.6.3
Aceptación de las cadenas de entrada mediante
los autómatas
Un AFN acepta la cadena de entrada x si, y sólo si hay algún camino en el grafo de transición,
desde el estado inicial hasta uno de los estados de aceptación, de forma que los símbolos a lo
largo del camino deletreen a x. Observe que las etiquetas a lo largo del camino se ignoran, ya
que la cadena vacía no contribuye a la cadena que se construye a lo largo del camino.
Ejemplo 3.16: El AFN de la figura 3.24 acepta la cadena aabb. El camino etiquetado por
aabb del estado 0 al estado 3 que demuestra este hecho es:
0
a
0
a
1
b
2
b
3
Observe que varios caminos etiquetadas por la misma cadena pueden conducir hacia estados
distintos. Por ejemplo, el camino
0
a
0
a
0
b
0
b
0
es otro camino que parte del estado 0, etiquetada por la cadena aabb. Este camino conduce al
estado 0, que no de aceptación. Sin embargo, recuerde que un AFN acepta a una cadena siempre y cuando haya cierto camino etiquetado por esa cadena, que conduzca del estado inicial
a un estado de aceptación. Es irrelevante la existencia de otros caminos que conduzcan a un
estado de no aceptación.
2
El lenguaje definido (o aceptado) por un AFN es el conjunto de cadenas que etiquetan cierto
camino, del estado inicial a un estado de aceptación. Como mencionamos antes, el AFN de la
figura 3.24 define el mismo lenguaje que la expresión regular (a|b)*abb; es decir, todas las cadenas del alfabeto {a, b} que terminen en abb. Podemos usar L(A) para representar el lenguaje
aceptado por el autómata A.
Ejemplo 3.17: La figura 3.26 es un AFN que acepta a L(aa*|bb*). La cadena aaa se acepta
debido a el camino.
0
ε
1
a
2
a
2
a
2
Observe que las s “desaparecen” en una concatenación, por lo que la etiqueta del camino es
aaa.
2
3.6.4
Autómatas finitos deterministas
Un autómata finito determinista (AFD) es un caso especial de un AFN, en donde:
Capítulo 3. Análisis léxico
150
a
ε
1
ε
3
a
2
inicio
0
b
4
b
Figura 3.26: AFN que acepta a aa*|bb*
1. No hay movimientos en la entrada .
2. Para cada estado s y cada símbolo de entrada a, hay exactamente una línea que surge de
s, Indecidible como a.
Si utilizamos una tabla de transición para representar a un AFD, entonces cada entrada es un
solo estado. Por ende, podemos representar a este estado sin las llaves que usamos para formar
los conjuntos.
Mientras que el AFN es una representación abstracta de un algoritmo para reconocer las
cadenas de cierto lenguaje, el AFD es un algoritmo simple y concreto para reconocer cadenas.
Sin duda es afortunado que cada expresión regular y cada AFN puedan convertirse en un AFD
que acepte el mismo lenguaje, ya que es el AFD el que en realidad implementamos o simulamos
al construir analizadores léxicos. El siguiente algoritmo muestra cómo aplicar un AFD a una
cadena.
Algoritmo 3.18: Simulación de un AFD.
ENTRADA: Una cadena de entrada x, que se termina con un carácter de fin de archivo eof.
Un AFD D con el estado inicial s 0, que acepta estados F, y la función de transición mover.
SALIDA: Responde “sí” en caso de que D acepte a x; “no” en caso contrario.
MÉTODO: Aplicar el algoritmo de la figura 3.27 a la cadena de entrada x. La función mover(s,c)
proporciona el estado para el cual hay un flanco desde el estado s sobre la entrada c. La función
sigCar devuelve el siguiente carácter de la cadena de entrada x. 2
Ejemplo 3.19: En la figura 3.28 vemos el grafo de transición de un AFD que acepta el lenguaje (a|b)*abb, el mismo que acepta el AFN de la figura 3.24. Dada la cadena de entrada ababb,
este AFD introduce la secuencia de estados 0, 1, 2, 1, 2, 3 y devuelve “si”.
2
151
3.6 Autómatas finitos
s = s 0;
c = sigCar();
while ( c != eof ) {
s = mover(s, c);
c = sigCar();
}
if ( s está en F ) return "si";
else return "no";
Figura 3.27: Simulación de un AFD
b
b
inicio
0
a
1
b
2
b
3
a
a
a
Figura 3.28: AFD que acepta a (a|b)*abb
3.6.5
Ejercicios para la sección 3.6
! Ejercicio 3.6.1: La figura 3.19 en los ejercicios de la sección 3.4 calcula la función de fallo
para el algoritmo KMP. Muestre cómo, dada esa función de fallo, podemos construir, a partir
de una palabra clave b1b2 · · · bn, un AFD de n + 1 estados que reconozca a .*b1b2 · · · bn, en
donde el punto representa a “cualquier carácter”. Además, este AFD puede construirse en un
tiempo O(n).
Ejercicio 3.6.2: Diseñe autómatas finitos (deterministas o no) para cada uno de los lenguajes
del ejercicio 3.3.5.
Ejercicio 3.6.3: Para el AFN de la figura 3.29, indique todos los caminos etiquetadas como
aabb. ¿El AFN acepta a aabb?
ε
inicio
0
a, b
a
1
a, b
a
2
b
a, b
Figura 3.29: AFN para el ejercicio 3.6.3
3
Capítulo 3. Análisis léxico
152
ε
inicio
0
a
ε
1
b
ε
2
b
3
ε
a
Figura 3.30: AFN para el ejercicio 3.6.4
Ejercicio 3.6.4: Repita el ejercicio 3.6.3 para el AFN de la figura 3.30.
Ejercicio 3.6.5: Proporcione las tablas de transición para el AFN de:
a) El ejercicio 3.6.3.
b) El ejercicio 3.6.4.
c) La figura 3.26.
3.7
De las expresiones regulares a los autómatas
La expresión regular es la notación de elección para describir analizadores léxicos y demás
software de procesamiento de patrones, como se vio en la sección 3.5. No obstante, la implementación de ese software requiere la simulación de un AFD, como en el algoritmo 3.18, o
tal vez la simulación de un AFN. Como, por lo general, un AFN tiene la opción de moverse
sobre un símbolo de entrada (como lo hace la figura 3.24, sobre la entrada a desde el estado 0)
o sobre (como lo hace la figura 3.26 desde el estado 0), o incluso la opción de realizar una
transición sobre o sobre un símbolo de entrada real, su simulación es menos simple que la de
un AFD. Por ende, con frecuencia es importante convertir un AFN a un AFD que acepte el
mismo lenguaje.
En esta sección veremos primero cómo convertir AFNs a AFDs. Después utilizaremos esta
técnica, conocida como “la construcción de subconjuntos”, para producir un algoritmo útil
para simular a los AFNs de forma directa, en situaciones (aparte del análisis léxico) en donde
la conversión de AFN a AFD requiere más tiempo que la simulación directa. Después, mostraremos cómo convertir las expresiones regulares en AFNs, a partir de lo cual puede construir un
AFD, si lo desea. Concluiremos con una discusión de las concesiones entre tiempo y espacio,
inherentes en los diversos métodos para implementar expresiones regulares, y veremos cómo
puede elegir el método apropiado para su aplicación.
3.7.1
Conversión de un AFN a AFD
La idea general de la construcción de subconjuntos es que cada estado del AFD construido
corresponde a un conjunto de estados del AFN. Después de leer la entrada a1a2 · · · an, el AFD
153
3.7 De las expresiones regulares a los autómatas
se encuentra en el estado que corresponde al conjunto de estados que el AFN puede alcanzar,
desde su estado inicial, siguiendo los caminos etiquetados como a1a2 · · · an.
Es posible que el número de estados del AFD sea exponencial en el número de estados del
AFN, lo cual podría provocar dificultades al tratar de implementar este AFD. No obstante,
parte del poder del método basado en autómatas para el análisis léxico es que para los lenguajes reales, el AFN y el AFD tienen aproximadamente el mismo número de estados, y no se ve
el comportamiento exponencial.
Algoritmo 3.20: La construcción de subconjuntos de un AFD, a partir de un AFN.
ENTRADA: Un AFN N.
SALIDA: Un AFD D que acepta el mismo lenguaje que N.
MÉTODO: Nuestro algoritmo construye una tabla de transición Dtran para D. Cada estado
de D es un conjunto de estados del AFN, y construimos Dtran para que D pueda simular “en
paralelo” todos los posibles movimientos que N puede realizar sobre una cadena de entrada
dada. Nuestro primer problema es manejar las transiciones de N en forma apropiada. En
la figura 3.31 vemos las definiciones de varias funciones que describen cálculos básicos en los
estados de N que son necesarios en el algoritmo. Observe que s es un estado individual de N,
mientras que T es un conjunto de estados de N.
OPERACIÓN
DESCRIPCIÓN
-cerradura (s )
Conjunto de estados del AFN a los que se puede llegar desde el estado s
del AFN, sólo en las transiciones .
Conjunto de estados del AFN a los que se puede llegar desde cierto
estado s del AFN en el conjunto T, sólo en las transiciones ; = ∪s en T
-cerradura(s).
Conjunto de estados del AFN para los cuales hay una transición sobre el
símbolo de entrada a, a partir de cierto estado s en T.
-cerradura (T )
mover (T, a )
Figura 3.31: Operaciones sobre los estados del AFN
Debemos explorar esos conjuntos de estados en los que puede estar N después de ver cierta
cadena de entrada. Como base, antes de leer el primer símbolo de entrada, N puede estar en
cualquiera de los estados de -cerradura(s 0), en donde s 0 es su estado inicial. Para la inducción,
suponga que N puede estar en el conjunto de estados T después de leer la cadena de entrada x.
Si a continuación lee la entrada a, entonces N puede pasar de inmediato a cualquiera de los estados en mover(T, a). No obstante, después de leer a también podría realizar varias transiciones ; por lo tanto, N podría estar en cualquier estado de -cerradura(mover(T, a)) después de
leer la entrada xa. Siguiendo estas ideas, la construcción del conjunto de estados de D, Destados, y su función de transición Dtran, se muestran en la figura 3.32.
El estado inicial de D es -cerradura(s 0), y los estados de aceptación de D son todos aquellos conjuntos de estados de N que incluyen cuando menos un estado de aceptación de N. Para
Capítulo 3. Análisis léxico
154
completar nuestra descripción de la construcción de subconjuntos, sólo necesitamos mostrar cómo al principio, -cerradura(s 0) es el único estado en Destados, y está sin marcar:
while ( hay un estado sin marcar T en Destados ) {
marcar T;
for ( cada símbolo de entrada a ) {
U = -cerradura(mover(T, a));
if ( U no está en Destados )
agregar U como estado sin marcar a Destados;
Dtran[T, a] = U;
}
}
Figura 3.32: La construcción de subconjuntos
-cerradura(T ) se calcula para cualquier conjunto de estados T del AFN. Este proceso, que se
muestra en la figura 3.33, es una búsqueda simple y directa en un gráfo, a partir de un conjunto
de estados. En este caso, imagine que sólo están disponibles las líneas Indecidibles como en
el gráfico.
2
meter todos los estados de T en pila;
inicializar -cerradura(T ) con T;
while ( pila no está vacía ) {
sacar t, el elemento superior, de la pila;
for ( cada estado u con un flanco de t a u, Indecidible como )
if ( u no está en -cerradura(T ) ) {
agregar u a -cerradura(T );
meter u en la pila;
}
}
Figura 3.33: Cálculo de -cerradura(T )
Ejemplo 3.21: La figura 3.34 muestra a otro AFN que acepta a (a|b)*abb; resulta que es el
que vamos a construir directamente a partir de esta expresión regular en la sección 3.7. Vamos
a aplicar el Algoritmo 3.20 a la figura 3.29.
El estado inicial A del AFD equivalente es -cerradura(0), o A = {0, 1, 2, 4, 7}, ya que éstos
son los mismos estados a los que se puede llegar desde el estado 0, a través de un camino cuyas
líneas tienen todos la etiqueta . Observe que un camino pueda tener cero líneas, por lo que se
puede llegar al estado 0 desde sí mismo, mediante un camino etiquetada como .
El alfabeto de entrada es {a, b}. Por ende, nuestro primer paso es marcar A y calcular
Dtran[A, a] = -cerradura(mover(A, a)) y Dtran[A, b] = -cerradura (mover(A, b)). De los
estados 0, 1, 2, 4 y 7, sólo 2 y 7 tienen transiciones sobre a, hacia 3 y 8, respectivamente.
Por ende, mover(A, a) = {3, 8}. Además, -cerradura ({3, 8}) = {1, 2, 3, 4, 6, 7, 8}, por lo
cual concluimos que:
155
3.7 De las expresiones regulares a los autómatas
ε
2
inicio
0
ε
a
3
ε
1
ε
ε
ε
4
b
6
ε
7
a
8
b
9
b
10
5
ε
Figura 3.34: El AFN N para (a|b)*abb
Dtran[A, a] = -cerradura(mover(A, a)) = -cerradura({3, 8}) = {1, 2, 3, 4, 6, 7, 8}
Vamos a llamar a este conjunto B, de manera que Dtran[A, a] = B.
Ahora, debemos calcular Dtran[A, b]. De los estados en A sólo el 4 tiene una transición sobre
b, y va al estado 5. Por ende,
Dtran[A, b] = -cerradura({5}) = {1, 2, 4, 6, 7}
Vamos a llamar al conjunto anterior C, de manera que Dtran[A, b] = C.
ESTADO DEL AFN
{0, 1, 2, 4, 7}
{1, 2, 3, 4, 6, 7, 8}
{1, 2, 4, 5, 6, 7}
{1, 2, 4, 5, 6, 7, 9}
{1, 2, 3, 5, 6, 7, 10}
ESTADO DEL AFD
A
B
C
D
E
a
B
B
B
B
B
b
C
D
C
E
C
Figura 3.35: Tabla de transición Dtran para el AFD D
Si continuamos este proceso con los conjuntos desmarcados B y C, en un momento dado
llegaremos a un punto en el que todos los estados del AFD estén marcados. Esta conclusión se
garantiza, ya que “sólo” hay 211 subconjuntos distintos de un conjunto de once estados de un
AFN. Los cinco estados distintos del AFD que realmente construimos, sus correspondientes
conjuntos de estados del AFN, y la tabla de transición para el AFD D se muestran en la figura
3.35, y el gráfico de transición para D está en la figura 3.36. El estado A es el estado inicial, y
el estado E, que contiene el estado 10 del AFN, es el único estado de aceptación.
Observe que D tiene un estado más que el AFD de la figura 3.28 para el mismo lenguaje.
Los estados A y C tienen la misma función de movimiento, por lo cual pueden combinarse. En
la sección 3.9.6 hablaremos sobre la cuestión de reducir al mínimo el número de estados de un
AFD.
2
Capítulo 3. Análisis léxico
156
b
C
b
inicio
A
b
a
a
B
b
D
b
E
a
a
a
Figura 3.36: Resultado de aplicar la construcción de subconjuntos a la figura 3.34
3.7.2
Simulación de un AFN
Una estrategia que se ha utilizado en varios programas de edición de texto es la de construir
un AFN a partir de una expresión regular, y después simular el AFN utilizando algo como una
construcción de subconjuntos “sobre la marcha”. La simulación se describe a continuación.
Algoritmo 3.22: Simulación de un AFN.
ENTRADA: Una cadena de entrada x que termina con un carácter de fin de línea eof. Un
AFN N con el estado inicial s 0, que acepta estados F, y la función de transición mover.
SALIDA: Responde “sí” en caso de que M acepte a x; “no” en caso contrario.
MÉTODO: El algoritmo mantiene un conjunto de estados actuales S, aquellos a los que se
llega desde s 0 siguiendo un camino etiquetado por las entradas leídas hasta ese momento. Si c
es el siguiente carácter de entrada leído por la función sigCar(), entonces primero calculamos
mover(S, c) y después cerramos ese conjunto usando -cerradura(). En la figura 3.37 se muestra
un bosquejo del algoritmo.
2
1)
2)
3)
4)
5)
6)
7)
8)
S = -cerradura(s 0);
c = sigCar();
while ( c != eof ) {
S = -cerradura(mover(S,c));
c = sigCar();
}
if ( S ∩ F != ∅ ) return "sı́";
else return "no";
Figura 3.37: Simulación de un AFN
3.7 De las expresiones regulares a los autómatas
3.7.3
157
Eficiencia de la simulación de un AFN
Si se implementa con cuidado, el algoritmo 3.22 puede ser bastante eficiente. Como las ideas
implicadas son útiles en una variedad de algoritmos similares que involucran la búsqueda de
gráficos, veremos esta implementación con más detalle. Las estructuras de datos que necesitamos son:
1. Dos pilas, cada una de las cuales contiene un conjunto de estados del AFN. Una de estas
pilas, estadosAnt, contiene el conjunto “actual” de estados; es decir, el valor de S del lado
derecho de la línea (4) en la figura 3.37. La segunda, estadosNuev, contiene el “siguiente”
conjunto de estados: S del lado izquierdo de la línea (4). Hay un paso que no se ve en
el que, a medida que avanzamos por el ciclo de las líneas (3) a la (6), estadosNuev se
transfiere a estadosAnt.
2. Un arreglo booleano yaEstaEn, indexado por los estados del AFN, para indicar cuáles
estados ya están en estadosNuev. Aunque el arreglo y la pila contienen la misma información, es mucho más rápido interrogar a yaEstaEn[s] que buscar el estado s en la pila
estadosNuev. Es por eficiencia que mantenemos ambas representaciones.
3. Un arreglo bidimensional mover[s, a] que contiene la tabla de transición del AFN. Las
entradas en esta tabla, que son conjuntos de estados, se representan mediante listas enlazadas.
Para implementar la línea (1) de la figura 3.37, debemos establecer cada entrada en el
arreglo yaEstaEn a FALSE, después para cada estado s en -cerradura(s 0), hay que meter s en
estadosAnt y yaEstaEn[s] a TRUE. Esta operación sobre el estado s, y la implementación de
la línea (4) también, se facilitan mediante una función a la que llamaremos agregarEstado(s).
Esta función mete el estado s en estadosNuev, establece yaEstaEn[s] a TRUE, y se llama a sí
misma en forma recursiva sobre los estados en mover[s, ] para poder ampliar el cálculo de
-cerradura(s). Sin embargo, para evitar duplicar el trabajo, debemos tener cuidado de nunca
llamar a agregarEstado en un estado que ya se encuentre en la pila estadosNuev. La figura 3.38
muestra un bosquejo de esta función.
9)
10)
11)
12)
13)
14)
15)
agregarEstado(s) {
meter s en estadosNuev;
yaEstaEn[s] = TRUE;
for ( t en mover[s, ] )
if ( !yaEstaEn(t) )
agregarEstado(t);
}
Figura 3.38: Agregar un nuevo estado s, que sabemos no se encuentra en estadosNuev
Para implementar la línea (4) de la figura 3.37, analizamos cada estado s en estadosAnt.
Primero buscamos el conjunto de estados mover[s, c], en donde c es la siguiente entrada, y para
Capítulo 3. Análisis léxico
158
cada uno de esos estados que no se encuentren ya en estadosNuev, le aplicamos agregarEstado. Observe que agregarEstado tiene el efecto de calcular -cerradura y de agregar todos esos
estados a estadosNuev también, si no se encontraban ya agregados. Esta secuencia de pasos se
resume en la figura 3.39.
16)
17)
18)
19)
20)
21)
for ( s en estadosAnt ) {
for ( t en mover[s, c] )
if ( !yaEstaEn[t] )
agregarEstado(t);
sacar s de estadosAnt;
}
22)
23)
24)
25)
26)
for (s en estadosNuev ) {
sacar s de estadosNuev;
meter s a estadosAnt;
yaEstaEn[s] = FALSE;
}
Figura 3.39: Implementación del paso (4) de la figura 3.37
Ahora, suponga que el AFN N tiene n estados y m transiciones; es decir, m es la suma de
todos los estados del número de símbolos (o ) sobre los cuales el estado tiene una transición
de salida. Sin contar la llamada a agregarEstado en la línea (19) de la figura 3.39, el tiempo
invertido en el ciclo de las líneas (16) a (21) es O(n). Es decir, podemos recorrer el ciclo cuando mucho n veces, y cada paso del ciclo requiere un trabajo constante, excepto por el tiempo
invertido en agregarEstado. Lo mismo se aplica al ciclo de las líneas (22) a la (26).
Durante una ejecución de la figura 3.39, es decir, del paso (4) de la figura 3.37, sólo es
posible llamar una vez a agregarEstado sobre un estado dado. La razón es que, cada vez que
llamamos a agregarEstado(s), establecemos yaEstaEn[s] a TRUE en la línea 11 de la figura 3.39.
Una vez que yaEstaEn[s] es TRUE, las pruebas en la línea (13) de la figura 3.38, y la línea (18)
de la figura 3.39 evitan otra llamada.
El tiempo invertido en una llamada a agregarEstado, exclusivo del tiempo invertido en las
llamadas recursivas en la línea (14), es O(1) para las líneas (10) y (11). Para las líneas (12) y
(13), el tiempo depende de cuántas transiciones haya al salir del estado s. No conocemos este
número para un estado dado, pero sabemos que hay cuando menos m transiciones en total, saliendo de todos los estados. Como resultado, el tiempo adicional invertido en las líneas (11) de
todas las llamadas a agregarEstado durante una ejecución del código de la figura 3.39 es O(m).
El agregado para el resto de los pasos de agregarEstado es O(n), ya que es una constante por
llamada, y hay cuando menos n llamadas.
Concluimos que, si se implementa en forma apropiada, el tiempo para ejecutar la línea (4)
de la figura 3.37 es O(n + m). El resto del ciclo while de las líneas (3) a la (6) requiere de un
tiempo O(1) por iteración. Si la entrada x es de longitud k, entonces el trabajo total en ese
ciclo es O((k (n + m)). La línea (1) de la figura 3.37 puede ejecutarse en un tiempo O(n + m),
159
3.7 De las expresiones regulares a los autómatas
Notación O grande (Big-Oh)
Una expresión como O(n) es una abreviación para “cuando menos ciertos tiempos n constantes”. Técnicamente, decimos que una función f (n), tal vez el tiempo de ejecución de
algún paso de un algoritmo, es O(g(n)) si hay constantes c y n0, de tal forma que cada vez
que n ≥ n0, es cierto que f (n) ≤ cg(n). Un modismo útil es “O(1)”, que significa “alguna
constante”. El uso de esta notación Big-Oh nos permite evitar profundizar demasiado en
los detalles acerca de lo que contamos como unidad de tiempo de ejecución, y aún así nos
permite expresar la velocidad a la cual crece el tiempo de ejecución de un algoritmo.
ya que en esencia consta de los pasos de la figura 3.39, en donde estadosAnt sólo contienen el
estado s 0. Las líneas (2), (7) y (8) requieren de un tiempo O(1) cada una. Por ende, el tiempo
de ejecución del Algoritmo 3.22, si se implementa en forma apropiada, es O((k(n + m)). Es decir, el tiempo requerido es proporcional a la longitud de la entrada multiplicada por el tamaño
(nodos más líneas) del gráfo de transición.
3.7.4
Construcción de un AFN a partir de una expresión regular
Ahora le proporcionaremos un algoritmo para convertir cualquier expresión regular en un AFN
que defina el mismo lenguaje. El algoritmo está orientado a la sintaxis, ya que recorre en forma
recursiva hacia arriba el árbol sintáctico para la expresión regular. Para cada subexpresión, el
algoritmo construye un AFN con un solo estado aceptante.
Algoritmo 3.23: El algoritmo de McNaughton-Yamada-Thompson para convertir una expresión regular en un AFN.
ENTRADA: Una expresión regular r sobre el alfabeto Σ.
SALIDA: Un AFN N que acepta a L(r).
MÉTODO: Empezar por un análisis sintáctico de r para obtener las subexpresiones que la
constituyen. Las reglas para construir un AFN consisten en reglas básicas para el manejo de
subexpresiones sin operadores, y reglas inductivas para construir AFNs más largos, a partir
de los AFNs para las subexpresiones inmediatas de una expresión dada.
BASE: Para la expresión , se construye el siguiente AFN:
inicio
ε
i
f
Aquí, i es un nuevo estado, el estado inicial de este AFN, y f es otro nuevo estado, el estado
aceptante para el AFN.
Para cualquier subexpresión a en Σ, se construye el siguiente AFN:
inicio
i
a
f
Capítulo 3. Análisis léxico
160
en donde otra vez i y f son nuevos estados, los estados inicial y de aceptación, respectivamente.
Observe que en ambas construcciones básicas, construimos un AFN distinto, con nuevos estados, para cada ocurrencia de o alguna a como subexpresión de r.
INDUCCIÓN: Suponga que N(s) y N(t) son AFNs para las expresiones regulares s y t, respec-
tivamente.
a) Suponga que r = s|t. Entonces N(r), el AFN para r, se construye como en la figura 3.40.
Aquí, i y f son nuevos estados, los estados inicial y de aceptación de N(r), respectivamente. Hay transiciones desde i hasta los estados iniciales de N(s) y N(t), y cada uno
de los estados de aceptación tiene transiciones hacia el estado de aceptación f. Observe
que los estados aceptantes de N(s) y N(t) no son de aceptación en N(r). Como cualquier
camino de i a f debe pasar por N(s) o N(t) exclusivamente, y como la etiqueta de ese camino no se modifica por el hecho de que las s salen de i o entran a f, concluimos que
N(r) acepta a L(s) ∪ L(t), que es lo mismo que L(r). Es decir, la figura 3.40 es una construcción correcta para el operador de unión.
N (s)
ε
ε
inicio
i
f
ε
ε
N (t )
Figura 3.40: AFN para la unión de dos expresiones regulares
b) Suponga que r = st. Entonces, construya N(r) como en la figura 3.41. El estado inicial
de N(s) se convierte en el estado inicial de N(r), y el estado de aceptación de N(t) es el
único estado de aceptación de N(r). El estado de aceptación de N(s) y el estado inicial
de N(t) se combinan en un solo estado, con todas las transiciones que entran o salen de
cualquiera de esos estados. Un camino de i a f en la figura 3.41 debe pasar primero a
través de N(s) y, por lo tanto, su etiqueta empezará con alguna cadena en L(s). Después,
el camino continúa a través de N(t), por lo que la etiqueta del camino termina con una
cadena en L(t). Como pronto argumentaremos, los estados de aceptación nunca tienen
líneas de salida y los estados iniciales nunca tienen líneas de entrada, por lo que no es
posible que un camino vuelva a entrar a N(s) después de salir de este estado. Por ende,
N(r) acepta exactamente a L(s)L(t), y es un AFN correcto para r = st.
inicio
i
N (s)
N (t )
f
Figura 3.41: AFN para la concatenación de dos expresiones regulares
161
3.7 De las expresiones regulares a los autómatas
c) Suponga que r = s*. Entonces, para r construimos el AFN N(r) que se muestra en la
figura 3.42. Aquí, i y f son nuevos estados, el estado inicial y el único estado aceptante de
N(r). Para ir de i a f, podemos seguir el camino introducido, etiquetado como , el cual
se ocupa de la única cadena en L(s)0, podemos pasar al estado inicial de N(s), a través
de ese AFN, y después de su estado de aceptación regresar a su estado inicial, cero o
más veces. Estas opciones permiten que N(r) acepte a todas las cadenas en L(s)1, L(s)2,
y así en lo sucesivo, de forma que el conjunto entero de cadenas aceptadas por N(r) es
L(s*).
ε
inicio
i
ε
N (s)
ε
f
ε
Figura 3.42: AFN para el cierre de una expresión regular
d) Por último, suponga que r = (s). Entonces, L(r) = L(s), y podemos usar el AFN N(s)
como N(r).
2
La descripción del método en el Algoritmo 3.23 contiene sugerencias de por qué la construcción inductiva funciona en la forma esperada. No proporcionaremos una prueba formal de
su correcto funcionamiento, pero sí presentaremos varias propiedades de los AFNs construidos,
además del importantísimo hecho de que N(r) acepta el lenguaje L(r). Estas propiedades son
interesantes y útiles para realizar una prueba formal.
1. N(r) tiene cuando menos el doble de estados, que equivalen a los operadores y operandos en r. Este enlace resulta del hecho de que cada paso del algoritmo crea cuando menos
dos nuevos estados.
2. N(r) tiene un estado inicial y un estado de aceptación. El estado de aceptación no tiene
transiciones salientes, y el estado inicial no tiene transiciones entrantes.
3. Cada estado de N(r) que no sea el estado de aceptación tiene una transición saliente en
un símbolo en Σ, o dos transiciones salientes, ambas en .
Ejemplo 3.24: Vamos a usar el Algoritmo 3.23 para construir un AFN para r = (a|b)*abb.
La figura 3.43 muestra un árbol de análisis sintáctico para r, que es análogo a los árboles de
análisis sintáctico construidos para las expresiones aritméticas en la sección 2.2.3. Para la
subexpresión r1, la primera a, construimos el siguiente AFN:
Capítulo 3. Análisis léxico
162
r11
r9
r10
r7
r8
r5
r6
r4
*
(
r3
)
r1
|
r2
a
b
b
a
b
Figura 3.43: Árbol de análisis sintáctico para (a|b)*abb
inicio
a
2
3
Hemos elegido los números de los estados para que sean consistentes con lo que sigue. Para r2
construimos lo siguiente:
inicio
b
4
5
Ahora podemos combinar N(r1) y N(r2), mediante la construcción de la figura 3.40 para obtener el AFN para r3 = r1|r2; este AFN se muestra en la figura 3.44.
2
inicio
1
a
3
ε
ε
ε
ε
4
b
6
5
Figura 3.44: AFN para r3
El AFN para r4 = (r3) es el mismo que para r3. Entonces, el AFN para r5 = (r3)* es como se
muestra en la figura 3.45. Hemos usado la construcción en la figura 3.42 para crear este AFN,
a partir del AFN de la figura 3.44.
163
3.7 De las expresiones regulares a los autómatas
ε
a
2
inicio
ε
ε
0
3
1
ε
ε
ε
4
b
ε
6
7
5
ε
Figura 3.45: AFN para r5
Ahora consideremos la subexpresión r6, que es otra a. Utilizamos la construcción básica
para a otra vez, pero debemos usar nuevos estados. No se permite reutilizar el AFN que construimos para r1, aun cuando r1 y r6 son la misma expresión. El AFN para r6 es:
inicio
a
7’
8
Para obtener el AFN de r7 = r5 r6, aplicamos la construcción de la figura 3.41. Combinamos los
estados 7 y 7, con lo cual se produce el AFN de la figura 3.46. Si continuamos de esta forma
con nuevos AFNs para las dos subexpresiones b llamadas r8 y r10, en un momento dado construiremos el AFN para (a|b)*abb que vimos primero en la figura 3.34. 2
ε
2
inicio
0
ε
a
3
ε
1
ε
ε
ε
4
b
6
ε
7
a
8
5
ε
Figura 3.46: AFN para r7
3.7.5
Eficiencia de los algoritmos de procesamiento de cadenas
Observamos que el Algoritmo 3.18 procesa una cadena x en un tiempo O(|x |), mientras que
en la sección 3.7.3 concluimos que podríamos simular un AFN en un tiempo proporcional al
Capítulo 3. Análisis léxico
164
producto de |x| y el tamaño del gráfo de transición del AFN. Obviamente, es más rápido hacer
un AFD que simular un AFN, por lo que podríamos preguntarnos por qué tendría sentido
simular un AFN.
Una cuestión que puede favorecer a un AFN es que la construcción de los subconjuntos
puede, en el peor caso, exponenciar el número de estados. Aunque en principio, el número de
estados del AFD no influye en el tiempo de ejecución del Algoritmo 3.18, si el número de estados se vuelve tan grande que la tabla de transición no quepa en la memoria principal, entonces
el verdadero tiempo de ejecución tendría que incluir la E/S en disco y, por ende, se elevaría de
manera considerable.
Ejemplo 3.25: Considere la familia de lenguajes descritos por las expresiones regulares de
la forma Ln = (a|b)*a(a|b)n−1, es decir, cada lenguaje Ln consiste en cadenas de as y bs de tal
forma que el n-ésimo carácter a la izquierda del extremo derecho contiene a a. Un AFN de
n + 1 estados es fácil de construir. Permanece en su estado inicial bajo cualquier entrada, pero
también tiene la opción, en la entrada a, de pasar al estado 1. Del estado 1 pasa al estado 2 en
cualquier entrada, y así en lo sucesivo, hasta que en el estado n acepta. La figura 3.47 sugiere
este AFN.
a
inicio
0
a
1
a, b
2
a, b
...
a, b
a, b
n
b
Figura 3.47: Un AFN que tiene mucho menos estados que el AFD equivalente más pequeño
No obstante, cualquier AFD para el lenguaje Ln debe tener cuando menos 2n estados. No
demostraremos este hecho, pero la idea es que si dos cadenas de longitud n pueden llevar al
AFD al mismo estado, entonces podemos explotar la última posición en la que difieren las
cadenas (y, por lo tanto, una debe tener a a y la otra a b) para continuarlas en forma idéntica,
hasta que sean la misma en las últimas n − 1 posiciones. Entonces, el AFD estará en un estado
en el que debe tanto aceptar como no aceptar. Por fortuna, como dijimos antes, es raro que en
el análisis léxico se involucren patrones de este tipo, y no esperamos encontrar AFDs con números extravagantes de estados en la práctica. 2
Sin embargo, los generadores de analizadores léxicos y otros sistemas de procesamiento de
cadenas empiezan a menudo con una expresión regular. Nos enfrentamos a la opción de convertir esta expresión en un AFN o AFD. El costo adicional de elegir un AFD es, por ende, el costo
de ejecutar el Algoritmo 3.23 en el AFN (podríamos pasar directo de una expresión regular a
un AFD, pero en esencia el trabajo es el mismo). Si el procesador de cadenas se va a ejecutar
165
3.7 De las expresiones regulares a los autómatas
muchas veces, como es el caso para el análisis léxico, entonces cualquier costo de convertir a
un AFD es aceptable. No obstante, en otras aplicaciones de procesamiento de cadenas, como
Grez, en donde el usuario especifica una expresión regular y uno de varios archivos en los que
se va a buscar el patrón de esa expresión, puede ser más eficiente omitir el paso de construir
un AFD, y simular el AFN directamente.
Vamos a considerar el costo de convertir una expresión regular r en un AFN mediante el
Algoritmo 3.23. Un paso clave es construir el árbol de análisis sintáctico para r. En el capítulo 4 veremos varios métodos que son capaces de construir este árbol de análisis sintáctico en
tiempo lineal, es decir, en un tiempo O(|r |), en donde |r | representa el tamaño de r : la suma
del número de operadores y operandos en r. También es fácil verificar que cada una de las
construcciones básica e inductiva del Algoritmo 3.23 requiera un tiempo constante, de manera
que el tiempo completo invertido por la conversión a un AFN es O(|r |).
Además, como observamos en la sección 3.7.4, el AFN que construimos tiene cuando mucho
|r | estados y 2|r | transiciones. Es decir, en términos del análisis en la sección 3.7.3, tenemos
que n ≤ |r | y m ≤ 2|r |. Así, la simulación de este AFN en una cadena de entrada x requiere un
tiempo O(|r | × |x |). Este tiempo domina el tiempo requerido por la construcción del AFN, que
es O(|r |) y, por lo tanto, concluimos que es posible tomar una expresión regular r y la cadena
x, e indicar si x está en L(r) en un tiempo O(|r | × |x |).
El tiempo que requiere la construcción de subconjuntos depende en gran parte del número
de estados que tenga el AFD resultante. Para empezar, observe que en la construcción de la figura 3.32, el paso clave, la construcción de un conjunto de estados U a partir de un conjunto de
estados T y un símbolo de entrada a, es muy parecida a la construcción de un nuevo conjunto
de estados a partir del antiguo conjunto de estados en la simulación de un AFN del Algoritmo
3.22. Ya hemos concluido que, si se implementa en forma apropiada, este paso requiere cuando mucho un tiempo proporcional al número de estados y transiciones del AFN.
Suponga que empezamos con una expresión regular r y la convertimos en un AFN. Este
AFN tiene cuando mucho |r | estados y 2|r | transiciones. Además, hay cuando mucho |r | símbolos de entrada. Así, para cada estado construido del AFD, debemos construir cuando mucho |r|
nuevos estados, y cada uno requiere a lo más un tiempo O(|r | + 2|r |). El tiempo para construir
un AFD de s estados es, por consiguiente, O(|r |2s).
En el caso común en el que s es aproximadamente |r |, la construcción de subconjuntos
requiere un tiempo O(|r |3). No obstante, en el peor caso como en el ejemplo 3.25, este tiempo
es O(|r |22|r|). La figura 3.48 resume las opciones cuando recibimos una expresión regular r y
deseamos producir un reconocedor que indique si una o más cadenas x están en L(r).
AUTÓMATA
AFN
Caso típico del AFD
Peor caso del AFD
INICIAL
O(|r |)
O(|r |3)
O(|r |22|r|)
POR CADENA
O(|r | × |x |)
O(|x |)
O(|x |)
Figura 3.48: Costo inicial y costo por cadena de varios métodos para reconocer el lenguaje de
una expresión regular
Capítulo 3. Análisis léxico
166
Si domina el costo por cadena, como es el caso cuando construimos un analizador léxico, es
evidente que preferimos el AFD. No obstante, en los comandos como grep, en donde ejecutamos el autómata sólo sobre una cadena, por lo general, preferimos el AFN. No es sino hasta
que |x | se acerca a |r |3 que empezamos a considerar la conversión a un AFD.
Sin embargo, hay una estrategia mixta que es casi tan buena como la mejor estrategia del
AFN y AFD para cada expresión r y cadena x. Empezamos simulando el AFN, pero recordamos los conjuntos de estados del AFN (es decir, los estados del AFD) y sus transiciones,
a medida que los calculamos. Antes de procesar el conjunto actual de estados del AFN y el
símbolo de entrada actual, comprobamos si tenemos ya calculada esta transición, y utilizamos
la información en caso de que así sea.
3.7.6
Ejercicios para la sección 3.7
Ejercicio 3.7.1: Convierta a AFDs los AFNs de:
a) La figura 3.26.
b) La figura 3.29.
c) La figura 3.30.
Ejercicio 3.7.2: Use el Algoritmo 3.22 para simular los AFNs.
a) Figura 3.29.
b) Figura 3.30.
con la entrada aabb.
Ejercicio 3.7.3: Convierta las siguientes expresiones regulares en autómatas finitos deterministas, mediante los algoritmos 3.23 y 3.20:
a) (a|b)*.
b) (a*|b*)*.
c) ((|a)b*)*.
d) (a|b)*abb(a|b)*.
3.8
Diseño de un generador de analizadores léxicos
En esta sección aplicaremos las técnicas presentadas en la sección 3.7 para ver la arquitectura
de un generador de analizadores léxicos como Lex. Hablaremos sobre dos métodos, basados en
AFNs y AFDs; el último es en esencia la implementación de Lex.
167
3.8 Diseño de un generador de analizadores léxicos
3.8.1
La estructura del analizador generado
La figura 3.49 presenta las generalidades acerca de la arquitectura de un analizador léxico
generado por Lex. El programa que sirve como analizador léxico incluye un programa fijo que
simula a un autómata; en este punto dejamos abierta la decisión de si el autómata es determinista o no. El resto del analizador léxico consiste en componentes que se crean a partir del
programa Lex, por el mismo Lex.
Búfer de entrada
lexema
inicioLexema
avance
Simulador
de autómata
Programa
en Lex
Compilador
de Lex
Tabla de
transición
Acciones
Figura 3.49: Un programa en Lex se convierte en una tabla de transición y en acciones, para
que las utilice un simulador de autómatas finitos
Estos componentes son:
1. Una tabla de transición para el autómata.
2. Las funciones que se pasan directamente a través de Lex a la salida (vea la sección
3.5.2).
3. Las acciones del programa de entrada, que aparece como fragmentos de código que el
simulador del autómata debe invocar en el momento apropiado.
Para construir el autómata, empezamos tomando cada patrón de expresión regular en el
programa en Lex y lo convertimos, mediante el Algoritmo 3.23, en un AFN. Necesitamos un
solo autómata que reconozca los lexemas que coinciden con alguno de los patrones en el programa, por lo que combinamos todos los AFNs en uno solo, introduciendo un nuevo estado inicial
con transiciones hacia cada uno de los estados iniciales del Ni de los AFNs para el patrón pi.
Esta construcción se muestra en la figura 3.50.
Ejemplo 3.26: Vamos a ilustrar las ideas de esta sección con el siguiente ejemplo abstracto
simple:
Capítulo 3. Análisis léxico
168
N ( p1 )
ε
ε
N ( p2 )
s0
...
ε
N ( pn )
Figura 3.50: Un AFN construido a partir de un programa en Lex
a
abb
a*b+
{ acción A1 para el patrón p1 }
{ acción A2 para el patrón p2 }
{ acción A3 para el patrón p3 }
Observe que estos tres patrones presentan ciertos conflictos del tipo que describimos en la sección 3.5.3. En especial, la cadena abb coincide con el segundo y tercer patrones, pero vamos a
considerarla como un lexema para el patrón p2, ya que el patrón se lista primero en el programa
anterior en Lex. Entonces, las cadenas de entrada como aabbb · · · tienen muchos prefijos que
coinciden con el tercer patrón. La regla de Lex es tomar el más largo, por lo que continuamos
leyendo bs hasta encontrarnos con otra a, en donde reportamos que el lexema consta de las as
iniciales, seguidas de todas las bs que haya.
La figura 3.51 muestra tres AFNs que reconocen los tres patrones. El tercero es una simplificación de lo que saldría del Algoritmo 3.23. Así, la figura 3.52 muestra estos tres AFNs combinados en un solo AFN, mediante la adición del estado inicial 0 y de tres transiciones .
2
3.8.2
Coincidencia de patrones con base en los AFNs
Si el analizador léxico simula un AFN como el de la figura 3.52, entonces debe leer la entrada
que empieza en el punto de su entrada, al cual nos hemos referido como inicioLexema. A medida que el apuntador llamado avance avanza hacia delante en la entrada, calcula el conjunto
de estados en los que se encuentra en cada punto, siguiendo el Algoritmo 3.22.
En algún momento, la simulación del AFN llega a un punto en la entrada en donde no hay
siguientes estados. En ese punto, no hay esperanza de que cualquier prefijo más largo de la entrada haga que el AFN llegue a un estado de aceptación; en vez de ello, el conjunto de estados
siempre estará vacío. Por ende, estamos listos para decidir sobre el prefijo más largo que sea
un lexema que coincide con cierto patrón.
169
3.8 Diseño de un generador de analizadores léxicos
inicio
a
1
inicio
a
3
inicio
b
7
a
2
b
4
b
5
6
8
b
Figura 3.51: AFNs para a, abb y a*b+
a
1
2
ε
inicio
ε
0
a
3
b
4
5
b
6
ε
b
7
8
a
b
Figura 3.52: AFN combinado
a
a
0
2
1
4
3
7
a
b
7
a* b + a
8
ninguno
7
Figura 3.53: Secuencia de los conjuntos de estados que se introducen al procesar la entrada
aaba
Capítulo 3. Análisis léxico
170
Buscamos hacia atrás en la secuencia de conjuntos de estados, hasta encontrar un conjunto
que incluya uno o más estados de aceptación. Si hay varios estados de aceptación en ese conjunto, elegimos el que esté asociado con el primer patrón pi en la lista del programa en Lex.
Retrocedemos el apuntador avance hacia el final del lexema, y realizamos la acción Ai asociada
con el patrón pi.
Ejemplo 3.27: Suponga que tenemos los patrones del ejemplo 3.36 y que la entrada empieza
con aaba. La figura 3.53 muestra los conjuntos de estados del AFN de la figura 3.52 que introducimos, empezando con -cerradura del estado inicial 0, el cual es {0, 1, 3, 7}, y procediendo
a partir de ahí. Después de leer el cuarto símbolo de entrada, nos encontramos en un conjunto
vacío de estados, ya que en la figura 3.52 no hay transiciones salientes del estado 8 en la entrada a.
Por ende, necesitamos retroceder para buscar un conjunto de estados que incluya un estado
aceptante. Observe que, como se indica en la figura 3.53, después de leer a nos encontramos en
un conjunto que incluye el estado 2 y, por lo tanto, indica que el patrón a tiene una coincidencia. No obstante, después de leer aab nos encontramos en el estado 8, el cual indica que se ha
encontrado una coincidencia con a*b+; el prefijo aab es el prefijo más largo que nos lleva a un
estado de aceptación. Por lo tanto, seleccionamos aab como el lexema y ejecutamos la acción
A3, la cual debe incluir un regreso al analizador sintáctico, indicando que se ha encontrado el
token cuyo patrón es p3 = a*b+.
2
3.8.3
AFDs para analizadores léxicos
Otra arquitectura, que se asemeja a la salida de Lex, es convertir el AFN para todos los patrones en un AFD equivalente, mediante la construcción de subconjuntos del Algoritmo 3.20.
Dentro de cada estado del AFD, si hay uno o más estados aceptantes del AFN, se determina
el primer patrón cuyo estado aceptante se representa, y ese patrón se convierte en la salida del
estado AFD.
Ejemplo 3.28: La figura 3.54 muestra un diagrama de transición de estado basado en el AFD
que se construye mediante la construcción de subconjuntos del AFN en la figura 3.52. Los estados de aceptación se etiquetan mediante el patrón identificado por ese estado. Por ejemplo,
el estado {6, 8} tiene dos estados de aceptación, los cuales corresponden a los patrones abb
y a*b+. Como el primer patrón se lista primero, ése es el patrón que se asocia con el estado
{6, 8}.
2
Utilizamos el AFD en un analizador léxico en forma muy parecida al AFN. Simulamos el
AFD hasta que en cierto punto no haya un estado siguiente (o hablando en sentido estricto,
hasta que el siguiente estado sea ∅, el estado muerto que corresponde al conjunto vacío de estados del AFN). En ese punto, retrocedemos a través de la secuencia de estados que introdujimos
y, tan pronto como nos encontramos con un estado de aceptación del AFD, realizamos la acción
asociada con el patrón para ese estado.
Ejemplo 3.29: Suponga que el AFD de la figura 3.54 recibe la entrada abba. La secuencia
de estados introducidos es 0137, 247, 58, 68, y en la a final no hay una transición que salga del
estado 68. Por ende, consideramos la secuencia a partir del final, y en este caso, 68 en sí es
un estado de aceptación que reporta el patrón p2 = abb.
2
171
3.8 Diseño de un generador de analizadores léxicos
a
inicio
a
0137
a
b
247
a
b
7
b
b
b
8
a*b
+
68
b
abb
58
a*b +
Figura 3.54: Grafo de transición para un AFD que maneja los patrones a, abb y a*b+
3.8.4
Implementación del operador de preanálisis
En la sección 3.5.4 vimos que algunas veces es necesario el operador de preanálisis / de Lex en
un patrón r1/r2, ya que tal vez el patrón r1 para un token específico deba describir cierto contexto r2 a la izquierda, para poder identificar en forma correcta el lexema actual. Al convertir el
patrón r1/r2 en un AFN, tratamos al / como si fuera , por lo que en realidad no buscamos un /
en la entrada. No obstante, si el AFN reconoce un prefijo xy del búfer de entrada, de forma que
coincida con esta expresión regular, el final del lexema no es en donde el AFN entró a su estado
de aceptación. En vez de ello, el final ocurre cuando el AFN entra a un estado s tal que:
1. s tenga una transición en el / imaginario.
2. Hay un camino del estado inicial del AFN hasta el estado s, que deletrea a x.
3. Hay un camino del estado s al estado de aceptación que deletrea a y.
4. x es lo más largo posible para cualquier xy que cumpla con las condiciones 1-3.
Si sólo hay un estado de transición en el / imaginario en el AFN, entonces el final del
lexema ocurre cuando se entra a este estado por última vez, como se ilustra en el siguiente
ejemplo. Si el AFN tiene más de un estado de transición en el / imaginario, entonces el problema general de encontrar el estado s actual se dificulta mucho más.
Ejemplo 3.30: En la figura 3.55 se muestra un AFN para el patrón de la instrucción IF de
Fortran con lectura adelantada del ejemplo 3.13. Observe que la transición del estado 2 al 3
representa al operador de aceptación. El estado 6 indica la presencia de la palabra clave IF. No
obstante, para encontrar el lexema IF exploramos en retroceso hasta la última ocurrencia del
estado 2, cada vez que se entra al estado 6.
2
Capítulo 3. Análisis léxico
172
Estados muertos en los AFDs
Técnicamente, el autómata en la figura 3.54 no es en sí un AFD. La razón es que un
AFD tiene una transición proveniente de cada estado, en cada símbolo en su alfabeto de
entrada. Aquí hemos omitido las transiciones que van al estado muerto ∅ y, por lo tanto,
hemos omitido las transiciones que van del estado muerto hacia sí mismo, en todas las
entradas. Los ejemplos anteriores de conversión de AFN a AFD no tenían una forma de
pasar del estado inicial a ∅, pero el AFN de la figura 3.52 sí.
No obstante, al construir un AFD para usarlo en un analizador léxico, es importante
que tratemos al estado muerto de manera distinta, ya que debemos saber cuando no hay
más posibilidad de reconocer un lexema más largo. Por ende, sugerimos siempre omitir
las transiciones hacia el estado muerto y eliminar el estado muerto en sí. De hecho, el
problema es mucho más difícil de lo que parece, ya que una construcción de AFN a AFD
puede producir varios estados que no puedan llegar a un estado de aceptación, y debemos
saber cuándo se ha llegado a cualquiera de estos estados. La sección 3.9.6 habla sobre
cómo combinar todos estos estados en un estado muerto, de manera que sea más fácil
identificarlos. También es interesante observar que si construimos un AFD a partir de
una expresión regular que utilice los Algoritmos 3.20 y 3.23, entonces el AFD no tendrá
ningún estado, aparte de ∅, que no pueda dirigirnos hacia un estado de aceptación.
cualquiera
inicio
0
I
1
F
ε (/)
2
3
(
4
)
5
letra
6
Figura 3.55: AFN que reconoce la palabra clave IF
3.8.5
Ejercicios para la sección 3.8
Ejercicio 3.8.1: Suponga que tenemos dos tokens: (1) la palabra clave if y (2) los identificadores, que son cadenas de letras distintas de if. Muestre:
a) El AFN para estos tokens.
b) El AFD para estos tokens.
Ejercicio 3.8.2: Repita el ejercicio 3.8.1 para los tokens que consistan en (1) la palabra clave
while, (2) la palabra clave when, y (3) los identificadores compuestos de cadenas de letras y
dígitos, empezando con una letra.
! Ejercicio 3.8.3: Suponga que vamos a revisar la definición de un AFD para permitir cero o
una transición saliente de cada estado, en cada símbolo de entrada (en vez de que sea exactamente una transición, como en la definición estándar del AFD). De esta forma, algunas expre-
3.9 Optimización de los buscadores por concordancia de patrones basados en AFD
173
siones regulares tendrían “AFDs” más pequeños en comparación con la definición estándar de
un AFD. Proporcione un ejemplo de una expresión regular así.
!! Ejercicio 3.8.4: Diseñe un algoritmo para reconocer los patrones de lectura por adelantado
de Lex de la forma r1/ r2, en donde r1 y r2 son expresiones regulares. Muestre cómo funciona
su algoritmo en las siguientes entradas:
a) (abcd|abc)/d
b) (a|ab)/ba
c) aa * /a*
3.9
Optimización de los buscadores por concordancia
de patrones basados en AFD
En esa sección presentaremos tres algoritmos que se utilizan para implementar y optimizar
buscadores por concordancia de patrones, construidos a partir de expresiones regulares.
1. El primer algoritmo es útil en un compilador de Lex, ya que construye un AFD directamente a partir de una expresión regular, sin construir un AFN intermedio. Además, el AFD
resultante puede tener menos estados que el AFD que se construye mediante un AFN.
2. El segundo algoritmo disminuye al mínimo el número de estados de cualquier AFD, mediante la combinación de los estados que tienen el mismo comportamiento a futuro. El
algoritmo en sí es bastante eficiente, pues se ejecuta en un tiempo O(n log n), en donde
n es el número de estados del AFD.
3. El tercer algoritmo produce representaciones más compactas de las tablas de transición
que la tabla estándar bidimensional.
3.9.1
Estados significativos de un AFN
Para empezar nuestra discusión acerca de cómo pasar directamente de una expresión regular
a un AFD, primero debemos analizar con cuidado la construcción del AFN del algoritmo 3.23
y considerar los papeles que desempeñan varios estados. A un estado del AFN le llamamos
significativo si tiene una transición de salida que no sea . Observe que la construcción de
subconjuntos (Algoritmo 3.20) sólo utiliza los estados significativos en un conjunto T cuando
calcula -cerradura(mover(T, a)), el conjunto de estados a los que se puede llegar desde T con
una entrada a. Es decir, el conjunto de estados mover(s, a) no está vacío sólo si el estado s es
importante. Durante la construcción de subconjuntos, pueden identificarse dos conjuntos de
estados del AFN (que se tratan como si fueran el mismo conjunto) si:
1. Tienen los mismos estados significativos.
2. Ya sea que ambos tengan estados de aceptación, o ninguno.
Capítulo 3. Análisis léxico
174
Cuando el AFN se construye a partir de una expresión regular mediante el Algoritmo 3.23,
podemos decir más acerca de los estados significativos. Los únicos estados significativos son
los que se introducen como estados iniciales en la parte básica para la posición de un símbolo
específico en la expresión regular. Es decir, cada estado significativo corresponde a un operando específico en la expresión regular.
El AFN construido sólo tiene un estado de aceptación, pero éste, que no tiene transiciones
de salida, no es un estado significativo. Al concatenar un único marcador final # derecho con
una expresión regular r, proporcionamos al estado de aceptación para r una transición sobre #,
con lo cual lo marcamos como un estado significativo del AFN para (r)#. En otras palabras,
al usar la expresión regular aumentada (r)#, podemos olvidarnos de los estados de aceptación
a medida que procede la construcción de subconjuntos; cuando se completa la construcción,
cualquier estado con una transición sobre # debe ser un estado de aceptación.
Los estados significativos del AFN corresponden directamente a las posiciones en la expresión regular que contienen símbolos del alfabeto. Como pronto veremos, es conveniente presentar la expresión regular mediante su árbol sintáctico, en donde las hojas corresponden a
los operandos y los nodos interiores corresponden a los operadores. A un nodo interior se le
llama nodo-concat, nodo-o o nodo-asterisco si se etiqueta mediante el operador de concatenación (punto), el operador de unión |, o el operador *, respectivamente. Podemos construir un
árbol sintáctico para una expresión regular al igual que como lo hicimos para las expresiones
aritméticas en la sección 2.5.1.
Ejemplo 3.31: La figura 3.56 muestra el árbol sintáctico para la expresión regular de nuestro
bosquejo. Los nodos-concat se representan mediante círculos.
2
#
6
b
5
b
4
*
a
3
|
a
1
b
2
Figura 3.56: Árbol sintáctico para (a|b)*abb#
175
3.9 Optimización de los buscadores por concordancia de patrones basados en AFD
Las hojas en un árbol sintáctico se etiquetan mediante o mediante un símbolo del alfabeto. Para cada hoja que no se etiqueta como , le adjuntamos un entero único. Nos referimos a
este entero como la posición de la hoja y también como una posición de su símbolo. Observe
que un símbolo puede tener varias posiciones; por ejemplo, a tiene las posiciones 1 y 3 en la
figura 3.56. Las posiciones en el árbol sintáctico corresponden a los estados significativos del
AFN construido.
Ejemplo 3.32: La figura 3.57 muestra el AFN para la misma expresión regular que la figura
3.56, con los estados significativos enumerados y los demás estados representados por letras.
Los estados enumerados en el AFN y las posiciones en el árbol sintáctico corresponden de una
forma que pronto veremos.
2
ε
inicio
A
1
ε
B
a
C
ε
ε
ε
ε
2
b
E
ε
3
a
4
b
5
b
6
#
F
D
ε
Figura 3.57: AFN construido por el Algoritmo 3.23 para (a|b)*abb#
3.9.2
Funciones calculadas a partir del árbol sintáctico
Para construir un AFD directamente a partir de una expresión regular, construimos su árbol
sintáctico y después calculamos cuatro funciones: anulable, primerapos, ultimapos y siguientepos, las cuales se definen a continuación. Cada definición se refiere al árbol sintáctico para una
expresión regular aumentada (r)# específica.
1. anulable(n) es verdadera para un nodo n del árbol sintáctico si, y sólo si, la subexpresión
representada por n tiene a en su lenguaje. Es decir, la subexpresión puede “hacerse nula”
o puede ser la cadena vacía, aun cuando pueda representar también a otras cadenas.
2. primerapos(n) es el conjunto de posiciones en el subárbol con raíz en n, que corresponde al
primer símbolo de por lo menos una cadena en el lenguaje de la subexpresión con raíz en n.
3. ultimapos(n) es el conjunto de posiciones en el subárbol con raíz en n, que corresponde al
último símbolo de por lo menos una cadena en el leguaje de la subexpresión con raíz en n.
Capítulo 3. Análisis léxico
176
4. siguientepos(p), para una posición p, es el conjunto de posiciones q en todo el árbol
sintáctico, de tal forma que haya cierta cadena x = a1a2 · · · an en una L((r)#) tal que
para cierta i, haya una forma de explicar la membresía de x en L((r)#), haciendo que ai
coincida con la posición p del árbol sintáctico y ai+1 con la posición q.
Ejemplo 3.33: Considere el nodo-concat n en la figura 3.56, que corresponde a la expresión
(a|b)*a. Alegamos que anulable(n) es falsa, ya que este nodo genera todas las cadenas de as
y bs que terminan en una a; no genera a . Por otro lado, el nodo-asterisco debajo de él puede
hacerse nulo; genera a junto con todas las demás cadenas de as y bs.
primerapos(n) = {1, 2, 3}. En una cadena generada en forma común, como aa, la primera
posición de la cadena corresponde a la posición 1 del árbol, y en una cadena como ba, la primera posición de la cadena proviene de la posición 2 del árbol. No obstante, cuando la cadena
generada por la expresión del nodo n es sólo a, entonces esta a proviene de la posición 3.
ultimapos(n) = {3}. Es decir, sin importar qué cadena se genere a partir de la expresión del
nodo n, la última posición es la a que proviene de la posición 3 de la cadena.
siguientepos es más difícil de calcular, pero en breve veremos las reglas para hacerlo. He
aquí un ejemplo del razonamiento: siguientepos (1) = {1, 2, 3}. Considere una cadena · · ·ac· · ·,
en donde la c puede ser a o b, y la a proviene de la posición 1. Es decir, esta a es una de las
que se generan mediante la a en la expresión (a|b)*. Esta a podría ir seguida de otra a o b que
provenga de la misma subexpresión, en cuyo caso c proviene de la posición 1 o 2. También es
posible que esta a sea la última en la cadena generada por (a|b)*, en cuyo caso el símbolo c
debe ser la a que proviene de la posición 3. Por ende, 1, 2 y 3 son exactamente las posiciones
que pueden seguir de la posición 1. 2
3.9.3
Cálculo de anulable, primerapos y ultimapos
Podemos calcular a anulable, primerapos y ultimapos mediante recursividad directa sobre la
altura del árbol. Las reglas básicas e inductivas para anulable y primerapos se resumen en
la figura 3.58. Las reglas para ultimapos son en esencia las mismas que para primerapos, pero las
funciones de los hijos c1 y c2 deben intercambiarse en la regla para un nodo-concat.
Ejemplo 3.34: De todos los nodos en la figura 3.56, sólo el nodo-asterisco puede hacerse nulo.
De la tabla de la figura 3.58 podemos observar que ninguna de las hojas puede hacerse nula,
ya que todas ellas corresponden a operandos que no son . El nodo-o no puede hacerse nulo, ya
que ninguno de sus hijos lo son. El nodo-asterisco puede hacerse nulo, ya que todos los nodosasterisco pueden hacerse nulos. Por último, cada uno de los nodos-concatt, que tienen por lo
menos un hijo que no puede hacerse nulo, no pueden hacerse nulos.
El cálculo de primerapos y ultimapos para cada uno de los nodos se muestra en la figura
3.59, con primerapos(n) a la izquierda del nodo n, y ultimapos(n) a su derecha. Cada una de las
hojas sólo se tiene a sí misma para primerapos y ultimapos, según lo requerido en la regla para
las hojas que no son en la figura 3.58. Para el nodo-o, tomamos la unión de primerapos en los
hijos y hacemos lo mismo para ultimapos. La regla para el nodo-asterisco dice que debemos tomar
el valor de primerapos o ultimapos en el único hijo de ese nodo.
177
3.9 Optimización de los buscadores por concordancia de patrones basados en AFD
NODO n
anulable(n)
primerapos(n)
Una hoja etiquetada como true
∅
Una hoja con la posición i
false
{i}
Un nodo-o n = c1|c2
anulable(c1) or
anulable (c2)
primerapos(c1) ∪ primerapos(c2)
Un nodo-concat n = c1c2
anulable(c1) and
anulable(c2)
if (anulable(c1) )
primerapos(c1) ∪ primerapos(c2)
else primerapos(c1)
Un nodo-asterisco n = c1*
true
primerapos(c1)
Figura 3.58: Reglas para calcular a anulable y primerapos
Ahora, considere el nodo-concat más inferior, al que llamaremos n. Para calcular primerapos(n),
primero consideramos si el operando izquierdo puede hacerse nulo, lo cual es cierto en este
caso. Por lo tanto, primerapos para n es la unión de primerapos para cada uno de sus hijos;
es decir, {1, 2} ∪ {3} = {1, 2, 3}. La regla para ultimapos no aparece en forma explícita en la
figura 3.58, pero como dijimos antes, las reglas son las mismas que para primerapos, con los
hijos intercambiados. Es decir, para calcular ultimapos(n) debemos verificar si su hijo derecho
(la hoja con la posición 3) puede hacerse nulo, lo cual no es cierto. Por lo tanto, ultimapos(n)
es el mismo que el nodo ultimapos del hijo derecho, o {3}. 2
3.9.4
Cálculo de siguientepos
Por último, necesitamos ver cómo calcular siguientepos. Sólo hay dos formas en que podemos
hacer que la posición de una expresión regular siga a otra.
1. Si n es un nodo-concat con el hijo izquierdo c1 y con el hijo derecho c2, entonces para
cada posición i en ultimapos(c1), todas las posiciones en primerapos(c2) se encuentran en
siguientepos(i).
2. Si n es un nodo-asterisco e i es una posición en ultimapos(n), entonces todas las posiciones en primerapos(n) se encuentran en siguientepos(i).
Ejemplo 3.35: Vamos a continuar con nuestro bosquejo; recuerde que en la figura 3.59 calculamos primerapos y ultimapos. La regla 1 para siguientepos requiere que analicemos cada nodoconcat, y que coloquemos cada posición en primerapos de su hijo derecho en siguientepos, para
cada posición en ultimapos de su hijo izquierdo. Para el nodo-concat más inferior en la figura
3.59, esa regla indica que la posición 3 está en siguientepos(1) y siguientepos(2). El siguiente
nodo-concat indica que la posición 4 está en siguientepos(3), y los dos nodos-concat restantes nos dan la posición 5 en siguientepos(4) y la 6 en siguientepos(5).
Capítulo 3. Análisis léxico
178
{1,2,3}
{1,2,3}
{1,2,3}
{1,2,3}
{3}
{1,2} * {1,2}
{6}
{6} # {6}
{5}
{5} b {5}
{4}
{4} b {4}
{3} a {3}
{1,2} | {1,2}
{1} a {1}
{2} b {2}
Figura 3.59: primerapos y ultimapos para los nodos en el árbol sintáctico para (a|b)*abb#
También debemos aplicar la regla 2 al nodo-asterisco. Esa regla nos indica que las posiciones 1 y 2 están tanto en siguientepos(1) como en siguientepos(2), ya que tanto primerapos
como ultimapos para este nodo son {1,2}. Los conjuntos completos siguientepos se resumen en
la figura 3.60.
2
NODO n
1
2
3
4
5
6
siguientepos(n)
{1, 2, 3}
{1, 2, 3}
{4}
{5}
{6}
∅
Figura 3.60: La función siguientepos
Podemos representar la función siguientepos mediante la creación de un gráfico dirigido,
con un nodo para cada posición y un arco de la posición i a la posición j si y sólo si j se encuentra en siguientepos(i). La figura 3.61 muestra este gráfico para la función de la figura 3.60.
No debe sorprendernos el hecho de que el grafo para siguientepos sea casi un AFN sin transacciones para la expresión regular subyacente, y se convertiría en uno si:
1. Hacemos que todas las posiciones en primerapos de la raíz sean estados iniciales,
2. Etiquetamos cada arco de i a j mediante el símbolo en la posición i.
179
3.9 Optimización de los buscadores por concordancia de patrones basados en AFD
1
3
4
5
6
2
Figura 3.61: Gráfico dirigido para la función siguientepos
3. Hacemos que la posición asociada con el marcador final # sea el único estado de aceptación.
3.9.5
Conversión directa de una expresión regular a un AFD
Algoritmo 3.36: Construcción de un AFD a partir de una expresión regular r.
ENTRADA: Una expresión regular r.
SALIDA: Un AFD D que reconoce a L(r).
MÉTODO:
1. Construir un árbol sintáctico T a partir de la expresión regular aumentada (r)#.
2. Calcular anulable, primerapos, ultimapos y siguientepos para T, mediante los métodos de
las secciones 3.9.3 y 3.9.4.
3. Construir Destados, el conjunto de estados del AFD D, y Dtran, la función de transición para D, mediante el procedimiento de la figura 3.62. Los estados de D son estados
de posiciones en T. Al principio, cada estado está “sin marca”, y un estado se “marca”
justo antes de que consideremos sus transiciones de salida. El estado inicial de D es
primerapos(n 0), en donde el nodo n 0 es la raíz de T. Los estados de aceptación son los
que contienen la posición para el símbolo de marcador final #.
2
Ejemplo 3.37: Ahora podemos unir los pasos de nuestro bosquejo y construir un AFD para
la expresión regular r = (a|b)*abb. El árbol sintáctico para (r)# apareció en la figura 3.56.
Ahí observamos que para este árbol, anulable es verdadera sólo para el nodo-asterisco, y exhibimos a primerapos y ultimapos en la figura 3.59. Los valores de siguientepos aparecen en la
figura 3.60.
El valor de primerapos para la raíz del árbol es {1, 2, 3}, por lo que este conjunto es el
estado inicial de D. Llamemos a este conjunto de estados A. Debemos calcular Dtran[A, a]
y Dtran[A, b]. De entre las posiciones de A, la 1 y la 3 corresponden a a, mientras que la 2
corresponde a b. Por ende, Dtran[A, a] = siguientepos(1) ∪ siguientepos(3) = {1, 2, 3, 4} y
Capítulo 3. Análisis léxico
180
inicializar Destados para que contenga sólo el estado sin marcar primerapos(n 0),
en donde n 0 es la raíz del árbol sintáctico T para (r)#;
while ( hay un estado sin marcar S en Destados ) {
marcar S;
for ( cada símbolo de entrada a ) {
dejar que U sea la unión de siguientepos(p) para todas las p
en S que correspondan a a;
if ( U no está en Destados )
agregar U como estado sin marcar a Destados;
Dtran[S, a] = U;
}
}
Figura 3.62: Construcción de un AFD directamente a partir de una expresión regular
Dtran[A, b] = siguientepos(2) = {1, 2, 3}. Este último es el estado A y, por lo tanto, no tiene
que agregarse a Destados, pero el anterior, B = {1, 2, 3, 4} es nuevo, por lo que lo agregamos
a Destados y procedemos a calcular sus transiciones. El AFD completo se muestra en la figura
3.63. 2
b
b
inicio
123
a
a
1234
a
b
1235
b
1236
a
Figura 3.63: AFD construido a partir de la figura 3.57
3.9.6
Minimización del número de estados de un AFD
Puede haber muchos AFDs que reconozcan el mismo lenguaje. Por ejemplo, observe que los
AFDs de las figuras 3.36 y 3.63 reconocen el lenguaje L((a|b)*abb). Estos autómatas no solo
tienen estados con distintos nombres, sino que ni siquiera tienen el mismo número de estados.
Si implementamos un analizador léxico como un AFD, por lo general, es preferible un AFD que
tenga el menor número de estados posible, ya que cada estado requiere entradas en la tabla
para describir al analizador léxico.
El asunto de los nombres de los estados es menor. Decimos que dos autómatas tienen nombres de estados equivalentes si uno puede transformarse en el otro con sólo cambiar los nombres
de los estados. Las figuras 3.36 y 3.63 no tienen nombres de estados equivalentes. No obstante,
hay una estrecha relación entre los estados de cada uno. Los estados A y C de la figura 3.36
son en realidad equivalentes, ya que ninguno es un estado de aceptación, y en cualquier entrada
3.9 Optimización de los buscadores por concordancia de patrones basados en AFD
181
transfieren hacia el mismo estado: hacia B en la entrada a y hacia C en la entrada b. Además,
ambos estados A y C se comportan como el estado 123 de la figura 3.63. De igual forma, el
estado B de la figura 3.36 se comporta como el estado 1234 de la figura 3.63, el estado D se
comporta como el estado 1235, y el estado E se comporta como el estado 1236.
Como resultado siempre hay un AFD único con el número mínimo de estados (equivalentes)
para cualquier lenguaje regular. Además, este AFD con el mínimo número de estados pude construirse a partir de cualquier AFD para el mismo lenguaje, mediante el agrupamiento de conjuntos con estados equivalentes. En el caso de L((a|b)*abb), la figura 3.63 es el AFD con el
mínimo número de estados, y puede construirse particionando los estados de la figura 3.36 de
la siguiente manera: {A,C}{B}{D}{E}.
Para poder comprender el algoritmo para crear la partición de estados que convierta a cualquier AFD en su AFD equivalente con el mínimo número de estados, tenemos que ver cómo las
cadenas de entrada diferencian un estado de otro. Decimos que la cadena x diferencia el estado
s del estado t si sólo uno de los estados a los que se llega desde s y t, siguiendo el camino con
la etiqueta x, es un estado de aceptación. El estado s puede diferenciarse del estado t si hay
alguna cadena que los diferencie.
Ejemplo 3.38: La cadena vacía diferencia a cualquier estado de aceptación de cualquier estado
de no aceptación. En la figura 3.36, la cadena bb diferencia el estado A del B, ya que bb lleva al
estado A hacia un estado C de no aceptación, pero lleva a B al estado de aceptación E. 2
El algoritmo de minimización de estados funciona mediante el particionamiento de los estados de un AFD en grupos de estados que no puedan diferenciarse. Después, cada grupo se
combina en un solo estado del AFD con el número mínimo de estados. El algoritmo funciona
manteniendo una partición, cuyos grupos son conjuntos de estados que no se han diferenciado
todavía, mientras que se sabe que dos estados cualesquiera de distintos grupos pueden diferenciarse. Cuando la partición no puede depurarse más mediante la descomposición de cualquier
grupo en grupos más pequeños, tenemos el AFD con el mínimo número de estados.
Al principio, la partición cosiste en dos grupos: los estados de aceptación y los de no aceptación. El paso fundamental es tomar cierto grupo de la partición actual, por decir A = {s1,
s2, . . . , sk}, y cierto símbolo de entrada a, y ver cómo puede usarse a para diferenciar algunos
estados en el grupo A. Examinamos las transiciones que salen de cada estado s1, s2, . . . , s k en
la entrada a, y si los estados a los que se llega pertenecen a dos o más grupos de la partición
actual, dividimos a A en una colección de grupos, para que s i y s j estén en el mismo grupo, si y
sólo si pasan al mismo grupo en la entrada a. Repetimos este proceso de dividir grupos, hasta
que ninguno de los grupos pueda dividirse más con ningún símbolo de entrada. En el siguiente
algoritmo se formaliza esta idea.
Algoritmo 3.39: Minimización del número de estados de un AFD.
ENTRADA: Un AFD D con un conjunto de estados S, el alfabeto de entrada Σ, el estado
inicial s 0 y el conjunto de estados de aceptación F.
SALIDA: Un AFD D , que acepta el mismo lenguaje que D y tiene el menor número de estados
posible.
Capítulo 3. Análisis léxico
182
Por qué funciona el algoritmo de minimización de estados
Debemos demostrar dos cosas: que los estados que permanecen en el mismo grupo en
IIfinal no pueden diferenciarse por ninguna cadena, y que los estados que terminan en grupos distintos sí pueden hacerlo. La primera es una inducción sobre i, la cual nos dice que
si después de la i-ésima iteración del paso (2) del Algoritmo 3.39, s y t se encuentran en
el mismo grupo, entonces no hay una cadena de longitud i o menor que pueda diferenciarlos. Dejaremos los detalles de esta inducción para que usted los deduzca.
La segunda es una inducción sobre i, la cual nos dice que si los estados s y t se colocan en distintos grupos en la i-ésima iteración del paso (2), entonces hay una cadena que
puede diferenciarlos. La base, cuando s y t se colocan en grupos distintos de la partición
inicial, es simple: uno debe ser de aceptación y el otro no, para que los diferencíe. Para
la inducción, debe haber una entrada a y estados p y q, de tal forma que s y t vayan a
los estados p y q, respectivamente, con la entrada a. Además, p y q ya deben haberse
colocado en grupos distintos. Entonces, por la hipótesis inductiva, hay alguna cadena x
que diferencia a p de q. Por lo tanto, ax diferencia a s de t.
MÉTODO:
1. Empezar con una partición inicial Π con dos grupos, F y S – F, los estados de aceptación
y de no aceptación de D.
2. Aplicar el procedimiento de la figura 3.64 para construir una nueva partición Πnueva.
al principio, dejar que Πnueva = Π;
for (cada grupo G de Π ) {
particionar G en subgrupos, de forma que dos estados s y t
se encuentren en el mismo subgrupo, si y sólo si para todos
los símbolos de entrada a, los estados s y t tienen transiciones
sobre a hacia estados en el mismo grupo de Π;
/* en el peor caso, un estado estará en un subgrupo por sí solo */
sustituir G en Πnueva por el conjunto de todos los subgrupos formados;
}
Figura 3.64: Construcción de Πnueva
3. Si Πnueva = Π, dejar que Πfinal = Π y continuar con el paso (4). De no ser así, repetir el
paso (2) con Πnueva en vez de Π.
4. Elegir un estado en cada grupo de Πfinal como el representante para ese grupo. Los representantes serán los estados del AFD D con el mínimo número de estados. Los demás
componentes de D se construyen de la siguiente manera:
3.9 Optimización de los buscadores por concordancia de patrones basados en AFD
183
Eliminación del estado muerto
Algunas veces, el algoritmo de minimización produce un AFD con un estado muerto;
uno que no es aceptante y que se transfiere hacia sí mismo en cada símbolo de entrada.
Técnicamente, este estado es necesario, ya que un AFD debe tener una transición proveniente de cualquier estado con cada símbolo. No obstante y como vimos en la sección
3.8.3, con frecuencia es conveniente saber cuando no hay posibilidad de aceptación, para
poder determinar que se ha visto el lexema apropiado. Por ende, tal vez sea conveniente
eliminar el estado muerto y utilizar un autómata que omita algunas transiciones. Este
autómata tiene un estado menos que el AFD con el mínimo número de estados, pero
hablando en sentido estricto no es un AFD, debido a las transiciones que faltan hacia el
estado muerto.
(a) El estado inicial de D es el representante del grupo que contiene el estado inicial
de D.
(b) Los estados de aceptación de D son los representantes de los grupos que contienen
un estado de aceptación de D. Observe que cada grupo contiene sólo estados de
aceptación o sólo estados de no aceptación, ya que empezamos separando esas dos
clases de estados, y el procedimiento de la figura 3.64 siempre forma nuevos grupos que son subgrupos de los grupos que se construyeron previamente.
(c) Dejar que s sea el representante de algún grupo G de Πfinal, y dejar que la transición
de D, desde s con la entrada a, sea hacia el estado t. Dejar que r sea el representante del grupo H de t. Después en D , hay una transición desde s hacia r con la entrada a.
Observe que en D, cada estado en el grupo G debe ir hacia algún estado del grupo H
con la entrada a, o de lo contrario, el grupo G se hubiera dividido de acuerdo a la
figura 3.64.
2
Ejemplo 3.40: Vamos a reconsiderar el AFD de la figura 3.36. La partición inicial consiste
en los dos grupos {A, B, C, D}{E} que son, respectivamente, los estados de no aceptación y los
estados de aceptación. Para construir Πnueva, el procedimiento de la figura 3.64 considera ambos grupos y recibe como entrada a y b. El grupo {E} no puede dividirse, ya que sólo tiene un
estado, por lo cual {E} permanecerá intacto en Πnueva.
El otro grupo {A, B, C, D} puede dividirse, por lo que debemos considerar el efecto de cada
símbolo de entrada. Con la entrada a, cada uno de estos estados pasa al estado B, por lo que no
hay forma de diferenciarlos mediante cadenas que empiecen con a. Con la entrada b, los estados
A, B y C pasan a los miembros del grupo {A, B, C, D}, mientras que el estado D pasa a E, un
miembro de otro grupo. Por ende, en Πnueva, el grupo {A, B, C, D} se divide en {A, B, C}{D}, y
Πnueva para esta ronda es {A, B, C}{D}{E}.
Capítulo 3. Análisis léxico
184
En la siguiente ronda, podemos dividir a {A, B, C} en {A, C}{B}, ya que cada uno de los
estados A y C pasan a un miembro de {A, B, C} con la entrada b, mientras que B pasa a un
miembro de otro grupo, {D}. Así, después de la segunda iteración, Πnueva = {A, C}{B}{D}{E}.
Para la tercera iteración, no podemos dividir el único grupo restante con más de un estado,
ya que A y C van al mismo estado (y, por lo tanto, al mismo grupo) con cada entrada. Concluimos que Πfinal = {A, C}{B}{D}{E}.
Ahora vamos a construir el AFD con el mínimo número de estados. Tiene cuatro estados,
los cuales corresponden a los cuatro grupos de Πfinal, y vamos a elegir a A, B, D y E como los
representantes de estos grupos. El estado inicial es A y el único estado aceptante es E. La figura
3.65 muestra la función de transición para el AFD. Por ejemplo, la transición desde el estado E
con la entrada b es hacia A, ya que en el AFD original, E pasa a C con la entrada b, y A es el
representante del grupo de C. Por la misma razón, la transición con b desde el estado A pasa al
mismo estado A, mientras que todas las demás transiciones son como en la figura 3.36.
2
ESTADO
A
B
D
E
a
B
B
B
B
b
A
D
E
A
Figura 3.65: Tabla de transición de un AFD con el mínimo número de estados
3.9.7
Minimización de estados en los analizadores léxicos
Para aplicar el procedimiento de minimización de estados a los AFDs generados en la sección
3.8.3, debemos empezar el Algoritmo 3.39 con la partición que agrupa en conjunto a todos los
estados que reconocen a un token específico, y que también coloca en un grupo a todos los estados que no indican ningún token. Para hacer más claro esto, vamos a ver un ejemplo.
Ejemplo 3.41: Para el AFD de la figura 3.54, la partición inicial es:
{0137, 7}{247}{8, 58}{7}{68}{∅}
Es decir, los estados 0137 y 7 deben estar juntos, ya que ninguno de ellos anuncia un token.
Los estados 8 y 58 deben estar juntos, ya que ambos anuncian el token a*b+. Observe que
hemos agregado un estado muerto ∅, el cual suponemos tiene transiciones hacia sí mismo con
las entradas a y b. El estado muerto también es el destino de las transiciones faltantes con a
desde los estados 8, 58 y 68.
Debemos separar a 0137 de 7, ya que pasan a distintos grupos con la entrada a. También
separamos a 8 de 58, ya que pasan a distintos grupos con b. Por ende, todos los estados se
encuentran en grupos por sí solos, y la figura 3.54 es el AFD con el mínimo número de estados
que reconoce a sus tres tokens. Recuerde que un AFD que sirve como analizador léxico, por lo
general, elimina el estado muerto, mientras que tratamos a las transiciones faltantes como una
señal para finalizar el reconocimiento de tokens. 2
3.9 Optimización de los buscadores por concordancia de patrones basados en AFD
3.9.8
185
Intercambio de tiempo por espacio en la simulación
de un AFD
La manera más simple y rápida de representar la función de transición de un AFD es una tabla
bidimensional indexada por estados y caracteres. Dado un estado y el siguiente carácter de
entrada, accedemos al arreglo para encontrar el siguiente estado y cualquier acción especial que
debemos tomar; por ejemplo, devolver un token al analizador sintáctico. Como un analizador
léxico ordinario tiene varios cientos de estados en su AFD e involucra al alfabeto ASCII de 128
caracteres de entrada, el arreglo consume menos de un megabyte.
No obstante, los compiladores también aparecen en dispositivos muy pequeños, en donde
hasta un megabyte de memoria podría ser demasiado. Para tales situaciones, existen muchos
métodos que podemos usar para compactar la tabla de transición. Por ejemplo, podemos representar cada estado mediante una lista de transiciones (es decir, pares carácter-estado) que
se terminen mediante un estado predeterminado, el cual debe elegirse para cualquier carácter
de entrada que no se encuentre en la lista. Si elegimos como predeterminado el siguiente estado que ocurra con más frecuencia, a menudo podemos reducir la cantidad de almacenamiento
necesario por un factor extenso.
Hay una estructura de datos más sutil que nos permite combinar la velocidad del acceso a los
arreglos con la compresión de listas con valores predeterminados. Podemos considerar esta estructura como cuatro arreglos, según lo sugerido en la figura 3.66.5 El arreglo base se utiliza para
determinar la ubicación base de las entradas para el estado s, que se encuentran en los arreglos siguiente y comprobacion. El arreglo predeterminado se utiliza para determinar una ubicación base
alternativa, si el arreglo comprobacion nos indica que el que proporciona base[s] es inválido.
predeterminado
s
q
comprosiguiente bación
base
a
r
t
Figura 3.66: Estructura de datos para representar tablas de transición
Para calcular siguienteEstado(s, a), la transición para el estado s con la entrada a, examinamos las entradas siguiente y comprobacion en la ubicación l = base[s]+a, en donde el carácter
a se trata como entero, supuestamente en el rango de 0 a 127. Si comprobacion[l ] = s, entonces
5
En la práctica, habría otro arreglo indexado por estados, para proporcionar la acción asociada con ese estado,
si lo hay.
Capítulo 3. Análisis léxico
186
esta entrada es válida y el siguiente estado para el estado s con la entrada a es siguiente[l ]. Si
comprobacion[l ] ≠ s, entonces determinamos otro estado t = predeterminado[s] y repetimos el
proceso, como si t fuera el estado actual. De manera más formal, la función siguienteEstado se
define así:
int siguienteEstado(s, a) {
if ( comprobacion[base[s]+a] = s ) return siguiente[base[s] + a];
else return siguienteEstado(predeterminado[s], a);
}
El uso que se pretende de la estructura de la figura 3.66 es acortar los arreglos siguiente-comprobacion, aprovechando las similitudes entre los estados. Por ejemplo, el estado t, el
predeterminado para el estado s, podría ser el estado que dice “estamos trabajando con un
identificador”, al igual que el estado 10 en la figura 3.14. Tal vez se entre al estado s después
de ver las letras th, que son un prefijo de la palabra clave then, así como también podrían ser
el prefijo de algún lexema para un identificador. Con el carácter de entrada e, debemos pasar
del estado s a un estado especial que recuerde que hemos visto the, pero en caso contrario, el
estado s se comporta de igual forma que t. Por ende, a comprobacion[base[s]+e] le asignamos
s (para confirmar que esta entrada es válida para s) y a siguiente[base[s]+e] le asignamos el
estado que recuerda a the. Además, a predeterminado[s] se le asigna t.
Aunque tal vez no podamos elegir los valores de base de forma que no haya entradas en
siguiente-comprobacion sin utilizar, la experiencia ha demostrado que la estrategia simple de
asignar valores base a los estados en turno, y asignar a cada valor de base[s] el entero más bajo,
de manera que las entradas especiales para el estado s no estén ya ocupadas, utiliza un poco
más de espacio que el mínimo posible.
3.9.9
Ejercicios para la sección 3.9
Ejercicio 3.9.1: Extienda la tabla de la figura 3.58 para que incluya los operadores (a) ? y
(b) +.
Ejercicio 3.9.2: Use el Algoritmo 3.36 para convertir las expresiones regulares del ejercicio
3.7.3 directamente en autómatas finitos deterministas.
! Ejercicio 3.9.3: Podemos demostrar que dos expresiones regulares son equivalentes si mostramos que sus AFDs con el mínimo número de estados son los mismos si se cambia el nombre a
los estados. Demuestre de esta forma que las siguientes expresiones regulares: (a|b)*, (a*|b*)*
y ((|a)b*)* son todas equivalentes. Nota: Tal vez haya construido los AFDs para estas expresiones, al responder al ejercicio 3.7.3.
! Ejercicio 3.9.4: Construya los AFDs con el mínimo número de estados para las siguientes
expresiones regulares:
a) (a|b)*a(a|b).
b) (a|b)*a(a|b)(a|b).
c) (a|b)*a(a|b)(a|b)(a|b).
187
3.10 Resumen del capítulo 3
¿Puede ver un patrón?
!! Ejercicio 3.9.5: Para hacer uso formal de la afirmación informal del ejemplo 3.25, muestre
que cualquier autómata finito determinista para la siguiente expresión regular:
(a|b)*a(a|b)(a|b)···(a|b)
en donde (a|b) aparece n − 1 veces al final, debe tener por lo menos 2n estados. Sugerencia:
Observe el patrón en el ejercicio 3.9.4. ¿Qué condición con relación al historial de entradas
representa cada estado?
3.10
Resumen del capítulo 3
♦ Tokens. El analizador léxico explora el programa fuente y produce como salida una secuencia de tokens, los cuales, por lo general, se pasan al analizador sintáctico, uno a la
vez. Algunos tokens pueden consistir sólo de un nombre de token, mientras que otros
también pueden tener un valor léxico asociado, el cual proporciona información acera de
la instancia específica del token que se ha encontrado en la entrada.
♦ Lexemas. Cada vez que el analizador léxico devuelve un token al analizador sintáctico, tiene un lexema asociado: la secuencia de caracteres de entrada que representa el
token.
♦ Uso de búferes. Como a menudo es necesario explorar por adelantado sobre la entrada,
para poder ver en dónde termina el siguiente lexema, es necesario que el analizador léxico
utilice búferes en la entrada. El uso de un par de búferes en forma cíclica y la terminación del contenido de cada búfer con un centinela que avise al llegar a su final, son dos
técnicas que aceleran el proceso de escaneo de la entrada.
♦ Patrones. Cada token tiene un patrón que describe cuáles son las secuencias de caracteres que pueden formar los lexemas correspondientes a ese token. Al conjunto de palabras,
o cadenas de caracteres, que coinciden con un patrón dado se le conoce como lenguaje.
♦ Expresiones regulares. Estas expresiones se utilizan con frecuencia para describir los
patrones. Las expresiones regulares se crean a partir de caracteres individuales, mediante
el operador de unión, de concatenación y el cierre de Kleene, o el operador “cualquier
número de”.
♦ Definiciones regulares. Las colecciones complejas de lenguajes, como los patrones que describen a los tokens de un lenguaje de programación, se definen a menudo mediante una
definición regular, la cual es una secuencia de instrucciones, en las que cada una de ellas define a una variable que representa a cierta expresión regular. La expresión regular para
una variable puede utilizar las variables definidas con anterioridad en su expresión
regular.
♦ Notación de expresión regular extendida. Puede aparecer una variedad de operadores
adicionales como abreviaciones en las expresiones regulares, para facilitar la acción de
expresar los patrones. Algunos ejemplos incluyen el operador + (uno o más de), ? (cero
188
Capítulo 3. Análisis léxico
o uno de), y las clases de caracteres (la unión de las cadenas, en donde cada una consiste
en uno de los caracteres).
♦ Diagramas de transición de estados. A menudo, el comportamiento de un analizador
léxico puede describirse mediante un diagrama de transición de estados. Estos diagramas
tienen estados, cada uno de los cuales representa algo acerca del historial de los caracteres vistos durante el escaneo actual, en busca de un lexema que coincida con uno de los
posibles patrones. Hay flechas, o transiciones, de un estado a otro, cada una de las cuales
indica los posibles siguientes caracteres de entrada, que pueden hacer que el analizador
léxico realice ese cambio de estado.
♦ Autómatas finitos. Son una formalización de los diagramas de transición de estados, los
cuales incluyen una designación de un estado inicial y uno o más estados de aceptación,
así como el conjunto de estados, caracteres de entrada y transiciones entre los estados.
Los estados aceptantes indican que se ha encontrado el lexema para cierto token. A diferencia de los diagramas de transición de estados, los autómatas finitos pueden realizar
transiciones sobre una entrada vacía, así como sobre los caracteres de entrada.
♦ Autómatas finitos deterministas. Un AFD es un tipo especial de autómata finito, el cual
tiene exactamente una transición saliente de cada estado, para cada símbolo de entrada.
Tampoco se permiten las transiciones sobre una entrada vacía. El AFD se puede simular
con facilidad, además de que realiza una buena implementación de un analizador léxico,
similar a un diagrama de transición.
♦ Autómatas finitos no deterministas. A los autómatas que no son AFDs se les conoce
como no deterministas. A menudo, los AFNs son más fáciles de diseñar que los AFDs.
Otra posible arquitectura para un analizador léxico es tabular todos los estados en los
que pueden encontrarse los AFNs para cada uno de los posibles patrones, a medida que
exploramos los caracteres de entrada.
♦ Conversión entre representaciones de patrones. Es posible convertir cualquier expresión
regular en un AFN de un tamaño aproximado, que reconozca el mismo lenguaje que
define la expresión regular. Además, cualquier AFN puede convertirse en un AFD para
el mismo patrón, aunque en el peor de los casos (que nunca se presenta en los lenguajes
de programación comunes), el tamaño del autómata puede crecer en forma exponencial.
También es posible convertir cualquier autómata finito no determinista o determinista en
una expresión regular que defina el mismo lenguaje reconocido por el autómata finito.
♦ Lex. Hay una familia de sistemas de software, incluyendo a Lex y Flex, que son generadores de analizadores léxicos. El usuario especifica los patrones para los tokens, usando
una notación de expresiones regulares extendidas. Lex convierte estas expresiones en un
analizador léxico, el cual en esencia es un autómata finito determinista que reconoce
cualquiera de los patrones.
♦ Minimización de autómatas finitos. Para cada AFD, hay un AFD con el mínimo número
de estados que acepta el mismo lenguaje. Además, el AFD con el mínimo número de
estados para un lenguaje dado es único, excepto por los nombres que se da a los diversos estados.
3.11 Referencias para el capítulo 3
3.11
189
Referencias para el capítulo 3
En la década de 1950, Kleene desarrolló por primera vez las expresiones regulares [9]. Kleene
estaba interesado en describir los eventos que podrían representarse por el modelo de autómatas finitos de actividad neuronal de McCullough y Pitts [12]. Desde entonces, las expresiones
regulares y los autómatas finitos se han utilizado en gran medida en la ciencia computacional.
Desde el comienzo, se han usado las expresiones regulares en diversas formas en muchas
herramientas populares de Unix, como awk, ed, egrep, grep, lex, sed, sh y vi. Los documentos de los estándares IEEE 1003 e ISO/IEC 9945 para la Interfaz de sistemas operativos
portables (POSIX) definen las expresiones regulares extendidas de POSIX, que son similares a
las expresiones regulares originales de Unix, con algunas excepciones como las representaciones
de nemónicos para las clases de caracteres. Muchos lenguajes de secuencias de comandos como
Perl, Python y Tcl han adoptado las expresiones regulares, pero a menudo con extensiones
incompatibles.
El conocido modelo de autómata finito y la minimización de autómatas finitos, como se vieron
en el Algoritmo 3.39, provienen de Huffman [6] y Moore [14]. Rabin y Scott [15] propusieron por
primera vez los autómatas finitos no deterministas; la construcción de subconjuntos del Algoritmo 3.20, que muestran la equivalencia de los autómatas finitos deterministas y no deterministas,
provienen de ahí.
McNaughton y Yamada [13] fueron los primeros en proporcionar un algoritmo para convertir las expresiones regulares de manera directa en autómatas finitos deterministas. Aho utilizó
por primera vez el algoritmo 3.36 descrito en la sección 3.9 para crear la herramienta para
relacionar expresiones regulares, conocida como egrep. Este algoritmo también se utilizó en
las rutinas para relacionar patrones de expresiones regulares en awk [3]. El método de utilizar
autómatas no deterministas como intermediarios se debe a Thompson [17]. Este último artículo
también contiene el algoritmo para la simulación directa de autómatas finitos no deterministas
(Algoritmo 3.22), que Thompson utilizó en el editor de texto QED.
Lesk desarrolló la primera versión de Lex; después Lesk y Schmidt crearon una segunda versión, utilizando el Algoritmo 3.36 [10]. Después de ello, se han implementado muchas variantes
de Lex. La versión de GNU llamada Flex puede descargarse, junto con su documentación, en [4].
Las versiones populares de Lex en Java incluyen a JFlex [7] y JLex [8].
El algoritmo de KMP, que describimos en los ejercicios para la sección 3.4, justo antes del
ejercicio 3.4.3, es de [11]. Su generalización para muchas palabras clave aparece en [2]; Aho lo
utilizó en la primera implementación de la herramienta fgrep de Unix.
La teoría de los autómatas finitos y las expresiones regulares se cubre en [5]. En [1] hay una
encuesta de técnicas para relacionar cadenas.
1. Aho, A. V., “Algorithms for finding patterns in strings”, en Handbook of Theoretical
Computer Science (J. van Leeuwen, ed.), Vol. A, Cap. 5, MIT Press, Cambridge, 1990.
2. Aho, A. V. y M. J. Corasick, “Efficient string matching: an aid to bibliographic search”,
Comm. ACM 18:6 (1975), pp. 333-340.
3. Aho, A. V., B. W. Kernigham y P. J. Weinberger, The AWK Programming Language,
Addison-Wesley, Boston, MA, 1988.
Capítulo 3. Análisis léxico
190
4. Página inicial de Flex, http://www.gnu.org/software/flex/, Fundación de software
libre.
5. Hopcroft, J. E., R. Motwani y J. D. Ullman, Introduction to Automata Theory, Languages and Computation, Addison-Wesley, Boston MA, 2006.
6. Huffman, D. A., “The synthesis of sequential machines”, J. Franklin Inst. 257 (1954),
pp. 3-4, 161, 190, 275-303.
7. Página inicial de JFlex, http://jflex.de/ .
8. http://www.cs.princeton.edu/~appel/modern/java/JLex.
9. Kleene, S. C., “Representation of events in nerve nets”, en [16], pp. 3-40.
10. Lesk, M. E., “Lex – a lexical analyzer generator”, Computing Science Tech. Reporte
39, Bell Laboratories, Murray Hill, NJ, 1975. Un documento similar con el mismo nombre del título, pero con E. Schmidt como coautor, aparece en el volumen 2 de Unix
Programmer’s Manual, Bell Laboratories, Murray Hill NJ, 1975; vea http://dino−
saur.compilertools.net/lex/index.html.
11. Knuth, D. E., J. H. Morris y V. R. Pratt, “Fast pattern matching in strings”, SIAM J.
Computing 6:2 (1977), pp. 323-350.
12. McCullough, W. S. y W. Pitts, “A logical calculus of the ideas immanent in nervous
activity”, Bull. Math. Biophysics 5 (1943), pp. 115-133.
13. McNaughton, R. y H. Yamada, “Regular expressions and state graphs for automata”,
IRE Trans. on Electronic Computers EC-9:1 (1960), pp. 38-47.
14. Moore, E. F., “Gedanken experiments on sequential machines”, en [16], pp. 129-153.
15. Rabin, M. O. y D. Scott, “Finite automata and their decision problems”, IBM J. Res.
and Devel. 3:2 (1959), pp. 114-125.
16. Shannon, C. y J. McCarthy (eds.), Automata Studies, Princeton Univ. Press, 1956.
17. Thompson, K., “Regular expression search algorithm”, Comm. ACM 11:6 (1968), pp.
419-422.
Capítulo 4
Análisis sintáctico
Este capítulo está dedicado a los métodos de análisis sintáctico que se utilizan, por lo general, en los
compiladores. Primero presentaremos los conceptos básicos, después las técnicas adecuadas para
la implementación manual y, por último, los algoritmos que se han utilizado en las herramientas
automatizadas. Debido a que los programas pueden contener errores sintácticos, hablaremos sobre
las extensiones de los métodos de análisis sintáctico para recuperarse de los errores comunes.
Por diseño, cada lenguaje de programación tiene reglas precisas, las cuales prescriben la
estructura sintáctica de los programas bien formados. Por ejemplo, en C un programa está
compuesto de funciones, una función de declaraciones e instrucciones, una instrucción de expresiones, y así sucesivamente. La sintaxis de las construcciones de un lenguaje de programación
puede especificarse mediante gramáticas libres de contexto o la notación BNF (Forma de Backus-Naur), que se presentó en la sección 2.2. Las gramáticas ofrecen beneficios considerables,
tanto para los diseñadores de lenguajes como para los escritores de compiladores.
• Una gramática proporciona una especificación sintáctica precisa, pero fácil de entender,
de un lenguaje de programación.
• A partir de ciertas clases de gramáticas, podemos construir de manera automática un
analizador sintáctico eficiente que determine la estructura sintáctica de un programa
fuente. Como beneficio colateral, el proceso de construcción del analizador sintáctico puede revelar ambigüedades sintácticas y puntos problemáticos que podrían haberse pasado
por alto durante la fase inicial del diseño del lenguaje.
• La estructura impartida a un lenguaje mediante una gramática diseñada en forma apropiada es útil para traducir los programas fuente en código objeto correcto, y para detectar
errores.
• Una gramática permite que un lenguaje evolucione o se desarrolle en forma iterativa, agregando
nuevas construcciones para realizar nuevas tareas. Estas nuevas construcciones pueden integrarse con más facilidad en una implementación que siga la estructura gramátical del lenguaje.
191
Capítulo 4. Análisis sintáctico
192
4.1
Introducción
En esta sección, examinaremos la forma en que el analizador sintáctico se acomoda en un
compilador ordinario. Después analizaremos las gramáticas comunes para las expresiones aritméticas. Las gramáticas para las expresiones son suficientes para ilustrar la esencia del análisis
sintáctico, ya que dichas técnicas de análisis para las expresiones se transfieren a la mayoría de
las construcciones de programación. Esta sección termina con una explicación sobre el manejo
de errores, ya que el analizador sintáctico debe responder de manera adecuada para descubrir
que su entrada no puede ser generada por su gramática.
4.1.1
La función del analizador sintáctico
En nuestro modelo de compilador, el analizador sintáctico obtiene una cadena de tokens del
analizador léxico, como se muestra en la figura 4.1, y verifica que la cadena de nombres de los
tokens pueda generarse mediante la gramática para el lenguaje fuente. Esperamos que el analizador sintáctico reporte cualquier error sintáctico en forma inteligible y que se recupere de los
errores que ocurren con frecuencia para seguir procesando el resto del programa. De manera
conceptual, para los programas bien formados, el analizador sintáctico construye un árbol de
análisis sintáctico y lo pasa al resto del compilador para que lo siga procesando. De hecho, el árbol
de análisis sintáctico no necesita construirse en forma explícita, ya que las acciones de comprobación y traducción pueden intercalarse con el análisis sintáctico, como veremos más adelante.
Por ende, el analizador sintáctico y el resto de la interfaz de usuario podrían implementarse sin
problemas mediante un solo módulo.
programa Analizador
léxico
fuente
token
obtener
siguiente
token
Analizador
sintáctico
árbol de
análisis
sintáctico
Resto de
front-end
representación
intermedia
Tabla de
símbolos
Figura 4.1: Posición del analizador sintáctico en el modelo del compilador
Existen tres tipos generales de analizadores para las gramáticas: universales, descendentes y
ascendentes. Los métodos universales de análisis sintáctico como el algoritmo de Cocke-YoungerKasami y el algoritmo de Earley pueden analizar cualquier gramática (vea las notas bibliográficas). Sin embargo, estos métodos generales son demasiado ineficientes como para usarse en la
producción de compiladores.
Los métodos que se utilizan, por lo regular, en los compiladores pueden clasificarse como
descendentes o ascendentes. Según sus nombres, los métodos descendentes construyen árboles de
análisis sintáctico de la parte superior (raíz) a la parte inferior (hojas), mientras que los métodos
ascendentes empiezan de las hojas y avanzan hasta la raíz. En cualquier caso, la entrada al analizador se explora de izquierda a derecha, un símbolo a la vez.
193
4.1 Introducción
Los métodos descendentes y ascendentes más eficientes sólo funcionan para subclases de
gramáticas, pero varias de estas clases, en especial las gramáticas LL y LR, son lo bastante
expresivas como para describir la mayoría de las construcciones sintácticas en los lenguajes
de programación modernos. Los analizadores sintácticos que se implementan en forma manual
utilizan con frecuencia gramáticas LL; por ejemplo, el método de análisis sintáctico predictivo
de la sección 2.4.2 funciona para las gramáticas LL. Los analizadores para la clase más extensa de gramáticas LR, por lo general, se construyen mediante herramientas automatizadas.
En este capítulo vamos a suponer que la salida del analizador sintáctico es cierta representación del árbol de análisis sintáctico para el flujo de tokens que proviene del analizador
léxico. En la práctica, hay una variedad de tareas que podrían realizarse durante el análisis
sintáctico, como la recolección de información de varios tokens para colocarla en la tabla de símbolos, la realización de la comprobación de tipos y otros tipos de análisis semántico, así como
la generación de código intermedio. Hemos agrupado todas estas actividades en el cuadro con el
título “Resto del front-end” de la figura 4.1. En los siguientes capítulos cubriremos con detalle
estas actividades.
4.1.2
Representación de gramáticas
Algunas de las gramáticas que examinaremos en este capítulo se presentan para facilitar la
representación de gramáticas. Las construcciones que empiezan con palabras clave como while
o int son muy fáciles de analizar, ya que la palabra clave guía la elección de la producción
gramátical que debe aplicarse para hacer que coincida con la entrada. Por lo tanto, nos concentraremos en las expresiones, que representan un reto debido a la asociatividad y la precedencia
de operadores.
La asociatividad y la precedencia se resuelvan en la siguiente gramática, que es similar a
las que utilizamos en el capítulo 2 para describir expresiones, términos y factores. E representa a las expresiones que consisten en términos separados por los signos +, T representa a los
términos que consisten en factores separados por los signos *, y F representa a los factores que
pueden ser expresiones entre paréntesis o identificadores:
E → E+T|T
T → T∗F|F
F → ( E ) | id
(4.1)
La gramática para expresiones (4.1) pertenece a la clase de gramáticas LR que son adecuadas
para el análisis sintáctico ascendentes. Esta gramática puede adaptarse para manejar operadores adicionales y niveles adicionales de precedencia. Sin embargo, no puede usarse para el
análisis sintáctico descendente, ya que es recursiva por la izquierda.
La siguiente variante no recursiva por la izquierda de la gramática de expresiones (4.1) se
utilizará para el análisis sintáctico descendente:
E
E
T
T
F
→
→
→
→
→
T E
+ T E | F T
∗ F T | ( E ) | id
(4.2)
Capítulo 4. Análisis sintáctico
194
La siguiente gramática trata a los signos + y * de igual forma, de manera que sirve para ilustrar las técnicas para el manejo de ambigüedades durante el análisis sintáctico:
E → E + E | E * E | ( E ) | id
(4.3)
Aquí, E representa a las expresiones de todo tipo. La gramática (4.3) permite más de un árbol
de análisis sintáctico para las expresiones como a + b * c.
4.1.3
Manejo de los errores sintácticos
El resto de esta sección considera la naturaleza de los errores sintácticos y las estrategias generales para recuperarse de ellos. Dos de estas estrategias, conocidas como recuperaciones en
modo de pánico y a nivel de frase, se describirán con más detalle junto con los métodos específicos de análisis sintáctico.
Si un compilador tuviera que procesar sólo programas correctos, su diseño e implementación se simplificaría en forma considerable. No obstante, se espera que un compilador ayude al
programador a localizar y rastrear los errores que, de manera inevitable, se infiltran en los programas, a pesar de los mejores esfuerzos del programador. Aunque parezca increíble, son pocos
los lenguajes que se diseñan teniendo en mente el manejo de errores, aun cuando éstos son tan
comunes. Nuestra civilización sería radicalmente distinta si los lenguajes hablados tuvieran
los mismos requerimientos en cuanto a precisión sintáctica que los lenguajes de computadora.
La mayoría de las especificaciones de los lenguajes de programación no describen la forma en
que un compilador debe responder a los errores; el manejo de los mismos es responsabilidad
del diseñador del compilador. La planeación del manejo de los errores desde el principio puede
simplificar la estructura de un compilador y mejorar su capacidad para manejar los errores.
Los errores de programación comunes pueden ocurrir en muchos niveles distintos.
• Los errores léxicos incluyen la escritura incorrecta de los identificadores, las palabras
clave o los operadores; por ejemplo, el uso de un identificador tamanioElipce en vez de
tamanioElipse, y la omisión de comillas alrededor del texto que se debe interpretar como
una cadena.
• Los errores sintácticos incluyen la colocación incorrecta de los signos de punto y coma, además de llaves adicionales o faltantes; es decir, “{” o “}”. Como otro ejemplo, en C o Java,
la aparición de una instrucción case sin una instrucción switch que la encierre es un error
sintáctico (sin embargo, por lo general, esta situación la acepta el analizador sintáctico y se
atrapa más adelante en el procesamiento, cuando el compilador intenta generar código).
• Los errores semánticos incluyen los conflictos de tipos entre los operadores y los operandos. Un
ejemplo es una instrucción return en un método de Java, con el tipo de resultado void.
• Los errores lógicos pueden ser cualquier cosa, desde un razonamiento incorrecto del programador en el uso (en un programa en C) del operador de asignación =, en vez del
operador de comparación ==. El programa que contenga = puede estar bien formado; sin
embargo, tal vez no refleje la intención del programador.
La precisión de los métodos de análisis sintáctico permite detectar los errores sintácticos con
mucha eficiencia. Varios métodos de análisis sintáctico, como los métodos LL y LR, detectan
4.1 Introducción
195
un error lo más pronto posible; es decir, cuando el flujo de tokens que proviene del analizador
léxico no puede seguirse analizando de acuerdo con la gramática para el lenguaje. Dicho en forma más precisa, tienen la propiedad de prefijo viable, lo cual significa que detectan la ocurrencia
de un error tan pronto como ven un prefijo de la entrada que no puede completarse para formar
una cadena válida en el lenguaje.
Otra de las razones para enfatizar la recuperación de los errores durante el análisis sintáctico es que muchos errores parecen ser sintácticos, sea cual fuere su causa, y se exponen cuando el
análisis sintáctico no puede continuar. Algunos errores semánticos, como los conflictos entre los
tipos, también pueden detectarse con eficiencia; sin embargo, la detección precisa de los errores
semánticos y lógicos en tiempo de compilación es, por lo general, una tarea difícil.
El mango de errores en un analizador sintáctico tiene objetivos que son simples de declarar,
pero difíciles de llevar a cabo:
• Reportar la presencia de errores con claridad y precisión.
• Recuperarse de cada error lo bastante rápido como para poder detectar los errores siguientes.
• Agregar una sobrecarga mínima al procesamiento de los programas correctos.
Por fortuna, los errores comunes son simples, y a menudo basta con un mecanismo simple
para su manejo.
¿De qué manera un mango de errores debe reportar la presencia de un error? Como mínimo, debe reportar el lugar en el programa fuente en donde se detectó un error, ya que hay una
buena probabilidad de que éste en sí haya ocurrido en uno de los pocos tokens anteriores. Una
estrategia común es imprimir la línea del problema con un apuntador a la posición en la que
se detectó el error.
4.1.4
Estrategias para recuperarse de los errores
Una vez que se detecta un error, ¿cómo debe recuperarse el analizador sintáctico? Aunque no
hay una estrategia que haya demostrado ser aceptable en forma universal, algunos métodos
pueden aplicarse en muchas situaciones. El método más simple es que el analizador sintáctico
termine con un mensaje de error informativo cuando detecte el primer error. A menudo se descubren errores adicionales si el analizador sintáctico puede restaurarse a sí mismo, a un estado
en el que pueda continuar el procesamiento de la entrada, con esperanzas razonables de que un
mayor procesamiento proporcione información útil para el diagnóstico. Si los errores se apilan,
es mejor para el compilador desistir después de exceder cierto límite de errores, que producir
una molesta avalancha de errores “falsos”.
El resto de esta sección se dedica a las siguientes estrategias de recuperación de los errores:
modo de pánico, nivel de frase, producciones de errores y corrección global.
Recuperación en modo de pánico
Con este método, al describir un error el analizador sintáctico descarta los símbolos de entrada,
uno a la vez, hasta encontrar un conjunto designado de tokens de sincronización. Por lo general, los tokens de sincronización son delimitadores como el punto y coma o }, cuya función en
196
Capítulo 4. Análisis sintáctico
el programa fuente es clara y sin ambigüedades. El diseñador del compilador debe seleccionar
los tokens de sincronización apropiados para el lenguaje fuente. Aunque la corrección en modo
de pánico a menudo omite una cantidad considerable de entrada sin verificar errores adicionales, tiene la ventaja de ser simple y, a diferencia de ciertos métodos que consideraremos más
adelante, se garantiza que no entrará en un ciclo infinito.
Recuperación a nivel de frase
Al descubrir un error, un analizador sintáctico puede realizar una corrección local sobre la
entrada restante; es decir, puede sustituir un prefijo de la entrada restante por alguna cadena
que le permita continuar. Una corrección local común es sustituir una coma por un punto y
coma, eliminar un punto y coma extraño o insertar un punto y coma faltante. La elección de la
corrección local se deja al diseñador del compilador. Desde luego que debemos tener cuidado de
elegir sustituciones que no nos lleven hacia ciclos infinitos, como sería, por ejemplo, si siempre
insertáramos algo en la entrada adelante del símbolo de entrada actual.
La sustitución a nivel de frase se ha utilizado en varios compiladores que reparan los errores, ya que puede corregir cualquier cadena de entrada. Su desventaja principal es la dificultad
que tiene para arreglárselas con situaciones en las que el error actual ocurre antes del punto
de detección.
Producciones de errores
Al anticipar los errores comunes que podríamos encontrar, podemos aumentar la gramática
para el lenguaje, con producciones que generen las construcciones erróneas. Un analizador
sintáctico construido a partir de una gramática aumentada por estas producciones de errores
detecta los errores anticipados cuando se utiliza una producción de error durante el análisis
sintáctico. Así, el analizador sintáctico puede generar diagnósticos de error apropiados sobre la
construcción errónea que se haya reconocido en la entrada.
Corrección global
Lo ideal sería que un compilador hiciera la menor cantidad de cambios en el procesamiento
de una cadena de entrada incorrecta. Hay algoritmos para elegir una secuencia mínima de
cambios, para obtener una corrección con el menor costo a nivel global. Dada una cadena
de entrada incorrecta x y una gramática G, estos algoritmos buscarán un árbol de análisis
sintáctico para una cadena y relacionada, de tal forma que el número de inserciones, eliminaciones y modificaciones de los tokens requeridos para transformar a x en y sea lo más pequeño
posible. Por desgracia, estos métodos son en general demasiado costosos para implementarlos en términos de tiempo y espacio, por lo cual estas técnicas sólo son de interés teórico en
estos momentos.
Hay que observar que un programa casi correcto tal vez no sea lo que el programador tenía
en mente. Sin embargo, la noción de la corrección con el menor costo proporciona una norma
para evaluar las técnicas de recuperación de los errores, la cual se ha utilizado para buscar
cadenas de sustitución óptimas para la recuperación a nivel de frase.
197
4.2 Gramáticas libres de contexto
4.2
Gramáticas libres de contexto
En la sección 2.2 se presentaron las gramáticas para describir en forma sistemática la sintaxis
de las construcciones de un lenguaje de programación, como las expresiones y las instrucciones.
Si utilizamos una variable sintáctica instr para denotar las instrucciones, y una variable expr
para denotar las expresiones, la siguiente producción:
instr
→
if ( expr ) instr else instr
(4.4)
especifica la estructura de esta forma de instrucción condicional. Entonces, otras producciones
definen con precisión lo que es una expr y qué más puede ser una instr.
En esta sección repasaremos la definición de una gramática libre de contexto y presentaremos la terminología para hablar acerca del análisis sintáctico. En especial, la noción de derivaciones es muy útil para hablar sobre el orden en el que se aplican las producciones durante
el análisis sintáctico.
4.2.1
La definición formal de una gramática libre de contexto
En la sección 2.2 vimos que una gramática libre de contexto (o simplemente gramática) consiste en terminales, no terminales, un símbolo inicial y producciones.
1. Los terminales son los símbolos básicos a partir de los cuales se forman las cadenas. El
término “nombre de token” es un sinónimo de “terminal”; con frecuencia usaremos la
palabra “token” en vez de terminal, cuando esté claro que estamos hablando sólo sobre
el nombre del token. Asumimos que las terminales son los primeros componentes de los
tokens que produce el analizador léxico. En (4.4), los terminales son las palabras reservadas if y else, y los símbolos “(” y “)”.
2. Los no terminales son variables sintácticas que denotan conjuntos de cadenas. En (4.4),
instr y expr son no terminales. Los conjuntos de cadenas denotados por los no terminales
ayudan a definir el lenguaje generado por la gramática. Los no terminales imponen una
estructura jerárquica sobre el lenguaje, que representa la clave para el análisis sintáctico
y la traducción.
3. En una gramática, un no terminal se distingue como el símbolo inicial, y el conjunto de
cadenas que denota es el lenguaje generado por la gramática. Por convención, las producciones para el símbolo inicial se listan primero.
4. Las producciones de una gramática especifican la forma en que pueden combinarse los
terminales y los no terminales para formar cadenas. Cada producción consiste en:
(a) Un no terminal, conocido como encabezado o lado izquierdo de la producción; esta
producción define algunas de las cadenas denotadas por el encabezado.
(b) El símbolo →. Algunas veces se ha utilizado ::= en vez de la flecha.
(c) Un cuerpo o lado derecho, que consiste en cero o más terminales y no terminales. Los
componentes del cuerpo describen una forma en que pueden construirse las cadenas
del no terminal en el encabezado.
Capítulo 4. Análisis sintáctico
198
Ejemplo 4.5: La gramática en la figura 4.2 define expresiones aritméticas simples. En esta gramática, los símbolos de los terminales son:
id + − * / ( )
Los símbolos de los no terminales son expresión, term y factor, y expresión es el símbolo inicial.
2
expresión
expresión
expresión
term
term
term
factor
factor
→
→
→
→
→
→
→
→
expresión + term
expresión − term
term
term * factor
term / factor
factor
( expresión )
id
Figura 4.2: Gramática para las expresiones aritméticas simples
4.2.2
Convenciones de notación
Para evitar siempre tener que decir que “éstos son los terminales”, “éstos son los no terminales”, etcétera, utilizaremos las siguientes convenciones de notación para las gramáticas durante
el resto de este libro:
1. Estos símbolos son terminales:
(a) Las primeras letras minúsculas del alfabeto, como a, b, c.
(b) Los símbolos de operadores como +, * , etcétera.
(c) Los símbolos de puntuación como paréntesis, coma, etcétera.
(d) Los dígitos 0, 1, …, 9.
(e) Las cadenas en negrita como id o if, cada una de las cuales representa un solo símbolo terminal.
2. Estos símbolos son no terminales:
(a) Las primeras letras mayúsculas del alfabeto, como A, B, C.
(b) La letra S que, al aparecer es, por lo general, el símbolo inicial.
(c) Los nombres en cursiva y minúsculas, como expr o instr.
(d) Al hablar sobre las construcciones de programación, las letras mayúsculas pueden
utilizarse para representar no terminales. Por ejemplo, los no terminales para las
expresiones, los términos y los factores se representan a menudo mediante E, T y F,
respectivamente.
199
4.2 Gramáticas libres de contexto
3. Las últimas letras mayúsculas del alfabeto, como X, Y, Z, representan símbolos gramaticales; es decir, pueden ser no terminales o terminales.
4. Las últimas letras minúsculas del alfabeto, como u, v, …, z, representan cadenas de terminales (posiblemente vacías).
5. Las letras griegas minúsculas α, β, γ, por ejemplo, representan cadenas (posiblemente
vacías) de símbolos gramaticales. Por ende, una producción genérica puede escribirse
como A → α, en donde A es el encabezado y α el cuerpo.
6. Un conjunto de producciones A → α1, A → α2, …, A → αk con un encabezado común A
(las llamaremos producciones A), puede escribirse como A → α1 | α2 | … | αk. A α1,
α2, …, αk les llamamos las alternativas para A.
7. A menos que se indique lo contrario, el encabezado de la primera producción es el símbolo inicial.
Ejemplo 4.6: Mediante estas convenciones, la gramática del ejemplo 4.5 puede rescribirse en
forma concisa como:
Las convenciones de notación nos indican que E, T y F son no terminales, y E es el símbolo
inicial. El resto de los símbolos son terminales.
2
4.2.3
Derivaciones
La construcción de un árbol de análisis sintáctico puede hacerse precisa si tomamos una vista
derivacional, en la cual las producciones se tratan como reglas de rescritura. Empezando con el
símbolo inicial, cada paso de rescritura sustituye a un no terminal por el cuerpo de una de sus
producciones. Esta vista derivacional corresponde a la construcción descendente de un árbol de
análisis sintáctico, pero la precisión que ofrecen las derivaciones será muy útil cuando hablemos
del análisis sintáctico ascendente. Como veremos, el análisis sintáctico ascendente se relaciona
con una clase de derivaciones conocidas como derivaciones de “más a la derecha”, en donde el
no terminal por la derecha se rescribe en cada paso.
Por ejemplo, considere la siguiente gramática, con un solo no terminal E, la cual agrega una
producción E → − E a la gramática (4.3):
E → E + E | E ∗ E | − E | ( E ) | id
(4.7)
La producción E → − E significa que si E denota una expresión, entonces − E debe también
denotar una expresión. La sustitución de una sola E por − E se describirá escribiendo lo siguiente:
E ⇒ −E
Capítulo 4. Análisis sintáctico
200
lo cual se lee como “E deriva a − E ”. La producción E → ( E ) puede aplicarse para sustituir
cualquier instancia de E en cualquier cadena de símbolos gramaticales por (E ); por ejemplo,
E ∗ E ⇒ (E ) ∗ E o E ∗ E ⇒ E ∗ (E ). Podemos tomar una sola E y aplicar producciones en forma repetida y en cualquier orden para obtener una secuencia de sustituciones. Por ejemplo,
E ⇒ −E ⇒ −(E ) ⇒ −(id)
A dicha secuencia de sustituciones la llamamos una derivación de −(id) a partir de E. Esta
derivación proporciona la prueba de que la cadena −(id) es una instancia específica de una
expresión.
Para una definición general de la derivación, considere un no terminal A en la mitad de
una secuencia de símbolos gramaticales, como en αAβ, en donde α y β son cadenas arbitrarias
de símbolos gramaticales. Suponga que A → γ es una producción. Entonces, escribimos αAβ
⇒ αγβ. El símbolo ⇒ significa, “se deriva en un paso”. Cuando una secuencia de pasos de
derivación α1 ⇒ α2 ⇒ … ⇒ αn se rescribe como α1 a αn, decimos que α1 deriva a αn. Con
frecuencia es conveniente poder decir, “deriva en cero o más pasos”. Para este fin, podemos
∗ . Así,
usar el símbolo ⇒
∗ α, para cualquier cadena α.
1. α ⇒
∗ β y β ⇒ γ, entonces α ⇒
∗ γ.
2. Si α ⇒
+
De igual forma, ⇒
significa “deriva en uno o más pasos”.
∗
Si S ⇒ α, en donde S es el símbolo inicial de una gramática G, decimos que α es una forma
de frase de G. Observe que una forma de frase puede contener tanto terminales como no terminales, y puede estar vacía. Un enunciado de G es una forma de frase sin símbolos no terminales.
El lenguaje generado por una gramática es su conjunto de oraciones. Por ende, una cadena de
terminales w está en L(G), el lenguaje generado por G, si y sólo si w es un enunciado de G
∗ w). Un lenguaje que puede generarse mediante una gramática se considera un lengua(o S ⇒
je libre de contexto. Si dos gramáticas generan el mismo lenguaje, se consideran como equivalentes.
La cadena −(id + id) es un enunciado de la gramática (4.7), ya que hay una derivación
E ⇒ −E ⇒ −(E ) ⇒ −(E + E ) ⇒ −(id + E ) ⇒ −(id + id)
(4.8)
Las cadenas E, −E, −(E ), …, −(id + id) son todas formas de frases de esta gramática. Escri∗ −(id + id) para indicar que −(id + id) puede derivarse de E.
bimos E ⇒
En cada paso de una derivación, hay dos elecciones por hacer. Debemos elegir qué no terminal
debemos sustituir, y habiendo realizado esta elección, debemos elegir una producción con ese no
terminal como encabezado. Por ejemplo, la siguiente derivación alternativa de −(id + id) difiere
de la derivación (4.8) en los últimos dos pasos:
E ⇒ −E ⇒ −(E ) ⇒ −(E + E ) ⇒ −(E + id) ⇒ −(id + id)
(4.9)
201
4.2 Gramáticas libres de contexto
Cada no terminal se sustituye por el mismo cuerpo en las dos derivaciones, pero el orden de
las sustituciones es distinto.
Para comprender la forma en que trabajan los analizadores sintácticos, debemos considerar las derivaciones en las que el no terminal que se va a sustituir en cada paso se elige de la
siguiente manera:
1. En las derivaciones por la izquierda, siempre se elige el no terminal por la izquierda en
cada de frase. Si α ⇒ β es un paso en el que se sustituye el no terminal por la izquierda
en α, escribimos α ⇒ β.
lm
2. En las derivaciones por la derecha, siempre se elige el no terminal por la derecha; en este
caso escribimos α ⇒ β.
rm
La derivación (4.8) es por la izquierda, por lo que puede rescribirse de la siguiente manera:
E ⇒ −E ⇒ −(E ) ⇒ −(E + E ) ⇒ −(id + E ) ⇒ −(id + id)
lm
lm
lm
lm
lm
Observe que (4.9) es una derivación por la derecha.
Si utilizamos nuestras convenciones de notación, cada paso por la izquierda puede escribirse como wAγ ⇒ wδγ, en donde w consiste sólo de terminales, A → δ es la producción que se
lm
aplica, y γ es una cadena de símbolos gramaticales. Para enfatizar que α deriva a β mediante
∗ β. Si S ⇒
∗ α, decimos que α es una forma de
una derivación por la izquierda, escribimos α ⇒
lm
lm
frase izquierda de la gramática en cuestión.
Las análogas definiciones son válidas para las derivaciones por la derecha. A estas derivaciones se les conoce algunas veces como derivaciones canónicas.
4.2.4
Árboles de análisis sintáctico y derivaciones
Un árbol de análisis sintáctico es una representación gráfica de una derivación que filtra el
orden en el que se aplican las producciones para sustituir los no terminales. Cada nodo interior
de un árbol de análisis sintáctico representa la aplicación de una producción. El nodo interior se
etiqueta con el no terminal A en el encabezado de la producción; los hijos del nodo se etiquetan,
de izquierda a derecha, mediante los símbolos en el cuerpo de la producción por la que se sustituyó esta A durante la derivación.
Por ejemplo, el árbol de análisis sintáctico para −(id + id) en la figura 4.3 resulta de la
derivación (4.8), así como de la derivación (4.9).
Las hojas de un árbol de análisis sintáctico se etiquetan mediante no terminales o terminales y, leídas de izquierda a derecha, constituyen una forma de frase, a la cual se le llama
producto o frontera del árbol.
Para ver la relación entre las derivaciones y los árboles de análisis sintáctico, considere cualquier derivación α1 ⇒ α2 ⇒ … ⇒ αn, en donde α1 es un sólo no terminal A. Para cada forma
de frase αi en la derivación, podemos construir un árbol de análisis sintáctico cuyo producto
sea αi. El proceso es una inducción sobre i.
BASE: El árbol para α1 = A es un solo nodo, etiquetado como A.
202
Capítulo 4. Análisis sintáctico
Figura 4.3: Árbol de análisis sintáctico para −(id + id)
INDUCCIÓN: Suponga que ya hemos construido un árbol de análisis sintáctico con el pro-
ducto αi−1 = X1X2 …Xk (tenga en cuenta que, de acuerdo a nuestras convenciones de notación,
cada símbolo gramatical Xi es un no terminal o un terminal). Suponga que αi se deriva de αi−1
al sustituir Xj, un no terminal, por β = Y1Y2 …Ym. Es decir, en el i-ésimo paso de la derivación,
la producción Xj → β se aplica a αi−1 para derivar αi = X1X2 …Xj−1βXj+1 …Xk.
Para modelar este paso de la derivación, buscamos la j-ésima hoja, partiendo de la izquierda
en el árbol de análisis sintáctico actual. Esta hoja se etiqueta como Xj. A esta hoja le damos m
hijos, etiquetados Y1,Y2, …,Ym, partiendo de la izquierda. Como caso especial, si m = 0 entonces β = , y proporcionamos a la j-ésima hoja un hijo etiquetado como .
Ejemplo 4.10: La secuencia de árboles de análisis sintáctico que se construyen a partir de la
derivación (4.8) se muestra en la figura 4.4. En el primer paso de la derivación, E ⇒ −E. Para
modelar este paso, se agregan dos hijos, etiquetados como − y E, a la raíz E del árbol inicial.
El resultado es el segundo árbol.
En el segundo paso de la derivación, −E ⇒ −(E ). Por consiguiente, agregamos tres hijos,
etiquetados como (, E y ), al nodo hoja etiquetado como E del segundo árbol, para obtener el
tercer árbol con coséchale producto −(E ). Si continuamos de esta forma, obtenemos el árbol
de análisis sintáctico completo como el sexto árbol.
2
Como un árbol de análisis sintáctico ignora las variaciones en el orden en el que se sustituyen los símbolos en las formas de las oraciones, hay una relación de varios a uno entre las
derivaciones y los árboles de análisis sintáctico. Por ejemplo, ambas derivaciones (4.8) y (4.9)
se asocian con el mismo árbol de análisis sintáctico final de la figura 4.4.
En lo que sigue, realizaremos con frecuencia el análisis sintáctico produciendo una derivación por la izquierda o por la derecha, ya que hay una relación de uno a uno entre los árboles
de análisis sintáctico y este tipo de derivaciones. Tanto las derivaciones por la izquierda como
las de por la derecha eligen un orden específico para sustituir símbolos en las formas de las
oraciones, por lo que también filtran las variaciones en orden. No es difícil mostrar que todos
los árboles sintácticos tienen asociadas una derivación única por la izquierda y una derivación
única por la derecha.
4.2 Gramáticas libres de contexto
203
Figura 4.4: Secuencia de árboles de análisis sintáctico para la derivación (4.8)
4.2.5
Ambigüedad
En la sección 2.2.4 vimos que una gramática que produce más de un árbol de análisis sintáctico
para cierto enunciado es ambiguo. Dicho de otra forma, una gramática ambigua es aquella que
produce más de una derivación por la izquierda, o más de una derivación por la derecha para
el mismo enunciado.
Ejemplo 4.11: La gramática de expresiones aritméticas (4.3) permite dos derivaciones por la
izquierda distintas para el enunciado id + id ∗ id:
Los árboles de análisis sintáctico correspondientes aparecen en la figura 4.5.
Observe que el árbol de análisis sintáctico de la figura 4.5(a) refleja la precedencia que se
asume comúnmente para + y *, mientras que el árbol de la figura 4.5(b) no. Es decir, lo común
es tratar al operador * teniendo mayor precedencia que +, en forma correspondiente al hecho
de que, por lo general, evaluamos la expresión a + b ∗ c como a + (b ∗ c), en vez de hacerlo
como (a + b) ∗ c.
2
Para la mayoría de los analizadores sintácticos, es conveniente que la gramática no tenga
ambigüedades, ya que de lo contrario, no podemos determinar en forma única qué árbol de
análisis sintáctico seleccionar para un enunciado. En otros casos, es conveniente usar gramáticas ambiguas elegidas con cuidado, junto con reglas para eliminar la ambigüedad, las cuales
“descartan” los árboles sintácticos no deseados, dejando sólo un árbol para cada enunciado.
Capítulo 4. Análisis sintáctico
204
Figura 4.5: Dos árboles de análisis sintáctico para id+id*id
4.2.6
Verificación del lenguaje generado por una gramática
Aunque los diseñadores de compiladores muy raras veces lo hacen para una gramática de lenguaje de programación completa, es útil poder razonar que un conjunto dado de producciones
genera un lenguaje específico. Las construcciones problemáticas pueden estudiarse mediante la
escritura de una gramática abstracta y concisa, y estudiando el lenguaje que genera. A continuación vamos a construir una gramática de este tipo, para instrucciones condicionales.
Una prueba de que una gramática G genera un lenguaje L consta de dos partes: mostrar
que todas las cadenas generadas por G están en L y, de manera inversa, que todas las cadenas
en L pueden generarse sin duda mediante G.
Ejemplo 4.12: Considere la siguiente gramática:
S→(S)S|
(4.13)
Tal vez no sea evidente desde un principio, pero esta gramática simple genera todas las
cadenas de paréntesis balanceados, y sólo ese tipo de cadenas. Para ver por qué, primero mostraremos que todas las frases que se derivan de S son balanceadas, y después que todas las
cadenas balanceadas se derivan de S. Para mostrar que todas las frases que pueden derivarse de S son balanceadas, utilizaremos una prueba inductiva sobre el número de pasos n en una
derivación.
BASE: La base es n = 1. La única cadena de terminales que puede derivarse de S en un paso
es la cadena vacía, que sin duda está balanceada.
INDUCCIÓN: Ahora suponga que todas las derivaciones de menos de n pasos producen fra-
ses balanceadas, y considere una derivación por la izquierda, con n pasos exactamente. Dicha
derivación debe ser de la siguiente forma:
Las derivaciones de x y y que provienen de S requieren menos de n pasos, por lo que en
base a la hipótesis inductiva, x y y están balanceadas. Por lo tanto, la cadena (x)y debe ser
balanceada. Es decir, tiene un número equivalente de paréntesis izquierdos y derechos, y cada
prefijo tiene, por lo menos, la misma cantidad de paréntesis izquierdos que derechos.
205
4.2 Gramáticas libres de contexto
Habiendo demostrado entonces que cualquier cadena que se deriva de S está balanceada,
debemos ahora mostrar que todas las cadenas balanceadas se derivan de S. Para ello, utilizaremos la inducción sobre la longitud de una cadena.
BASE: Si la cadena es de longitud 0, debe ser , la cual está balanceada.
INDUCCIÓN: Primero, observe que todas las cadenas balanceadas tienen longitud uniforme.
Suponga que todas las cadenas balanceadas de una longitud menor a 2n se derivan de S, y
considere una cadena balanceada w de longitud 2n, n ≥ 1. Sin duda, w empieza con un paréntesis izquierdo. Hagamos que (x ) sea el prefijo no vacío más corto de w, que tenga el mismo
número de paréntesis izquierdos y derechos. Así, w puede escribirse como w = (x )y , en donde
x y y están balanceadas. Como x y y tienen una longitud menor a 2n, pueden derivarse de S mediante la hipótesis inductiva. Por ende, podemos buscar una derivación de la siguiente forma:
∗ (x )S ⇒
∗ (x )y
S ⇒ (S )S ⇒
con lo cual demostramos que w = (x )y también puede derivarse de S.
4.2.7
2
Comparación entre gramáticas libres de contexto
y expresiones regulares
Antes de dejar esta sección sobre las gramáticas y sus propiedades, establecemos que las gramáticas son una notación más poderosa que las expresiones regulares. Cada construcción que puede describirse mediante una expresión regular puede describirse mediante una gramática, pero
no al revés. De manera alternativa, cada lenguaje regular es un lenguaje libre de contexto, pero no
al revés.
Por ejemplo, la expresión regular (a|b)∗abb y la siguiente gramática:
A0 → aA0 | bA0 | aA1
A1 → bA2
A2 → bA3
A3 → describen el mismo lenguaje, el conjunto de cadenas de as y bs que terminan en abb.
Podemos construir de manera mecánica una gramática para reconocer el mismo lenguaje
que un autómata finito no determinista (AFN). La gramática anterior se construyó a partir del
AFN de la figura 3.24, mediante la siguiente construcción:
1. Para cada estado i del AFN, crear un no terminal Ai.
2. Si el estado i tiene una transición al estado j con la entrada a, agregar la producción Ai →
aAj. Si el estado i pasa al estado j con la entrada , agregar la producción Ai → Aj.
3. Si i es un estado de aceptación, agregar Ai → .
4. Si i es el estado inicial, hacer que Ai sea el símbolo inicial de la gramática.
Capítulo 4. Análisis sintáctico
206
Por otra parte, el lenguaje L = {anbn | n ≥ 1} con un número equivalente de as y bs es un
ejemplo de prototipo de un lenguaje que puede describirse mediante una gramática, pero no
mediante una expresión regular. Para ver por qué, suponga que L es el lenguaje definido por
alguna expresión regular. Construiríamos un AFD D con un número finito de estados, por decir
k, para aceptar a L. Como D sólo tiene k estados, para una entrada que empieza con más de k as,
D debe entrar a cierto estado dos veces, por decir si, como en la figura 4.6. Suponga que la ruta
de si de vuelta a sí mismo se etiqueta con una secuencia aj−i. Como aibi está en el lenguaje, debe
haber una ruta etiquetada como bi desde si hasta un estado de aceptación f. Pero, entonces
también hay una ruta que sale desde el estado s 0 y pasa a través de si para llegar a f, etiquetada
como ajbi, como se muestra en la figura 4.6. Por ende, D también acepta a ajbi, que no está en
el lenguaje, lo cual contradice la suposición de que L es el lenguaje aceptado por D.
ruta etiquetada como a j − i
ruta etiquetada como ai
ruta etiquetada como bi
Figura 4.6: Un AFD D que acepta a aibi y a ajbi
En lenguaje coloquial, decimos que “los autómatas finitos no pueden contar”, lo cual significa que un autómata finito no puede aceptar un lenguaje como {anbn | n ≥ 1}, que requiera
que el autómata lleve la cuenta del número de as antes de ver las bs. De igual forma, “una
gramática puede contar dos elementos pero no tres”, como veremos cuando hablemos sobre las
construcciones de lenguajes que no son libres de contexto en la sección 4.3.5.
4.2.8
Ejercicios para la sección 4.2
Ejercicio 4.2.1: Considere la siguiente gramática libre de contexto:
S→SS+|SS∗|a
y la cadena aa + a∗.
a) Proporcione una derivación por la izquierda para la cadena.
b) Proporcione una derivación por la derecha para la cadena.
c) Proporcione un árbol de análisis sintáctico para la cadena.
! d) ¿La gramática es ambigua o no? Justifique su respuesta.
! e) Describa el lenguaje generado por esta gramática.
Ejercicio 4.2.2: Repita el ejercicio 4.2.1 para cada una de las siguientes gramáticas y cadenas:
207
4.2 Gramáticas libres de contexto
a) S → 0 S 1 | 0 1 con la cadena 000111.
b) S → + S S | ∗ S S | a con la cadena + ∗ aaa.
! c) S →
S ( S ) S | con la cadena (()()).
! d) S →
S + S | S S | ( S ) | S ∗ | a con la cadena (a + a) ∗ a.
! e) S → ( L ) | a y L → L, S | S con la cadena ((a, a), a, (a)).
!! f) S
→
a S b S | b S a S | con la cadena aabbab.
! g) La siguiente gramática para las expresiones booleanas:
bexpr
bterm
bfactor
→
→
→
bexpr or bterm | bterm
bterm and bfactor | bfactor
not bfactor | ( bexpr ) | true | false
Ejercicio 4.2.3: Diseñe gramáticas para los siguientes lenguajes:
a) El conjunto de todas las cadenas de 0s y 1s, de tal forma que justo antes de cada 0 vaya
por lo menos un 1.
! b) El conjunto de todas las cadenas de 0s y 1s que sean palíndromos; es decir, que la cadena
se lea igual al derecho y al revés.
! c) El conjunto de todas las cadenas de 0s y 1s con un número igual de 0s y 1s.
!! d) El conjunto de todas las cadenas de 0s y 1s con un número desigual de 0s y 1s.
! e) El conjunto de todas las cadenas de 0s y 1s en donde 011 no aparece como una subcadena.
!! f) El conjunto de todas las cadenas de 0s y 1s de la forma xy, en donde x ≠ y, y x y y tienen
la misma longitud.
! Ejercicio 4.2.4: Hay una notación de gramática extendida de uso común. En esta notación,
los corchetes y las llaves en los cuerpos de las producciones son meta símbolos (como → o |)
con los siguientes significados:
i) Los corchetes alrededor de un símbolo o símbolos gramaticales denota que estas construcciones son opcionales. Por ende, la producción A → X [Y ] Z tiene el mismo efecto
que las dos producciones A → X Y Z y A → X Z.
ii) Las llaves alrededor de un símbolo o símbolos gramaticales indican que estos símbolos
pueden repetirse cualquier número de veces, incluyendo cero. Por ende, A → X {Y Z}
tiene el mismo efecto que la secuencia infinita de producciones A → X, A → X Y Z,
A → X Y Z Y Z, y así sucesivamente.
Capítulo 4. Análisis sintáctico
208
Muestre que estas dos extensiones no agregan potencia a las gramáticas; es decir, cualquier
lenguaje que pueda generarse mediante una gramática con estas extensiones, podrá generarse
mediante una gramática sin las extensiones.
Ejercicio 4.2.5: Use las llaves descritas en el ejercicio 4.2.4 para simplificar la siguiente gramática para los bloques de instrucciones y las instrucciones condicionales:
instr
→
|
|
listaInstr →
if expr then instr else instr
if instr then instr
begin listaInstr end
instr ; listaInstr | instr
! Ejercicio 4.2.6: Extienda la idea del ejercicio 4.2.4 para permitir cualquier expresión regular
de símbolos gramaticales en el cuerpo de una producción. Muestre que esta extensión no permite que las gramáticas definan nuevos lenguajes.
! Ejercicio 4.2.7: Un símbolo gramatical X (terminal o no terminal) es inútil si no hay deri∗ wXy ⇒
∗ wxy. Es decir, X nunca podrá aparecer en la derivación de un
vación de la forma S ⇒
enunciado.
a) Proporcione un algoritmo para eliminar de una gramática todas las producciones que
contengan símbolos inútiles.
b) Aplique su algoritmo a la siguiente gramática:
S
A
B
→ 0|A
→ AB
→ 1
Ejercicio 4.2.8: La gramática en la figura 4.7 genera declaraciones para un solo identificador numérico; estas declaraciones involucran a cuatro propiedades distintas e independientes
de números.
instr
listaOpciones
opcion
modo
escala
precision
base
→
→
→
→
→
→
→
declare id listaOpciones
listaOpciones opcion | modo | escala | precision | base
real | complex
fixed | floating
single | double
binary | decimal
Figura 4.7: Una gramática para declaraciones con varios atributos
a) Generalice la gramática de la figura 4.7, permitiendo n opciones Ai, para algunas n fijas
y para i = 1, 2, …, n, en donde Ai puede ser ai o bi. Su gramática deberá usar sólo O(n)
símbolos gramaticales y tener una longitud total de producciones igual a O(n).
209
4.3 Escritura de una gramática
! b) La gramática de la figura 4.7 y su generalización en la parte (a) permite declaraciones
que son contradictorias o redundantes, tales como:
declare foo real fixed real floating
Podríamos insistir en que la sintaxis del lenguaje prohíbe dichas declaraciones; es decir,
cada declaración generada por la gramática tiene exactamente un valor para cada una
de las n opciones. Si lo hacemos, entonces para cualquier n fija hay sólo un número finito de declaraciones legales. Por ende, el lenguaje de declaraciones legales tiene una gramática (y también una expresión regular), al igual que cualquier lenguaje finito. La gramática
obvia, en la cual el símbolo inicial tiene una producción para cada declaración legal, tiene n!
producciones y una longitud de producciones total de O(n × n!). Hay que esforzarse más:
una longitud de producciones total que sea O(n2n).
!! c) Muestre que cualquier gramática para la parte (b) debe tener una longitud de producciones total de por lo menos 2n.
d) ¿Qué dice la parte (c) acerca de la viabilidad de imponer la no redundancia y la no
contradicción entre las opciones en las declaraciones, a través de la sintaxis del lenguaje
de programación?
4.3
Escritura de una gramática
Las gramáticas son capaces de describir casi la mayoría de la sintaxis de los lenguajes de programación. Por ejemplo, el requerimiento de que los identificadores deben declararse antes de
usarse, no puede describirse mediante una gramática libre de contexto. Por lo tanto, las secuencias de los tokens que acepta un analizador sintáctico forman un superconjunto del lenguaje
de programación; las fases siguientes del compilador deben analizar la salida del analizador
sintáctico, para asegurar que cumpla con las reglas que no verifica el analizador sintáctico.
Esta sección empieza con una discusión acerca de cómo dividir el trabajo entre un analizador léxico y un analizador sintáctico. Después consideraremos varias transformaciones que
podrían aplicarse para obtener una gramática más adecuada para el análisis sintáctico. Una
técnica puede eliminar la ambigüedad en la gramática, y las otras (eliminación de recursividad
por la izquierda y factorización por la izquierda) son útiles para rescribir las gramáticas, de
manera que sean adecuadas para el análisis sintáctico descendente. Concluiremos esta sección
considerando algunas construcciones de los lenguajes de programación que ninguna gramática
puede describir.
4.3.1
Comparación entre análisis léxico y análisis sintáctico
Como observamos en la sección 4.2.7, todo lo que puede describirse mediante una expresión
regular también puede describirse mediante una gramática. Por lo tanto, sería razonable preguntar: “¿Por qué usar expresiones regulares para definir la sintaxis léxica de un lenguaje?”
Existen varias razones.
Capítulo 4. Análisis sintáctico
210
1. Al separar la estructura sintáctica de un lenguaje en partes léxicas y no léxicas, se proporciona una manera conveniente de colocar en módulos la interfaz de usuario de un
compilador en dos componentes de un tamaño manejable.
2. Las reglas léxicas de un lenguaje son con frecuencia bastante simples, y para describirlas
no necesitamos una notación tan poderosa como las gramáticas.
3. Por lo general, las expresiones regulares proporcionan una notación más concisa y fácil
de entender para los tokens, en comparación con las gramáticas.
4. Pueden construirse analizadores léxicos más eficientes en forma automática a partir de
expresiones regulares, en comparación con las gramáticas arbitrarias.
No hay lineamientos firmes en lo que se debe poner en las reglas léxicas, en contraste a las reglas sintácticas. Las expresiones regulares son muy útiles para describir la estructura de las
construcciones como los identificadores, las constantes, las palabras reservadas y el espacio en
blanco. Por otro lado, las gramáticas son muy útiles para describir estructuras anidadas, como
los paréntesis balanceados, las instrucciones begin-end relacionadas, las instrucciones if-then-else
correspondientes, etcétera. Estas estructuras anidadas no pueden describirse mediante las expresiones regulares.
4.3.2
Eliminación de la ambigüedad
Algunas veces, una gramática ambigua puede rescribirse para eliminar la ambigüedad. Como
ejemplo, vamos a eliminar la ambigüedad de la siguiente gramática del “else colgante”:
instr
→
|
|
if expr then instr
if expr then instr else instr
otra
(4.14)
Aquí, “otra” representa a cualquier otra instrucción. De acuerdo con esta gramática, la siguiente instrucción condicional compuesta:
instr
instr
instr
instr
instr
Figura 4.8: Árbol de análisis sintáctico para una instrucción condicional
211
4.3 Escritura de una gramática
tiene el árbol de análisis sintáctico que se muestra en la figura 4.8.1 La gramática (4.14) es
ambigua, ya que la cadena
if E1 then if E2 then S1 else S2
(4.15)
tiene los dos árboles de análisis sintáctico que se muestran en la figura 4.9.
instr
instr
instr
instr
instr
instr
instr
instr
Figura 4.9: Dos árboles de análisis sintáctico para un enunciado ambiguo
En todos los lenguajes de programación con instrucciones condicionales de esta forma, se
prefiere el primer árbol de análisis sintáctico. La regla general es, “Relacionar cada else con el
then más cercano que no esté relacionado”.2 Esta regla para eliminar ambigüedad puede, en
teoría, incorporarse directamente en una gramática, pero en la práctica raras veces se integra
a las producciones.
Ejemplo 4.16: Podemos rescribir la gramática del else colgante (4.14) como la siguiente
gramática sin ambigüedades. La idea es que una instrucción que aparece entre un then y un
else debe estar “relacionada”; es decir, la instrucción interior no debe terminar con un then
sin relacionar o abierto. Una instrucción relacionada es una instrucción if-then-else que no
contiene instrucciones abiertas, o es cualquier otro tipo de instrucción incondicional. Por ende,
podemos usar la gramática de la figura 4.10. Esta gramática genera las mismas cadenas que
la gramática del else colgante (4.14), pero sólo permite un análisis sintáctico para la cadena
(4.15); en específico, el que asocia a cada else con la instrucción then más cercana que no
haya estado relacionada antes. 2
Los subíndices en E y S son sólo para diferenciar las distintas ocurrencias del mismo no terminal, por lo cual no
implican no terminales distintos.
2
Hay que tener en cuenta que C y sus derivados se incluyen en esta clase. Aun cuando la familia de lenguajes C
no utiliza la palabra clave then, su función se representa mediante el paréntesis de cierre para la condición que va
después de if.
1
Capítulo 4. Análisis sintáctico
212
instr →
|
instr-relacionada →
|
instr-abierta →
|
instr-relacionada
instr-abierta
if expr then instr-relacionada else instr-relacionada
otra
if expr then instr
if expr then instr-relacionada else instr-abierta
Figura 4.10: Gramática sin ambigüedades para las instrucciones if-then-else
4.3.3
Eliminación de la recursividad por la izquierda
Una gramática es recursiva por la izquierda si tiene una terminal A tal que haya una derivación
+
A ⇒ Aα para cierta cadena α. Los métodos de análisis sintáctico descendentes no pueden manejar las gramáticas recursivas por la izquierda, por lo que se necesita una transformación para
eliminar la recursividad por la izquierda. En la sección 2.4.5 hablamos sobre la recursividad
inmediata por la izquierda, en donde hay una producción de la forma A → Aα. Aquí estudiaremos el caso general. En la sección 2.4.5, mostramos cómo el par recursivo por la izquierda de
producciones A → Aα | β podía sustituirse mediante las siguientes producciones no recursivas
por la izquierda:
A → βA
A → αA | sin cambiar las cadenas que se derivan de A. Esta regla por sí sola basta para muchas gramáticas.
Ejemplo 4.17: La gramática de expresiones no recursivas por la izquierda (4.2), que se repite
a continuación:
E → T E
E → + T E
T → F T
T → ∗ F T
F → ( E ) | id
se obtiene mediante la eliminación de la recursividad inmediata por la izquierda de la gramática de expresiones (4.1). El par recursivo por la izquierda de las producciones E → E + T | T
se sustituye mediante E → T E y E → + T E | . Las nuevas producciones para T y T se
obtienen de manera similar, eliminando la recursividad inmediata por la izquierda.
2
La recursividad inmediata por la izquierda puede eliminarse mediante la siguiente técnica,
que funciona para cualquier número de producciones A. En primer lugar, se agrupan las producciones de la siguiente manera:
A → Aα1 | Aα2 | … | Aαm | β1 | β2 | … | βn
en donde ninguna βi termina con una A. Después, se sustituyen las producciones A mediante
lo siguiente:
213
4.3 Escritura de una gramática
A → β1A | β2A | … | βnA
A → α1A | α2A | … | αmA | El no terminal A genera las mismas cadenas que antes, pero ya no es recursiva por la izquierda. Este procedimiento elimina toda la recursividad por la izquierda de las producciones A y
A (siempre y cuando ninguna αi sea ), pero no elimina la recursividad por la izquierda que
incluye a las derivaciones de dos o más pasos. Por ejemplo, considere la siguiente gramática:
S→Aa | b
A→Ac | Sd | (4.18)
El no terminal S es recursiva por la izquierda, ya que S ⇒ Aa ⇒ Sda, pero no es inmediatamente recursiva por la izquierda.
El Algoritmo 4.19, que se muestra a continuación, elimina en forma sistemática la recursividad por la izquierda de una gramática. Se garantiza que funciona si la gramática no tiene ciclos
+
(derivaciones de la forma A ⇒ A) o producciones (producciones de la forma A → ). Los ciclos pueden eliminarse en forma sistemática de una gramática, al igual que las producciones (vea los ejercicios 4.4.6 y 4.4.7).
Algoritmo 4.19: Eliminación de la recursividad por la izquierda.
ENTRADA: La gramática G sin ciclos ni producciones .
SALIDA: Una gramática equivalente sin recursividad por la izquierda.
MÉTODO: Aplicar el algoritmo de la figura 4.11 a G. Observe que la gramática no recursiva
por la izquierda resultante puede tener producciones .
1)
2)
3)
4)
5)
6)
7)
2
ordenar los no terminales de cierta forma A1, A2, …, An.
for ( cada i de 1 a n ) {
for ( cada j de 1 a i − 1 ) {
sustituir cada producción de la forma Ai → Ajγ por las
producciones Ai → δ1γ | δ2γ | … | δkγ, en donde
Aj → δ1 | δ2 | … | δk sean todas producciones Aj actuales
}
eliminar la recursividad inmediata por la izquierda entre las producciones Ai
}
Figura 4.11: Algoritmo para eliminar la recursividad por la izquierda de una gramática
El procedimiento en la figura 4.11 funciona de la siguiente manera. En la primera iteración
para i = 1, el ciclo for externo de las líneas (2) a la (7) elimina cualquier recursividad inmediata
por la izquierda entre las producciones A1. Cualquier producción A1 restante de la forma A1 →
Al α debe, por lo tanto, tener l > 1. Después de la i-1-ésima iteración del ciclo for externo, todas
las no terminales Ak, en donde k < i, se “limpian”; es decir, cualquier producción Ak → Al α debe
tener l > k. Como resultado, en la i-ésima iteración, el ciclo interno de las líneas (3) a la (5) eleva
en forma progresiva el límite inferior en cualquier producción Ai → Amα, hasta tener m ¦ i.
Capítulo 4. Análisis sintáctico
214
Después, la eliminación de la recursividad inmediata por la izquierda para las producciones Ai
en la línea (6) obliga a que m sea mayor que i.
Ejemplo 4.20: Vamos a aplicar el Algoritmo 4.19 a la gramática (4.18). Técnicamente, no
se garantiza que el algoritmo vaya a funcionar debido a la producción , pero en este caso, la
producción A → resulta ser inofensiva.
Ordenamos los no terminales S, A. No hay recursividad inmediata por la izquierda entre
las producciones S, por lo que no ocurre nada durante el ciclo externo para i = 1. Para i = 2,
sustituimos la S en A → S d para obtener las siguientes producciones A.
A→Ac|Aad|bd|
Al eliminar la recursividad inmediata por la izquierda entre las producciones A produce la
siguiente gramática:
S→Aa | b
A → b d A | A
A → c A | a d A | 2
4.3.4
Factorización por la izquierda
La factorización por la izquierda es una transformación gramátical, útil para producir una gramática adecuada para el análisis sintáctico predictivo, o descendente. Cuando la elección entre
dos producciones A alternativas no está clara, tal vez podamos rescribir las producciones para
diferir la decisión hasta haber visto la suficiente entrada como para poder realizar la elección
correcta.
Por ejemplo, si tenemos las siguientes dos producciones:
instr →
|
if expr then instr else instr
if expr then instr
al ver la entrada if, no podemos saber de inmediato qué producción elegir para expandir instr.
En general, si A → αβ1 | αβ2 son dos producciones A, y la entrada empieza con una cadena no
vacía derivada de α, no sabemos si debemos expandir A a αβ1 o a αβ2. No obstante, podemos
diferir la decisión si expandimos A a αA. Así, después de ver la entrada derivada de α, expandimos A a β1 o a β2. Es decir, si se factorizan por la izquierda, las producciones originales se
convierten en:
A → αA
A → β1 | β2
Algoritmo 4.21: Factorización por la izquierda de una gramática.
ENTRADA: La gramática G.
SALIDA: Una gramática equivalente factorizada por la izquierda.
215
4.3 Escritura de una gramática
MÉTODO: Para cada no terminal A, encontrar el prefijo α más largo que sea común para una
o más de sus alternativas. Si α ≠ (es decir, si hay un prefijo común no trivial), se sustituyen
todas las producciones A, A → αβ1 | αβ2 | … | αβn | γ, en donde γ representa a todas las alternativas que no empiezan con α, mediante lo siguiente:
A → αA | γ
A → β1 | β2 | … | βn
Aquí, A es un no terminal nuevo. Se aplica esta transformación en forma repetida hasta que
no haya dos alternativas para un no terminal que tengan un prefijo común.
2
Ejemplo 4.22: La siguiente gramática abstrae el problema del “else colgante”:
S→iEtS | iEtSeS | a
E→b
(4.23)
Aquí, i, t y e representan a if, then y else; E y S representan “expresión condicional” e “instrucción”. Si se factoriza a la izquierda, esta gramática se convierte en:
S → i E t S S | a
S → e S | E→b
(4.24)
Así, podemos expandir S a iEtSS con la entrada i, y esperar hasta que se haya visto iEtS para
decidir si se va a expandir S a eS o a . Desde luego que estas gramáticas son ambiguas, y con
la entrada e no quedará claro qué alternativa debe elegirse para S . El ejemplo 4.33 habla sobre
cómo salir de este dilema. 2
4.3.5
Construcciones de lenguajes que no son libres de contexto
Algunas construcciones sintácticas que se encuentran en los lenguajes de programación ordinarios no pueden especificarse sólo mediante el uso de gramáticas. Aquí consideraremos dos de
estas construcciones, usando lenguajes abstractos simples para ilustrar las dificultades.
Ejemplo 4.25: El lenguaje en este ejemplo abstrae el problema de comprobar que se declaren
los identificadores antes de poder usarlos en un programa. El lenguaje consiste en cadenas de la
forma wcw, en donde la primera w representa la declaración de un identificador w, c representa
un fragmento intermedio del programa, y la segunda w representa el uso del identificador.
El lenguaje abstracto es L1 = {wcw | w está en (a|b)∗}. L1 consiste en todas las palabras
compuestas de una cadena repetida de as y bs separadas por c, como aabcaab. Aunque no lo
vamos a demostrar aquí, la característica de no ser libre de contexto de L1 implica directamente
que los lenguajes de programación como C y Java no sean libres de contexto, los cuales requieren la declaración de los identificadores antes de usarlos, además de permitir identificadores de
longitud arbitraria.
Por esta razón, una gramática para C o Java no hace diferencias entre los identificadores
que son cadenas distintas de caracteres. En vez de ello, todos los identificadores se representan
Capítulo 4. Análisis sintáctico
216
mediante un token como id en la gramática. En un compilador para dicho lenguaje, la fase de
análisis semántico comprueba que los identificadores se declaren antes de usarse.
2
Ejemplo 4.26: El lenguaje, que no es independiente del contexto en este ejemplo, abstrae el
problema de comprobar que el número de parámetros formales en la declaración de una función
coincida con el número de parámetros actuales en un uso de la función. El lenguaje consiste
en cadenas de la forma anbmcndm. (Recuerde que an significa a escrita n veces). Aquí, an y bm
podrían representar las listas de parámetros formales de dos funciones declaradas para tener n
y m argumentos, respectivamente, mientras que cn y dm representan las listas de los parámetros
actuales en las llamadas a estas dos funciones.
El lenguaje abstracto es L2 = {anbmcndm | n ¦ 1 y m ¦ 1}. Es decir, L2 consiste de cadenas
en el lenguaje generado por la expresión regular a∗b∗c∗d∗, de tal forma que el número de as y
cs y de bs y ds sea igual. Este lenguaje no es independiente del contexto.
De nuevo, la sintaxis común de las declaraciones de las funciones y los usos no se responsabiliza de contar el número de parámetros. Por ejemplo, la llamada a una función en un lenguaje
similar a C podría especificarse de la siguiente manera:
instr → id (lista-expr)
lista-expr → lista-expr, expr
| expr
con producciones adecuadas para expr. La comprobación de que el número de parámetros en
una llamada sea correcto se realiza por lo general durante la fase del análisis semántico. 2
4.3.6
Ejercicios para la sección 4.3
Ejercicio 4.3.1: La siguiente gramática es para expresiones regulares sobre los símbolos a y
b solamente, usando + en vez de | para la unión, con lo cual se evita el conflicto con el uso de
la barra vertical como un meta símbolo en las gramáticas:
rexpr
rterm
rfactor
rprimario
→
→
→
→
rexpr + rterm | rterm
rterm rfactor | rfactor
rfactor ∗ | rprimario
a|b
a) Factorice esta gramática por la izquierda.
b) ¿La factorización por la izquierda hace a la gramática adecuada para el análisis sintáctico descendente?
c) Además de la factorización por la izquierda, elimine la recursividad por la izquierda de
la gramática original.
d) ¿La gramática resultante es adecuada para el análisis sintáctico descendente?
Ejercicio 4.3.2: Repita el ejercicio 4.3.1 con las siguientes gramáticas:
a) La gramática del ejercicio 4.2.1.
b) La gramática del ejercicio 4.2.2(a).
217
4.4 Análisis sintáctico descendente
c) La gramática del ejercicio 4.2.2(c).
d) La gramática del ejercicio 4.2.2(e).
e) La gramática del ejercicio 4.2.2(g).
! Ejercicio 4.3.3: Se propone la siguiente gramática para eliminar la “ambigüedad del else
colgante”, descrita en la sección 4.3.2:
instr
→
|
instrRelacionada →
|
if expr then instr
instrRelacionada
if expr then instrRelacionada else instr
otra
Muestre que esta gramática sigue siendo ambigua.
4.4
Análisis sintáctico descendente
El análisis sintáctico descendente puede verse como el problema de construir un árbol de análisis sintáctico para la cadena de entrada, partiendo desde la raíz y creando los nodos del árbol
de análisis sintáctico en preorden (profundidad primero, como vimos en la sección 2.3.4). De
manera equivalente, podemos considerar el análisis sintáctico descendente como la búsqueda
de una derivación por la izquierda para una cadena de entrada.
Ejemplo 4.27: La secuencia de árboles de análisis sintáctico en la figura 4.12 para la entrada
id+id*id es un análisis sintáctico descendente, de acuerdo con la gramática (4.2), que repetimos a continuación:
E
E
T
T
F
→
→
→
→
→
T E
+ T E | F T
∗ F T | ( E ) | id
Esta secuencia de árboles corresponde a una derivación por la izquierda de la entrada.
(4.28)
2
En cada paso de un análisis sintáctico descendente, el problema clave es el de determinar
la producción que debe aplicarse para un no terminal, por decir A. Una vez que se elige una
producción A, el resto del proceso de análisis sintáctico consiste en “relacionar” los símbolos
terminales en el cuerpo de la producción con la cadena de entrada.
Esta sección empieza con una forma general del análisis sintáctico descendente, conocida
como análisis sintáctico de descenso recursivo, la cual puede requerir de un rastreo hacia atrás
para encontrar la producción A correcta que debe aplicarse. La sección 2.4.2 introdujo el análisis sintáctico predictivo, un caso especial de análisis sintáctico de descenso recursivo, en donde
no se requiere un rastreo hacia atrás. El análisis sintáctico predictivo elige la producción A
correcta mediante un análisis por adelantado de la entrada, en donde se ve un número fijo de
símbolos adelantados; por lo general, sólo necesitamos ver un símbolo por adelantado (es decir,
el siguiente símbolo de entrada).
218
Capítulo 4. Análisis sintáctico
Figura 4.12: Análisis sintáctico descendente para id+id∗id
Por ejemplo, considere el análisis sintáctico descendente en la figura 4.12, en la cual se
construye un árbol con dos nodos etiquetados como E . En el primer nodo E (en preorden),
se elige la producción E → +TE ; en el segundo nodo E , se elige la producción E → . Un
analizador sintáctico predictivo puede elegir una de las producciones E mediante el análisis del
siguiente símbolo de entrada.
A la clase de gramáticas para las cuales podemos construir analizadores sintácticos predictivos que analicen k símbolos por adelantado en la entrada, se le conoce algunas veces como
la clase LL(k). En la sección 4.4.3 hablaremos sobre la clase LL(1), pero presentaremos antes
ciertos cálculos, llamados PRIMERO y SIGUIENTE, en la sección 4.4.2. A partir de los conjuntos
PRIMERO y SIGUIENTE para una gramática, construiremos “tablas de análisis sintáctico predictivo”, las cuales hacen explícita la elección de la producción durante el análisis sintáctico
descendente. Estos conjuntos también son útiles durante el análisis sintáctico ascendente.
En la sección 4.4.4 proporcionaremos un algoritmo de análisis sintáctico no recursivo que
mantiene una pila en forma explícita, en vez de hacerlo en forma implícita mediante llamadas
recursivas. Por último, en la sección 4.4.5 hablaremos sobre la recuperación de errores durante
el análisis sintáctico descendente.
219
4.4 Análisis sintáctico descendente
4.4.1
1)
2)
3)
4)
5)
6)
7)
Análisis sintáctico de descenso recursivo
void A() {
Elegir una producción A, A → X1X2 … Xk ;
for ( i = 1 a k ) {
if ( Xi es un no terminal )
llamar al procedimiento Xi();
else if ( Xi es igual al símbolo de entrada actual a )
avanzar la entrada hasta el siguiente símbolo;
else /* ha ocurrido un error */;
}
}
Figura 4.13: Un procedimiento ordinario para un no terminal en un analizador sintáctico descendente
Un programa de análisis sintáctico de descenso recursivo consiste en un conjunto de procedimientos, uno para cada no terminal. La ejecución empieza con el procedimiento para el símbolo
inicial, que se detiene y anuncia que tuvo éxito si el cuerpo de su procedimiento explora toda
la cadena completa de entrada. En la figura 4.13 aparece el seudocódigo para un no terminal
común. Observe que este seudocódigo es no determinista, ya que empieza eligiendo la producción A que debe aplicar de una forma no especificada.
El descenso recursivo general puede requerir de un rastreo hacia atrás; es decir, tal vez
requiera exploraciones repetidas sobre la entrada. Sin embargo, raras veces se necesita el rastreo hacia atrás para analizar las construcciones de un lenguaje de programación, por lo que
los analizadores sintácticos con éste no se ven con frecuencia. Incluso para situaciones como el
análisis sintáctico de un lenguaje natural, el rastreo hacia atrás no es muy eficiente, por lo cual
se prefieren métodos tabulares como el algoritmo de programación dinámico del ejercicio 4.4.9,
o el método de Earley (vea las notas bibliográficas).
Para permitir el rastreo hacia atrás, hay que modificar el código de la figura 4.13. En primer
lugar, no podemos elegir una producción A única en la línea (1), por lo que debemos probar
cada una de las diversas producciones en cierto orden. Después, el fallo en la línea (7) no es definitivo, sino que sólo sugiere que necesitamos regresar a la línea (1) y probar otra producción A.
Sólo si no hay más producciones A para probar es cuando declaramos que se ha encontrado un
error en la entrada. Para poder probar otra producción A, debemos restablecer el apuntador de
entrada a la posición en la que se encontraba cuando llegamos por primera vez a la línea (1).
Es decir, se requiere una variable local para almacenar este apuntador de entrada, para un uso
futuro.
Ejemplo 4.29: Considere la siguiente gramática:
S
A
→ cAd
→ ab | a
Para construir un árbol de análisis sintáctico descendente para la cadena de entrada w = cad,
empezamos con un árbol que consiste en un solo nodo etiquetado como S, y el apuntador de entrada apunta a c, el primer símbolo de w. S sólo tiene una producción, por lo que la utilizamos
Capítulo 4. Análisis sintáctico
220
para expandir S y obtener el árbol de la figura 4.14(a). La hoja por la izquierda, etiquetada como c,
coincide con el primer símbolo de la entrada w, por lo que avanzamos el apuntador de entrada
hasta a, el segundo símbolo de w, y consideramos la siguiente hoja, etiquetada como A.
c
A
Figura 4.14: Los pasos de un análisis sintáctico descendente
Ahora expandimos A mediante la primera alternativa A → a b para obtener el árbol de
la figura 4.14(b). Tenemos una coincidencia para el segundo símbolo de entrada a, por lo que
avanzamos el apuntador de entrada hasta d, el tercer símbolo de entrada, y comparamos a d
con la siguiente hoja, etiquetada con b. Como b no coincide con d, reportamos un error y regresamos a A para ver si hay otra alternativa para A que no hayamos probado y que pueda
producir una coincidencia.
Al regresar a A, debemos restablecer el apuntador de entrada a la posición 2, la posición
que tenía cuando llegamos por primera vez a A, lo cual significa que el procedimiento para A
debe almacenar el apuntador de entrada en una variable local.
La segunda alternativa para A produce el árbol de la figura 4.14(c). La hoja a coincide con
el segundo símbolo de w y la hoja d coincide con el tercer símbolo. Como hemos producido un
árbol de análisis sintáctico para w, nos detenemos y anunciamos que se completó el análisis
sintáctico con éxito.
2
Una gramática recursiva por la izquierda puede hacer que un analizador sintáctico de descenso recursivo, incluso uno con rastreo hacia atrás, entre en un ciclo infinito. Es decir, al tratar
de expandir una no terminal A, podríamos en un momento dado encontrarnos tratando otra
vez de expandir a A, sin haber consumido ningún símbolo de la entrada.
4.4.2
PRIMERO y SIGUIENTE
La construcción de los analizadores sintácticos descendentes y ascendentes es auxiliada por dos
funciones, PRIMERO y SIGUIENTE, asociadas con la gramática G. Durante el análisis sintáctico
descendente, PRIMERO y SIGUIENTE nos permiten elegir la producción que vamos a aplicar, con
base en el siguiente símbolo de entrada. Durante la recuperación de errores en modo de pánico, los
conjuntos de tokens que produce SIGUIENTE pueden usarse como tokens de sincronización.
Definimos a PRIMERO(α), en donde α es cualquier cadena de símbolos gramaticales, como
∗ , entonces
el conjunto de terminales que empiezan las cadenas derivadas a partir de α. Si α ⇒
lm
∗
también se encuentra en PRIMERO(α). Por ejemplo, en la figura 4.15, A ⇒ cγ, por lo que c
lm
está en PRIMERO(A).
Para una vista previa de cómo usar PRIMERO durante el análisis sintáctico predictivo,
considere dos producciones A, A → α | β, en donde PRIMERO(α) y PRIMERO(β) son conjuntos
4.4 Análisis sintáctico descendente
221
Figura 4.15: El terminal c está en PRIMERO(A) y a está en SIGUIENTE(A)
separados. Entonces, podemos elegir una de estas producciones A si analizamos el siguiente
símbolo de entrada a, ya que a puede estar a lo más en PRIMERO(α) o en PRIMERO(β), pero no
en ambos. Por ejemplo, si a está en PRIMERO(β), elegimos la producción A → β. Exploraremos
esta idea en la sección 4.4.3, cuando definamos las gramáticas LL(1).
Definimos a SIGUIENTE(A), para el no terminal A, como el conjunto de terminales a que
pueden aparecer de inmediato a la derecha de A en cierta forma de frase; es decir, el conjunto
∗ αAaβ, para algunas α y
de terminales A de tal forma que exista una derivación de la forma S ⇒
β, como en la figura 4.15. Observe que pudieron haber aparecido símbolos entre A y a, en algún
momento durante la derivación, pero si es así, derivaron a y desaparecieron. Además, si A
puede ser el símbolo por la derecha en cierta forma de frase, entonces $ está en SIGUIENTE(A);
recuerde que $ es un símbolo “delimitador” especial, el cual se supone que no es un símbolo de
ninguna gramática.
Para calcular PRIMERO(X ) para todos los símbolos gramaticales X, aplicamos las siguientes
reglas hasta que no pueden agregarse más terminales o a ningún conjunto PRIMERO.
1. Si X es un terminal, entonces PRIMERO(X ) = {X }.
2. Si X es un no terminal y X → Y1Y2 …Yk es una producción para cierta k ¦ 1, entonces
se coloca a en PRIMERO(X ) si para cierta i, a está en PRIMERO(Yi), y está en todas
∗ . Si está en
las funciones PRIMERO(Y1), …, PRIMERO(Yi−1); es decir, Y1 …Yi−1 ⇒
PRIMERO(Y j ) para todas las j = 1, 2, …, k, entonces se agrega a PRIMERO(X). Por ejemplo, todo lo que hay en PRIMERO(Y1) se encuentra sin duda en PRIMERO(X ). Si Y1 no
∗ , entonces
deriva a , entonces no agregamos nada más a PRIMERO(X ), pero si Y1 ⇒
agregamos PRIMERO(Y2), y así sucesivamente.
3. Si X → es una producción, entonces se agrega a PRIMERO(X ).
Ahora, podemos calcular PRIMERO para cualquier cadena X1X2 … Xn de la siguiente manera. Se agregan a PRIMERO(X1X2 … Xn) todos los símbolos que no sean de PRIMERO(X1).
También se agregan los símbolos que no sean de PRIMERO(X2), si está en PRIMERO(X1); los
símbolos que no sean de PRIMERO(X3), si está en PRIMERO(X1) y PRIMERO(X2); y así sucesivamente. Por último, se agrega a PRIMERO(X1X2 … Xn) si, para todas las i, se encuentra
en PRIMERO(Xi).
Para calcular SIGUIENTE(A) para todas las no terminales A, se aplican las siguientes reglas
hasta que no pueda agregarse nada a cualquier conjunto SIGUIENTE.
1. Colocar $ en SIGUIENTE(S ), en donde S es el símbolo inicial y $ es el delimitador derecho
de la entrada.
Capítulo 4. Análisis sintáctico
222
2. Si hay una producción A → αBβ, entonces todo lo que hay en PRIMERO(β) excepto está en SIGUIENTE(B).
3. Si hay una producción A → αB, o una producción A → αBβ, en donde PRIMERO(β)
contiene a , entonces todo lo que hay en SIGUIENTE(A) está en SIGUIENTE(B).
Ejemplo 4.30: Considere de nuevo la gramática no recursiva por la izquierda (4.28). Entonces:
1. PRIMERO(F ) = PRIMERO(T ) = PRIMERO(E ) = {(, id}. Para ver por qué, observe que las
dos producciones para F tienen producciones que empiezan con estos dos símbolos terminales, id y el paréntesis izquierdo. T sólo tiene una producción y empieza con F. Como
F no deriva a , PRIMERO(T ) debe ser igual que PRIMERO(F ). El mismo argumento se
cumple con PRIMERO(E ).
2. PRIMERO(E ) = {+, }. La razón es que una de las dos producciones para E tiene un
cuerpo que empieza con el terminal +, y la otra es . Cada vez que un no terminal deriva
a , colocamos a en PRIMERO para ese no terminal.
3. PRIMERO(T ) = {∗, }. El razonamiento es análogo al de PRIMERO(E ).
4. SIGUIENTE(E ) = SIGUIENTE(E ) = {), $}. Como E es el símbolo inicial, SIGUIENTE(E )
debe contener $. El cuerpo de la producción ( E ) explica por qué el paréntesis derecho
está en SIGUIENTE(E ). Para E , observe que esta no terminal sólo aparece en los extremos de los cuerpos de las producciones E. Por ende, SIGUIENTE(E ) debe ser el mismo
que SIGUIENTE(E ).
5. SIGUIENTE(T ) = SIGUIENTE(T ) = {+, ), $}. Observe que T aparece en las producciones
sólo seguido por E . Por lo tanto, todo lo que esté en PRIMERO(E ), excepto , debe estar
en SIGUIENTE(T ); eso explica el símbolo +. No obstante, como PRIMERO(E ) contiene a
∗ ), y E es la cadena completa que va después de T en los cuerpos de las
(es decir, E ⇒
producciones E, todo lo que hay en SIGUIENTE(E ) también debe estar en SIGUIENTE(T ).
Eso explica los símbolos $ y el paréntesis derecho. En cuanto a T , como aparece sólo en
los extremos de las producciones T, debe ser que SIGUIENTE(T ) = SIGUIENTE(T ).
6. SIGUIENTE(F ) = {+, ∗, ), $}. El razonamiento es análogo al de T en el punto (5).
2
4.4.3
Gramáticas LL(1)
Los analizadores sintácticos predictivos, es decir, los analizadores sintácticos de descenso recursivo que no necesitan rastreo hacia atrás, pueden construirse para una clase de gramáticas
llamadas LL(1). La primera “L” en LL(1) es para explorar la entrada de izquierda a derecha
(por left en inglés), la segunda “L” para producir una derivación por la izquierda, y el “1” para
usar un símbolo de entrada de anticipación en cada paso, para tomar las decisiones de acción
del análisis sintáctico.
4.4 Análisis sintáctico descendente
223
Diagramas de transición para analizadores sintácticos predictivos
Los diagramas de transición son útiles para visualizar los analizadores sintácticos predictivos. Por ejemplo, los diagramas de transición para las no terminales E y E de la
gramática (4.28) aparecen en la figura 4.16(a). Para construir el diagrama de transición
a partir de una gramática, primero hay que eliminar la recursividad por la izquierda y
después factorizar la gramática por la izquierda. Entonces, para cada no terminal A,
1. Se crea un estado inicial y un estado final (retorno).
2. Para cada producción A → X1X2 … Xk, se crea una ruta desde el estado inicial
hasta el estado final, con los flancos etiquetados como X1, X2, …, Xk . Si A → , la
ruta es una línea que se etiqueta como .
Los diagramas de transición para los analizadores sintácticos predictivos difieren de
los diagramas para los analizadores léxicos. Los analizadores sintácticos tienen un diagrama para cada no terminal. Las etiquetas de las líneas pueden ser tokens o no terminales.
Una transición sobre un token (terminal) significa que tomamos esa transición si ese
token es el siguiente símbolo de entrada. Una transición sobre un no terminal A es una
llamada al procedimiento para A.
Con una gramática LL(1), la ambigüedad acera de si se debe tomar o no una línea puede resolverse si hacemos que las transiciones sean la opción predeterminada.
Los diagramas de transición pueden simplificarse, siempre y cuando se preserve la
secuencia de símbolos gramaticales a lo largo de las rutas. También podemos sustituir el
diagrama para un no terminal A, en vez de una línea etiquetada como A. Los diagramas en
las figuras 4.16(a) y (b) son equivalentes: si trazamos rutas de E a un estado de aceptación
y lo sustituimos por E , entonces, en ambos conjuntos de diagramas, los símbolos gramaticales a lo largo de las rutas forman cadenas del tipo T + T + … + T. El diagrama en (b)
puede obtenerse a partir de (a) mediante transformaciones semejantes a las de la sección
2.5.4, en donde utilizamos la eliminación de recursividad de la parte final y la sustitución
de los cuerpos de los procedimientos para optimizar el procedimiento en un no terminal.
La clase de gramáticas LL(1) es lo bastante robusta como para cubrir la mayoría de las
construcciones de programación, aunque hay que tener cuidado al escribir una gramática adecuada para el lenguaje fuente. Por ejemplo, ninguna gramática recursiva por la izquierda o
ambigua puede ser LL(1).
Una gramática G es LL(1) si, y sólo si cada vez que A → α | β son dos producciones distintas de G, se aplican las siguientes condiciones:
1. Para el no terminal a, tanto α como β derivan cadenas que empiecen con a.
2. A lo más, sólo α o β puede derivar la cadena vacía.
∗ , entonces α no deriva a ninguna cadena que empiece con una terminal en
3. Si β ⇒
∗
SIGUIENTE(A). De igual forma, si α ⇒ , entonces β no deriva a ninguna cadena que
empiece con una terminal en SIGUIENTE(A).
Capítulo 4. Análisis sintáctico
224
Figura 4.16: Diagramas de transición para los no terminales E y E de la gramática 4.28
Las primeras dos condiciones son equivalentes para la instrucción que establece que PRIMERO(α)
y PRIMERO(β) son conjuntos separados. La tercera condición equivale a decir que si está en
PRIMERO(β), entonces PRIMERO(α) y SIGUIENTE(A) son conjuntos separados, y de igual forma
si está en PRIMERO(α).
Pueden construirse analizadores sintácticos predictivos para las gramáticas LL(1), ya que
puede seleccionarse la producción apropiada a aplicar para una no terminal con sólo analizar
el símbolo de entrada actual. Los constructores del flujo de control, con sus palabras clave
distintivas, por lo general, cumplen con las restricciones de LL(1). Por ejemplo, si tenemos las
siguientes producciones:
instr
→
|
|
if ( expr ) instr else instr
while ( expr ) instr
{ lista-instr }
entonces las palabras clave if, while y el símbolo { nos indican qué alternativa es la única que
quizá podría tener éxito, si vamos a buscar una instrucción.
El siguiente algoritmo recolecta la información de los conjuntos PRIMERO y SIGUIENTE en
una tabla de análisis predictivo M[A, a], un arreglo bidimensional, en donde A es un no terminal
y a es un terminal o el símbolo $, el marcador de fin de la entrada. El algoritmo se basa en la
siguiente idea: se elige la producción A → α si el siguiente símbolo de entrada a se encuentra
∗ .
en PRIMERO(α). La única complicación ocurre cuando α = , o en forma más general, α ⇒
En este caso, debemos elegir de nuevo A →α si el símbolo de entrada actual se encuentra en
SIGUIENTE(A), o si hemos llegado al $ en la entrada y $ se encuentra en SIGUIENTE(A).
Algoritmo 4.31: Construcción de una tabla de análisis sintáctico predictivo.
ENTRADA: La gramática G.
SALIDA: La tabla de análisis sintáctico M.
MÉTODO: Para cada producción A → α de la gramática, hacer lo siguiente:
1. Para cada terminal a en PRIMERO(A), agregar A → α a M [A, a].
2. Si está en PRIMERO(α), entonces para cada terminal b en SIGUIENTE(A), se agrega
A → α a M [A, b]. Si está en PRIMERO(α) y $ se encuentra en SIGUIENTE(A), se agrega A → α a M [A, $] también.
225
4.4 Análisis sintáctico descendente
Si después de realizar lo anterior, no hay producción en M [A, a], entonces se establece M [A, a]
a error (que, por lo general, representamos mediante una entrada vacía en la tabla).
2
Ejemplo 4.32: Para la gramática de expresiones (4.28), el Algoritmo 4.31 produce la tabla de
análisis sintáctico en la figura 4.17. Los espacios en blanco son entradas de error; los espacios
que no están en blanco indican una producción con la cual se expande un no terminal.
NO
TERMINAL
SÍMBOLO DE ENTRADA
Figura 4.17: Tabla de análisis sintáctico M para el ejemplo 4.32
Considere la producción E → TE . Como
PRIMERO(TE ) = PRIMERO(T ) = {(, id}
esta producción se agrega a M [E, (] y M [E, id]. La producción E → +TE se agrega a M [E ,
+], ya que PRIMERO(+TE ) = {+}. Como SIGUIENTE(E ) ) = {), $}, la producción E → se
agrega a M [E , )] y a M [E , $].
2
El algoritmo 4.31 puede aplicarse a cualquier gramática G para producir una tabla de
análisis sintáctico M. Para cada gramática LL(1), cada entrada en la tabla de análisis sintáctico identifica en forma única a una producción, o indica un error. Sin embargo, para algunas
gramáticas, M puede tener algunas entradas que tengan múltiples definiciones. Por ejemplo, si
G es recursiva por la izquierda o ambigua, entonces M tendrá por lo menos una entrada con
múltiples definiciones. Aunque la eliminación de la recursividad por la izquierda y la factorización por la izquierda son fáciles de realizar, hay algunas gramáticas para las cuales ningún tipo
de alteración producirá una gramática LL(1).
El lenguaje en el siguiente ejemplo no tiene una gramática LL(1).
Ejemplo 4.33: La siguiente gramática, que abstrae el problema del else colgante, se repite
aquí del ejemplo 4.22:
S →
S →
E →
iEtSS | a
eS | b
La tabla de análisis sintáctico para esta gramática aparece en la figura 4.18. La entrada para
M [S , e] contiene tanto a S → eS como a S → .
La gramática es ambigua y la ambigüedad se manifiesta mediante una elección de qué producción usar cuando se ve una e (else). Podemos resolver esta ambigüedad eligiendo S → eS.
Capítulo 4. Análisis sintáctico
226
NO
TERMINAL
SÍMBOLO DE ENTRADA
Figura 4.18: Tabla de análisis sintáctico M para el ejemplo 4.33
Esta elección corresponde a la asociación de un else con el then anterior más cercano. Observe que la elección S → evitaría que e se metiera en la pila o se eliminara de la entrada, y
eso definitivamente está mal. 2
4.4.4
Análisis sintáctico predictivo no recursivo
Podemos construir un analizador sintáctico predictivo no recursivo mediante el mantenimiento
explícito de una pila, en vez de hacerlo mediante llamadas recursivas implícitas. El analizador
sintáctico imita una derivación por la izquierda. Si w es la entrada que se ha relacionado hasta
ahora, entonces la pila contiene una secuencia de símbolos gramaticales α de tal forma que:
∗ wα
S⇒
lm
El analizador sintáctico controlado por una tabla, que se muestra en la figura 4.19, tiene un
búfer de entrada, una pila que contiene una secuencia de símbolos gramaticales, una tabla de
análisis sintáctico construida por el Algoritmo 4.31, y un flujo de salida. El búfer de entrada
contiene la cadena que se va a analizar, seguida por el marcador final $. Reutilizamos el símbolo $ para marcar la parte inferior de la pila, que al principio contiene el símbolo inicial de la
gramática encima de $.
El analizador sintáctico se controla mediante un programa que considera a X, el símbolo
en la parte superior de la pila, y a a, el símbolo de entrada actual. Si X es un no terminal, el
analizador sintáctico elige una producción X mediante una consulta a la entrada M [X, a] de la
tabla de análisis sintáctico M (aquí podría ejecutarse código adicional; por ejemplo, el código
para construir un nodo en un árbol de análisis sintáctico). En cualquier otro caso, verifica si
hay una coincidencia entre el terminal X y el símbolo de entrada actual a.
El comportamiento del analizador sintáctico puede describirse en términos de sus configuraciones, que proporcionan el contenido de la pila y el resto de la entrada. El siguiente algoritmo
describe la forma en que se manipulan las configuraciones.
Algoritmo 4.34: Análisis sintáctico predictivo, controlado por una tabla.
ENTRADA: Una cadena w y una tabla de análisis sintáctico M para la gramática G.
SALIDA: Si w está en L(G), una derivación por la izquierda de w; en caso contrario, una indicación de error.
227
4.4 Análisis sintáctico descendente
Entrada
Programa
de análisis
sintáctico
predictivo
Pila
Salida
Tabla
de análisis
sintáctico M
Figura 4.19: Modelo de un analizador sintáctico predictivo, controlado por una tabla
MÉTODO: Al principio, el analizador sintáctico se encuentra en una configuración con w$ en
el búfer de entrada, y el símbolo inicial S de G en la parte superior de la pila, por encima de $.
El programa en la figura 4.20 utiliza la tabla de análisis sintáctico predictivo M para producir
un análisis sintáctico predictivo para la entrada.
2
establecer ip para que apunte al primer símbolo de w;
establecer X con el símbolo de la parte superior de la pila;
while ( X ≠ $ ) { /* la pila no está vacía */
if ( X es a ) sacar de la pila y avanzar ip;
else if ( X es un terminal ) error();
else if ( M [X, a] es una entrada de error ) error();
else if ( M [X, a] = X → Y1Y2 …Yk ) {
enviar de salida la producción X → Y1Y2 … Yk;
sacar de la pila;
meter Yk, Yk−1, …, Y1 en la pila, con Y1 en la parte superior;
}
establecer X con el símbolo de la parte superior de la pila;
}
Figura 4.20: Algoritmo de análisis sintáctico predictivo
Ejemplo 4.35: Considere la gramática (4.28); se muestra la tabla de análisis sintáctico en la
figura 4.17. Con la entrada id + id ∗ id, el analizador predictivo sin recursividad del Algoritmo 4.34 realiza la secuencia de movimientos en la figura 4.21. Estos movimientos corresponden
a una derivación por la izquierda (vea la figura 4.12 para la derivación completa):
E ⇒ TE ⇒ FT E ⇒ id T E ⇒ id E ⇒ id + TE ⇒ …
lm
lm
lm
lm
lm
lm
Capítulo 4. Análisis sintáctico
228
COINCIDENCIA
PILA
ENTRADA
ACCIÓN
emitir
emitir
emitir
relacionar
emitir
emitir
relacionar
emitir
emitir
relacionar
emitir
relacionar
emitir
relacionar
emitir
emitir
Figura 4.21: Movimientos que realiza un analizador sintáctico predictivo con la entrada
id + id ∗ id
Observe que las formas de frases en esta derivación corresponden a la entrada que ya se ha
relacionado (en la columna COINCIDENCIA), seguida del contenido de la pila. La entrada relacionada se muestra sólo para resaltar la correspondencia. Por la misma razón, la parte superior
de la pila está a la izquierda; cuando consideremos el análisis sintáctico ascendente, será más
natural mostrar la parte superior de la pila a la derecha. El apuntador de la entrada apunta al
símbolo por la izquierda de la cadena en la columna ENTRADA.
2
4.4.5
Recuperación de errores en el análisis sintáctico predictivo
Esta discusión sobre la recuperación de errores se refiere a la pila de un analizador sintáctico
predictivo controlado por una tabla, ya que hace explícitas los terminales y no terminales que
el analizador sintáctico espera relacionar con el resto de la entrada; las técnicas también pueden
usarse con el análisis sintáctico de descenso recursivo.
Durante el análisis sintáctico predictivo, un error se detecta cuando el terminal en la parte
superior de la pila no coincide con el siguiente símbolo de entrada, o cuando el no terminal A
se encuentra en la parte superior de la pila, a es el siguiente símbolo de entrada y M [A, a] es
error (es decir, la entrada en la tabla de análisis sintáctico está vacía).
Modo de pánico
La recuperación de errores en modo de pánico se basa en la idea de omitir símbolos en la entrada hasta que aparezca un token en un conjunto seleccionado de tokens de sincronización. Su
4.4 Análisis sintáctico descendente
229
efectividad depende de la elección del conjunto de sincronización. Los conjuntos deben elegirse
de forma que el analizador sintáctico se recupere con rapidez de los errores que tengan una
buena probabilidad de ocurrir en la práctica. Algunas heurísticas son:
1. Como punto inicial, colocar todos los símbolos que están en SIGUIENTE(A) en el conjunto de sincronización para el no terminal A. Si omitimos tokens hasta que se vea un elemento de SEGUIMENTO(A) y sacamos a A de la pila, es probable que el análisis sintáctico
pueda continuar.
2. No basta con usar SIGUIENTE(A) como el conjunto de sincronización para A. Por ejemplo, si los signos de punto y coma terminan las instrucciones, como en C, entonces las
palabras reservadas que empiezan las instrucciones no pueden aparecer en el conjunto
SIGUIENTE del no terminal que representa a las expresiones. Un punto y coma faltante
después de una asignación puede, por lo tanto, ocasionar que se omita la palabra reservada que empieza la siguiente instrucción. A menudo hay una estructura jerárquica en
las construcciones en un lenguaje; por ejemplo, las expresiones aparecen dentro de las
instrucciones, las cuales aparecen dentro de bloques, y así sucesivamente. Al conjunto
de sincronización de una construcción de bajo nivel podemos agregar los símbolos que
empiezan las construcciones de un nivel más alto. Por ejemplo, podríamos agregar palabras clave que empiezan las instrucciones a los conjuntos de sincronización para los no
terminales que generan las expresiones.
3. Si agregamos los símbolos en PRIMERO(A) al conjunto de sincronización para el no terminal A, entonces puede ser posible continuar con el análisis sintáctico de acuerdo con A,
si en la entrada aparece un símbolo que se encuentre en PRIMERO(A).
4. Si un no terminal puede generar la cadena vacía, entonces la producción que deriva a puede usarse como predeterminada. Al hacer esto se puede posponer cierta detección de
errores, pero no se puede provocar la omisión de un error. Este método reduce el número
de terminales que hay que considerar durante la recuperación de errores.
5. Si un terminal en la parte superior de la pila no se puede relacionar, una idea simple es
sacar el terminal, emitir un mensaje que diga que se insertó el terminal, y continuar con
el análisis sintáctico. En efecto, este método requiere que el conjunto de sincronización
de un token consista de todos los demás tokens.
Ejemplo 4.36: El uso de los símbolos en PRIMERO y SIGUIENTE como tokens de sincronización funciona razonablemente bien cuando las expresiones se analizan de acuerdo con la gramática usual (4.28). La tabla de análisis sintáctico para esta gramática de la figura 4.17 se repite
en la figura 4.22, en donde “sinc” indica los tokens de sincronización obtenidos del conjunto
SIGUIENTE de la no terminal en cuestión. Los conjuntos SIGUIENTE para las no terminales se
obtienen del ejemplo 4.30.
La tabla en la figura 4.22 debe usarse de la siguiente forma. Si el analizador sintáctico busca
la entrada M [A, a] y descubre que está en blanco, entonces se omite el símbolo de entrada a. Si la
entrada es “sinc”, entonces se saca el no terminal que está en la parte superior de la pila, en un
intento por continuar con el análisis sintáctico. Si un token en la parte superior de la pila no
coincide con el símbolo de entrada, entonces sacamos el token de la pila, como dijimos antes.
Capítulo 4. Análisis sintáctico
230
SÍMBOLO DE ENTRADA
NO
TERMINAL
sinc
sinc
sinc
sinc
sinc
sinc
sinc
sinc
sinc
Figura 4.22: Tokens de sincronización agregados a la tabla de análisis sintáctico
de la figura 4.17
Con la entrada errónea )id ∗ + id, el analizador sintáctico y el mecanismo de recuperación
de errores de la figura 4.22 se comporta como en la figura 4.23.
2
PILA
ENTRADA
COMENTARIO
error, omitir )
id está en PRIMERO(E )
error, M [F, +] = sinc
Se sacó F
Figura 4.23: Movimientos de análisis sintáctico y recuperación de errores realizados
por un analizador sintáctico predictivo
La discusión anterior sobre la recuperación en modo de pánico no señala el punto importante
relacionado con los mensajes de error. El diseñador del compilador debe proporcionar mensajes
de error informativos que no sólo describan el error, sino que también llamen la atención hacia
el lugar en donde se descubrió el error.
4.4 Análisis sintáctico descendente
231
Recuperación a nivel de frase
La recuperación de errores a nivel de frase se implementa llenando las entradas en blanco en la
tabla de análisis sintáctico predictivo con apuntadores a rutinas de error. Estas rutinas pueden
modificar, insertar o eliminar símbolos en la entrada y emitir mensajes de error apropiados.
También pueden sacar de la pila. La alteración de los símbolos de la pila o el proceso de meter
nuevos símbolos a la pila es cuestionable por dos razones. En primer lugar, los pasos que realiza
el analizador sintáctico podrían entonces no corresponder a la derivación de ninguna palabra
en el lenguaje. En segundo lugar, debemos asegurarnos de que no haya posibilidad de un ciclo
infinito. Verificar que cualquier acción de recuperación ocasione en un momento dado que se
consuma un símbolo de entrada (o que se reduzca la pila si se ha llegado al fin de la entrada)
es una buena forma de protegerse contra tales ciclos.
4.4.6
Ejercicios para la sección 4.4
Ejercicio 4.4.1: Para cada una de las siguientes gramáticas, idee analizadores sintácticos predictivos y muestre las tablas de análisis sintáctico. Puede factorizar por la izquierda o eliminar
la recursividad por la izquierda de sus gramáticas primero.
a) La gramática del ejercicio 4.2.2(a).
b) La gramática del ejercicio 4.2.2(b).
c) La gramática del ejercicio 4.2.2(c).
d) La gramática del ejercicio 4.2.2(d).
e) La gramática del ejercicio 4.2.2(e).
f) La gramática del ejercicio 4.2.2(g).
!! Ejercicio 4.4.2: ¿Es posible, mediante la modificación de la gramática en cualquier forma,
construir un analizador sintáctico predictivo para el lenguaje del ejercicio 4.2.1 (expresiones
postfijo con el operando a)?
Ejercicio 4.4.3: Calcule PRIMERO y SIGUIENTE para la gramática del ejercicio 4.2.1.
Ejercicio 4.4.4: Calcule PRIMERO y SIGUIENTE para cada una de las gramáticas del ejercicio
4.2.2.
Ejercicio 4.4.5: La gramática S → a S a | a a genera todas las cadenas de longitud uniforme
de as. Podemos idear un analizador sintáctico de descenso recursivo con rastreo hacia atrás
para esta gramática. Si elegimos expandir mediante la producción S → a a primero, entonces
sólo debemos reconocer la cadena aa. Por ende, cualquier analizador sintáctico de descenso
recursivo razonable probará S → a S a primero.
a) Muestre que este analizador sintáctico de descenso recursivo reconoce las entradas aa,
aaaa y aaaaaaaa, pero no aaaaaa.
!! b) ¿Qué lenguaje reconoce este analizador sintáctico de descenso recursivo?
232
Capítulo 4. Análisis sintáctico
Los siguientes ejercicios son pasos útiles en la construcción de una gramática en la “Forma
Normal de Chomsky” a partir de gramáticas arbitrarias, como se define en el ejercicio 4.4.8.
! Ejercicio 4.4.6: Una gramática es libre de si ningún cuerpo de las producciones es (a lo
cual se le llama producción ).
a) Proporcione un algoritmo para convertir cualquier gramática en una gramática libre de
que genere el mismo lenguaje (con la posible excepción de la cadena vacía; ninguna
gramática libre de puede generar a ).
b) Aplique su algoritmo a la gramática S → aSbS | bSaS | . Sugerencia: Primero busque
todas las no terminales que sean anulables, lo cual significa que generan a , tal vez mediante una derivación extensa.
! Ejercicio 4.4.7: Una producción simple es una producción cuyo cuerpo es una sola no terminal; por ejemplo, una producción de la forma A → A.
a) Proporcione un algoritmo para convertir cualquier gramática en una gramática libre de
, sin producciones simples, que genere el mismo lenguaje (con la posible excepción de la
cadena vacía) Sugerencia: Primero elimine las producciones y después averigüe para
∗ B mediante una secuencia de proqué pares de no terminales A y B se cumple que A ⇒
ducciones simples.
b) Aplique su algoritmo a la gramática (4.1) en la sección 4.1.2.
c) Muestre que, como consecuencia de la parte (a), podemos convertir una gramática en
una gramática equivalente que no tenga ciclos (derivaciones de uno o más pasos, en los
∗ A para cierta no terminal A).
que A ⇒
!! Ejercicio 4.4.8: Se dice que una gramática está en Forma Normal de Chomsky (FNC) si toda
producción es de la forma A → BC o de la forma A → a, en donde A, B y C son no terminales,
y a es un terminal. Muestre cómo convertir cualquier gramática en una gramática FNC para
el mismo lenguaje (con la posible excepción de la cadena vacía; ninguna gramática FNC puede
generar a ).
! Ejercicio 4.4.9: Todo lenguaje que tiene una gramática libre de contexto puede reconocerse
en un tiempo máximo de O(n3) para las cadenas de longitud n. Una manera simple de hacerlo,
conocida como el algoritmo de Cocke-Younger-Kasami (o CYK), se basa en la programación
dinámica. Es decir, dada una cadena a1a2 … an, construimos una tabla T de n por n de tal
forma que Tij sea el conjunto de no terminales que generen la subcadena aiai+1 … aj. Si la gramática subyacente está en FNC (vea el ejercicio 4.4.8), entonces una entrada en la tabla puede
llenarse en un tiempo O(n), siempre y cuando llenemos las entradas en el orden apropiado: el
menor valor de j − i primero. Escriba un algoritmo que llene en forma correcta las entradas
de la tabla, y muestre que su algoritmo requiere un tiempo O(n3). Después de llenar la tabla,
¿cómo podemos determinar si a1a2 … an está en el lenguaje?
233
4.5 Análisis sintáctico ascendente
! Ejercicio 4.4.10: Muestre cómo, después de llenar la tabla como en el ejercicio 4.4.9, podemos recuperar en un tiempo O(n) un árbol de análisis sintáctico para a1a2 … an. Sugerencia:
Modifique la tabla de manera que registre, para cada no terminal A en cada entrada de la
tabla Tij, cierto par de no terminales en otras entradas en la tabla que justifiquen la acción de
colocar a A en Tij.
! Ejercicio 4.4.11: Modifique su algoritmo del ejercicio 4.4.9 de manera que busque, para
cualquier cadena, el menor número de errores de inserción, eliminación y mutación (cada error
de un solo carácter) necesarios para convertir la cadena en una cadena del lenguaje de la gramática subyacente.
instr
→
|
|
|
colaInstr →
|
lista
→
colaLista →
→
if e then instr colaInstr
while e do instr
begin lista end
s
else instr
instr colaLista
; lista
Figura 4.24: Una gramática para ciertos tipos de instrucciones
! Ejercicio 4.4.12: En la figura 4.24 hay una gramática para ciertas instrucciones. Podemos
considerar que e y s son terminales que representan expresiones condicionales y “otras instrucciones”, respectivamente. Si resolvemos el conflicto en relación con la expansión de la instrucción
“else” opcional (la no terminal colaInstr) al preferir consumir un else de la entrada cada vez que
veamos uno, podemos construir un analizador sintáctico predictivo para esta gramática. Usando
la idea de los símbolos de sincronización descritos en la sección 4.4.5:
a) Construya una tabla de análisis sintáctico predictivo con corrección de errores para la
gramática.
b) Muestre el comportamiento de su analizador sintáctico con las siguientes entradas:
(i)
(ii)
4.5
if e then s ; if e then s end
while e do begin s ; if e then s ; end
Análisis sintáctico ascendente
Un análisis sintáctico ascendente corresponde a la construcción de un árbol de análisis sintáctico para una cadena de entrada que empieza en las hojas (la parte inferior) y avanza hacia la
raíz (la parte superior). Es conveniente describir el análisis sintáctico como el proceso de construcción de árboles de análisis sintáctico, aunque de hecho un front-end de usuario podría realizar una traducción directamente, sin necesidad de construir un árbol explícito. La secuencia
Capítulo 4. Análisis sintáctico
234
Figura 4.25: Un análisis sintáctico ascendente para id * id
de imágenes de árboles en la figura 4.25 ilustra un análisis sintáctico ascendente del flujo de
tokens id ∗ id, con respecto a la gramática de expresiones (4.1).
Esta sección presenta un estilo general de análisis sintáctico ascendente, conocido como
análisis sintáctico de desplazamiento-reducción. En las secciones 4.6 y 4.7 hablaremos sobre
las gramáticas LR, la clase más extensa de gramáticas para las cuales pueden construirse los
analizadores sintácticos de desplazamiento-reducción. Aunque es demasiado trabajo construir
un analizador sintáctico LR en forma manual, las herramientas conocidas como generadores
automáticos de analizadores sintácticos facilitan la construcción de analizadores sintácticos LR
eficientes a partir de gramáticas adecuadas. Los conceptos en esta sección son útiles para escribir
gramáticas adecuadas que nos permitan hacer un uso efectivo de un generador de analizadores
sintácticos LR. En la sección 4.7 aparecen los algoritmos para implementar los generadores de
analizadores sintácticos.
4.5.1
Reducciones
Podemos considerar el análisis sintáctico ascendente como el proceso de “reducir” una cadena w
al símbolo inicial de la gramática. En cada paso de reducción, se sustituye una subcadena específica que coincide con el cuerpo de una producción por el no terminal que se encuentra en el encabezado de esa producción.
Las decisiones clave durante el análisis sintáctico ascendente son acerca de cuándo reducir
y qué producción aplicar, a medida que procede el análisis sintáctico.
Ejemplo 4.37: Las imágenes en la figura 4.25 ilustran una secuencia de reducciones; la gramática es la gramática de expresiones (4.1). Hablaremos sobre las reducciones en términos de
la siguiente secuencia de cadenas:
id ∗ id, F ∗ id, T ∗ id, T ∗ F, T, E
Las cadenas en esta secuencia se forman a partir de las raíces de todos los subárboles de las
imágenes. La secuencia empieza con la cadena de entrada id ∗ id. La primera reducción produce F ∗ id al reducir el id por la izquierda a F, usando la producción F → id. La segunda
reducción produce T ∗ id al reducir F a T.
Ahora tenemos una elección entre reducir la cadena T, que es el cuerpo de E → T, y la
cadena que consiste en el segundo id, que es el cuerpo de F → id. En vez de reducir T a E,
el segundo id se reduce a T, con lo cual se produce la cadena T ∗ F. Después, esta cadena se
reduce a T. El análisis sintáctico termina con la reducción de T al símbolo inicial E. 2
235
4.5 Análisis sintáctico ascendente
Por definición, una reducción es el inverso de un paso en una derivación (recuerde que en
una derivación, un no terminal en una forma de frase se sustituye por el cuerpo de una de
sus producciones). Por lo tanto, el objetivo del análisis sintáctico ascendente es construir una
derivación en forma inversa. La siguiente derivación corresponde al análisis sintáctico en la
figura 4.25:
E ⇒ T ⇒ T ∗ F ⇒ T ∗ id ⇒ F ∗ id ⇒ id ∗ id
Esta derivación es de hecho una derivación por la derecha.
4.5.2
Poda de mangos
Durante una exploración de izquierda a derecha de la entrada, el análisis sintáctico ascendente
construye una derivación por la derecha en forma inversa. De manera informal, un “mango” es
una subcadena que coincide con el cuerpo de una producción, y cuya reducción representa un
paso a lo largo del inverso de una derivación por la derecha.
Por ejemplo, si agregamos subíndices a los tokens id para mejorar la legibilidad, los mangos
durante el análisis sintáctico de id1 ∗ id2, de acuerdo con la gramática de expresiones (4.1),
son como en la figura 4.26. Aunque T es el cuerpo de la producción E → T, el símbolo T no es
un mango en la forma de frase T ∗ id2. Si T se sustituyera por E, obtendríamos la cadena E ∗
id2, lo cual no puede derivarse del símbolo inicial E. Por ende, la subcadena por la izquierda
que coincide con el cuerpo de alguna producción no necesita ser un mango.
FORMA
DE FRASE DERECHA
MANGO
REDUCCIÓN
DE LA PRODUCCIÓN
Figura 4.26: Mangos durante un análisis sintáctico de id1 * id2
∗ αAw ⇒ αβw, como en la figura 4.27, entonces la producción A → β
De manera formal, si S ⇒
rm
rm
en la posición que sigue después de α es un mango de αβw. De manera alternativa, un mango
de la forma de frase derecha γ es una producción A → β y una posición de γ en donde puede
encontrarse la cadena β, de tal forma que al sustituir β en esa posición por A se produzca la
forma de frase derecha anterior en una derivación por la derecha de γ.
Observe que la cadena w a la derecha del mango debe contener sólo símbolos terminales.
Por conveniencia, nos referimos al cuerpo β en vez de A → β como un mango. Observe que
decimos “un mango” en vez de “el mango”, ya que la gramática podría ser ambigua, con más de
una derivación por la derecha de αβw. Si una gramática no tiene ambigüedad, entonces cada forma
de frase derecha de la gramática tiene sólo un mango.
Puede obtenerse una derivación por la derecha en forma inversa mediante la “poda de
mangos”. Es decir, empezamos con una cadena de terminales w a las que se les va a realizar
Capítulo 4. Análisis sintáctico
236
Figura 4.27: Un mango A → β en el árbol de análisis sintáctico para αβw
el análisis sintáctico. Si w es un enunciado de la gramática a la mano, entonces dejamos que
w = γn, en donde γn es la n-ésima forma de frase derecha de alguna derivación por la derecha,
que todavía se desconoce:
Para reconstruir esta derivación en orden inverso, localizamos el mango βn en γn y sustituimos βn por el encabezado de la producción An → βn para obtener la forma de frase derecha
γn−1 anterior. Tenga en cuenta que todavía no sabemos cómo se van a encontrar los mangos,
pero en breve veremos métodos para hacerlo.
Después repetimos este proceso. Es decir, localizamos el mango βn−1 en γn−1 y reducimos este
mango para obtener la forma de frase derecha γn−2. Si al continuar este proceso producimos una
forma de frase derecha que consista sólo en el símbolo inicial S, entonces nos detenemos y anunciamos que el análisis sintáctico se completó con éxito. El inverso de la secuencia de producciones
utilizadas en las reducciones es una derivación por la derecha para la cadena de entrada.
4.5.3
Análisis sintáctico de desplazamiento-reducción
El análisis sintáctico de desplazamiento-reducción es una forma de análisis sintáctico ascendente, en la cual una pila contiene símbolos gramaticales y un búfer de entrada contiene el resto de
la cadena que se va a analizar. Como veremos, el mango siempre aparece en la parte superior
de la pila, justo antes de identificarla como el mango.
Utilizamos el $ para marcar la parte inferior de la pila y también el extremo derecho de la
entrada. Por convención, al hablar sobre el análisis sintáctico ascendente, mostramos la parte
superior de la pila a la derecha, en vez de a la izquierda como hicimos para el análisis sintáctico
descendente. Al principio la pila está vacía, y la cadena w está en la entrada, como se muestra
a continuación:
PILA
$
ENTRADA
w$
Durante una exploración de izquierda a derecha de la cadena de entrada, el analizador sintáctico
desplaza cero o más símbolos de entrada y los mete en la pila, hasta que esté listo para reducir
una cadena β de símbolos gramaticales en la parte superior de la pila. Después reduce β al encabezado de la producción apropiada. El analizador sintáctico repite este ciclo hasta que haya
detectado un error, o hasta que la pila contenga el símbolo inicial y la entrada esté vacía:
PILA
$S
ENTRADA
$
237
4.5 Análisis sintáctico ascendente
Al entrar a esta configuración, el analizador sintáctico se detiene y anuncia que el análisis
sintáctico se completó con éxito. La figura 4.28 avanza por pasos a través de las acciones que
podría realizar un analizador sintáctico de desplazamiento-reducción al analizar la cadena de
entrada id1 * id2, de acuerdo con la gramática de expresiones (4.1).
PILA
ENTRADA
ACCIÓN
desplazar
reducir
reducir
desplazar
desplazar
reducir
reducir
reducir
aceptar
Figura 4.28: Configuraciones de un analizador sintáctico de desplazamiento-reducción,
con una entrada id1 * id2
Aunque las operaciones primarias son desplazar y reducir, en realidad hay cuatro acciones
posibles que puede realizar un analizador sintáctico de desplazamiento-reducción: (1) desplazar, (2) reducir, (3) aceptar y (4) error.
1. Desplazar. Desplazar el siguiente símbolo de entrada y lo coloca en la parte superior de
la pila.
2. Reducir. El extremo derecho de la cadena que se va a reducir debe estar en la parte superior de la pila. Localizar el extremo izquierdo de la cadena dentro de la pila y decidir con
qué terminal se va a sustituir la cadena.
3. Aceptar. Anunciar que el análisis sintáctico se completó con éxito.
4. Error. Descubrir un error de sintaxis y llamar a una rutina de recuperación de errores.
El uso de una pila en el análisis sintáctico de desplazamiento-reducción se justifica debido
a un hecho importante: el mango siempre aparecerá en algún momento dado en la parte superior de la pila, nunca en el interior. Este hecho puede demostrarse si consideramos las posibles
formas de dos pasos sucesivos en cualquier derivación por la izquierda. La figura 4.29 ilustra
los dos posibles casos. En el caso (1), A se sustituye por βBy, y después el no terminal B por
la derecha en el cuerpo βBy se sustituye por γ. En el caso (2), A se expande primero otra vez,
pero ahora el cuerpo es una cadena y que consiste sólo en terminales. El siguiente no terminal
B por la derecha se encontrará en alguna parte a la derecha de y.
En otras palabras:
Capítulo 4. Análisis sintáctico
238
Caso (1)
Caso (2)
Figura 4.29: Casos para dos pasos sucesivos de una derivación por la derecha
Considere el caso (1) a la inversa, en donde un analizador sintáctico de desplazamiento-reducción acaba de llegar a la siguiente configuración:
PILA
$αβγ
ENTRADA
yz$
El analizador sintáctico reduce el mango γ a B para llegar a la siguiente configuración:
$αβB
yz$
Ahora el analizador puede desplazar la cadena y y colocarla en la pila, mediante una secuencia
de cero o más movimientos de desplazamiento para llegar a la configuración
$αβBy
z$
con el mango βBy en la parte superior de la pila, y se reduce a A.
Ahora considere el caso (2). En la configuración
$αγ
xyz$
el mango γ está en la parte superior de la pila. Después de reducir el mango γ a B, el analizador
sintáctico puede reducir la cadena xy para meter el siguiente mango y en la parte superior de
la pila, listo para reducirse a A:
$αBxy
z$
En ambos casos, después de realizar una reducción, el analizador sintáctico tuvo que desplazar
cero o más símbolos para meter el siguiente mango en la pila. Nunca tuvo que buscar el mango
dentro de la pila.
4.5.4
Conflictos durante el análisis sintáctico
de desplazamiento-reducción
Existen gramáticas libres de contexto para las cuales no se puede utilizar el análisis sintáctico
de desplazamiento-reducción. Cada analizador sintáctico de desplazamiento-reducción para
una gramática de este tipo puede llegar a una configuración en la cual el analizador sintáctico,
conociendo el contenido completo de la pila y el siguiente símbolo de entrada, no puede decidir
239
4.5 Análisis sintáctico ascendente
si va a desplazar o a reducir (un conflicto de desplazamiento/reducción), o no puede decidir qué
reducciones realizar (un conflicto de reducción/reducción). Ahora veremos algunos ejemplos de
construcciones sintácticas que ocasionan tales gramáticas. Técnicamente, estas gramáticas no
están en la clase LR(k) de gramáticas definidas en la sección 4.7; nos referimos a ellas como
gramáticas no LR. La k en LR(k) se refiere al número de símbolos de preanálisis en la entrada.
Las gramáticas que se utilizan en la compilación, por lo general, entran en la clase LR(1), con
un símbolo de anticipación a lo más.
Ejemplo 4.38: Una gramática ambigua nunca podrá ser LR. Por ejemplo, considere la gramática del else colgante (4.14) de la sección 4.3:
instr →
|
|
if expr then instr
if expr then instr else instr
otra
Si tenemos un analizador sintáctico de desplazamiento-reducción con la siguiente configuración:
PILA
… if expr then instr
ENTRADA
else … $
no podemos saber si if expr then instr es el mango, sin importar lo que aparezca debajo de
él en la pila. Aquí tenemos un conflicto de desplazamiento/reducción. Dependiendo de lo que
siga después del else en la entrada, podría ser correcto reducir if expr then instr a instr, o
podría ser correcto desplazar el else y después buscar otro instr para completar la expresión
alternativa if expr then instr else instr.
Observe que el análisis sintáctico de desplazamiento-reducción puede adaptarse para analizar ciertas gramáticas ambiguas, como la gramática if-then-else anterior. Si resolvemos el
conflicto de desplazamiento/reducción en el else a favor del desplazamiento, el analizador
sintáctico se comportará como esperamos, asociando cada else con el then anterior sin coincidencia. En la sección 4.8 hablaremos sobre los analizadores sintácticos para dichas gramáticas
ambiguas. 2
Otra configuración común para los conflictos ocurre cuando sabemos que tenemos un mango,
pero el contenido de la pila y el siguiente símbolo de entrada no son suficientes para determinar
qué producción debe usarse en una reducción. El siguiente ejemplo ilustra esta situación.
Ejemplo 4.39: Suponga que tenemos un analizador léxico que devuelve el nombre de token id
para todos los nombres, sin importar su tipo. Suponga también que nuestro lenguaje invoca a
los procedimientos proporcionando sus nombres, con los parámetros rodeados entre paréntesis,
y que los arreglos se referencian mediante la misma sintaxis. Como la traducción de los índices
en las referencias a arreglos y los parámetros en las llamadas a procedimientos son distintos,
queremos usar distintas producciones para generar listas de parámetros e índices actuales. Por
lo tanto, nuestra gramática podría tener producciones como las de la figura 4.30 (entre otras).
Una instrucción que empieza con p(i,j) aparecería como el flujo de tokens id(id, id) para
el analizador sintáctico. Después de desplazar los primeros tres tokens en la pila, un analizador
sintáctico de desplazamiento-reducción tendría la siguiente configuración:
Capítulo 4. Análisis sintáctico
240
(1)
instr →
(2)
instr →
(3) lista-parametros →
(4) lista-parametros →
(5)
parametro →
(6)
expr →
(7)
expr →
(8)
lista-expr →
(9)
lista-expr →
id ( lista-parametros )
expr := expr
lista-parametros , parámetro
parámetro
id
id ( lista-expr )
id
lista-expr , expr
expr
Figura 4.30: Producciones que implican llamadas a procedimientos y referencias a arreglos
PILA
… id ( id
ENTRADA
, id ) …
Es evidente que el id en la parte superior de la pila debe reducirse, pero ¿mediante qué producción? La elección correcta es la producción (5) si p es un procedimiento, pero si p es un arreglo,
entonces es la producción (7). La pila no indica qué información debemos usar en la en la tabla
de símbolos que se obtiene a partir de la declaración de p.
Una solución es cambiar el token id en la producción (1) a procid y usar un analizador
léxico más sofisticado, que devuelva el nombre de token procid cuando reconozca un lexema
que sea el nombre de un procedimiento. Para ello se requeriría que el analizador léxico consultara la tabla de símbolos, antes de devolver un token.
Si realizamos esta modificación, entonces al procesar p(i,j) el analizador se encontraría
en la siguiente configuración:
PILA
… procid ( id
ENTRADA
, id ) …
o en la configuración anterior. En el caso anterior, elegimos la reducción mediante la producción
(5); en el último caso mediante la producción (7). Observe cómo el tercer símbolo de la parte
superior de la pila determina la reducción que se va a realizar, aun cuando no está involucrado
en la reducción. El análisis sintáctico de desplazamiento-reducción puede utilizar información
de más adentro en la pila, para guiar el análisis sintáctico.
2
4.5.5
Ejercicios para la sección 4.5
Ejercicio 4.5.1: Para la gramática S → 0 S 1 | 0 1 del ejercicio 4.2.2(a), indique el mango
en cada una de las siguientes formas de frases derechas:
a) 000111.
b) 00S11.
Ejercicio 4.5.2: Repita el ejercicio 4.5.1 para la gramática S → S S + | S S ∗ | a del ejercicio
4.2.1 y las siguientes formas de frases derechas:
4.6 Introducción al análisis sintáctico LR: SLR (LR simple)
241
a) SSS + a ∗ +.
b) SS + a ∗ a +.
c) aaa ∗ a + +.
Ejercicio 4.5.3: Proporcione los análisis sintácticos ascendentes para las siguientes cadenas
de entrada y gramáticas:
a) La entrada 000111, de acuerdo a la gramática del ejercicio 4.5.1.
b) La entrada aaa ∗ a++, de acuerdo a la gramática del ejercicio 4.5.2.
4.6
Introducción al análisis sintáctico LR: SLR (LR simple)
El tipo más frecuente de analizador sintáctico ascendentes en la actualidad se basa en un
concepto conocido como análisis sintáctico LR(k); la “L” indica la exploración de izquierda
a derecha de la entrada, la “R” indica la construcción de una derivación por la derecha a la
inversa, y la k para el número de símbolos de entrada de preanálisis que se utilizan al hacer
decisiones del análisis sintáctico. Los casos k = 0 o k = 1 son de interés práctico, por lo que
aquí sólo consideraremos los analizadores sintácticos LR con k ¥ 1. Cuando se omite (k), se
asume que k es 1.
Esta sección presenta los conceptos básicos del análisis sintáctico LR y el método más sencillo para construir analizadores sintácticos de desplazamiento-reducción, llamados “LR Simple”
(o SLR). Es útil tener cierta familiaridad con los conceptos básicos, incluso si el analizador
sintáctico LR se construye mediante un generador de analizadores sintácticos automático. Empezaremos con “elementos” y “estados del analizador sintáctico”; la salida de diagnóstico de un
generador de analizadores sintácticos LR, por lo general, incluye estados del analizador sintáctico, los cuales pueden usarse para aislar las fuentes de conflictos en el análisis sintáctico.
La sección 4.7 introduce dos métodos más complejos (LR canónico y LALR) que se utilizan
en la mayoría de los analizadores sintácticos LR.
4.6.1
¿Por qué analizadores sintácticos LR?
Los analizadores sintácticos LR son controlados por tablas, en forma muy parecida a los analizadores sintácticos LL no recursivos de la sección 4.4.4. Se dice que una gramática para la cual
podemos construir una tabla de análisis sintáctico, usando uno de los métodos en esta sección
y en la siguiente, es una gramática LR. De manera intuitiva, para que una gramática sea LR,
basta con que un analizador sintáctico de desplazamiento-reducción de izquierda a derecha
pueda reconocer mangos de las formas de frases derechas, cuando éstas aparecen en la parte
superior de la pila.
El análisis sintáctico LR es atractivo por una variedad de razones:
• Pueden construirse analizadores sintácticos LR para reconocer prácticamente todas las
construcciones de lenguajes de programación para las cuales puedan escribirse gramáticas
libres de contexto. Existen gramáticas libres de contexto que no son LR, pero por lo general se pueden evitar para las construcciones comunes de los lenguajes de programación.
Capítulo 4. Análisis sintáctico
242
• El método de análisis sintáctico LR es el método de análisis sintáctico de desplazamiento
reducción sin rastreo hacia atrás más general que se conoce a la fecha, y aún así puede
implementarse con la misma eficiencia que otros métodos más primitivos de desplazamiento-reducción (vea las notas bibliográficas).
• Un analizador sintáctico LR puede detectar un error sintáctico tan pronto como sea posible en una exploración de izquierda a derecha de la entrada.
• La clase de gramáticas que pueden analizarse mediante los métodos LR es un superconjunto propio de la clase de gramáticas que pueden analizarse con métodos predictivos o
LL. Para que una gramática sea LR(k), debemos ser capaces de reconocer la ocurrencia
del lado derecho de una producción en una forma de frase derecha, con k símbolos de entrada de preanálisis. Este requerimiento es mucho menos estricto que para las gramáticas
LL(k), en donde debemos ser capaces de reconocer el uso de una producción, viendo sólo
los primeros símbolos k de lo que deriva su lado derecho. Por ende, no debe sorprender
que las gramáticas LR puedan describir más lenguajes que las gramáticas LL.
La principal desventaja del método LR es que es demasiado trabajo construir un analizador
sintáctico LR en forma manual para una gramática común de un lenguaje de programación.
Se necesita una herramienta especializada: un generador de analizadores sintácticos LR. Por
fortuna, hay varios generadores disponibles, y en la sección 4.9 hablaremos sobre uno de los que
se utilizan con más frecuencia: Yacc. Dicho generador recibe una gramática libre de contexto
y produce de manera automática un analizador para esa gramática. Si la gramática contiene
ambigüedades u otras construcciones que sean difíciles de analizar en una exploración de izquierda a derecha de la entrada, entonces el generador de analizadores sintácticos localiza estas
construcciones y proporciona mensajes de diagnóstico detallados.
4.6.2
Los elementos y el autómata LR(0)
¿Cómo sabe un analizador sintáctico de desplazamiento-reducción cuándo desplazar y cuándo
reducir? Por ejemplo, con el contenido $T de la pila y el siguiente símbolo de entrada ∗ en la
figura 4.28, ¿cómo sabe el analizador sintáctico que la T en la parte superior de la pila no es
un mango, por lo cual la acción apropiada es desplazar y no reducir T a E ?
Un analizador sintáctico LR realiza las decisiones de desplazamiento-reducción mediante el
mantenimiento de estados, para llevar el registro de la ubicación que tenemos en un análisis
sintáctico. Los estados representan conjuntos de “elementos”. Un elemento LR(0) (elemento,
para abreviar) de una gramática G es una producción de G con un punto en cierta posición del
cuerpo. Por ende, la producción A → XYZ produce los siguientes cuatro elementos:
La producción A → genera sólo un elemento, A → · .
De manera intuitiva, un elemento indica qué parte de una producción hemos visto en un
punto dado del proceso de análisis sintáctico. Por ejemplo, el elemento A → ·XYZ indica
4.6 Introducción al análisis sintáctico LR: SLR (LR simple)
243
Representación de conjuntos de elementos
Un generador de análisis sintáctico que produce un analizador descendente tal vez requiera representar elementos y conjuntos de elementos en una forma conveniente. Un elemento puede representarse mediante un par de enteros, el primero de los cuales es el número
de una de las producciones de la gramática subyacente, y el segundo de los cuales es la
posición del punto. Los conjuntos de elementos pueden representarse mediante una lista
de estos pares. No obstante, como veremos pronto, los conjuntos necesarios de elementos
a menudo incluyen elementos de “cierre”, en donde el punto se encuentra al principio del
cuerpo. Estos siempre pueden reconstruirse a partir de los otros elementos en el conjunto,
por lo que no tenemos que incluirlos en la lista.
que esperamos ver una cadena que pueda derivarse de XYZ a continuación en la entrada. El
elemento A → X ·YZ indica que acabamos de ver en la entrada una cadena que puede derivarse
de X, y que esperamos ver a continuación una cadena que pueda derivarse de Y Z. El elemento
A → XYZ· indica que hemos visto el cuerpo XYZ y que puede ser hora de reducir XYZ a A.
Una colección de conjuntos de elementos LR(0), conocida como la colección LR(0) canónica, proporciona la base para construir un autómata finito determinista, el cual se utiliza
para realizar decisiones en el análisis sintáctico. A dicho autómata se le conoce como autómata
LR(0).3 En especial, cada estado del autómata LR(0) representa un conjunto de elementos
en la colección LR(0) canónica. El autómata para la gramática de expresiones (4.1), que se
muestra en la figura 4.31, servirá como ejemplo abierto para hablar sobre la colección LR(0)
canónica para una gramática.
Para construir la colección LR(0) canónica de una gramática, definimos una gramática aumentada y dos funciones, CERRADURA e ir_A. Si G es una gramática con el símbolo inicial S,
entonces G , la gramática aumentada para G, es G con un nuevo símbolo inicial S y la producción S → S. El propósito de esta nueva producción inicial es indicar al analizador sintáctico
cuándo debe dejar de analizar para anunciar la aceptación de la entrada. Es decir, la aceptación
ocurre sólo cuando el analizador sintáctico está a punto de reducir mediante S → S.
Cerradura de conjuntos de elementos
Si I es un conjunto de elementos para una gramática G, entonces CERRADURA(I ) es el conjunto de elementos que se construyen a partir de I mediante las siguientes dos reglas:
1. Al principio, agregar cada elemento en I a CERRADURA(I ).
2. Si A → α·Bβ está en CERRADURA(I ) y B → γ es una producción, entonces agregar el
elemento B → γ a CERRADURA(I ), si no se encuentra ya ahí. Aplicar esta regla hasta
que no puedan agregarse más elementos nuevos a CERRADURA(I ).
3
Técnicamente, el autómata deja de ser determinista de acuerdo a la definición de la sección 3.6.4, ya que no
tenemos un estado muerto, que corresponde al conjunto vacío de elementos. Como resultado, hay ciertos pares estadoentrada para los cuales no existe un siguiente estado.
Capítulo 4. Análisis sintáctico
244
aceptar
Figura 4.31: Autómata LR(0) para la gramática de expresiones (4.1)
De manera intuitiva, A → α·Bβ en CERRADURA(I ) indica que, en algún punto en el proceso de análisis sintáctico, creemos que podríamos ver a continuación una subcadena que pueda
derivarse de Bβ como entrada. La subcadena que pueda derivarse de Bβ tendrá un prefijo que
pueda derivarse de B, mediante la aplicación de una de las producciones B. Por lo tanto, agregamos elementos para todas las producciones B; es decir, si B → γ es una producción, también
incluimos a B → ·γ en CERRADURA(I ).
Ejemplo 4.40: Considere la siguiente gramática de expresiones aumentada:
E
E
T
E
→
→
→
→
E
E+T | T
T ∗ F | F
( E ) | id
Si I es el conjunto de un elemento {[E → ·E ]}, entonces CERRADURA(I ) contiene el conjunto de elementos I0 en la figura 4.31.
4.6 Introducción al análisis sintáctico LR: SLR (LR simple)
245
Para ver cómo se calcula la CERRADURA, E → ·E se coloca en CERRADURA(I ) mediante
la regla (1). Como hay una E justo a la derecha de un punto, agregamos las producciones E
con puntos en los extremos izquierdos: E → ·E + T y E → ·T. Ahora hay una T justo a la
derecha de un punto en este último elemento, por lo que agregamos T → ·T ∗ F y T → ·F.
A continuación, la F a la derecha de un punto nos obliga a agregar F → ·(E ) y F → ·id, pero
no necesita agregarse ningún otro elemento.
2
La cerradura puede calcularse como en la figura 4.32. Una manera conveniente de implementar la función cerradura es mantener un arreglo booleano llamado agregado, indexado mediante los no terminales de G, de tal forma que agregado[B] se establezca a true si, y sólo si
agregamos el elemento B → ·γ para cada producción B de la forma B → γ.
ConjuntoDeElementos CERRADURA(I ) {
J = I;
repeat
for ( cada elemento A → α·Bβ en J )
for ( cada producción B → γ de G )
if ( B → ·γ no está en J )
agregar B → ·γ a J;
until no se agreguen más elementos a J en una ronda;
return J;
}
Figura 4.32: Cálculo de CERRADURA
Observe que si se agrega una producción B al cierre de I con el punto en el extremo izquierdo, entonces se agregarán todas las producciones B de manera similar al cierre. Por ende, no
es necesario en algunas circunstancias listar los elementos B → γ que se agregan a I mediante
CERRADURA. Basta con una lista de los no terminales B cuyas producciones se agregaron.
Dividimos todos los conjuntos de elementos de interés en dos clases:
1. Elementos del corazón: el elemento inicial, S → ·S, y todos los elementos cuyos puntos
no estén en el extremo izquierdo.
2. Elementos que no son del corazón: todos los elementos con sus puntos en el extremo
izquierdo, excepto S → ·S.
Además, cada conjunto de elementos de interés se forma tomando la cerradura de un conjunto
de elementos del corazón; desde luego que los elementos que se agregan en la cerradura nunca
podrán ser elementos del corazón. Por lo tanto, podemos representar los conjuntos de elementos en los que realmente estamos interesados con muy poco almacenamiento si descartamos todos
los elementos que no sean del corazón, sabiendo que podrían regenerarse mediante el proceso de
cerradura. En la figura 4.31, los elementos que no son del corazón se encuentran en la parte sombreada del cuadro para un estado.
Capítulo 4. Análisis sintáctico
246
La función ir_A
La segunda función útil es ir_A(I, X ), en donde I es un conjunto de elementos y X es un símbolo gramatical. ir_A(I, X ) se define como la cerradura del conjunto de todos los elementos [A →
αX·β], de tal forma que [A → α·Xβ] se encuentre en I. De manera intuitiva, la función ir_A se
utiliza para definir las transiciones en el autómata LR(0) para una gramática. Los estados del
autómata corresponden a los conjuntos de elementos, y ir_A(I, X ) especifica la transición que
proviene del estado para I, con la entrada X.
Ejemplo 4.41: Si I es el conjunto de dos elementos {[E → E·], [E → E· + T ]}, entonces
ir_A(I, +) contiene los siguientes elementos:
E
T
T
F
F
→ E + ·T
→ ·T ∗ F
→ ·F
→ ·(E )
→ ·id
Para calcular ir_A(I, +), examinamos I en busca de elementos con + justo a la derecha
del punto. E → E· no es uno de estos elementos, pero E → E· + T sí lo es. Desplazamos el
punto sobre el + para obtener E → E + ·T y después tomamos la cerradura de este conjunto
singleton.
2
Ahora estamos listos para que el algoritmo construya a C, la colección canónica de conjuntos de elementos LR(0) para una gramática aumentada G ; el algoritmo se muestra en la
figura 4.33.
void elementos(G ) {
C = CERRADURA({[S → ·S]});
repeat
for ( cada conjunto de elementos I en C )
for ( cada símbolo gramatical X )
if ( ir_A(I, X ) no está vacío y no está en C )
agregar ir_A(I, X ) a C ;
until no se agreguen nuevos conjuntos de elementos a C en una iteración;
}
Figura 4.33: Cálculo de la colección canónica de conjuntos de elementos LR(0)
Ejemplo 4.42: La colección canónica de conjuntos de elementos LR(0) para la gramática
(4.1) y la función ir_A se muestran en la figura 4.31. ir_A se codifica mediante las transiciones
en la figura.
2
247
4.6 Introducción al análisis sintáctico LR: SLR (LR simple)
Uso del autómata LR(0)
La idea central del análisis sintáctico “LR simple”, o SLR, es la construcción del autómata LR(0)
a partir de la gramática. Los estados de este autómata son los conjuntos de elementos de la colección LR(0) canónica, y las traducciones las proporciona la función ir_A. El autómata LR(0)
para la gramática de expresiones (4.1) apareció antes en la figura 4.31.
El estado inicial del autómata LR(0) es CERRADURA({[S → ·S]}), en donde S es el símbolo inicial de la gramática aumentada. Todos los estados son de aceptaciones. Decimos que el
“estado j ” se refiere al estado que corresponde al conjunto de elementos Ij.
¿Cómo puede ayudar el autómata LR(0) con las decisiones de desplazar-reducir? Estas decisiones pueden realizarse de la siguiente manera. Suponga que la cadena γ de símbolos gramaticales
lleva el autómata LR(0) del estado inicial 0 a cierto estado j. Después, se realiza un desplazamiento
sobre el siguiente símbolo de entrada a si el estado j tiene una transición en a. En cualquier otro
caso, elegimos reducir; los elementos en el estado j nos indicarán qué producción usar.
El algoritmo de análisis sintáctico LR que presentaremos en la sección 4.6.3 utiliza su pila
para llevar el registro de los estados, así como de los símbolos gramaticales; de hecho, el símbolo
gramatical puede recuperarse del estado, por lo que la pila contiene los estados. El siguiente
ejemplo proporciona una vista previa acerca de cómo pueden utilizarse un autómata LR(0) y una
pila de estados para realizar decisiones de desplazamiento-reducción en el análisis sintáctico.
Ejemplo 4.43: La figura 4.34 ilustra las acciones de un analizador sintáctico de desplazamientoreducción con la entrada id ∗ id, usando el autómata LR(0) de la figura 4.31. Utilizamos una
pila para guardar los estados; por claridad, los símbolos gramaticales que corresponden a los estados en la pila aparecen en la columna SÍMBOLOS. En la línea (1), la pila contiene el estado inicial
0 del autómata; el símbolo correspondiente es el marcador $ de la parte inferior de la pila.
LÍNEA
PILA
SÍMBOLOS
ENTRADA
ACCIÓN
desplazar
reducir
reducir
desplazar
desplazar
reducir
reducir
reducir
aceptar
Figura 4.34: El análisis sintáctico de id ∗ id
El siguiente símbolo de entrada es id y el estado 0 tiene una transición en id al estado 5.
Por lo tanto, realizamos un desplazamiento. En la línea (2), el estado 5 (símbolo id) se ha
metido en la pila. No hay transición desde el estado 5 con la entrada ∗, por lo que realizamos
una reducción. Del elemento [F → id·] en el estado 5, la reducción es mediante la producción
F → id.
Capítulo 4. Análisis sintáctico
248
Con los símbolos, una reducción se implementa sacando el cuerpo de la producción de la
pila (en la línea (2), el cuerpo es id) y metiendo el encabezado de la producción (en este caso,
F ). Con los estados, sacamos el estado 5 para el símbolo id, lo cual lleva el estado 0 a la parte
superior y buscamos una transición en F, el encabezado de la producción. En la figura 4.31, el
estado 0 tiene una transición en F al estado 3, por lo que metemos el estado 3, con el símbolo
F correspondiente; vea la línea (3).
Como otro ejemplo, considere la línea (5), con el estado 7 (símbolo ∗) en la parte superior
de la pila. Este estado tiene una transición al estado 5 con la entrada id, por lo que metemos
el estado 5 (símbolo id). El estado 5 no tiene transiciones, así que lo reducimos mediante F →
id. Al sacar el estado 5 para el cuerpo id, el estado 7 pasa a la parte superior de la pila. Como
el estado 7 tiene una transición en F al estado 10, metemos el estado 10 (símbolo F ).
2
4.6.3
El algoritmo de análisis sintáctico LR
En la figura 4.35 se muestra un diagrama de un analizador sintáctico LR. Este diagrama consiste en una entrada, una salida, una pila, un programa controlador y una tabla de análisis
sintáctico que tiene dos partes (ACCION y el ir_A). El programa controlador es igual para todos los analizadores sintácticos LR; sólo la tabla de análisis sintáctico cambia de un analizador
sintáctico a otro. El programa de análisis sintáctico lee caracteres de un búfer de entrada, uno
a la vez. En donde un analizador sintáctico de desplazamiento-reducción desplazaría a un símbolo, un analizador sintáctico LR desplaza a un estado. Cada estado sintetiza la información
contenida en la pila, debajo de éste.
Entrada
Pila
Programa
de análisis
sintáctico LR
ACCION
Salida
ir_A
Figura 4.35: Modelo de un analizador sintáctico LR
La pila contiene una secuencia de estados, s0s1 … sm, en donde sm, se encuentra en la parte
superior. En el método SLR, la pila contiene estados del autómata LR(0); los métodos LR
canónico y LALR son similares. Por construcción, cada estado tiene un símbolo gramatical
correspondiente. Recuerde que los estados corresponden a los conjuntos de elementos, y que
hay una transición del estado i al estado j si ir_A(Ii, X ) = Ij. Todas las transiciones al estado j
deben ser para el mismo símbolo gramatical X. Por ende, cada estado, excepto el estado inicial
0, tiene un símbolo gramatical único asociado con él.4
4
Lo opuesto no necesariamente es válido; es decir, más de un estado puede tener el mismo símbolo gramatical. Por
4.6 Introducción al análisis sintáctico LR: SLR (LR simple)
249
Estructura de la tabla de análisis sintáctico LR
La tabla de análisis sintáctico consiste en dos partes: una función de acción de análisis sintáctico llamada ACCION y una función ir_A.
1. La función ACCION recibe como argumentos un estado i y un terminal a (o $, el marcador
de fin de entrada). El valor de ACCION[i, a] puede tener una de cuatro formas:
(a) Desplazar j, en donde j es un estado. La acción realizada por el analizador sintáctico
desplaza en forma efectiva la entrada a hacia la pila, pero usa el estado j para representar la a.
(b) Reducir A → β. La acción del analizador reduce en forma efectiva a β en la parte
superior de la pila, al encabezado A.
(c) Aceptar. El analizador sintáctico acepta la entrada y termina el análisis sintáctico.
(d) Error. El analizador sintáctico descubre un error en su entrada y realiza cierta acción
correctiva. En las secciones 4.8.3 y 4.8.4 hablaremos más acerca de cómo funcionan
dichas rutinas de recuperación de errores.
2. Extendemos la función ir_A, definida en los conjuntos de elementos, a los estados: si ir_
A[Ii, A] = Ij, entonces ir_A también asigna un estado i y un no terminal A al estado j.
Configuraciones del analizador sintáctico LR
Para describir el comportamiento de un analizador sintáctico LR, es útil tener una notación
que represente el estado completo del analizador sintáctico: su pila y el resto de la entrada. Una
configuración de un analizador sintáctico LR es un par:
(s 0s1 … sm, aiai+1 … an$)
en donde el primer componente es el contenido de la pila (parte superior a la derecha) y el
segundo componente es el resto de la entrada. Esta configuración representa la forma de frase
derecha:
X1X2 … Xmaiai+1 … an
básicamente en la misma forma en que lo haría un analizador sintáctico de desplazamientoreducción; la única diferencia es que en vez de símbolos gramaticales, la pila contiene estados
a partir de los cuales pueden recuperarse los símbolos gramaticales. Es decir, Xi es el símbolo
gramatical representado mediante el estado si. Observe que s 0, el estado inicial del analizador
sintáctico, no representa a un símbolo gramatical y sirve como marcador de la parte inferior de
la pila, así como también juega un papel importante en el análisis sintáctico.
ejemplo, vea los estados 1 y 8 en el autómata LR(0) de la figura 4.31, a los cuales se entra mediante las transiciones
en E, o los estados 2 y 9, a los cuales se entra mediante las transiciones en T.
Capítulo 4. Análisis sintáctico
250
Comportamiento del analizador sintáctico LR
El siguiente movimiento del analizador sintáctico a partir de la configuración anterior, se
determina mediante la lectura de ai, el símbolo de entrada actual, y sm, el estado en la parte
superior de la pila, y después se consulta la entrada ACCION[sm, ai] en la tabla de acción del
análisis sintáctico. Las configuraciones resultantes después de cada uno de los cuatro tipos de
movimiento son:
1. Si ACCION[sm, ai] = desplazar s, el analizador ejecuta un movimiento de desplazamiento;
desplaza el siguiente estado s y lo mete en la pila, introduciendo la siguiente configuración:
(s 0s1 … sms, ai+1 … an$)
El símbolo ai no necesita guardarse en la pila, ya que puede recuperarse a partir de s, si
es necesario (en la práctica, nunca lo es). Ahora, el símbolo de entrada actual es ai+1.
2. Si ACCION[sm, ai] = reducir A → β, entonces el analizador sintáctico ejecuta un movimiento de reducción, entrando a la siguiente configuración:
(s 0s1 … sm−rs, aiai+1 … an$ )
en donde r es la longitud de β, y s = ir_A[sm−r, A]. Aquí, el analizador sintáctico primero
sacó los símbolos del estado r de la pila, exponiendo al estado sm−r. Después el analizador sintáctico metió a s, la entrada para ir_A[sm−r, A] en la pila. El símbolo de entrada
actual no se cambia en un movimiento de reducción. Para los analizadores sintácticos LR
que vamos a construir, Xm−r+1 … Xm, la secuencia de los símbolos gramaticales correspondientes a los estados que se sacan de la pila, siempre coincidirá con β, el lado derecho
de la producción reductora.
La salida de un analizador sintáctico LR se genera después de un movimiento de reducción, mediante la ejecución de la acción semántica asociada con la producción reductora.
Por el momento, vamos a suponer que la salida consiste sólo en imprimir la producción
reductora.
3. Si ACCION[sm, ai] = aceptar, se completa el análisis sintáctico.
4. Si ACCION[sm, ai] = error, el analizador ha descubierto un error y llama a una rutina de
recuperación de errores.
A continuación se sintetiza el algoritmo de análisis sintáctico LR. Todos los analizadores
sintácticos LR se comportan de esta manera; la única diferencia entre un analizador sintáctico
LR y otro es la información en los campos ACCION e ir_A de la tabla de análisis sintáctico.
Algoritmo 4.44: Algoritmo de análisis sintáctico LR.
ENTRADA: Una cadena de entrada w y una tabla de análisis sintáctico LR con las funciones
ACCION e ir_A, para una gramática G.
251
4.6 Introducción al análisis sintáctico LR: SLR (LR simple)
SALIDA: Si w está en L(G ), los pasos de reducción de un análisis sintáctico ascendentes para
w; en cualquier otro caso, una indicación de error.
MÉTODO: Al principio, el analizador sintáctico tiene s 0 en su pila, en donde s 0 es el estado
inicial y w$ está en el búfer de entrada. Entonces, el analizador ejecuta el programa en la figura
4.36. 2
hacer que a sea el primer símbolo de w$;
while(1) { /* repetir indefinidamente */
hacer que s sea el estado en la parte superior de la pila;
if ( ACCION[s, a] = desplazar t ) {
meter t en la pila;
hacer que a sea el siguiente símbolo de entrada;
} else if ( ACCION[s, a] = reducir A → β ) {
sacar |β| símbolos de la pila;
hacer que el estado t ahora esté en la parte superior de la pila;
meter ir_A[t, A] en la pila;
enviar de salida la producción A → β;
} else if ( ACCION[s, a] = aceptar ) break; /* terminó el análisis sintáctico */
else llamar a la rutina de recuperación de errores;
}
Figura 4.36: Programa de análisis sintáctico LR
Ejemplo 4.45: La figura 4.37 muestra las funciones ACCION e ir_A de una tabla de análisis
sintáctico LR para la gramática de expresiones (4.1), que repetimos a continuación con las
producciones enumeradas:
(1)
(2)
(3)
E→E+T
E→T
T→T∗F
(4)
(5)
(6)
T→F
T → (E )
F → id
Los códigos para las acciones son:
1. si significa desplazar y meter el estado i en la pila,
2. rj significa reducir mediante la producción enumerada como j,
3. acc significa aceptar,
4. espacio en blanco significa error.
Observe que el valor de ir_A[s, a] para el terminal a se encuentra en el campo ACCION
conectado con la acción de desplazamiento en la entrada a, para el estado s. El campo ir_A
proporciona ir_A[s, A] para los no terminales A. Aunque no hemos explicado aún cómo se seleccionaron las entradas para la figura 4.37, en breve trataremos con esta cuestión.
Capítulo 4. Análisis sintáctico
252
ESTADO
ACCIÓN
ir_A
Figura 4.37: Tabla de análisis sintáctico para la gramática de expresiones
En la entrada id ∗ id + id, la secuencia del contenido de la pila y de la entrada se muestra
en la figura 4.38. Para fines de claridad, también se muestran las secuencias de los símbolos
gramaticales que corresponden a los estados contenidos en la pila. Por ejemplo, en la línea (1)
el analizador LR se encuentra en el estado 0, el estado inicial sin símbolo gramatical, y con id el
primer símbolo de entrada. La acción en la fila 0 y la columna id del campo acción de la figura 4.37 es s5, lo cual significa desplazar metiendo el estado 5. Esto es lo que ha ocurrido en
la línea (2) se ha metido en la pila el símbolo de estado 5, mientras que id se ha eliminado de la
entrada.
Después, ∗ se convierte en el símbolo de entrada actual, y la acción del estado 5 sobre la entrada ∗ es reducir mediante F → id. Se saca un símbolo de estado de la pila. Después se expone
el estado 0. Como el ir_A del estado 0 en F es 3, el estado 3 se mete a la pila. Ahora tenemos
la configuración de la línea (3). Cada uno de los movimientos restantes se determina en forma
similar.
2
4.6.4
Construcción de tablas de análisis sintáctico SLR
El método SLR para construir tablas de análisis sintáctico es un buen punto inicial para estudiar el análisis sintáctico LR. Nos referiremos a la tabla de análisis sintáctico construida por
este método como una tabla SLR, y a un analizador sintáctico LR que utiliza una tabla de
análisis sintáctico SLR como un analizador sintáctico SLR. Los otros dos métodos aumentan el
método SLR con información de anticipación.
El método SLR empieza con elementos LR(0) y un autómata LR(0), que presentamos en la
sección 4.5. Es decir, dada una gramática G, la aumentamos para producir G , con un nuevo
símbolo inicial S . A partir de G construimos a C, la colección canónica de conjuntos de elementos para G , junto con la función ir_A.
253
4.6 Introducción al análisis sintáctico LR: SLR (LR simple)
PILA
SÍMBOLOS
ENTRADA
ACCIÓN
desplazar
reducir mediante
reducir mediante
desplazar
desplazar
reducir mediante
reducir mediante
reducir mediante
desplazar
desplazar
reducir mediante
reducir mediante
reducir mediante
aceptar
Figura 4.38: Movimientos de un analizador sintáctico LR con id ∗ id + id
Después, las entradas ACCION e ir_A en la tabla de análisis sintáctico se construyen utilizando el siguiente algoritmo. Para ello, requerimos conocer SIGUIENTE(A) para cada no terminal A de una gramática (vea la sección 4.4).
Algoritmo 4.46: Construcción de una tabla de análisis sintáctico SLR.
ENTRADA: Una gramática aumentada G .
SALIDA: Las funciones ACCION e ir_A para G de la tabla de análisis sintáctico SLR.
MÉTODO:
1. Construir C = { I0, I1, …, In }, la colección de conjuntos de elementos LR(0) para G .
2. El estado i se construye a partir de Ii. Las acciones de análisis sintáctico para el estado
i se determinan de la siguiente forma:
(a) Si [A → α·aβ] está en Ii e ir_A(Ii, a) = Ij, entonces establecer ACCION[i, a] a “desplazar j ”. Aquí, a debe ser una terminal.
(b) Si [A → α·] está en Ii, entonces establecer ACCION[i, a] a “reducir A → α” para toda
a en SIGUIENTE(A); aquí, A tal vez no sea S .
(c) Si [S → S·] está en Ii, entonces establecer ACCION[i, $] a “aceptar”.
Si resulta cualquier acción conflictiva debido a las reglas anteriores, decimos que la gramática no es SLR(1). El algoritmo no produce un analizador sintáctico en este caso.
Capítulo 4. Análisis sintáctico
254
3. Las transiciones de ir_A para el estado i se construyen para todos los no terminales A,
usando la regla: Si ir_A(Ii, A) = Ij, entonces ir_A[i, A] = j.
4. Todas las entradas que no estén definidas por las reglas (2) y (3) se dejan como “error”.
5. El estado inicial del analizador sintáctico es el que se construyó a partir del conjunto de
elementos que contienen [S → ·S].
2
A la tabla de análisis sintáctico que consiste en las funciones ACCION e ir_A determinadas
por el algoritmo 4.46 se le conoce como la tabla SLR(1) para G. A un analizador sintáctico LR
que utiliza la tabla SLR(1) para G se le conoce como analizador sintáctico SLR(1) par G, y a
una gramática que tiene una tabla de análisis sintáctico SLR(1) se le conoce como SLR(1). Por
lo general, omitimos el “(1)” después de “SLR”, ya que no trataremos aquí con los analizadores
sintácticos que tienen más de un símbolo de preanálisis.
Ejemplo 4.47: Vamos a construir la tabla SLR para la gramática de expresiones aumentada.
La colección canónica de conjuntos de elementos LR(0) para la gramática se mostró en la figura
4.31. Primero consideremos el conjunto de elementos I0:
E → ·E
E → ·E + T
E → ·T
T → ·T ∗ F
T → ·F
F → ·(E )
F → ·id
El elemento F → ·(E ) produce la entrada ACCION[0, (] = desplazar 4, y el elemento F →·id
produce la entrada ACCION[0, id] = desplazar 5. Los demás elementos en I0 no producen ninguna acción. Ahora consideremos I1:
E → E·
E → E· + T
El primer elemento produce ACCION[1, $] = aceptar, y el segundo produce ACCION[1, +] = desplazar 6. Ahora consideremos I2:
E → T·
T → T· ∗ F
Como SIGUIENTE(E ) = {$, +, )}, el primer elemento produce lo siguiente:
ACCION[2, $] = ACCION[2, +] = ACCION[2, )] = reducir E → T
El segundo elemento produce ACCION[2, ∗] = desplazar 7. Si continuamos de esta forma obtendremos las tablas ACCION y ir_A que se muestran en la figura 4.31. En esa figura, los números de las producciones en las acciones de reducción son los mismos que el orden en el que
aparecen en la gramática original (4.1). Es decir, E → E + T es el número 1, E → T es 2, y
así sucesivamente.
2
4.6 Introducción al análisis sintáctico LR: SLR (LR simple)
255
Ejemplo 4.48: Cada una de las gramáticas SLR(1) no tiene ambigüedad, pero hay muchas
gramáticas sin ambigüedad que no son SLR(1). Considere la gramática con las siguientes producciones:
S → L=R|R
L → ∗R | id
R → L
(4.49)
Consideremos que L y R representan l-value y r-value, respectivamente, y que * es un operador
que indica “el contenido de”.5 La colección canónica de conjuntos de elementos LR(0) para la
gramática (4.49) se muestra en la figura 4.39.
Figura 4.39: Colección LR(0) canónica para la gramática (4.49)
Considere el conjunto de elementos I2. El primer elemento en este conjunto hace que ACCION
[2, =] sea “desplazar 6”. Como SIGUIENTE(R) contiene = (para ver por qué, considere la derivación S ⇒ L = R ⇒ ∗R = R), el segundo elemento establece ACCION[2, =] a “reducir R → L”.
Como hay tanto una entrada de desplazamiento como una de reducción en ACCION[2, =], el estado 2
tiene un conflicto de desplazamiento/reducción con el símbolo de entrada =.
La gramática (4.49) no es ambigua. Este conflicto de desplazamiento/reducción surge del
hecho de que el método de construcción del analizador sintáctico SLR no es lo bastante poderoso como para recordar el suficiente contexto a la izquierda para decidir qué acción debe
realizar el analizador sintáctico sobre la entrada =, habiendo visto una cadena que puede reducirse a L. Los métodos canónico y LALR, que veremos a continuación, tendrán éxito en una
colección más extensa de gramáticas, incluyendo la gramática (4.49). Sin embargo, observe que
5
Al igual que en la sección 2.8.3, un l-value designa una ubicación y un r-value es un valor que puede almacenarse
en una ubicación.
Capítulo 4. Análisis sintáctico
256
hay gramáticas sin ambigüedad para las cuales todos los métodos de construcción de analizadores sintácticos LR producirán una tabla de acciones de análisis sintáctico con conflictos. Por
fortuna, dichas gramáticas pueden, por lo general, evitarse en las aplicaciones de los lenguajes
de programación.
2
4.6.5
Prefijos viables
¿Por qué pueden usarse los autómatas LR(0) para realizar decisiones de desplazamiento-reducción? El autómata LR(0) para una gramática caracteriza las cadenas de símbolos gramaticales que pueden aparecer en la pila de un analizador sintáctico de desplazamiento-reducción
para la gramática. El contenido de la pila debe ser un prefijo de una forma de frase derecha. Si
la pila contiene a α y el resto de la entrada es x, entonces una secuencia de reducciones llevará
∗ αx.
a αx a S. En términos de derivaciones, S ⇒
rm
Sin embargo, no todos los prefijos de las formas de frases derechas pueden aparecer en la pila,
ya que el analizador sintáctico no debe desplazar más allá del mango. Por ejemplo, suponga que:
∗ F ∗ id ⇒
∗ (E ) * id
E⇒
rm
rm
Entonces, en diversos momentos durante el análisis sintáctico, la pila contendrá (, (E y (E ),
pero no debe contener (E )∗, ya que (E ) es un mango, que el analizador sintáctico debe reducir
a F antes de desplazar a ∗.
Los prefijos de las formas de frases derechas que pueden aparecer en la pila de un analizador sintáctico de desplazamiento-reducción se llaman prefijos viables. Se definen de la siguiente
manera: un prefijo viable es un prefijo de una forma de frase derecha que no continúa más allá
del extremo derecho del mango por la derecha de esa forma de frase. Mediante esta definición,
siempre es posible agregar símbolos terminales al final de un prefijo viable para obtener una
forma de frase derecha.
El análisis sintáctico SLR se basa en el hecho de que los autómatas LR(0) reconocen los
prefijos viables. Decimos que el elemento A → β1·β2 es válido para un prefijo viable αβ1 si
∗ αAw ⇒ αβ β w. En general, un elemento será válido para muchos
hay una derivación S ⇒
1 2
rm
rm
prefijos viables.
El hecho de que A → β1·β2 sea válido para αβ1 nos dice mucho acerca de si debemos desplazar o reducir cuando encontremos a αβ1 en la pila de análisis sintáctico. En especial, si β2 ≠ , entonces sugiere que no hemos desplazado aún el mango hacia la pila, por lo que el desplazamiento
es nuestro siguiente movimiento. Si β2 = , entonces parece que A → β1 es el mango, y debemos
reducir mediante esta producción. Desde luego que dos elementos válidos pueden indicarnos que
debemos hacer distintas cosas para el mismo prefijo viable. Podemos resolver algunos de estos
conflictos analizando el siguiente símbolo de entrada, y otros podemos resolverlos mediante los
métodos de la sección 4.8, pero no debemos suponer que todos los conflictos de acciones de análisis sintáctico pueden resolverse si se aplica el método LR a una gramática arbitraria.
Podemos calcular con facilidad el conjunto de elementos válidos para cada prefijo viable
que puede aparecer en la pila de un analizador sintáctico LR. De hecho, un teorema central de
la teoría de análisis sintáctico LR nos dice que el conjunto de elementos válidos para un prefijo viable γ es exactamente el conjunto de elementos a los que se llega desde el estado inicial,
4.6 Introducción al análisis sintáctico LR: SLR (LR simple)
257
Los elementos como estados de un AFN
Podemos construir un autómata finito no determinista N para reconocer prefijos viables
si tratamos a los mismos elementos como estados. Hay una transición desde A → α·Xβ
hacia A → αX·β etiquetada como X, y hay una transición desde A → α·Bβ hacia B → ·γ
etiquetada como . Entonces, CERRADURA(I ) para el conjunto de elementos (estados de
N ) I es exactamente el cierre de un conjunto de estados de un AFN definidos en la sección 3.7.1. Por ende, ir_A(I, X ) proporciona la transición proveniente de I en el símbolo X
del AFD construido a partir de N mediante la construcción del subconjunto. Si lo vemos
de esta forma, el procedimiento elementos(G ) en la figura 4.33 es sólo la construcción del
mismo subconjunto que se aplica al AFN N, con los elementos como estados.
a lo largo de la ruta etiquetada como γ en el autómata LR(0) para la gramática. En esencia, el
conjunto de elementos válidos abarca toda la información útil que puede deducirse de la pila.
Aunque aquí no demostraremos este teorema, vamos a ver un ejemplo.
Ejemplo 4.50: Vamos a considerar de nuevo la gramática de expresiones aumentada, cuyos conjuntos de elementos y la función ir_A se exhiben en la figura 4.31. Sin duda, la cadena E + T ∗ es
un prefijo viable de la gramática. El autómata de la figura 4.31 se encontrará en el estado 7, después
de haber leído E + T ∗. El estado 7 contiene los siguientes elementos:
que son precisamente los elementos válidos para E + T ∗. Para ver por qué, considere las siguientes tres derivaciones por la derecha:
La primera derivación muestra la validez de T → T ∗ ·F, la segunda muestra la validez deF → ·(E ),
y la tercera muestra la validez de F → ·id. Se puede mostrar que no hay otros elementos válidos
para E + T ∗, aunque aquí no demostraremos ese hecho. 2
4.6.6
Ejercicios para la sección 4.6
Ejercicio 4.6.1: Describa todos los prefijos viables para las siguientes gramáticas:
a) La gramática S → 0 S 1 | 0 1 del ejercicio 4.2.2(a).
Capítulo 4. Análisis sintáctico
258
! b) La gramática S → S S + | S S ∗ | a del ejercicio 4.2.1.
! c) La gramática S → S ( S ) | del ejercicio 4.2.2(c).
Ejercicio 4.6.2: Construya los conjuntos SLR de elementos para la gramática (aumentada)
del ejercicio 4.2.1. Calcule la función ir_A para estos conjuntos de elementos. Muestre la tabla de
análisis sintáctico para esta gramática. ¿Es una gramática SLR?
Ejercicio 4.6.3: Muestre las acciones de su tabla de análisis sintáctico del ejercicio 4.6.2 sobre
la entrada aa ∗ a+.
Ejercicio 4.6.4: Para cada una de las gramáticas (aumentadas) del ejercicio 4.2.2(a)-(g):
a) Construya los conjuntos SLR de elementos y su función ir_A.
b) Indique cualquier conflicto de acciones en sus conjuntos de elementos.
c) Construya la tabla de análisis sintáctico SLR, si es que existe.
Ejercicio 4.6.5: Muestre que la siguiente gramática:
S → AaAb|BbBa
A → B → es LL(1), pero no SLR(1).
Ejercicio 4.6.6: Muestre que la siguiente gramática:
S →
A →
SA|A
a
es SLR(1), pero no LL(1).
!! Ejercicio 4.6.7: Considere la familia de gramáticas Gn definidas por:
S
Ai
→ Ai bi
→ aj Ai | aj
para 1 ¥ i ¥ n
para 1 ¥ i, j ¥ n e i ≠ j
Muestre que:
a) Gn tiene 2n2 − 2 producciones.
b) Gn tiene 2n2 + n2 + n conjuntos de elementos LR(0).
c) Gn es SLR(1).
¿Qué dice este análisis acerca de la extensión que pueden llegar a tener los analizadores sintácticos LR?
4.7 Analizadores sintácticos LR más poderosos
259
! Ejercicio 4.6.8: Sugerimos que los elementos individuales pudieran considerarse como estados de un autómata finito no determinista, mientras que los conjuntos de elementos válidos
son los estados de un autómata finito determinista (vea el recuadro titulado “Los elementos
como estados de un AFN” en la sección 4.6.5). Para la gramática S → S S + | S S ∗ | a del
ejercicio 4.2.1:
a) Dibuje el diagrama de transición (AFN) para los elementos válidos de esta gramática,
de acuerdo a la regla que se proporciona en el recuadro antes citado.
b) Aplique la construcción de subconjuntos (Algoritmo 3.20) a su AFN, a partir de (a).
¿Cómo se compara el AFD resultante con el conjunto de elementos LR(0) para la gramática?
!! c) Muestre que en todos los casos, la construcción de subconjuntos que se aplica al AFN
que proviene de los elementos válidos para una gramática produce los conjuntos LR(0)
de elementos.
! Ejercicio 4.6.9: La siguiente es una gramática ambigua:
S → AS|b
A → SA|a
Construya para esta gramática su colección de conjuntos de elementos LR(0). Si tratamos de
construir una tabla de análisis sintáctico LR para la gramática, hay ciertas acciones en conflicto. ¿Qué son? Suponga que tratamos de usar la tabla de análisis sintáctico eligiendo en forma
no determinista una posible acción, cada vez que haya un conflicto. Muestre todas las posibles
secuencias de acciones con la entrada abab.
4.7
Analizadores sintácticos LR más poderosos
En esta sección vamos a extender las técnicas anteriores de análisis sintáctico LR, para usar un
símbolo de preanálisis en la entrada. Hay dos métodos distintos:
1. El método “LR canónico”, o simplemente “LR”, que utiliza al máximo el (los) símbolo(s)
de preanálisis. Este método utiliza un extenso conjunto de elementos, conocidos como
elementos LR(1).
2. El método “LR con símbolo de preanálisis” o “LALR(lookahead LR)”, que se basa en
los conjuntos de elementos LR(0), y tiene mucho menos estados que los analizadores sintácticos comunes, basados en los elementos LR(1). Si introducimos con cuidado lecturas
anticipadas en los elementos LR(0), podemos manejar muchas gramáticas más con el
método LALR que con el SLR, y construir tablas de análisis sintáctico que no sean más
grandes que las tablas SLR. LALR es el método de elección en la mayoría de las situaciones.
Después de presentar ambos métodos, concluiremos con una explicación acerca de cómo compactar las tablas de análisis sintáctico LR para los entornos con memoria limitada.
Capítulo 4. Análisis sintáctico
260
4.7.1
Elementos LR(1) canónicos
Ahora presentaremos la técnica más general para construir una tabla de análisis sintáctico LR
a partir de una gramática. Recuerde que en el método SLR, el estado i llama a la reducción
mediante A → α si el conjunto de elementos Ii contiene el elemento [A → α·] y α se encuentra
en SIGUIENTE(A). No obstante, en algunas situaciones cuando el estado i aparece en la parte
superior de la pila, el prefijo viable βα en la pila es tal que βA no puede ir seguida de a en
ninguna forma de frase derecha. Por ende, la reducción mediante A → α debe ser inválida con
la entrada a.
Ejemplo 4.51: Vamos a reconsiderar el ejemplo 4.48, en donde en el estado 2 teníamos el
elemento R → L, el cual podía corresponder a la A → α anterior, y a podía ser el signo =, que
se encuentra en SIGUIENTE(R). Por ende, el analizador sintáctico SLR llama a la reducción
mediante R → L en el estado 2, con = como el siguiente símbolo de entrada (también se llama
a la acción de desplazamiento, debido al elemento S → L·=R en el estado 2). Sin embargo, no
hay forma de frase derecha de la gramática en el ejemplo 4.48 que empiece como R = … . Por
lo tanto, el estado 2, que es el estado correspondiente al prefijo viable L solamente, en realidad
no debería llamar a la reducción de esa L a R. 2
Es posible transportar más información en el estado, que nos permita descartar algunas de
estas reducciones inválidas mediante A → α. Al dividir estados según sea necesario, podemos
hacer que cada estado de un analizador sintáctico LR indique con exactitud qué símbolos de
entrada pueden ir después de un mango α para el cual haya una posible reducción a A.
La información adicional se incorpora al estado mediante la redefinición de elementos, para
que incluyan un símbolo terminal como un segundo componente. La forma general de un elemento se convierte en [A → α · β, a], en donde A → αβ es una producción y a es un terminal o
el delimitador $ derecho. A un objeto de este tipo le llamamos elemento LR(1). El 1 se refiere
a la longitud del segundo componente, conocido como la lectura anticipada del elemento.6 La
lectura anticipada no tiene efecto sobre un elemento de la forma [A→ α·β, a], en donde β no
es , pero un elemento de la forma [A → α·, a] llama a una reducción mediante A → α sólo si
el siguiente símbolo de entrada es a. Por ende, nos vemos obligados a reducir mediante A →
α sólo con esos símbolos de entrada a para los cuales [A → α·, a] es un elemento LR(1) en el
estado en la parte superior de la pila. El conjunto de tales as siempre será un subconjunto de
SIGUIENTE(A), pero podría ser un subconjunto propio, como en el ejemplo 4.51.
De manera formal, decimos que el elemento LR(1) [A → α·β, a] es válido para un prefijo
∗
viable γ si hay una derivación S ⇒
δAw ⇒ δαβw, en donde
rm
rm
1. γ = δα, y
2. a es el primer símbolo de w, o w es y, a es $.
Ejemplo 4.52: Consideremos la siguiente gramática:
6
Desde luego que son posibles las lecturas anticipadas que sean cadenas de una longitud mayor a uno, pero no las
consideraremos en este libro.
4.7 Analizadores sintácticos LR más poderosos
261
S →BB
B→aB | b
∗ aaBab ⇒ aaaBab. Podemos ver que el elemento [B →
Hay una derivación por la derecha S ⇒
rm
rm
a·B, a] es válido para un prefijo viable γ = aaa, si dejamos que δ = aa, A = B, w = ab α = a y
∗ BaB ⇒ BaaB.
β = B en la definición anterior. También hay una derivación por la derecha S ⇒
rm
rm
De esta derivación podemos ver que el elemento [B → α·B, $] es válido para el prefijo viable
Baa.
2
4.7.2
Construcción de conjuntos de elementos LR(1)
El método para construir la colección de conjuntos de elementos LR(1) válidos es en esencia el
mismo que para construir la colección canónica de conjuntos de elementos LR(0). Sólo necesitamos modificar los dos procedimientos CERRADURA e ir_A.
ConjuntoDeElementos CERRADURA(I ) {
repeat
for ( cada elemento [A → α·Bβ, a] en I )
for ( cada producción B → γ en G )
for ( cada terminal b en PRIMERO(βa) )
agregar [B → ·γ, b] al conjunto I;
until no se agreguen más elementos a I;
return I;
}
ConjuntoDeElementos ir_A(I, X ) {
inicializar J para que sea el conjunto vacío;
for ( cada elemento [A → α·Xβ, a] en I )
agregar el elemento [A → αX·β, a] al conjunto J;
return CERRADURA(J);
}
void elementos(G ) {
inicializar C a CERRADURA({[S → ·S, $]});
repeat
for ( cada conjunto de elementos I en C )
for ( cada símbolo gramatical X )
if ( ir_A(I, X ) no está vacío y no está en C )
agregar ir_A(I, X ) a C;
until no se agreguen nuevos conjuntos de elementos a C;
}
Figura 4.40: Construcción de conjuntos de elementos LR(1) para la gramática G Capítulo 4. Análisis sintáctico
262
Para apreciar la nueva definición de la operación CERRADURA, en especial, por qué b debe estar en PRIMERO(βa), considere un elemento de la forma [A → α·Bβ, a] en el conjunto de elementos válido para cierto prefijo viable γ. Entonces hay una derivación por la derecha
∗ δAax ⇒ δαBβax, en donde γ = δα. Suponga que βax deriva a la cadena de terminales
S⇒
rm
rm
by. Entonces, para cada producción de la forma B → η para cierta η, tenemos la derivación
∗
S ⇒ γBby ⇒ γηby. Por ende, [B → ·η, b] es válida para γ. Observe que b puede ser el primer terrm
rm
∗ by y,
minal derivado a partir de β, o que es posible que β derive a en la derivación βax ⇒
rm
por lo tanto, b puede ser a. Para resumir ambas posibilidades, decimos que b puede ser cualquier
terminal en PRIMERO(βax), en donde PRIMERO es la función de la sección 4.4. Observe que x
no puede contener la primera terminal de by, por lo que PRIMERO(βax) = PRIMERO(βa). Ahora
proporcionaremos la construcción de los conjuntos de elementos LR(1).
Figura 4.41: El gráfico de ir_A para la gramática (4.55)
Algoritmo 4.53: Construcción de los conjuntos de elementos LR(1).
ENTRADA: Una gramática aumentada G .
SALIDA: Los conjuntos de elementos LR(1) que son el conjunto de elementos válido para uno
o más prefijos viables de G .
263
4.7 Analizadores sintácticos LR más poderosos
MÉTODO: Los procedimientos CERRADURA e ir_A, y la rutina principal elementos para cons-
truir los conjuntos de elementos se mostraron en la figura 4.40.
2
Ejemplo 4.54: Considere la siguiente gramática aumentada:
S
S
C
→
→
→
S
CC
cC | d
(4.55)
Empezamos por calcular la cerradura de {[S → ·S, $]}. Relacionamos el elemento [S → ·S, $]
con el elemento [A → α·Bβ, a] en el procedimiento CERRADURA. Es decir, A = S , α = , B = S,
β = y a = $. La función CERRADURA nos indica que debemos agregar [B → ·γ, b] para cada
producción B → y y la terminal b en PRIMERO(βa). En términos de la gramática actual, B → γ
debe ser S → CC, y como β es y a es $, b sólo puede ser $. Por ende, agregamos [S → ·CC, $].
Para seguir calculando la cerradura, agregamos todos los elementos [C → ·γ, b] para b en
PRIMERO(C $). Es decir, si relacionamos [S→ ·CC, $] con [A → α·Bβ, a], tenemos que A = S, α = ,
B = C, β = C y a = $. Como C no deriva a la cadena vacía, PRIMERO(C $) = PRIMERO(C ).
Como PRIMERO(C ) contiene los terminales c y d, agregamos los elementos [C → ·cC, c], [C →
·cC, d ], [C → ·d, c] y [C → ·d, d ]. Ninguno de los elementos nuevos tiene un no terminal justo a
la derecha del punto, por lo que hemos completado nuestro primer conjunto de elementos LR(1).
El conjunto inicial de elementos es:
I0 :
S → ·S, $
S → ·CC, $
C → ·cC, c/d
C → ·d, c/d
Hemos omitido los corchetes por conveniencia de notación, y utilizamos la notación [C → ·cC,
c/d] como abreviación para los dos elementos [C → ·cC, c ] y [C → ·cC, d ].
Ahora calculamos ir_A(I0, X ) para los diversos valores de X. Para X = S debemos la cerradura del elemento [S → S·, $]. No es posible una cerradura adicional, ya que el punto está en
el extremo derecho. Por ende, tenemos el siguiente conjunto de elementos:
I1 :
S → S·, $
Para X = C calculos la cerradura [S → C·C, $]. Agregamos las producciones C con el segundo
componente $ y después no podemos agregar más, produciendo lo siguiente:
I2 :
S → C·C, $
C → ·cC, $
C → ·d, $
Ahora, dejamos que X = c. Debemos calcular la cerradura {[C → c·C, c/d]}. Agregamos las
producciones C con el segundo componente c/d, produciendo lo siguiente:
Capítulo 4. Análisis sintáctico
264
I3 :
C → c·C, c/d
C → ·cC, c/d
C → ·d, c/d
Por último, dejamos que X = d, y terminamos con el siguiente conjunto de elementos:
I4 :
C → d·, c/d
Hemos terminado de considerar a ir_A con I0. No obtenemos nuevos conjuntos de I1, pero I2,
tiene ir_A en C, c y d. Para ir_A(I2, C), obtenemos lo siguiente:
I5 :
S → CC·, $
sin que se requiera una cerradura. Para calcular ir_A(I2, c) tomamos la cerradura de {[C →
c·C, $], para obtener lo siguiente:
I6 :
C → c·C, $
C → ·cC, $
C → ·d, $
Observe que I6 difiere de I3 sólo en los segundos componentes. Más adelante veremos que es común para ciertos conjuntos de elementos LR(1) que una gramática tenga los mismos primeros
componentes y que difieran en sus segundos componentes. Cuando construyamos la colección
de conjuntos de elementos LR(0) para la misma gramática, cada conjunto de LR(0) coincidirá
con el conjunto de los primeros componentes de uno o más conjuntos de elementos LR(1).
Cuando hablemos sobre el análisis sintáctico LALR, veremos más sobre este fenómeno.
Continuando con la función ir_A para I2, ir_A(I2, d) se ve de la siguiente manera:
I7 :
C → d·, $
Si pasamos ahora a I3, los ir_A de I3 en c y d son I3 e I4, respectivamente, y ir_A(I3, C) es:
I8 :
C → cC·, c/d
I4 e I5 no tienen ir_As, ya que todos los elementos tienen sus puntos en el extremo derecho.
Los ir_As de I6 en c y d son I6 e I7, respectivamente, y ir_A(I6, C) es:
I9 :
C → cC·, $
Los conjuntos restantes de elementos no producen mas ir_A, por lo que hemos terminado.
La figura 4.41 muestra los diez conjuntos de elementos con sus ir_A.
2
4.7 Analizadores sintácticos LR más poderosos
4.7.3
265
Tablas de análisis sintáctico LR(1) canónico
Ahora proporcionaremos las reglas para construir las funciones ACCION e ir_A de LR(1), a
partir de los conjuntos de elementos LR(1). Estas funciones se representan mediante una tabla,
como antes. La única diferencia está en los valores de las entradas.
Algoritmo 4.56: Construcción de tablas de análisis sintáctico LR canónico.
ENTRADA: Una gramática aumentada G .
SALIDA: Las funciones ACCION e ir_A de la tabla de análisis sintáctico LR canónico para G .
MÉTODO:
1. Construir C = { I0, I1, …, In }, la colección de conjuntos de elementos LR(1) para G .
2. El estado i del analizador sintáctico se construye a partir de Ii. La acción de análisis
sintáctico para el estado i se determina de la siguiente manera.
(a) Si [A → α·aβ, b] está en Ii, e ir_A(Ii, a) = Ij, entonces hay que establecer ACCION[i, a] a
“desplazar j”. Aquí, a debe ser una terminal.
(b) Si [A → α·, a] está en Ii, A ≠ S , entonces hay que establecer ACCION[i, a] a “reducir
A → α”.
(c) Si [S → S·, $] está en Ii, entonces hay que establecer ACCION[i, $] a “aceptar”.
Si resulta cualquier acción conflictiva debido a las reglas anteriores, decimos que la gramática no es LR(1). El algoritmo no produce un analizador sintáctico en este caso.
3. Las transiciones ir_A para el estado i se construyen para todos los no terminales A
usando la regla: Si ir_A(Ii, A) = Ij, entonces ir_A[i, A] = j.
4. Todas las entradas no definidas por las reglas (2) y (3) se vuelven “error”.
2
5. El estado inicial del analizador sintáctico es el que se construye a partir del conjunto de
elementos que contienen [S → ·S, $].
A la tabla que se forma a partir de la acción de análisis sintáctico y las funciones producidas por el Algoritmo 4.44 se le conoce como la tabla de análisis LR(1) canónica. Si la función
de acción de análisis sintáctico no tiene entradas definidas en forma múltiple, entonces a la
gramática dada se le conoce como gramática LR(1). Como antes, omitimos el “(1)” si queda
comprendida su función.
Ejemplo 4.57: La tabla de análisis sintáctico canónica para la gramática (4.55) se muestra en
la figura 4.42. Las producciones 1, 2 y 3 son S → CC, C → cC y C → d, respectivamente. 2
Cada gramática SLR(1) es una gramática LR(1), pero para una gramática SLR(1) el analizador sintáctico LR canónico puede tener más estados que el analizador sintáctico SLR para
la misma gramática. La gramática de los ejemplos anteriores es SLR, y tiene un analizador
sintáctico SLR con siete estados, en comparación con los diez de la figura 4.42.
Capítulo 4. Análisis sintáctico
266
ESTADO
ACCIÓN
ir_A
Figura 4.42: Tabla de análisis sintáctico canónica para la gramática (4.55)
4.7.4
Construcción de tablas de análisis sintáctico LALR
Ahora presentaremos nuestro último método de construcción de analizadores sintácticos, la
técnica LALR (LR con lectura anticipada). Este método se utiliza con frecuencia en la práctica,
ya que las tablas que se obtienen son considerablemente menores que las tablas LR canónicas, y
a pesar de ello la mayoría de las construcciones sintácticas comunes de los lenguajes de programación pueden expresarse en forma conveniente mediante una gramática LALR. Lo mismo es
casi válido para las gramáticas SLR, pero hay algunas construcciones que las técnicas SLR no
pueden manejar de manera conveniente (vea el ejemplo 4.48).
Para una comparación del tamaño de los analizadores sintácticos, las tablas SLR y LALR
para una gramática siempre tienen el mismo número de estados, y este número consiste, por lo
general, en cientos de estados, para un lenguaje como C. La tabla LR canónica tendría, por lo general, varios miles de estados para el lenguaje con el mismo tamaño. Por ende, es mucho más fácil
y económico construir tablas SLR y LALR que las tablas LR canónicas.
Con el fin de una introducción, consideremos de nuevo la gramática (4.55), cuyos conjuntos
de elementos LR(1) se mostraron en la figura 4.41. Tomemos un par de estados con apariencia
similar, como I4 e I7. Cada uno de estos estados sólo tiene elementos con el primer componente
C → d·. En I4, los símbolos de anticipación son c o d; en I7, $ es el único símbolo de anticipación.
Para ver las diferencias entre las funciones de I4 e I7 en el analizador sintáctico, observe que
la gramática genera el lenguaje regular c∗dc∗d. Al leer una entrada cc…cdcc…cd, el analizador
sintáctico desplaza el primer grupo de cs y la d subsiguiente, y las mete en la pila, entrando al
estado 4 después de leer la d. Después, el analizador llama a una reducción mediante C → d,
siempre y cuando el siguiente símbolo de entrada sea c o d. El requerimiento de que sigue c o d
tiene sentido, ya que éstos son los símbolos que podrían empezar cadenas en c∗d. Si $ sigue después de la primera d, tenemos una entrada como ccd, que no está en el lenguaje, y el estado 4
declara en forma correcta un error si $ es la siguiente entrada.
El analizador sintáctico entra al estado 7 después de leer la segunda d. Después, el analizador debe ver a $ en la entrada, o de lo contrario empezó con una cadena que no es de la forma
4.7 Analizadores sintácticos LR más poderosos
267
c∗dc∗d. Por ende, tiene sentido que el estado 7 deba reducir mediante C → d en la entrada $,
y declarar un error en entradas como c o d.
Ahora vamos a sustituir I4 e I7 por I47, la unión de I4 e I7, que consiste en el conjunto de tres
elementos representados por [C → d·, c/d/$]. Las transacciones ir_A en d que pasan a I4 o a I7
desde I0, I2, I3 e I6 ahora entran a I47. La acción del estado 47 es reducir en cualquier entrada.
El analizador sintáctico revisado se comporta en esencia igual que el original, aunque podría
reducir d a C en circunstancias en las que el original declararía un error, por ejemplo, en una
entrada como ccd o cdcdc. En un momento dado, el error se atrapará; de hecho, se atrapará
antes de que se desplacen más símbolos de entrada.
En forma más general, podemos buscar conjuntos de elementos LR(1) que tengan el mismo corazón; es decir, el mismo conjunto de primeros componentes, y podemos combinar estos
conjuntos con corazones comunes en un solo conjunto de elementos. Por ejemplo, en la figura
4.41, I4 e I7 forman dicho par, con el corazón {C → d·}. De manera similar, I3 e I6 forman otro
par, con el corazón {C → c·C, C → ·cC, C → ·d}. Hay un par más, I8 e I9, con el corazón común {C → cC·}. Observe que, en general, un corazón es un conjunto de elementos LR(0) para
la gramática en cuestión, y que una gramática LR(1) puede producir más de dos conjuntos de
elementos con el mismo corazón.
Como el corazón de ir_A(I, X ) depende sólo del corazón de I, las transacciones ir_A de los
conjuntos combinados pueden combinarse entre sí mismos. Por ende, no hay problema al revisar
la función ir_A a medida que combinamos conjuntos de elementos. Las funciones activas se modifican para reflejar las acciones sin error de todos los conjuntos de elementos en la combinación.
Suponga que tenemos una gramática LR(1), es decir, una cuyos conjuntos de elementos
LR(1) no produzcan conflictos de acciones en el análisis sintáctico. Si sustituimos todos los
estados que tengan el mismo corazón con su unión, es posible que la unión resultante tenga un
conflicto, pero es poco probable debido a lo siguiente: Suponga que en la unión hay un conflicto
en el símbolo anticipado, debido a que hay un elemento [A → α·, a] que llama a una reducción
mediante A → α, y que hay otro elemento [B → β·aγ, b] que llama a un desplazamiento. Entonces, cierto conjunto de elementos a partir del cual se formó la unión tiene el elemento [A → α·, a],
y como los corazones de todos estos estados son iguales, debe tener un elemento [B → β·aγ, c]
para alguna c. Pero entonces, este estado tiene el mismo conflicto de desplazamiento/reducción
en a, y la gramática no era LR(1) como supusimos. Por ende, la combinación de estados con
corazones comunes nunca podrá producir un conflicto de desplazamiento/reducción que no haya
estado presente en uno de los estados originales, ya que las acciones de desplazamiento dependen
sólo del corazón, y no del símbolo anticipado.
Sin embargo, es posible que una combinación produzca un conflicto de reducción/reducción,
como se muestra en el siguiente ejemplo.
Ejemplo 4.58: Considere la siguiente gramática:
S
S
A
B
→
→
→
→
S
aAd | bBd | aBe | bAe
c
c
la cual genera las cuatro cadenas acd, ace, bcd y bce. El lector puede comprobar que la gramática es LR(1) mediante la construcción de los conjuntos de elementos. Al hacer esto, encontramos
Capítulo 4. Análisis sintáctico
268
el conjunto de elementos {[A → c·, d ], [B → c·, e]} válido para el prefijo viable ac y {[A →
c·, e], [B → c·, d ]} válido para bc. Ninguno de estos conjuntos tiene un conflicto, y sus corazones
son iguales. Sin embargo, su unión, que es:
A → c·, d/e
B → c·, d/e
genera un conflicto de reducción/reducción, ya que las reducciones mediante A → c y B → c
se llaman para las entradas d y e.
2
Ahora estamos preparados para proporcionar el primero de dos algoritmos de construcción
de tablas LALR. La idea general es construir los conjuntos de elementos LR(1), y si no surgen
conflictos, combinar los conjuntos con corazones comunes. Después, construiremos la tabla de
análisis sintáctico a partir de la colección de conjuntos de elementos combinados. El método
que vamos a describir sirve principalmente como una definición de las gramáticas LALR(1). El
proceso de construir la colección completa de conjuntos de elementos LR(1) requiere demasiado
espacio y tiempo como para que sea útil en la práctica.
Algoritmo 4.59: Una construcción de tablas LALR sencilla, pero que consume espacio.
ENTRADA: Una gramática aumentada G .
SALIDA: Las funciones de la tabla de análisis sintáctico LALR ACCION e ir_A para G .
MÉTODO:
1. Construir C = {I0, I1, …, In}, la colección de conjuntos de elementos LR(1).
2. Para cada corazón presente entre el conjunto de elementos LR(1), buscar todos los conjuntos que tengan ese corazón y sustituir estos conjuntos por su unión.
3. Dejar que C = {J 0, J 1, …, J m} sean los conjuntos resultantes de elementos LR(1). Las
acciones de análisis sintáctico para el estado i se construyen a partir de J i, de la misma
forma que en el Algoritmo 4.56. Si hay un conflicto de acciones en el análisis sintáctico, el algoritmo no produce un analizador sintáctico y decimos que la gramática no es
LALR(1).
4. La tabla ir_A se construye de la siguiente manera. Si J es la unión de uno o más con
juntos de elementos LR(1), es decir, J = I1 ∩ I2 ∩ … ∩ Ik, entonces los corazones de
ir_A(I 1, X ), ir_A(I 2, X ), …, ir_A(I k, X ) son iguales, ya que I 1, I 2, …, I k tienen el mismo
corazón. Dejar que K sea la unión de todos los conjuntos de elementos que tienen el
mismo corazón que ir_A(I 1, X ). Entonces, ir_A(J, X ) = K.
2
A la tabla producida por el algoritmo 4.59 se le conoce como la tabla de análisis sintáctico
LALR para G. Si no hay conflictos de acciones en el análisis sintáctico, entonces se dice que la
gramática dada es una gramática LALR(1). A la colección de conjuntos de elementos que se
construye en el paso (3) se le conoce como colección LALR(1).
269
4.7 Analizadores sintácticos LR más poderosos
Ejemplo 4.60: Considere de nuevo la gramática (4.55), cuyo gráfico de ir_A se mostró en la
figura 4.41. Como dijimos antes, hay tres pares de conjuntos de elementos que pueden combinarse. I3 e I6 se sustituyen por su unión:
I36 : C → c·C, c/d/$
C → ·cC, c/d/$
C → ·d, c/d/$
I4 e I7 se sustituyen por su unión:
I47 : C → d·, c/d/$
después, I8 e I9 se sustituyen por su unión:
I89 : C → cC·, c/d/$
Las funciones de acción e ir_A LALR para los conjuntos combinados de elementos se muestran
en la figura 4.43.
ESTADO
ACCIÓN
ir_A
Figura 4.43: Tabla de análisis sintáctico LALR para la gramática del ejemplo 4.54
Para ver cómo se calculan los ir_A, considere el ir_A(I36, C ). En el conjunto original de elementos LR(1), ir_A(I3, C ) = I8, y ahora I8 es parte de I89, por lo que hacemos que ir_A(I36, C )
sea I89. Podríamos haber llegado a la misma conclusión si consideráramos a I6, la otra parte de
I36. Es decir, ir_A(I6, C ) = I9, y ahora I9 forma parte de I89. Para otro ejemplo, considere a
ir_A(I2, c), una entrada que se ejerce después de la acción de desplazamiento de I2 en la entrada c.
En los conjuntos originales de elementos LR(1), ir_A(I2, c) = I6. Como I6 forma ahora parte de
I36, ir_A(I2, c) se convierte en I36. Por lo tanto, la entrada en la figura 4.43 para el estado 2 y
la entrada c se convierte en s36, lo cual significa desplazar y meter el estado 36 en la pila. 2
Cuando se les presenta una cadena del lenguaje c∗dc∗d, tanto el analizador sintáctico LR
de la figura 4.42 como el analizador sintáctico LALR de la figura 4.43 realizan exactamente la misma secuencia de desplazamientos y reducciones, aunque los nombres de los estados en
la pila pueden diferir. Por ejemplo, si el analizador sintáctico LR mete a I3 o I6 en la pila, el
analizador sintáctico LALR meterá a I36 en la pila. Esta relación se aplica en general para
Capítulo 4. Análisis sintáctico
270
una gramática LALR. Los analizadores sintácticos LR y LALR se imitarán uno al otro en las
entradas correctas.
Si se le presenta una entrada errónea, el analizador sintáctico LALR tal vez proceda a realizar ciertas reducciones, una vez que el analizador sintáctico LR haya declarado un error. Sin
embargo, el analizador sintáctico LALR nunca desplazará otro símbolo después de que el analizador sintáctico LR declare un error. Por ejemplo, en la entrada ccd seguida de $, el analizador
sintáctico LR de la figura 4.42 meterá lo siguiente en la pila:
0334
y en el estado 4 descubrirá un error, ya que $ es el siguiente símbolo de entrada y el estado 4
tiene una acción de error en $. En contraste, el analizador sintáctico LALR de la figura 4.43
realizará los movimientos correspondientes, metiendo lo siguiente en la pila:
0 36 36 47
Pero el estado 47 en la entrada $ tiene la acción de reducir C → d. El analizador sintáctico
LALR cambiará, por lo tanto, su pila a:
0 36 36 89
Ahora, la acción del estado 89 en la entrada $ es reducir C → cC. La pila se convierte en lo
siguiente:
0 36 89
en donde se llama a una reducción similar, con lo cual se obtiene la pila:
02
Por último, el estado 2 tiene una acción de error en la entrada $, por lo que ahora se descubre
el error.
4.7.5
Construcción eficiente de tablas de análisis sintáctico LALR
Hay varias modificaciones que podemos realizar al Algoritmo 4.59 para evitar construir la colección completa de conjuntos de elementos LR(1), en el proceso de crear una tabla de análisis
sintáctico LALR(1).
• En primer lugar, podemos representar cualquier conjunto de elementos I LR(0) o LR(1)
mediante su corazón (kernel); es decir, mediante aquellos elementos que sean el elemento
inicial, [S → ·S ] o [S → ·S, $], o que tengan el punto en algún lugar que no sea al principio del cuerpo de la producción.
• Podemos construir los corazones de los elementos LALR(1) a partir de los corazones de
los elementos LR(0), mediante un proceso de propagación y generación espontánea de lecturas adelantadas, lo cual describiremos en breve.
• Si tenemos los corazones LALR(1), podemos generar la tabla de análisis sintáctico
LALR(1) cerrando cada corazón, usando la función CERRADURA de la figura 4.40, y después calculando las entradas en la tabla mediante el Algoritmo 4.56, como si los conjuntos
de elementos LALR(1) fueran conjuntos de elementos LR(1) canónicos.
4.7 Analizadores sintácticos LR más poderosos
271
Ejemplo 4.61: Vamos a usar, como un ejemplo del eficiente método de construcción de una
tabla LALR(1), la gramática que no es SLR del ejemplo 4.48, la cual reproducimos a continuación en su forma aumentada:
S
S
L
R
→ S
→ L=R | R
→ ∗R | id
→ L
En la figura 4.39 se mostraron los conjuntos completos de elementos LR(0) para esta gramática. Los corazones de estos elementos se muestran en la figura 4.44. 2
Figura 4.44: Corazones de los conjuntos de elementos LR(0) para la gramática (4.49)
Ahora debemos adjuntar los símbolos de anticipación apropiados para los elementos LR(0)
en los corazones, para crear los corazones de los conjuntos de elementos LALR(1). Hay dos
formas en que se puede adjuntar un símbolo de anticipación b a un elemento LR(0) B → γ·δ,
en cierto conjunto de elementos LALR(1) J:
1. Hay un conjunto de elementos I, con un elemento de corazón A → α·β, a, y J = ir_A
(I, X ), y la construcción de
ir_A(CERRADURA({[A → α·β, a]}), X )
como se proporciona en la figura 4.40, contiene [B → γ·δ, b], sin importar a. Se considera
que dicho símbolo de anticipación b se genera en forma espontánea para B → γ·δ.
2. Como caso especial, el símbolo de anticipación $ se genera en forma espontánea para el
elemento S → ·S en el conjunto inicial de elementos.
3. Todo es como en (1), pero a = b e ir_A(CERRADURA({[A → α·β, b]}), X ), como se proporciona en la figura 4.40, contiene [B → γ·δ, b] sólo porque A → α·β tiene a b como uno de sus
símbolos de anticipación asociados. En tal caso, decimos que los símbolos de anticipación
se propagan desde A → α·β en el corazón de I, hasta B → γ·δ en el corazón de J. Observe
que la propagación no depende del símbolo de anticipación específico; o todos los símbolos
de anticipación se propagan desde un elemento hasta otro, o ninguno lo hace.
Capítulo 4. Análisis sintáctico
272
Debemos determinar los símbolos de anticipación generados en forma espontánea para cada
conjunto de elementos LR(0), y también determinar cuáles elementos propagan los símbolos de
anticipación desde cuáles otros. En realidad, la prueba es bastante simple. Hagamos que # sea un
símbolo que no esté en la gramática en cuestión. Hagamos que A → α·β sea un elemento de corazón LR(0) en el conjunto I. Calcule, para cada X, J = ir_A(CERRADURA({[A → α·β, #]}), X ).
Para cada elemento de corazón en J, examinamos su conjunto de símbolos de anticipación. Si
# es un símbolo de anticipación, entonces los símbolos de anticipación se propagan hacia ese
elemento desde A → α·β. Cualquier otro símbolo de anticipación se genera de manera espontánea. Estas ideas se hacen precisas en el siguiente algoritmo, el cual también hace uso del hecho
de que los únicos elementos de corazón en J deben tener a X justo a la izquierda del punto; es
decir, deben ser de la forma B → γX·δ.
Algoritmo 4.62: Determinación de los símbolos de anticipación.
ENTRADA: El corazón K de un conjunto de elementos LR(0) I y un símbolo gramatical X.
SALIDA: Los símbolos de anticipación generados en forma espontánea por los elementos en
I, para los elementos de corazón en ir_A(I, X ) y los elementos en I, a partir de los cuales los
símbolos de anticipación se propagan hacia los elementos de corazón en ir_A(I, X ).
MÉTODO: El algoritmo se proporciona en la figura 4.45.
for
(
2
cada elemento A → α·β en K ) {
J := CERRADURA({[A → α·β, #]} );
if ( [B → γ·Xδ, a] está en J, y a no es # )
concluir que el símbolo de anticipación a se genera en forma espontánea
para el elemento B → γX·δ en ir_A(I, X );
if ( [B → γ·Xδ, #] está en J )
concluir que los símbolos de anticipación se propagan desde A → α·β en I
hacia B → γX·δ en ir_A(I, X );
}
Figura 4.45: Descubrimiento de símbolos de anticipación propagados y espontáneos
Ahora estamos listos para adjuntar los símbolos de anticipación a los corazones de los conjuntos de elementos LR(0) para formar los conjuntos de elementos LALR(1). En primer lugar,
sabemos que $ es un símbolo de anticipación para S → ·S en el conjunto inicial de elementos
LR(0). El Algoritmo 4.62 nos proporciona todos los símbolos de anticipación que se generan
en forma espontánea. Después de listar todos esos símbolos de anticipación, debemos permitir
que se propaguen hasta que ya no puedan hacerlo más. Hay muchos métodos distintos, todos
los cuales en cierto sentido llevan la cuenta de los “nuevos” símbolos de anticipación que se han
propagado en un elemento, pero que todavía no se propagan hacia fuera. El siguiente algoritmo
describe una técnica para propagar los símbolos de anticipación hacia todos los elementos.
Algoritmo 4.63: Cálculo eficiente de los corazones de la colección de conjuntos de elementos
LALR(1).
ENTRADA: Una gramática aumentada G .
273
4.7 Analizadores sintácticos LR más poderosos
SALIDA: Los corazones de la colección de conjuntos de elementos LALR(1) para G .
MÉTODO:
1. Construir los corazones de los conjuntos de elementos LR(0) para G. Si el espacio no es
de extrema importancia, la manera más simple es construir los conjuntos de elementos
LR(0), como en la sección 4.6.2, y después eliminar los elementos que no sean del corazón. Si el espacio está restringido en extremo, tal vez sea conveniente almacenar sólo los
elementos del corazón de cada conjunto, y calcular ir_A para un conjunto de elementos I,
para lo cual primero debemos calcular la cerradura de I.
2. Aplicar el Algoritmo 4.62 al corazón de cada conjunto de elementos LR(0) y el símbolo
gramatical X para determinar qué símbolos de anticipación se generan en forma espontánea para los elementos del corazón en ir_A(I, X ), y a partir los cuales se propagan los
elementos en los símbolos de anticipación I a los elementos del corazón en ir_A(I, X ).
3. Inicializar una tabla que proporcione, para cada elemento del corazón en cada conjunto
de elementos, los símbolos de anticipación asociados. Al principio, cada elemento tiene asociados sólo los símbolos de anticipación que determinamos en el paso (2) que se generaron
en forma espontánea.
4. Hacer pasadas repetidas sobre los elementos del corazón en todos los conjuntos. Al visitar un elemento i, buscamos los elementos del corazón para los cuales i propaga sus
símbolos de anticipación, usando la información que se tabuló en el paso (2). El conjunto
actual de símbolos de anticipación para i se agrega a los que ya están asociados con cada
uno de los elementos para los cuales i propaga sus símbolos de anticipación. Continuamos realizando pasadas sobre los elementos del corazón hasta que no se propaguen más
símbolos nuevos de anticipación.
2
Ejemplo 4.64: Vamos a construir los corazones de los elementos LALR(1) para la gramática del ejemplo 4.61. Los corazones de los elementos LR(0) se mostraron en la figura 4.44.
Al aplicar el Algoritmo 4.62 al corazón del conjunto de elementos I0, primero calculamos
CERRADURA({[S → ·S, #]}), que viene siendo:
S → ·S, #
S → ·L = R, #
S → ·R, #
L → ·∗R, #/=
L → ·id, #/=
R → ·L, #
Entre los elementos en la cerradura, vemos dos en donde se ha generado el símbolo de anticipación = en forma espontánea. El primero de éstos es L → · ∗ R. Este elemento, con ∗ a la
derecha del punto, produce [L → ∗·R, =]. Es decir, = es un símbolo de anticipación generado
en forma espontánea para L → ∗·R, que se encuentra en el conjunto de elementos I4. De manera similar, [L → ·id, =] nos indica que = es un símbolo de anticipación generado en forma
espontánea para L → id· en I5.
Como # es un símbolo de anticipación para los seis elementos en la cerradura, determinamos que el elemento S → S en I0 propaga los símbolos de anticipación hacia los siguientes seis
elementos:
Capítulo 4. Análisis sintáctico
274
DESDE
HACIA
Figura 4.46: Propagación de los símbolos de anticipación
En la figura 4.47, mostramos los pasos (3) y (4) del Algoritmo 4.63. La columna etiquetada
como INIC muestra los símbolos de anticipación generados en forma espontánea para cada elemento del corazón. Éstas son sólo las dos ocurrencias de = que vimos antes, y el símbolo de anticipación $ espontáneo para el elemento inicial S → ·S.
En la primera pasada, el símbolo $ de anticipación se propaga de S → S en I0 hacia los seis
elementos listados en la figura 4.46. El símbolo = de anticipación se propaga desde L → ∗·R
en I4 hacia los elementos L → * R· en I7 y R → L· en I8. También se propaga hacia sí mismo
y hacia L → id· en I5, pero estos símbolos de anticipación ya están presentes. En la segunda y
tercera pasada, el único símbolo nuevo de anticipación que se propaga es $, descubierto para
los sucesores de I2 e I4 en la pasada 2 y para el sucesor de I6 en la pasada 3. En la pasada 4
no se propagan nuevos símbolos de anticipación, por lo que el conjunto final de símbolos de
anticipación se muestra en la columna por la derecha de la figura 4.47.
Observe que el conflicto de desplazamiento/reducción en el ejemplo 4.48 que utiliza el método SLR ha desaparecido con la técnica LALR. La razón es que sólo el símbolo $ de anticipación
está asociado con R → L· en I2, por lo que no hay conflicto con la acción de análisis sintáctico
2
de desplazamiento en =, generada por el elemento S → L·=R en I2.
275
4.7 Analizadores sintácticos LR más poderosos
CONJUNTO
ELEMENTO
INIC
SÍMBOLOS DE ANTICIPACIÓN
PASADA 1 PASADA 2 PASADA 3
Figura 4.47: Cálculo de los símbolos de anticipación
4.7.6
Compactación de las tablas de análisis sintáctico LR
Una gramática de lenguaje de programación común, con una cantidad de 50 a 100 terminales
y 100 producciones, puede tener una tabla de análisis sintáctico LALR con varios cientos de
estados. La función de acción podría fácilmente tener 20 000 entradas, cada una requiriendo
por lo menos 8 bits para codificarla. En los dispositivos pequeños, puede ser importante tener
una codificación más eficiente que un arreglo bidimensional. En breve mencionaremos algunas
técnicas que se han utilizado para comprimir los campos ACCION e ir_A de una tabla de análisis sintáctico LR.
Una técnica útil para compactar el campo de acción es reconocer que, por lo general, muchas filas de la tabla de acciones son idénticas. Por ejemplo, en la figura 4.42 los estados 0 y
3 tienen entradas de acción idénticas, al igual que los estados 2 y 6. Por lo tanto, podemos
ahorrar una cantidad de espacio considerable, con muy poco costo en relación con el tiempo,
si creamos un apuntador para cada estado en un arreglo unidimensional. Los apuntadores para
los estados con las mismas acciones apuntan a la misma ubicación. Para acceder a la información desde este arreglo, asignamos a cada terminal un número desde cero hasta uno menos que
el número de terminales, y utilizamos este entero como un desplazamiento a partir del valor del
apuntador para cada estado. En un estado dado, la acción de análisis sintáctico para la i-ésima
terminal se encontrará a i ubicaciones más allá del valor del apuntador para ese estado.
Puede lograrse una mejor eficiencia en cuanto al espacio, a expensas de un analizador sintáctico un poco más lento, mediante la creación de una lista para las acciones de cada estado. Esta
lista consiste en pares (terminal-símbolo, acción). La acción más frecuente para un estado puede
Capítulo 4. Análisis sintáctico
276
colocarse al final de una lista, y en lugar de un terminal podemos usar la notación “cualquiera”,
indicando que si no se ha encontrado el símbolo de entrada actual hasta ese punto en la lista, debemos realizar esa acción sin importar lo que sea la entrada. Además, las entradas de error pueden
sustituirse sin problemas por acciones de reducción, para una mayor uniformidad a lo largo de una
fila. Los errores se detectarán más adelante, antes de un movimiento de desplazamiento.
Ejemplo 4.65: Considere la tabla de análisis sintáctico de la figura 4.37. En primer lugar,
observe que las acciones para los estados 0, 4, 6 y 7 coinciden. Podemos representarlas todas
mediante la siguiente lista:
SÍMBOLO
id
(
cualquiera
ACCIÓN
s5
s4
error
El estado 1 tiene una lista similar:
+
$
cualquiera
s6
acc
error
En el estado 2, podemos sustituir las entradas de error por r2, para que se realice la reducción
mediante la producción 2 en cualquier entrada excepto *. Por ende, la lista para el estado 2 es:
∗
cualquiera
s7
r2
El estado 3 tiene sólo entradas de error y r4. Podemos sustituir la primera por la segunda,
de manera que la lista para el estado 3 consista sólo en el par (cualquiera, r4). Los estados
5, 10 y 11 pueden tratarse en forma similar. La lista para el estado 8 es:
+
)
cualquiera
s6
s11
error
∗
)
cualquiera
s7
s11
r1
y para el estado 9 es:
2
También podemos codificar la tabla ir_A mediante una lista, pero aquí es más eficiente
crear una lista de pares para cada no terminal A. Cada par en la lista para A es de la forma
(estadoActual, siguienteEstado), lo cual indica que:
ir_A[estadoActual, A] = siguienteEstado
277
4.7 Analizadores sintácticos LR más poderosos
Esta técnica es útil, ya que tiende a haber menos estados en cualquier columna de la tabla
ir_A. La razón es que el ir_A en el no terminal A sólo puede ser un estado que pueda derivarse a partir de un conjunto de elementos en los que algunos elementos tengan a A justo a la
izquierda de un punto. Ningún conjunto tiene elementos con X y Y justo a la izquierda de un
punto si X ≠ Y. Por ende, cada estado aparece como máximo en una columna ir_A.
Para una mayor reducción del espacio, hay que observar que las entradas de error en la
tabla de ir_A nunca se consultan. Por lo tanto, podemos sustituir cada entrada de error por
la entrada más común sin error en su columna. Esta entrada se convierte en la opción predeterminada; se representa en la lista para cada columna mediante un par con cualquiera en
vez de estadoActual.
Ejemplo 4.66: Considere de nuevo la figura 4.37. La columna para F tiene la entrada 10 para
el estado 7, y todas las demás entradas son 3 o error. Podemos sustituir error por 3 y crear,
para la columna F, la siguiente lista:
ESTADOACTUAL
7
cualquiera
SIGUIENTEESTADO
10
3
De manera similar, una lista adecuada para la columna T es:
6
cualquiera
9
2
Para la columna E podemos elegir 1 o 8 como la opción predeterminada; son necesarias dos entradas en cualquier caso. Por ejemplo, podríamos crear para la columna E la siguiente lista:
4
cualquiera
8
1
2
El ahorro de espacio en estos pequeños ejemplos puede ser engañoso, ya que el número total
de entradas en las listas creadas en este ejemplo y el anterior, junto con los apuntadores desde
los estados hacia las listas de acción, y desde las no terminales hacia las listas de los siguientes
estados, producen un ahorro de espacio mínimo, en comparación con la implementación de una
matriz de la figura 4.37. En las gramáticas prácticas, el espacio necesario para la representa
ción de la lista es, por lo general, menos del diez por ciento de lo necesario para la representación de la matriz. Los métodos de compresión de tablas para los autómatas finitos que vimos en
la sección 3.9.8 pueden usarse también para representar las tablas de análisis sintáctico LR.
4.7.7
Ejercicios para la sección 4.7
Ejercicio 4.7.1: Construya los conjuntos de elementos
a) LR canónicos, y
b) LALR.
para la gramática S → S S + | S S ∗ | a del ejercicio 4.2.1.
Capítulo 4. Análisis sintáctico
278
Ejercicio 4.7.2: Repita el ejercicio 4.7.1 para cada una de las gramáticas (aumentadas) del
ejercicio 4.2.2(a)-(g).
! Ejercicio 4.7.3: Para la gramática del ejercicio 4.7.1, use el Algoritmo 4.63 para calcular la
colección de conjuntos de elementos LALR, a partir de los corazones de los conjuntos de elementos LR(0).
! Ejercicio 4.7.4: Muestre que la siguiente gramática:
S
A
→ Aa | bAc | dc | bda
→ d
es LALR(1), pero no SLR(1).
! Ejercicio 4.7.5: Muestre que la siguiente gramática:
S
A
B
→ Aa | bAc | Bc | dBa
→ d
→ d
es LR(1), pero no LALR(1).
4.8
Uso de gramáticas ambiguas
Es un hecho que ninguna gramática ambigua es LR y, por ende, no se encuentra en ninguna
de las clases de gramáticas que hemos visto en las dos secciones anteriores. No obstante, ciertos tipos de gramáticas ambiguas son bastante útiles en la especificación e implementación de
lenguajes. Para las construcciones de lenguajes como las expresiones, una gramática ambigua
proporciona una especificación más corta y natural que cualquier gramática no ambigua equivalente. Otro uso de las gramáticas ambiguas es el de aislar las construcciones sintácticas que
ocurren con frecuencia para la optimización de casos especiales. Con una gramática ambigua,
podemos especificar las construcciones de casos especiales, agregando con cuidado nuevas producciones a la gramática.
Aunque las gramáticas que usamos no son ambiguas, en todos los casos especificamos reglas para eliminar la ambigüedad, las cuales sólo permiten un árbol de análisis sintáctico para
cada enunciado. De esta forma, se eliminan las ambigüedades de la especificación general del
lenguaje, y algunas veces es posible diseñar un analizador sintáctico LR que siga las mismas
opciones para resolver las ambigüedades. Debemos enfatizar que las construcciones ambiguas deben utilizarse con medida y en un forma estrictamente controlada; de no ser así, no puede haber
garantía en el lenguaje que reconozca un analizador sintáctico.
279
4.8 Uso de gramáticas ambiguas
4.8.1
Precedencia y asociatividad para resolver conflictos
Considere la gramática ambigua (4.3) para las expresiones con los operadores + y ∗, que repetimos a continuación por conveniencia:
E → E + E | E ∗ E | (E ) | id
Esta gramática es ambigua, ya que no especifica la asociatividad ni la precedencia de los operadores + y ∗. La gramática sin ambigüedad (4.1), que incluye las producciones E → E + T
y T → T ∗ F, genera el mismo lenguaje, pero otorga a + una precedencia menor que la de ∗, y
hace que ambos operadores sean asociativos por la izquierda. Hay dos razones por las cuales
podría ser más conveniente preferir el uso de la gramática ambigua. En primer lugar, como
veremos más adelante, podemos cambiar con facilidad la asociatividad y la precedencia de los
operadores + y ∗ sin perturbar las producciones de (4.3) o el número de estados en el analizador sintáctico resultante. En segundo lugar, el analizador sintáctico para la gramática sin
ambigüedad invertirá una fracción considerable de su tiempo realizando reducciones mediante
las producciones E → T y T → F, cuya única función es hacer valer la asociatividad y la
precedencia. El analizador sintáctico para la gramática sin ambigüedad (4.3) no desperdiciará
tiempo realizando reducciones mediante estas producciones simples (producciones cuyo cuerpo
consiste en un solo no terminal).
Los conjuntos de elementos LR(0) para la gramática de expresiones sin ambigüedad (4.3)
aumentada por E → E se muestran en la figura 4.48. Como la gramática (4.3) es ambigua,
habrá conflictos de acciones de análisis sintáctico cuando tratemos de producir una tabla de
análisis sintáctico LR a partir de los conjuntos de elementos. Los estados que corresponden a
los conjuntos de elementos I7 e I8 generan estos conflictos. Suponga que utilizamos el método
SLR para construir la tabla de acciones de análisis sintáctico. El conflicto generado por I7 entre la
reducción mediante E → E + E y el desplazamiento en + o ∗ no puede resolverse, ya que + y ∗
se encuentran en SIGUIENTE(E ). Por lo tanto, se llamaría a ambas acciones en las entradas +
y ∗. I8 genera un conflicto similar, entre la reducción mediante E → E ∗ E y el desplazamiento
en las entradas + y ∗. De hecho, cada uno de nuestros métodos de construcción de tablas de
análisis sintáctico LR generarán estos conflictos.
No obstante, estos problemas pueden resolverse mediante el uso de la información sobre la
precedencia y la asociatividad para + y ∗. Considere la entrada id + id ∗ id, la cual hace que
un analizador sintáctico basado en la figura 4.48 entre al estado 7 después de procesar id + id;
de manera específica, el analizador sintáctico llega a la siguiente configuración:
PREFIJO
E+E
PILA
0147
ENTRADA
∗ id $
Por conveniencia, los símbolos que corresponden a los estados 1, 4 y 7 también se muestran
bajo PREFIJO.
Si ∗ tiene precedencia sobre +, sabemos que el analizador sintáctico debería desplazar a ∗
hacia la pila, preparándose para reducir el ∗ y sus símbolos id circundantes a una expresión. El
analizador sintáctico SLR de la figura 4.37 realizó esta elección, con base en una gramática sin
ambigüedad para el mismo lenguaje. Por otra parte, si + tiene precedencia sobre ∗, sabemos
que el analizador sintáctico debería reducir E + E a E. Por lo tanto, la precedencia relativa
280
Capítulo 4. Análisis sintáctico
Figura 4.48: Conjuntos de elementos LR(0) para una gramática de expresiones aumentada
de + seguido de ∗ determina en forma única la manera en que debería resolverse el conflicto de
acciones de análisis sintáctico entre la reducción E → E + E y el desplazamiento sobre ∗ en
el estado 7.
Si la entrada hubiera sido id + id + id, el analizador sintáctico llegaría de todas formas a
una configuración en la cual tendría la pila 0 1 4 7 después de procesar la entrada id + id. En
la entrada + hay de nuevo un conflicto de desplazamiento/reducción en el estado 7. Sin embargo, ahora la asociatividad del operador + determina cómo debe resolverse este conflicto. Si
+ es asociativo a la izquierda, la acción correcta es reducir mediante E → E + E. Es decir, los
símbolos id que rodean el primer + deben agruparse primero. De nuevo, esta elección coincide
con lo que haría el analizador sintáctico SLR para la gramática sin ambigüedad.
En resumen, si asumimos que + es asociativo por la izquierda, la acción del estado 7 en
la entrada + debería ser reducir mediante E → E + E, y suponiendo que ∗ tiene precedencia
sobre +, la acción del estado 7 en la entrada ∗ sería desplazar. De manera similar, suponiendo
que ∗ sea asociativo por la izquierda y tenga precedencia sobre +, podemos argumentar que
el estado 8, que puede aparecer en la parte superior de la pila sólo cuando E ∗ E son los tres
símbolos gramaticales de la parte superior, debería tener la acción de reducir E → E ∗ E en
las entradas + y ∗. En el caso de la entrada +, la razón es que ∗ tiene precedencia sobre +,
mientras que en el caso de la entrada ∗, el fundamento es que ∗ es asociativo por la izquierda.
281
4.8 Uso de gramáticas ambiguas
Si procedemos de esta forma, obtendremos la tabla de análisis sintáctico LR que se muestra
en la figura 4.49. Las producciones de la 1 a la 4 son E → E + E, E → E ∗ E, → (E ) y E →
id, respectivamente. Es interesante que una tabla de acciones de análisis sintáctico similar se
produzca eliminando las reducciones mediante las producciones simples E → T y T → F a partir de la tabla SLR para la gramática de expresiones sin ambigüedad (4.1) que se muestra en la
figura 4.37. Las gramáticas ambiguas como la que se usa para las expresiones pueden manejarse
en una forma similar, en el contexto de los análisis sintácticos LALR y LR canónico.
ACCIÓN
ESTADO
ir_A
Figura 4.49: Tabla de análisis sintáctico para la gramática (4.3)
4.8.2
La ambigüedad del “else colgante”
Considere de nuevo la siguiente gramática para las instrucciones condicionales:
instr
→
|
|
if expr then instr else instr
if expr then instr
otras
Como vimos en la sección 4.3.2, esta gramática no tiene ambigüedades, ya que no resuelve la
ambigüedad del else colgante. Para simplificar la discusión, vamos a considerar una abstracción
de esta gramática, en donde i representa a if expr then, e representa a else, y a representa a
“todas las demás producciones”. De esta forma podemos escribir la gramática, con la producción aumentada S → S, como
S
S
→ S
→ iSeS | iS | a
(4.67)
Los conjuntos de elementos LR(0) para la gramática (4.67) se muestran en la figura 4.50. La
ambigüedad en (4.67) produce un conflicto de desplazamiento/reducción en I4. Ahí, S → iS·eS
llama a un desplazamiento de e y, como SIGUIENTE(S ) = {e, $}, el elemento S → iS· llama a
la reducción mediante S → iS en la entrada e.
Capítulo 4. Análisis sintáctico
282
Figura 4.50: Estados LR(0) para la gramática aumentada (4.67)
Traduciendo esto de vuelta a la terminología if-then-else, si tenemos a:
if expr then instr
en la pila y a else como el primer símbolo de entrada, ¿debemos desplazar el else hacia la pila
(es decir, desplazar a e) o reducir if expr then instr (es decir, reducir mediante S → iS )? La
respuesta es que debemos desplazar el else, ya que está “asociado” con el then anterior. En
la terminología de la gramática (4.67), la e en la entrada, que representa a else, sólo puede
formar parte del cuerpo que empieza con la iS que está ahora en la parte superior de la pila.
Si lo que sigue después de e en la entrada no puede analizarse como una S, para completar el
cuerpo iSeS, entonces podemos demostrar que no hay otro análisis sintáctico posible.
Concluimos que el conflicto de desplazamiento/reducción en I4 debe resolverse a favor del
desplazamiento en la entrada e. La tabla de análisis sintáctico SLR que se construyó a partir de
los conjuntos de elementos de la figura 4.48, que utiliza esta resolución del conflicto de acciones
de análisis sintáctico en I4 con la entrada e, se muestra en la figura 4.51. Las producciones de
la 1 a la 3 son S → iSeS, S → iS y S → a, respectivamente.
ESTADO
ACCIÓN
ir_A
Figura 4.51: Tabla de análisis sintáctico LR para la gramática del “else colgante”
283
4.8 Uso de gramáticas ambiguas
Por ejemplo, en la entrada iiaea, el analizador sintáctico realiza los movimientos que se muestran en la figura 4.52, correspondientes a la resolución correcta del “else colgante”. En la línea
(5), el estado 4 selecciona la acción de desplazamiento en la entrada e, mientras que en la línea (9),
el estado 4 llama a la reducción mediante S → iS en la entrada $.
PILA
SÍMBOLOS
ENTRADA
ACCIÓN
desplazar
desplazar
desplazar
desplazar
reducir
desplazar
reducir
reducir
reducir
aceptar
Figura 4.52: Acciones de análisis sintáctico con la entrada iiaea
Con el fin de comparar, si no podemos usar una gramática ambigua para especificar instrucciones condicionales, entonces tendríamos que usar una gramática más robusta a lo largo de las
líneas del ejemplo 4.16.
4.8.3
Recuperación de errores en el análisis sintáctico LR
Un analizador sintáctico LR detectará un error al consultar la tabla de acciones de análisis
sintáctico y encontrar una entrada de error. Los errores nunca se detectan al consultar la tabla de ir_A. Un analizador sintáctico LR anunciará un error tan pronto como no haya una
continuación válida para la porción de la entrada que se ha explorado hasta ese momento. Un
analizador sintáctico LR canónico no realizara ni siquiera una sola reducción antes de anunciar
un error. Los analizadores sintácticos SLR y LALR pueden realizar varias reducciones antes de
anunciar un error, pero nunca desplazarán un símbolo de entrada erróneo hacia la pila.
En el análisis sintáctico LR, podemos implementar la recuperación de errores en modo de
pánico de la siguiente manera. Exploramos la pila en forma descendente hasta encontrar un
estado s con un ir_A en un no terminal A específico. Después, se descartan cero o más símbolos
de entrada hasta encontrar un símbolo a que pueda seguir a A de manera legítima. A continuación, el analizador sintáctico mete el estado ir_A(s, A) en la pila y continúa con el análisis
sintáctico normal. Podría haber más de una opción para el no terminal A. Por lo general, éstos
serían no terminales que representen las piezas principales del programa, como una expresión,
una instrucción o un bloque. Por ejemplo, si A es el no terminal instr, a podría ser un punto y
coma o }, lo cual marca el final de una secuencia de instrucciones.
Este método de recuperación de errores trata de eliminar la frase que contiene el error sintáctico. El analizador sintáctico determina que una cadena que puede derivarse de A contiene
un error. Parte de esa cadena ya se ha procesado, y el resultado de este procesamiento es una
Capítulo 4. Análisis sintáctico
284
secuencia de estados en la parte superior de la pila. El resto de la cadena sigue en la entrada,
y el analizador sintáctico trata de omitir el resto de esta cadena buscando un símbolo en la
entrada que pueda seguir de manera legítima a A. Al eliminar estados de la pila, el analizador
sintáctico simula que ha encontrado una instancia de A y continúa con el análisis sintáctico
normal.
Para implementar la recuperación a nivel de frase, examinamos cada entrada de error en la
tabla de análisis sintáctico LR y decidimos, en base al uso del lenguaje, el error más probable
del programador que pudiera ocasionar ese error. Después podemos construir un procedimiento de
recuperación de errores apropiado; se supone que la parte superior de la pila y los primeros símbolos
de entrada se modificarían de una forma que se considera como apropiada para cada entrada de
error.
Al diseñar rutinas de manejo de errores específicas para un analizador sintáctico LR, podemos rellenar cada entrada en blanco en el campo de acción con un apuntador a una rutina de
error que tome la acción apropiada, seleccionada por el diseñador del compilador. Las acciones
pueden incluir la inserción o eliminación de símbolos de la pila o de la entrada (o de ambas), o
la alteración y transposición de los símbolos de entrada. Debemos realizar nuestras elecciones
de tal forma que el analizador sintáctico LR no entre en un ciclo infinito. Una estrategia segura
asegurará que por lo menos se elimine o se desplace un símbolo de entrada en un momento
dado, o que la pila se reduzca si hemos llegado al final de la entrada. Debemos evitar sacar
un estado de la pila que cubra un no terminal, ya que esta modificación elimina de la pila una
construcción que ya se haya analizado con éxito.
Ejemplo 4.68: Considere de nuevo la siguiente gramática de expresiones:
E → E + E | E ∗ E | (E ) | id
La figura 4.53 muestra la tabla de análisis sintáctico LR de la figura 4.49 para esta gramática, modificada para la detección y recuperación de errores. Hemos modificado cada estado
que llama a una reducción específica en ciertos símbolos de entrada, mediante la sustitución de
las entradas de error en ese estado por la reducción. Este cambio tiene el efecto de posponer la
detección de errores hasta que se realicen una o más reducciones, pero el error seguirá atrapándose antes de que se realice cualquier desplazamiento. Las entradas restantes en blanco de la
figura 4.49 se han sustituido por llamadas a las rutinas de error.
Las rutinas de error son las siguientes:
e1: Esta rutina se llama desde los estados 0, 2, 4 y 5, y todos ellos esperan el principio de un
operando, ya sea un id o un paréntesis izquierdo. En vez de ello, se encontró +, ∗ o el
final de la entrada.
meter el estado 3 (el ir_A de los estados 0, 2, 4 y 5 en id);
emitir el diagnóstico “falta operando”.
e2: Se llama desde los estados 0, 1, 2, 4 y 5 al encontrar un paréntesis derecho.
eliminar el paréntesis derecho de la entrada;
emitir el diagnóstico “paréntesis derecho desbalanceado”.
285
4.8 Uso de gramáticas ambiguas
ESTADO
ACCIÓN
ir_A
Figura 4.53: Tabla de análisis sintáctico LR con rutinas de error
e3: Se llama desde los estados 1 o 6 cuando se espera un operador y se encuentra un id o paréntesis derecho.
meter el estado 4 (correspondiente al símbolo +) en la pila;
emitir el diagnóstico “falta un operador”.
e4: Se llama desde el estado 6 cuando se encuentra el final de la entrada.
meter el estado 9 (para un paréntesis derecho) a la pila;
emitir el diagnóstico “falta paréntesis derecho”.
En la entrada errónea id + ), la secuencia de configuraciones que introduce el analizador
sintáctico se muestra en la figura 4.54. 2
4.8.4
Ejercicios para la sección 4.8
! Ejercicio 4.8.1: La siguiente es una gramática ambigua para las expresiones con n operadores
binarios infijo, con n niveles distintos de precedencia:
E → E θ1 E | E θ2 E | … E θn E | ( E ) | id
a) Como una función de n, ¿cuáles son los conjuntos de elementos SLR?
b) ¿Cómo resolvería los conflictos en los elementos SLR, de manera que todos los operadores sean asociativos a la izquierda, y que θ1 tenga precedencia sobre θ2, que tiene precedencia sobre θ3, y así sucesivamente?
c) Muestre la tabla de análisis sintáctico SLR que resulta de sus decisiones en la parte (b).
Capítulo 4. Análisis sintáctico
286
PILA
SÍMBOLOS
ENTRADA ACCIÓN
“paréntesis derecho
desbalanceado”
e2 elimina el paréntesis derecho
“falta un operando”
e1 mete el estado 3
en la pila
Figura 4.54: Movimientos de análisis sintáctico y recuperación de errores realizados por un
analizador sintáctico LR
d) Repita las partes (a) y (c) para la gramática sin ambigüedad, la cual define el mismo
conjunto de expresiones, como se muestra en la figura 4.55.
e) ¿Cómo se comparan los conteos del número de conjuntos de elementos y los tamaños
de las tablas para las dos gramáticas (ambigua y sin ambigüedad)? ¿Qué nos dice esa
comparación acerca del uso de las gramáticas de expresiones ambiguas?
Figura 4.55: Gramática sin ambigüedad para n operadores
! Ejercicio 4.8.2: En la figura 4.56 hay una gramática para ciertas instrucciones, similar a la
que vimos en el ejercicio 4.4.12. De nuevo, e y s son terminales que representan expresiones
condicionales y “otras instrucciones”, respectivamente.
a) Construya una tabla de análisis sintáctico LR para esta gramática, resolviendo los conflictos de la manera usual para el problema del else colgante.
b) Implemente la corrección de errores, llenando las entradas en blanco en la tabla de análisis sintáctico con acciones de reducción adicionales, o rutinas de recuperación de errores
adecuadas.
c) Muestre el comportamiento de su analizador sintáctico con las siguientes entradas:
(i)
(ii)
if e then s ; if e then s end
while e do begin s ; if e then s ; end
287
4.9 Generadores de analizadores sintácticos
instr
lista
→
|
|
|
|
→
|
if e then instr
if e then instr else instr
while e do instr
begin lista end
s
lista ; instr
instr
Figura 4.56: Una gramática para ciertos tipos de instrucciones
4.9
Generadores de analizadores sintácticos
En esta sección veremos cómo puede usarse un generador de analizadores sintácticos para
facilitar la construcción del front-end de usuario de un compilador. Utilizaremos el generador
de analizadores sintácticos LALR de nombre Yacc como la base de nuestra explicación, ya que
implementa muchos de los conceptos que vimos en las dos secciones anteriores, y se emplea
mucho. Yacc significa “yet another compiler-compiler” (otro compilador-de compiladores más),
lo cual refleja la popularidad de los generadores de analizadores sintácticos a principios de la
década de 1970, cuando S. C. Johnson creó la primera versión de Yacc. Este generador está disponible en forma de comando en el sistema en UNIX, y se ha utilizado para ayudar a implementar
muchos compiladores de producción.
4.9.1
El generador de analizadores sintácticos Yacc
Puede construirse un traductor mediante el uso de Yacc de la forma que se ilustra en la figura
4.57. En primer lugar se prepara un archivo, por decir traducir.y, el cual contiene una especificación de Yacc del traductor. El siguiente comando del sistema UNIX:
yacc traducir.y
transforma el archivo traducir.y en un programa en C llamado y.tab.c, usando el método
LALR descrito en el algoritmo 4.63. El programa y.tab.c es una representación de un analizador sintáctico LALR escrito en C, junto con otras rutinas en C que el usuario puede haber
preparado. La tabla de análisis sintáctico LR se compacta según lo descrito en la sección 4.7.
Al compilar y.tab.c junto con la biblioteca ly que contiene el programa de análisis sintáctico
LR mediante el uso del comando:
cc y.tab.c −ly
obtenemos el programa objeto a.out deseado, el cual realiza la traducción especificada por el
programa original en Yacc.7 Si se necesitan otros procedimientos, pueden compilarse o cargarse
con y.tab.c, de igual forma que con cualquier programa en C.
Un programa fuente en Yacc tiene tres partes:
7
El nombre ly es dependiente del sistema.
Capítulo 4. Análisis sintáctico
288
Especificación
de Yacc
traducir.y
Compilador
de Yacc
Compilador
de C
entrada
salida
Figura 4.57: Creación de un traductor de entrada/salida con Yacc
declaraciones
%%
reglas de traducción
%%
soporte de las rutinas en C
Ejemplo 4.69: Para ilustrar cómo preparar un programa fuente en Yacc, vamos a construir
una calculadora de escritorio simple que lee una expresión aritmética, la evalúa e imprime su
valor numérico. Vamos a construir la calculadora de escritorio empezando con la siguiente gramática para las expresiones aritméticas:
E → E+T | T
T → T∗F | F
R → ( E ) | digit
El token digito es un solo dígito entre 0 y 9. En la figura 4.58 se muestra un programa de
calculadora de escritorio en Yacc, derivado a partir de esta gramática.
2
La parte de las declaraciones
Hay dos secciones en la parte de las declaraciones de un programa en Yacc; ambas son opcionales. En la primera sección, colocamos las declaraciones ordinarias en C, delimitadas mediante
%{ y %}. Aquí colocamos las declaraciones de cualquier valor temporal usado por las reglas
de traducción o los procedimientos de las secciones segunda y tercera. En la figura 4.58, esta
sección contiene sólo la siguiente instrucción de inclusión:
#include <ctype.h>
la cual ocasiona que el preprocesador de C incluya el archivo de encabezado estándar <ctype.h>,
el cual contiene el predicado isdigit.
Además, en la parte de las declaraciones se encuentran las declaraciones de los tokens de
gramática. En la figura 4.58, la instrucción
%token DIGITO
289
4.9 Generadores de analizadores sintácticos
%{
#include <ctype.h>
%}
%token DIGITO
%%
linea
expr
term
factor
:
;
:
|
;
:
|
;
:
|
;
expr ’\n’
{ printf("%d\n", $1); }
expr ’+’ term
term
{ $$ = $1 + $3; }
term ’*’ factor
factor
{ $$ = $1 * $3; }
’(’ expr ’)’
DIGITO
{ $$ = $2; }
%%
yylex() {
int c;
c = getchar();
if (isdigit(c)) {
yylval = c−’0’;
return DIGITO;
}
return c;
}
Figura 4.58: Especificación de Yacc de una calculadora de escritorio simple
declara a DIGITO como un token. Los tokens declarados en esta sección pueden usarse en las
partes segunda y tercera de la especificación de Yacc. Si se utiliza Lex para crear el analizador
léxico que pasa el token al analizador sintáctico Yacc, entonces estas declaraciones de tokens
también se vuelven disponibles para el analizador generado por Lex, como vimos en la sección
3.5.2.
La parte de las reglas de traducción
En la parte de la especificación de Yacc después del primer par de %%, colocamos las reglas de
traducción. Cada regla consiste en una producción gramatical y la acción semántica asociada.
Un conjunto de producciones que hemos estado escribiendo como:
encabezado
→
cuerpo1 | cuerpo2 | … | cuerpon
podría escribirse en Yacc de la siguiente manera:
Capítulo 4. Análisis sintáctico
290
encabezado
:
|
|
;
cuerpo1
cuerpo2
…
cuerpon
{ acción semántica1 }
{ acción semántica2 }
{ acción semántican }
En una producción de Yacc, las cadenas sin comillas de letras y dígitos que no se declaren
como tokens se consideran como no terminales. Un solo carácter entre comillas, por ejemplo ’c’,
se considera como el símbolo terminal c, así como el código entero para el token representado
por ese carácter (es decir, Lex devolvería el código de carácter para ’c’ al analizador sintáctico,
como un entero). Los cuerpos alternativos pueden separarse mediante una barra vertical; además se coloca un punto y coma después de cada encabezado con sus alternativas y sus acciones
semánticas. El primer encabezado se considera como el símbolo inicial.
Una acción semántica de Yacc es una secuencia de instrucciones en C. En una acción semántica, el símbolo $$ se refiere al valor del atributo asociado con el no terminal del encabezado, mientras que $i se refiere al valor asociado con el i-ésimo símbolo gramatical (terminal
o no terminal) del cuerpo. La acción semántica se realiza cada vez que reducimos mediante
la producción asociada, por lo que normalmente la acción semántica calcula un valor para $$
en términos de los $i’s. En la especificación de Yacc, hemos escrito las dos producciones E
siguientes:
E→E+T | T
y sus acciones semánticas asociadas como:
expr : expr ’+’ term
| term
;
{ $$ = $1 + $3; }
Observe que el no terminal term en la primera producción es el tercer símbolo gramatical del
cuerpo, mientras que + es el segundo. La acción semántica asociada con la primera producción
agrega el valor de la expr y la term del cuerpo, y asigna el resultado como el valor para el no
terminal expr del encabezado. Hemos omitido del todo la acción semántica para la segunda
producción, ya que copiar el valor es la acción predeterminada para las producciones con un
solo símbolo gramatical en el cuerpo. En general, { $$ = $1; } es la acción semántica predeterminada.
Observe que hemos agregado una nueva producción inicial:
linea : expr ’\n’
{ printf("%d\n", $1); }
a la especificación de Yacc. Esta producción indica que una entrada para la calculadora de
escritorio debe ser una expresión seguida de un carácter de nueva línea. La acción semántica
asociada con esta producción imprime el valor decimal de la expresión que va seguida de un
carácter de nueva línea.
291
4.9 Generadores de analizadores sintácticos
La parte de las rutinas de soporte en C
La tercera parte de una especificación de Yacc consiste en las rutinas de soporte en C. Debe
proporcionarse un analizador léxico mediante el nombre yylex(). La elección común es usar
Lex para producir yylex(); vea la sección 4.9.3. Pueden agregarse otros procedimientos como
las rutinas de recuperación de errores, según sea necesario.
El analizador léxico yylex() produce tokens que consisten en un nombre de token y su
valor de atributo asociado. Si se devuelve el nombre de un token como DIGITO, el nombre del
token debe declararse en la primera sección de la especificación de Yacc. El valor del atributo
asociado con un token se comunica al analizador sintáctico, a través de una variable yylval
definida por Yacc.
El analizador léxico en la figura 4.58 es bastante burdo. Lee un carácter de entrada a la vez,
usando la función de C getchar(). Si el carácter es un dígito, el valor del dígito se almacena
en la variable yylval y se devuelve el nombre de token DIGITO. En cualquier otro caso, se
devuelve el mismo carácter como el nombre de token.
4.9.2
Uso de Yacc con gramáticas ambiguas
Ahora vamos a modificar la especificación de Yacc, de tal forma que la calculadora de escritorio resultante sea más útil. En primer lugar, vamos a permitir que la calculadora de escritorio
evalúe una secuencia de expresiones, de una a una línea. También vamos a permitir líneas en
blanco entre las expresiones. Para ello, cambiaremos la primer regla a:
lineas : lineas expr ’\n’
| lineas ’\n’
| /* vacia */
;
{ printf("%g\n", $2); }
En Yacc, una alternativa vacía, como lo es la tercera línea, denota a .
En segundo lugar, debemos agrandar la clase de expresiones para incluir números en vez
de dígitos individuales, y para incluir los operadores aritméticos +, −, (tanto binarios como
unarios), ∗ y /. La manera más sencilla de especificar esta clase de expresiones es utilizar la
siguiente gramática ambigua:
E → E + E | E − E | E ∗ E | E / E | − E | numero
La especificación resultante de Yacc se muestra en la figura 4.59.
Como la gramática en la especificación de Yacc en la figura 4.59 es ambigua, el algoritmo
LALR generará conflictos de acciones de análisis sintáctico. Yacc reporta el número de conflictos de acciones de análisis sintáctico que se generan. Podemos obtener una descripción de
los conjuntos de elementos y los conflictos de acciones de análisis sintáctico si invocamos a
Yacc con una opción −v. Esta opción genera un archivo adicional y.output, el cual contiene
los corazones de los conjuntos de elementos encontrados para la gramática, una descripción
de los conflictos de acciones de análisis sintáctico generados por el algoritmo LALR, y una
representación legible de la tabla de análisis sintáctico LR que muestra cómo se resolvieron
los conflictos de acciones de análisis sintáctico. Cada vez que Yacc reporta que ha encontrado
Capítulo 4. Análisis sintáctico
292
%{
#include <ctype.h>
#include <stdio.h>
#define YYSTYPE double
%}
%token NUMERO
/* tipo double para la pila de Yacc */
%left ’+’, ’−’
%left ’*’, ’/’
%right UMENOS
%%
lines
: lines expr ’\n’
| lines ’\n’
| /* vacia */
expr
:
|
|
|
|
|
|
;
expr ’+’
expr ’−’
expr ’*’
expr ’/’
’(’ expr
’−’ expr
NUMERO
{ printf("%g\n", $2); }
expr
{ $$ = $1 + $3; }
expr
{ $$ = $1 − $3; }
expr
{ $$ = $1 * $3; }
expr
{ $$ = $1 / $3; }
’)’
{ $$ = $2; }
%prec UMENOS { $$ = − $2; }
%%
yylex() {
int c;
while ( ( c = getchar() ) ) == ’ ’ );
if ( ( c == ’.’) || (isdigit(c)) ) {
unget(c, stdin);
scanf( "%lf", &yyval);
return NUMERO;
}
return c;
}
Figura 4.59: Especificación de Yacc para una calculadora de escritorio más avanzada
293
4.9 Generadores de analizadores sintácticos
conflictos de acciones de análisis sintáctico, es conveniente crear y consultar el archivo y.output
para ver por qué se generaron los conflictos de acciones de análisis sintáctico y si se resolvieron
en forma correcta.
A menos que se indique lo contrario, Yacc resolverá todos los conflictos de las acciones de
análisis sintáctico mediante las siguientes dos reglas:
1. Un conflicto de reducción/reducción se resuelve eligiendo la producción en conflicto que
se presente primero en la especificación de Yacc.
2. Un conflicto de desplazamiento/reducción se resuelve a favor del desplazamiento. Esta
regla resuelve en forma correcta el conflicto de desplazamiento/reducción ocasionado por
la ambigüedad del else colgante.
Como estas reglas predeterminadas no siempre pueden ser lo que desea el escritor de
compiladores, Yacc proporciona un mecanismo general para resolver los conflictos de desplazamiento/reducción. En la porción de las declaraciones, podemos asignar precedencias y asociatividades a las terminales. La siguiente declaración:
%left ’+’ ’−’
hace que + y − sean de la misma precedencia y asociativos a la izquierda. Podemos declarar un
operador como asociativo a la derecha si escribimos lo siguiente:
%right ’^’
y podemos forzar a un operador para que sea un operador binario sin asociatividad (es decir, no
pueden combinarse dos ocurrencias del operador de ninguna manera) escribiendo lo siguiente:
%nonassoc ’<’
Los tokens reciben las precedencias en el orden en el que aparecen en la parte de las declaraciones, en donde la menor precedencia va primero. Los tokens en la misma declaración tienen
la misma precedencia. Así, la declaración
%right UMENOS
en la figura 4.59 proporciona al token UMENOS un nivel de precedencia mayor que el de las cinco
terminales anteriores.
Yacc resuelve los conflictos de desplazamiento/reducción adjuntando una precedencia y una
asociatividad a cada una de las producciones involucradas en un conflicto, así como también a
cada terminal involucrada en un conflicto. Si debe elegir entre desplazar el símbolo de entrada
a y reducir mediante la producción A → α, Yacc reduce si la precedencia de la producción es
mayor que la de a, o si las precedencias son iguales y la asociatividad de la producción es left.
En cualquier otro caso, el desplazamiento es la acción elegida.
Por lo general, la precedencia de una producción se considera igual a el de su terminal
por la derecha. Ésta es la decisión sensata en la mayoría de los casos. Por ejemplo, dadas las
siguientes producciones:
E →
E+E | E+E
Capítulo 4. Análisis sintáctico
294
sería preferible reducir mediante E → E +E con el símbolo de anticipación +, ya que el + en
el cuerpo tiene la misma precedencia que el símbolo de anticipación, pero es asociativo a la
izquierda. Con el símbolo de anticipación *, sería más preferible desplazar, ya que éste tiene
una precedencia mayor que la del + en la producción.
En esas situaciones en las que el terminal por la derecha no proporciona la precedencia
apropiada a una producción, podemos forzar el uso de una precedencia si adjuntamos a una
producción la siguiente etiqueta:
%prec terminal
La precedencia y la asociatividad de la producción serán entonces iguales que la del terminal,
que se supone está definida en la sección de declaraciones. Yacc no reporta los conflictos de
desplazamiento/reducción que se resuelven usando este mecanismo de precedencia y asociatividad.
Este “terminal” podría ser un receptáculo como UMENOS en la figura 4.59: el analizador léxico no devuelve este terminal, sino que está declarado con el único fin de definir una precedencia
para una producción. En la figura 4.59, la declaración
%right UMENOS
asigna al token UMENOS una precedencia mayor que la de ∗ y /. En la parte de las reglas de
traducción, la etiqueta:
%prec UMENOS
al final de la producción
expr
: ’−’ expr
hace que el operador de resta unario en esta producción tenga una menor precedencia que
cualquier otro operador.
4.9.3
Creación de analizadores léxicos de Yacc con Lex
Lex se diseñó para producir analizadores léxicos que pudieran utilizarse con Yacc. La biblioteca ll de Lex proporciona un programa controlador llamado yylex(), el nombre que Yacc requiere para su analizador léxico. Si se utiliza Lex para producir el analizador léxico, sustituimos
la rutina yylex() en la tercera parte de la especificación de Yacc con la siguiente instrucción:
#include "lex.yy.c"
y hacemos que cada acción de Lex devuelva un terminal conocido a Yacc. Al usar la instrucción #include "lex.yy.c", el programa yylex tiene acceso a los nombres de Yacc para los
tokens, ya que el archivo de salida de Lex se compila como parte del archivo de salida y.tab.c
de Yacc.
En el sistema UNIX, si la especificación de Lex está en el archivo primero.l y la especificación de Yacc en segundo.y, podemos escribir lo siguiente:
4.9 Generadores de analizadores sintácticos
295
lex primero.l
yacc segundo.y
cc y.tab.c −ly −ll
para obtener el traductor deseado.
La especificación de Lex en la figura 4.60 puede usarse en vez del analizador léxico de la
figura 4.59. El último patrón, que significa “cualquier carácter”, debe escribirse como \nl. ya
que el punto en Lex coincide con cualquier carácter, excepto el de nueva línea.
numero
%%
[ ]
{numero}
\nl.
[0−9]+\e.?|[0−9]*\e.[0−9]+
{ /* omitir espacios en blanco */ }
{ sscanf(yytext, "%lf", &yylval);
return NUMERO; }
{ return yytext[0]; }
Figura 4.60: Especificación de Lex para yylex() en la figura 4.59
4.9.4
Recuperación de errores en Yacc
En Yacc, la recuperación de errores utiliza una forma de producciones de error. En primer lugar, el usuario decide qué no terminales “importantes” tendrán la recuperación de errores asociado con ellas. Las elecciones típicas son cierto subconjunto de los no terminales que generan
expresiones, instrucciones, bloques y funciones. Después el usuario agrega a las producciones de
error gramaticales de la forma A → error α, en donde A es un no terminal importante y α es
una cadena de símbolos gramaticales, tal vez la cadena vacía; error es una palabra reservada
de Yacc. Yacc generará un analizador sintáctico a partir de dicha especificación, tratando a las
producciones de error como producciones ordinarias.
No obstante, cuando el analizador sintáctico generado por Yacc encuentra un error, trata
a los estados cuyos conjuntos de elementos contienen producciones de error de una manera
especial. Al encontrar un error, Yacc saca símbolos de su pila hasta que encuentra el estado en
la parte superior de su pila cuyo conjunto subyacente de elementos incluya a un elemento de la
forma A → · error α. Después, el analizador sintáctico “desplaza” un token ficticio error hacia
la pila, como si hubiera visto el token error en su entrada.
Cuando α es , se realiza una reducción a A de inmediato y se invoca la acción semántica
asociada con la producción A → · error (que podría ser una rutina de recuperación de errores
especificada por el usuario). Después, el analizador sintáctico descarta los símbolos de entrada
hasta que encuentra uno con el cual pueda continuar el análisis sintáctico normal.
Si α no está vacía, Yacc sigue recorriendo la entrada, ignorando los símbolos hasta que
encuentra una subcadena que pueda reducirse a α. Si α consiste sólo en terminales, entonces
busca esta cadena de terminales en la entrada y los “reduce” al desplazarlas hacia la pila. En
este punto, el analizador sintáctico tendrá a error α en la parte superior de su pila. Después,
el analizador sintáctico reducirá error α a A y continuará con el análisis sintáctico normal.
Por ejemplo, una producción de error de la siguiente forma:
Capítulo 4. Análisis sintáctico
296
%{
#include <ctype.h>
#include <stdio.h>
#define YYSTYPE double
%}
%token NUMERO
/* tipo double para la pila de Yacc */
%left ’+’ ’−’
%left ’*’ ’/’
%right UMENOS
%%
lineas :
|
|
|
expr
;
:
|
|
|
|
|
|
;
lineas expr ’\n’
{ printf("%g\n", $2); }
lineas ’\n’
/* vacia */
error ’\n’ { yyerror("reintroduzca linea anterior:");
yyerrok; }
expr ’+’
expr ’−’
expr ’*’
expr ’/’
’(’ expr
’−’ expr
NUMERO
expr
{ $$ = $1 + $3; }
expr
{ $$ = $1 − $3; }
expr
{ $$ = $1 * $3; }
expr
{ $$ = $1 / $3; }
‘)’
{ $$ = $2; }
%prec UMENOS { $$ = − $2; }
%%
#include "lex.yy.c"
Figura 4.61: Calculadora de escritorio con recuperación de errores
instr → error ;
especificaría al analizador sintáctico que debe omitir lo que esté más allá después del siguiente
punto y coma al ver un error, y debe suponer que se ha encontrado una instrucción. La rutina
semántica para esta producción de error no tendría que manipular la entrada, pero podría generar un mensaje de diagnóstico y establecer una bandera para inhibir la generación de código
objeto, por ejemplo.
Ejemplo 4.70: La figura 4.61 muestra la calculadora de escritorio Yacc de la figura 4.59, con
la siguiente producción de error:
lineas : error ’\n’
Esta producción de error hace que la calculadora de escritorio suspenda el análisis sintáctico
normal al encontrar un error sintáctico en una línea de entrada. Al encontrar el error, el ana-
297
4.10 Resumen del capítulo 4
lizador sintáctico en la calculadora de escritorio empieza a sacar símbolos de su pila hasta que
encuentra un estado con una acción de desplazamiento en el token error. El estado 0 es un
estado de este tipo (en este ejemplo, es el único estado así), ya que sus elementos incluyen:
lineas → · error ’\n’
Además, el estado 0 siempre se encuentra en la parte inferior de la pila. El analizador sintáctico desplaza el token error hacia la pila y después continúa ignorando símbolos en la entrada
hasta encontrar un carácter de nueva línea. En este punto, el analizador sintáctico desplaza el
carácter de nueva línea hacia la pila, reduce error ’\n’ a lineas, y emite el mensaje de diagnóstico “reintroduzca linea anterior:”. La rutina especial de Yacc llamada yyerrok restablece
el analizador sintáctico a su modo normal de operación. 2
4.9.5
Ejercicios para la sección 4.9
! Ejercicio 4.9.1: Escriba un programa en Yacc que reciba expresiones booleanas como entrada
[según lo indicado por la gramática del ejercicio 4.2.2(g)] y produzca el valor verdadero de las
expresiones.
! Ejercicio 4.9.2: Escriba un programa en Yacc que reciba listas (según lo definido por la
gramática del ejercicio 4.2.2(e), pero con cualquier carácter individual como elemento, no sólo
a) y produzca como salida una representación lineal de la misma lista; por ejemplo, una lista
individual de los elementos, en el mismo orden en el que aparecen en la entrada.
! Ejercicio 4.9.3: Escriba un programa en Yacc que indique si su entrada es un palíndromo
(secuencia de caracteres que se leen igual al derecho y al revés).
!! Ejercicio 4.9.4: Escriba un programa en Yacc que reciba expresiones regulares (según lo definido por la gramática del ejercicio 4.2.2(d), pero con cualquier carácter individual como argumento, no sólo a) y produzca como salida una tabla de transición para un autómata finito no
determinista que reconozca el mismo lenguaje.
4.10
Resumen del capítulo 4
♦ Analizadores sintácticos. Un analizador sintáctico recibe como entrada tokens del analizador léxico, y trata los nombres de los tokens como símbolos terminales de una gramática
libre de contexto. Después, el analizador construye un árbol de análisis sintáctico para su
secuencia de tokens de entrada; el árbol de análisis sintáctico puede construirse en sentido
figurado (pasando por los pasos de derivación correspondientes) o en forma literal.
♦ Gramáticas libres de contexto. Una gramática especifica un conjunto de símbolos terminales (entradas), otro conjunto de no terminales (símbolos que representan construcciones sintácticas) y un conjunto de producciones, cada una de las cuales proporciona
una forma en la que se pueden construir las cadenas representadas por un no terminal,
a partir de símbolos terminales y cadenas representados por otros no terminales. Una
producción consiste en un encabezado (el no terminal a sustituir) y un cuerpo (la cadena
de símbolos gramaticales de sustitución).
298
Capítulo 4. Análisis sintáctico
♦ Derivaciones. Al proceso de empezar con el no terminal inicial de una gramática y sustituirlo en forma repetida por el cuerpo de una de sus producciones se le conoce como
derivación. Si siempre se sustituye el no terminal por la izquierda (o por la derecha), entonces a la derivación se le llama por la izquierda (o respectivamente, por la derecha).
♦ Árboles de análisis sintáctico. Un árbol de análisis sintáctico es una imagen de una derivación, en la cual hay un nodo para cada no terminal que aparece en la derivación. Los hijos de
un nodo son los símbolos mediante los cuales se sustituye este no terminal en la derivación.
Hay una correspondencia de uno a uno entre los árboles de análisis sintáctico, las derivaciones por la izquierda y las derivaciones por la derecha de la misma cadena de terminales.
♦ Ambigüedad. Una gramática para la cual cierta cadena de terminales tiene dos o más
árboles de análisis sintáctico distintos, o en forma equivalente, dos o más derivaciones
por la izquierda, o dos o más derivaciones por la derecha, se considera ambigua. En la
mayoría de los casos de interés práctico, es posible rediseñar una gramática ambigua de
tal forma que se convierta en una gramática sin ambigüedad para el mismo lenguaje. No
obstante, las gramáticas ambiguas con ciertos trucos aplicados nos llevan algunas veces
a la producción de analizadores sintácticos más eficientes.
♦ Análisis sintáctico descendente y ascendente. Por lo general, los analizadores sintácticos
se diferencian en base a si trabajan de arriba hacia abajo (si empiezan con el símbolo
inicial de la gramática y construyen el árbol de análisis sintáctico partiendo de la parte
superior) o de abajo hacia arriba (si empiezan con los símbolos terminales que forman las
hojas del árbol de análisis sintáctico y construyen el árbol partiendo de la parte inferior).
Los analizadores sintácticos descendentes incluyen los analizadores sintácticos con descenso recursivo y LL, mientras que las formas más comunes de analizadores sintácticos
ascendentes son analizadores sintácticos LR.
♦ Diseño de gramáticas. A menudo, las gramáticas adecuadas para el análisis sintáctico
descendente son más difíciles de diseñar que las utilizadas por los analizadores sintácticos
ascendentes. Es necesario eliminar la recursividad por la izquierda, una situación en la
que un no terminal deriva a una cadena que empieza con el mismo no terminal. También
debemos factorizar por la izquierda; las producciones de grupo para el mismo no terminal que tengan un prefijo común en el cuerpo.
♦ Analizadores sintácticos de descenso recursivo. Estos analizadores sintácticos usan un
procedimiento para cada no terminal. El procedimiento analiza su entrada y decide qué producción aplicar para su no terminal. Los terminales en el cuerpo de la producción se
relacionan con la entrada en el momento apropiado, mientras que las no terminales en
el cuerpo producen llamadas a su procedimiento. El rastreo hacia atrás, en el caso de
cuando se elige la producción incorrecta, es una posibilidad.
♦ Analizadores sintácticos LL(1). Una gramática en la que es posible elegir la producción
correcta con la cual se pueda expandir un no terminal dado, con solo analizar el siguiente
símbolo de entrada, se conoce como LL(1). Estas gramáticas nos permiten construir una
tabla de análisis sintáctico predictivo que proporcione, para cada no terminal y cada
símbolo de preanálisis, la elección de la producción correcta. La corrección de errores se
puede facilitar al colocar las rutinas de error en algunas, o en todas las entradas en la
tabla que no tengan una producción legítima.
4.10 Resumen del capítulo 4
299
♦ Análisis sintáctico de desplazamiento-reducción. Por lo general, los analizadores sintácticos ascendentes operan mediante la elección, en base al siguiente símbolo de entrada
(símbolo de anticipación) y el contenido de la pila, de si deben desplazar la siguiente entrada hacia la pila, o reducir algunos símbolos en la parte superior de la misma. Un paso
de reducción toma un cuerpo de producción de la parte superior de la pila y lo sustituye
por el encabezado de la producción.
♦ Prefijos viables. En el análisis sintáctico de desplazamiento-reducción, el contenido de la
pila siempre es un prefijo viable; es decir, un prefijo de cierta forma de frase derecha que
termina a la derecha, no más allá del final del mango de ésta. El mango es la subcadena
que se introdujo en el último paso de la derivación por la derecha de esa forma de frase.
♦ Elementos válidos. Un elemento es una producción con un punto en alguna parte del
cuerpo. Un elemento es válido para un prefijo viable si la producción de ese elemento se
utiliza para generar el mango, y el prefijo viable incluye todos esos símbolos a la izquierda del punto, pero no los que están abajo.
♦ Analizadores sintácticos LR. Cada uno de los diversos tipos de analizadores sintácticos
LR opera construyendo primero los conjuntos de elementos válidos (llamados estados LR)
para todos los prefijos viables posibles, y llevando el registro del estado para cada prefijo en la pila. El conjunto de elementos válidos guía la decisión de análisis sintáctico de
desplazamiento-reducción. Preferimos reducir si hay un elemento válido con el punto en el
extremo derecho del cuerpo, y desplazamos el símbolo de anticipación hacia la pila si ese
símbolo aparece justo a la derecha del punto, en algún elemento válido.
♦ Analizadores sintácticos LR simples. En un analizador sintáctico SLR, realizamos una
reducción implicada por un elemento válido con un punto en el extremo derecho, siempre
y cuando el símbolo de anticipación pueda seguir el encabezado de esa producción en
alguna forma de frase. La gramática es SLR, y este método puede aplicarse si no hay
conflictos de acciones de análisis sintáctico; es decir, que para ningún conjunto de elementos y para ningún símbolo de anticipación haya dos producciones mediante las cuales
se pueda realizar una reducción, ni exista la opción de reducir o desplazar.
♦ Analizadores sintácticos LR canónicos. Esta forma más compleja de analizador sintáctico
LR utiliza elementos que se aumentan mediante el conjunto de símbolos de anticipación
que pueden seguir el uso de la producción subyacente. Las reducciones sólo se eligen
cuando hay un elemento válido con el punto en el extremo derecho, y el símbolo actual
de anticipación es uno de los permitidos para este elemento. Un analizador sintáctico
LR canónico puede evitar algunos de los conflictos de acciones de análisis sintáctico que
están presentes en los analizadores sintácticos SLR; pero a menudo tiene más estados que el
analizador sintáctico SLR para la misma gramática.
♦ Analizadores sintácticos LR con lectura anticipada. Los analizadores sintácticos LALR ofrecen muchas de las ventajas de los analizadores sintácticos SLR y LR canónicos, mediante la
combinación de estados que tienen los mismos corazones (conjuntos de elementos, ignorando
los conjuntos asociados de símbolos de anticipación). Por ende, el número de estados es el
mismo que el del analizador sintáctico SLR, pero algunos conflictos de acciones de análisis
sintáctico presentes en el analizador sintáctico SLR pueden eliminarse en el analizador sintáctico LALR. Los analizadores sintácticos LALR se han convertido en el método más usado.
Capítulo 4. Análisis sintáctico
300
♦ Análisis sintáctico ascendente de gramáticas ambiguas. En muchas situaciones importantes,
como en el análisis sintáctico de expresiones aritméticas, podemos usar una gramática ambigua y explotar la información adicional, como la precedencia de operadores, para resolver
conflictos entre desplazar y reducir, o entre la reducción mediante dos reducciones distintas.
Por ende, las técnicas de análisis sintáctico LR se extienden a muchas gramáticas ambiguas.
♦ Yacc. El generador de analizadores sintácticos Yacc recibe una gramática (posiblemente)
ambigua junto con la información de resolución de conflictos, y construye los estados del
LALR. Después produce una función que utiliza estos estados para realizar un análisis sintáctico ascendente y llama a una función asociada cada vez que se realiza una reducción.
4.11
Referencias para el capítulo 4
El formalismo de las gramáticas libres de contexto se originó con Chomsky [5], como parte de
un estudio acerca del lenguaje natural. La idea también se utilizó en la descripción sintáctica
de dos de los primeros lenguajes: Fortran por Backus [2] y Algol 60 por Naur [26]. El erudito
Panini ideó una notación sintáctica equivalente para especificar las reglas de la gramática Sanskrit entre los años 400 a.C. y 200 a.C. [19].
Cantor [4] y Floyd [13] fueron los primeros que observaron el fenómeno de la ambigüedad.
La Forma Normal de Chomsky (ejercicio 4.4.8) proviene de [6]. La teoría de las gramáticas
libres de contexto se resume en [17].
El análisis sintáctico de descenso recursivo fue el método preferido para los primeros compiladores, como [16], y los sistemas para escribir compiladores, como META [28] y TMG [25].
Lewis y Stearns [24] introdujeron las gramáticas LL. El ejercicio 4.4.5, la simulación en tiempo
lineal del descenso recursivo, proviene de [3].
Una de las primeras técnicas de análisis sintáctico, que se debe a Floyd [14], implicaba la
precedencia de los operadores. Wirth y Weber [29] generalizaron la idea para las partes del
lenguaje que no involucran operadores. Estas técnicas se utilizan raras veces hoy en día, pero
podemos verlas como líderes en una cadena de mejoras para el análisis sintáctico LR.
Knuth [22] introdujo los analizadores sintácticos LR, y las tablas de análisis sintáctico LR
canónicas se originaron ahí. Este método no se consideró práctico, debido a que las tablas de
análisis sintáctico eran más grandes que las memorias principales de las computadoras típicas
de esa época, hasta que Korenjak [23] proporcionó un método para producir tablas de análisis
sintáctico de un tamaño razonable para los lenguajes de programación comunes. DeRemer desarrolló los métodos LALR [8] y SLR [9] que se usan en la actualidad. La construcción de las
tablas de análisis sintáctico LR para las gramáticas ambiguas provienen de [1] y [12].
El generador Yacc de Johnson demostró con mucha rapidez la habilidad práctica de generar
analizadores sintácticos con un generador de analizadores sintácticos LALR para los compiladores de producción. El manual para el generador de analizadores sintácticos Yacc se encuentra
en [20]. La versión de código-abierto, Bison, se describe en [10]. Hay un generador de analizadores sintácticos similar llamado CUP [18], el cual se basa en LALR y soporta acciones escritas
en Java. Los generadores de analizadores sintácticos descendentes incluyen a Antlr [27], un
generador de analizadores sintácticos de descenso recursivo que acepta acciones en C++, Java
o C#, y LLGen [15], que es un generador basado en LL(1).
Dain [7] proporciona una bibliografía acerca del manejo de errores sintácticos.
4.11 Referencias para el capítulo 4
301
El algoritmo de análisis sintáctico de programación dinámica de propósito general descrito
en el ejercicio 4.4.9 lo inventaron en forma independiente J. Cocke (sin publicar), Younger [30]
y Kasami [21]; de aquí que se le denomine “algoritmo CYK”. Hay un algoritmo más complejo
de propósito general que creó Earley [11], que tabula los elementos LR para cada subcadena de
la entrada dada; este algoritmo, que también requiere un tiempo O(n3) en general, sólo requiere
un tiempo O(n2) en las gramáticas sin ambigüedad.
1. Aho, A. V., S. C. Johnson y J. D. Ullman, “Deterministic parsing of ambiguous grammars”, Comm. ACM 18:8 (Agosto, 1975), pp. 441-452.
2. Backus, J. W, “The syntax and semantics of the proposed international algebraic language of the Zurich-ACM-GAMM Conference”, Proc. Intl. Conf. Information Processing, UNESCO, París (1959), pp. 125-132.
3. Birman, A. y J. D. Ullman, “Parsing algorithms with backtrack”, Information and
Control 23:1 (1973), pp. 1-34.
4. Cantor, D. C., “On the ambiguity problem of Backus systems”, J. ACM 9:4 (1962),
pp. 477-479.
5. Chomsky, N., “Three models for the description of language”, IRE Trans. on Information Theory IT-2:3 (1956), pp. 113-124.
6. Chomsky, N., “On certain formal properties of grammars”, Information and Control
2:2 (1959), pp. 137-167.
7. Dain, J., “Bibliography on Syntax Error Handling in Language Translation Systems”,
1991. Disponible en el grupo de noticias comp.compilers; vea http://compilers.
iecc.com/comparch/article/91−04−050.
8. DeRemer, F., “Practical Translators for LR(k) Languages”, Tésis Ph.D., MIT, Cambridge, MA, 1969.
9. DeRemer, F., “Simple LR(k) grammars”, Comm. ACM 14:7 (Julio, 1971), pp. 453-460.
10. Donnelly, C. y R. Stallman, “Bison: The YACC-compatible Parser Generator”, http://
www.gnu.org/software/bison/manual/.
11. Earley, J., “An efficient context-free parsing algorithm”, Comm. ACM 13:2 (Febrero,
1970), pp. 94-102.
12. Earley, J., “Ambiguity and precedence in syntax description”, Acta Informatica 4:2
(1975), pp. 183-192.
13. Floyd, R. W., “On ambiguity in phrase-structure languages”, Comm. ACM 5:10 (Octubre, 1962), pp. 526-534.
14. Floyd, R. W., “Syntactic analysis and operator precedence”, J. ACM 10:3 (1963),
pp. 316-333.
302
Capítulo 4. Análisis sintáctico
15. Grune, D. y C. J. H. Jacobs, “A programmer-friendly LL(1) parser generator”, Software Practice and Experience 18:1 (Enero, 1988), pp. 29-38. Vea también http://www.
cs.vu.nl/~ceriel/LLgen.html.
16. Hoare, C. A. R., “Report on the Elliott Algol translator”, Computer J. 5:2 (1962),
pp. 127-129.
17. Hopcroft, J. E., R. Motwani y J. D. Ullman, Introduction to Automata Theory, Languages, and Computation, Addison-Wesley, Boston, MA, 2001.
18. Hudson, S. E. et al., “CUP LALR Parser Generator in Java”, Disponible en http://
www2.cs.tum.edu/projects/cup/.
19. Ingerman, P. Z., “Panini-Backus form suggested”, Comm. ACM 10:3 (Marzo, 1967),
p. 137.
20. Johnson, S. C., “Yacc — Yet Another Compiler Compiler”, Computing Science Technical Report 32, Bell Laboratories, Murray Hill, NJ, 1975. Disponible en http://dino−
saur.compilertools.net/yacc/.
21. Kasami, T., “An efficient recognition and syntax analysis algorithm for context-free languages”, AFCRL-65-758, Air Force Cambridge Research Laboratory, Bedford, MA, 1965.
22. Knuth, D. E., “On the translation of languages from left to right”, Information and
Control 8:6 (1965), pp. 607-639.
23. Korenjak, A. J., “A practical method for constructing LR(k) processors”, Comm. ACM
12:11 (Noviembre, 1969), pp. 613-623.
24. Lewis, P. M. II y R. E. Stearns, “Syntax-directed transduction”, J. ACM 15:3 (1968),
pp. 465-488.
25. McClure, R. M., “TMG — a syntax-directed compiler”, proc. 20 th ACM Natl. Conf. (1965),
pp. 262-274.
26. Naur, P. et al., “Report on the algorithmic language ALGOL 60”, Comm. ACM 3:5
(Mayo, 1960), pp. 299-314. Vea también Comm. ACM 6:1 (Enero, 1963), pp. 1-17.
27. Parr, T., “ANTLR”, http://www.antlr.org/.
28. Schorre, D. V., “Meta-II: a syntax-oriented compiler writing language”, Proc. 19 th ACM
Natl. Conf. (1964), pp. D1.3-1–D1.3.-11.
29. Wirth, N. y H. Weber, “Euler: a generalization of Algol and its formal definition: Part
I”, Comm. ACM 9:1 (Enero, 1966), pp. 13-23.
30. Younger, D. H., “Recognition and parsing of context-free languages in time n3”, Information and Control 10:2 (1967), pp. 189-208.
Capítulo 5
Traducción orientada
por la sintaxis
Este capítulo desarrolla el tema de la sección 2.3: la traducción de los lenguajes guiados por
las gramáticas libres de contexto. Las técnicas de traducción de este capítulo se aplicarán en
el capítulo 6 a la comprobación de tipos y la generación de código intermedio. Estas técnicas
son también útiles en la implementación de pequeños lenguajes para tareas especializadas; este
capítulo incluye un ejemplo de composición tipográfica.
Para asociar la información con una construcción del lenguaje, adjuntamos atributos al (los)
símbolo(s) gramatical(es) que representa(n) la construcción, como vimos en la sección 2.3.2.
Una definición orientada por la sintaxis especifica los valores de los atributos mediante la asociación de las reglas semánticas con las producciones gramaticales. Por ejemplo, un traductor
de infijo a postfijo podría tener la siguiente producción con la siguiente regla:
PRODUCCIÓN
E → E1 + T
REGLA SEMÁNTICA
E.codigo = E1.codigo || T.codigo || +
(5.1)
Esta producción tiene dos no terminales, E y T; el subíndice en E1 diferencia la ocurrencia
de E en el cuerpo de la producción de la ocurrencia de E como el encabezado. Tanto E como
T tienen un atributo codigo con valor de cadena. La regla semántica especifica que la cadena
E.codigo debe formarse mediante la concatenación de E1.codigo, T.codigo y el carácter +.
Aunque la regla deja explícito que la traducción de E se conforma a partir de las traducciones
de E1, T y +, puede ser ineficiente implementar la traducción en forma directa, mediante la
manipulación de cadenas.
Como vimos en la sección 2.3.5, un esquema de traducción orientado por la sintaxis incrusta
fragmentos de programa, llamados acciones semánticas, con cuerpos de producciones, como en
E → E1 + T { print + }
(5.2)
Por convención, las acciones semánticas se encierran entre llaves (si las llaves ocurren como
símbolos gramaticales, las encerramos entre comillas sencillas, como en { y } ). La posición
303
304
Capítulo 5. Traducción orientada por la sintaxis
de una acción semántica en el cuerpo de una producción determina el orden en el que se ejecuta la acción. En la producción (5.2), la acción ocurre al final, después de todos los símbolos
gramaticales; en general, las acciones semánticas pueden ocurrir en cualquier posición dentro
del cuerpo de una producción.
Entre las dos notaciones, las definiciones dirigidas por la sintaxis pueden ser más legibles, y
por ende más útiles para las especificaciones. No obstante, los esquemas de traducción pueden
ser más eficientes y, por lo tanto, más útiles para las implementaciones.
El método más general para la traducción orientada por la sintaxis es construir un árbol
de análisis sintáctico o un árbol sintáctico, y después calcular los valores de los atributos en
los nodos del árbol, visitándolas. En muchos casos, la traducción puede realizarse durante el
análisis sintáctico sin construir un árbol explícito. Por lo tanto, vamos a estudiar una clase de
traducciones orientadas a la sintaxis, conocidas como “traducciones con atributos heredados
por la izquierda” (L indica de izquierda a derecha), los cuales abarcan prácticamente todas las
traducciones que pueden realizarse durante el análisis sintáctico. También estudiaremos una
clase más pequeña, llamada “traducciones con atributos sintetizados” (S de sintetizados), las
cuales pueden realizarse con facilidad en conexión con un análisis sintáctico ascendente.
5.1
Definiciones dirigidas por la sintaxis
Una definición dirigida por la sintaxis es una gramática libre de contexto, junto con atributos
y reglas. Los atributos sintetizados se asocian con los símbolos gramaticales y las reglas se asocian con las producciones. Si X es un símbolo y a es uno de sus atributos, entonces escribimos
X.a para denotar el valor de a en el nodo específico de un árbol de análisis sintáctico, etiquetado como X. Si implementamos los nodos del árbol de análisis sintáctico mediante registros
u objetos, entonces los atributos de X pueden implementarse mediante campos de datos en
los registros, que representen los nodos para X. Los atributos pueden ser de cualquier tipo:
por ejemplo, números, tipos, referencias de tablas o cadenas. Las cadenas pueden incluso ser
secuencias largas de código, por decir código tenemos el lenguaje intermedio utilizado por un
compilador.
5.1.1
Atributos heredados y sintetizados
Vamos a manejar dos tipos de atributos para los no terminales:
1. Un atributo sintetizado para un no terminal A en un nodo N de un árbol sintáctico se
define mediante una regla semántica asociada con la producción en N. Observe que la
producción debe tener a A como su encabezado. Un atributo sintetizado en el nodo N se
define sólo en términos de los valores de los atributos en el hijo de N, y en el mismo N.
2. Un atributo heredado para un no terminal B en el nodo N de un árbol de análisis sintáctico se define mediante una regla semántica asociada con la producción en el padre de N.
Observe que la producción debe tener a B como un símbolo en su cuerpo. Un atributo
heredado en el nodo N se define sólo en términos de los valores de los atributos en el
padre de N, en el mismo N y en sus hermanos.
305
5.1 Definiciones dirigidas por la sintaxis
Una definición alternativa de los atributos heredados
No se habilitan traducciones adicionales si permitimos que un atributo heredado B.c en un
nodo N se defina en términos de los valores de los atributos en los hijos de N, así como en
el mismo N, en su padre y en sus hermanos. Dichas reglas pueden “simularse” mediante la
creación de atributos adicionales de B, por ejemplo, B.c1, B.c2, … . Éstos son atributos sintetizados que copian los atributos necesarios de los hijos del nodo etiquetado como B. Después
calculamos a B.c como un atributo heredado, usando los atributos B.c1, B.c2, … en vez de los
atributos en el hijo. Dichos atributos sintetizados se necesitan raras veces en la práctica.
Aunque no permitimos que un atributo heredado en el nodo N se defina en términos de los
valores de los atributos en el hijo del nodo N, sí permitimos que un atributo sintetizado en el
nodo N se defina en términos de los valores de los atributos heredados en el mismo nodo N.
Los terminales pueden tener atributos sintetizados, pero no atributos heredados. Los atributos para los terminales tienen valores léxicos que suministra el analizador léxico; no hay
reglas semánticas en la misma definición dirigida por la sintaxis para calcular el valor de un
atributo para un terminal.
Ejemplo 5.1: La definición dirigida por la sintaxis en la figura 5.1 se basa en nuestra conocida
gramática para las expresiones aritméticas con los operadores + y ∗. Evalúa las expresiones que
terminan con un marcador final n. En la definición dirigida por la sintaxis, cada una de los no terminales tiene un solo atributo sintetizado, llamado val. También suponemos que el terminal digito
tiene un atributo sintetizado valex, el cual es un valor entero que devuelve el analizador léxico.
PRODUCCIÓN
digito
REGLAS
SEMÁNTICAS
digito.lexval
Figura 5.1: Definición orientada por la sintaxis de una calculadora de escritorio simple
La regla para la producción 1, L → E n, establece L.val a E.val, que veremos que es el valor
numérico de toda la expresión.
La producción 2, E → E1 + T, también tiene una regla, la cual calcula el atributo val para
el encabezado E como la suma de los valores en E1 y T. En cualquier nodo N de un árbol sintáctico, etiquetado como E, el valor de val para E es la suma de los valores de val en los hijos
del nodo N, etiquetados como E y T.
306
Capítulo 5. Traducción orientada por la sintaxis
La producción 3, E → T, tiene una sola regla que define el valor de val para E como el
mismo que el valor de val en el hijo para T. La producción 4 es similar a la segunda producción;
su regla multiplica los valores en los hijos, en vez de sumarlos. Las reglas para las producciones
5 y 6 copian los valores en un hijo, como el de la tercera producción. La producción 7 proporciona a F.val el valor de un dígito; es decir, el valor numérico del token digito que devolvió el
analizador léxico.
2
A una definición dirigida por la sintaxis que sólo involucra atributos sintetizados se le conoce como definición dirigida por la sintaxis con atributos sintetizados; la definición dirigida por
la sintaxis en la figura 5.1 tiene esta propiedad. En una definición dirigida por la sintaxis con
atributos sintetizados, cada regla calcula un atributo para el no terminal en el encabezado de
una producción, a partir de los atributos que se toman del cuerpo de la producción.
Por simpleza, los ejemplos en esta sección tienen reglas semánticas sin efectos adicionales.
En la práctica, es conveniente permitir que las definiciones dirigidas por la sintaxis tengan
efectos adicionales limitados, como imprimir el resultado calculado por una calculadora de
escritorio o interactuar con una tabla de símbolos. Una vez que veamos el orden de evaluación
de los atributos en la sección 5.2, permitiremos que las reglas semánticas calculen funciones
arbitrarias, lo cual es probable que involucre efectos adicionales.
Una definición dirigida por la sintaxis con atributos sintetizados puede implementarse de
manera natural, en conjunción con un analizador sintáctico LR. De hecho, la definición dirigida por la sintaxis en la figura 5.1 es un reflejo del programa de Yacc de la figura 4.58, el cual
ilustra la traducción durante el análisis sintáctico LR. La diferencia es que, en la regla para la
producción 1, el programa de Yacc imprime el valor E.val como un efecto adicional, en vez de
definir el atributo L.val.
A una definición dirigida por la sintaxis sin efectos adicionales se le llama algunas veces
gramática atribuida. Las reglas en una gramática atribuida definen el valor de un atributo, sólo
en términos de los valores de otros atributos y constantes.
5.1.2
Evaluación de una definición dirigida por la sintaxis
en los nodos de un árbol de análisis sintáctico
Para visualizar la traducción especificada por una definición dirigida por la sintaxis, es útil
trabajar con los árboles de análisis sintáctico, aun cuando un traductor en realidad no necesita
construir un árbol de análisis sintáctico. Imagine, por lo tanto, que las reglas de una definición
dirigida por la sintaxis se aplican construyendo primero un árbol de análisis sintáctico, y después usando las reglas para evaluar todos los atributos en cada uno de los nodos del árbol.
A un árbol sintáctico, que muestra el (los) valor(es) de su(s) atributo(s) se le conoce como árbol
de análisis sintáctico anotado.
¿Cómo construimos un árbol de análisis sintáctico anotado? ¿En qué orden evaluamos los
atributos? Antes de poder evaluar un atributo en un nodo del árbol de análisis sintáctico, debemos evaluar todos los atributos de los cuales depende su valor. Por ejemplo, si todos los atributos sintetizados son sintetizados, como en el ejemplo 5.1, entonces debemos evaluar los atributos
val en todos los hijos de un nodo, para poder evaluar el atributo val en el mismo nodo.
Con los atributos sintetizados, podemos evaluar los atributos en cualquier orden de abajo
hacia arriba, como el de un recorrido postorden del árbol de análisis sintáctico; en la sección
5.2.3 hablaremos sobre la evaluación de las definiciones con atributos sintetizados.
307
5.1 Definiciones dirigidas por la sintaxis
Para las definiciones dirigidas por la sintaxis con atributos heredados y sintetizados, no hay
garantía de que haya siquiera un orden en el que se puedan evaluar los atributos en los nodos.
Por ejemplo, considere los no terminales A y B, con los atributos sintetizados y heredados A.s
y B.i, respectivamente, junto con la siguiente producción y las siguientes reglas:
PRODUCCIÓN
A→B
REGLAS SEMÁNTICAS
A.s = B.i;
B.i = A.s + 1
Estas reglas son circulares; es imposible evaluar A.s en un nodo N o B.i como el hijo de N sin
primero evaluar el otro. La dependencia circular de A.s y B.i en cierto par de nodos en un árbol
sintáctico se sugiere mediante la figura 5.2.
Figura 5.2: La dependencia circular de A.s y B.i, uno del otro
En términos computacionales, es difícil determinar si existen o no circularidades en cualquiera de los árboles de análisis sintáctico que tenga que traducir una definición dirigida por
la sintaxis dada.1 Por fortuna, hay subclases útiles de definiciones dirigidas por la sintaxis que
bastan para garantizar que exista un orden de evaluación, como veremos en la sección 5.2.
Ejemplo 5.2: La figura 5.3 muestra un árbol de análisis sintáctico anotado para la cadena de
entrada 3 ∗ 5 + 4 n, construida mediante el uso de la gramática y las reglas de la figura 5.1. Se
supone que el analizador léxico proporciona los valores de valex. Cada uno de los nodos para
los no terminales tiene el atributo val calculado en orden de abajo hacia arriba, y podemos ver los
valores resultantes asociados con cada nodo. Por ejemplo, en el nodo con un hijo etiquetado
como ∗, después de calcular T.val = 3 y F.val = 5 en sus hijos primero y tercero, aplicamos la
regla que dice que T.val es el producto de estos dos valores, o 15.
2
Los atributos heredados son útiles cuando la estructura de un árbol de análisis sintáctico
no “coincide” con la sintaxis abstracta del código fuente. El siguiente ejemplo muestra cómo
pueden usarse los atributos heredados para solucionar dicho conflicto, debido a una gramática
diseñada para el análisis sintáctico, en vez de la traducción.
1
Sin entrar en detalles, aunque el problema puede decidirse, no puede resolverse mediante un algoritmo en tiempo
polinomial, incluso si P = N P, ya que tiene una complejidad de tiempo exponencial.
Capítulo 5. Traducción orientada por la sintaxis
308
digito.valex
digito.valex
digito.valex
Figura 5.3: Árbol de análisis sintáctico anotado para 3 ∗ 5 + 4 n
Ejemplo 5.3: La definición dirigida por la sintaxis en la figura 5.4 calcula términos como 3 ∗
5 y 3 ∗ 5 ∗ 7. El análisis sintáctico descendente de la entrada 3 ∗ 5 empieza con la producción
T → FT . Aquí, F genera el dígito 3, pero el operador ∗ se genera mediante T . Por ende, el
operando izquierdo 3 aparece en un subárbol distinto del árbol de análisis sintáctico de ∗. Por
lo tanto, se utilizará un atributo heredado para pasar el operando al operador.
La gramática en este ejemplo es un extracto de una versión no recursiva por la izquierda
de la conocida gramática de expresiones; utilizamos dicha gramática como un ejemplo para
ilustrar el análisis sintáctico descendente en la sección 4.4.
PRODUCCIÓN
REGLAS
SEMÁNTICAS
her
sin
digito
her
sin
her
sin
sin
her
digito.valex
Figura 5.4: Una definición dirigida por la sintaxis basada en una gramática adecuada
para el análisis sintáctico de descendente
Cada uno de los no terminales T y F tiene un atributo sintetizado val; el terminal digito
tiene un atributo sintetizado valex. El no terminal T tiene dos atributos: un atributo heredado
her y un atributo sintetizado sin..
309
5.1 Definiciones dirigidas por la sintaxis
Las reglas semánticas se basan en la idea de que el operando izquierdo del operador ∗ es
heredado. Dicho en forma más precisa, el encabezado T de la producción T → ∗ F T 1 hereda
el operando izquierdo de ∗ en el cuerpo de la producción. Dado un término x ∗ y ∗ z, la raíz del
subárbol para ∗ y ∗ z hereda a x. Entonces, la raíz del subárbol para ∗ z hereda el valor de x ∗ y,
y así en lo sucesivo, si hay más factores en el término. Una vez que se han acumulado todos los
factores, el resultado se pasa de vuelta al árbol, mediante el uso de atributos sintetizados.
Para ver cómo se utilizan las reglas semánticas, considere el árbol de análisis sintáctico
anotado para 3 ∗ 5 en la figura 5.5. La hoja de más a la izquierda en el árbol de análisis sintáctico, etiquetada como digito, tiene el valor de atributo valex = 3, en donde el 3 lo suministra
el analizador léxico. Su padre es para la producción 4, F → digito. La única regla semántica
asociada con esta producción define a F.val = digito.valex, que es igual a 3.
her
sin
her
sin
digito.valex
digito.valex
Figura 5.5: Árbol de análisis sintáctico anotado para 3 ∗ 5
En el segundo hijo de la raíz, el atributo heredado T .her se define mediante la regla semántica T .her = F.val, asociada con la producción 1. Por ende, el operando izquierdo 3 para el
operador ∗ se pasa de izquierda a derecha, a lo largo de los hijos de la raíz.
La producción en el nodo para T es T → ∗FT 1 (retenemos el subíndice 1 en el árbol de
análisis sintáctico anotado para diferenciar entre los dos nodos para T ). El atributo heredado
T 1 .her se define mediante la regla semántica T 1 .her = T .her × F.val asociada con la producción 2.
Con T .her = 3 y F.val = 5, obtenemos T 1 .her = 15. En el nodo inferior para T 1 , la producción es T → . La regla semántica T .sin = T .her define T 1 .sin = 15. Los atributos sintetizados en los nodos para T pasan el valor 15 hacia arriba del árbol, hasta el nodo para T,
en donde T.val = 15.
2
5.1.3
Ejercicios para la sección 5.1
Ejercicio 5.1.1: En la definición dirigida por la sintaxis de la figura 5.1, proporcione árboles
de análisis sintáctico anotados para las siguientes expresiones:
a) (3 + 4) ∗ (5 + 6) n.
Capítulo 5. Traducción orientada por la sintaxis
310
b) 1 ∗ 2 ∗ 3 ∗ (4 + 5) n.
c) (9 + 8 ∗ (7 + 6) + 5) ∗ 4 n.
Ejercicio 5.1.2: Extienda la definición dirigida por la sintaxis de la figura 5.4 para manejar
las expresiones como en la figura 5.1.
Ejercicio 5.1.3: Repita el ejercicio 5.1.1, usando su definición dirigida por la sintaxis del
ejercicio 5.1.2.
5.2
Órdenes de evaluación para las definiciones
dirigidas por la sintaxis
Los “grafos de dependencias” son una herramienta útil en la determinación de un orden de
evaluación para las instancias de los atributos en un árbol de análisis sintáctico dado. Mientras
que un árbol de análisis sintáctico anotado muestra los valores de los atributos, un grafo de
dependencias nos ayuda a determinar cómo pueden calcularse esos valores.
En esta sección, además de los grafos de dependencias definiremos dos clases importantes
de definiciones dirigidas por la sintaxis: la definición dirigida por la sintaxis “con atributos sintetizados” y la definición dirigida por la sintaxis más general “con atributos heredados por la
izquierda”. Las traducciones especificadas por estas dos clases se adaptan bien con los métodos
de análisis sintáctico que hemos estudiado, y la mayoría de las transiciones que se encuentran
en la práctica pueden escribirse para conformarse a los requerimientos de por lo menos una de
estas clases.
5.2.1
Gráficos de dependencias
Un grafo de dependencias describe el flujo de información entre las instancias de atributos en
un árbol de análisis sintáctico específico; una flecha de una instancia de atributo a otra significa
que el valor de la primera se necesita para calcular la segunda. Las flechas expresan las restricciones que imponen las reglas semánticas. Dicho en forma más detallada:
• Para cada nodo del árbol de análisis sintáctico, por decir un nodo etiquetado mediante el
símbolo gramatical X, el grafo de dependencias tiene un nodo para cada atributo asociado
con X.
• Suponga que una regla semántica asociada con una producción p define el valor del atributo sintetizado A.b en términos del valor de X.c (la regla puede definir a A.b en términos de los demás atributos, aparte de X.c). Entonces, el grafo de dependencias tiene una
flecha desde X.c hasta A.b. Dicho en forma más precisa, en cada nodo N etiquetado como
A en el que se aplica la producción p, se crea una flecha que va al atributo b en N, desde
el atributo c en el hijo de N que corresponde a esta instancia del símbolo X en el cuerpo
de la producción.2
2
Como un nodo N puede tener varios hijos etiquetados como X, de nuevo suponemos que los subíndices diferencian
los usos del mismo símbolo en distintos lugares en la producción.
311
5.2 Órdenes de evaluación para las definiciones dirigidas por la sintaxis
• Suponga que una regla semántica asociada con una producción p define el valor del atributo heredado B.c en términos del valor de X.a. Entonces, el grafo de dependencias tiene
una flecha que va desde X.a hasta B.c. Para cada nodo N etiquetado como B, que corresponda a una ocurrencia de esta B en el cuerpo de la producción p, se crea una flecha que
va al atributo c en N, desde el atributo a en el nodo M que corresponde a esta ocurrencia
de X. Observe que M podría ser el padre o un hermano de N.
Ejemplo 5.4: Considere la siguiente producción y regla:
PRODUCCIÓN
E → E1 + T
REGLA SEMÁNTICA
E.val = E1.val + T.val
En cada nodo N etiquetado como E, con hijos que corresponden al cuerpo de esta producción,
el atributo sintetizado val en N se calcula usando los valores de val en los dos hijos, etiquetados
como E y T. Por ende, una parte del grafo de dependencias para cada árbol de análisis sintáctico en el cual se utilice esta producción, se verá como el de la figura 5.6. Como convención,
vamos a mostrar las flechas del árbol de análisis sintáctico como líneas punteadas, mientras que
las flechas del grafo de dependencias son sólidas. 2
Figura 5.6: E.val se sintetiza a partir de E1.val y E 2.val
Ejemplo 5.5: En la figura 5.7 aparece un ejemplo de un grafo de dependencias completo. Los
nodos del grafo de dependencias, que se representan mediante los números del 1 al 9, corresponden a los atributos en el árbol de análisis sintáctico anotado en la figura 5.5.
sin
her
digito
valex
her
digito
sin
valex
Figura 5.7: Grafo de dependencias para el árbol de análisis sintáctico anotado de la figura 5.5
Los nodos 1 y 2 representan el atributo valex asociado con las dos hojas etiquetadas como
digito. Los nodos 3 y 4 representan el atributo val asociado con los dos nodos etiquetados como F.
Capítulo 5. Traducción orientada por la sintaxis
312
Las flechas que van al nodo 3 desde 1 y al nodo 4 desde 2 resultan de la regla semántica que
define a F.val en términos de digito.valex. De hecho, F.val es igual a digito.valex, sólo que la
flecha representa la dependencia, no la igualdad.
Los nodos 5 y 6 representan el atributo heredado T .her asociado con cada una de las ocurrencias del no terminal T . La flecha de 5 a 3 se debe a la regla T .her = F.val, la cual define
a T .her en el hijo derecho de la raíz que parte de F.val en el hijo izquierdo. Vemos flechas
que van a 6 desde el nodo 5 para T .her, y desde el nodo 4 para F.val, ya que estos valores se
multiplican para evaluar el atributo her en el nodo 6.
Los nodos 7 y 8 representan el atributo sintetizado sin, asociado con las ocurrencias de T .
La flecha que va al nodo 7 desde 6 se debe a la regla semántica T .sin = T .her asociada con la
producción 3 en la figura 5.4. La flecha que va al nodo 8 desde 7 se debe a una regla semántica
asociada con la producción 2.
Por último, el nodo 9 representa el atributo T.val. La flecha que va a 9 desde 8 se debe a la
regla semántica T.val = T .sin, asociada con la producción 1. 2
5.2.2
Orden de evaluación
El grafo de dependencias caracteriza los órdenes posibles en los cuales podemos evaluar los
atributos en los diversos nodos de un árbol de análisis sintáctico. Si el grafo de dependencias
tiene una flecha que va del nodo M al nodo N, entonces el atributo correspondiente a M debe
evaluarse antes del atributo de N. Por ende, los únicos órdenes de evaluación permisibles son
aquellas secuencias de nodos N1, N2, …, Nk, de tal forma que si hay una flecha del grafo de
dependencias que va desde Ni hasta Nj, entonces i < j. Dicho ordenamiento incrusta un grafo
dirigido en un orden lineal, a lo cual se le conoce como orden topológico del grafo.
Si hay un ciclo en el grafo, entonces no hay órdenes topológicos; es decir, no hay forma de
evaluar la definición dirigida por la sintaxis en este árbol de análisis sintáctico. No obstante,
si no hay ciclos, entonces hay por lo menos un orden topológico. Para ver por qué, debido a
que no hay ciclos, de seguro podemos encontrar un nodo en el que no entren flechas. En caso
de que no hubiera dicho nodo, podríamos proceder de predecesor en predecesor hasta regresar
a algún nodo que ya hayamos visto, produciendo un ciclo. Debemos hacer que este nodo sea
el primero en el orden topológico, eliminarlo del grafo de dependencias y repetir el proceso en
los nodos restantes.
Ejemplo 5.6: El grafo de dependencias de la figura 5.7 no tiene ciclos. Un orden topológico es
el orden en el que ya se han numerado los nodos: 1, 2, …, 9. Observe que cada flecha del grafo
pasa de un nodo a otro de mayor numeración, por lo que sin duda este orden es topológico. Hay
otros órdenes topológicos también, como 1, 3, 5, 2, 4, 6, 7, 8, 9.
2
5.2.3
Definiciones con atributos sintetizados
Como dijimos antes, dada una definición dirigida por la sintaxis, es muy difícil saber si existen
árboles de análisis sintáctico cuyos grafo de dependencias tengan ciclos. En la práctica, las
traducciones pueden implementarse mediante el uso de clases de definiciones dirigidas por la
sintaxis que garanticen un orden de evaluación, ya que no permiten grafos de dependencias con
5.2 Órdenes de evaluación para las definiciones dirigidas por la sintaxis
313
ciclos. Además, las dos clases presentadas en esta sección pueden implementarse de manera
eficiente en conexión con el análisis sintáctico descendente o ascendente.
La primera clase se define de la siguiente manera:
• Una definición dirigida por la sintaxis tiene atributos sintetizados si todos los atributos
sintetizados son sintetizados.
Ejemplo 5.7: La definición dirigida por la sintaxis de la figura 5.1 es un ejemplo de una definición con atributos sintetizados. Cada atributo, L.val, E.val, T.val y F.val es sintetizado.
2
Cuando una definición dirigida por la sintaxis tiene atributos sintetizados, podemos evaluar sus atributos en cualquier orden de abajo hacia arriba de los nodos del árbol de análisis
sintáctico. A menudo, es bastante sencillo evaluar los atributos mediante la realización de un
recorrido postorden del árbol y evaluar los atributos en un nodo N cuando el recorrido sale de
N por última vez. Es decir, aplicamos la función postorden, que se define a continuación, a la
raíz del árbol de análisis sintáctico (vea también el recuadro “Recorridos preorden y postorden”
en la sección 2.3.4):
postorden(N ) {
for ( cada hijo C de N, empezando desde la izquierda ) postorden(C );
evaluar los atributos asociados con el nodo N;
}
Las definiciones de los atributos sintetizados pueden implementarse durante el análisis
sintáctico de ascendente, ya que un análisis sintáctico de este tipo corresponde a un recorrido
postorden. En forma específica, el postorden corresponde exactamente al orden en el que un
analizador sintáctico LR reduce el cuerpo de una producción a su encabezado. Este hecho se
utilizará en la sección 5.4.2 para evaluar los atributos sintetizados y almacenarlos en la pila
durante el análisis sintáctico LR, sin crear los nodos del árbol en forma explícita.
5.2.4
Definiciones con atributos heredados
A la segunda clase de definiciones dirigidas por la sintaxis se le conoce como definiciones con
atributos heredados. La idea de esta clase es que, entre los atributos asociados con el cuerpo de
una producción, las flechas del grafo de dependencias pueden ir de izquierda a derecha, pero no
al revés (de aquí que se les denomine “atributos heredados por la izquierda”). Dicho en forma
más precisa, cada atributo debe ser:
1. Sintetizado.
2. Heredado, pero con las reglas limitadas de la siguiente manera. Suponga que hay una
producción A → X1X2 ··· Xn, y que hay un atributo heredado Xi.a, el cual se calcula
mediante una regla asociada con esta producción. Entonces, la regla puede usar sólo:
(a) Los atributos heredados asociados con el encabezado A.
(b) Los atributos heredados o sintetizados asociados con las ocurrencias de los símbolos
X1, X2, …, Xi−1 ubicados a la izquierda de Xi.
Capítulo 5. Traducción orientada por la sintaxis
314
(c) Los atributos heredados o sintetizados asociados con esta misma ocurrencia de Xi,
pero sólo en una forma en la que no haya ciclos en un grafo de dependencias formado
por los atributos de esta Xi.
Ejemplo 5.8: La definición dirigida por la sintaxis en la figura 5.4 tiene atributos heredados
por la izquierda. Para ver por qué, considere las reglas semánticas para los atributos heredados,
que repetimos a continuación por conveniencia:
PRODUCCIÓN
T → F T
T → ∗ F T 1
REGLAS SEMÁNTICA
T .her = F.val
T 1.her = T .her × F.val
La primera de estas reglas define el atributo heredado T .her, usando sólo F.val y F aparece
a la izquierda de T en el cuerpo de la producción, según se requiera. La segunda regla define a
T 1 .her usando el atributo heredado T .her, asociado con el encabezado, y F.val, en donde F
aparece a la izquierda de T 1 en el cuerpo de la producción.
En cada uno de estos casos, las reglas utilizan la información “de arriba o de la izquierda”,
según lo requiera la clase. El resto de los atributos sintetizados son sintetizados. Por ende, la
definición dirigida por la sintaxis tiene atributos heredados por la izquierda.
2
Ejemplo 5.9: Cualquier definición dirigida por la sintaxis que contenga la siguiente producción y reglas no puede tener atributos heredados por la izquierda:
PRODUCCIÓN
A→ BC
REGLAS SEMÁNTICAS
A.s = B.b;
B.i = f (C.c, A.s)
La primera regla, A.s = B.b, es una regla legítima en una definición dirigida por la sintaxis
con atributos sintetizados o atributos heredados por la izquierda. Define a un atributo sintetizado A.s en términos de un atributo en un hijo (es decir, un símbolo dentro del cuerpo de la
producción).
La segunda regla define a un atributo heredado B.i, de forma que la definición dirigida por la
sintaxis completa no puede tener atributos sintetizados. Además, aunque la regla es legal, la definición dirigida por la sintaxis no puede tener atributos heredados por la izquierda, debido a que
el atributo C.c se utiliza para ayudar a definir B.i, y C está a la derecha de B en el cuerpo de la
producción. Aunque los atributos en los hermanos de un árbol de análisis sintáctico pueden usarse
en las definiciones dirigidas por la sintaxis con atributos heredados por la izquierda, deben estar a
la izquierda del símbolo cuyo atributo se está definiendo. 2
5.2.5
Reglas semánticas con efectos adicionales controlados
En la práctica, las traducciones involucran efectos adicionales: una calculadora de escritorio
podría imprimir un resultado; un generador de código podría introducir el tipo de un identificador en una tabla de símbolos. Con las definiciones dirigidas por la sintaxis, encontramos
un equilibrio entre las gramáticas de atributos y los esquemas de traducción. Las gramáticas
atribuidas no tienen efectos adicionales y permiten cualquier orden de evaluación consistente
con el grafo de dependencias. Los esquemas de traducción imponen la evaluación de izquierda
5.2 Órdenes de evaluación para las definiciones dirigidas por la sintaxis
315
a derecha y permiten que las acciones semánticas contengan cualquier fragmento del programa;
en la sección 5.4 hablaremos sobre los esquemas de traducción.
Controlaremos los efectos adicionales en las definiciones dirigidas por la sintaxis, en una de
las dos formas siguientes:
• Permitir los efectos adicionales incidentales que no restrinjan la evaluación de los atributos. En otras palabras, hay que permitir los efectos adicionales cuando la evaluación de
los atributos basada en cualquier orden topológico del grafo de dependencias produzca
una traducción “correcta”, en donde “correcta” depende de la aplicación.
• Restringir los órdenes de evaluación permitidos, de manera que se produzca la misma
traducción para cualquier orden permitido. Las restricciones pueden considerarse como
flechas implícitas agregadas al grafo de dependencias.
Como ejemplo de un efecto adicional incidental, vamos a modificar la calculadora de escritorio del ejemplo 5.1 para imprimir un resultado. En vez de la regla L.val = E.val, que almacena el resultado en el atributo sintetizado L.val, considere lo siguiente:
1)
PRODUCCIÓN
L→ En
REGLA SEMÁNTICA
print(E.val )
Las reglas semánticas que se ejecuten por sus efectos adicionales, como print(E.val), se tratarán
como las definiciones de los atributos falsos sintetizados, asociados con el encabezado de la
producción. La definición dirigida por la sintaxis modificada produce la misma traducción bajo
cualquier orden topológico, ya que la instrucción print se ejecuta al final, después de calcular
el resultado y colocarlo en E.val.
Ejemplo 5.10: La definición dirigida por la sintaxis en la figura 5.8 recibe una declaración D
simple, que consiste en un tipo T básico seguido de una lista L de identificadores. T puede ser
int o float. Para cada identificador en la lista, se introduce el tipo en la entrada en la tabla
de símbolos para ese identificador. Asumimos que al introducir el tipo para un identificador,
no se ve afectada la entrada en la tabla de símbolos de ningún otro identificador. Por ende,
las entradas pueden actualizarse en cualquier orden. Esta definición dirigida por la sintaxis no
verifica si un identificador se declara más de una vez; puede modificarse para ello.
PRODUCCIÓN
REGLAS
her
tipo
SEMÁNTICAS
tipo
tipo
her
her
agregarTipo(id.entrada, L.her)
agregarTipo(id.entrada, L.her)
Figura 5.8: Definición orientada por la sintaxis para las declaraciones de tipos simples
Capítulo 5. Traducción orientada por la sintaxis
316
La no terminal D representa una declaración que, a partir de la producción 1, consiste en un
tipo T seguido de una lista L de identificadores. T tiene un atributo, T.tipo, el cual es el tipo
en la declaración D. La no terminal L tiene también un atributo, al cual llamaremos her para
enfatizar que es un atributo heredado. El propósito de L.her es pasar el tipo declarado hacia
abajo en la lista de identificadores, de manera que pueda agregarse a las entradas apropiadas
en la tabla de símbolos.
Cada una de las producciones 2 y 3 evalúan el atributo sintetizado T.tipo, proporcionándole
el valor apropiado, integer o float. Este tipo se pasa al atributo L.her en la regla para la producción 1. La producción 4 pasa L.her hacia abajo en el árbol de análisis sintáctico. Es decir,
el valor L1.her se calcula en un nodo del árbol de análisis sintáctico mediante la copia del valor
de L.her del padre de ese nodo; el padre corresponde al encabezado de la producción.
Las producciones 4 y 5 también tienen una regla en la que se hace una llamada a la función
agregarTipo con dos argumentos:
1. id.entrada, un valor léxico que apunta a un objeto en la tabla de símbolos.
2. L.her, el tipo que se va a asignar a todos los identificadores en la lista.
Suponemos que la función agregarTipo instala en forma apropiada el tipo L.her como el tipo
del identificador representado.
En la figura 5.9 aparece un grafo de dependencias para la cadena de entrada float id1, id2,
id3. Los números del 1 al 10 representan los nodos del grafo de dependencias. Los nodos 1, 2 y 3
representan el atributo entrada asociado con cada una de las hojas etiquetadas como id. Los
nodos 6, 8 y 10 son los atributos falsos que representan la aplicación de la función agregarTipo
a un tipo y uno de estos valores entrada.
tipo
her
entrada
entrada
her
entrada
entrada
inh
entrada
entrada
Figura 5.9: Grafo de dependencias para una declaración float id1, id2, id3
El nodo 4 representa el atributo T.tipo, y en realidad es en donde empieza la evaluación de
atributos. Después, este tipo se pasa a los nodos 5, 7 y 9 que representan a L.her asociado con
cada una de las ocurrencias del no terminal L. 2
5.2 Órdenes de evaluación para las definiciones dirigidas por la sintaxis
5.2.6
317
Ejercicios para la sección 5.2
Ejercicio 5.2.1: ¿Cuáles son todos los órdenes topológicos para el grafo de dependencias de
la figura 5.7?
Ejercicio 5.2.2: Para la definición dirigida por la sintaxis de la figura 5.8, proporcione los
árboles de análisis sintáctico anotados para las siguientes expresiones:
a) int a, b, c.
b) float w, x, y, z.
Ejercicio 5.2.3: Suponga que tenemos una producción A → BCD. Cada una de los cuatro no
terminales A, B, C y D tienen dos atributos: s es un atributo sintetizado, e i es un atributo
heredado. Para cada uno de los siguientes conjuntos de reglas, indique si (i) las reglas son consistentes con una definición con atributos sintetizados, (ii ) las reglas son consistentes con una
definición con atributos heredados por la oizquierda, y (iii) si las reglas son consistentes con
algún orden de evaluación.
a) A.s = B.i + C.s.
b) A.s = B.i + C.s y D.i = A.i + B.s.
c) A.s = B.s + D.s.
! d) A.s = D.i, B.i = A.s + C.s, C.i = B.s y D.i = B.i + C.i.
! Ejercicio 5.2.4: Esta gramática genera números binarios con un punto “decimal”:
S→ L.L|L
L→ LB|B
B→ 0|1
Diseñe una definición dirigida por la sintaxis con atributos heredados por la izquierda para
calcular S.val, el valor de número decimal de una cadena de entrada. Por ejemplo, la traducción
de la cadena 101.101 debe ser el número decimal 5.625. Sugerencia: use un atributo heredado
L.lado que nos indique en qué lado del punto decimal se encuentra un bit.
!! Ejercicio 5.2.5: Diseñe una definición dirigida por la sintaxis con atributos sintetizados para
la gramática y la traducción descritas en el ejercicio 5.2.4.
!! Ejercicio 5.2.6: Implemente el Algoritmo 3.23, que convierte una expresión regular en un
autómata finito no determinista, mediante una definición dirigida por la sintaxis con atributos
heredados por la izquierda en una gramática analizable mediante el análisis sintáctico descendente. Suponga que hay un token car que representa a cualquier carácter, y que car.valex es
el carácter que representa. También puede suponer la existencia de una función nuevo() que
devuelva un nuevo estado, es decir, un estado que esta función nunca haya devuelto. Use cualquier notación conveniente para especificar las transiciones del AFN.
318
5.3
Capítulo 5. Traducción orientada por la sintaxis
Aplicaciones de la traducción orientada
por la sintaxis
Las técnicas de traducción orientada por la sintaxis de este capítulo se aplicarán en el capítulo 6
a la comprobación de tipos y la generación de código intermedio. Aquí consideraremos ejemplos
seleccionados para ilustrar algunas definiciones dirigidas por la sintaxis representativas.
La principal aplicación en esta sección es la construcción de árboles de análisis sintáctico.
Como algunos compiladores utilizan árboles sintácticos como una representación intermedia,
una forma común de definición dirigida por la sintaxis convierte su cadena de entrada en un
árbol. Para completar la traducción en código intermedio, el compilador puede recorrer el árbol
sintáctico, usando otro conjunto de reglas que son en realidad una definición dirigida por la sintaxis en el árbol sintáctico, en vez del árbol de análisis sintáctico. El capítulo 6 también habla
sobre los métodos para la generación de código intermedio que aplican una definición dirigida
por la sintaxis sin siquiera construir un árbol en forma explícita.
Vamos a considerar dos definiciones dirigidas por la sintaxis en la construcción de árboles
sintácticos para las expresiones. La primera, una definición con atributos sintetizados, es adecuada para usarse durante el análisis sintáctico ascendente. La segunda, con atributos heredados por la izquierda, es adecuada para usarse durante el análisis sintáctico descendente.
El ejemplo final de esta sección es una definición con atributos heredados a la izquierda que
maneja los tipos básicos y de arreglos.
5.3.1
Construcción de árboles de análisis sintáctico
Como vimos en la sección 2.8.2, cada nodo en un árbol sintáctico representa a una construcción; los hijos del nodo representan los componentes significativos de la construcción. Un nodo
de árbol sintáctico que representa a una expresión E1 + E 2 tiene la etiqueta + y dos hijos que
representan las subexpresiones E1 y E 2.
Vamos a implementar los nodos de un árbol sintáctico mediante objetos con un número
adecuado de campos. Cada objeto tendrá un campo op que es la etiqueta del nodo. Los objetos
tendrán los siguientes campos adicionales:
• Si el nodo es una hoja, un campo adicional contiene el valor léxico para esa hoja. Un constructor Hoja(op, val) crea un objeto hoja. De manera alternativa, si los nodos se ven como
registros, entonces Hoja devuelve un apuntador a un nuevo registro para una hoja.
• Si el nodo es interior, hay tantos campos adicionales como hijos que tiene el nodo en el
árbol sintáctico. Un constructor Nodo recibe dos o más argumentos: Nodo(op, c1, c2, …, ck)
crea un objeto con el primer campo op y k campos adicionales para los k hijos c1, …, ck.
Ejemplo 5.11: La definición con atributos sintetizados en la figura 5.10 construye árboles
sintácticos para una gramática de expresiones simple, que involucra sólo a los operadores binarios + y −. Como siempre, estos operadores tienen el mismo nivel de precedencia y ambos
son asociativos por la izquierda. Todos los no terminales tienen un atributo sintetizado llamado
nodo, el cual representa a un nodo del árbol sintáctico.
Cada vez que se utiliza la primera producción E → E1 + T, su regla crea un nodo con +
para op y dos hijos, E1.nodo y T.nodo, para las subexpresiones. La segunda producción tiene
una regla similar.
5.3 Aplicaciones de la traducción orientada por la sintaxis
PRODUCCIÓN
319
REGLAS SEMÁNTICAS
E.nodo = new Nodo(+, E1.nodo, T.nodo)
E.nodo = new Nodo(−, E1.nodo, T.nodo)
E.nodo = T.nodo
T.nodo = E.nodo
T.nodo = new Hoja(id, id.entrada)
T.nodo = new Hoja(num, num.val)
Figura 5.10: Construcción de árboles sintácticos para expresiones simples
Para la producción 3, E → T, no se crea ningún nodo, ya que E.nodo es igual que T.nodo.
De manera similar, no se crea ningún nodo para la producción 4, T → ( E ). El valor de T.nodo es
igual que E.nodo, ya que los paréntesis sólo se utilizan para agrupar; tienen influencia sobre la
estructura del árbol de análisis sintáctico y del árbol en sí, pero una vez que termina su trabajo,
no hay necesidad de retenerlos en el árbol sintáctico.
Las últimas dos producciones T tienen un solo terminal a la derecha. Utilizamos el constructor Hoja para crear un nodo adecuado, el cual se convierte en el valor de T.nodo.
La figura 5.11 muestra la construcción de un árbol sintáctico para la entrada a − 4 + c. Los
nodos del árbol sintáctico se muestran como registros, con el campo op primero. Las flechas del
árbol sintáctico ahora se muestran como líneas sólidas. El árbol de análisis sintáctico subyacente, que en realidad no necesita construirse, se muestra con flechas punteadas. El tercer tipo de
línea, que se muestra discontinua, representa los valores de E.nodo y T.nodo; cada línea apunta
al nodo apropiado del árbol sintáctico.
En la parte inferior podemos ver hojas para a, 4 y c, construidas por Hoja. Suponemos
que el valor léxico id.entrada apunta a la tabla de símbolos, y que el valor léxico num.val es
el valor numérico de una constante. Estas hojas, o los apuntadores a ellas, se convierten en el
valor de T.nodo en los tres nodos del árbol sintáctico etiquetados como T, de acuerdo con las
reglas 5 y 6. Observe que en base a la regla 3, el apuntador a la hoja para a es también el valor
de E.nodo para la E de más a la izquierda en el árbol de análisis sintáctico.
La regla 2 ocasiona la creación de un nodo con op igual al signo negativo y los apuntadores
a las primeras dos hojas. Después, la regla 1 produce el nodo raíz del árbol sintáctico, mediante
la combinación del nodo para – con la tercera hoja.
Si las reglas se evalúan durante un recorrido postorden del árbol de análisis sintáctico, o con
reducciones durante un análisis sintáctico ascendente, entonces la secuencia de pasos que se muestra en la figura 5.12 termina con p5 apuntando a la raíz del árbol sintáctico construido. 2
Con una gramática diseñada para el análisis sintáctico descendente se construyen los mismos árboles sintácticos, mediante la misma secuencia de pasos, aun cuando la estructura de los
árboles de análisis sintáctico difiere de manera considerable de la de los árboles sintácticos.
Ejemplo 5.12: La definición con atributos heredados por la izquierda en la figura 5.13 realiza
la misma traducción que la definición con atributos sintetizados en la figura 5.10. Los atributos
para los símbolos gramaticales E, T, id y num son como vimos en el ejemplo 5.11.
Capítulo 5. Traducción orientada por la sintaxis
320
E.nodo
E.nodo
T.nodo
T.nodo
E.nodo
T.nodo
entrada para c
entrada para a
Figura 5.11: Árbol sintáctico para a − 4 + c
1)
2)
3)
4)
5)
p1
p2
p3
p4
p5
=
=
=
=
=
new
new
new
new
new
Hoja(id, entrada-a);
Hoja(num, 4);
Nodo(−, p1, p2);
Hoja(id, entrada-c);
Nodo(+, p3, p4);
Figura 5.12: Pasos en la construcción del árbol sintáctico para a − 4 + c
Las reglas para construir árboles sintácticos en este ejemplo son similares a las reglas para
la calculadora de escritorio en el ejemplo 5.3. En este ejemplo se evalúo un término x ∗ y, pasando x como un atributo heredado, ya que x y ∗ y aparecían en distintas porciones del árbol
de análisis sintáctico. Aquí, la idea es construir un árbol sintáctico para x + y, pasando x como
un atributo heredado, ya que x y + y aparecen en distintos subárboles. El no terminal E es la
contraparte del no terminal T en el ejemplo 5.3. Compare el grafo de dependencias para a −
4 + c en la figura 5.14, con el grafo para 3 ∗ 5 en la figura 5.7.
El no terminal E tiene un atributo heredado her y un atributo sintetizado sin. El atributo
E .her representa el árbol sintáctico parcial que se ha construido hasta ahora. En forma específica, representa la raíz del árbol para el prefijo de la cadena de entrada que se encuentra a
la izquierda del subárbol para E . En el nodo 5 en el grafo de dependencias de la figura 5.14,
E .her denota la raíz del árbol sintáctico parcial para el identificador a; es decir, la hoja para a.
En el nodo 6, E .her denota la raíz para el árbol sintáctico parcial para la entrada a − 4. En
el nodo 9, E .her denota el árbol sintáctico para a − 4 + c.
321
5.3 Aplicaciones de la traducción orientada por la sintaxis
PRODUCCIÓN
REGLAS SEMÁNTICAS
E.nodo = E .sin
E .her = T.nodo
E 1 .her = new Nodo(+, E .her, T.nodo)
E .sin = E 1 .sin
E 1 .her = new nodo(−, E .her, T.nodo)
E .sin = E 1 .sin
E .sin = E .her
T.nodo = E.nodo
T.nodo = new Hoja(id, id.entrada)
T.nodo = new Hoja(num, num.entrada)
Figura 5.13: Construcción de árboles sintácticos durante el análisis sintáctico descendente
nodo
nodo
sin
her
entrada
nodo
sin
her
nodo
her
sin
entrada
Figura 5.14: Gráfico de dependencias para a − 4 + c, con la definición dirigida
por la sintaxis de la figura 5.13
Como no hay más entrada, en el nodo 9, E .her apunta a la raíz de todo el árbol sintáctico.
Los atributos sintetizados pasan este valor de vuelta hacia arriba del árbol de análisis sintáctico, hasta que se convierte en el valor de E.nodo. De manera específica, el valor del atributo
en el nodo 10 se define mediante la regla E .sin = E .her asociada con la producción E → .
El valor del atributo en el nodo 11 se define mediante la regla E .sin = E 1 .sin asociada con la
producción 2 en la figura 5.13. Hay reglas similares que definen los valores de los atributos en
los nodos 12 y 13.
2
5.3.2
La estructura de tipos
Los atributos heredados son útiles cuando la estructura del árbol de análisis sintáctico difiere
de la sintaxis abstracta de la entrada; entonces, los atributos pueden usarse para llevar información de una parte del árbol de análisis sintáctico a otra. El siguiente ejemplo muestra cómo
Capítulo 5. Traducción orientada por la sintaxis
322
un conflicto en la estructura puede deberse al diseño del lenguaje, y no a las restricciones que
impone el método de análisis sintáctico.
Ejemplo 5.13: En C, el tipo int [2][3] puede leerse como “arreglo de 2 arreglos de 3 enteros”.
La expresión del tipo correspondiente arreglo(2, arreglo(3, integer)) se representa mediante el
árbol en la figura 5.15. El operador arreglo recibe dos parámetros, un número y un tipo. Si
los tipos se representan mediante árboles, entonces este operador devuelve un nodo de árbol
etiquetado como arreglo, con dos hijos para el número y el tipo.
arreglo
arreglo
integer
Figura 5.15: Expresión de los tipos para int[2][3]
Con la definición dirigida por la sintaxis en la figura 5.16, el no terminal T genera un tipo
básico o un tipo arreglo. El no terminal B genera uno de los tipos básicos int y float. T genera
un tipo básico cuando T deriva a B C y C deriva a . En cualquier otro caso, C genera componentes de un arreglo que consisten en una secuencia de enteros, en donde cada entero está
rodeado por llaves.
PRODUCCIÓN
REGLAS SEMÁNTICAS
arreglo
Figura 5.16: T genera un tipo básico o un tipo de arreglo
Los no terminales B y T tienen un atributo sintetizado t que representa un tipo. El no terminal C tiene dos atributos: un atributo heredado b y un atributo sintetizado t. Los atributos b
heredados pasan un tipo básico hacia abajo del árbol, y las atributos t sintetizados acumulan
el resultado.
En la figura 5.17 se muestra un árbol de análisis sintáctico anotado para la cadena de entrada int[2][3]. La expresión de tipo correspondiente en la figura 5.15 se construye pasando el
tipo integer desde B, hacia abajo por la cadena de Cs y a través de los atributos heredados b.
El tipo arreglo se sintetiza hacia arriba por la cadena de Cs, a través de los atributos t.
Con más detalle, en la raíz para T → B C, el no terminal C hereda el tipo de B, usando el
atributo heredado C.b. En el nodo de más a la derecha para C, la producción es C → , por lo
que C.t es igual a C.b. Las reglas semánticas para la producción C → [ num ] C1 forman C.t
mediante la aplicación del operador arreglo a los operandos num.val y C1.t.
2
323
5.4 Esquemas de traducción orientados por la sintaxis
arreglo(2, arreglo(3, integer))
arreglo(2, arreglo(3, integer))
arreglo(3, integer)
Figura 5.17: Traducción orientada por la sintaxis de los tipos de arreglos
5.3.3
Ejercicios para la sección 5.3
Ejercicio 5.3.1: A continuación se muestra una gramática para expresiones, en la que se involucra el operador + y los operandos de entero o punto flotante. Los números de punto flotante
se diferencian debido a que tienen un punto decimal.
E → E+T|T
T → num . num | num
a) Proporcione una definición dirigida por la sintaxis para determinar el tipo de cada término T y cada expresión E.
b) Extienda su definición dirigida por la sintaxis de (a) para traducir expresiones a la notación postfijo. Use el operador unario intToFloat para convertir un entero en un número
equivalente de punto flotante.
! Ejercicio 5.3.2: Proporcione una definición dirigida por la sintaxis para traducir las expresiones infijo con + y ∗ en expresiones equivalentes sin paréntesis redundantes. Por ejemplo, como
ambos operadores asocian por la izquierda, y ∗ tiene precedencia sobre +, ((a∗(b+c))∗(d)) se
traduce en a ∗ (b + c) ∗ d.
! Ejercicio 5.3.3: Proporcione una definición dirigida por la sintaxis para diferenciar expresiones como x ∗ (3 ∗ x + x ∗ x), en las que se involucren los operadores + y ∗, la variable x y
constantes. Suponga que no ocurre ninguna simplificación, de forma que, por ejemplo, 3 ∗ x se
traducirá en 3 ∗ 1 + 0 ∗ x.
5.4 Esquemas de traducción orientados por la sintaxis
Los esquemas de traducción orientados por la sintaxis son una notación complementaria para las
definiciones dirigidas a la sintaxis. Todas las aplicaciones de las definiciones dirigidas por la sintaxis
en la sección 5.3 pueden implementarse mediante el uso de esquemas de traducción orientados por
la sintaxis.
324
Capítulo 5. Traducción orientada por la sintaxis
En la sección 2.3.5 vimos que un esquema de traducción orientado por la sintaxis (esquema
de traducción orientado a la sintaxis) es una gramática libre de contexto, con fragmentos de
programa incrustados dentro de los cuerpos de las producciones. A estos fragmentos se les
conoce como acciones semánticas y pueden aparecer en cualquier posición dentro del cuerpo
de una producción. Por convención, colocamos llaves alrededor de las acciones; si las llaves se
necesitan como símbolos gramaticales, entonces las encerramos entre comillas.
Podemos implementar cualquier esquema de traducción orientado a la sintaxis, construyendo primero un árbol de análisis sintáctico y después realizando las acciones en un orden de
izquierda a derecha, con búsqueda en profundidad; es decir, durante un recorrido preorden. En
la sección 5.4.3 aparece un ejemplo.
Por lo general, los esquemas de traducción orientados a la sintaxis se implementan durante
el análisis sintáctico, sin construir un árbol de análisis sintáctico. En esta sección nos enfocaremos en el uso de esquemas de traducción orientados a la sintaxis para implementar dos clases
importantes de definiciones dirigidas por la sintaxis:
1. Puede utilizarse el análisis sintáctico LR en la gramática subyacente, y la definición dirigida por la sintaxis tiene atributos sintetizados.
2. Puede utilizarse el análisis sintáctico LL en la gramática subyacente, y la definición dirigida por la sintaxis tiene atributos heredados por la izquierda.
Más adelante veremos cómo en ambos casos, las reglas semánticas en una definición dirigida
por la sintaxis pueden convertirse en un esquema de traducción orientado a la sintaxis con acciones que se ejecuten en el momento adecuado. Durante el análisis sintáctico, una acción en el
cuerpo de una producción se ejecuta tan pronto como los símbolos gramaticales a la izquierda
de la acción tengan coincidencias.
Los esquemas de traducción orientados a la sintaxis que pueden implementarse durante el
análisis sintáctico se caracterizan mediante la introducción de no terminales marcadores en lugar
de cada acción incrustada; cada marcador M sólo tiene una producción, M → . Si la gramática
con no terminales marcadores puede analizarse mediante un método dado, entonces el esquema
de traducción orientado a la sintaxis puede implementarse durante el análisis sintáctico.
5.4.1
Esquemas de traducción postfijos
Hasta ahora, la implementación de definiciones dirigidas por la sintaxis más simple ocurre
cuando podemos analizar sintácticamente la gramática de abajo hacia arriba, y la definición
dirigida por la sintaxis tiene atributos sintetizados. En ese caso, podemos construir un esquema
de traducción orientado a la sintaxis en el cual cada acción se coloque al final de la producción
y se ejecute junto con la reducción del cuerpo para el encabezado de esa producción. Los esquemas de traducción orientados a la sintaxis con todas las acciones en los extremos derechos de los
cuerpos de las producciones se llaman esquemas de traducción orientados a la sintaxis postfijo.
Ejemplo 5.14: El esquema de traducción orientado a la sintaxis postfijo en la figura 5.18
implementa la definición dirigida por la sintaxis de la calculadora de escritorio de la figura 5.1,
con un cambio: la acción para la primera producción imprime un valor. El resto de las acciones
son contrapartes exactas de las reglas semánticas. Como la gramática subyacente es LR, y la
definición dirigida por la sintaxis tiene atributos sintetizados, estas acciones pueden realizarse
en forma correcta, junto con los pasos de reducción del analizador sintáctico.
2
325
5.4 Esquemas de traducción orientados por la sintaxis
imprimir
digito
digito.valex; }
Figura 5.18: Implementación de un esquema de traducción orientado
a la sintaxis postfijo de la calculadora de escritorio
5.4.2
Implementación de esquemas de traducción orientados
a la sintaxis postfijo con la pila del analizador sintáctico
Los esquemas de traducción orientados a la sintaxis postfijo pueden implementarse durante el
análisis sintáctico LR mediante la ejecución de las acciones cuando ocurren las reducciones.
El (los) atributo(s) de cada símbolo gramatical puede(n) colocarse en la pila, en un lugar en el
que puedan encontrarse durante la reducción. El mejor plan es colocar los atributos junto con
los símbolos gramaticales (o con los estados LR que representan estos símbolos) en registros
en la misma pila.
En la figura 5.19, la pila del analizador sintáctico contiene registros con un campo para un
símbolo gramatical (o estado del analizador sintáctico) y, debajo de él, un campo para un atributo. Los tres símbolos gramaticales X Y Z están en la parte superior de la pila; tal vez estén
a punto de reducirse en base a una producción como A → X Y Z. Aquí, mostramos X.x como
el único atributo de X, y así en lo sucesivo. En general, podemos permitir más atributos, ya sea
haciendo los registros lo bastante extensos o colocando apuntadores a registros en la pila. Con
atributos pequeños, puede ser más simple hacer los registros lo bastante grandes, aun cuando
algunos campos no se utilizan la mayor parte del tiempo. No obstante, si uno o más atributos
sintetizados son de un tamaño sin límite (por decir, si son cadenas de caracteres), entonces sería
mejor colocar un apuntador al valor del atributo en el registro de la pila y almacenar el valor
real en alguna área de almacenamiento compartido más extensa, que no forme parte de la pila.
Estado/símbolo gramatical
Atributo(s) sintetizado(s)
tope
Figura 5.19: La pila del analizador sintáctico, con un campo para los atributos sintetizados
Si los atributos sintetizados son todos sintetizados, y las acciones ocurren en los extremos
de las producciones, entonces podemos calcular los atributos para el encabezado cuando reduzcamos el cuerpo al encabezado. Si reducimos mediante una producción como A → X Y Z,
entonces tenemos todos los atributos de X, Y y Z disponibles, en posiciones conocidas de la
pila, como en la figura 5.19. Después de la acción, A y sus atributos sintetizados se encuentran
en la parte superior de la pila, en la posición del registro para X.
Capítulo 5. Traducción orientada por la sintaxis
326
Ejemplo 5.15: Vamos a rescribir las acciones del esquema de traducción orientado a la sintaxis de la calculadora de escritorio del ejemplo 5.14, de manera que manipulen la pila del
analizador sintáctico en forma explícita. Por lo general, el analizador sintáctico es el que realiza
en forma automática la manipulación de la pila.
PRODUCCIÓN
L→En
ACCIONES
{ imprimir(pila[tope − 1].val);
tope = tope − 1; }
E → E1 + T
{ pila[tope − 2].val = pila[tope − 2].val + pila[tope].val;
tope = tope − 2; }
E→T
T → T1 ∗ F
T→F
F→(E)
{ pila[tope − 2].val = pila[tope − 2].val × pila[tope].val;
tope = tope − 2; }
{ pila[tope − 2].val = pila[tope − 1].val;
tope = tope − 2; }
F → digito
Figura 5.20: Implementación de la calculadora de escritorio en una pila de análisis
sintáctico ascendente
Suponga que la pila se mantiene en un arreglo de registros llamado pila, en donde tope es
un cursor para la parte superior de la pila. Por ende, pila[tope] se refiere al registro superior en
la pila, pila[superior − 1] al registro debajo de éste, y así en lo sucesivo. Además, suponemos
que cada registro tiene un campo llamado val, el cual contiene el atributo de cualquier símbolo
gramatical que se represente en ese registro. Por ende, podemos referirnos al atributo E.val
que aparece en la tercera posición en la pila como pila[tope − 2].val. El esquema de traducción
orientado por la sintaxis completo se muestra en la figura 5.20.
Por ejemplo, en la segunda producción, E → E1 + T, recorremos dos posiciones debajo de
la parte superior para obtener el valor de E1, y encontramos el valor de T en superiores tope.
La suma resultante se coloca en donde debe aparecer el encabezado E después de la reducción;
es decir, dos posiciones debajo del tope actual. La razón es que, después de la reducción, los
tres símbolos de la parte del tope de la pila se sustituyen por uno solo. Después de calcular
E.val, sacamos dos símbolos del tope de la pila, por lo que el registro en donde colocamos E.val
ahora estará en el tope de la pila.
En la tercera producción, E → T, no es necesaria ninguna acción ya que la longitud de la
pila no cambia, y el valor de T.val en el tope de la pila simplemente se convertirá en el valor de
E.val. La misma observación se aplica a las producciones T → F y F → digito. La producción
F → ( E ) es un poco distinta. Aunque el valor no cambia, dos posiciones se eliminan de la
pila durante la reducción, por lo que el valor tiene que avanzar a la posición que está después
de la reducción.
Observe que hemos omitido los pasos que manipulan el primer campo de los registros de la
pila; el campo que proporciona el estado LR o, en cualquier otro caso, que representa el símbolo
gramatical. Si vamos a realizar un análisis sintáctico LR, la tabla de análisis sintáctico nos in-
5.4 Esquemas de traducción orientados por la sintaxis
327
dica cuál es el nuevo estado cada vez que realizamos una reducción; vea el algoritmo 4.44. Por
ende, simplemente podemos colocar el estado en el registro para el nuevo tope de la pila. 2
5.4.3
Esquemas de traducción orientados a la sintaxis
con acciones dentro de las producciones
Puede colocarse una acción en cualquier posición dentro del cuerpo de una producción. Ésta
se ejecuta de inmediato, después de procesar todos los símbolos a su izquierda. Por ende, si
tenemos una producción B → X {a } Y, la acción a se realiza después de que reconozcamos a
X (si X es un terminal) o todos los terminales derivados de X (si X es un no terminal). Dicho
en forma más precisa,
• Si el análisis sintáctico es ascendente, entonces ejecutamos la acción a tan pronto como
aparezca esta ocurrencia de X en el tope de la pila de análisis sintáctico.
• Si el análisis sintáctico es descendente, ejecutamos la acción a justo antes de tratar de
expandir esta ocurrencia de Y (si Y es una no terminal) o comprobamos a Y en la entrada
(si Y es una terminal).
Los esquemas de traducción orientados a la sintaxis que pueden implementarse durante el
análisis sintáctico incluyen a los esquemas de traducción orientados a la sintaxis postfijos y a
una clase de esquemas de traducción orientados a la sintaxis que veremos en la sección 5.5, los
cuales implementan definiciones con atributos heredados por la izquierda. No todos los esquemas de traducción orientados a la sintaxis pueden implementarse durante el análisis sintáctico,
como veremos en el siguiente ejemplo.
Ejemplo 5.16: Como un ejemplo extremo de un esquema de traducción orientado a la sintaxis
problemático, suponga que convertimos nuestro ejemplo de una calculadora de escritorio en un
esquema de traducción orientado a la sintaxis que imprime la forma prefijo de una expresión, en
vez de evaluar esa expresión. En la figura 5.21 se muestran las producciones y las acciones.
digito {print(digito.valex); }
Figura 5.21: Esquema de traducción orientado a la sintaxis problemático para
la traducción de infijo a postfijo durante el análisis sintáctico
Por desgracia, es imposible implementar este esquema de traducción orientado a la sintaxis
durante el análisis sintáctico descendente o ascendente, debido a que el analizador sintáctico
tendría que realizar acciones críticas, como imprimir instancias de ∗ o +, mucho antes de saber
si estos símbolos aparecerán en su entrada.
Usando los no terminales como marcadores M2 y M4 para las acciones en las producciones
2 y 4, respectivamente, en la entrada 3 un analizador sintáctico de desplazamiento-reducción
(vea la sección 4.5.3) tiene conflictos entre reducir mediante M2 → , reducir mediante M4 → ,
o desplazar el dígito.
2
Capítulo 5. Traducción orientada por la sintaxis
328
Cualquier esquema de traducción orientado a la sintaxis puede implementarse de la siguiente manera:
1. Ignorando las acciones, se analiza sintácticamente la entrada y se produce un árbol de
análisis sintáctico como resultado.
2. Después se examina cada nodo interior N, por ejemplo, uno para la producción A → α. Se
agregan hijos adicionales a N para las acciones en α, para que los hijos de N de izquierda
a derecha tengan exactamente los símbolos y las acciones de α.
3. Realizar un recorrido en preorden (vea la sección 2.3.4) del árbol, y tan pronto como se
visite un nodo etiquetado por una acción, realizar esa acción.
Por ejemplo, la figura 5.22 muestra el árbol de análisis sintáctico para la expresión 3 ∗ 5 + 4
con acciones insertadas. Si visitamos los nodos en preorden, obtenemos la forma prefijo de la
expresión: + ∗ 3 5 4.
digito
digito
digito
Figura 5.22: Árbol de análisis sintáctico con acciones incrustadas
5.4.4
Eliminación de la recursividad por la izquierda
de los esquemas de traducción orientados a la sintaxis
Como ninguna gramática con recursividad por la izquierda puede analizarse sintácticamente en
forma determinista, de forma descendente, en la sección 4.3.3 examinamos la eliminación de la
recursividad por la izquierda. Cuando la gramática forma parte de un esquema de traducción
orientado a la sintaxis, también debemos preocuparnos de cómo se manejan las acciones.
En primer lugar, considere el caso simple, en donde lo único que nos importa es el orden en
el que se realizan las acciones en un esquema de traducción orientado a la sintaxis. Por ejemplo,
si cada acción sólo imprime una cadena, sólo nos preocupa el orden en el que se imprimen las
cadenas. En este caso, el siguiente principio puede servirnos de guía:
• Al transformar la gramática, trate a las acciones como si fueran símbolos terminales.
5.4 Esquemas de traducción orientados por la sintaxis
329
Este principio se basa en la idea de que la transformación gramatical preserva el orden de los
terminales en la cadena generada. Por lo tanto, las acciones se ejecutan en el mismo orden
en cualquier análisis sintáctico de izquierda a derecha, de arriba hacia abajo o de abajo hacia
arriba.
El “truco” para eliminar la recursividad por la izquierda es tomar dos producciones:
A → Aα | β
que generen cadenas que consisten en una β y cualquier número de αs, y sustituirlas por producciones que generen las mismas cadenas, usando un nuevo no terminal R (de “resto”) de la
primera producción:
A → βR
R → αR | Si β no empieza con A, entonces A ya no tiene una producción recursiva por la izquierda. En
términos de las definiciones regulares, con ambos conjuntos de producciones, A se define mediante β(α)∗. En la sección 4.3.3 podrá consultar la sección sobre el manejo de situaciones en
las que A tiene más producciones recursivas o no recursivas.
Ejemplo 5.17: Considere las siguientes producciones E de un esquema de traducción orientado a la sintaxis, para traducir expresiones infijo a la notación postfijo:
E → E1 + T { print(+); }
E → T
Si aplicamos la transformación estándar a E, el resto de la producción recursiva por la izquierda es:
α = + T { print(+): }
y β, el cuerpo de la otra producción es T. Si introducimos R para el resto de E, obtenemos el
siguiente conjunto de producciones:
E → TR
R → + T { print(+); } R
R → 2
Cuando las acciones de una definición dirigida por la sintaxis calculan atributos en vez de
sólo imprimir la salida, debemos tener más cuidado en la forma de eliminar la recursividad
por la izquierda de una gramática. No obstante, si la definición dirigida por la sintaxis tiene
atributos sintetizados, entonces siempre podremos construir un esquema de traducción orientado a la sintaxis colocando las acciones de cálculo de atributos en posiciones apropiadas en
las producciones nuevas.
Vamos a ver un esquema general para el caso de una sola producción recursiva, una sola
producción no recursiva, y un solo atributo del no terminal recursiva por la izquierda; la generalización a muchas producciones de cada tipo no es difícil, pero en cuanto a notación es
compleja. Suponga que las dos producciones son:
330
Capítulo 5. Traducción orientada por la sintaxis
A → A1 Y {A.a = g(A1.a,Y.y)}
A → X {A.a = f (X.x)}
Aquí, A.a es el atributo sintetizado del no terminal A recursivo por la izquierda, y X y Y son
símbolos gramaticales individuales con los atributos sintetizados X.x y Y.y, respectivamente.
Éstos podrían representar a una cadena de varios símbolos gramaticales, cada uno con su(s)
propio(s) atributo(s), ya que el esquema tiene una función arbitraria g que calcula a A.a en la
producción recursiva, y una función arbitraria f que calcula a A.a en la segunda producción. En
cada caso, f y g reciben como argumentos los atributos a los que tengan permitido el acceso, si
la definición dirigida por la sintaxis tiene atributos sintetizados.
Queremos convertir la gramática subyacente en:
A → XR
R → YR | La figura 5.23 sugiere lo que debe hacer el esquema de traducción orientado a la sintaxis con la
nueva gramática. En (a) podemos ver el efecto del esquema de traducción orientado a la sintaxis
postfijo sobre la gramática original. Aplicamos f una vez, que corresponde al uso de la producción
A → X, y después aplicamos g todas las veces que utilicemos la producción A → A Y. Como R
genera un “residuo” de Ys, su traducción depende de la cadena a su izquierda, una cadena de
la forma XYY ···Y. Cada uso de la producción R → Y R resulta en una aplicación de g. Para
R, utilizamos un atributo heredado R.i para acumular el resultado de aplicar la función g en
forma sucesiva, empezando con el valor de A.a.
Figura 5.23: Eliminación de la recursividad por la izquierda de un esquema
de traducción orientado a la sintaxis postfijo
Además, R tiene un atributo sintetizado R.s, el cual no se muestra en la figura 5.23. Este
atributo se calcula primero cuando R termina su generación de símbolos Y, como se indica
mediante el uso de la producción R → . Después, R.s se copia hacia arriba en el árbol, para
que pueda convertirse en el valor de A.a para toda la expresión XYY ···Y. El caso en el que A
genera a XYY se muestra en la figura 5.23, y podemos ver que el valor de A.a en la raíz de (a)
tiene dos usos de g. Lo mismo pasa con R.i en la parte inferior del árbol (b), y es el valor de
R.s el que se copia hacia arriba de ese árbol.
Para lograr esta traducción, utilizamos el siguiente esquema de traducción orientado a la
sintaxis:
5.4 Esquemas de traducción orientados por la sintaxis
331
A → X {R.i = f (X.x)} R {A.a = R.s}
R → Y {R1.i = g(R.i,Y.y)} R1 {R.s = R1.s}
R → {R.s = R.i}
Observe que el atributo heredado R.i se evalúa de inmediato, antes de usar R en el cuerpo,
mientras que los atributos sintetizados A.a y R.s se evalúan al final de las producciones. Por
ende, cualquiera que sean los valores que se tengan que calcular, estos atributos estarán disponibles en lo que se haya calculado a la izquierda.
5.4.5
Esquemas de traducción orientados a la sintaxis para
definiciones con atributos heredados por la izquierda
En la sección 5.4.1 vimos la conversión de definiciones dirigidas por la sintaxis con atributos sintetizados en esquemas de traducción orientados a la sintaxis postfijo, con acciones en los extremos
derechos de las producciones. Siempre y cuando la gramática subyacente sea LR, los Esquemas de traducción orientados a la sintaxis postfijos podrán analizarse sintácticamente y traducirse
de abajo hacia arriba.
Ahora, vamos a considerar el caso más general de un definición dirigida por la sintaxis con
atributos heredados por la izquierda. Vamos a suponer que la gramática subyacente puede
analizarse de arriba hacia abajo, porque de lo contrario sería con frecuencia imposible realizar
la traducción en conexión con un analizador sintáctico LL o LR. Con cualquier gramática, la
técnica antes mencionada puede implementarse adjuntando las acciones a un árbol de análisis
sintáctico y ejecutándolas durante el recorrido preorden del árbol.
A continuación se muestran las reglas para convertir una definición dirigida por la sintaxis
con atributos heredados por la izquierda en un esquema de traducción orientado a la sintaxis:
1. Incrustar la acción que calcule los atributos heredados para un no terminal A, justo después de esa ocurrencia de A en el cuerpo de la producción. Si varios atributos heredados
para A dependen uno del otro en forma acíclica, ordenar la evaluación de los atributos,
de manera que los que se necesiten primero sean los que se calculen primero.
2. Colocar las acciones que calculan un atributo sintetizado para el encabezado de una
producción, al final del cuerpo de esa producción.
Vamos a ilustrar estos principios con dos ejemplos completos. El primero involucra a la composición tipográfica. Muestra cómo pueden usarse las técnicas de compilación en el procesamiento de lenguajes, para aplicaciones distintas a las que consideramos, por lo general, como lenguajes
de programación. El segundo ejemplo trata acerca de la generación de código intermedio para
una construcción común en un lenguaje de programación: una forma de instrucción while.
Ejemplo 5.18: Este ejemplo está inspirado en los lenguajes para las fórmulas matemáticas
de la composición tipográfica. Eqn es uno de los primeros ejemplos de dicho lenguaje; las ideas de
Eqn se siguen utilizando en el sistema de composición tipográfica TEX, que se utilizó para producir este libro.
Vamos a concentrarnos sólo en la capacidad de definir subíndices, subíndices de subíndices, y así
en lo sucesivo, ignorando los superíndices, las fracciones y subfracciones y todas las demás características matemáticas. En el lenguaje Eqn, escribimos a sub i sub j para establecer la expresión a i j .
Una gramática simple para los cuadros (elementos de texto delimitados por un rectángulo) es:
Capítulo 5. Traducción orientada por la sintaxis
332
B → B1 B2 | B1 sub B2 | ( B1 ) | texto
De manera correspondiente a estas cuatro producciones, un cuadro puede ser:
1. Dos cuadros yuxtapuestos, con la primera producción B1, a la izquierda de la otra producción B2.
2. Un cuadro y un cuadro de subíndice. El segundo cuadro aparece en un tamaño más pequeño, más abajo y a la derecha del primer cuadro.
3. Un cuadro entre paréntesis, para agrupar cuadros y subíndices. Eqn y TEX utilizan llaves
para agrupar, pero nosotros usaremos paréntesis redondos ordinarios para evitar que se
confundan con las llaves que rodean a las acciones en los esquemas de traducción orientados a la sintaxis.
4. Una cadena de texto; es decir, cualquier cadena de caracteres.
Esta gramática es ambigua, pero aún podemos usarla para el análisis sintáctico ascendente, si
hacemos al subíndice y la yuxtaposición asociativos a la derecha, en donde sub tenga mayor
precedencia que la yuxtaposición.
Se aplicará la composición tipográfica a las expresiones mediante la construcción de cuadros
más grandes que delimiten a los más pequeños. En la figura 5.24, se va a crear una yuxtaposición
entre los cuadros para E1 y .altura, para formar el cuadro para E1.altura. El cuadro izquierdo
para E1 se construye a partir del cuadro para E y el subíndice 1. Para manejar el subíndice 1,
se reduce su cuadro aproximadamente un 30%, se baja y se coloca después del cuadro para E.
Aunque debemos tratar a .altura como una cadena de texto, los rectángulos dentro de su cuadro
muestran cómo puede construirse a partir de los cuadros para las letras individuales.
altura
profundidad
E1
. height
altura
profundidad
Figura 5.24: Construcción de cuadros más grandes, a partir de cuadros más pequeños
En este ejemplo, nos concentramos sólo en la geometría vertical de los cuadros. La geometría horizontal (la anchura de los cuadros) también es interesante, en especial cuando caracteres
distintos tienen anchuras diferentes. Tal vez no sea aparente a la vista, pero cada uno de los
caracteres distintos en la figura 5.24 tienen anchura diferente.
A continuación se describen los valores asociados con la geometría vertical de los cuadros:
a) El tamaño de punto se utiliza para establecer el texto dentro de un cuadro. Vamos a
suponer que los caracteres que no están en subíndices se establecen en un tipo de punto
10, el tamaño del tipo en este libro. Además, si un cuadro tiene el tamaño de punto p,
entonces su cuadro de subíndice tiene el tamaño de punto más pequeño 0.7p. El atributo
heredado B.tp representará el tamaño de punto del bloque B. Este atributo debe ser heredado, ya que el contexto determina cuánto necesita reducirse un cuadro dado, debido
al número de niveles de subíndices.
5.4 Esquemas de traducción orientados por la sintaxis
333
b) Cada cuadro tiene una línea base, la cual es una posición vertical que corresponde a la
parte inferior de las líneas de texto, sin contar las letras, como “g” que se extiende debajo de la línea base normal. En la figura 5.24, la línea punteada representa la línea base
para los cuadros E, .altura y toda la expresión completa. La línea base para el cuadro
que contiene el subíndice 1 se ajusta para bajar el subíndice.
c) Un cuadro tiene una altura, que es la distancia a partir de la parte superior del cuadro
hasta la línea base. El atributo sintetizado B.al proporciona la altura del cuadro B.
d) Un cuadro tiene una profundidad, que es la distancia a partir de la línea base, hasta la
parte inferior del cuadro. El atributo sintetizado B.pr proporciona la profundidad del
cuadro B.
La definición dirigida por la sintaxis en la figura 5.25 nos proporciona las reglas para calcular los tamaños de punto, las alturas y las profundidades. La producción 1 se utiliza para asignar
B.tp como el valor inicial 10.
PRODUCCIÓN
REGLAS SEMÁNTICAS
B.tp = 10
B1.tp = B.tp
B2.tp = B.tp
B.al = max(B1.al, B2.al)
B.pr = max(B1.pr, B2.pr)
B1.tp = B.tp
B2.tp = 0.7 × B.tp
B.al = max(B1.al, B2.al − 0.25 × B.tp)
B.pr = max(B1.pr, B2.pr + 0.25 × B.tp)
B1.tp = B.tp
B.al = B1.al
B.pr = B1.pr
texto
B.al = obtenerAl(B.tp, texto.valex)
B.pr = obtenerPr(B.tp, texto.valex)
Figura 5.25: Definición dirigida por la sintaxis para la composición tipográfica de los cuadros
La producción 2 maneja la yuxtaposición. Los tamaños de los puntos se copian hacia abajo
del árbol de análisis sintáctico; es decir, dos subcuadros de un cuadro heredan el mismo tamaño de
punto del cuadro más grande. Las alturas y las profundidades se calculan hacia arriba del árbol,
tomando el máximo. Esto es, la altura del cuadro más grande es el máximo de las alturas de sus
dos componentes, y esto es igual para la profundidad.
La producción 3 maneja el uso de subíndices, y es la más sutil. En este ejemplo bastante
simplificado, asumimos que el tamaño de punto de un cuadro con subíndice es 70% del tamaño
de punto de su padre. La realidad es mucho más compleja, ya que los subíndices no se pueden
Capítulo 5. Traducción orientada por la sintaxis
334
reducir en forma indefinida; en la práctica, después de unos cuantos niveles, los tamaños de los
subíndices casi no se reducen. Además, suponemos que la línea base de un cuadro de subíndice
se reduce un 25% del tamaño de punto del padre; de nuevo, la realidad es más compleja.
La producción 4 copia los atributos en forma apropiada, cuando se utilizan paréntesis. Por
último, la producción 5 maneja las hojas que representan los cuadros de texto. También en
esta cuestión, la situación verdadera es complicada, por lo que sólo mostramos dos funciones
sin especificaciones llamadas obtenerAl y obtenerPr, las cuales examinan las tablas creadas con
cada fuente para determinar la máxima altura y la máxima profundidad de cualquier carácter
en la cadena de texto. Se supone que se proporciona la misma cadena como el atributo valex
de el terminal texto.
Nuestra última tarea es convertir esta definición dirigida por la sintaxis en un esquema
de traducción orientado a la sintaxis, siguiendo las reglas para una definición dirigida por la
sintaxis con atributos heredados por la izquierda, y la figura 5.25 es de este tipo. El esquema
de traducción orientado a la sintaxis apropiado se muestra en la figura 5.26. Por cuestión de
legibilidad, como los cuerpos de las producciones se vuelven extensos, los dividimos entre varias
líneas y alineamos las acciones. Por lo tanto, los cuerpos de las producciones consisten en el
contenido de todas las líneas, hasta el encabezado de la siguiente producción.
2
PRODUCCIÓN
ACCIONES
{ B.tp = 10; }
{ B1.tp = B.tp; }
{ B2.tp = B.tp; }
{ B.al = max(B1.al, B2.al);
B.pr = max(B1.pr, B2.pr); }
{ B1.tp = B.tp; }
{ B2.tp = 0.7 × B.tp; }
{ B.al = max(B1.al, B2.al − 0.25 × B.tp);
B.pr = max(B1.pr, B2.pr + 0.25 × B.tp); }
{ B1.tp = B.tp; }
{ B.al = B1.al;
B.pr = B1.pr; }
texto
{ B.al = obtenerAl (B.tp, texto.valex);
B.pr = obtenerPr (B.tp, texto.valex); }
Figura 5.26: Esquema de traducción orientado a la sintaxis para la composición tipográfica
de los cuadros
Nuestro siguiente ejemplo se concentra en una instrucción while simple y la generación de
código intermedio para este tipo de instrucción. El código inmediato se tratará como un atributo con valor de cadena. Más adelante, exploraremos técnicas que involucran la escritura de
piezas de un atributo con valor de cadena a medida que realizamos el análisis sintáctico, con lo
cual evitamos la copia de cadenas extensas, para construir cadenas aún más largas. La técnica
5.4 Esquemas de traducción orientados por la sintaxis
335
se presentó en el ejemplo 5.17, en donde generamos la forma postfija de una expresión infija “al
instante”, en vez de calcularla como un atributo. No obstante, en nuestra primera formulación,
creamos un atributo con valor de cadena mediante la concatenación.
Ejemplo 5.19: Para este ejemplo, sólo necesitamos una producción:
S → while ( C ) S1
Aquí, S es el no terminal que genera todo tipo de instrucciones, que supuestamente incluye instrucciones if, instrucciones de asignación y otras. En este ejemplo, C representa a una expresión
condicional: una expresión booleana que se evalúa como verdadera o falsa.
En este ejemplo de flujo de control, lo único que vamos a generar son etiquetas. Se asume
que todas las demás instrucciones de código intermedio se van a generar mediante partes del
esquema de traducción orientado a la sintaxis que no se muestran. En forma específica, generaremos instrucciones explícitas de la forma etiqueta L, en donde L es un identificador, para
indicar que L es la etiqueta de la instrucción que le sigue. Asumimos que el código intermedio
es como el que se presentó en la sección 2.8.4.
El propósito de nuestra instrucción while es que se evalúe la C condicional. Si es verdadera,
el control pasa al principio del código para S1. Si es falsa, entonces el control pasa al código que
sigue del código de la instrucción while. El código para S1 debe diseñarse de manera que salte
al principio del código para la instrucción while al terminar; el salto al principio del código que
evalúa a C no se muestra en la figura 5.27.
Utilizamos los siguientes atributos para generar el código intermedio apropiado:
1. El atributo heredado S.siguiente etiqueta el principio del código que debe ejecutarse una
vez que S termine.
2. El atributo sintetizado S.codigo es la secuencia de pasos de código intermedio que implementa a una instrucción S y termina con un salto a S.siguiente.
3. El atributo heredado C.true etiqueta el principio del código que debe ejecutarse si C
es verdadera.
4. El atributo heredado C.false etiqueta el principio del código que debe ejecutarse si C es
falsa.
5. El atributo sintetizado C.codigo es la secuencia de pasos de código intermedio que implementa a la condición C, y salta a C.true o a C.false, dependiendo de si C es verdadera o
falsa.
La definición dirigida por la sintaxis que calcula estos atributos para la instrucción while se
muestra en la figura 5.27. Hay varios puntos que ameritan una explicación:
• La función new genera nuevas etiquetas.
• Las variables L1 y L2 contienen etiquetas que necesitamos en el código. L1 es el principio
del código para la instrucción while, y tenemos que arreglar que S1 salte ahí una vez que
termine. Ésta es la razón por la cual establecemos S1.siguiente a L1. L2 es el principio del
código para S1, y se convierte en el valor de C.true, debido a que bifurcamos hacia allá
cuando C es verdadera.
Capítulo 5. Traducción orientada por la sintaxis
336
S → while (C ) S1
L1 = new();
L2 = new()
S1.siguiente = L1;
C.false = S.siguiente;
C.true = L2;
S.codigo = etiqueta || L1 || C.codigo || etiqueta || L2 || S1.codigo
Figura 5.27: Esquema de definición dirigida por la sintaxis para las instrucciones while
• Observe que C.false se establece a S.siguiente, ya que cuando la condición es falsa, ejecutamos el código que vaya después del código para S.
• Utilizamos || como el símbolo de concatenación de los fragmentos de código intermedio.
Por lo tanto, el valor de S.codigo empieza con la etiqueta L1, después el código para la
condición C, otra etiqueta L2 y el código para S1.
Esta definición dirigida por la sintaxis tiene atributos heredados por la izquierda. Al convertirla en un esquema de traducción orientado a la sintaxis, lo único que queda pendiente es
cómo manejar las etiquetas L1 y L2, que son variables y no atributos. Si tratamos a las acciones como no terminales falsos, entonces dichas variables pueden tratarse como los atributos
sintetizados de los no terminales falsos. Como L1 y L2 no dependen de ningún otro atributo,
pueden asignarse a la primera acción en la producción. En la figura 5.28 se muestra el esquema de traducción orientado a la sintaxis resultante con acciones incrustadas que implementa a
esta definición con atributos heredados por la izquierda.
2
S → while (
C)
S1
{ L1 = new(); L2 = new(); C.false = S.siguiente; C.true = L2; }
{ S1.siguiente = L1; }
{ S.codigo = etiqueta || L1 || C.codigo || etiqueta || L2 || S1.codigo; }
Figura 5.28: Esquema de traducción orientado a la sintaxis para las instrucciones while
5.4.6
Ejercicios para la sección 5.4
Ejercicio 5.4.1: En la sección 5.4.2 mencionamos que es posible deducir, a partir del estado
LR en la pila de análisis sintáctico, cuál es el símbolo gramatical que se representa mediante el
estado. ¿Cómo descubriríamos esta información?
Ejercicio 5.4.2: Reescriba el siguiente esquema de traducción orientado a la sintaxis:
A → A {a} B | A B {b} | 0
B → B {c } A | B A {d} | 1
de manera que la gramática subyacente quede sin recursividad por la izquierda. Aquí, a, b, c y d
son acciones, y 0 y 1 son no terminales.
5.5 Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la izquierda
337
! Ejercicio 5.4.3: El siguiente esquema de traducción orientado a la sintaxis calcula el valor de
una cadena de 0s y 1s, interpretada como un entero binario positivo.
B →
|
|
B1 0 {B.val = 2 × B1.val }
B1 1 {B.val = 2 × B1.val + 1}
1 {B.val = 1}
Reescriba este esquema de traducción orientado a la sintaxis, de manera que la gramática
subyacente no sea recursiva por la izquierda, y de todas formas se calcule el mismo valor de
B.val para toda la cadena de entrada completa.
! Ejercicio 5.4.4: Escriba definiciones dirigidas por la sintaxis con atributos heredados por la
izquierda, que sean análogas a las del ejemplo 5.19 para las siguientes producciones, cada una
de las cuales representa a una construcción de flujo de control conocida, como en el lenguaje de
programación C. Tal vez deba generar una instrucción de tres direcciones para saltar hacia una
etiqueta L específica, en cuyo caso debe generar goto L.
a) S
→ if ( C ) S1 else S2
b) S → do S1 while ( C )
c) S → { L {; L →
LS|
Observe que cualquier instrucción en la lista puede tener un salto desde su parte media
hasta la siguiente instrucción, por lo que no basta con sólo generar código para cada
instrucción en orden.
Ejercicio 5.4.5: Convierta cada una de sus definiciones dirigidas por la sintaxis del ejercicio
5.4.4 en un esquema de traducción orientado a la sintaxis, como en el ejemplo 5.19.
Ejercicio 5.4.6: Modifique la definición dirigida por la sintaxis de la figura 5.25 para incluir
un atributo sintetizado B.le, la longitud de un cuadro. La longitud de la concatenación de dos
cuadros es la suma de las longitudes de cada uno. Después agregue sus nuevas reglas a las posiciones apropiadas en el esquema de traducción orientado a la sintaxis de la figura 5.26.
Ejercicio 5.4.7: Modifique la definición dirigida por la sintaxis de la figura 5.25 para incluir
superíndices denotados por el operador sup entre los cuadros. Si el cuadro B2 es un superíndice
del cuadro B1, entonces posicione la línea base de B2 a 0.6 veces el tamaño de punto de B1 encima de la línea base de B1. Agregue la nueva producción y las reglas al esquema de traducción
orientado a la sintaxis de la figura 5.26.
5.5
Implementación de definiciones dirigidas por la
sintaxis con atributos heredados por la izquierda
Debido a que pueden tratarse muchas aplicaciones de traducción mediante el uso de definiciones con atributos heredados por la izquierda, vamos a considerar su implementación con más
detalle en esta sección. Los siguientes métodos realizan la traducción mediante el recorrido de
un árbol de análisis sintáctico:
338
Capítulo 5. Traducción orientada por la sintaxis
1. Construir el árbol de análisis sintáctico y anotar. Este método funciona para cualquier
definición dirigida por la sintaxis acíclica en cualquier caso. En la sección 5.1.2 vimos los
árboles de análisis sintáctico anotados.
2. Construir el árbol de análisis sintáctico, agregar las acciones y ejecutarlas en preorden.
Este método funciona para cualquier definición con atributos heredados por la izquierda.
En la sección 5.4.5 vimos cómo convertir una definición dirigida por la sintaxis con atributos heredados por la izquierda en un esquema de traducción orientado a la sintaxis; en
especial, hablamos sobre cómo incrustar las acciones en producciones, de acuerdo con las
reglas semánticas de dicha definición dirigida por la sintaxis.
En esta sección, hablaremos sobre los siguientes métodos para traducir durante el análisis
sintáctico:
3. Usar un analizador de descenso recursivo con una función para cada no terminal. La
función para el no terminal A recibe los atributos heredados de A como argumentos, y
devuelve los atributos sintetizados de A.
4. Generar código al instante, mediante el uso de un analizador sintáctico de descenso recursivo.
5. Implementar un esquema de traducción orientado a la sintaxis en conjunto con un analizador sintáctico LL. Los atributos sintetizados se mantienen en la pila de análisis sintáctico, y las reglas obtienen los atributos necesarios de ubicaciones conocidas en la pila.
6. Implementar un esquema de traducción orientado a la sintaxis en conjunto con un analizador sintáctico LR. Este método puede sorprenderle, ya que por lo general el esquema
de traducción orientado a la sintaxis para una definición dirigida por la sintaxis con
atributos heredados por la izquierda tiene acciones en medio de las producciones, y no
podemos estar seguros que durante un análisis sintáctico LR nos encontremos siquiera
en esa producción, sino hasta que se haya construido todo su cuerpo. Sin embargo, más
adelante veremos que si la gramática subyacente es LL, siempre podremos manejar tanto
el análisis como la traducción de abajo hacia arriba.
5.5.1
Traducción durante el análisis sintáctico de descenso recursivo
Un analizador sintáctico de descenso recursivo tiene una función A para cada no terminal A,
como vimos en la sección 4.4.1. Podemos extender el analizador sintáctico en un traductor, de
la siguiente manera:
a) Los argumentos de la función A son los atributos heredados del no terminal A.
b) El valor de retorno de la función A es la colección de atributos sintetizados del no terminal A.
En el cuerpo de la función A, debemos analizar y manejar los atributos:
1. Decidir la producción que utilizaremos para expandir A.
2. Comprobar que cada terminal aparezca en la entrada cuando se le requiera. Debemos
asumir que no es necesario el rastreo hacia atrás, pero la extensión al análisis sintáctico
de descenso recursivo con este rastreo puede llevarse a cabo mediante la restauración de
la posición de entrada al momento de la falla, como vimos en la sección 4.4.1.
5.5 Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la izquierda
339
3. Preservar en las variables locales los valores de todos los atributos necesarios para calcular los atributos heredados para los no terminales en el cuerpo, o los atributos sintetizados para el no terminal del encabezado.
4. Llamar a las funciones correspondientes a los no terminales en el cuerpo de la producción
seleccionada, proporcionándoles los argumentos apropiados. Como la definición dirigida
por la sintaxis subyacente tiene atributos heredados por la izquierda, ya hemos calculado
estos atributos y los almacenamos en variables locales.
Ejemplo 5.20: Vamos a considerar la definición dirigida por la sintaxis y el esquema de traducción orientado a la sintaxis del ejemplo 5.19 para las instrucciones while. En la figura 5.29
aparece una muestra en seudocódigo de las partes relevantes de la función S.
cadena S(etiqueta siguiente) {
cadena Scodigo, Ccodigo; /* las variables locales contienen fragmentos de código */
etiqueta L1, L2; /* las etiquetas locales */
if ( entrada actual == token while ) {
avanzar entrada;
comprobar que siga ( en la entrada, y avanzar;
L1 = new();
L2 = new();
Ccodigo = C(siguiente, L2);
comprobar que siga ( en la entrada, y avanzar;
Scodigo = S(L1);
return("etiqueta" || L1 || Ccodigo || "etiqueta" || L2 || Scodigo);
}
else /* otros tipos de instrucciones */
}
Figura 5.29: Implementación de instrucciones while con un analizador sintáctico de descenso
recursivo
Mostramos a S para almacenar y devolver cadenas largas. En la práctica, sería mucho más
eficiente para las funciones como S y C devolver apuntadores a registros que representen estas
cadenas. Así, la instrucción de retorno en la función S no concatenaría físicamente los componentes mostrados, sino que construiría un registro, o tal vez un árbol de registros, expresando
la concatenación de las cadenas representadas por Scodigo y Ccodigo, las etiquetas L1 y L2, y
las dos ocurrencias de la cadena literal "etiqueta". 2
Ejemplo 5.21: Ahora vamos a tomar el esquema de traducción orientado a la sintaxis de la
figura 5.26 para cuadros de composición tipográfica. Primero señalaremos el análisis sintáctico,
ya que la gramática subyacente en la figura 5.26 es ambigua. La siguiente gramática transformada hace a la yuxtaposición y al uso de subíndices asociativos a la derecha, en donde sub
tiene precedencia sobre la yuxtaposición:
Capítulo 5. Traducción orientada por la sintaxis
340
S →
B →
T →
F →
B
T B1 | T
F sub T1 | F
( B ) | texto
Los dos nuevos no terminales, T y F, se motivan mediante términos y factores en las expresiones. Aquí, un “factor” generado por F es un cuadro entre paréntesis o una cadena de texto.
Un “término” generado por T es un “factor” con una secuencia de subíndices, y un cuadro
generado por B es una secuencia de “términos” yuxtapuestos.
Los atributos de B se transportan hacia T y F, ya que los nuevas no terminales también
denotan cuadros; se introdujeron sólo para ayudar en el análisis sintáctico. Por ende, tanto T
como F tienen un atributo heredado tp, además de los atributos heredados al y pr, con acciones semánticas que pueden adaptarse del esquema de traducción orientado a la sintaxis en la
figura 5.26.
La gramática aún no está lista para el análisis sintáctico descendente, ya que las producciones para B y T tienen prefijos comunes. Por ejemplo, considere a T. Un analizador sintáctico
descendente no puede elegir una de las dos producciones para T con sólo ver un símbolo por
adelantado en la entrada. Por fortuna, podemos usar una forma de factorización por la izquierda, descrita en la sección 4.3.4, para dejar lista la gramática. Con los esquemas de traducción
orientados a la sintaxis, la noción de prefijo común se aplica a las acciones también. Ambas
producciones para T empiezan con el no terminal F, que hereda el atributo tp de T.
El seudocódigo en la figura 5.30 para T(tp) se mezcla con el código para F(tp). Después
de aplicar la factorización por la izquierda a T → F sub T1 | F, sólo hay una llamada a F; el
seudocódigo muestra el resultado de sustituir el código para F en vez de esta llamada.
La llamada a la función T se hace como T(10.0) por medio de la función para B, la cual no
mostramos. Devuelve un par que consiste en la altura y la profundidad del cuadro generado por
el no terminal T ; en la práctica, devolvería un registro que contiene la altura y la profundidad.
La función T empieza comprobando si hay un paréntesis izquierdo, en cuyo caso debe tener
la producción F → ( B ) para trabajar con ella. Guarda lo que devuelva la B dentro de los paréntesis, pero si esa B no va seguida de un paréntesis derecho, entonces hay un error de sintaxis,
el cual debe manejarse de una forma que no se muestra.
En cualquier otro caso, si la entrada actual es texto, entonces la función T utiliza obtenerAl
y obtenerPr para determinar la anchura y la profundidad de este texto.
Después, T decide si el siguiente cuadro es un subíndice o no, y ajusta el tamaño del punto,
en caso de que sí sea. Utilizamos las acciones asociadas con la producción B → B sub B en la
figura 5.26 para la altura y la profundidad del cuadro más grande. En cualquier otro caso, sólo
devolvemos lo que F hubiera devuelto: (a1, p1).
2
5.5.2
Generación de código al instante
La construcción de cadenas largas de código que son valores de atributos, como en el ejemplo
5.20, no es apropiado por varias razones, incluyendo el tiempo requerido para copiar o mover
cadenas largas. En casos comunes como nuestro ejemplo de generación de código, podemos en
su lugar generar piezas en forma incremental del código y colocarlas en un arreglo o archivo
de salida, mediante la ejecución de las acciones en un esquema de traducción orientado a la
sintaxis. Los elementos que necesitamos para hacer funcionar esta técnica son:
5.5 Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la izquierda
341
(float, float) T (float tp) {
float a1, a2, p1, p2; /* variables locales que guardan alturas y profundidades */
/* código inicial para F(tp) */
if ( entrada actual == ( ) {
avanzar entrada;
(a1, p1) = B(tp);
if (entrada actual != ) ) error de sintaxis: se esperaba );
avanzar entrada;
}
else if ( entrada actual == texto ) {
hacer que el valor léxico de texto.valex sea igual a t;
avanzar entrada;
a1 = obtenerAl(tp, t);
p1 = obtenerPr(tp, t);
}
else error de sintaxis: se esperaba texto o (;
/* fin del código para F(tp) */
if ( entrada actual == sub ) {
avanzar entrada;
(a2, p2) = T (0.7 * tp);
return (max(a1, a2 – 0.25 * tp), max(p1, p2 + 0.25 * tp));
}
return (a1, p1);
}
Figura 5.30: Composición tipográfica de los cuadros con descenso recursivo
1. Para una o más no terminales existe un atributo principal. Por conveniencia, vamos a suponer que todos los atributos principales tienen valor de cadena. En el ejemplo 5.20, los atributos sintetizados S.codigo y C.codigo son atributos principales; los demás atributos no lo son.
2. Los atributos principales están sintetizados.
3. Las reglas que evalúan el (los) atributo(s) principal(es) aseguran que:
(a) El atributo principal sea la concatenación de los atributos principales de los no terminales que aparecen en el cuerpo de la producción involucrada, tal vez con otros
elementos que no sean atributos principales, como la cadena etiqueta o los valores
de las etiquetas L1 y L2.
(b) Los atributos principales de los no terminales aparecen en la regla, en el mismo orden que los mismos no terminales aparecen en el cuerpo de la producción.
Como consecuencia de las condiciones antes mencionadas, el atributo principal puede construirse mediante la emisión de los elementos de la concatenación que no son atributos principales. Podemos confiar en las llamadas recursivas a las funciones para los no terminales en un
cuerpo de producción, para emitir el valor de su atributo principal en forma incremental.
342
Capítulo 5. Traducción orientada por la sintaxis
El tipo de los atributos principales
Nuestra suposición de simplificación de que los atributos principales son de tipo cadena
es en realidad demasiado restrictiva. El verdadero requerimiento es que el tipo de todos
los atributos principales debe tener valores que puedan construirse mediante la concatenación de elementos. Por ejemplo, una lista de objetos de cualquier tipo sería apropiada,
siempre y cuando representemos estas listas de una forma que permita adjuntar en forma
eficiente los elementos al final de la lista. Por ende, si el propósito del atributo principal es
representar una secuencia de instrucciones de código intermedio, podríamos producir este
código intermedio escribiendo instrucciones al final de un arreglo de objetos. Desde luego
que los requerimientos declarados en la sección 5.5.2 se siguen aplicando a las listas; por
ejemplo, los atributos principales deben ensamblarse a partir de otros atributos principales, mediante la concatenación en orden.
Ejemplo 5.22: Podemos modificar la función de la figura 5.29 para emitir los elementos de la
traducción principal S.codigo, en vez de guardarlos para la concatenación en un valor de retorno de S.codigo. La función S modificada aparece en la figura 5.31.
void S(etiqueta siguiente) {
etiqueta L1, L2; /* las etiquetas locales */
if ( entrada actual == token while ) {
avanzar entrada;
comprobar que siga ( en la entrada, y avanzar;
L1 = new();
L2 = new();
print("etiqueta", L1);
C(siguiente, L2);
comprobar que siga ) en la entrada, y avanzar;
print("etiqueta", L2);
S(L1);
}
else /* otros tipos de instrucciones */
}
Figura 5.31: Generación de código de descenso recursivo al instante para instrucciones while
En la figura 5.32, S y C no tienen ahora valor de retorno, ya que sus únicos atributos sintetizados se producen mediante la impresión. Además, la posición de las primeras instrucciones
es considerable. El orden en el que se imprime la salida es: primero etiqueta L1, después el
código para C (que es igual que el valor de Ccodigo en la figura 5.29), después etiqueta L2, y
por último el código de la llamada recursiva por S (que es igual que Scodigo en la figura 5.29).
Por ende, el código que imprime esta llamada a S es exactamente el mismo que el valor de
Scodigo que se devuelve en la figura 5.29). 2
5.5 Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la izquierda
343
De paso, podemos realizar el mismo cambio al esquema de traducción orientado a la sintaxis subyacente: convertir la construcción de un atributo principal en acciones que emitan los
elementos de ese atributo. En la figura 5.32 podemos ver el esquema de traducción orientado a
la sintaxis de la figura 5.28, modificado para generar código al instante.
S
→ while (
C)
S1
{ L1 = new(); L2 = new(); C.false = S.siguiente;
C.true = L2; print("etiqueta", L1); }
{ S1.siguiente = L1; print("etiqueta", L2); }
Figura 5.32: Esquema de traducción orientado a la sintaxis para la generación de código
al instante, para instrucciones while
5.5.3
Las definiciones dirigidas por la sintaxis con atributos
heredados por la izquierda y el análisis sintáctico LL
Suponga que una definición dirigida por la sintaxis con atributos heredados por la izquierda
está basada en una gramática LL, y que la hemos convertida en un esquema de traducción
orientado a la sintaxis con acciones incrustadas en las producciones, como se describe en la
sección 5.4.5. De esta forma, podemos realizar la traducción durante el análisis sintáctico LL,
extendiendo la pila del analizador sintáctico para guardar las acciones y ciertos elementos de
datos necesarios para la evaluación de los atributos. Por lo general, los elementos de datos son
copias de los atributos.
Además de los registros que representan terminales y no terminales, la pila del analizador
sintáctico contiene registros de acción, que representan las acciones que van a ejecutarse, y registros de sintetizado para guardar los atributos sintetizados para los no terminales. Utilizamos
los siguientes dos principios para manejar los atributos en la pila:
• Los atributos heredados de una no terminal A se colocan en el registro de la pila que
representa a ese no terminal. El código para evaluar esos atributos por lo general se representa mediante un registro de acción, justo encima del registro de pila para A; de hecho,
la conversión de las definiciones dirigidas por la sintaxis con atributos heredados por la
izquierda a esquemas de traducción orientados a la sintaxis asegura que el registro de acción se encuentre de inmediato encima de A.
• Los atributos sintetizados para un no terminal A se colocan en un registro de sintetizado
separado, el cual se encuentra justo debajo del registro para A en la pila.
Esta estrategia coloca registros de varios tipos en la pila de análisis sintáctico, confiando en
que estos tipos de registros variantes pueden manejarse en forma apropiada como subclases
de una clase “registro de pila”. En la práctica podríamos combinar varios registros en uno,
pero tal vez las ideas se expliquen mejor al separar los datos utilizados para distintos fines en
distintos registros.
Los registros de acción contienen apuntadores al código que se va a ejecutar. Las acciones
también pueden aparecer en los registros sintetizados; estas acciones por lo general colocan
copias del (los) atributo(s) sintetizado(s) en otros registros más abajo en la pila, en donde el
344
Capítulo 5. Traducción orientada por la sintaxis
valor de ese atributo se requerirá una vez que se saquen de la pila el registro sintetizado y sus
atributos.
Vamos a examinar brevemente el análisis sintáctico LL, para ver la necesidad de crear
copias temporales de los atributos. En la sección 4.4.4 vimos que un analizador sintáctico LL
controlado por una tabla imita a una derivación de más a la izquierda. Si w es la entrada que
ha coincidido hasta ahora, entonces la pila contiene una secuencia de símbolos gramaticales α,
∗ w α, en donde S es el símbolo inicial. Cuando el analizador sintáctico se exde forma que S ⇒
lm
pande mediante una producción A → B C, sustituye a A por B C en el tope de la pila.
Suponga que el no terminal C tiene un atributo heredado C.i. Con A → B C, el atributo
heredado C.i puede depender no sólo de los atributos heredados de A, sino de todos los atributos
de B. Por ende, tal vez sea necesario procesar a B por completo, antes de poder evaluar a C.i.
Entonces, guardamos copias temporales de todos los atributos necesarios para evaluar a C.i en
el registro de acción que evalúa a C.i. En cualquier otro caso, cuando el analizador sintáctico
sustituye a A por B C en la parte superior de la pila, los atributos heredados de A habrán
desaparecido, junto con su registro de pila.
Como la definición dirigida por la sintaxis subyacente tiene atributos heredados por la izquierda, podemos estar seguros de que los valores de los atributos heredados de A están disponibles cuando A pasa a la parte superior de la pila. Por ende, los valores estarán disponibles a
tiempo para copiarlos en el registro de acción que evalúa los atributos heredados de C. Además,
el espacio para los atributos sintetizados de A no es un problema, ya que el espacio está en el
registro de sintetizado para A, que permanece en la pila, debajo de B y C, cuando el analizador
sintáctico expande mediante A → B C.
A medida que se procesa B, podemos realizar acciones (a través de un registro justo encima de
B en la pila) para copiar sus atributos heredados de manera que los utilice C según sea necesario,
y después de procesar a B el registro de sintetizado para B puede copiar sus atributos sintetizados
para que los use C, en caso de ser necesario. De igual forma, tal vez los atributos sintetizados de
A necesiten valores temporales para ayudar a calcular su valor, y éstos pueden copiarse al registro
de sintetizado para A, a medida que se procesa B y después C. El principio que hace que funcione
todo este proceso de copiado de atributos es el siguiente:
• Todo el proceso de copiado se lleva a cabo entre los registros que se crean durante una
expansión de un no terminal. Así, cada uno de estos registros sabe qué tan abajo en la
pila se encuentra cada uno de los demás registros, y puede escribir valores en los registros
de abajo con seguridad.
El siguiente ejemplo ilustra la implementación de los atributos heredados durante el análisis sintáctico LL, al copiar con diligencia los valores de los atributos. Es posible usar métodos
abreviados u optimizaciones, en especial con las reglas de copiado, que sólo copian el valor de
un atributo hacia otro. Veremos los métodos abreviados hasta el ejemplo 5.24, el cual ilustra
también los registros de sintetizado.
Ejemplo 5.23: Este ejemplo implementa el esquema de traducción orientado a la sintaxis de
la figura 5.32, que genera código al instante para la producción while. Este esquema de traducción orientado a la sintaxis no tiene atributos sintetizados, excepto los atributos falsos que
representan las etiquetas.
La figura 5.33(a) muestra cuando estamos a punto de usar la producción while para expandir S, supuestamente debido a que el símbolo de lectura adelantada en la entrada es while.
5.5 Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la izquierda
345
El registro en la parte superior de la pila es para S, y contiene sólo el atributo heredado
S.siguiente, el cual suponemos tiene el valor x. Como ahora vamos a realizar el análisis sintáctico de arriba-abajo, mostramos el tope de la pila a la izquierda, de acuerdo con nuestra
convención usual.
tope
siguiente
tope
Acción
ssig
L1 = new();
L2 = new();
pila[tope − 1].false = ssig;
pila[tope − 1].true = L2;
pila[tope − 3].al 1 = L1;
pila[tope − 3].al2 = L2;
print("etiqueta", L1);
Acción
siguiente
pila[tope − 1].siguiente = al1;
print("etiqueta", al2);
Figura 5.33: Expansión de S, de acuerdo a la producción de la instrucción while
La figura 5.33(b) muestra justo después de haber expandido S. Hay registros de acción en
frente de los no terminales C y S1, que corresponden a las acciones en el esquema de traducción
orientado a la sintaxis subyacente de la figura 5.32. El registro para C tiene espacio para los
atributos heredados true y false, mientras que el registro para S1 tiene espacio para el atributo
siguiente, como lo deben tener todos los registros S. Mostramos los valores para estos campos
como ?, ya que todavía no conocemos sus valores.
A continuación, el analizador sintáctico reconoce los símbolos while y ( en la entrada, y
saca sus registros de la pila. Ahora, la primera acción está en el tope, y debe ejecutarse. Este
registro de acción tiene un campo llamado ssig, el cual contiene una copia del atributo heredado S.siguiente. Cuando se saca S de la pila, el valor de S.siguiente se copia al campo ssig
para usarlo durante la evaluación de los atributos heredados para C. El código para la primera
acción genera nuevos valores para L1 y L2, que debemos suponer son y y z, respectivamente.
El siguiente paso es hacer que z sea el valor de C.true. La asignación pila[tope − 1].true = L2
se escribe sabiendo que sólo se ejecuta cuando este registro de acción se encuentra en el tope de
la pila, por lo que tope − 1 se refiere al registro debajo de él (el registro para C ).
Luego, el primer registro de acción copia a L1 en el campo al1 en la segunda acción, en
donde se utilizará para evaluar a S1.siguiente. También copia a L2 en un campo llamado al2 de
la segunda acción; este valor se requiere para que ese registro de acción imprima su salida en
forma apropiada. Por último, el primer registro de acción imprime etiqueta y en la salida.
Capítulo 5. Traducción orientada por la sintaxis
346
tope
Acción
siguiente
pila[superior − 1].siguiente = al 1;
print("etiqueta", al2);
Figura 5.34: Después de realizar la acción encima de C
La situación después de completar la primera acción y sacar su registro de la pila se muestra
en la figura 5.34. Los valores de los atributos heredados en el registro para C se han llenado en
forma apropiada, al igual que los valores temporales al 1 y al2 en el segundo registro de acción.
En este punto se expande C y suponemos que se genera el código para implementar su prueba
que contiene saltos a las etiquetas x y z, según lo apropiado. Cuando el registro C se saca de
la pila, el registro para ) se convierte en el tope y hace que el analizador sintáctico compruebe
si hay un ) en su entrada.
Con la acción encima de S1 en el tope de la pila, su código establece S1.siguiente y emite
etiqueta z. Cuando termina con eso, el registro para S1 se convierte en el tope de la pila, y a
medida que se expande, suponemos que genera en forma correcta el código que implementa el
tipo de instrucción que sea y después salta a la etiqueta y.
2
Ejemplo 5.24: Ahora vamos a considerar la misma instrucción while, pero con una traducción que produzca la salida S.codigo como un atributo sintetizado, en vez de la generación
instantánea. Para poder seguir la explicación, es útil tener en cuenta la siguiente hipótesis
inductiva o invariante, que suponemos se sigue para cualquier no terminal:
• Todo no terminal que tenga código asociado con el sale de ese código, en forma de cadena,
en el registro de sintetizado que se encuentra justo debajo de ese no terminal en la pila.
Suponiendo que esta instrucción sea verdadera, vamos a manejar la producción while de manera que mantenga esta instrucción como una invariante.
La figura 5.35(a) muestra la situación justo antes de expandir S mediante el uso de la producción para las instrucciones while. En el tope de la pila podemos ver el registro para S; tiene
un campo para su atributo heredado S.siguiente, como en el ejemplo 5.23. Justo debajo de ese
registro se encuentra el registro de sintetizado para esta ocurrencia de S. Este registro tiene
un campo para S.codigo, como todos los registros de sintetizado para S deben tener. También
lo mostramos con otros campos para almacenamiento local y acciones, ya que el esquema de
traducción orientado a la sintaxis para la producción while en la figura 5.28 sin duda forma
parte de un esquema de traducción orientado a la sintaxis más grande.
Nuestra expansión de S se basa en el esquema de traducción orientado a la sintaxis de la
figura 5.28, y se muestra en la figura 5.35(b). Como método abreviado, durante la expansión
asumimos que el atributo heredado S.siguiente se asigna de manera directa a C.false, en vez de
colocarse en la primera acción y después copiarse al registro para C.
Vamos a examinar lo que hace cada registro cuando se convierte en el tope de la pila. En primer lugar, el registro while hace que el token while se relacione con la entrada y se produzca
5.5 Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la izquierda
347
tope
Sintetiza
S.codigo
siguiente
codigo
datos
acciones
tope
(
Acción
false
true
L1 = new();
L2 = new();
pila[tope − 1].true = L2;
pila[tope − 4].siguiente = L2;
pila[tope − 5].l 1 = L1;
pila[tope − 5].l 2 = L2;
Sintetiza
codigo
codigo
pila tope
. Ccodigo
S1
)
siguiente
codigo
Sintetiza
S1. codigo
codigo
Ccodigo
Sintetiza
S.codigo
codigo
datos
acciones
pila[tope − 1].codigo =
"etiqueta" || l 1 || Ccodigo
|| "etiqueta" || l 2 || codigo;
Figura 5.35: Expansión de S con el atributo sintetizado construido en la pila
una coincidencia, pues de lo contrario no habríamos expandido a S de esta manera. Después de
sacar a while y ( de la pila, se ejecuta el código para el registro de acción. Éste genera valores
para L1 y L2, y usamos el método abreviado de copiarlos directamente a los atributos heredados que los necesitan: S1.siguiente y C.true. Los últimos dos pasos de la acción hacen que L1 y
L2 se copien en el registro llamado “Sintetizar S1.codigo”.
El registro de sintetizado para S1 realiza una doble función: no sólo contiene el atributo sintetizado S1.codigo, sino que también sirve como registro de acción para completar la evaluación
de los atributos para toda la producción S → while ( C ) S1. En especial, cuando llegue a la
parte superior calculará el atributo sintetizado S.codigo y colocará su valor en el registro de
sintetizado para el encabezado S.
Cuando C llega a la parte superior de la pila, se calculan sus dos atributos heredados. Mediante la hipótesis inductiva que mencionamos antes, suponemos que genera en forma correcta
el código para ejecutar su condición y saltar a la etiqueta apropiada. También asumimos que
las acciones realizadas durante la expansión de C colocan en forma correcta este código en el
registro de abajo, como el valor del atributo sintetizado C.codigo.
Una vez que se saca C, el registro de sintetizado para C.codigo se convierte en la parte
superior. Su código es necesario en el registro de sintetizado para S1.codigo, debido a que ahí
es en donde concatenamos todos los elementos de código para formar a S.codigo. Por lo tanto,
el registro de sintetizado para C.codigo tiene una acción para copiar C.codigo en el registro de
sintetizado para S1.codigo. Después de hacerlo, el registro para el token ) llega al tope de la
pila y provoca que se compruebe si hay un ) en la entrada. Si la prueba tiene éxito, el registro
para S1 se convierte en el tope de la pila. Mediante nuestra hipótesis inductiva, este no terminal
se expande y el efecto neto es que su código se construye en forma correcta, y se coloca en el
campo para codigo en el registro de sintetizado para S1.
Capítulo 5. Traducción orientada por la sintaxis
348
¿Podemos manejar definiciones dirigidas por la sintaxis con
atributos heredados por la izquierda en gramáticas LR?
En la sección 5.4.1 vimos que todos las definiciones dirigidas por la sintaxis con atributos
sintetizados en una gramática LR se pueden implementar durante un análisis sintáctico
ascendente. En la sección 5.5.3 vimos que todas las definiciones dirigidas por la sintaxis
con atributos heredados por la izquierda en una gramática LL pueden analizarse de arriba
hacia abajo. Como las gramáticas LL son un subconjunto propio de las gramáticas LR,
y las definiciones dirigidas por la sintaxis con atributos sintetizados son un subconjunto
propio de las definiciones dirigidas por la sintaxis con atributos heredados por la izquierda, ¿podemos manejar todas las gramáticas LR y las definiciones dirigidas por la sintaxis
con atributos heredados por la izquierda de abajo hacia arriba?
No podemos, como lo demuestra el siguiente argumento intuitivo. Suponga que tenemos una producción A → B C en una gramática LR, y que hay un atributo B.i heredado que depende de los atributos heredados de A. Al reducir a B, aún no hemos visto
la entrada que genera C, por lo que no podemos estar seguros de tener un cuerpo de la
producción A → B C. Por ende, no podemos calcular B.i todavía, ya que no sabemos si
usar o no la regla asociada con esta producción.
Tal vez podríamos esperar hasta haber reducido a C, y sepamos que debemos reducir B C
a A. No obstante, incluso así no conocemos los atributos heredados de A, ya que después de
cada reducción, tal vez no estemos seguros del cuerpo de la producción que contenga esta A.
Podríamos razonar que esta decisión debería diferirse también y, por lo tanto, se diferiría
aún más el cálculo de B.i. Si seguimos razonando de esta forma, pronto nos daremos cuenta
de que no podemos tomar ninguna decisión sino hasta que se analice la entrada completa.
En esencia, hemos llegado a la estrategia de “construir primero el árbol de análisis sintáctico y después realizar la traducción”.
Ahora, todos los campos de datos del registro sintetizados para S1 se han llenado, por lo que
cuando se convierta en el tope de la pila, podrá ejecutarse la acción en ese registro. La acción
hace que las etiquetas y el código de C.codigo y S1.codigo se concatenen en el orden apropiado.
La cadena resultante se coloca en el registro de abajo; es decir, en el registro sintetizado para S.
Ahora hemos calculado correctamente a S.codigo, y cuando el registro sintetizado para S se
convierta en el tope, ese código estará disponible para colocarlo en otro registro más abajo en
la pila, en donde en algún momento dado se ensamblará en una cadena de código más grande,
para implementar el elemento de un programa del cual esta S forma parte.
2
5.5.4
Análisis sintáctico ascendente de las definiciones dirigidas
por la sintaxis con atributos heredados por la izquierda
Podemos hacer cualquier traducción tanto de abajo hacia arriba, como de arriba hacia abajo.
Dicho en forma más precisa, dada una definición dirigida por la sintaxis con atributos heredados por la izquierda en una gramática LL, podemos adaptar la gramática para calcular la
misma definición dirigida por la sintaxis en la nueva gramática, durante un análisis sintáctico
LR. El “truco” tiene tres partes:
5.5 Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la izquierda
349
1. Empezar con el esquema de traducción orientado a la sintaxis construido como en la
sección 5.4.5, el cual coloca acciones incrustadas antes de cada no terminal, para calcular
sus atributos heredados y una acción al final de la producción, para calcular los atributos
sintetizados.
2. Introducir en la gramática un no terminal como marcador en vez de cada acción incrustada. Cada una de estas posiciones obtiene un marcador distinto, y hay una producción
para cualquier marcador M, en específico, M → .
3. Modificar la acción a si el no terminal como marcador M la sustituye en alguna producción A → α {a} β, y asociar con M → una acción a que
(a) Copie, como atributo heredado de M, cualquier atributo de A o símbolo de α que la
acción a requiera.
(b) Calcule los atributos de la misma forma que a, pero que los haga atributos sintetizados de M.
Este cambio parece ser ilegal, ya que por lo general la acción asociada con la producción
M → tendrá que acceder a los atributos que pertenezcan a los símbolos gramaticales que
no aparezcan en esta producción. Sin embargo, vamos a implementar las acciones en la
pila de análisis sintáctico LR, así que los atributos necesarios siempre estarán disponibles
en un número conocido de posiciones hacia abajo en la pila.
Ejemplo 5.25: Suponga que hay una producción A → B C en una gramática LL, y que el
atributo heredado B.i se calcula a partir del atributo heredado A.i mediante cierta fórmula B.i =
f (A.i). Es decir, el fragmento de un esquema de traducción orientado a la sintaxis que nos importa es:
A → {B.i = f (A.i);} B C
Introducimos el marcador M con el atributo heredado M.i y el atributo sintetizado M.s. El
primero será una copia de A.i y el segundo será B.i. El esquema de traducción orientado a la
sintaxis se escribirá así:
A→MBC
A → {M.i = A.i; M.s = f (M.i);}
Observe que la regla para M no tiene a A.i disponible, pero de hecho vamos a arreglar que
cada atributo heredado para un no terminal como A aparezca en la pila, justo debajo del lugar
en el que se llevará a cabo la reducción a A. Así, cuando reduzcamos a M, encontraremos a A.i
justo debajo de ella, desde donde podrá leerse. Además, el valor de M.s, que se deja en la pila junto
con M, es en realidad B.i y se encuentra apropiadamente justo debajo del lugar en el que ocurrirá
después la reducción a B. 2
Ejemplo 5.26: Vamos a convertir el esquema de traducción orientado a la sintaxis de la figura
5.28 en un esquema de traducción orientado a la sintaxis que pueda operar con un analizador
sintáctico LR de la gramática modificada. Introduciremos un marcador M antes que C y un
marcador N antes que S1, para que la gramática subyacente se convierta en lo siguiente:
350
Capítulo 5. Traducción orientada por la sintaxis
Por qué funcionan los marcadores
Los marcadores son no terminales que derivan sólo a y que aparecen sólo una vez entre
todos los cuerpos de todas las producciones. No proporcionaremos una prueba formal
de que, cuando una gramática es LL, pueden agregarse no terminales como marcadores
en cualquier posición en el cuerpo, y la gramática resultante seguirá siendo LR. Sin
embargo, si una gramática es LL, entonces podemos determinar que una cadena w en la
entrada se deriva del no terminal A, en una derivación que empieza con la producción
A → α, con sólo ver el primer símbolo de w (o el siguiente símbolo, si w = ). Por ende,
si analizamos a w de abajo hacia arriba, entonces el hecho de que un prefijo de w debe
reducirse a α y después a S, se conoce tan pronto como aparece el principio de w en la
entrada. En especial, si insertamos marcadores en cualquier parte de α, los estados LR
incorporarán el hecho de que este marcador tiene que estar ahí, y reducirá al marcador
en el punto apropiado en la entrada.
S → while ( M C ) N S1
M → N → Antes de hablar sobre las acciones asociadas con los marcadores M y N, vamos a describir la
“hipótesis inductiva” de dónde se almacenan los atributos.
1. Debajo del cuerpo completo de la producción while (es decir, debajo de while en la pila)
estará el atributo heredado S.siguiente. Tal vez no conozcamos el no terminal o el estado
del analizador sintáctico asociado con este registro de pila, pero podemos estar seguros
de que tendrá un campo, en una posición fija del registro, que contendrá a S.siguiente
antes de empezar a reconocer lo que se deriva de esta S.
2. Los atributos heredados C.true y C.false se encontrarán justo debajo del registro de la
pila para C. Como se supone que la gramática es LL, la apariencia de while en la entrada nos asegura que la producción while sea la única que pueda reconocerse, por lo que
podemos estar seguros de que M aparecerá justo debajo de C en la pila, y el registro de
M contendrá los atributos heredados de C.
3. De manera similar, el atributo heredado S1.siguiente debe aparecer justo debajo de S1 en
la pila, para que podamos colocar ese atributo en el registro para N.
4. El atributo sintetizado C.codigo aparecerá en el registro para C. Como siempre cuando
tenemos una cadena extensa como valor de un atributo, esperamos que en la práctica
aparezca un apuntador a (un objeto que representa) la cadena en el registro, mientras
que la cadena en sí se encuentra fuera de la pila.
5. De manera similar, el atributo sintetizado S1.codigo aparecerá en el registro para S1.
Vamos a seguir el proceso de análisis sintáctico para una instrucción while. Suponga que
un registro que contiene a S.siguiente aparece en la parte superior de la pila, y que la siguiente
5.5 Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la izquierda
351
entrada es el terminal while. Desplazamos esta terminal hacia la pila. Después es evidente que
la producción que se está reconociendo es while, por lo que el analizador sintáctico LR puede desplazar a “(” y determinar que su siguiente paso debe ser reducir a M. En la figura 5.36 se muestra la pila en ese momento. También mostramos en esa figura la acción que está asociada con
la reducción a M. Creamos valores para L1 y L2, los cuales viven en campos del registro M,
Además, en ese registro hay campos para C.true y C.false. Estos atributos deben estar en los
campos segundo y tercero del registro, para tener consistencia con otros registros de la pila
que puedan aparecer debajo de C en otros contextos, y que también deban proporcionar estos
atributos a C. La acción se completa asignando valores a C.true y C.false, uno del valor L2
que se acaba de generar, y el otro recorriendo la pila hasta la posición en la que sabemos se
encuentra S.siguiente.
tope
Código que se ejecuta durante la
reducción de a M
siguiente
pila[tope
3].siguiente;
Figura 5.36: La pila de análisis sintáctico LR, después de reducir a M
Suponemos que las siguientes entradas se reducen a C en forma apropiada. Por lo tanto, el
atributo sintetizado C.codigo se coloca en el registro para C. Este cambio a la pila se muestra
en la figura 5.37, que también incorpora los siguientes registros que se colocan posteriormente
por encima de C en la pila.
e
S.siguiente
código
siguiente
codigo
Figura 5.37: La pila, justo antes de la reducción del cuerpo de la producción while a S
Continuando con el reconocimiento de la instrucción while, el analizador sintáctico debe
encontrar ahora un “)” en la entrada, al cual mete en la pila, en un registro propio. En ese
punto, el analizador, que sabe está trabajando en una instrucción while, porque la gramática
es LL, reducirá a N. La pieza individual de datos asociada con N es el atributo heredado
Capítulo 5. Traducción orientada por la sintaxis
352
S1.siguiente. Observe que este atributo necesita estar en el registro para N, ya que estará justo
debajo del registro para S1. El código que se ejecuta para calcular el valor de S1.siguiente es:
S1.siguiente = pila[tope − 3].L1;
Esta acción llega tres registros debajo de N, que se encuentra en el tope de la pila cuando se
ejecuta el código, y obtiene el valor de L1.
A continuación, el analizador sintáctico reduce cierto prefijo de la entrada restante a S, a la
cual nos hemos referido en forma consistente como S1, para diferenciarla de la S en el encabezado de la producción. El valor de S1.codigo se calcula y aparece en el registro de pila para S1.
Este paso nos lleva a la condición que se ilustra en la figura 5.37.
En este punto, el analizador reconocerá todo, desde while hasta S1, y hasta S. El código que
se ejecuta durante esta reducción es:
codigoTemp = etiqueta || pila[tope − 4].L1 || pila[tope − 3].codigo ||
etiqueta || pila[tope − 4].L2 || pila[tope].codigo;
tope = tope − 5;
pila[tope].codigo = codigoTemp;
Esto es, construimos el valor de S.codigo en una variable llamada codigoTemp. Ese código es el
usual, que consiste en las dos etiquetas L1 y L2, el código para C y el código para S1. Se saca de
la pila, para que aparezca S en donde se encontraba while. El valor del código para S se coloca
en el campo codigo de ese registro, en donde puede interpretarse como el atributo sintetizado
S.codigo. Observe que no mostramos, en ninguna parte de esta explicación, la manipulación
de los estados LR, que también deben aparecer en la pila, en el campo que hemos llenado con
símbolos gramaticales.
2
5.5.5
Ejercicios para la sección 5.5
Ejercicio 5.5.1: Implemente cada una de sus definiciones dirigidas por la sintaxis del ejercicio
5.4.4 como un analizador sintáctico de descenso recursivo, como en la sección 5.5.1.
Ejercicio 5.5.2: Implemente cada una de sus definiciones dirigidas por la sintaxis del ejercicio
5.4.4 como un analizador sintáctico de descenso recursivo, como en la sección 5.5.2.
Ejercicio 5.5.3: Implemente cada una de sus definiciones dirigidas por la sintaxis del ejercicio
5.4.4 como un analizador sintáctico LL, como en la sección 5.5.3, con el código generado “al
instante”.
Ejercicio 5.5.4: Implemente cada una de sus definiciones dirigidas por la sintaxis del ejercicio
5.4.4 como un analizador sintáctico LL, como en la sección 5.5.3, pero con el código (o apuntadores al código) almacenado en la pila.
Ejercicio 5.5.5: Implemente cada una de sus definiciones dirigidas por la sintaxis del ejercicio
5.4.4 con un analizador sintáctico LR, como en la sección 5.5.4.
Ejercicio 5.5.6: Implemente su definición dirigida por la sintaxis del ejercicio 5.2.4 como en
la sección 5.5.1. ¿Sería distinta una implementación como en la sección 5.5.2?
5.6 Resumen del capítulo 5
5.6
353
Resumen del capítulo 5
♦ Atributos heredados y sintetizados: Las definiciones dirigidas por la sintaxis pueden utilizar dos tipos de atributos. Un atributo sintetizado en el nodo de un árbol sintáctico se
calcula a partir de los atributos en sus hijos. Un atributo heredado en un nodo se calcula
a partir de los atributos en su padre y/o hermanos.
♦ Grafos de dependencias: Dado un árbol de análisis sintáctico y una definición dirigida por
la sintaxis, dibujamos flechas entre las instancias de los atributos asociados con cada nodo
del árbol sintáctico, para denotar que el valor del atributo en el encabezado de la flecha
se calcula en términos del valor del atributo en la parte final de la flecha.
♦ Definiciones cíclicas: En las definiciones dirigidas por la sintaxis problemáticas, encontramos que hay algunos árboles de análisis sintáctico para los cuales es imposible encontrar
un orden en el que podamos calcular todos los atributos en todos los nodos. Estos árboles
tienen ciclos en sus grafos de dependencias asociados. Es indecidible decidir si una definición dirigida por la sintaxis tiene dichos grafos de dependencias circular.
♦ Definiciones con atributos sintetizados: En una definición dirigida por la sintaxis con
atributos sintetizados, todos los atributos sintetizados son sintetizados.
♦ Definiciones con atributos heredados por la izquierda: En una definición dirigida por la
sintaxis con atributos heredados por la izquierda, los atributos pueden ser heredados
o sintetizados. Sin embargo, los atributos heredados en el nodo de un árbol sintáctico
pueden depender sólo de los atributos heredados de su padre y de (cualquiera de) los
atributos de los hermanos a su izquierda.
♦ Árboles sintácticos: Cada nodo en un árbol sintáctico representa a una construcción; los
hijos del nodo representan los componentes significativos de la construcción.
♦ Implementación de una definición dirigida por la sintaxis con atributos sintetizados: Una
definición con atributos sintetizados puede implementarse mediante un esquema de traducción orientado a la sintaxis, en el que todas las acciones se encuentran al final de la
producción (un esquema de traducción orientado a la sintaxis “postfijo”). Las acciones
calculan los atributos sintetizados del encabezado de la producción, en términos de los
atributos sintetizados de los símbolos en el cuerpo. Si la gramática subyacente es LR,
entonces este esquema de traducción orientado a la sintaxis puede implementarse en la
pila del analizador sintáctico LR.
♦ Eliminación de la recursividad por la izquierda de los esquemas de traducción orientados a la sintaxis: Si un esquema de traducción orientado a la sintaxis sólo tiene efectos
adicionales (no se calculan atributos), entonces el algoritmo estándar para eliminar la
recursividad por la izquierda para las gramáticas nos permitirá realizar las acciones como
si fueran terminales. Cuando los atributos sintetizados se calculan, de todas formas podemos eliminar la recursividad por la izquierda, si el esquema de traducción orientado a
la sintaxis es postfijo.
♦ Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la
izquierda mediante el análisis sintáctico de descenso recursivo: Si tenemos una definición
354
Capítulo 5. Traducción orientada por la sintaxis
con atributos heredados por la izquierda en una gramática que pueda analizarse de arriba hacia abajo, podemos construir un analizador de descenso recursivo sin rastreo hacia
atrás para implementar la traducción. Los atributos heredados se convierten en argumentos de las funciones para sus no terminales, y los atributos sintetizados se devuelven
mediante esa función.
♦ Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la
izquierda en una gramática LL: Toda definición con atributos heredados por la izquierda
y una gramática LL subyacente puede implementarse junto con el análisis sintáctico.
Los registros para guardar los atributos sintetizados para una no terminal se colocan
debajo de esa no terminal en la pila, mientras que los atributos no heredados para un no
terminal se almacenan con ese no terminal en la pila. Los registros de acción también se
colocan en la pila para calcular los atributos en el tiempo apropiado.
♦ Implementación de definiciones dirigidas por la sintaxis con atributos heredados por la izquierda en una gramática LL, de abajo hacia arriba: Una definición con atributos heredados por la izquierda y una gramática LL subyacente puede convertirse en una traducción
en una gramática LR, y la traducción se realiza en conexión con un análisis sintáctico
ascendente. La transformación gramatical introduce no terminales “marcadores” que
aparecen en la pila del analizador sintáctico ascendente y contienen atributos heredados
del no terminal encima de ella en la pila. Los atributos sintetizados se mantienen con su
no terminal en la pila.
5.7
Referencias para el capítulo 5
Las definiciones dirigidas por la sintaxis son una forma de definición inductiva, en la cual la
inducción es sobre la estructura sintáctica. Como tales, se han utilizado desde hace mucho
tiempo de manera informal en las matemáticas. Su aplicación a los lenguajes de programación
se dio con el uso de una gramática para estructurar el reporte de Algol 60.
La idea de un analizador sintáctico que llama a las acciones semánticas puede encontrarse
en Samelson y Bauer [8], y en Brooker y Morris [1]. Irons [2] construyó uno de los primeros
compiladores orientados a la sintaxis, usando atributos sintetizados. La clase de definiciones con
atributos heredados por la izquierda proviene de [6].
Los atributos heredados, los grafos de dependencias, y una prueba de circularidad de las definiciones dirigidas por la sintaxis (es decir, si hay o no algún árbol sintáctico sin orden, en el que puedan calcularse los atributos) son de Knuth [5]. Jazayeri, Ogden y Rounds [3] mostraron que para
probar la circularidad se requiere un tiempo exponencial, como una función del tamaño de la definición dirigida por la sintaxis.
Los generadores de analizadores sintácticos como Yacc [4] (vea también las notas bibliográficas en el capítulo 4) soportan la evaluación de atributos durante el análisis sintáctico.
La encuesta realizada por Paakki [7] es un punto inicial para acceder a la extensa literatura
sobre las definiciones y traducciones orientadas a la sintaxis.
1.
Brooker, R. A. y D. Morris, “A general translation program for phrase structure languages”, J. ACM 9:1 (1962), pp. 1-10.
5.7 Referencias para el capítulo 5
355
2.
Irons, E. T., “A syntax directed compiler for Algol 60”, Comm. ACM 4:1 (1961),
pp. 51-55.
3.
Jazayeri, M., W. F. Odgen y W. C. Rounds, “The intrinsic exponential complexity of the
circularity problem for attribute grammars”, Comm. ACM 18:12 (1975), pp. 697-706.
4.
Johnson, S. C., “Yacc – Yet Another Compiler Compiler”, Computing Science Technical
Report 32, Bell Laboratories, Murray Hill, NJ, 1975. Disponible en http://dinousaur.
compilertools.net/yacc/.
5.
Knuth, D. E., “Semantics of context-free languages”, Mathematical Systems Theory 2:2
(1968), pp. 127-145. Vea también Mathematical Systems Theory 5:1 (1971), pp. 95-96.
6.
Lewis, P. M. II, D. J. Rosenkrantz y R. E. Stearns, “Attributed translations”, J. Computer and System Sciences 9:3 (1974), pp. 279-307.
7.
Paakki, J., “Attribute grammar paradigms – a high-level methodology in language
implementation”, Computing Surveys 27:2 (1995), pp. 196-255.
8.
Samelson, K. y F. L. Bauer, “Sequential formula translation”, Comm. ACM 3:2 (1960),
pp. 76-83.
Capítulo 6
Generación de código
intermedio
En el modelo de análisis y síntesis de un compilador, el front-end analiza un programa fuente
y crea una representación intermedia, a partir de la cual el back-end genera el código destino.
Lo apropiado es que los detalles del lenguaje fuente se confinen al front-end, y los detalles
de la máquina de destino al respaldo. Con una representación intermedia definida de manera
adecuada, podemos construir un compilador para el lenguaje i y la máquina j mediante la combinación del front-end para el lenguaje i y el back-end para la máquina j. Este método para
crear una suite de compiladores puede ahorrar una considerable cantidad de esfuerzo: podemos
construir m × n compiladores con sólo escribir m front-ends y n back-ends.
Este capítulo trata las representaciones intermedias, la comprobación estática de tipos y
la generación de código intermedio. Por cuestión de simplicidad, asumimos que el front-end
de un compilador se organiza como en la figura 6.1, en donde el análisis sintáctico, la comprobación estática y la generación de código intermedio se realizan en forma secuencial; algunas
veces pueden combinarse y mezclarse en el análisis sintáctico. Utilizaremos los formalismos
orientados a la sintaxis de los capítulos 2 y 5 para especificar la comprobación y la traducción.
Muchos de los esquemas de traducción pueden implementarse durante el análisis sintáctico de
abajo-arriba o arriba-abajo, usando las técnicas del capítulo 5. Todos los esquemas pueden
implementarse mediante la creación de un árbol sintáctico y después el recorrido de éste.
Analizador
sintáctico
Comprobador
estático
Generador
código
de código
intermedio intermedio
front-end
Generador
de código
back-end
Figura 6.1: Estructura lógica del front-end de un compilador
La comprobación estática incluye la comprobación de tipos, la cual asegura que los operadores se apliquen a los operandos compatibles. También incluye cualquier comprobación sintáctica
357
358
Capítulo 6. Generación de código intermedio
que resulte después del análisis sintáctico. Por ejemplo, la comprobación estática asegura que
una instrucción break en C esté encerrada dentro de una instrucción while, for o switch; se reporta un error si no existe dicha instrucción envolvente.
El método en este capítulo puede usarse para una gran variedad de representaciones intermedias, incluyendo los árboles sintácticos y el código de tres direcciones, los cuales se presentaron en la sección 2.8. El término “código de tres direcciones” proviene de las instrucciones
de la forma general x = y op z con tres direcciones: dos para los operandos y y z, y una para
el resultado x.
Durante el proceso de traducir un programa en un lenguaje fuente dado al código para una
máquina destino, un compilador puede construir una secuencia de representaciones intermedias, como en la figura 6.2. Las representaciones de alto nivel están cerca del lenguaje fuente
y las representaciones de bajo nivel están cerca de la máquina destino. Los árboles sintácticos
son de alto nivel; describen la estructura jerárquica natural del programa fuente y se adaptan
bien a tareas como la comprobación estática de tipos.
Programa
fuente
Representación
intermedia
de alto nivel
Representación
intermedia
de bajo nivel
Código
destino
Figura 6.2: Un compilador podría usar una secuencia de representaciones intermedias
Una representación de bajo nivel es adecuada para las tareas dependientes de la máquina,
como la asignación de registros y la selección de instrucciones. El código de tres direcciones
puede variar de alto a bajo nivel, dependiendo de la elección de operadores. Para las expresiones, las diferencias entre los árboles sintácticos y el código de tres direcciones es superficial, como veremos en la sección 6.2.3. Por ejemplo, para las instrucciones de ciclos un árbol
sintáctico representa a los componentes de una instrucción, mientras que el código de tres
direcciones contiene etiquetas e instrucciones de salto para representar el flujo de control,
como en el lenguaje máquina.
La elección o diseño de una representación intermedia varía de un compilador a otro. Una
representación intermedia puede ser un verdadero lenguaje, o puede consistir en estructuras
de datos internas que se comparten mediante las fases del compilador. C es un lenguaje de
programación, y aún así se utiliza con frecuencia como forma intermedia, ya que es flexible, se
compila en código máquina eficiente y existe una gran variedad de compiladores. El compilador
de C++ original consistía en una front-end que generaba C, y trataba a un compilador de C
como back-end.
6.1
Variantes de los árboles sintácticos
Los nodos en un árbol sintáctico representan construcciones en el programa fuente; los hijos de
un nodo representan los componentes significativos de una construcción. Un grafo acíclico dirigido (de aquí en adelante lo llamaremos GDA) para una expresión identifica a las subexpresiones
comunes (subexpresiones que ocurren más de una vez) de la expresión. Como veremos en esta
sección, pueden construirse GDAs mediante el uso de las mismas técnicas que construyen los
árboles sintácticos.
6.1 Variantes de los árboles sintácticos
6.1.1
359
Grafo dirigido acíclico para las expresiones
Al igual que el árbol sintáctico para una expresión, un GDA tiene hojas que corresponden a
los operandos atómicos, y códigos interiores que corresponden a los operadores. La diferencia
es que un nodo N en un GDA tiene más de un padre si N representa a una subexpresión común; en un árbol sintáctico, el árbol para la subexpresión común se replica tantas veces como
aparezca la subexpresión en la expresión original. Por ende, un GDA no solo representa a las
expresiones en forma más breve, sino que también proporciona pistas importantes al compilador, en la generación de código eficiente para evaluar las expresiones.
Ejemplo 6.1: La figura 6.3 muestra el GDA para la siguiente expresión:
a + a * (b − c) + (b − c) * d
La hoja para a tiene dos padres, ya que a aparece dos veces en la expresión. Lo más interesante
es que dos ocurrencias de la subexpresión común b−c se representan mediante un nodo, etiquetado como −. Ese nodo tiene dos padres, los cuales representan sus dos usos en las subexpresiones a*(b−c) y (b−c)*d. Aun cuando b y c aparecen dos veces en la expresión completa, cada
uno de sus nodos tiene un padre, ya que ambos usos se encuentran en la subexpresión común
b−c.
2
Figura 6.3: GDA para la expresión a + a * (b − c) + (b − c) * d
Las definiciones dirigidas por la sintaxis de la figura 6.4 puede construir árboles sintácticos o
GDAs. Se utilizó para construir árboles sintácticos en el ejemplo 5.11, en donde las funciones Hoja
y Nodo creaban un nodo nuevo cada vez que se les llamaba. Construirá un GDA si, antes de crear
un nuevo nodo, estas funciones primero comprueban que ya existe un nodo idéntico. En caso de ser
así, se devuelve el nodo existente. Por ejemplo, antes de construir un nuevo nodo, Nodo(op, izq, der),
comprobamos si ya hay un nodo con la etiqueta op, y los hijos izq y der, en ese orden. De ser así,
Nodo devuelve el nodo existente; en caso contrario, crea uno nuevo.
Ejemplo 6.2: La secuencia de pasos que se muestra en la figura 6.5 construye el GDA de la
figura 6.3, siempre y cuando Nodo y Hoja devuelvan un nodo existente, si es posible, como
360
Capítulo 6. Generación de código intermedio
PRODUCCIÓN
REGLAS SEMÁNTICAS
E.nodo = new Nodo(+, E1.nodo, T.nodo)
E.nodo = new Nodo(−, E1.nodo, T.nodo)
E.nodo = T.nodo
T.nodo = E.nodo
T.nodo = new Hoja(id, id.entrada)
T.nodo = new Hoja(num, num.val)
Figura 6.4: Definición orientada por la sintaxis para producir árboles sintácticos o GDAs
1)
2)
3)
4)
5)
6)
7)
8)
9)
10)
11)
12)
13)
p1 = Hoja(id, entrada-a)
p2 = Hoja(id, entrada-a) = p1
p3 = Hoja(id, entrada-b)
p4 = Hoja(id, entrada-c)
p5 = Nodo(–, p3, p4)
p6 = Nodo(∗, p1, p5)
p7 = Nodo(+, p1, p6)
p8 = Hoja(id, entrada-b) = p3
p9 = Hoja(id, entrada-c) = p4
p10 = Nodo(–, p3, p4) = p5
p11 = Hoja(id, entrada-d )
p12 = Nodo(∗, p5, p11)
p13 = Nodo(+, p7, p12)
Figura 6.5: Pasos para construir el GDA de la figura 6.3
dijimos antes. Asumimos que entrada-a apunta a la entrada en la tabla de símbolos para a, y de
manera similar para los demás identificadores.
Cuando la llamada a Hoja (id, entrada-a) se repite en el paso 2, se devuelve el nodo creado
por la llamada anterior, por lo que p2 = p1. De manera similar, los nodos devueltos en los pasos
8 y 9 son iguales que los devueltos en los pasos 3 y 4 (es decir, p8 = p3 y p9 = p4). Por ende, el
nodo devuelto en el paso 10 debe ser igual que el devuelto en el paso 5; es decir, p10 = p5. 2
6.1.2
El método número de valor para construir GDAs
A menudo, los nodos de un árbol sintáctico o GDA se almacenan en un arreglo de registros,
como lo sugiere la figura 6.6. Cada fila del arreglo representa a un registro y, por lo tanto, a un
nodo. En cada registro, el primer campo es un código de operación, el cual indica la etiqueta
del nodo. En la figura 6.6(b) las hojas tienen un campo adicional, el cual contiene el valor
léxico (ya sea un apuntador a una tabla de símbolos o una constante, en este caso), y los nodos
interiores tienen dos campos adicionales que indican a los hijos izquierdo y derecho.
361
6.1 Variantes de los árboles sintácticos
a la entrada
para i
DGA
Arreglo
Figura 6.6: Nodos de un GDA para i = i + 10, asignados en un arreglo
En este arreglo, para referimos a los nodos proporcionamos el índice entero del registro
para ese nodo dentro del arreglo. Con el tiempo, a este entero se le ha denominado el número
de valor para el nodo, o para la expresión que éste representa. Por ejemplo, en la figura 6.6
el nodo etiquetado como + tiene el número de valor 3, y sus hijos izquierdo y derecho tienen
los números de valor 1 y 2, respectivamente. En la práctica, podríamos utilizar apuntadores a
registros o referencias a objetos en vez de índices enteros, pero de todas formas nos referiremos
a la referencia de un nodo como su “número de valor”. Si se almacenan en una estructura de
datos apropiada, los números de valor nos ayudan a construir los GDAs de expresiones con
eficiencia: el siguiente algoritmo muestra cómo.
Suponga que los nodos están almacenados en un arreglo, como en la figura 6.6, y que se
hace referencia a cada nodo por su número de valor. Hagamos que la firma de un nodo interior
sea el triple op, i, d , en donde op es la etiqueta, i el número de valor de su hijo izquierdo y d
el número de valor de su hijo derecho. Podemos suponer que un operador unario tiene r = 0.
Algoritmo 6.3: El método número de valor para construir los nodos de un GDA.
ENTRADA: Etiqueta op, nodo i y nodo d.
SALIDA: El número de valor de un nodo en el arreglo con la firma op, i, d .
MÉTODO: Buscar en el arreglo un nodo M con la etiqueta op, el hijo izquierdo i y el hijo de-
recho r. Si hay un nodo así, devolver el número de valor M. Si no, crear un nuevo nodo N en
el arreglo, con la etiqueta op, el hijo izquierdo i y el hijo derecho d, y devolver su número de
valor. 2
Aunque el Algoritmo 6.3 produce la salida deseada, se requiere mucho tiempo para buscar en todo el archivo cada vez que necesitamos localizar un nodo, en especial si el arreglo
contiene expresiones de todo un programa. Un método más eficiente es usar una tabla hash,
en la cual los nodos se colocan en “baldes”, cada uno de los cuales tendrá, por lo general, sólo
unos cuantos nodos. La tabla hash es una de varias estructuras de datos que soportan los
diccionarios en forma eficiente.1 Un diccionario es un tipo abstracto de datos que nos permite
insertar y eliminar elementos de un conjunto, y determinar si un elemento dado se encuentra
1
Vea Aho, A. V., J. E. Hopcroft y J. D. Ullman, Data Structures and Algorithms, Addison-Wesley, 1983, para una
explicación sobre las estructuras de datos con soporte para diccionarios.
362
Capítulo 6. Generación de código intermedio
en el conjunto. Una buena estructura de datos para diccionarios, como una tabla hash, realiza
cada una de estas operaciones en un tiempo constante o casi constante, sin importar el tamaño
del conjunto.
Para construir una tabla hash para los nodos de un GDA, necesitamos una función hash h
para calcular el índice del balde para una firma op, i, d , de tal forma que distribuya las firmas
entre los baldes, para que sea poco probable que un balde obtenga mucho más de la porción
justa de nodos. El índice de balde h (op, i, d ) se calcula en forma determinista a partir de op, i
y r, de manera que podemos repetir el cálculo y siempre llegaremos al mismo índice de balde
para el nodo op, i, d .
Los baldes pueden implementarse como listas enlazadas, como en la figura 6.7. Un arreglo,
indexado por un valor hash, contiene los encabezados de baldes, cada uno de los cuales apunta a
la primera celda de una lista. Dentro de la lista enlazada para un balde, cada celda contiene el
número de valor de uno de los nodos que se asignan a ese balde. Es decir, el nodo op, i, d puede
encontrarse en la lista cuyo encabezado se encuentra en el índice h (op, i, d ) del arreglo.
Elementos de la lista
que representan a los nodos
Arreglo de
encabezados
de baldes, indexados
por valor hash
Figura 6.7: Estructura de datos para buscar en los baldes
Por lo tanto, dado el nodo de entrada op, i y d, calculamos el índice de balde h (op, i, d) y
buscamos el nodo de entrada dado en la lista de celdas en este balde. Por lo general, hay suficientes baldes de manera que no haya una lista con más de unas cuantas celdas. Sin embargo,
tal vez tengamos que buscar en todas las celdas dentro de un balde, y para cada número de valor
v que encontremos en una celda, debemos comprobar si la firma op, i, d del nodo de entrada
coincide con el nodo que tiene el número de valor v en la lista de celdas (como en la figura 6.7).
Si encontramos una coincidencia, devolvemos v. Si no la encontramos, sabemos que no puede
existir un nodo de ese tipo en ningún otro balde, por lo que creamos una nueva celda, la agregamos a la lista de celdas para el índice de balde h (op, i, d), y devolvemos el número de valor en
esa nueva celda.
6.1.3
Ejercicios para la sección 6.1
Ejercicio 6.1.1: Construya el GDA para la siguiente expresión:
((x + y) − ((x + y) ∗ (x − y))) + ((x + y) ∗ (x − y))
363
6.2 Código de tres direcciones
Ejercicio 6.1.2: Construya el GDA e identifique los números de valores para las subexpresiones de las siguientes expresiones, suponiendo que + asocia por la izquierda.
a) a + b + (a + b).
b) a + b + a + b.
c) a + a + ((a + a + a + (a + a + a + a)).
6.2
Código de tres direcciones
En el código de tres direcciones, hay máximo un operador en el lado derecho de una instrucción; es decir, no se permiten expresiones aritméticas acumuladas. Por ende, una expresión del
lenguaje fuente como x+y*z podría traducirse en la siguiente secuencia de instrucciones de tres
direcciones:
t1 = y * z
t2 = x + t1
en donde t1 y t2 son nombres temporales que genera el compilador. Este desenmarañamiento
de expresiones aritméticas con varios operadores y de instrucciones de flujo de control anidadas hace que el código de tres direcciones sea conveniente para la generación y optimización
de código destino, como veremos en los capítulos 8 y 9. El uso de nombres para los valores
intermedios calculados por un programa permite reordenar el código de tres direcciones con
facilidad.
Ejemplo 6.4: El código de tres direcciones es una representación lineal de un árbol sintáctico
o de un GDA, en el cual los nombres explícitos corresponden a los nodos interiores del grafo.
El GDA en la figura 6.3 se repite en la figura 6.8, junto con la secuencia de código de tres
direcciones correspondiente.
2
(a) DAG
(b) Código de tres direcciones
Figura 6.8: Un GDA y su código correspondiente de tres direcciones
364
6.2.1
Capítulo 6. Generación de código intermedio
Direcciones e instrucciones
El código de tres direcciones se basa en dos conceptos: direcciones e instrucciones. En términos
de orientación a objetos, estos conceptos corresponden a las clases y los diversos tipos de direcciones e instrucciones corresponden a subclases apropiadas. De manera alternativa, podemos
implementar el código de tres direcciones usando registros con campos para las direcciones; en
la sección 6.2.2 hablaremos sobre los registros, conocidos como cuádruplos y tripletas.
Una dirección puede ser una de las siguientes opciones:
• Un nombre. Por conveniencia, permitimos que los nombres de los programas fuente aparezcan como direcciones en código de tres direcciones. En una implementación, un nombre de origen se sustituye por un apuntador a su entrada en la tabla de símbolos, en
donde se mantiene toda la información acerca del nombre.
• Una constante. En la práctica, un compilador debe tratar con muchos tipos distintos de
constantes y variables. En la sección 6.5.2 veremos las conversiones de tipos dentro de las
expresiones.
• Un valor temporal generado por el compilador. Es conveniente, en especial con los compiladores de optimización, crear un nombre distinto cada vez que se necesita un valor
temporal. Estos valores temporales pueden combinarse, si es posible, cuando se asignan
los registros a las variables.
Ahora consideraremos las tres instrucciones de tres direcciones comunes que utilizaremos
en el resto de este libro. Las etiquetas simbólicas son para uso de las instrucciones que alteran
el flujo de control. Una etiqueta simbólica representa el índice de una instrucción de tres direcciones en la secuencia de instrucciones. Los índices reales pueden sustituirse por las etiquetas,
ya sea mediante una pasada separada o mediante la técnica de “parcheo de retroceso”, que
veremos en la sección 6.7. He aquí una lista de las formas comunes de instrucciones de tres
direcciones:
1. Instrucciones de asignación de la forma x = y op z, en donde op es una operación aritmética o lógica binaria, y x, y y z son direcciones.
2. Asignaciones de la forma x = op y, en donde op es una operación unaria. En esencia, las
operaciones unarias incluyen la resta unaria, la negación lógica, los operadores de desplazamiento y los operadores de conversión que, por ejemplo, convierten un entero en un
número de punto flotante.
3. Instrucciones de copia de la forma x = y, en donde a x se le asigna el valor de y.
4. Un salto incondicional goto L. La instrucción de tres direcciones con la etiqueta L es la
siguiente que se va a ejecutar.
5. Saltos condicionales de la forma if x goto L e ifFalse x goto L. Estas instrucciones
ejecutan a continuación la instrucción con la etiqueta L si x es verdadera y falsa, respectivamente. En cualquier otro caso, la siguiente instrucción en ejecutarse es la instrucción
de tres direcciones que siga en la secuencia, como siempre.
365
6.2 Código de tres direcciones
6. Saltos condicionales como if x relop y goto L, que aplican un operador relacional (<, ==,
>=, etc.) a x y y, y ejecutan a continuación la instrucción con la etiqueta L si x predomina
en la relación relop con y. Si no es así, se ejecuta a continuación la siguiente instrucción
de tres direcciones que vaya después de if x relop y goto L, en secuencia.
7. Las llamadas a los procedimientos y los retornos se implementan mediante el uso de las siguientes instrucciones: param x para los parámetros; call p, n y y = call p, n para las llamadas a procedimientos y funciones, respectivamente; y return y, en donde y, que representa a
un valor de retorno, es opcional. Su uso común es como la siguiente secuencia de instrucciones
de tres direcciones:
param x1
param x2
...
param xn
call p, n
que se generan como parte de una llamada al procedimiento p(x1, x2, …, xn). El entero n, que
indica el número de parámetros actuales en “call p, n” no es redundante, ya que las llamadas
pueden anidarse. Es decir, algunas de las primeras instrucciones param podrían ser parámetros de una llamada que se realice después de que p devuelva su valor; ese valor se convierte
en otro parámetro de la llamada anterior. En la sección 6.9 se describe la implementación
de llamadas a procedimientos.
8. Instrucciones de copia indexadas, de la forma x = y[i] y x[i ] = y. La instrucción x = y[i]
establece x al valor en la ubicación que se encuentra a i unidades de memoria más allá
de y. La instrucción x[i] = y establece el contenido de la ubicación que se encuentra a i
unidades más allá de x, con el valor de y.
9. Asignaciones de direcciones y apuntadores de la forma x = &y, x = * y y * x = y. La instrucción x = &y establece el r-value de x para que sea la ubicación (l-value) de y.2 Se supone que y es un nombre, tal vez temporal, que denota a una expresión con un l-value tal
como A[i][j], y que x es el nombre de un apuntador o valor temporal. En la instrucción
x = * y, se supone que y es un apuntador o un temporal cuyo r-value es una ubicación.
El r-value de x se hace igual al contenido de esa ubicación. Por último, * x = y establece el
r-value del objeto al que apunta x con el r-value de y.
Ejemplo 6.5: Considere la siguiente instrucción:
do i = i+1; while (a[i] < v);
En la figura 6.9 se muestran dos posibles traducciones de esta instrucción. La traducción en la
figura 6.9 utiliza una etiqueta simbólica L, que se adjunta a la primera instrucción. La traducción
2
En la sección 2.8.3 vimos que los l y r-value son apropiados en los lados izquierdo y derecho de las asignaciones,
respectivamente.
366
Capítulo 6. Generación de código intermedio
en (b) muestra números de posición para las instrucciones, empezando en forma arbitraria en la
posición 100. En ambas traducciones, la última instrucción es un salto condicional a la primera instrucción. La multiplicación i* 8 es apropiada para un arreglo de elementos en el que cada uno de
ellos ocupa 8 unidades de espacio. 2
(a) Etiquetas simbólicas
(b) Números de posición
Figura 6.9: Dos formas de asignar etiquetas a las instrucciones de tres direcciones
La elección de operadores permitidos es una cuestión importante en el diseño de una forma
intermedia. Es evidente que el conjunto de operadores debe ser lo bastante amplio como para
implementar las operaciones en el lenguaje fuente. Los operadores cercanos a las instrucciones
de máquina facilitan la implementación de la forma intermedia en una máquina de destino. No
obstante, si la front-end debe generar secuencias largas de instrucciones para algunas operaciones en lenguaje fuente, entonces el optimizador y el generador de código tal vez tengan que
trabajar duro para redescubrir la estructura y generar buen código para estas operaciones.
6.2.2
Cuádruplos
La descripción de las instrucciones de tres direcciones especifica los componentes de cada tipo
de instrucción, pero no especifica la representación de estas instrucciones en una estructura de
datos. En un compilador, estas instrucciones pueden implementarse como objetos o como registros, con campos para el operador y los operandos. Tres de estas representaciones se conocen
como “cuádruplos”, “tripletas” y “tripletas indirectas”.
Un cuádruplo tiene cuatro campos, a los cuales llamamos op, arg1, arg2 y resultado. El campo
op contiene un código interno para el operador. Por ejemplo, la instrucción de tres direcciones
x = y + z se representa colocando a + en op, y en arg1, z en arg2 y x en resultado. A continuación
se muestran dos excepciones a esta regla:
1. Las instrucciones con operadores unarios como x = menos y o x = y no utilizan arg2. Observe que para una instrucción de copia como x = y, op es =, mientras que para la mayoría
de las otras operaciones, el operador de asignación es implícito.
2. Los operadores como param no utilizan arg2 ni resultado.
3. Los saltos condicionales e incondicionales colocan la etiqueta de destino en resultado.
Ejemplo 6.6: El código de tres direcciones para la asignación a = b * − c + b * − c; aparece
en la figura 6.10(a). El operador especial menos se utiliza para distinguir al operador de resta
367
6.2 Código de tres direcciones
unario, como en − c, del operador de resta binario, como en b − c. Observe que la instrucción
de “tres direcciones” de resta unaria sólo tiene dos direcciones, al igual que la instrucción de
copia a = t5.
Los cuádruplos en la figura 6.10(b) implementan el código de tres direcciones en (a). 2
resultado
menos
menos
menos
menos
(a) Código de tres direcciones
(b) Cuádruplos
Figura 6.10: Código de tres direcciones y su representación en cuádruplos
Por cuestión de legibilidad, usamos identificadores reales como a, b y c en los campos
arg1, arg2 y resultado en la figura 6.10(b), en vez de apuntadores a sus entradas en la tabla de
símbolos. Los nombres temporales pueden introducirse en la tabla de símbolos como nombres
definidos por el programador, o pueden implementarse como objetos de una clase Temp con
sus propios métodos.
6.2.3
Tripletas
Un triple sólo tiene tres campos, a los cuales llamamos op, arg1 y arg2. Observe que el campo resultado de la figura 6.10(b) se utiliza principalmente para los nombres temporales. Al usar tripletas, nos referimos al resultado de una operación x op y por su posición, en vez de usar un nombre
temporal explícito. Por ende, en vez del valor temporal t1 en la figura 6.10(b), una representación
en tripletas se referiría a la posición (0). Los números entre paréntesis representan apuntadores
a la misma estructura de las tripletas. En la sección 6.1.2, a las posiciones o apuntadores a las
posiciones se les llamó números de valor.
Las tripletas son equivalentes a las firmas en el Algoritmo 6.3. Por ende, el GDA y las representaciones en tripletas de las expresiones son equivalentes. La equivalencia termina con las
expresiones, ya que las variantes del árbol sintáctico y el código de tres direcciones representan
el flujo de control de una manera muy distinta.
Ejemplo 6.7: El árbol sintáctico y las tripletas en la figura 6.11 corresponden al código de
tres direcciones y los cuádruplos en la figura 6.10. En la representación en tripletas de la figura
6.11(b), la instrucción de copia a = t5 está codificada en la representación en tripletas, mediante la colocación de a en el campo arg1 y (4) en el campo arg2.
2
Una operación ternaria como x[i ] = y requiere dos entradas en la estructura de las tripletas;
por ejemplo, podemos colocar a x e i en una tripleta y a y en la siguiente. De manera similar,
podemos implementar x = y[i] tratándola como si fuera las dos instrucciones t = y[i] y x = t,
368
Capítulo 6. Generación de código intermedio
¿Por qué necesitamos instrucciones de copia?
Un algoritmo simple para traducir expresiones genera instrucciones de copia para las
asignaciones, como en la figura 6.10(a), en donde copiamos t5 en a, en vez de asignarle
directamente t2 + t4. Por lo general, cada subexpresión obtiene su nuevo valor temporal
propio para guardar su resultado, y sólo cuando se procesa el operador de asignación = es
cuando aprendemos en dónde colocar el valor de la expresión completa. Una pasada de
optimización de código, tal vez mediante el uso del GDA de la sección 6.1.1 como forma
intermedia, puede descubrir que t5 se puede sustituir por a.
menos
menos
menos
menos
(a) Árbol sintáctico
(b) Triples
Figura 6.11: Representaciones de a + a ∗ (b – c) + (b – c) ∗ d
en donde t es un valor temporal generado por el compilador. Observe que el valor temporal t en
realidad no aparece en un triple, ya que se hace referencia a los valores temporales mediante
su posición en la estructura de la misma.
En un compilador optimizador podemos ver un beneficio de los cuádruples, en comparación
con las tripletas, en donde las instrucciones se mueven con frecuencia. Con los cuádruples, si
movemos una instrucción que calcule un valor temporal t, entonces las instrucciones que usen
a t no requerirán ninguna modificación. Con las tripletas, se hace referencia al resultado de
una operación mediante su posición, por lo que al mover una instrucción tal vez tengamos que
modificar todas las referencias a ese resultado. Este problema no ocurre con las tripletas indirectas, que veremos a continuación.
Las tripletas indirectas consisten en un listado de apuntadores a tripletas, en vez de ser un
listado de las mismas tripletas. Por ejemplo, vamos a utilizar un arreglo llamado instrucción
para listar apuntadores a tripletas en el orden deseado. Así, las tripletas de la figura 6.11(b)
podrían representarse como en la figura 6.12.
Con las tripletas indirectas, un compilador de optimización puede mover una instrucción reordenando la lista en instrucción, sin afectar a los mismas tripletas. Si se implementa en Java,
un arreglo de objetos de instrucciones es análogo a una representación en tripletas indirectas,
ya que Java trata a los elementos del arreglo como referencias a objetos.
369
6.2 Código de tres direcciones
instrucción
menos
menos
Figura 6.12: Representación en tripletas indirectas del código de tres direcciones
6.2.4
Forma de asignación individual estática
La forma de asignación individual estática es una representación intermedia que facilita ciertas
optimizaciones de código. Dos aspectos distinguen a la forma de asignación individual estática
del código de tres direcciones. El primero es que todas las asignaciones en asignación individual
estática son a variables con distintos nombres; de aquí que se utilice el término asignación
individual estática. La figura 6.13 muestra el mismo programa intermedio en código de tres
direcciones y en forma de asignación individual estática. Observe que los subíndices diferencian
cada definición de variables p y q en la representación de asignación individual estática.
(a) Código de tres direcciones
(b) Forma de asignación individual estática
Figura 6.13: Programa intermedio en código de tres direcciones y asignación
individual estática
La misma variable puede definirse en dos rutas de flujo de control distintas en un programa.
Por ejemplo, el siguiente programa fuente:
if ( bandera ) x = −1; else x = 1;
y = x * a;
tiene dos rutas de flujo de control en las que se define la variable x. Si utilizamos distintos
nombres para x en la parte verdadera y en la parte falsa de la instrucción condicional, entonces
¿qué nombre debemos usar en la asignación y = x * a? Aquí es donde entra en juego el segundo
aspecto distintivo de asignación individual estática. Dicha asignación utiliza una convención de
notación, conocida como función φ, para combinar las dos definiciones de x:
if ( bandera ) x1 = −1; else x2 = 1;
x3 = φ(x1, x2);
370
Capítulo 6. Generación de código intermedio
Aquí, φ(x1, x2,) tiene el valor x1 si el flujo de control pasa a través de la parte verdadera
de la condicional, y el valor x2 si el flujo de control pasa a través de la parte falsa. Es decir, la
función φ devuelve el valor de su argumento que corresponde a la ruta de flujo de control que
se eligió para llegar a la instrucción de asignación que contiene la función φ.
6.2.5
Ejercicios para la sección 6.2
Ejercicio 6.2.1: Traduzca la expresión aritmética a + −(b + c) en:
a) Un árbol sintáctico.
b) Cuádruplos.
c) Tripletas.
d) Tripletas indirectos.
Ejercicio 6.2.2: Repita el ejercicio 6.2.1 para las siguientes instrucciones de asignación:
! Ejercicio 6.2.3: Muestre cómo transformar una secuencia de código de tres direcciones en
una en la que cada variable definida obtenga un nombre de variable único.
6.3
Tipos y declaraciones
Las aplicaciones de los tipos pueden agruparse mediante la comprobación y la traducción:
• La comprobación de tipos utiliza reglas lógicas para razonar acerca del comportamiento de
un programa en tiempo de ejecución. En específico, asegura que los tipos de los operandos coincidan con el tipo esperado por un operador. Por ejemplo, el operador && en Java
espera que sus dos operandos sean booleanos; el resultado también es de tipo booleano.
• Aplicaciones de traducción. A partir del tipo de un nombre, un compilador puede determinar el almacenamiento necesario para ese nombre en tiempo de ejecución. La información del tipo también se necesita para calcular la dirección que denota la referencia a un
arreglo, para insertar conversiones de tipo explícitas, y para elegir la versión correcta de
un operador aritmético, entre otras cosas.
371
6.3 Tipos y declaraciones
En esta sección, examinaremos los tipos y la distribución del almacenamiento para los
nombres declarados dentro de un procedimiento o una clase. El almacenamiento actual para la
llamada a un procedimiento o un objeto se asigna en tiempo de ejecución, cuando se llama al
procedimiento o se crea el objeto. Sin embargo, al examinar las declaraciones locales en tiempo
de compilación, podemos distribuir direcciones relativas, en donde la dirección relativa de un
nombre o un componente de una estructura de datos es un desplazamiento a partir del inicio
de un área de datos.
6.3.1
Expresiones de tipos
Los tipos tienen estructura, a la cual representamos mediante el uso de las expresiones de tipos:
una expresión de tipos es un tipo básico o se forma mediante la aplicación de un operador
llamado constructor de tipos a una expresión de tipos. Los conjuntos de tipos básicos y los
constructores dependen del lenguaje que se va a comprobar.
Ejemplo 6.8: El tipo de arreglo int[2][3] puede leerse como “arreglo de 2 arreglos, de 3
enteros cada uno” y escribirse como una expresión de tipos arreglo(2, arreglo(3, integer)). Este
tipo se representa mediante el árbol en la figura 6.14. El operador arreglo recibe dos parámetros, un número y un tipo. 2
arreglo
arreglo
Figura 6.14: Expresión de tipos para int[2][3]
Vamos a usar la siguiente definición de expresiones de tipos:
• Un tipo básico es una expresión de tipos. Los tipos básicos comunes para un lenguaje incluyen boolean, char, integer, float y void; este último denota “la ausencia de un valor”.
• El nombre de un tipo es una expresión de tipos.
• Una expresión de tipos se puede formar mediante la aplicación del constructor de tipo
arreglo a un número y a una expresión de tipos.
• Un registro es una estructura de datos con campos identificados por nombres. Una expresión de tipos se puede formar mediante la aplicación del constructor de tipo registro a
los nombres de los campos y sus tipos. Los registros de los tipos se implementarán en la
sección 6.3.6, mediante la aplicación del constructor registro a una tabla de símbolos que
contiene entradas para los campos.
• Una expresión de tipos puede formarse mediante el uso del constructor de tipo → para los
tipos de funciones. Escribimos s → t para la “ función del tipo s al tipo t ”. Los tipos de
funciones serán útiles cuando hablemos sobre la comprobación de tipos en la sección 6.5.
372
Capítulo 6. Generación de código intermedio
Nombres de los tipos y tipos recursivos
Una vez que se define una clase, su nombre se puede usar como el nombre de un tipo en
C++ o en Java; por ejemplo, considere a Nodo en el siguiente fragmento de programa:
public class Nodo { ⋅⋅⋅ }
⋅⋅⋅
public Nodo n;
Pueden usarse nombres para definir tipos recursivos, los cuales son necesarios para
las estructuras de datos como las listas enlazadas. El seudocódigo para el elemento de
una lista:
class Celda { int info; Celda siguiente; ⋅⋅⋅ }
define el tipo recursivo Celda como una clase que contiene un campo info y un campo
siguiente de tipo Celda. Pueden definirse tipos recursivos similares en C, mediante
el uso de registros y apuntadores. Las técnicas en este capítulo se enfocan en los tipos
recursivos.
• Si s y t son expresiones de tipos, entonces su producto Cartesiano s × t es una expresión
de tipos. Los productos se introducen por completud; pueden usarse para representar una
lista o tupla de tipos (por ejemplo, para los parámetros de funciones). Asumimos que × se
asocia a la izquierda y que tiene mayor precedencia que →.
• Las expresiones de tipos pueden contener variables cuyos valores sean expresiones de
tipos. En la sección 6.5.4 utilizaremos variables de tipos generados por el compilador.
Una manera conveniente de representar una expresión de tipos es mediante un grafo. El
método número de valor de la sección 6.1.2 pude adaptarse para construir un GDA para una
expresión de tipos, con nodos interiores para los constructores de tipos y hojas para los tipos
básicos, los nombres de los tipos y las variables de tipos; por ejemplo, vea el árbol de la figura
6.14.3
6.3.2
Equivalencia de tipos
¿Cuándo son equivalentes dos expresiones de tipos? Muchas reglas de comprobación de tipos
tienen la forma: “if dos expresiones de tipos son iguales then devolver cierto tipo else error”.
Surgen ambigüedades potenciales cuando se proporcionan nombres a las expresiones de tipos
y después se utilizan los nombres en expresiones de tipos subsiguientes. La cuestión clave es si
el nombre en una expresión de tipos se representa a sí mismo o si es una abreviación para otra
expresión de tipos.
3
Como los nombres de los tipos denotan expresiones de tipos, pueden establecer ciclos implícitos; vea el recuadro
sobre “Nombres de tipos y tipos recursivos”. Si las flechas que van a los nombres de los tipos se redirigen a las expresiones de tipos denotadas por los nombres, entonces el grafo resultante puede tener ciclos debido a los tipos recursivos.
373
6.3 Tipos y declaraciones
Cuando las expresiones de tipos se representan mediante grafos, dos tipos son equivalentes
en estructura si y solo si una de las siguientes condiciones es verdadera:
• Son el mismo tipo básico.
• Se forman mediante la aplicación del mismo constructor de tipos equivalentes en estructura.
• Uno es el nombre de un tipo que denota al otro.
Si los nombres de los tipos se tratan como si se representaran a sí mismos, entonces las primeras dos condiciones en la definición anterior conducen a la equivalencia de nombres de las
expresiones de tipos.
Las expresiones con nombres equivalentes reciben el mismo número de valor, si utilizamos el
Algoritmo 6.3. Podemos probar la equivalencia estructural usando el algoritmo de unificación
de la sección 6.5.5.
6.3.3
Declaraciones
Vamos a estudiar los tipos y las declaraciones mediante una gramática simplificada que declara
sólo un nombre a la vez; las declaraciones con listas de nombres pueden manejarse como vimos
en el ejemplo 5.10. La gramática es:
D → T id ; D | T → B C | record { D }
B → int | float
C → | [ num ] C
El fragmento de la gramática anterior que maneja los tipos básicos y de arreglo se utilizó
para ilustrar los atributos heredados en la sección 5.3.2. La diferencia en esta sección es que
consideraremos la distribución del almacenamiento, así como los tipos.
La no terminal D genera una secuencia de declaraciones. La no terminal T genera tipos
básicos, de arreglos o de registros. La no terminal B genera uno de los tipos básicos int y
float. La no terminal C, de “componente”, genera cadenas de cero o más enteros, en donde
cada entero se encierra entre llaves. Un tipo de arreglo consiste en un tipo básico especificado por
B, seguido de los componentes del arreglo especificados por la no terminal C. Un tipo de registro (la segunda producción para T ) es una secuencia de declaraciones para los campos del registro,
todas encerradas entre llaves.
6.3.4
Distribución del almacenamiento para los nombres locales
Del tipo de un nombre podemos determinar la cantidad de almacenamiento que será necesaria
para ese nombre en tiempo de ejecución. En tiempo de compilación, podemos usar estas cantidades para asignar a cada nombre una dirección relativa. El tipo y la dirección relativa se
almacenan en esta entrada en la tabla de símbolos para el nombre. Los datos de longitud variable, como las cadenas, o los datos cuyo tamaño no puede determinarse sino hasta en tiempo
de ejecución, como los arreglos dinámicos, se manejan reservando una cantidad fija conocida de
almacenamiento para un apuntador a los datos. En el capítulo 7 hablaremos sobre el manejo
del almacenamiento en tiempo de ejecución.
374
Capítulo 6. Generación de código intermedio
Alineación de direcciones
La distribución del almacenamiento para los objetos de datos tiene gran influencia de
las restricciones de direccionamiento de la máquina de destino. Por ejemplo, las instrucciones para sumar enteros pueden esperar que los enteros estén alineados; es decir, que
se coloquen en ciertas posiciones en memoria, como en una dirección divisible entre 4.
Aunque un arreglo de diez caracteres sólo necesita suficientes bytes para guardar diez caracteres, un compilador podría, por lo tanto, asignar 12 bytes (el siguiente múltiplo de 4),
dejando 2 bytes sin usar. El espacio que se deja sin utilizar debido a las consideraciones
de alineación se conoce como relleno. Cuando el espacio es de extrema importancia, un
compilador puede empaquetar los datos, para que no quede relleno; entonces tal vez haya
que ejecutar instrucciones adicionales en tiempo de ejecución para posicionar los datos
empaquetados, de manera que pueda operarse con ellos como si estuvieran alineados en
forma apropiada.
Suponga que el almacenamiento se da en bloques de bytes contiguos, en donde un byte es
la unidad más pequeña de memoria que puede direccionarse. Por lo general, un byte es de ocho
bits y cierto número de bytes forman una palabra de máquina. Los objetos de varios bytes se
almacenan en bytes consecutivos y reciben la dirección del primer byte.
La anchura de un tipo es el número de unidades de almacenamiento necesarias para los
objetos de ese tipo. Un tipo básico, como un carácter, entero o número de punto flotante, requiere un número integral de bytes. Para facilitar el acceso, el espacio para los agregados como
los arreglos y las clases se asigna en un bloque contiguo de bytes.4
El esquema de traducción (SDT) en la figura 6.15 calcula los tipos y sus tamaños para los
tipos básicos y de arreglos; en la sección 6.3.6 veremos los tipos de registros. El SDT utiliza los atributos sintetizados tipo y tamaño para cada no terminal y dos variables, t y w, para pasar la
información del tipo y el tamaño, de un nodo B en un árbol de análisis sintáctico al nodo para
la producción C → . En una definición orientada por la sintaxis, t y w serían atributos heredados para C.
El cuerpo de la producción T consiste en el no terminal B, una acción y el no terminal C,
que aparece en la siguiente línea. La acción entre B y C establece t a B.tipo y w a B.tamaño. Si
B → int entonces B.tipo se establece a integer y B.tamaño se establece a 4, el tamaño de un
entero. De manera similar, si B → float entonces B.tipo es float y B.tamaño es 8, el tamaño
de un número de punto flotante.
Las producciones para C determinan si T genera un tipo básico o un tipo de arreglo. Si C → ,
entonces t se convierte en C.tipo y w se convierte en C.tamaño.
En cualquier otro caso, C especifica el componente de un arreglo. La acción para C → [num]
C1 forma a C.tipo, aplicando el constructor de tipos arreglo a los operandos num.valor y C1.tipo.
Por ejemplo, el resultado de aplicar arreglo podría ser una estructura de árbol como la figura
6.14.
4
La asignación del almacenamiento para los apuntadores en C y C++ es más simple si todos los apuntadores
tienen la misma longitud. La razón es que el almacenamiento para un apuntador tal vez deba asignarse antes de que
conozcamos el tipo de objetos a los que puede apuntar.
375
6.3 Tipos y declaraciones
{ t = B.tipo; w = B.anchura; }
{ B.tipo = integer ; B.anchura = 4; }
{ B.tipo = float ; B.anchura = 8; }
{ C.tipo = t ; C.anchura = w ; }
{ arreglo(num.valor, C1.tipo);
C.anchura = num.valor × C1.anchura; }
Figura 6.15: Cálculo de los tipos y sus anchuras
El tamaño de un arreglo se obtiene al multiplicar el tamaño de un elemento por el número
de elementos en el arreglo. Si las direcciones de enteros consecutivos difieren por 4, entonces los
cálculos de las direcciones para un arreglo de enteros incluirán multiplicaciones por 4. Dichas
multiplicaciones proporcionan oportunidades para la optimización, por lo cual es útil para el
front-end hacerlas explícitas. En este capítulo, ignoramos otras dependencias de la máquina,
como la alineación de los objetos de datos sobre límites de palabras.
Ejemplo 6.9: El árbol de análisis sintáctico para el tipo int[2][3] se muestra mediante líneas punteadas en la figura 6.16. Las líneas sólidas muestran cómo se pasan el tipo y el tamaño
desde B, descendiendo por la cadena de Cs a través de las variables t y w, y ascendiendo por
la cadena como los atributos sintetizados tipo y tamaño. A las variables t y w se les asignan los
valores de B.tipo y B.tamaño, respectivamente, antes de examinar el subárbol con los nodos C.
Los valores de t y w se utilizan en el nodo para C → , con el fin de iniciar la evaluación de los
atributos sintetizados, ascendiendo por la cadena de nodos C. 2
tipo = arreglo(2, arreglo(3, integer))
tamaño = 24
tipo = integer
tamaño = 4
tipo = arreglo(2, arreglo(3, integer))
tamaño = 24
tipo = arreglo(3, integer)
tamaño = 12
tipo = integer
tamaño = 4
Figura 6.16: Traducción orientada por la sintaxis de los tipos de arreglos
376
6.3.5
Capítulo 6. Generación de código intermedio
Secuencias de las declaraciones
Los lenguajes como C y Java permiten que todas las declaraciones en un solo procedimiento
se procesen como un grupo. Las declaraciones pueden distribuirse dentro de un procedimiento de
Java, pero de todas formas pueden procesarse al momento de analizar el procedimiento. Por lo
tanto, podemos usar una variable, por decir desplazamiento, para llevar el registro de la siguiente
dirección relativa disponible.
El esquema de traducción de la figura 6.17 trata con una secuencia de declaraciones de la
forma T id, en donde T genera un tipo como en la figura 6.15. Antes de considerar la primera
declaración, desplazamiento se establece a 0. A medida que se ve cada nuevo nombre x, este
nombre se introduce en la tabla de símbolos y se establece su dirección relativa al valor actual
de desplazamiento, que después se incrementa en base a la anchura del tipo de x.
desplazamiento
superior
desplazamiento
tipo desplazamiento);
a
desplazamiento
tamaño
Figura 6.17: Cálculo de las direcciones relativas de los nombres declarados
La acción semántica dentro de la producción D → T id ; D1 crea una entrada en la tabla
de símbolos mediante la ejecución de superior.put(id.lexema, T.tipo, desplazamiento). Aquí,
superior denota la tabla de símbolos actual. El método superior.put crea una entrada en la
tabla de símbolos para id.lexema, con el tipo T.tipo y la dirección relativa desplazamiento en
su área de datos.
La inicialización de desplazamiento en la figura 6.17 es más evidente si la primera producción aparece en una línea como:
P → { desplazamiento = 0; } D
(6.1)
Los no terminales que generan a , conocidas como no terminales marcadores, pueden usarse
para rescribir las producciones, de manera que todas las acciones aparezcan al final de los lados
derechos; vea la sección 5.5.4. Si utilizamos un no terminal como marcador M, la producción
(6.1) puede redeclararse como:
P → MD
M → 6.3.6
{ desplazamiento = 0; }
Campos en registros y clases
La traducción de las declaraciones en la figura 6.17 se transfiere a los campos en los registros y las
clases. Podemos agregar tipos de registros a la gramática en la figura 6.15 si agregamos la siguiente
producción:
T
→
registro { D }
377
6.3 Tipos y declaraciones
Los campos en este tipo de registro se especifican mediante la secuencia de declaraciones generadas por D. El método de la figura 6.17 puede utilizarse para determinar los tipos y las direcciones relativas de los campos, siempre y cuando tengamos cuidado con dos cosas:
• Los nombres de los campos dentro de un registro deben ser distintos; es decir, puede
aparecer un nombre máximo una vez en las declaraciones generadas por D.
• El desplazamiento o dirección relativa para el nombre de un campo es relativo al área de
datos para ese registro.
Ejemplo 6.10: El uso de un nombre x para un campo dentro de un registro no entra en conflicto con los demás usos del nombre fuera del registro. Por ende, los tres usos de x en las siguientes
declaraciones son distintos y no entran en conflicto unos con otros:
float x;
registro { float x; float y; } p;
registro { int etiqueta; float x; float y; } q;
Una siguiente asignación x = p.x + q.x; establece la variable x a la suma de los campos llamados x en los registros p y q. Observe que la dirección relativa de x en p difiere de la dirección
relativa de x en q. 2
Por conveniencia, los tipos de registros codificarán tanto a los tipos como a las direcciones
relativas de sus campos, usando una tabla de símbolos para el tipo de registro. Un tipo de registro
tiene la forma registro(t ), en donde registro es el constructor de tipos y t es un objeto en la tabla
de símbolos que contiene información acerca de los campos de este tipo de registro.
El esquema de traducción de la figura 6.18 consiste en una sola producción que se va a agregar
a las producciones para T en la figura 6.15. Esta producción tiene dos acciones semánticas. La
acción incrustada antes de D almacena la tabla de símbolos existente, denotada por tope, y establece tope a una nueva tabla de símbolos. También almacena el desplazamiento actual, y establece
desplazamiento a 0. Las declaraciones generadas por D ocasionan que los tipos y las direcciones
relativas se metan en la nueva tabla de símbolos. La acción después de D crea un tipo de registro
usando tope, antes de restaurar la tabla de símbolos y el desplazamiento almacenados.
registro
Pila.
T.tipo
Tope
w
desplazamiento desplazamiento
registro
anchura
desplazamiento
desplazamiento
Pila
Figura 6.18: Manejo de los nombres de los campos en los registros
Para fines concretos, las acciones en la figura 6.18 proporcionan el seudocódigo para una implementación específica. Suponga que la clase Env implementa a las tablas de símbolos. La llamada Env.push(tope) mete la tabla de símbolos actual, denotada por tope, en una pila. Después,
la variable tope se establece a una nueva tabla de símbolos. De manera similar, desplazamiento
se mete en una pila llamada Pila. Después, la variable desplazamiento se establece a 0.
378
Capítulo 6. Generación de código intermedio
Una vez que se han traducido las declaraciones en D, la tabla de símbolos tope contiene los
tipos y las direcciones relativas de los campos en este registro. Además, desplazamiento proporciona el almacenamiento necesario para todos los campos. La segunda acción establece T.tipo
a registro(tope) y T.tamaño a desplazamiento. Después, las variables tope y desplazamiento se
restauran a los valores que se habían metido en la pila, para completar la traducción de este
tipo de registro.
Esta explicación sobre el almacenamiento para los tipos de registros se transfiere a las clases, ya que no hay almacenamiento reservado para los métodos. Vea el ejercicio 6.3.2.
6.3.7
Ejercicios para la sección 6.3
Ejercicio 6.3.1: Determine los tipos y las direcciones relativas para los identificadores en la
siguiente secuencia de declaraciones:
float x;
registro { float x; float y; } p;
registro { int etiqueta; float x; float y; } q;
! Ejercicio 6.3.2: Extienda el manejo de los nombres de los campos en la figura 6.18 a las clases
y las jerarquías de clases con herencia simple.
a) Proporcione una implementación de la clase Env que permita tablas de símbolos enlazadas, de manera que una subclase pueda redefinir el nombre de un campo o hacer
referencia directa al nombre de un campo en una superclase.
b) Proporcione un esquema de traducción que asigne un área de datos contigua para los
campos en una clase, incluyendo los campos heredados. Estos campos deben mantener
las direcciones relativas que se les asignaron en el esquema para la superclase.
6.4
Traducción de expresiones
El resto de este capítulo explora las cuestiones que surgen durante la traducción de expresiones e instrucciones. En esta sección empezamos con la traducción de expresiones en código
de tres direcciones. Una expresión con más de un operador, como a + b ∗ c, se traducirá en
instrucciones con a lo más un operador por instrucción. La referencia a un arreglo A[i ][j ] se
expandirá en una secuencia de instrucciones de tres direcciones que calculan una dirección para
la referencia. En la sección 6.5 consideraremos la comprobación de tipos de las expresiones y en la
sección 6.6 veremos el uso de expresiones booleanas para dirigir el flujo de control a través de un
programa.
6.4.1
Operaciones dentro de expresiones
La definición orientada por la sintaxis en la figura 6.19 construye el código de tres direcciones
para una instrucción de asignación S, usando el atributo codigo para S y los atributos dir y codigo para una expresión E. Los atributos S.codigo y E.codigo denotan el código de tres direcciones
379
6.4 Traducción de expresiones
PRODUCCIÓN
REGLAS SEMÁNTICAS
S.codigo = E.codigo ||
gen(tope.get (id.lexema) = E.dir)
E.dir
= new Temp()
E.codigo = E1.codigo || E2.codigo ||
gen(E.dir = E1.dir + E2.dir)
E.dir
= new Temp()
E.codigo = E1.codigo ||
gen(E.dir = menos E1.dir)
E.dir
= E1.dir
E.codigo = E1.codigo
E.dir
= tope.get (id.lexema)
E.codigo = Figura 6.19: Código de tres direcciones para las expresiones
para S y E, respectivamente. El atributo E.dir denota la dirección que contendrá el valor de E.
En la sección 6.2.1 vimos que una dirección puede ser un nombre, una constante o un valor
temporal generado por el compilador.
Considere la última producción, E → id, en la definición orientada por la sintaxis de la
figura 6.19. Cuando una expresión es un solo identificador, por decir x, entonces el mismo x contiene el valor de la expresión. Las reglas semánticas para esta producción definen a E.dir para
que apunte a la entrada en la tabla de símbolos para esta instancia de id. Suponga que tope
denota la tabla de símbolos actual. La función tope.get obtiene la entrada cuando se aplica a la
representación de cadena id.lexema de esta instancia id. E.code se establece a la cadena vacía.
Cuando E → ( E1 ), la traducción de E es la misma que la de la subexpresión E1. Por ende,
E.dir es igual a E1.dir, y E.codigo es igual a E1.codigo.
Los operadores + y − unario en la figura 6.19 son representativos de los operadores en un
lenguaje ordinario. Las reglas semánticas para E → E1 + E2, generan código para calcular el
valor de E a partir de los valores de E1 y E2. Los valores se calculan y colocan en nombres
temporales recién generados. Si E1 se calcula y se coloca en E1.dir, y E2 en E2.dir, entonces
E1 + E2 se traduce en t = E1.addr + E2.addr, en donde t es un nuevo nombre temporal. A E.addr
se le asigna t. Para crear una secuencia de nombres temporales t1, t2, … distintos, se ejecuta
new Temp() en forma sucesiva.
Por conveniencia, usamos la notación gen(x = y + z) para representar la instrucción de
tres direcciones x = y + z. Las expresiones que aparecen en lugar de variables como x, y y z se
evalúan cuando se pasan a gen, y las cadenas entre comillas como = se interpretan en forma
literal.5 Otras instrucciones de tres direcciones se generarán de manera similar, aplicando gen
a una combinación de expresiones y cadenas.
5
En las definiciones dirigidas por la sintaxis, gen genera una instrucción y la devuelve. En los esquemas de traducción, gen genera una instrucción y la emite en forma incremental, colocándola en el flujo de instrucciones generadas.
380
Capítulo 6. Generación de código intermedio
Al traducir la producción E → E1 + E2, las reglas semánticas en la figura 6.19 generan a
E.codigo mediante la concatenación de E1.codigo, E2.codigo y una instrucción que suma los
valores de E1 y E2. La instrucción coloca el resultado de la suma en un nuevo nombre temporal
para E, denotado por E.dir.
La traducción de E → −E1 es similar. Las reglas crean un nuevo nombre temporal para E
y generan una instrucción para realizar la operación de resta unaria.
Por último, la producción S → id = E; genera instrucciones que asignan el valor de la expresión E al identificador id. La regla semántica para esta producción utiliza la función tope.
get para determinar la dirección del identificador representado por id, como en las reglas para
E → id. S.codigo consiste en las instrucciones para calcular el valor de E y colocarlo en una
dirección proporcionada por E.dir, seguida de una asignación a la dirección tope.get(id.lexema)
para esta instancia de id.
Ejemplo 6.11: La definición orientada por la sintaxis en la figura 6.19 traduce la instrucción
de asignación a = b + − c; en la siguiente secuencia de código de tres direcciones:
t1 = menos c
t2 = b + t1
a = t2
2
6.4.2
Traducción incremental
Los atributos de código pueden ser cadenas largas, así que, por lo general, se generan en forma
incremental, como vimos en la sección 5.5.2. Por ende, en vez de generar a E.codigo como en
la figura 6.19, podemos hacer que sólo se generen las nuevas instrucciones de tres direcciones,
como en el esquema de traducción de la figura 6.20. En el método incremental, gen no sólo construye una instrucción de tres direcciones, sino que también adjunta la instrucción a la secuencia
de instrucciones generadas hasta ahora. La secuencia puede retenerse en memoria para su posterior procesamiento, o puede enviarse como salida en forma incremental.
El esquema de traducción en la figura 6.20 genera el mismo código que la definición orientada
por la sintaxis de la figura 6.19. Con el método incremental, el atributo codigo no se utiliza, ya
que hay una sola secuencia de instrucciones que se crea mediante llamadas sucesivas a gen. Por
ejemplo, la regla semántica para E → E1 + E2 en la figura 6.20 simplemente llama a gen para
generar la instrucción de suma; las instrucciones para calcular E1 y colocarlo en E1.dir, y
para generar E2 y colocarlo en E2.dir ya se han generado.
El método de la figura 6.20 también puede usarse para construir un árbol sintáctico. La
nueva acción semántica para E → E1 + E2 crea un nodo mediante el uso de un constructor,
como en:
E
→
E1 + E2 { E.dir = new Nodo(+, E1.dir, E2.dir); }
Aquí, el atributo dir representa la dirección de un nodo, en vez de una variable o constante.
381
6.4 Traducción de expresiones
gen(tope.get (id.lexema) = E.dir); }
E.dir = new Temp();
gen(E.dir = E1.dir + E2.dir); }
E.dir = new Temp();
gen(E.dir = menos E1.dir); }
E.dir = E1.dir; }
E.dir = tope.get(id.lexema); }
Figura 6.20: Generación de código de tres direcciones para expresiones en forma incremental
6.4.3
Direccionamiento de los elementos de un arreglo
Se puede tener acceso rápido a los elementos de un arreglo, si éstos se encuentran almacenados
en un bloque de ubicaciones consecutivas. En C y Java, los elementos de un arreglo se enumeran desde 0, 1, …, n − 1 para un arreglo con n elementos. Si la anchura de cada elemento del
arreglo es w, entonces el i-ésimo elemento del arreglo A empieza en la siguiente ubicación:
base + i × w
(6.2)
en donde base es la dirección relativa del almacenamiento asignado para el arreglo. Es decir,
base es la dirección relativa de A[0].
La fórmula (6.2) se generaliza para dos o más dimensiones. En dos dimensiones, escribimos
A[i 1][i 2] en C y Java para el elemento i 2 en la fila i 1. Haga que w 1 sea el tamaño de una fila y
que w 2 sea el tamaño de un elemento en una fila. Así, la dirección relativa de A[i 1][i 2] se puede
calcular mediante la siguiente fórmula:
base + i 1 × w 1 + i 2 × w 2
(6.3)
base + i 1 × w 1 + i 2 × w 2 + … + i k × w k
(6.4)
En k dimensiones, la fórmula es:
en donde w j, para 1 ¥ j ¥ k, es la generalización de w 1 y w 2 en (6.3).
De manera alternativa, la dirección relativa de una referencia a un arreglo puede calcularse
en términos de los números de elementos nj a lo largo de la dimensión j del arreglo y la anchura w = w k de un solo elemento del arreglo. En dos dimensiones (es decir, k = 2 y w = w 2), la
ubicación para A[i 1][i 2] se da mediante:
base + (i1 × n 2 + i 2) × w
(6.5)
En k dimensiones, la siguiente fórmula calcula la misma dirección que (6.4):
base + ((…(i 1 × n 2 + i 2) × n 3 + i 3)…) × n k + i k) × w
(6.6)
382
Capítulo 6. Generación de código intermedio
En forma más general, los elementos de un arreglo no tienen que enumerarse empezando en 0.
En un arreglo unidimensional, los elementos del arreglo se enumeran como inferior, inferior +
1, …, superior y base es la dirección relativa de A[inferior ]. La fórmula (6.2) para la dirección
de A[i ] se sustituye por:
base + (i – inferior) × w
(6.7)
Las expresiones (6.2) y (6.7) pueden rescribirse como i × w + c, en donde la subexpresión
c = base – inferior × w puede calcularse previamente en tiempo de compilación. Observe que c =
base cuando inferior es 0. Asumimos que c se almacena en la entrada de la tabla de símbolos
para A, por lo que la dirección relativa de A[i] se obtiene con sólo sumar i × w a c.
El cálculo previo en tiempo de compilación también puede aplicarse a los cálculos de las
direcciones para los elementos de arreglos multidimensionales; vea el ejercicio 6.4.5. No obstante, hay una situación en la que no podemos usar el cálculo previo en tiempo de compilación:
cuando el tamaño del arreglo es dinámico. Si no conocemos los valores de inferior y superior
(o sus generalizaciones en muchas dimensiones) en tiempo de compilación, entonces no podemos
calcular constantes como c. Entonces, las fórmulas como (6.7) deben evaluarse como están
escritas, cuando el programa se ejecuta.
Los anteriores cálculos de las direcciones se basan en la distribución por filas para los arreglos, la cual se utiliza en C y Java. Por lo general, un arreglo bidimensional se almacena en
dos formas, ya sea en orden por filas (fila por fila) o por columnas (columna por columna). La
figura 6.21 muestra la distribución de un arreglo A de 2 × 3 en (a) forma de orden por filas y
(b) forma de orden por columnas. La forma de orden por columnas se utiliza en la familia de
lenguajes Fortran.
Primera columna
Primera fila
Segunda columna
Segunda fila
Tercera columna
Orden por filas
Orden por columnas
Figura 6.21: Distribuciones para un arreglo bidimensional
Podemos generalizar la forma de orden por filas o por columnas para muchas dimensiones.
La generalización de la forma de orden por filas es para almacenar los elementos de tal forma
que, a medida que exploramos en forma descendente un bloque de almacenamiento, los subíndices de más a la derecha parezcan variar más rápido, al igual que los números en un odómetro. La forma de orden por columnas se generaliza para la distribución opuesta, en donde los
subíndices de más a la izquierda varían más rápido.
383
6.4 Traducción de expresiones
6.4.4
Traducción de referencias a arreglos
El principal problema al generar código para las referencias a arreglos es relacionar las fórmulas
para calcular direcciones en la sección 6.4.3 a una gramática para referencias a arreglos. Suponga que el no terminal L genera un nombre de arreglo, seguido de una secuencia de expresiones
de índices:
L
→
L [ E ] | id [ E ]
Como en C y Java, suponga que el elemento del arreglo con menor numeración es 0. Vamos
a calcular las direcciones con base en los tamaños, mediante la fórmula (6.4) en vez de los números de elementos, como en (6.6). El esquema de traducción de la figura 6.22 genera código
de tres direcciones para las expresiones con referencias a arreglos. Consiste en las producciones
y las acciones semánticas de la figura 6.20, en conjunto con las producciones que involucran al
no terminal L.
{ gen (superior.get (id.lexema) = E.dir); }
{ gen (L.dir.base [ L.dir ] = E.dir); }
{ E.dir = new Temp ();
gen (E.dir = E 1.dir + E 2.dir); }
{ E.dir = superior.get (id.lexema); }
{ E.dir = new Temp ();
gen (E.dir = L.arreglo.base [ L.dir ]); }
{ L.arreglo = superior.get (id.lexema);
L.tipo = L.arreglo.tipo.elem;
L.dir = new Temp ();
gen (L.dir = E.dir ∗ L.tipo.tamaño); }
{ L.arreglo = L 1.arreglo;
L.tipo = L 1.tipo.elem;
t = new Temp ();
L.dir = new Temp ();
gen (t = E.dir ∗ L.tipo.tamaño); }
gen (L.dir = L 1.dir + t); }
Figura 6.22: Acciones semánticas para las referencias a arreglos
El no terminal L tiene tres atributos sintetizados:
1. L.dir denota un nombre temporal que se utiliza al calcular el desplazamiento para la
referencia al arreglo, sumando los términos ij × wj en (6.4).
384
Capítulo 6. Generación de código intermedio
2. L.arreglo es un apuntador a la entrada en la tabla de símbolos para el nombre del arreglo.
La dirección base del arreglo, digamos L.arreglo.base, se usa para determinar el l-value
actual de la referencia a un arreglo, después de analizar todas las expresiones de índices.
3. L.tipo es el tipo del subarreglo generado por L. Para cualquier tipo t, asumimos que su
tamaño se da mediante t.tamaño. Usamos tipos como atributos, en vez de tamaños, ya
que los tipos se necesitan de todas formas para la comprobación de tipos. Para cualquier
tipo de arreglo t, suponga que t.elem proporciona el tipo del elemento.
La producción S → id = E; representa una asignación a una variable que no es un arreglo, la cual se maneja de la forma usual. La acción semántica para S → L = E ; genera una
instrucción de copia indexada, para asignar el valor denotado por la expresión E a la ubicación
denotada por la referencia al arreglo L. Recuerde que el atributo L.arreglo proporciona la entrada en la tabla de símbolos para el arreglo. La dirección base del arreglo (la dirección de su
0-ésimo elemento) se proporciona mediante L.arreglo.base. El atributo L.dir denota el nombre
temporal que contiene el desplazamiento para la referencia al arreglo generada por L. Por lo
tanto, la ubicación para la referencia al arreglo es L.arreglo.base[L.dir]. La instrucción generada
copia el r-value de la dirección E.dir en la ubicación para L.
Las producciones E → E1 + E2 y E → id son las mismas que antes. La acción semántica
para la nueva producción E → L genera código para copiar el valor de la ubicación denotada
por L hacia un nuevo nombre temporal. Esta ubicación es L.arreglo.base[L.dir ], como vimos antes para la producción S → L = E;. De nuevo, el atributo L.arreglo proporciona el nombre del
arreglo, y L.arreglo.base proporciona su dirección base. El atributo L.dir denota el nombre temporal que contiene el desplazamiento. El código para la referencia al arreglo coloca el r-value
en la ubicación designada por la base y el desplazamiento en un nuevo nombre temporal, denotado por E.dir.
Ejemplo 6.12: Suponga que a denota un arreglo de 2 × 3 de enteros, y que c, i y j denotan
enteros. Entonces, el tipo de a es arreglo(2, arreglo(3, integer)). Su anchura w es 24, asumiendo
que la anchura de un entero es 4. El tipo de a[i] es arreglo(3, integer), de anchura w1 = 12. El
tipo de a[i][j] es integer.
En la figura 6.23 se muestra un árbol de análisis sintáctico anotado para la expresión
c+a[i][j]. La expresión se traduce en la secuencia de instrucciones de tres direcciones en la
figura 6.24. Como siempre, hemos usado el nombre de cada identificador para referirnos a su
entrada en la tabla de símbolos. 2
6.4.5
Ejercicios para la sección 6.4
Ejercicio 6.4.1: Agregue a la traducción de la figura 6.19 reglas para las siguientes producciones:
a) E
→ E1 ∗ E2.
b) E
→
+ E1 (suma unaria).
Ejercicio 6.4.2: Repita el ejercicio 6.4.1 para la traducción incremental de la figura 6.20.
385
6.4 Traducción de expresiones
E.dir
E.dir
E.dir
L.arreglo
L.tipo
L.dir
L.arreglo
L.tipo
L.dir
tipo
arreglo
arreglo
E.dir
E.dir
arreglo
Figura 6.23: Árbol de análisis sintáctico anotado para c + a[i][j]
Figura 6.24: Código de tres direcciones para la expresión c + a[i][j]
Ejercicio 6.4.3: Use la traducción de la figura 6.22 para traducir las siguientes asignaciones:
a) x = a[i] + b[j].
b) x = a[i][j] + b[i][j].
! c) x = a[b[i][j]][c[k]].
! Ejercicio 6.4.4: Modifique la traducción de la figura 6.22 para referencias a arreglos del estilo
Fortran; es decir, id[E1, E2, …, En] para un arreglo n-dimensional.
Ejercicio 6.4.5: Generalice la fórmula (6.7) para arreglos multidimensionales, e indique qué
valores pueden almacenarse en la tabla de símbolos y utilizarse para calcular desplazamientos.
Considere los siguientes casos:
a) Un arreglo A de dos dimensiones, en forma de orden por filas. La primera dimensión
tiene índices que van de i 1 a s 1, y la segunda dimensión tiene índices de i 2 a s 2. La anchura de un solo elemento del arreglo es w.
386
Capítulo 6. Generación de código intermedio
Anchuras de tipos simbólicos
El código intermedio debería ser relativamente independiente de la máquina destino, de
manera que el optimizador no tenga que cambiar mucho si el generador de código se sustituye por uno para una máquina distinta. Sin embargo, como hemos descrito el cálculo de
los tamaños de los tipos, hay una suposición en relación con los tipos básicos integrada en
el esquema de traducción. Por ejemplo, el ejemplo 6.12 supone que cada elemento de un
arreglo de enteros ocupa cuatro bytes. Algunos códigos intermedios, por ejemplo, P-code
para Pascal, dejan al generador de código la función de llenar el tamaño de los elementos
de los arreglos, por lo que el código intermedio es independiente del tamaño de una palabra de máquina. Podríamos haber hecho lo mismo en nuestro esquema de traducción si
sustituyéramos el 4 (como la anchura de un entero) por una constante simbólica.
b) Igual que (a), pero con el arreglo almacenado en forma de orden por columnas.
! c) Un arreglo A de k dimensiones, almacenado en forma de orden por filas, con elementos
de tamaño w. La j-ésima dimensión tiene índices que van desde i j hasta s j.
! d) Igual que (c), pero con el arreglo almacenado en forma de orden por columnas.
Ejercicio 6.4.6: Un arreglo de enteros A[i, j ] tiene el índice i que varía de 1 a 10, y el índice j
que varía de 1 a 20. Los enteros ocupan 4 bytes cada uno. Suponga que el arreglo a se almacena
empezando en el byte 0. Encuentre la ubicación de:
a) A[4, 5] b) A[10, 8] c) A[3, 17].
Ejercicio 6.4.7: Repita el ejercicio 6.4.6 si A está almacenado en orden por columnas.
Ejercicio 6.4.8: Un arreglo de números reales A[i, j, k ] tiene el índice i que varía de 1 a 4,
el índice j que varía de 0 a 4 y el índice k que varía de 5 a 10. Los números reales ocupan 8
bytes cada uno. Suponga que el arreglo A se almacena empezando en el byte 0. Encuentre la
ubicación de:
a) A[3, 4, 5] b) A[1, 2, 7] c) A[4, 3, 9].
Ejercicio 6.4.9: Repita el ejercicio 6.4.8 si A está almacenado en orden por columnas.
6.5
Comprobación de tipos
Para realizar la comprobación de tipos, un compilador debe asignar una expresión de tipos a
cada componente del programa fuente. Después, el compilador debe determinar que estas expresiones de tipos se conforman a una colección de reglas lógicas, conocida como el sistema de
tipos para el lenguaje fuente.
La comprobación de tipos tiene el potencial de atrapar errores en los programas. En principio, cualquier comprobación puede realizarse en forma dinámica, si el código de destino lleva el
6.5 Comprobación de tipos
387
tipo de un elemento, junto con el valor del elemento. Un sistema de tipos sólido elimina la necesidad de la comprobación dinámica para los errores de tipos, ya que nos permite determinar
en forma estática que estos errores no pueden ocurrir cuando se ejecuta el programa de destino.
Una implementación de un lenguaje está fuertemente tipificada si un compilador garantiza que
los programas que acepta se ejecutarán sin errores.
Además de su uso para la compilación, las ideas de la comprobación de tipos se han utilizado para mejorar la seguridad de los sistemas que permiten la importación y ejecución de
módulos de software. Los programas en Java se compilan en bytecodes independientes de la
máquina, que incluyen información detallada sobre los tipos, en relación con las operaciones en
los bytecodes. El código importado se verifica antes de permitirle que se ejecute, para protegerse contra los errores inadvertidos y el comportamiento malicioso.
6.5.1
Reglas para la comprobación de tipos
La comprobación de tipos puede tomar dos formas: síntesis e inferencia. La síntesis de tipos
construye el tipo de una expresión a partir de los tipos de sus subexpresiones. Requiere que se
declaren los nombres antes de utilizarlos. El tipo de E1 + E2 se define en términos de los tipos de
E1 y E2. Una regla común para la síntesis de tipos tiene la siguiente forma:
if f tiene el tipo s → t and x tiene el tipo s,
then la expresión f (x) tiene el tipo t
(6.8)
Aquí, f y x denotan expresiones, y s → t denota una función de s a t. Esta regla para las funciones con un argumento se pasa a las funciones con varios argumentos. La regla (6.8) puede
adaptarse para E1 + E2 si la vemos como una aplicación de la función sumar(E1, E2).6
La inferencia de tipos determina el tipo de una construcción del lenguaje a partir de la forma
en que se utiliza. Adelantándonos hasta los ejemplos en la sección 6.5.4, supongamos que null
sea una función que evalúe si una lista está vacía. Entonces, del uso null (x), podemos determinar que x debe ser una lista. No se conoce el tipo de los elementos de x; todo lo que sabemos es
que x debe ser una lista de elementos de algún tipo que hasta el momento se desconoce.
Las variables que representan expresiones de tipos nos permiten hablar acerca de los tipos
desconocidos. Vamos a usar las letras griegas α, β, … para las variables de los tipos en las
expresiones de tipos.
Una regla común para la inferencia de tipos tiene la siguiente forma:
if f (x) es una expresión,
then para cierta α y β, f tiene el tipo α → β and x tiene el tipo α
(6.9)
La inferencia de tipos es necesaria para lenguajes como ML, que comprueban los tipos pero no
requieren la declaración de nombres.
6
Vamos a usar el término “síntesis”, incluso aunque se utilice cierta información de contexto para determinar los
tipos. Con la sobrecarga de funciones, en donde se da el mismo nombre a más de una función, tal vez también haya
que considerar el contexto de E 1 + E 2 en algunos lenguajes.
388
Capítulo 6. Generación de código intermedio
En esta sección, consideramos la comprobación de tipos de las expresiones. Las reglas para
comprobar instrucciones son similares a las de las expresiones. Por ejemplo, tratamos la instrucción condicional “if (E )S;” como si fuera la aplicación de una función if a E y a S. Supongamos que el tipo especial void denota la ausencia de un valor. Entonces, la función if espera
aplicarse a un valor boolean y a un void; el resultado de la aplicación es un void.
6.5.2
Conversiones de tipos
Considere las expresiones como x + i, en donde x es de tipo punto flotante, e i es de tipo entero. Como la representación de enteros y números de punto flotante es distinta dentro de una
computadora, y se utilizan distintas instrucciones de máquina para las operaciones con enteros
y números de punto flotante, tal vez el compilador tenga que convertir uno de los operandos de
+ para asegurar que ambos operandos sean del mismo tipo cuando ocurra la suma.
Suponga que los enteros se convierten a números de punto flotante cuando es necesario,
usando un operador unario (float). Por ejemplo, el entero 2 se convierte a un número de
punto flotante en el código para la expresión 2 * 3.14:
t1 = (float) 2
t2 = t1 * 3.14
Podemos extender dichos ejemplos para considerar versiones enteras y de punto flotante de los
operadores; por ejemplo, int* para los operandos enteros y float* para los operandos de punto
flotante.
En la sección 6.4.2 ilustraremos la síntesis de tipos, extendiendo el esquema para traducir
expresiones. Presentaremos otro atributo E.tipo, cuyo valor es integer o float. La regla asociada
con E → E1 + E2 se basa en el siguiente seudocódigo:
if ( E1.tipo = integer and E2.tipo = integer ) E.tipo = integer;
else if ( E1.tipo = float and E2.tipo = integer ) …
…
A medida que se incrementa el número de tipos sujetos a conversión, el número de casos aumenta con rapidez. Por lo tanto, con números extensos de tipos, es importante una organización cuidadosa de las acciones semánticas.
Las reglas de conversión de tipos varían de un lenguaje a otro. Las reglas para Java en la
figura 6.25 hacen distinciones entre las conversiones de ampliación, que tienen el propósito
de preservar la información, y las conversiones de reducción, que pueden perder información.
Las reglas de ampliación se proporcionan mediante la jerarquía en la figura 6.25(a): cualquier
tipo con un nivel menor en la jerarquía puede ampliarse a un tipo con un nivel mayor. Por
ende, un char puede ampliarse a un int o a un float, pero un char no puede ampliarse a un
short. Las reglas de reducción se ilustran mediante el grafo en la figura 6.25(b): un tipo s
puede reducirse a un tipo t si hay un camino de s a t. Observe que char, short y byte pueden
convertirse entre sí.
389
6.5 Comprobación de tipos
(a) Conversiones de ampliación
(b) Conversiones de reducción
Figura 6.25: Conversiones entre tipos primitivos de Java
Se dice que la conversión de un tipo a otro es implícita si el compilador la realiza en forma
automática. Las conversiones de tipo implícitas, también conocidas como coerciones, están limitadas en muchos lenguajes a las conversiones de ampliación. Se dice que la conversión es explícita
si el programador debe escribir algo para provocar la conversión. A las conversiones explícitas también se les conoce como conversiones o casts.
La acción semántica para comprobar E → E1 + E2 utiliza dos funciones:
1. max(t 1, t 2) recibe dos tipos t 1 y t 2, y devuelve el máximo (o el límite superior) de los dos
tipos en la jerarquía de ampliación. Declara un error si t 1 o t 2 no se encuentran en la jerarquía; por ejemplo, si cualquiera de los dos tipos es un tipo de arreglo o de apuntador.
2. ampliar(a, t, w) genera conversiones de tipos, si es necesario, para ampliar una dirección
a de tipo t en una variable de tipo w. Devuelve la misma a si t y w son del mismo tipo.
En cualquier otro caso, genera una instrucción para realizar la conversión y colocar el
resultado en una t temporal, que se devuelve como el resultado. El seudocódigo para
ampliar, suponiendo que los únicos tipos son integer y float, aparece en la figura 6.26.
Dir ampliar(Dir a, Tipo t, Tipo w)
if ( t = w ) return a;
else if ( t = integer and w = float ) {
temp = new Temp();
gen(temp = (float) a);
return temp;
}
else error;
}
Figura 6.26: Seudocódigo para la función ampliar
390
Capítulo 6. Generación de código intermedio
La acción semántica para E → E1 + E2 en la figura 6.27 ilustra cómo pueden agregarse
conversiones de tipos al esquema en la figura 6.20 para traducir expresiones. En la acción semántica, la variable temporal a1 es E1.dir si el tipo de E1 no necesita convertirse al tipo de E,
o una nueva variable temporal devuelta por ampliar si esta conversión es necesaria. De manera
similar, a2 es E2.dir o un nuevo valor temporal que contiene el valor de E2 con el tipo convertido. Ninguna conversión es necesaria si ambos tipos son integer o ambos son float. Sin embargo,
en general podríamos descubrir que la única forma de sumar valores de dos tipos distintos es
convertir ambos en un tercer tipo.
E
→
E1 + E2
{ E.tipo = max(E1.tipo, E2.tipo);
a1 = ampliar(E1.dir, E1.tipo, E.tipo);
a2 = ampliar(E 2.dir, E 2.tipo, E.tipo);
E1.dir = new Temp();
gen(E.dir = a1 + a2); }
Figura 6.27: Introducción de conversiones de tipos en la evaluación de expresiones
6.5.3
Sobrecarga de funciones y operadores
Una sobrecarga de un símbolo tiene diferentes significados, dependiendo de su contexto. La
sobrecarga se resuelve cuando se determina un significado único para cada ocurrencia de un
nombre. En esta sección nos enfocaremos en la sobrecarga que puede resolverse con sólo ver los
argumentos de una función, como en Java.
Ejemplo 6.13: El operador + en Java denota la concatenación de cadenas o la suma, dependiendo de los tipos de sus operandos. Las funciones definidas por el usuario pueden sobrecargarse también, como en
void err() { ... }
void err(String s) { ... }
Observe que podemos elegir una de estas dos versiones de una función err con sólo ver sus
argumentos. 2
A continuación se muestra una regla de síntesis de tipos para funciones sobrecargadas:
if f puede tener el tipo si → ti para 1 ¥ i ¥ n, en donde si
and x tiene el tipo sk, para alguna 1 ¥ k ¥ n
then la expresión f (x) tiene el tipo tk
sj para i
j
(6.10)
El método número de valor de la sección 6.1.2 puede aplicarse a las expresiones de tipos
para resolver la sobrecarga con base en los tipos de los argumentos, de una forma eficiente. En
un GDA que representa a una expresión de tipos, asignamos un índice entero, conocido como
6.5 Comprobación de tipos
391
número de valor, a cada nodo. Mediante el uso del Algoritmo 6.3 construimos una firma para un
nodo, que consiste en su etiqueta y los números de valor de sus hijos, en orden de izquierda a derecha. La firma para una función consiste en el nombre de la función y los tipos de sus argumentos. La suposición de que podemos resolver la sobrecarga con base en los tipos de los argumentos
es equivalente a decir que podemos resolver la sobrecarga con base en las firmas.
No siempre es posible resolver la sobrecarga con sólo ver los argumentos de una función.
En Ada, en vez de un tipo simple, una subexpresión independiente puede tener un conjunto de
posibles tipos, para los cuales el contexto debe proporcionar suficiente información para reducir
las opciones a un tipo simple (vea el ejercicio 6.5.2).
6.5.4
Inferencia de tipos y funciones polimórficas
La inferencia de tipos es útil para un lenguaje como ML, el cual es fuertemente tipificado, pero
no requiere que se declaren los nombres antes de utilizarlos. La inferencia de tipos asegura que
los nombres se utilicen en forma consistente.
El término “polimórfico” se refiere a cualquier fragmento de código que puede ejecutarse
con argumentos de distintos tipos. En esta sección veremos el polimorfismo paramétros, en
donde el polimorfismo se caracteriza por parámetros o variables de tipo. El ejemplo abierto
es el programa de ML en la figura 6.28, el cual define la función longitud. El tipo de longitud
puede describirse como, “para cualquier tipo α, longitud asigna una lista de elementos de tipo
α a un entero”.
fun longitud(x) =
if null(x) then 0 else longitud(tl(x)) + 1;
Figura 6.28: Programa de ML para la longitud de una lista
Ejemplo 6.14: En la figura 6.28, la palabra clave fun introduce la definición de una función;
las funciones pueden ser recursivas. El fragmento del programa define la función longitud con
un parámetro x. El cuerpo de la función consiste en una expresión condicional. La función predefinida null evalúa si una lista está vacía, y la función predefinida tl (abreviación de “cola” en
inglés) devuelve el residuo de una lista, una vez que se elimina el primer elemento.
La función longitud determina la longitud o el número de elementos de una lista x. Todos los
elementos de una lista deben tener el mismo tipo, pero longitud puede aplicarse a las listas cuyos elementos sean de cualquier tipo. En la siguiente expresión, longitud se aplica a dos tipos
distintos de listas (los elementos de las listas se encierran entre “[” y “]”):
longitud (["dom","lun","mar"]) + longitud ([10, 9, 8, 7])
(6.11)
La lista de cadenas tiene longitud de 3 y la lista de enteros tiene longitud de 4, por lo que la
expresión (6.11) se evalúa en 7. 2
392
Capítulo 6. Generación de código intermedio
Si utilizamos el símbolo ∀ (que significa “para cualquier tipo”) y el constructor de tipos
lista, el tipo de longitud puede escribirse como
∀α. lista(α) → integer
(6.12)
El símbolo ∀ es el cuantificador universal, y se dice que la variable de tipo a la cual se
aplica está enlazada por este símbolo. Las variables enlazadas pueden renombrarse a voluntad,
siempre y cuando se renombren todas las ocurrencias de la variable. Por ende, la expresión de
tipos
∀β. lista(β) → integer
es equivalente a (6.12). A una expresión de tipos con un símbolo ∀ se le conoce de manera
informal como un “tipo polimórfico”.
Cada vez que se aplica una función polimórfica, sus variables de tipo enlazadas pueden
denotar un tipo distinto. Durante la comprobación de tipos, en cada uso de un tipo polimórfico sustituimos las variables enlazadas por variables nuevas y eliminamos los cuantificadores
universales.
El siguiente ejemplo infiere de manera informal un tipo para longitud, usando en forma
implícita las reglas de inferencia de tipos como (6.9), que repetimos a continuación:
if f (x) es una expresión,
then para alguna α y β, f tiene el tipo α → β and x tiene el tipo α
Ejemplo 6.15: El árbol sintáctico abstracto de la figura 6.29 representa la definición de longitud en la figura 6.28. La raíz del árbol, etiquetada como fun, representa a la definición de la
función. El resto de los nodos que no son hojas pueden verse como aplicaciones de funciones. El
nodo etiquetado como + representa la aplicación del operador + a un par de hijos. De manera
similar, el nodo etiquetado como if representa la aplicación de un operador if a una tripleta
formado por sus hijos (para la comprobación de tipos, no importa si se va a evaluar la parte
then o else, pero no ambas).
longitud
aplicar
aplicar
longitud aplicar
Figura 6.29: Árbol de sintaxis abstracto para la definición de la función de la figura 6.28
Del cuerpo de la función longitud podemos inferir su tipo. Considere los hijos del nodo
etiquetado como if, de izquierda a derecha. Como null espera aplicarse a listas, x debe ser una
lista. Vamos a usar la variable α como receptáculo para el tipo de los elementos de la lista; es
decir, x tiene el tipo “lista de α”.
6.5 Comprobación de tipos
393
Sustituciones, instancias y unificación
Si t es una expresión de tipos y S es una sustitución (una asignación de variables de tipos
a expresiones de tipos), entonces escribimos S(t ) para el resultado de sustituir de manera
consistente todas las ocurrencias de cada variable de tipo α en t por S(α). S(t ) se llama
instancia de t. Por ejemplo, lista(integer) es una instancia de lista(α), ya que es el resultado de sustituir integer por α en lista(α). Sin embargo, observe que integer → float no
es una instancia de α → α, ya que una sustitución debe reemplazar todas las ocurrencias
de α por la misma expresión de tipos.
La sustitución S es un unificador de las expresiones de tipos t 1 y t 2 si S(t 1) = S(t 2).
S es el unificador más general de t1 y t2 si para cualquier otro unificador de t 1 y t 2, por
decir S , se da el caso de que para cualquier t, S (t ) es una instancia de S(t ). En palabras,
S impone más restricciones sobre t que S.
Si null(x ) es verdadera, entonces longitud(x ) es 0. Por ende, el tipo de longitud debe ser
“función de la lista de α a entero”. Este tipo inferido es consistente con el uso de longitud en
la parte del else, longitud(tl (x )) + 1. 2
Como las variables pueden aparecer en las expresiones de tipos, tenemos que volver a examinar la noción de la equivalencia de tipos. Suponga que E1 del tipo s → s se aplica a E 2 del
tipo t. En vez de sólo determinar la igualdad de s y t, debemos “unificarlos”. De manera informal, determinamos si s y t pueden hacerse equivalentes en estructura mediante la sustitución
de las variables de tipos en s y t por expresiones de tipos.
Una sustitución es una asignación de las variables de tipos a las expresiones de tipos. Escribimos S(t ) para el resultado de aplicar la sustitución S a las variables en la expresión de
tipos t; consulte el recuadro titulado “Sustituciones, instancias y unificación”. Dos expresiones
de tipos t 1 y t 2 se unifican si existe alguna sustitución S tal que S(t 1) = S(t 2). En la práctica,
estamos interesados en el unificador más general, el cual es una sustitución que impone la menor cantidad de restricciones sobre las variables en las expresiones. En la sección 6.5.5 podrá
consultar un algoritmo de unificación.
Algoritmo 6.16: Inferencia de tipos para funciones polimórficas.
ENTRADA: Un programa que consiste en una secuencia de definiciones de funciones, seguidas
por una expresión a evaluar. Una expresión está compuesta de aplicaciones de funciones y
nombres, en donde los nombres pueden tener tipos polimórficos predefinidos.
SALIDA: Los tipos inferidos para los nombres en el programa.
MÉTODO: Por simplicidad, vamos a tratar sólo con funciones unarias. El tipo de una función
f (x 1, x 2) con dos parámetros puede representarse mediante una expresión de tipos s 1 × s 2 → t,
en donde s 1 y s 2 son los tipos de x 1 y x 2, respectivamente, y t es el tipo del resultado f (x 1, x 2).
Para comprobar una expresión f (a, b) tenemos que relacionar el tipo de a con s 1 y el tipo de
b con s 2.
394
Capítulo 6. Generación de código intermedio
Compruebe las definiciones de funciones y la expresión en la secuencia de entrada. Use el
tipo inferido de una función si se utiliza subsecuentemente en una expresión.
• Para una definición de función fun id1(id2) = E, cree nuevas variables de tipo α y β.
Asocie el tipo α → β con la función id1 y el tipo α con el parámetro id2. Después, infiera
un tipo para la expresión E. Suponga que α denota al tipo s y β denota al tipo t después
de la inferencia de tipos para E. El tipo inferido de la función id1 es s → t. Enlace cualquier variable de tipo que permanezca libre en s → t mediante cuantificadores ∀.
• Para una aplicación de función E1 (E 2), infiera los tipos para E1 y E 2. Ya que E1 se utiliza
como una función, su tipo debe tener la forma s → s . Técnicamente, el tipo de E1 debe
unificarse con β → γ, en donde β y γ son variables de tipos nuevas. Deje que t sea el tipo
inferido de E1. Unifique a s y t. Si la unificación falla, la expresión tiene un error de tipo. En
cualquier otro caso, el tipo inferido de E1(E 2) es s .
• Para cada ocurrencia de una función polimórfica, sustituya las variables enlazadas en su
tipo por variables nuevas distintas y elimine los cuantificadores ∀. La expresión de tipos
resultante es el tipo inferido de esta ocurrencia.
• Para un nombre que se encuentre por primera vez, introduzca una nueva variable para su
tipo.
2
Ejemplo 6.17: En la figura 6.30, inferimos un tipo para la función longitud. La raíz del árbol
sintáctico en la figura 6.29 es para una definición de función, por lo que introducimos las variables β y γ, asociamos el tipo β → γ con la función longitud y el tipo β con x ; vea las líneas
1-2 de la figura 6.30.
En el hijo derecho de la raíz, vemos a if como una función polimórfica que se aplica a una
tripleta, la cual consiste en un valor booleano y dos expresiones que representan las partes
then y else. Su tipo es ∀α. boolean × α × α → α.
Cada aplicación de una función polimórfica puede ser a un tipo distinto, por lo que creamos
una nueva variable αi (en donde i es de “if”) y eliminamos el ∀; vea la línea 3 de la figura 6.30.
El tipo del hijo izquierdo de if debe unificarse con boolean, y los tipos de sus otros dos hijos
deben unificarse con α.
La función predefinida null tiene el tipo ∀α. lista(α) → boolean. Usamos una nueva variable
de tipos αn (en donde n es de “null”) en vez de la variable enlazada α; vea la línea 4. De la aplicación de null a x, podemos inferir que el tipo β de x debe coincidir con lista(αn); vea la línea 5.
En el primer hijo de if, el tipo boolean para null(x) coincide con el tipo esperado por if. En
el segundo hijo, el tipo αi se unifica con integer; vea la línea 6.
Ahora, considere la subexpresión longitud(tl(x)) + 1. Creamos una nueva variable αt (en
donde t es de “tail” [cola]) para la variable enlazada α en el tipo de tl; vea la línea 8. De la aplicación tl(x), inferimos que lista(αt) = β = lista(αn); vea la línea 9.
Como longitud(tl(x)) es un operando de +, su tipo γ debe unificarse con integer; vea la
línea 10. Se deduce que el tipo de longitud es lista(αn) → integer. Una vez que se comprueba
395
6.5 Comprobación de tipos
LÍNEA
EXPRESIÓN
TIPO
UNIFICAR
longitud
lista
lista
lista
lista
lista
lista
lista
longitud
longitud
Figura 6.30: Inferencia de un tipo para la función longitud de la figura 6.28
la definición de la función, la variable de tipo αn permanece en el tipo de longitud. Como no se
hicieron suposiciones acerca de αn, puede sustituir a cualquier tipo cuando se utiliza la función.
Por lo tanto, creamos una variable enlazada y escribimos lo siguiente:
∀αn. lista(αn) → integer
para el tipo de longitud.
6.5.5
2
Un algoritmo para la unificación
De manera informal, la unificación es el problema de determinar si dos expresiones s y t pueden
hacerse idénticas mediante la sustitución de expresiones por las variables en s y t. La prueba
de igualdad de las expresiones es un caso especial de unificación; si s y t tienen constantes pero
no variables, entonces s y t se unifican si y sólo si son idénticas. El algoritmo de unificación
en esta sección se extiende a los grafos con ciclos, por lo que puede utilizarse para evaluar la
equivalencia estructural de los tipos circulares.7
Vamos a implementar una formulación gráfica-teórica de unificación, en donde los tipos se
representan mediante grafos. Las variables de tipos se representan por hojas y los constructores
de tipos se representan mediante nodos interiores. Los nodos se agrupan en clases de equivalencia; si dos nodos se encuentran en la misma clase de equivalencia, entonces las expresiones
de tipos que representan deben unificarse. Por ende, todos los nodos interiores en la misma
clase deben ser para el mismo constructor de tipo, y sus correspondientes hijos deben ser equivalentes.
Ejemplo 6.18: Considere las dos expresiones de tipos siguientes:
7
En algunas aplicaciones, es un error unificar una variable con una expresión que contenga a esa variable. El algoritmo 6.19 permite dichas sustituciones.
396
Capítulo 6. Generación de código intermedio
((α1 → α2) × lista(α3)) → lista(α2)
((α3 → α4) × lista(α3)) → α5
La siguiente sustitución S es el unificador más general para estas expresiones:
x
α1
α2
α3
α4
α5
S(x )
α1
α2
α1
α2
lista(α2)
Esta sustitución asigna las dos expresiones de tipos a la siguiente expresión:
((α1 → α2) × lista(α1)) → lista(α2)
Las dos expresiones se representan mediante los dos nodos etiquetados como →: 1 en la figura
6.31. Los enteros en los nodos indican las clases de equivalencias a las que pertenecen los nodos,
una vez que se unifican los nodos enumerados con el 1. 2
lista
lista
lista
Figura 6.31: Clases de equivalencias después de la unificación
Algoritmo 6.19: Unificación de un par de nodos en un grafo de tipos.
ENTRADA: Un grafo que representa a un tipo y un par de nodos m y n que se van a uni-
ficar.
SALIDA: El valor booleano verdadero si las expresiones representadas por los nodos m y n se
unifican; falso en cualquier otro caso.
MÉTODO: Se implementa un nodo mediante un registro con campos para un operador binario
y apuntadores a los hijos izquierdo y derecho. Los conjuntos de nodos equivalentes se mantienen
usando el campo set. Se elige un nodo en cada clase de equivalencia como el único representante de la clase de equivalencia, haciendo que su campo set contenga un apuntador nulo. Los
campos set de los nodos restantes en la clase de equivalencia apuntarán (quizá en forma indirecta a través de otros nodos en el conjunto) al representante. Al principio, cada nodo n es una
clase de equivalencia por sí solo, con n como su propio nodo representativo.
397
6.5 Comprobación de tipos
boolean unificar (Nodo m, Nodo n) {
s = buscar (m); t = buscar (n);
if ( s = t ) return true;
else if ( los nodos s y t representan el mismo tipo básico ) return true;
else if (s es un nodo op con los hijos s 1 y s 2 and
t es un nodo op con los hijos t 1 y t 2) {
union(s, t );
return unificar(s 1, t 1) and unificar(s 2, t 2);
}
else if s o t representa a una variable {
union(s, t );
return true;
}
else return false;
}
Figura 6.32: Algoritmo de unificación
El algoritmo de unificación, que se muestra en la figura 6.32, utiliza las siguientes dos operaciones con los nodos:
• buscar (n) devuelve el nodo representativo de la clase de equivalencia que contiene actualmente el nodo n.
• union (m, n) combina las clases de equivalencias que contienen los nodos m y n. Si uno
de los representantes para las clases de equivalencias de m y n es un nodo no variable,
union convierte a ese nodo no variable en el representante para la clase de equivalencia
combinada; en caso contrario, union convierte a uno de los dos representantes originales
en el nuevo representante. Esta asimetría en la especificación de union es importante, ya
que una variable no puede utilizarse como representante de una clase de equivalencia para
una expresión que contenga un constructor de tipos o un tipo básico. De lo contrario, dos
expresiones equivalentes podrían unificarse a través de esa variable.
La operación union en los conjuntos se implementa con sólo cambiar el campo set del representante de una clase de equivalencia para que apunte al representante del otro. Para encontrar
la clase de equivalencia a la que pertenece un nodo, seguimos los apuntadores set de los nodos
hasta llegar al representante (el nodo con un apuntador nulo en el campo set ).
Observe que el algoritmo en la figura 6.32 utiliza s = buscar (m) y t = buscar (n) en vez de m
y n, respectivamente. Los nodos representantes s y t son iguales si m y n están en la misma clase
de equivalencia, Si s y t representan el mismo tipo básico, la llamada a unificar (m, n) devuelve
verdadero. Si s y t son nodos interiores para un constructor de tipos binarios, combinamos sus
clases de equivalencias en base a la especulación y comprobamos en forma recursiva que sus respectivos hijos sean equivalentes. Al combinar primero, reducimos el número de clases de equivalencias antes de comprobar los hijos en forma recursiva, por lo que el algoritmo termina.
398
Capítulo 6. Generación de código intermedio
La sustitución de una expresión por una variable se implementa agregando la hoja para la
variable a la clase de equivalencia que contiene el nodo para esa expresión. Suponga que m o n son
una hoja para una variable. Suponga también que esta hoja se ha colocado en una clase de equivalencia con un nodo que representa a una expresión con un constructor de tipos o un tipo básico.
Entonces, buscar devolverá un representante que refleje el constructor de tipos o el tipo básico, de
manera que una variable no pueda unificarse con dos expresiones distintas. 2
Ejemplo 6.20: Suponga que las dos expresiones en el ejemplo 6.18 se representan mediante
el grafo inicial en la figura 6.33, en donde cada nodo está en su propia clase de equivalencia.
Cuando se aplica el Algoritmo 6.19 para calcular unificar (1, 9), observa que los nodos 1 y 9
representan al mismo operador. Por lo tanto, combina a 1 y 9 en la misma clase de equivalencia
y llama unificar (2, 10) y a unificar (8, 14). El resultado de calcular unificar (1, 9) es el grafo que
se mostró antes en la figura 6.31.
2
lista
lista
lista
Figura 6.33: Grafo inicial con cada nodo en su propia clase de equivalencia
Si el Algoritmo 6.19 devuelve verdadero, podemos construir una sustitución S que actúe
como el unificador, como se muestra a continuación. Para cada variable α, buscar(α) proporciona el nodo n que representa a la clase de equivalencia de α. La expresión representada por
n es S (α). Por ejemplo, en la figura 6.31 podemos ver que el representante para α3 es el nodo 4,
que representa a α1. El representante para α5 es el nodo 8, que representa a lista (α2). La
sustitución S resultante es como en el ejemplo 6.18.
6.5.6
Ejercicios para la sección 6.5
Ejercicio 6.5.1: Suponiendo que la función ampliar en la figura 6.26 pueda manejar cualquiera de los tipos en la jerarquía de la figura 6.25(a), traduzca las expresiones que se muestran a
continuación. Suponga que c y d son caracteres, que s y t son enteros cortos, y que i y j son
enteros, y x es un número de punto flotante.
a) x = s + c.
b) i = s + c.
c) x = (s + c) * (t + d).
6.6 Flujo de control
399
Ejercicio 6.5.2: Como en Ada, suponga que cada expresión debe tener un tipo único, pero
que a partir de esa expresión por sí sola, todo lo que podemos deducir es un conjunto de posibles tipos. Es decir, la aplicación de la función E1 al argumento E2, representada por E → E1
(E2), tiene la siguiente regla asociada:
E.tipo = { t | para alguna s en E2.tipo, s → t está en E1.tipo }
Describa una definiciones dirigidas por la sintaxis que determine un tipo único para cada
subexpresión, usando un atributo tipo para sintetizar un conjunto de posibles tipos de abajo
hacia arriba y, una vez que se determine el tipo único de la expresión en general, proceda de
arriba hacia abajo para determinar el atributo único para el tipo de cada subexpresión.
6.6
Flujo de control
La traducción de instrucciones como if-else y while está enlazada a la traducción de expresiones booleanas. En los lenguajes de programación, las expresiones booleanas se utilizan con
frecuencia para:
1. Alterar el flujo de control. Las expresiones booleanas se utilizan como expresiones condicionales en las instrucciones que alteran el flujo de control. El valor de dichas expresiones
booleanas es implícito en una posición a la que se llega en un programa. Por ejemplo, en
if (E ) S, la expresión E debe ser verdadera si se llega a la instrucción S.
2. Calcular los valores lógicos. Una expresión booleana puede representar a true o false como
valores. Dichas expresiones booleanas pueden evaluarse en analogía con las expresiones
aritméticas, mediante las instrucciones de tres direcciones con los operadores lógicos.
El uso que se pretende de las expresiones booleanas se determina mediante su contexto
sintáctico. Por ejemplo, una expresión que va después de la palabra clave if se utiliza para
alterar el flujo de control, mientras que una expresión del lado derecho de una asignación se
utiliza para denotar un valor lógico. Dichos contextos sintácticos pueden especificarse en una
variedad de formas: podemos usar dos no terminales distintos, usar los atributos heredados o
establecer una bandera durante el análisis sintáctico. De manera alternativa, podemos construir un árbol sintáctico e invocar distintos procedimientos para los dos usos distintos de las
expresiones booleanas.
Esta sección se concentra en el uso de las expresiones booleanas para alterar el flujo de
control. Por claridad, presentaremos una nueva no terminal B para este fin. En la sección 6.6.6
consideraremos la forma en que un compilador puede permitir que las expresiones booleanas
representen valores lógicos.
6.6.1
Expresiones booleanas
Las expresiones booleanas están compuestas de los operadores booleanos (que denotamos
como &&, || y !, usando la convención de C para los operadores AND, OR y NOT, respectivamente) que se aplican a elementos que son variables booleanas o expresiones relacionales.
Estas expresiones relacionales son de la forma E 1 rel E 2, en donde E 1 y E 2 son expresiones
400
Capítulo 6. Generación de código intermedio
aritméticas. En esta sección, consideraremos las expresiones booleanas generadas por la siguiente gramática:
B
→
B || B | B && B | !B | ( B ) | E rel E | true | false
Utilizamos el atributo rel.op para indicar cuál de los seis operadores de comparación <, <=,
=, ! =, > o >= se representa mediante rel. Como es costumbre, suponemos que || y && son
asociativos por la izquierda, y que || tiene la menor precedencia, después && y después !.
Dada la expresión B 1 || B 2, si determinamos que B 1 es verdadera, entonces podemos concluir que toda la expresión es verdadera sin tener que evaluar B 2. De manera similar, dada la
expresión B 1&&B 2, si B 1 es falsa, entonces toda la expresión es falsa.
La definición semántica del lenguaje de programación determina si deben evaluarse todas
las partes de una expresión booleana. Si la definición de lenguaje permite (o requiere) que se
queden sin evaluar partes de una expresión booleana, entonces el compilador puede optimizar
la evaluación de expresiones booleanas calculando sólo lo suficiente de una expresión como para
poder determinar su valor. Por ende, en una expresión como B 1 || B 2, no necesariamente se
evalúan por completo B 1 o B 2. Si B 1 o B 2 es una expresión con efectos adicionales (por ejemplo, si contiene una función que modifique a una variable global), entonces puede obtenerse
una respuesta inesperada.
6.6.2
Código de corto circuito
En el código de corto circuito (o de salto), los operadores booleanos &&, || y ! se traducen en
saltos. Los mismos operadores no aparecen en el código; en vez de ello, el valor de una expresión booleana se representa mediante una posición en la secuencia de código.
Ejemplo 6.21: La instrucción
if ( x < 100 || x > 200 && x != y ) x = 0;
podría traducirse en el código de la figura 6.34. En esta traducción, la expresión booleana es
verdadera si el control llega a la etiqueta L2. Si la expresión es falsa, el control pasa de inmediato a L1, ignorando a L2 y la asignación x = 0.
2
Figura 6.34: Código de salto
401
6.6 Flujo de control
6.6.3
Instrucciones de f lujo de control
Ahora consideraremos la traducción de expresiones booleanas en código de tres direcciones, en
el contexto de instrucciones como las que se generan mediante la siguiente gramática:
S → if ( B ) S1
S → if ( B ) S1 else S2
S → while ( B ) S1
En estas producciones, el no terminal B representa a una expresión booleana y en la no terminal S representa a una instrucción.
Esta gramática generaliza el ejemplo abierto de las expresiones while que presentamos en
el ejemplo 5.19. Como en ese ejemplo, tanto B como S tienen un atributo sintetizado llamado
codigo, el cual proporciona la traducción en instrucciones de tres direcciones. Por simplicidad,
generamos las traducciones B.codigo y S.codigo como cadenas, usando definiciones dirigidas
por la sintaxis. Las reglas semánticas que definen los atributos codigo podrían implementarse
en vez de generar árboles sintácticos y después emitir código durante el recorrido de un árbol,
o mediante cualquiera de los métodos descritos en la sección 5.5.
La traducción de if (B) S1 consiste en B.codigo seguida de S1.codigo, como se muestra en
la figura 6.35(a). Dentro de B.codigo hay saltos con base en el valor de B. Si B es verdadera,
el control fluye hacia la primera instrucción de S1.codigo, y si B es falsa, el control fluye a la
instrucción que sigue justo después de S1.codigo.
a
codigo
a
codigo
a
codigo
a
codigo
siguiente
codigo
siguiente
inicio
codigo
codigo
inicio
Figura 6.35: Código para las instrucciones if, if-else y while
Las etiquetas para los saltos en B.codigo y S.codigo se administran usando atributos heredados. Con una expresión booleana B, asociamos dos etiquetas: B.true, la etiqueta hacia la
402
Capítulo 6. Generación de código intermedio
cual fluye el control si B es verdadera, y B.false, la etiqueta hacia la cual fluye el control si B
es falsa. Con una instrucción S, asociamos un atributo heredado S.siguiente que denota a una
etiqueta para la instrucción que sigue justo después del código para S. En algunos casos, la
instrucción que va justo después de S.codigo es un salto hacia alguna etiqueta L. Un salto hacia
un salto a L desde el interior de S.codigo se evita mediante el uso de S.siguiente.
La definición dirigida por la sintaxis en la figura 6.36-6.37 produce código de tres direcciones para las expresiones booleanas en el contexto de instrucciones if, if-else y while.
PRODUCCIÓN
REGLAS SEMÁNTICAS
S.siguiente = nuevaetiqueta ()
P.codigo = S.codigo || etiqueta (S.siguiente)
S.codigo = assign.codigo
B.true = nuevaetiqueta ()
B.false = S 1.siguiente = S.siguiente
S.codigo = B.codigo || etiqueta (B.true) ||S 1.codigo
B.true = nuevaetiqueta ()
B.false = nuevaetiqueta ()
S 1.siguiente = S 2.siguiente = S.siguiente
S.codigo = B.codigo
|| etiqueta (B.true) || S 1.codigo
|| gen ( goto S.siguiente)
|| etiqueta (B.false) || S 2.codigo
inicio = nuevaetiqueta ()
B.true = nuevaetiqueta ()
B.false = S.siguiente
S 1.siguiente = inicio
S.codigo = etiqueta (inicio) || B.codigo
|| etiqueta (B.true) || S 1.codigo
|| gen ( goto inicio)
S 1.siguiente = nuevaetiqueta ()
S 2.siguiente = S.siguiente
S.codigo = S 1.codigo || etiqueta (S 1.siguiente) || S 2.codigo
Figura 6.36: Definición dirigida por la sintaxis para las instrucciones de flujo de control
Suponemos que nuevaEtiqueta() crea una nueva etiqueta cada vez que se llama, y que etiqueta(L)
adjunta la etiqueta L a la siguiente instrucción de tres direcciones que se va a generar.8
8
Si se implementan en sentido literal, las reglas semánticas generarán muchas etiquetas y pueden adjuntar más de una
etiqueta a una instrucción de tres direcciones. El método de “parcheo de retroceso” de la sección 6.7 crea etiquetas
6.6 Flujo de control
403
Un programa consiste en una instrucción generada por P → S. Las reglas semánticas asociadas con esta producción inicializan S.siguiente con una nueva etiqueta. P.codigo consiste
en S.codigo seguido de la nueva etiqueta S.siguiente. El token asigna en la producción S →
asigna es un receptor para las instrucciones de asignación. La traducción de las asignaciones
es como se describió en la sección 6.4; para esta explicación del flujo de control, S.codigo es
simplemente asigna.codigo.
Al traducir S → if (B ) S1, las reglas semánticas en la figura 6.36 crean una nueva etiqueta
B.true y la adjuntan a la primera instrucción de tres direcciones generada para la instrucción
S1, como se muestra en la figura 6.35(a). Por ende, los saltos a B.true dentro del código para B
irán al código para S1. Además, al establecer B.false a S.siguiente, aseguramos que el control
ignore el código para S1, si B se evalúa como falsa.
Al traducir la instrucción if-else S → if (B ) S1 else S2, el código para la expresión booleana
B tiene saltos hacia fuera de ésta, que van a la primera instrucción del código para S1 si B es
verdadera, y a la primera instrucción del código para S2 si B es falsa, como se ilustra en la figura 6.35(b). Además, el control fluye desde S1 y S2 hacia la instrucción de tres direcciones que
va justo después del código para S; su etiqueta se proporciona mediante el atributo heredado
S.siguiente. Una instrucción goto S.siguiente explícita aparece después del código de S1 para
ignorar el código de S2. No se necesita un goto después de S2, ya qu
Download