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