Universidad Nacional del Santa Curso: Teoría de Compiladores INTRODUCCION La computadora es la máquina más versátil concebida por el hombre. Aunque inicialmente es construida como un mecanismo de cálculo de prestaciones superiores a los primeros dispositivos mecánicos y electromecánicos, con el paso del tiempo se le incorporaron capacidades para la realización de operaciones lógicas y para la manipulación de datos no numéricos. Un largo proceso evolutivo ha llevado a este dispositivo calculador de la aritmética de cifras a la generación de información, teniendo como meta inmediata la manipulación del conocimiento y como objetivo futuro la generación de conciencia. Las actuales computadoras son capaces de realizar los más complejos cálculos aritméticos, lógicos y simbólicos, de emular los más elaborados mecanismos (incluyendo a otros computadores), de simular eventos naturales y de crear mundos virtuales. Las crecientes capacidades con que se presentan generación tras generación acercan a los nuevos computadores cada vez más a la realización de tareas mucho más complejas y que se antojan imposibles, como pueden ser la emulación de la mente y el pensamiento. Todo esto se sustenta, por supuesto, en la electrónica y en la capacidad de programación del computador. Sabemos que la electrónica del computador se denomina digital y que funciona con base en valores discretos. Sabemos que es a través de códigos y estrategias de representación de datos como podemos alimentar al computador con nuestras ideas y las expresiones con que identificamos a los objetos en nuestro mundo análogo y tridimensional. También sabemos que mediante reglas de operación perfectamente definidas podemos instruir al computador en la manipulación de dichos datos que llevarán a la generación de otros y a la obtención de información en la resolución de problemas. La forma de expresar dicha instrucción y la manera de llevar a cabo su traducción a lo que el computador es realmente capaz de procesar son el tema de este curso. El tema del diseño de compiladores es usualmente visto como uno de los más complejos, áridos y abstractos. Adicionalmente suele considerarse que estos temas no dejan de ser de un interés meramente académico, a menos que se trate de una enorme compañía de software dedicada a la creación de herramientas de desarrollo. Tales creencias han dado lugar a una enorme variedad de mitos, algunos de estos por ejemplo son: "Para competir con productos de calidad en un mercado tan dinámico, amplio e internacional como es el de la informática y computación, se requiere de una enorme cantidad de recursos humanos, materiales y económicos. El desarrollo de herramientas de programación como intérpretes y compiladores, ya sea para uso propio o comercialización, está reservado para las empresas que disponen de dichos recursos. La mediana y pequeña empresa no cuenta con los recursos necesarios para desarrollos internos de esta naturaleza, además de que no los necesita; si se trata de empresas dedicadas al desarrollo de programas su mercado o está únicamente en el campo de la consultoría y el desarrollo de sistemas a la medida o programación por contrato." "El conocimiento teórico asociado con el diseño y creación de compiladores y lenguajes de programación no son necesarios para un gerente de sistemas o líder de proyecto, esto es sólo para el académico, lo importante es estar al día en lo que a tendencias y productos de hardware y software se refiere." Docente: Ing. Mirko Manrique Ronceros ~1~ Universidad Nacional del Santa Curso: Teoría de Compiladores "Las actividades en informática y computación en nuestro país están dedicadas al desarrollo de software administrativo en su mayoría. Es poco probable que los profesionistas en estas áreas se enfrenten al reto del desarrollo de un compilador o de un lenguaje de programación." "Es más barato comprar software que desarrollarlo. Desarrollar software es lento y caro, es más rápido comprar algo hecho." "El diseño de compiladores y lenguajes de programación es una actividad reservada para investigadores y catedráticos en ciencias computacionales." "Siempre hay que usar un compilador, el código fuente queda seguro y la ejecución es más rápida. Los intérpretes no son adecuados para el desarrollo de sistemas de información, estos son sólo un recurso para la programación de computadoras pequeñas y para el usuario final." "Los compiladores son hechos por nerds o gurús de la computación que dominan obscuros lenguajes de programación, programan en lenguaje máquina y tienen un profundo conocimiento de la arquitectura del computador." Aunque al final del curso se darán cuenta el porqué las aseveraciones anteriores deben ser consideradas como falsas o imprecisas. Lenguaje C++, Fortran, COBOL, Pascal Java Modelo Compilado AWK, Basic, SQL, Lisp, Forth Interpretado Pseudo Compilado Características Sintaxis específica para tipos de datos. Ideal para el desarrollo de programas veloces o de tamaño reducido. Permiten la explotación de instrucciones especiales del microprocesador. Mayor seguridad para evitar alteración o robo de código fuente. Transportabilidad absoluta. Requiere de una máquina virtual para ser ejecutado. Mejor desempeño que un programa interpretado pero más lento que uno compilado. Lenguajes de sintaxis rigurosa. Requiere del intérprete para su ejecución. Desempeño lento. Ideal para desarrollos rápidos (prototipos), operaciones no planeadas y programas pequeños y simples. Lenguajes de sintaxis más relajadas y mayor libertad para la conversión de datos. Con el paso del tiempo los diversos lenguajes de programación han madurado. Hoy en día es posible categorizarlos por las características que han venido exhibiendo con dicha maduración. De manera que tenemos: 1GL o lenguajes de primera generación.- Esta fue (y continua siendo) aquella a la que pertenece el Lenguaje Máquina, el nivel en el que datos instrucciones son dados como una serie de códigos (binarios, octales, decimales o hexadecimales). 2GL o lenguajes de segunda generación.- Todos aquellos lenguajes ensambladores. 3GL o lenguajes de tercera generación.- También conocidos como lenguajes de nivel alto. 4GL o lenguajes de cuarta generación.- Generalmente un lenguaje 4GL es un lenguaje de propósito específico, que proveen un lenguaje muy cercano al lenguaje natural o simbólico manejado en un ámbito específico. Muchos lenguages son llamados 4GL cuando en realidad sólo son una mezcla de 3GL y 4GL o 3GL con extensiones de de dominio especícifico. Por ejemplo, el comando list en dBASE es un comando propio de un 4GL pero las aplicaciones programadas en dBASE son 3GL. El siguiente Docente: Ing. Mirko Manrique Ronceros ~2~ Universidad Nacional del Santa Curso: Teoría de Compiladores ejemplo ilustra la diferencia de una sintaxis 3GL y 4GL para abrir un registro de clientes y mostrar su contenido en pantalla. 5GL o lenguajes de quinta generación.- Estos lenguajes comienzan a ser identificados como aquellos que hacen uso de los ambientes gráficos para llevar a cabo la programación del computador a través de iconos o elementos gráficos similares. COMPILADORES Y PROGRAMAS RELACIONADOS: DEFINICIONES Y CONCEPTOS Aunque es equivocado, es común encontrar referencias en documentación de productos, publicidad y textos (e inclusive escuchar a la gente del medio informático) utilizando los términos traductor, compilador e intérprete de una forma libre e indistinta. Estas palabras no se utilizan para identificar de manera genérica a un programa que nos permitiría poder programar una computadora. Debemos ser precisos al emplear estas palabras, ya que se refieren a programas de distinta naturaleza que realizan labores encaminadas a un objetivo específico y particular. Aunque la conducta manifestada pueda ser similar, su comportamiento interno definitivamente es diferente. Genéricamente hablando, en ciencias de la computación, los procesadores de lenguajes son aquellos programas destinados a trabajar sobre una entrada que, por la forma como ha sido elaborada, pertenece a un lenguaje en particular reconocido o aceptado por el programa en cuestión. Los procesadores de lenguajes se clasifican como traductores o intérpretes. TRADUCTOR Un traductor es un programa que recibe una entrada escrita en un lenguaje (el lenguaje fuente) a una salida perteneciente a otro lenguaje (el lenguaje objeto), conservando su significado. En términos computacionales esto significa que tanto la entrada como la salida sean capaces de producir los mismos resultados. INTERPRETE Un intérprete, por otra parte, no lleva a cabo tal transformación; en su lugar obtiene los resultados conforme va analizando la entrada. Los intérpretes son útiles para el desarrollo de prototipos y pequeños programas para labores no previstas. Presentan la facilidad de probar el código casi de manera inmediata, sin tener que recurrir a la declaración previa de secciones de datos o código, y poder hallar errores de programación rápidamente. Resultan inadecuados para el desarrollo de complejos o grandes sistemas de información por ser más lentos en su ejecución. Los traductores son clasificados en compiladores, ensambladores y preprocesadores. Compiladores Un compilador es un programa que recibe como entrada un programa escrito en un lenguaje de nivel medio o superior (el programa fuente) y lo transforma a su equivalente en lenguaje ensamblador (el programa objeto), e inclusive hasta lenguaje máquina (el programa ejecutable) pero sin ejecutarlo. Un compilador es un traductor. La forma de como llevará a cabo tal traducción es el objetivo central en el diseño de un compilador. Docente: Ing. Mirko Manrique Ronceros ~3~ Universidad Nacional del Santa Programa Fuente Curso: Teoría de Compiladores Compilado r Programa Objeto Un compilador es un programa muy complejo con un número de líneas de código que puede variar de 10,000 a 1,000.000. Escribir un programa de esta naturaleza o incluso comprenderlo, no es tarea fácil, y la mayoría de los científicos y profesionales de la computación nunca escribirán un compilador completo. Ensamblador Un ensamblador es el programa encargado de llevar a cabo un proceso denominado de ensamble o ensamblado. Este proceso consiste en que, a partir de un programa escrito en lenguaje ensamblador, se produzca el correspondiente programa en lenguaje máquina (sin ejecutarlo), realizando: La integración de los diversos módulos que conforman al programa. La resolución de las direcciones de memoria designadas en el área de datos para el almacenamiento de variables, constantes y estructuras complejas; así como la determinación del tamaño de éstas. La identificación de las direcciones de memoria en la sección de código correspondientes a los puntos de entrada en saltos condicionales e incondicionales junto con los puntos de arranque de las subrutinas. La resolución de los diversos llamados a los servicios o rutinas del sistema operativo, código dinámico y bibliotecas de tiempo de ejecución. La especificación de la cantidad de memoria destinada para las áreas de datos, código, pila y montículo necesarios y otorgados para su ejecución. La incorporación de datos y código necesarios para la carga del programa y su ejecución. Precompilador Un precompilador, también llamado preprocesador, es un programa que se ejecuta antes de invocar al compilador. Este programa es utilizado cuando el programa fuente, escrito en el lenguaje que el compilador es capaz de reconocer (de aquí en adelante denominado lenguaje anfitrión-- en inglés host language), incluye estructuras, instrucciones o declaraciones escritas en otro lenguaje (el lenguaje empotrado-- en inglés embeded language). El lenguaje empotrado es siempre un lenguaje de nivel superior o especializado (e.g. de consulta, de cuarta generación, simulación, cálculo numérico o estadístico, etcétera). Siendo que el único lenguaje que el compilador puede trabajar es áquel para el cual ha sido escrito, todas las instrucciones del lenguaje empotrado deben ser traducidas a instrucciones del lenguaje anfitrión para que puedan ser compiladas. Así pu es un precompilador también es un traductor. Los precompiladores son una solución rápida y barata a la necesidad de llevar las instrucciones de nuevos paradigmas de programación (e.g. los lenguajes de cuarta generación), extensiones a lenguajes ya existentes (como el caso de C y C++) y soluciones de nivel conceptual superior (por ejemplo paquetes de simulación o cálculo numérico) a código máquina utilizando la tecnología existente, probada, optimizada y confiable (lo que evita el desarrollo de nuevos compiladores). Facilitan la incorporación de las nuevas herramientas de desarrollo en sistemas ya elaborados (por ejemplo, la consulta a bases de datos relacionales substituyendo las instrucciones de acceso a archivos por consultas en SQL). Docente: Ing. Mirko Manrique Ronceros ~4~ Universidad Nacional del Santa Curso: Teoría de Compiladores Resulta común encontrar que el flujo de proceso en los lenguajes de cuarta generación o de propósito especial puede resultar demasiado inflexible para su implantación en los procesos de una empresa, flujos de negocio o interacción con otros elementos de software y hardware, de aquí que se recurra o prefiera la creación de sistemas híbridos soportados en programas elaborados en lenguajes de tercera generación con instrucciones empotradas de nivel superior o propósito especial. Pseudocompilador Un pseudocompilador es un programa que actúa como un compilador, salvo que su producto no es ejecutable en ninguna máquina real sino en una máquina virtual. Un pseudocompilador toma de entrada un programa escrito en un lenguaje determinado y lo transforma a una codificación especial llamada código de byte. Este código no tendría nada de especial o diferente al código máquina de cualquier microprocesador salvo por el hecho de ser el código máquina de un microprocesador ficticio. Tal procesador no existe, en su lugar existe un programa que emula a dicho procesador, de aquí el nombre de máquina virtual. La ventaja de los pseudocompiladores que permite tener tantos emuladores como microprocesadores reales existan, pero sólo se requiere un compilador para producir código que se ejecutará en todos estos emuladores. Este método es una de las respuestas más aceptadas para el problema del tan ansiado lenguaje universal o código portable independiente de plataforma. Un intérprete es un programa que ejecuta cada una de las instrucciones y declaraciones que encuentra conforme va analizando el programa que le ha sido dado de entrada (sin producir un programa objeto o ejecutable). La ejecución consiste en llamar a rutinas ya escritas en código máquina cuyos resultados u operaciones están asociados de manera unívoca al significado de la instrucciones o declaraciones identificadas. Ligadores Tanto los compiladores como los ensambladores a menudo dependen de un programa conocido como ligador, el cual recopila el código que se compila o ensambla por separado en diferentes archivos objetos, a un archivo que es directamente ejecutable. En este sentido, puede hacerse una distinción entre código objeto (código máquina que todavía no se ha ligado) y código de máquina ejecutable. Un ligador también conecta un programa objeto con el código de funciones de librerías estándar, así como con recursos suministrados por el sistema operativo de la computadora, tales como asignadotes de memoria y dispositivos de entrada y salida. Es interesante advertir que los ligadores ahora realizan la tarea que originalmente era una de las principales actividades de un compilador (de aquí el uso de la palabra compilador: construir mediante la recopilación o compilación de fuentes diferentes). Cargadores Con frecuencia un compilador, ensamblador o ligador producirá un código que todavía no está completamente organizado y listo para ejecutarse, pero cuyas principales referencias de memoria se hacen relativas a una localidad de arranque indeterminada que puede estas en cualquier sitio de la memoria. Se dice que tal código es relocalizable y un cargador resolverá todas las direcciones relocalizables relativas a una dirección base, o de inicio, dada. Docente: Ing. Mirko Manrique Ronceros ~5~ Universidad Nacional del Santa Curso: Teoría de Compiladores El uso de un cargador hace mas flexible el código ejecutable, pero el proceso de carga con frecuencia ocurre en segundo plano (como parte del entorno operacional) o conjuntamente con el ligado. Rara vez un cargador es en realidad un programa por separado. Editores Los compiladores por lo regular aceptan programas fuente escritos utilizando cualquier editor que pueda producir un archivo estándar, tal como un archivo ASCII. Más recientemente, los compiladores han sido integrados junto con los editores y otros programas en un ambiente de desarrollo interactivo o IDE. En un caso así, un editor, mientras que aún produce archivos estándar, puede ser orientado hacia el formato o estructura del lenguaje de programación en cuestión. Tales editores se denominaban basados en estructura y ya incluyen algunas de las operaciones de un compilador, de manera que, por ejemplo, pueda informarse al programador de los errores a medida que el programa se vaya escribiendo en lugar de hacerlo cuando está compilado. El compilador y sus programas acompañantes también pueden llamarse desde el editor, de modo que el programador pueda ejecutar el programa sin tener que abandonar el editor. Depuradores Un depurador es un programa que puede utilizarse para determinar los errores de ejecución en un programa compilado. A menudo está integrado en un IDE. La ejecución de un programa con un depurador se diferencia de la ejecución directa en que el depurador se mantiene al tanto de la mayoría o la totalidad de la información sobre el código fuente, tal como los números de línea y los nombres de las variables y procedimientos. También puede detener la ejecución en ubicaciones previamente especificadas denominadas puntos de ruptura, además de proporcionar información de cuáles funciones se ha invocado y cuáles son los valores actuales de las variables. Para efectuar estas funciones el compilador debe suministrar al depurador la información simbólica apropiada, lo cual en ocasiones puede ser difícil, en especial en un compilador que intente optimizar el código objeto. De este modo, la depuración se convierte en una cuestión de compilación. Perfiladores Es un programa que recolecta estadísticas sobre el comportamiento de un programa objeto durante la ejecución. Las estadísticas típicas que pueden ser de interés para el programador son el número de veces que se llama cada procedimiento y el porcentaje de tiempo de ejecución que se ocupa cada uno de ellos. Tales estadísticas pueden ser muy útiles para ayudar al programador a mejorar la velocidad de ejecución del programa. A veces el compilador utilizará incluso la salida del perfilador para mejorar de manera automática el código objeto sin la intervención del programador. PROCESO DE COMPILACION Docente: Ing. Mirko Manrique Ronceros ~6~ Universidad Nacional del Santa Curso: Teoría de Compiladores Un compilador se compone internamente de varias etapas, o fases que realizan distintas operaciones lógicas. Es útil pensar en estas fases como piezas separadas dentro del compilador, y pueden en realidad escribirse como operaciones codificadas separadamente aunque en la práctica a menudo se integren juntas. Figura 1.- Etapas del proceso de compilación. Docente: Ing. Mirko Manrique Ronceros ~7~ Universidad Nacional del Santa Curso: Teoría de Compiladores La entrada a este proceso es por supuesto el programa fuente. Por lo general éste es un archivo que es creado por el usuario como un texto ASCII con o sin un formato específico aunque también puede ser el resultado de algún otro proceso. A partir de este archivo diversos pasos pueden ser llevados a cabo: Preprocesamiento.- Un preprocesador es la estrategia generalmente adoptada como solución a lenguajes huéspedes, extensiones, lenguajes 4GL, o lenguajes de dominio específico. El preprocesador es un traductor encargado de transformar dichas instrucciones a instrucciones del lenguaje anfitrión (generalmente un tradicional 3GL) sobre las cuales finalmente trabajará el compilador. Esta etapa es definitivamente opcional. Análisis Léxico.- En esta fase, la cadena de caracteres que conforma al programa fuente es despojada de comentarios, espacios en blanco y otros elementos superfluos. El programa encargado de hacer esto es conocido como un scanner, y de aquí que al proceso se le refiera comúnmente como scanning (exploración). Durante esta fase se identifican los elementos gramaticales usados en la creación del programa. Cada elemento identificado es substituido por un código numérico conocido como token. Análisis Sintáctico.- La cadena de tokens resultante es alimentada a un programa conocido como parser. El parser es el encargado de verificar que la secuencia y disposición de los tokens corresponda con la sintaxis del lenguaje. Este proceso de verificación sintáctica es conocido como parsing y es completamente guiado por la gramática del lenguaje. Análisis Semántico y Generación de Código.- Una vez que la secuencia de tokens ha sido validada, ésta es utilizada para identificar el sentido de la acción a realizar y generar el correspondiente código en lenguaje máquina. Algunos compiladores recurren a la creación de código intermedio para posteriormente generar la secuencia de instrucciones máquinas necesarias, mientras que algunos otros proceden a la generación directa del código máquina. Optimización de Código.- Esta es otra etapa opcional. La optimización de código es una actividad que raya en un arte dominado solamente por un experimentado programador de ensamblador y conocedor de la arquitectura del computador. Existen algunas técnicas desarrolladas al respecto pero nada supera a la experiencia de un hábil programador. En esta etapa, ya sea posteriormente o trabajando al unísono con el generador de código, secuencias de instrucciones y estructuras de datos son examinadas buscando su substitución con secuencias, instrucciones o estructuras más cortas, rápidas o eficientes. Ligado.- Como paso final, todas las referencias pendientes de resolver sobre rutinas, módulos, bibliotecas y dem´s porciones de código necesarias para el funcionamiento del programa son cubiertas en esta parte. La resolución puede consistir desde el proporcionar meramente una dirección o llamado a una función hasta la inclusión de enormes porciones de código. Al final, como producto de todo este proceso, lo que se obtiene es un programa escrito en código máquina que puede ser cargado en memoria y ejecutado. El proceso seguido por un intérprete es ligeramente diferente, ya que mientras que cubre todas las etapas de análisis no cuenta con una fase síntesis. Un intérprete no genera código, se limita a invocar rutinas ya escritas (proceso muchas veces llamado de interpretación). La siguiente figura ilustra esto. Docente: Ing. Mirko Manrique Ronceros ~8~ Universidad Nacional del Santa Curso: Teoría de Compiladores Figura 2.- Etapas del proceso de interpretación En el caso de un pseudo-compilador, cuyo caso mejor conocido es el de Java, la diferencia consiste en el código generado. Mientras que todas las etapas de un compilador son cubiertas, el programa ejecutable no es creado para ser ejecutado en un procesador "real" sino para uno "hipotético" o "imaginario" y conocido generalmente como máquina virtual. La máquina virtual es otro programa cuyo funcionamiento simula al de un procesador. Este procesador recibe de entrada el pseudo-código creado por el compilador y procede a la ejecución de las instrucciones contenidas en éste; puede verse que no se trata más que de un intérprete muy sencillo. Figura 3.- Etapas del proceso de pseudo-compilación. Docente: Ing. Mirko Manrique Ronceros ~9~ Universidad Nacional del Santa Curso: Teoría de Compiladores La siguiente figura ilustra con mayor detalle lo que pasa en cada una de las etapas del proceso de compilación. El procesamiento de instrucciones de un lenguaje huesped (como puede ser SQL) correría a cargo del pre-procesador, siendo transformadas instrucciones del lenguaje anfitrión. Durante la fase de análisis léxico el scanner se encarga de identificar cada uno de los elementos usados para escribir el programa fuente, substituyendo a cada uno de estos por un código numérico único (tokens). En este proceso se eliminan comentarios y espacios en blanco. Los tokens son alimentados al analizador sintáctico que valida que su disposición está acorde a las reglas del lenguaje. Validado este el analizador semántico procede a identificar el propósito de las diversas secuencias de tokens y buscará generar representaciones intermedias de cada acción o directamente código máquina. Este posteriormente es optimizado. Figura 4.- Detalle del flujo de datos y acciones en el proceso de compilación. Docente: Ing. Mirko Manrique Ronceros ~ 10 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores AMBIENTES DE COMPILACION Los compiladores a menudo producen como resultado del análisis semántico, una forma de representación intermedia del código fuente. Hoy en día, es cada vez más común que, en ambientes de estación de trabajo o de computador central, todos los compiladores de los distintos lenguajes generen el mismo código intermedio, el cual después, por un generador de código, es transformado en el código objeto. Esto tiene una gran ventaja: si se cambia el sistema operativo o alguna otra cosa, solo hay que reemplazar el generador de código, y no todo todos los compiladores. La generación de códigos intermedios aumenta la transportabilidad de los compiladores, ya que no es necesario cambiar sus partes independientes de la máquina para un nuevo hardware distinto. CODIGO FUENTE CODIGO INTERMEDIO CODIGO HEXADECIMAL Int suma_enteros( int i,j, suma) *SECTION 9 Define la sección de código. 00000000 222F 0004 { * SECTION 14 define la sección de Pila 00000004 202F 0008 SECTION 9 00000008 206F 000C xDEF .suma_enteros 0000000C D081 MOVE.L 4(A7), D1 0000000E 2080 MOVE.L 8(A7), D0 00000010 4E75 suma=i+j; } MOVE.L 12(A7).A0 ADD.L D1,D0 MOVE.L D0(A0) RTS * SECTION 14 * Asignaciones de suma_enteros * 4(A7) .i * 8(A7) .j * 12(a7) .suma ANALISIS Y SINTESIS La compilación de un programa consiste en analizar y sintetizar dicho programa, es decir, determinar la estructura y el significado de un código fuente y traducir ese código fuente a un código de máquina equivalente. Las tareas o fases principales de un compilador son: Análisis léxico. Análisis sintáctico. Análisis semántico. Generación de código. Podemos considerar a un programa como un flujo de caracteres que sirven como entrada para el análisis léxico. La tarea del análisis léxico consiste en reconocer los componentes léxicos dentro de ese flujo, es decir, transformar un flujo de caracteres en un flujo de componentes léxicos (como Docente: Ing. Mirko Manrique Ronceros ~ 11 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores los textos en lenguaje natural, podemos distinguir entre palabras y componentes léxicos: el número de palabras determina el tamaño del vocabulario del programa, mientras que el número de componentes léxicos determina la longitud del programa. Por ejemplo: La proposición i:= 10; Producirá lo siguiente: el identificador i el símbolo de asignación := el número 10 el símbolo delimitador; (punto y coma) Los identificadores o nombres reconocidos se organizan en una tabla de símbolos, que es una estructura de datos que contiene registros con campos de atributo para cada nombre. El contenido de la tabla de símbolos se completa con el análisis léxico y sintáctico y se usará para el análisis semántico y la generación de código. El siguiente paso es el análisis sintáctico. La palabra “Sintaxis” significa “estructura del orden de las palabras en una frase”. Otro término utilizado para el análisis sintáctico es el análisis jerárquico. La tarea del análisis sintáctico es revisar si los símbolos aparecen en el orden correcto (es decir, revisar si el programa fuente fue diseñado de acuerdo con la sintaxis del lenguaje de programación) y combina los símbolos del código fuente para formar unidades gramaticales. En esta fase se detectan errores de sintaxis como: h + x := x * y En general, las unidades gramaticales se organizan y representan con árboles de análisis sintáctico o árboles sintácticos. En la siguiente figura se muestra el árbol de análisis sintáctico de la siguiente proposición: h:= x + y – x * y Asignación identificador expresión identificador expresión identificador expresión identificador h := x + Docente: Ing. Mirko Manrique Ronceros y – x expresión * y ~ 12 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Después del análisis semantico y el de tipos. El análisis semantico es mucho más difícil que el sintáctico, pues hay que considerar el significado de una unidad gramatical; es decir hay que interpretarla. Esto se puede lograr traduciendo la entrada a una forma de representación intermedia. Por ejemplo, nunca hubiéramos definido la variable h de la figura, la proposición de asignación no tendría sentido. En forma análoga, la asignación de una variable booleana a una variable real tampoco tendrá sentido. Este tipo de inconsistencias será reconocido por el análisis de tipos. El código objeto se genera en la última fase de la compilación: el generador de código. En esta fase el código intermedio se transforma en código de maquina y la memoria necesaria quedara determinada. Obviamente, esta es la única fase que depende del hardware, ya que por lo general, los conjuntos de instrucciones varían de un computador a otro. Ambigüedad Se ha de tener cuidado al considerar la estructura de una cadena según una gramática. Aunque es evidente que cada árbol de análisis sintáctico deriva exactamente la cadena que leer en sus hojas, una gramática puede tener más de un árbol de análisis sintáctico que genere una cadena dada de componentes léxicos. Esta clase de gramática se dice que es ambigua. Para demostrar que una gramática es ambigua, lo único que se requiere es encontrar una cadena de componentes léxicos que tenga más de un árbol de análisis sintáctico. Como una cadena que cuenta con más de un árbol de análisis sintáctico suele tener más de un significado, para aplicaciones de compilación es necesario diseñar gramáticas no ambiguas o utilizar gramáticas ambiguas con reglas adicionales para resolver las ambigüedades. Por ejemplo, si se tiene la expresión 9 – 5 + 2 tiene ahora más de un árbol de análisis sintáctico. Los dos árboles de 9 – 5 + 2 corresponden a dos formas de agrupamientos entre paréntesis de la expresión : (9 – 5) + 2 y 9 – (5 + 2). Esta segunda forma de agrupamiento entre paréntesis da a la expresión el valor de 2, en lugar del valor acostumbrado 6. Asociatividad de 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 con 5 tiene operadores a su izquierda y derecha, se necesitan convenciones para decidir que operador considera es operando. Se dice que el operador + asocia a la izquierda, porque un operando que tenga un signo + a ambos lados es tomado por el operador que esté a su izquierda. En la mayoría de los lenguajes de programación, los cuatro operadores matemáticos, adición, sustracción, multiplicación y división son asociativos a la izquierda. Docente: Ing. Mirko Manrique Ronceros ~ 13 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Algunos operadores comunes, como la exponenciacion son asociativos por la derecha. Otro ejemplo análogo, el operador de asignación = en C es asociativo por la derecha; en C la expresión a = b = c; con un operador asociativo por la derecha, son generadas por 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 la siguiente figura: expresión expresión expresión – digito – digito derecha digito 2 5 letra a = derecha letra b 9 = derecha letra c Procedencia de operadores Considere la expresión 9 + 5 * 2. Hay dos interpretaciones posibles de esta expresión: (9 + 5) * 2 o 9 + (5 * 2). La asociatividad de + y * no resuelve esta ambigüedad. Por esta razón, se necesita conocer la precedencia relativa de los operadores cuando esté presente más de una clase de operadores. Se dice que * tiene mayor precedencia que + si * considera sus operandos antes que lo haga +. En aritmética elemental, la multiplicación y división tiene mayor precedencia que la adición y sustracción. Por tanto 5, es considerado por * en 9 + 5 * 2 y en 9 * 5 + 2; es decir las expresiones son equivalentes a 9 + (5 * 2) y (9 * 5) + 2, respectivamente. TRADUCCION DIRIGIDA POR LA SINTAXIS Para traducir una construcción de un lenguaje de programación, un compilador puede necesitar tener en cuenta muchas características, además del código generado para la construcción. Por ejemplo, puede ocurrir que el compilador necesite conocer el tipo de la construcción, la posición de la primera instrucción del código objeto o el numero de instrucciones generadas. Por tanto, los atributos asociados con las construcciones se mencionan de manera abstracta. Un atributo puede representar cualquier cantidad, por ejemplo, una expresión, una cadena, una posición de memoria o cualquier otra cosa. Docente: Ing. Mirko Manrique Ronceros ~ 14 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Notación Posfija La notación posfija de una expresión E se puede definir de manera inductiva como sigue: Si E es una variable o una constante, entonces la notación posfija de E es también E. Si E es una expresión de la forma E1 op E2, donde op es cualquier operador binario, entonces la notación posfija de E es E’1E’2op, donde E’1 y E’2 son las notación posfijas de E1 y E2 respectivamente. Si E es una expresión de la forma (E1), entonces la notación posfija de E1 es también la notación posfija de E. La notación posfija no necesita paréntesis, porque la posición y la ariedad (numero de argumentos) de los operadores permiten solo una descodificación de una expresión posfija. Por ejemplo, la notación posfija de (9 – 5) + 2 es 95-2+ y la notación posfija de 9-(5+2) es 952+-. Docente: Ing. Mirko Manrique Ronceros ~ 15 ~ Universidad Nacional del Santa Docente: Ing. Mirko Manrique Ronceros Curso: Teoría de Compiladores ~ 16 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores EXPRESIONES REGULARES Y AUTOMATAS La fase de rastreo, o análisis léxico, de un compilador tiene la tarea de leer el programa fuente como un archivo de caracteres y dividirlo en tokens. Los tokens son como las palabras de un lenguaje natural: cada token es una secuencia de caracteres que representa una unidad de información en el programa fuente. Ejemplos típicos de token son las palabras reservadas, como if y while, las cuales son cadenas fijas de letras; los identificadores, que son cadenas definidas por el usuario, compuestas por lo regular de letras y números, y que comienzan con una letra; los símbolos especiales, como los símbolos aritméticos + y *; además de algunos símbolos compuestos de múltiples caracteres, tales como > = y <>. En cada caso un token representa cierto patrón de caracteres que el analizador léxico reconoce, o ajusta desde el inicio de los caracteres de entrada restantes. Como la tarea que realiza el analizador léxico es un caso especial de coincidencia de patrones, necesitamos estudiar métodos de especificación y reconocimiento de patrones en la medida en que se aplican al proceso de análisis léxico. Estos métodos son principalmente los de las expresiones regulares y los autómatas finitos. Sin embargo, un analizador léxico también es la parte del compilador que maneja la entrada del código fuente, y puesto que esta entrada a menudo involucra un importante gasto de tiempo, el analizador léxico debe funcionar de manera tan eficiente como sea posible. Por lo tanto, también necesitamos poner mucha atención a los detalles prácticos de la estructura del analizador léxico. Dividiremos el estudio de las cuestiones del analizador léxico como sigue. En primer lugar, daremos una perspectiva general de la función de un analizador léxico y de las estructuras y conceptos involucrados. Enseguida, estudiaremos las expresiones regulares y por último el estudio de las máquinas de estados finitos o autómatas finitos. EL PROCESO DEL ANÁLISIS LÉXICO El trabajo del analizador léxico es leer los caracteres del código fuente y formarlos en unidades lógicas para que lo aborden las partes siguientes del compilador (generalmente el analizador sintáctico). Las unidades lógicas que genera el analizador léxico se denominan tokens, y formar caracteres en tokens es muy parecido a formar palabras a partir de caracteres en una oración en un lenguaje Docente: Ing. Mirko Manrique Ronceros ~ 17 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores natural como el inglés o cualquier otro y decidir lo que cada palabra significa. En esto se asemeja a la tarea del deletreo. Los tokens son entidades lógicas que por lo regular se definen como un tipo enumerado. Por ejemplo, pueden definirse en C como: typedef enum {IF,THEN,ELSE,PLUS,MINUS,NUM,ID,...} TokenType; Los tokens caen en diversas categorías, una de ellas la constituyen las palabras reservadas, como IF y THEN, que representan las cadenas de caracteres "if' y "then". Una segunda categoría es la de los símbolos especiales, como los símbolos aritméticos MÁS y MENOS, los que se representan con los caracteres "+" y "—". Finalmente, existen tokens que pueden representar cadenas de múltiples caracteres. Ejemplos de esto son NUM e ID, los cuales representan números e identificadores. Los tokens como entidades lógicas se deben distinguir claramente de las cadenas de caracteres que representan. Por ejemplo, el token de la palabra reservada IF se debe distinguir de la cadena de caracteres "if' que representa. Para hacer clara la distinción, la cadena de caracteres representada por un token se denomina en ocasiones su valor de cadena o su lexema. Algunos tokens tienen sólo un lexema: las palabras reservadas tienen esta propiedad. No obstante, un token puede representar un número infinito de lexemas. Los identificadores, por ejemplo, están todos representados por el token simple ID, pero tienen muchos valores de cadena diferentes que representan sus nombres individuales. Estos nombres no se pueden pasar por alto, porque un compilador debe estar al tanto de ellos en una tabla de símbolos. Por consiguiente, un rastreador o analizador léxico también debe construir los valores de cadena de por lo menos algunos de los tokens. EXPRESIONES REGULARES Las expresiones regulares representan patrones de cadenas de caracteres. Una expresión regular r se encuentra completamente definida mediante el conjunto de cadenas con las que concuerda. Este conjunto se denomina lenguaje generado por la expresión regular y se escribe como L(r), Aquí la palabra lenguaje se utiliza sólo para definir "conjunto de cadenas" y no tiene (por lo menos en esta etapa) una relación específica con un lenguaje de programación. Este lenguaje depende, en primer lugar, del conjunto de caracteres que se encuentra disponible. En general, estaremos hablando del conjunto de caracteres ASCII o de algún subconjunto del mismo. En ocasiones el conjunto será más general que el conjunto de caracteres ASCII, en cuyo caso los elementos del conjunto se Docente: Ing. Mirko Manrique Ronceros ~ 18 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores describirán como símbolos. Este conjunto de símbolos legales se conoce como alfabeto y por lo general se representa mediante el símbolo griego Σ (sigma). Una expresión regular r también contendrá caracteres del alfabeto, pero esos caracteres tendrán un significado diferente: en una expresión regular todos los símbolos indican patrones. En este capítulo distinguiremos el uso de un carácter como patrón escribiendo todo los patrones en negritas. De este modo, a es el carácter a usado como patrón. Por último, una expresión regular r puede contener caracteres que tengan significados especiales. Este tipo de caracteres se llaman metacaracteres o metasímbolos, y por lo general no pueden ser caracteres legales en el alfabeto, porque no podríamos distinguir su uso como metacaracteres de su uso como miembros del alfabeto. Sin embargo, a menudo no es posible requerir tal exclusión, por lo que se debe utilizar una convención para diferenciar los dos usos posibles de un metacaracter. En muchas situaciones esto se realiza mediante el uso de un carácter de escape que "desactiva" el significado especial de un metacaracter. Unos caracteres de escape comunes son la diagonal inversa y las comillas. Advierta que los caracteres de escape, si también son caracteres legales en el alfabeto, son por sí mismos metacaracteres. Docente: Ing. Mirko Manrique Ronceros ~ 19 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Definición de expresiones regulares Expresiones regulares básicas: Estas son precisamente los caracteres simples del alfabeto, los cuales se corresponden a sí mismos. Dado cualquier carácter a del alfabeto Σ, indicamos que la expresión regular a corresponde al carácter a escribiendo L(a) = {a}. Existen otros dos símbolos que necesitaremos en situaciones especiales. Necesitamos poder indicar una concordancia con la cadena vacía, es decir, la cadena que no contiene ningún carácter. Utilizaremos el símbolo ε (épsilon) para denotar la cadena vacía, y definiremos el metasímbolo ε (e en negritas) estableciendo que L(ε) = { ε } . También necesitaremos ocasionalmente ser capaces de describir un símbolo que corresponda a la ausencia de cadenas, es decir, cuyo lenguaje sea el conjunto vacío, el cual escribiremos como { }. Emplearemos para esto el símbolo φ y escribiremos L(φ) = { }. Observe la diferencia entre { } y {ε}: el conjunto { } no contiene ninguna cadena, mientras que el conjunto {ε} contiene la cadena simple que no se compone de ningún carácter. Operaciones de expresiones regulares: Existen tres operaciones básicas en las expresiones regulares: 1) selección entre alternativas, la cual se indica mediante el metacaracter | (barra vertical); 2) concatenación, que se indica medíante yuxtaposición (sin un metacaracter), y 3) repetición o "cerradura", la cual se indica mediante el metacaracter *. Analizaremos cada una por turno, proporcionando la construcción del conjunto correspondiente para los lenguajes de cadenas concordantes. Selección entre alternativas: Si r y s son expresiones regulares, entonces r|s es una expresión regular que define cualquier cadena que concuerda con r o con s. En términos de lenguajes, el lenguaje de r | s es la unión de los lenguajes de r y s, o L(r | s) = L(r) u L(s). Como un ejemplo simple, considere la expresión regular a | b: ésta corresponde tanto al carácter a como al carácter b, es decir, L(a | b) = L(a) U L(b) = {a} u {b} = {a, b}. Como segundo ejemplo, la expresión regular a | ε corresponde tanto al carácter simple a como a la cadena vacía (que no está compuesta por ningún carácter). En otras palabras, L(a | ε) = {a, ε}. La selección se puede extender a más de una alternativa, de manera que, por ejemplo, L(a | b | c | d) = {a, b, c, d}. En ocasiones también escribiremos largas secuencias de selecciones con puntos, como en a | b | ... | z, que corresponde a cualquiera de las letras minúsculas de la a a la z. Concatenación: La concatenación de dos expresiones regulares r y s se escribe como rs, y corresponde a cualquier cadena que sea la concatenación de dos cadenas, con la primera Docente: Ing. Mirko Manrique Ronceros ~ 20 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores de ellas correspondiendo a r y la segunda correspondiendo a s. Por ejemplo, la expresión regular ab corresponde sólo a la cadena ab, mientras que la expresión regular (a | b) c corresponde a las cadenas ac y bc. (El uso de los paréntesis como metacaracteres en esta expresión regular se explicará en breve). Podemos describir el efecto de la concatenación en términos de lenguajes generados al definir la concatenación de dos conjuntos de cadenas. Dados dos conjuntos de cadenas S1 y S2, el conjunto concatenado de cadenas S1S2 es el conjunto de cadenas de S1 complementado con todas las cadenas de S2. Por ejemplo, si S1 = {aa, b} y S2 = {a, bb}, entonces S1S2 = {aaa, aabb, ba, bbb}. Ahora la operación de concatenación para expresiones regulares se puede definir como sigue: L(rs)=L(r)L(s). De esta manera (utilizando nuestro ejemplo anterior), L{(a | b) c) = L(a | b)L(c) = {a, b } { c ) = {ac, bc}. La concatenación también se puede extender a más de dos expresiones regulares: L(r¡ r2 . . . r„) = L(ri)L(r2) . . . L(rn) = el conjunto de cadenas formado al concatenar todas las cadenas de cada una de las L(r1), . . . , L(rn). Repetición: La operación de repetición de una expresión regular, denominada también en ocasiones cerradura (de Kleene), se escribe r*, donde r es una expresión regular. La expresión regular r* corresponde a cualquier concatenación finita de cadenas, cada una de las cuales corresponde a r. Por ejemplo, a* corresponde a las cadenas e, a, aa, aaa, .... (Concuerda con e porque e es la concatenación de ninguna cadena concordante con a.) Podemos definir la operación de repetición en términos de lenguajes generados definiendo, a su vez, una operación similar * para conjuntos de cadenas. Dado un conjunto S de cadenas, sea: S* = {e} uSuSSuSSSu... Ahora podemos definir la operación de repetición para expresiones regulares como sigue: L{r*) = L(r)* Considere como ejemplo la expresión regular (a | bb) *. (De nueva cuenta, la razón de tener paréntesis como metacaracteres se explicará más adelante.) Esta expresión regular corresponde a cualquiera de las cadenas siguientes: e, a, bb, aa, abb, bba, bbbb, aaa, aabb y así sucesivamente. En términos de lenguajes, L( (a | bb) *) = L(a | bb)* = [a, bb}* = {ε, a, bb, aa, abb, bba, bbbb, aaa, aabb, abba, abbbb, bbaa, . . .}. Docente: Ing. Mirko Manrique Ronceros ~ 21 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Precedencia de operaciones y el uso de los paréntesis La descripción precedente no toma en cuenta la cuestión de la precedencia de las operaciones de elección, concatenación y repetición. Por ejemplo, dada la expresión regular a | b*, ¿deberíamos interpretar esto como (a | b) * o como a|(b*) ? (Existe una diferencia importante, puesto que L( (a |b) *) = {ε, a, b, aa, ab, ba, bb, .. .}, mientras que L(a | (b*)) = {ε, a, b, bb, bbb, . . .}.) La convención estándar es que la repetición debería tener mayor precedencia, por lo tanto, la segunda interpretación es la correcta. En realidad, entre las tres operaciones, se le da al * la precedencia más alta, a la concatenación se le da la precedencia que sigue y a la | se le otorga la precedencia más baja. De este modo, por ejemplo, a | bc* se interpreta como a|(b (c * ) ), mientras que ab | c*d se interpreta como (ab) | ( ( c * ) d ) . Cuando deseemos indicar una precedencia diferente, debemos usar paréntesis para hacerlo. Ésta es la razón por la que tuvimos que escribir (a|b)c para indicar que la operación de elección debería tener mayor precedencia que la concatenación, ya que de otro modo a | bc se interpretaría como si correspondiera tanto a a como a bc. De manera similar, (a l bb) * se interpretaría sin los paréntesis como a | bb*, lo que corresponde a a, b, bb, bbb, .... Los paréntesis aquí se usan igual que en aritmética, donde (3 + 4) * 5 = 35, pero 3 + 4 * 5 = 23, ya que se supone que * tiene precedencia más alta que +. Nombres para expresiones regulares A menudo es útil como una forma de simplificar la notación proporcionar un nombre para una expresión regular larga, de modo que no tengamos que escribir la expresión misma cada vez que deseemos utilizarla. Por ejemplo, si deseáramos desarrollar una expresión regular para una secuencia de uno o más dígitos numéricos, entonces escribiríamos (0|1|2| ... |9)(0|1|2| ... |9)* o podríamos escribir dígito dígito* donde dígito = 0I1I2I...I9 es una definición regular del nombre dígito. El uso de una definición regular es muy conveniente, pero introduce la complicación agregada de que el nombre mismo se convierta en un metasímbolo y se deba encontrar un significado para distinguirlo de la concatenación de sus caracteres. En nuestro caso hicimos esa distinción al utilizar letra cursiva para el nombre. Advierta que no se debe emplear el nombre del término en su propia definición (es decir, de manera recursiva): debemos poder eliminar nombres reemplazándolos sucesivamente con las expresiones regulares para las que se establecieron. Docente: Ing. Mirko Manrique Ronceros ~ 22 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Antes de considerar una serie de ejemplos para elaborar nuestra definición de expresiones regulares, reuniremos todas las piezas de la definición de una expresión regular. Una expresión regular es una de las siguientes: 1. Una expresión regular básica constituida por un solo carácter a, donde a proviene de un alfabeto Σ de caracteres legales; el metacarácter ε ; o el metacarácter ε. En el primer caso, L(a) = {a}; en el segundo, L(ε) = {ε}; en el tercero, L(φ) = {}. 2. Una expresión de la forma r | s, donde r y s son expresiones regulares. En este caso, L(r | s) = L(r) u L(s). 3. Una expresión de la forma rs, donde r y s son expresiones regulares. En este caso, L(rs) = L(r)L(s). 4. Una expresión de la forma r*, donde r es una expresión regular. En este caso, L(r*)=L(r)*. 5. Una expresión de la forma (r), donde r es una expresión regular. En este caso, L((r)) = L(r). De este modo, los paréntesis no cambian el lenguaje, sólo se utilizan para ajustar la precedencia de las operaciones. Ejemplo1: Consideremos el alfabeto simple constituido por sólo tres caracteres alfabéticos: Σ= {a, b,c). También el conjunto de todas las cadenas en este alfabeto que contengan exactamente una b. Este conjunto es generado por la expresión regular (alc)*b(alc)* Advierta que, aunque b aparece en el centro de la expresión regular, la letra b no necesita estar en el centro de la cadena que se desea definir. En realidad, la repetición de a o c antes y después de la b puede presentarse en diferentes números de veces. Por consiguiente, todas las cadenas siguientes están generadas mediante la expresión regular anterior: b, abc, abaca, baaaac, ccbaca, ccccccb. Ejemplo2: Con el mismo alfabeto que antes, considere el conjunto de todas las cadenas que contienen como máximo una b. Una expresión regular para este conjunto se puede obtener utilizando la solución al ejemplo anterior como una alternativa (definiendo aquellas cadenas con exactamente una b) y la expresión regular ( a l c ) * como la otra alternativa (definiendo los casos sin b en todo). De este modo, tenemos la solución siguiente: (alc)*|( a l c ) * b ( a | c ) * Docente: Ing. Mirko Manrique Ronceros ~ 23 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Una solución alternativa sería permitir que b o la cadena vacía apareciera entre las dos repeticiones de a o c: ( a |c )* (b l ε ) (alc)* Ejemplo3: Consideremos el conjunto de cadenas S sobre el alfabeto Σ = {a,b} compuesto de una b simple rodeada por el mismo número de a: n n S = {b, aba, aabaa, aaabaaa, . . .} = {a ba |n ≠ 0} Docente: Ing. Mirko Manrique Ronceros ~ 24 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores AUTÓMATAS FINITOS Los autómatas finitos, o máquinas de estados finitos, son una manera matemática para describir clases particulares de algoritmos (o "máquinas"). En particular, los autómatas finitos se pueden utilizar para describir el proceso de reconocimiento de patrones en cadenas de entrada, y de este modo se pueden utilizar para construir analizadores léxicos. Por supuesto, también existe una fuerte relación entre los autómatas finitos y las expresiones regulares, y veremos en la sección siguiente cómo construir un autómata finito a partir de una expresión regular. Sin embargo, antes de comenzar nuestro estudio de los autómatas finitos de manera apropiada, consideraremos un ejemplo explicativo. El patrón para identificadores como se define comúnmente en los lenguajes de programación está dado por la siguiente definición regular (supondremos que letra y dígito ya se definieron): identificador = letra(letra|dígito)* Esto representa una cadena que comienza con una letra y continúa con cualquier secuencia de letras y/o dígitos. El proceso de reconocer una cadena así se puede describir mediar diagrama de la figura: En este diagrama los círculos numerados 1 y 2 representan estados, que son localidades en proceso de reconocimiento que registran cuánto del patrón ya se ha visto. Las líneas flechas representan transiciones que registran un cambio de un estado a otro en una coincidencia del carácter o caracteres mediante los cuales son etiquetados. En el diagrama muestra, el estado 1 es el estado de inicio, o el estado en el que comienza el proceso de reconocimiento. Por convención, el estado de inicio se indica dibujando una línea con flecha sin etiqueta que proviene de "de ninguna parte". El estado 2 representa el punto en el cual se ha igualado una sola letra (lo que se indica mediante la transición del estado 1 al estado 2 etiquetada con letra). Una vez en el estado 2, cualquier número de letras y/o dígitos se puede ver, y una coincidencia de éstos nos regresa al Docente: Ing. Mirko Manrique Ronceros ~ 25 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores estado 2. Los estados que representan el fin del proceso de reconocimiento, en los cuales podemos declarar un éxito, se denominan estados de aceptación, y se indican dibujando un borde con línea doble alrededor del estado en el diagrama. Puede haber más de uno de éstos. En el diagrama de muestra el estado 2 es un estado de aceptación, lo cual indica que, después que cede una letra, cualquier secuencia de letras y dígitos subsiguiente (incluyendo la ausencia de todas) representa identificador legal. El proceso de reconocimiento de una cadena de caracteres real como un identificador ahora se puede indicar al enumerar la secuencia de estados y transiciones en el diagrama q se utiliza en el proceso de reconocimiento. Por ejemplo, el proceso de reconocer xtemp como un identificador se puede indicar como sigue: Un DFA (por las siglas del concepto autómata finito determinístico en inglés) M se compone de un alfabeto Σ, un conjunto de estados S, una función de transición T: S X Σ —> S, un estado de inicio s0 ε S y un conjunto de estados de aceptación A C S. El lenguaje aceptado por M, escrito como L(M), se define como el conjunto de cadenas de caracteres C1C2.. .c„ con cada c¡ Σ, tal que existen estados Sj = T(s0, C1), s2 = T(s1, c2),... ,sn = T(Sn-1, cn), con sn como un elemento de A (es decir, un estado de aceptación). Hacemos las anotaciones siguientes respecto a esta definición. S X Σ se refiere al producto cartesiano o producto cruz de S y Σ: el conjunto de pares (s, c), donde s S y c Σ La función T registra las transiciones: T(s, c) = s' si existe una transición del estado S al estado s' etiquetado mediante c. El segmento correspondiente del diagrama para M tendrá el aspecto siguiente: La aceptación como la existencia de una secuencia de estados s1 = T(so, c1), s2 = T(s1, c2),. . . , = T(sn-1, c„), con sn siendo un estado de aceptación, significa entonces lo mismo que el Docente: Ing. Mirko Manrique Ronceros ~ 26 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Advertimos un número de diferencias entre la definición de un DFA y el diagrama del ejemplo identificador. En primer lugar, utilizamos los números para los estados en el diagrama del identificador, mientras la definición no restrinja el conjunto de estados a números. En realidad, podemos emplear cualquier sistema de identificación que queramos para los estados, incluyendo nombres. Por ejemplo, podemos escribir un diagrama equivalente al de la figura como: donde ahora denominamos a los estados inicio (porque es el estado de inicio) y entrada_id (porque vimos una letra y estará reconociendo un identificador después de letras y números subsiguientes cualesquiera). El conjunto de estados para este diagrama se convierte ahora en {inicio, entrada_jd} en lugar de {1, 2}. Una segunda diferencia entre el diagrama y la definición es que no etiquetamos las transiciones con caracteres sino con nombres que representan un conjunto de caracteres. El conjunto de cadenas que contienen exactamente una b es aceptado por el siguiente DFA: El conjunto de cadenas que contienen como máximo una b es aceptado por el siguiente DFA: Docente: Ing. Mirko Manrique Ronceros ~ 27 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Definiciones regulares para constantes numéricas en notación científica como se muestra a continuación Un NFA (por las siglas del término autómata finito no determinístico en inglés) M consta de un alfabeto Σ, un conjunto de estados S y una función de transición T:S X(Σ u {ε}) -> φ(S), así como de un estado de inicio s0 de S y un conjunto de estados de aceptación A de S. El lenguaje aceptado por M, escrito como L(M), se define como el conjunto de cadenas de caracteres estados s1 en T(s0, c1), s2 c1c2. en T(s1, . .cn con cada c2),. .., sn c¡ de Σ u {ε} tal que existen en T(sn-1 cn), con Sn como un elemento de A. Considere el siguiente diagrama de un NFA La cadena abb puede ser aceptada por cualquiera de las siguientes secuencias de transacciones: Docente: Ing. Mirko Manrique Ronceros ~ 28 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores En realidad las transiciones del estado 1 al estado 2 en a, y del estado 2 al estado 4 en b, permiten que la máquina acepte la cadena ab, y entonces, utilizando la transición ε del estado 4 al estado 2, todas las cadenas igualan la expresión regular ab+. De manera similar, las transiciones del estado 1 al estado 3 en a, y del estado 3 al estado 4 en ε, permiten la aceptación de todas las cadenas que coinciden con ab*. Finalmente, siguiendo la transición e desde el estado 1 hasta el estado 4 se permite la aceptación de todas las cadenas coincidentes con b*. De este modo, este NFA acepta el mismo lenguaje que la expresión regular ab+ l ab* I b*. Una expresión regular más simple que genera el mismo lenguaje es (a|ε)b*. El siguiente DFA también acepta este lenguaje: Considere el siguiente NFA Docente: Ing. Mirko Manrique Ronceros ~ 29 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Este acepta la cadena acab al efectuar las transiciones siguientes: Docente: Ing. Mirko Manrique Ronceros ~ 30 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores LENGUAJES FORMALES En matemáticas, lógica, y ciencias de la computación, un lenguaje formal es un conjunto de palabras (cadenas de caracteres) de longitud finita en los casos más simples o expresiones válidas (formuladas por palabras) formadas a partir de un alfabeto (conjunto de caracteres) finito. El nombre lenguaje se justifica porque las estructuras que con este se forman tienen reglas de buena formación (gramática) e interpretación semántica (significado) en una forma muy similar a los lenguajes hablados. Un posible alfabeto sería, digamos, alfabeto sería, por ejemplo, , y una cadena cualquiera sobre este . Un lenguaje sobre este alfabeto, que incluyera esta cadena, sería: el conjunto de todas las cadenas que contienen el mismo número de símbolos que , por ejemplo. La palabra vacía (esto es, la cadena de longitud cero) se permite en este tipo de lenguajes, notándose frecuentemente mediante , ó . A diferencia de que ocurre con el alfabeto (que es un conjunto finito) y con cada palabra (que tiene una longitud también finita), un lenguaje puede estar compuesto por un número infinito de palabras. Ejemplos de lenguajes formales: El conjunto de todas las palabras sobre . El conjunto El conjunto de todos los programas sintácticamente válidos en un determinado es un número primo. lenguaje de programación. El conjunto de sentencias bien formadas en lógica de predicados. Especificación de lenguajes formales Los lenguajes formales se pueden especificar de una amplia variedad de formas, como por ejemplo: Cadenas producidas por una gramática formal (véase Jerarquía de Chomsky). Cadenas producidas por una expresión regular. Cadenas aceptadas por un autómata, tal como una máquina de Turing. Docente: Ing. Mirko Manrique Ronceros ~ 31 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Operaciones Se pueden utilizar varias operaciones para producir nuevos lenguajes a partir de otros dados. Supóngase que L1 y L2 son lenguajes sobre un alfabeto común. Entonces: La concatenación L1L2 consiste de todas aquellas palabras de la forma vw donde v es una palabra de L1 y w es una palabra de L2 La intersección L1&L2 consiste en todas aquellas palabras que están contenidas tanto en L1 como en L2 La unión L1|L2 consiste en todas aquellas palabras que están contenidas ya sea en L1 o en L2 El complemento ~L1 consiste en todas aquellas palabras producibles sobre el alfabeto de L1 que no están ya contenidas en L1 El cociente L1/L2 consiste de todas aquellas palabras v para las cuales existe una palabra w en L2 tales que vw se encuentra en L1 La estrella L1* consiste de todas aquellas palabras que pueden ser escritas de la forma W1W2...Wn donde todo Wi se encuentra en L1 y n ≥ 0. (Nótese que esta definición incluye a ε en cualquier L*) La intercalación L1*L2 consiste de todas aquellas palabras que pueden ser escritas de la forma v1w1v2w2...vnwn; son palabras tales que la concatenación v1...vn está en L1, y la concatenación w1...wn está en L2 Por contraposición al lenguaje propio de los seres vivos y en especial el lenguaje humano, considerados lenguajes naturales, se denomina lenguaje formal a los lenguajes «artificiales» propios de las matemáticas o la informática, los lenguajes artificiales son llamados lenguajes formales (incluyendo lenguajes de programación). Sin embargo, el lenguaje humano tiene una característica que no se encuentra en los lenguajes de programación: la diversidad. En 1956, Noam Chomsky creó la Jerarquía de Chomsky para organizar los distintos tipos de lenguaje formal. Docente: Ing. Mirko Manrique Ronceros ~ 32 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Verdades concernientes a los lenguajes formales Teorema 1: El conjunto de lenguajes en general (incluyendo los no-formales) es incontable. Lema 1: El conjunto de lenguajes en un alfabeto no vacío dado es incontable Afirmar que un alfabeto es no-vacío equivale a que ese alfabeto contenga al menos un símbolo, Basta demostrar que el conjunto de lenguajes en el alfabeto incontable. Como sabemos, un lenguaje L en es un subconjunto de , esto nos lleva a la conclusión de que, el conjunto de todos los lenguajes en es justamente (el conjunto de todos los subconjuntos o conjunto potencia de evidente que A ) y es es infinito (de hecho; contable), también ha sido demostrado que si A es un conjunto infinito (contable o incontable), entonces 2 2 es A es mayor que A porque pasa a ser un conjunto infinito de ordenes del infinito, al ser mayor, no existirá A A biyección entre A y 2 , lo que hace a 2 un conjunto infinito incontable. Demostración del Teorema 1: Puede derivarse fácilmente que la aseveración delineada en el Teorema 1 es verdadera, porque el conjunto de lenguajes en general A es justamente una unión infinita de conjuntos del tipo 2 , donde A es un conjunto infinito contable. Teorema 2: Los lenguajes son conjuntos contables Se sabe que un lenguaje L en un alfabeto Σ es un subconjunto de Σ hizo mención, Σ * * y como ya se es infinito incontable, por ende, L es como mucho un conjunto infinito incontable (del mismo tamaño que Σ *. Teorema 3: El conjunto de lenguajes formales es contable Como sabemos un lenguaje formal puede ser generado por una gramática formal (o de estructura de frase), lo cual implica que todo lenguaje formal puede ser aceptado por una Máquina de Turing(MT), lo que a su vez implica que se puede definir una biyección entre el conjunto de lenguajes formales y el conjunto de las MT´s (debido a la propiedad transitiva de la relación "existe biyección entre A y B"). Para demostrar el teorema se utilizará el concepto de codificación de MT´s que se introduce en el estudio de las MT´s universales, generalmente se codifica una MT con una función que Docente: Ing. Mirko Manrique Ronceros ~ 33 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores tiene precisamente como dominio al conjunto de las MT´s (lo llamaremos X) y como codominio , esa función puede ser una biyección si el codominio pasa a ser Y (un subconjunto de ) y como es contable, ese subconjunto también será contable y como existe dicha biyección (entre X e Y). Gramática formal Una gramática formal es un objeto o modelo matemático que permite especificar un lenguaje o lengua, es decir, es el conjunto de reglas capaces de generar todas las posibilidades combinatorias de ese lenguaje, ya sea éste un lenguaje formal o un lenguaje natural. Introducción El elemento en mayúsculas es el símbolo inicial. Los elementos en minúsculas son símbolos terminales. Las cadenas de la lengua son aquellas que solo contienen elementos terminales, como por ejemplo: bbbdeccc, de, bdec, ... Estas serían tres posibles realizaciones del lenguaje cuya gramática hemos definido con dos reglas. Para comprender mejor el concepto pondremos algunas reglas de la gramática castellana: Una FRASE se puede componer de SUJETO + PREDICADO O = SN + SV Un SUJETO se puede componer de un ARTÍCULO + NOMBRE o SUSTANTIVO (núcleo) + Complementos SN = Det + N + C Un PREDICADO se puede componer de un VERBO conjugado SV = Aux + GV Un ARTICULO puede ser la palabra "el" Un NOMBRE o SUBSTANTIVO puede ser "niño" Vemos que existen unas definiciones especiales como FRASE, SUJETO, etc. que no aparecen en la frase final formada. Son unas entidades abstractas denominadas Categorías Sintácticas que no son utilizables en una frase. Docente: Ing. Mirko Manrique Ronceros ~ 34 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Las categorías sintácticas definen la estructura del lenguaje representando porciones más o menos grandes de las frases. Existe una jerarquía interna entre las categorías sintácticas. La categoría superior sería la FRASE que representa una oración válida en lengua castellana. Por debajo de ella se encuentran sus componentes. Ninguna de estas categorías da lugar a frases válidas solo la categoría superior. Al finalizar toda la jerarquía llegamos a las palabras que son las unidades mínimas con significado que puede adoptar una frase. Aplicando las jerarquías y sustituyendo elementos, llegamos al punto en donde todas las categorías sintácticas se han convertido en palabras, obteniendo por tanto una oración VÁLIDA. (Como por ejemplo: El niño corre). Este proceso se llama producción o generación. En resumen: Elementos constituyentes Una gramática formal es un modelo matemático compuesto por una serie de categorías sintácticas que se combinan entre sí por medio de unas reglas sintácticas que definen cómo se crea una categoría sintáctica por medio de otras o símbolos de la gramática. Existe una única categoría superior que denota cadenas completas y válidas. Mecanismos de especificación Por medio de estos elementos constituyentes se define un mecanismo de especificación consistente en repetir el mecanismo de sustitución de una categoría por sus constituyentes en función de las reglas comenzando por la categoría superior y finalizando cuando la oración ya no contiene ninguna categoría. De esta forma, la gramática puede generar o producir cada una de las cadenas del lenguaje correspondiente y solo estas cadenas. Docente: Ing. Mirko Manrique Ronceros ~ 35 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Definición Una Gramática Formal es una cuádrupla donde: N es un alfabeto de símbolos no terminales (variables). T es un alfabeto de símbolos terminales (constantes). Debe cumplirse que . denotaremos con el alfabeto de la gramática. es el símbolo inicial o axioma de la gramática. es el conjunto de reglas de producción, de la forma β { α → β | α } Es decir, la cadena α debe contener al menos una variable, que puede estar rodeada de un contexto. Derivaciones Sea G = (N,T,P,S) una gramática, y sean α, β, δ, φ, ρ, ... palabras de Σ * . Entonces β se deriva de α en un paso de derivación, y lo denotamos con α dos cadenas β si existen , y una producción δ → ρ tales que α = φ1 δ φ2, y β = φ1 ρ φ2 Notamos con al cierre reflexivo y transitivo de . Es decir α β denota a una secuencia de derivaciones en un número finito de pasos desde α hasta β. es una forma sentencial de G, si puede obtenerse la siguiente secuencia de derivaciones . En el caso particular de que se dice que x es una sentencia Se denomina lenguaje Docente: Ing. Mirko Manrique Ronceros formal generado por G al conjunto ~ 36 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores EXPRESIÓN REGULAR Una expresión regular, a menudo llamada también patrón, es una expresión que describe un conjunto de cadenas sin enumerar sus elementos. Por ejemplo, el grupo formado por las cadenas Handel, Händel y Haendel se describe mediante el patrón "H(a|ä|ae)ndel". La mayoría de las formalizaciones proporcionan los siguientes constructores: una expresión regular es una forma de representar a los lenguajes regulares (finitos o infinitos) y se construye utilizando caracteres del alfabeto sobre el cual se define el lenguaje. Específicamente, las expresiones regulares se construyen utilizando los operadores unión concatenación y clausura de Kleene. alternación Una barra vertical separa las alternativas. Por ejemplo, "marrón|castaño" casa con marrón o castaño. cuantificación Un cuantificador tras un carácter especifica la frecuencia con la que éste puede ocurrir. Los cuantificadores más comunes son +, ? y *: + El signo más indica que el carácter al que sigue debe aparecer al menos una vez. Por ejemplo, "ho+la" describe el conjunto infinito hola, hoola, hooola, hoooola, etcétera. ? El signo de interrogación indica que el carácter al que sigue puede aparecer como mucho una vez. Por ejemplo, "ob?scuro" casa con oscuro y obscuro. * El asterisco indica que el carácter al que sigue puede aparecer cero, una, o más veces. Por ejemplo, "0*42" casa con 42, 042, 0042, 00042, etcétera. agrupación Los paréntesis pueden usarse para definir el ámbito y precedencia de los demás operadores. Por ejemplo, "(p|m)adre" es lo mismo que "padre|madre", y "(des)?amor" casa con amor y con desamor. Los constructores pueden combinarse libremente dentro de la misma expresión, por lo que "H(ae?|ä)ndel" equivale a "H(a|ae|ä)ndel". Docente: Ing. Mirko Manrique Ronceros ~ 37 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores La sintaxis precisa de las expresiones regulares cambia según las herramientas y aplicaciones consideradas, y se describe con más detalle a continuación. Su utilidad más obvia es la de describir un conjunto de cadenas, lo que resulta de utilidad en editores de texto y aplicaciones para buscar y manipular textos. Muchos lenguajes de programación admiten el uso de expresiones regulares con este fin. Por ejemplo, Perl tiene un potente motor de expresiones regulares directamente incluido en su sintaxis. Las herramientas proporcionadas por las distribuciones de Unix (incluyendo el editor sed y el filtro grep) fueron las primeras en popularizar el concepto de expresión regular. Aplicaciones Numerosos editores de texto y otras utilidades (especialmente en el sistema operativo UNIX/linux), como por ejemplo sed y awk, utilizan expresiones regulares para, por ejemplo, buscar palabras en el texto y reemplazarlas con alguna otra cadena de caracteres. Las expresiones regulares en programación En el área de la programación las expresiones regulares son un método por medio del cual se pueden realizar búsquedas dentro de cadenas de caracteres. Sin importar si la búsqueda requerida es de dos caracteres en una cadena de 10 o si es necesario encontrar todas las apariciones de un patrón definido de caracteres en un archivo de millones de caracteres, las expresiones regulares proporcionan una solución para el problema. Adicionalmente, un uso derivado de la búsqueda de patrones es la validación de un formato específico en una cadena de caracteres dada, como por ejemplo fechas o identificadores. Para poder utilizar las expresiones regulares al programar es necesario tener acceso a un motor de búsqueda con la capacidad de utilizarlas. Es posible clasificar los motores disponibles en dos tipos: Motores para el programador y Motores para el usuario final. Motores para el usuario final: son programas que permiten realizar búsquedas sobre el contenido de un archivo o sobre un texto extraído y colocado en el programa. Están diseñados para permitir al usuario realizar búsquedas avanzadas usando este mecanismo, sin embargo es necesario aprender a redactar expresiones regulares adecuadas para poder utilizarlos eficientemente. Éstos son algunos de los programas disponibles: Docente: Ing. Mirko Manrique Ronceros ~ 38 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores grep: programa de los sistemas operativos Unix/Linux PowerGrep: versión de grep para los sistemas operativos Windows RegexBuddy: ayuda a crear las expresiones regulares en forma interactiva y luego le permite al usuario usarlas y guardarlas. EditPad Pro: permite realizar búsquedas con expresiones regulares sobre archivos y las muestra por medio de código de colores para facilitar su lectura y comprensión. Motores para el programador: permiten automatizar el proceso de búsqueda de modo que sea posible utilizarlo muchas veces para un propósito específico. Estas son algunas de las herramientas de programación disponibles que ofrecen motores de búsqueda con soporte a expresiones regulares: Java: existen varias librerías hechas para java que permiten el uso de RegEx, y Sun planea dar soporte a estas desde el SDK JavaScript: a partir de la versión 1.2 (ie4+, ns4+) JavaScript tiene soporte integrado para expresiones regulares, lo que significa que las validaciones que se realizan normalmente en una página web podrían simplificarse grandemente si el programador supiera utilizar esta herramienta. Perl: es el lenguaje que hizo crecer a las expresiones regulares en el ámbito de la programación hasta llegar a lo que son hoy en día. PCRE: librería de ExReg para C, C++ y otros lenguajes que puedan utilizar librerías dll (Visual Basic 6 por ejemplo). PHP: tiene dos tipos diferentes de expresiones regulares disponibles para el programador. Python: lenguaje de "scripting" popular con soporte a Expresiones Regulares. .Net Framework: provee un conjunto de clases mediante las cuales es posible utilizar expresiones regulares para hacer búsquedas, reemplazar cadenas y validar patrones. Nota: de las herramientas mencionadas con anterioridad se utilizan el EditPad Pro y el .Net Framework para dar ejemplos, aunque es posible utilizar las expresiones regulares con cualquier combinación de las herramientas mencionadas. Aunque en general las Expresiones Regulares utilizan un lenguaje común en todas las herramientas, las explicaciones prácticas acerca de la utilización de las herramientas y los ejemplos de código deben ser interpretados de forma diferente. También es Docente: Ing. Mirko Manrique Ronceros ~ 39 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores necesario hacer notar que existen algunos detalles de sintaxis de las expresiones regulares que son propietarios del .Net Framework que se utilizan en forma diferente en las demás herramientas de programación. Cuando estos casos se den se hará notar en forma explícita para que el lector pueda buscar información respecto a estos detalles en fuentes adicionales. En el futuro se incluirán adicionalmente ejemplos de otras herramientas y lenguajes de programación. Expresiones regulares como motor de búsqueda Las expresiones regulares permiten encontrar porciones específicas de texto dentro de una cadena más grande de caracteres. Así, si es necesario encontrar el texto "lote" en la expresión "el ocelote salto al lote contiguo" cualquier motor de búsqueda sería capaz de efectuar esta labor. Sin embargo, la mayoría de los motores de búsqueda encontrarían también el fragmento "lote" de la palabra "ocelote", lo cual podría no ser el resultado esperado. Algunos motores de búsqueda permiten adicionalmente especificar que se desea encontrar solamente palabras completas, solucionando este problema. Las expresiones regulares permiten especificar todas estas opciones adicionales y muchas otras sin necesidad de configurar opciones adicionales, sino utilizando el mismo texto de búsqueda como un lenguaje que permite enviarle al motor de búsqueda exactamente lo que deseamos encontrar en todos los casos, sin necesidad de activar opciones adicionales al realizar la búsqueda. Expresiones regulares como lenguaje Para especificar opciones dentro del texto a buscar se utiliza un lenguaje o convención mediante el cual se le transmite al motor de búsqueda el resultado que se desea obtener. Este lenguaje le da un significado especial a una serie de caracteres. Por lo tanto cuando el motor de búsqueda de expresiones regulares encuentre estos caracteres no los buscará en el texto en forma literal, sino que buscará lo que los caracteres significan. A estos caracteres se les llama algunas veces "meta-caracteres". A continuación se listan los principales meta-caracteres y su función y como los interpreta el motor de expresiones regulares. Docente: Ing. Mirko Manrique Ronceros ~ 40 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Descripción de las expresiones regulares El Punto "." El punto es interpretado por el motor de búsqueda como cualquier otro carácter excepto los caracteres que representan un salto de línea, a menos que se le especifique esto al motor de Expresiones Regulares. Por lo tanto si esta opción se deshabilita en el motor de búsqueda que se utilice, el punto le dirá al motor que encuentre cualquier carácter incluyendo los saltos de línea. En la herramienta EditPad Pro esto se hace por medio de la opción "punto corresponde a nueva línea" en las opciones de búsqueda. En .Net Framework se utiliza la opción RegexOptions. Singleline al efectuar la búsqueda o crear la expresión regular. El punto se utiliza de la siguiente forma: Si se le dice al motor de RegEx que busque "g.t" en la cadena "el gato de piedra en la gótica puerta de getisboro goot" el motor de búsqueda encontrará "gat", "gót" y por último "get". Nótese que el motor de búsqueda no encuentra "goot"; esto es porque el punto representa un solo carácter y únicamente uno. Si es necesario que el motor encuentre también la expresión "goot", será necesario utilizar repeticiones, las cuales se explican más adelante. Aunque el punto es muy útil para encontrar caracteres que no conocemos, es necesario recordar que corresponde a cualquier carácter y que muchas veces esto no es lo que se requiere. Es muy diferente buscar cualquier carácter que buscar cualquier carácter alfanumérico o cualquier dígito o cualquier no-dígito o cualquier noalfanumérico. Se debe tomar esto en cuenta antes de utilizar el punto y obtener resultados no deseados. La barra inversa o contrabarra "\" Se utiliza para "marcar" el siguiente carácter de la expresión de búsqueda de forma que este adquiera un significado especial o deje de tenerlo. O sea, la barra inversa no se utiliza nunca por sí sola, sino en combinación con otros caracteres. Al utilizarlo por ejemplo en combinación con el punto "\." este deja de tener su significado normal y se comporta como un carácter literal. De la misma forma, cuando se coloca la barra inversa seguida de cualquiera de los caracteres especiales que discutiremos a continuación, estos dejan de tener su significado especial y se convierten en caracteres de búsqueda literal. Docente: Ing. Mirko Manrique Ronceros ~ 41 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Como ya se mencionó con anterioridad, la barra inversa también puede darle significado especial a caracteres que no lo tienen. A continuación hay una lista de algunas de estas combinaciones: \t — Representa un tabulador. \r — Representa el "regreso al inicio" o sea el lugar en que la línea vuelve a iniciar. \n — Representa la "nueva línea" el carácter por medio del cual una línea da inicio. Es necesario recordar que en Windows es necesaria una combinación de \r\n para comenzar una nueva línea, mientras que en Unix solamente se usa \n. \a — Representa una "campana" o "beep" que se produce al imprimir este carácter. \e — Representa la tecla "Esc" o "Escape" \f — Representa un salto de página \v — Representa un tabulador vertical \x — Se utiliza para representar caracteres ASCII o ANSI si conoce su código. De esta forma, si se busca el símbolo de derechos de autor y la fuente en la que se busca utiliza el conjunto de caracteres Latin-1 es posible encontrarlo utilizando "\xA9". \u — Se utiliza para representar caracteres Unicode si se conoce su código. "\u00A2" representa el símbolo de centavos. No todos los motores de Expresiones Regulares soportan Unicode. El .Net Framework lo hace, pero el EditPad Pro no, por ejemplo. \d — Representa un dígito del 0 al 9. \w — Representa cualquier carácter alfanumérico. \s — Representa un espacio en blanco. \D — Representa cualquier carácter que no sea un dígito del 0 al 9. \W — Representa cualquier carácter no alfanumérico. \S — Representa cualquier carácter que no sea un espacio en blanco. \A — Representa el inicio de la cadena. No un carácter sino una posición. \Z — Representa el final de la cadena. No un carácter sino una posición. Docente: Ing. Mirko Manrique Ronceros ~ 42 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores \b — Marca el inicio y el final de una palabra. \B — Marca la posición entre dos caracteres alfanuméricos o dos noalfanuméricos. Nota: La utilidad Charmap.exe de Windows permite encontrar los códigos ASCII/ANSI/UNICODE para utilizarlos en Expresiones Regulares. Los corchetes "[]" La función de los corchetes en el lenguaje de las expresiones regulares es representar "clases de caracteres", o sea, agrupar caracteres en grupos o clases. Son útiles cuando es necesario buscar uno de un grupo de caracteres. Dentro de los corchetes es posible utilizar el guión "-" para especificar rangos de caracteres. Adicionalmente, los metacaracteres pierden su significado y se convierten en literales cuando se encuentran dentro de los corchetes. Por ejemplo, como vimos en la entrega anterior "\d" nos es útil para buscar cualquier carácter que represente un dígito. Sin embargo esta denominación no incluye el punto "." que divide la parte decimal de un número. Para buscar cualquier carácter que representa un dígito o un punto podemos utilizar la expresión regular "[\d.]". Como se hizo notar anteriormente, dentro de los corchetes, el punto representa un carácter literal y no un metacaracter, por lo que no es necesario antecederlo con la barra inversa. El único carácter que es necesario anteceder con la barra inversa dentro de los corchetes es la propia barra inversa. La expresión regular "[\dA-Fa-f]" nos permite encontrar dígitos hexadecimales. Los corchetes nos permiten también encontrar palabras aún si están escritas de forma errónea, por ejemplo, la expresión regular "expresi[oó]n" permite encontrar en un texto la palabra "expresión" aunque se haya escrito con o sin tilde. Es necesario aclarar que sin importar cuantos caracteres se introduzcan dentro del grupo por medio de los corchetes, el grupo solo le dice al motor de búsqueda que encuentre un solo carácter a la vez, es decir, que "expresi[oó]n" no encontrará "expresioon" o "expresioón". La barra "|" Sirve para indicar una de varias opciones. Por ejemplo, la expresión regular "a|e" encontrará cualquier "a" o "e" dentro del texto. La expresión regular "este|oeste|norte|sur" permitirá encontrar cualquiera de los nombres de los puntos Docente: Ing. Mirko Manrique Ronceros ~ 43 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores cardinales. La barra se utiliza comúnmente en conjunto con otros caracteres especiales El signo de dólar "$" Representa el final de la cadena de caracteres o el final de la línea, si se utiliza el modo multi-línea. No representa un carácter en especial sino una posición. Si se utiliza la expresión regular "\.$" el motor encontrará todos los lugares donde un punto finalice la línea, lo que es útil para avanzar entre párrafos El acento circunflejo "^" Este carácter tiene una doble funcionalidad, que difiere cuando se utiliza individualmente y cuando se utiliza en conjunto con otros caracteres especiales. En primer lugar su funcionalidad como carácter individual: el carácter "^" representa el inicio de la cadena (de la misma forma que el signo de dólar "$" representa el final de la cadena). Por tanto, si se utiliza la expresión regular "^[a-z]" el motor encontrará todos los párrafos que den inicio con una letra minúscula. Cuando se utiliza en conjunto con los corchetes de la siguiente forma "[^\w ]" permite encontrar cualquier carácter que NO se encuentre dentro del grupo indicado. La expresión indicada permite encontrar, por ejemplo, cualquier carácter que no sea alfanumérico o un espacio, es decir, busca todos los símbolos de puntuación y demás caracteres especiales. La utilización en conjunto de los caracteres especiales "^" y "$" permite realizar validaciones en forma sencilla. Por ejemplo "^\d$" permite asegurar que la cadena a verificar representa un único dígito, "^\d\d/\d\d/\d\d\d\d$" permite validar una fecha en formato corto, aunque no permite verificar si es una fecha válida, ya que 99/99/9999 también sería válido en este formato; la validación completa de una fecha también es posible mediante expresiones regulares, como se ejemplifica más adelante. Los paréntesis"()" De forma similar que los corchetes, los paréntesis sirven para agrupar caracteres, sin embargo existen varias diferencias fundamentales entre los grupos establecidos por medio de corchetes y los grupos establecidos por paréntesis: Los caracteres especiales conservan su significado dentro de los paréntesis. Docente: Ing. Mirko Manrique Ronceros ~ 44 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Los grupos establecidos con paréntesis establecen una "etiqueta" o "punto de referencia" para el motor de búsqueda que puede ser utilizada posteriormente como se denota más adelante. Utilizados en conjunto con la barra "|" permite hacer búsquedas opcionales. Por ejemplo la expresión regular "al (este|oeste|norte|sur) de" permite buscar textos que den indicaciones por medio de puntos cardinales, mientras que la expresión regular "este|oeste|norte|sur" encontraría "este" en la palabra "esteban", no pudiendo cumplir con este propósito. Utilizado en conjunto con otros caracteres especiales que se detallan posteriormente, ofrece funcionalidad adicional El signo de interrogación "?" El signo de pregunta tiene varias funciones dentro del lenguaje de las expresiones regulares. La primera de ellas es especificar que una parte de la búsqueda es opcional. Por ejemplo, la expresión regular "ob?scuridad" permite encontrar tanto "oscuridad" como "obscuridad". En conjunto con los parentesis redondos permite especificar que un conjunto mayor de caracteres es opcional; por ejemplo "Nov(\.|iembre|ember)?" permite encontrar tanto "Nov" como "Nov.", "Noviembre" y "November". Como se mencionó anteriormente los paréntesis nos permiten establecer un "punto de referencia" para el motor de búsqueda, sin embargo, algunas veces, no se desea utilizarlos con este propósito, como en el ejemplo anterior "Nov(\.|iembre|ember)?". En este caso el establecimiento de este punto de referencia (que se detalla más adelante) representa una inversión inútil de recursos por parte del motor de búsqueda. Para evitar se puede utilizar el signo de pregunta de la siguiente forma: "Nov(?:\.|iembre|ember)?". Aunque el resultado obtenido será el mismo, el motor de búsqueda no realizará una inversión inútil de recursos en este grupo, sino que lo ignorará. Cuando no sea necesario reutilizar el grupo, es aconsejable utilizar este formato. De forma similar, es posible utilizar el signo de pregunta con otro significado: Los paréntesis definen grupos "anónimos", sin embargo el signo de pregunta en conjunto con los paréntesis triangulares "<>" permite "nombrar" estos grupos de la siguiente forma: "^(?<Día>\d\d)/(?<Mes>\d\d)/(?<Año>\d\d\d\d)$"; Con lo cual se le especifica al motor de búsqueda que los primeros dos dígitos encontrados llevarán la etiqueta "Día", los segundos la etiqueta "Mes" y los últimos cuatro dígitos llevarán la etiqueta "Año". Docente: Ing. Mirko Manrique Ronceros ~ 45 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Nota: a pesar de la complejidad y flexibilidad dada por los caracteres especiales estudiados hasta ahora, en su mayoría nos permiten encontrar solamente un caractér a la vez, o un grupo de caracteres a la vez. Los metacaracteres enumerados en adelante permiten establecer repeticiones Las llaves "{}" Comúnmente las llaves son caracteres literales cuando se utilizan por separado en una expresión regular. Para que adquieran su función de metacaracteres es necesario que encierren uno o varios números separados por coma y que estén colocados a la derecha de otra expresión regular de la siguiente forma: "\d{2}" Esta expresión le dice al motor de búsqueda que encuentre dos dígitos contiguos. Utilizando esta fórmula podríamos convertir el ejemplo "^\d\d/\d\d/\d\d\d\d$" que servía para validar un formato de fecha en "^\d{2}/\d{2}/\d{4}$" para una mayor claridad en la lectura de la expresión. Nota: aunque esta forma de encontrar elementos repetidos es muy útil, algunas veces no se conoce con claridad cuantas veces se repite lo que se busca o su grado de repetición es variable. En estos casos los siguientes metacaracteres son útiles. El asterisco "*" El asterisco sirve para encontrar algo que se encuentra repetido 0 o más veces. Por ejemplo, utilizando la expresión "[a-zA-Z]\d*" será posible encontrar tanto "H" como "H1", "H01", "H100" y "H1000", es decir, una letra seguida de un número indefinido de dígitos. Es necesario tener cuidado con el comportamiento del asterisco, ya que este por defecto trata de encontrar la mayor cantidad posible de caracteres que correspondan con el patrón que se busca. De esta forma si se utiliza "\(.*\)" para encontrar cualquier cadena que se encuentre entre paréntesis y se lo aplica sobre el texto "Ver (Fig. 1) y (Fig. 2)" se esperaría que el motor de búsqueda encuentre los textos "(Fig. 1)" y "(Fig. 2)", sin embargo, debido a esta característica, en su lugar encontrará el texto "(Fig. 1) y (Fig. 2)". Esto sucede porque el asterisco le dice al motor de búsqueda que llene todos los espacios posibles entre dos paréntesis. Para obtener el resultado deseado se debe utilizar el asterisco en conjunto con el signo de pregunta de la siguiente forma: "\(.*?\)" Esto es equivalente a decirle al motor de búsqueda que "Encuentre un paréntesis de apertura y luego encuentre cualquier carácter repetido hasta que encuentre un paréntesis de cierre” Docente: Ing. Mirko Manrique Ronceros ~ 46 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores El signo de suma "+" Se utiliza para encontrar una cadena que se encuentre repetida 1 o más veces. A diferencia del asterisco, la expresión "[a-zA-Z]\d+" encontrará "H1" pero no encontrará "H". También es posible utilizar este metacaracter en conjunto con el signo de pregunta para limitar hasta donde se efectúa la repetición Grupos anónimos Los grupos anónimos se establecen cada vez que se encierra una expresión regular en paréntesis, por lo que la expresión "<([a-zA-Z]\w*?)>" define un grupo anónimo que tendrá como resultado que el motor de búsqueda almacenará una referencia al texto que corresponda a la expresión encerrada entre los paréntesis. La forma más inmediata de utilizar los grupos que se definen es dentro de la misma expresión regular, lo cual se realiza utilizando la barra inversa "\" seguida del número del grupo al que se desea hacer referencia de la siguiente forma: "<([a-zAZ]\w*?)>.*?</\1>" Esta expresión regular encontrará tanto la cadena "Esta" como la cadena "prueba" en el texto "Esta es una prueba" a pesar de que la expresión no contiene los literales "font" y "B". Docente: Ing. Mirko Manrique Ronceros ~ 47 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores AUTOMATAS Autómata del griego automatos (αὐτόματος) que significa espontáneo o con movimiento propio, puede referirse a: Autómata programable: Equipo electrónico programable en lenguaje no informático y diseñado para controlar, en tiempo real y en ambiente industrial, procesos secuenciales. Teoría de autómatas: Estudio matemático de máquinas abstractas. (p.e. Autómata finito, autómata con pila) Autómata (mecánico): Máquina que imita la figura y los movimientos de un ser animado. Robot: Máquina o ingenio electrónico programable, capaz de manipular objetos y realizar operaciones antes reservadas solo a las personas. Teoría de autómatas La teoría de autómatas es una rama de las ciencias de la computación que estudia matemáticamente máquinas abstractas y problemas que éstas son capaces de resolver. La teoría de autómatas esta estrechamente relacionada con la teoría del lenguaje formal ya que los autómatas son clasificados a menudo por la clase de lenguajes formales que son capaces de reconocer. Un autómata es un modelo matemático para una máquina de estado finita (FSM sus siglas en inglés). Una FSM es una máquina que, dada una entrada de símbolos, "salta" a través de una serie de estados de acuerdo a una función de transición (que puede ser expresada como una tabla). En la variedad común "Mealy" de FSMs, esta función de transición dice al autómata a que estado cambiar dados un determinado estado y símbolo. La entrada es leída símbolo por símbolo, hasta que es "consumida" completamente (piense en esta como una cinta con una palabra escrita en ella, que es leída por por una cabeza lectora del autómata; la cabeza se mueve a lo largo de la cinta, leyendo un símbolo a la vez) una vez la entrada se ha agotado, el autómata se detiene. Dependiendo del estado en el que el autómata para se dice que este a aceptado o rechazado la entrada. Si este termina en el estado "acepta", el autómata acepta la palabra. Si lo hace en el estado "rechaza", el autómata rechazó la palabra, el conjunto de todas las palabras aceptadas por el autómata constituyen el lenguaje aceptado por el mismo. Docente: Ing. Mirko Manrique Ronceros ~ 48 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores HERRAMIENTAS PARA COMPILADORES Un analizador léxico también es conocido como escáner; pues su funcionalidad es la de analizar el lexema de las palabras o cadenas de caracteres sobre un patrón definido. Es decir; El proceso de análisis léxico se refiere al trabajo que realiza el scanner con relación al proceso de compilación. El scanner representa una interfaz entre el programa fuente y el analizador sintáctico o parser. El scanner, a través del examen carácter por carácter del texto, separa el programa fuente en piezas llamadas tokens, los cuales representan los nombres de las variables, operadores, etiquetas, y todo lo que comprende el programa fuente Un analizador de léxico tiene como función principal el tomar secuencias de caracteres o símbolos del alfabeto del lenguaje y ubicarlas dentro de categorías, conocidas como unidades de léxico. Las unidades de léxico son empleadas por el analizador gramatical para determinar si lo escrito en el programa fuente es correcto o no gramaticalmente. Algunas de las unidades de léxico no son empleadas por el analizador gramatical sino que son descartadas o filtradas. Tal es el caso de los comentarios, que documentan el programa pero que no tienen un uso gramatical, o los espacios en blanco, que sirven para dar legibilidad a lo escrito. Algunos generadores de Analizadores Léxico… LEX Código generado: C. FLEX Código generado: C++. ZLEX Código generado: C., Soporta códigos de caracteres de 16 bits. JAX Código generado: Java. No soporta entornos, está basado en expresiones regulares. No soporta Unicode. JLEX Código generado: Java. Similar a lex. Diseñado para ser usado junto con CUP. JFLEX Código generado: Java. Diseñado para ser usado junto con CUP. Docente: Ing. Mirko Manrique Ronceros ~ 49 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores LEX: Recibe la especificación de las expresiones regulares de los patrones que representan a los tokens del lenguaje y las acciones a tomar cuando los detecte. Genera los diagramas de transición de estados en código C, C ++, o Java generalmente. Ventajas: Comodidad de Desarrollo. Desventajas: 1. El mantenimiento del código generado resulta complicado. 2. La eficiencia del código generado depende del generador. Parte de un conjunto de reglas léxicas (expresiones regulares) y produce un programa (yylex) que reconoce las cadenas que cumplen dichas reglas. 1. Yylex es la implementación del Autómata Finito Determinista. FLEX: Flex es una herramienta para generar escáneres: programas que reconocen patrones léxicos en un texto. flex lee los ficheros de entrada dados, o la entrada estándar si no se le ha indicado ningún nombre de fichero, con la descripción de un escáner a generar. La descripción se encuentra en forma de parejas de expresiones regulares y código C, denominadas reglas. flex genera como salida un fichero fuente en C, `lex.yy.c', que define una rutina `yylex()'. Este fichero se compila y se enlaza con la librería `-lfl' para producir un ejecutable. Cuando se arranca el fichero ejecutable, este analiza su entrada en busca de casos de las expresiones regulares. Siempre que encuentra uno, ejecuta el código C correspondiente El principal objetivo de diseño de flex es que genere analizadores de alto rendimiento. Este ha sido optimizado para comportarse bien con conjuntos grandes de reglas. Aparte de los efectos sobre la velocidad del analizador con las opciones de compresión de tablas `-C' hay un número de opciones/acciones que degradan el rendimiento. Flex ofrece dos maneras distintas de generar analizadores para usar con C++. La primera manera es simplemente compilar un analizador generado por flex usando un compilador de C++ en lugar de un compilador de C. No debería encontrarse ante ningún error de compilación Puede entonces usar código C++ en sus acciones de las reglas en lugar de código C. Fíjese que la fuente de entrada por defecto para su Docente: Ing. Mirko Manrique Ronceros ~ 50 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores analizador permanece como yyin, y la repetición por defecto se hace aún a yyout. Ambos permanecen como variables `FILE *' y no como flujos de C++. Flex es una reescritura de la herramienta lex del Unix de AT&T (aunque las dos implementaciones no comparten ningún código), con algunas extensiones e incompatibilidades, de las que ambas conciernen a aquellos que desean escribir analizadores aceptables por cualquier implementación. Flex sigue completamente la especificación POSIX de lex, excepto que cuando se utiliza `%pointer' (por defecto), una llamada a `unput()' destruye el contenido de yytext, que va en contra de la especificación POSIX. http://ditec.um.es/~aflores/dile/flex/flex-es_toc.html#TOC1 JAX: Jax es un compilador léxico creado en lenguaje Java, que genera un escáner a partir de expresiones regulares que existen por defecto en un archivo de java. Jax procesa estas expresiones regulares y genera un ficher Java que pueda ser compilado por Java y así crear el escaner. Los escaners generados por Jax tienen entradas de búfer de tamaño arbitrario, y es al menos más conveniente para crear las tablas de tokens, Jax utiliza solo 7 bits de caracteres ASCII, y no permite código Unario. http://www.cs.princeton.edu/~ejberk/JavaLex/JavaLex.html Uno de los aspectos fundamentales que tienen los lenguajes Lex y Yacc es que son parte importante de un compilador. Pues la unión de estos dos generan un compilador, claro cada uno de ellos aportando su propio diseño y su forma de ejecutar sus procesos. Tanto el analizador léxico como el sintáctico pueden ser escritos en cualquier lenguaje de programación. A pesar de la habilidad de tales lenguajes de propósito general como C, lex y yacc son más flexibles y mucho menos complejos de usar. Docente: Ing. Mirko Manrique Ronceros ~ 51 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores LENGUAJE LEX ¿QUÉ ES LEX? Lex es un generador de analizadores léxicos. Cada vez que Lex encuentra un lexema que viene definido por una expresión regular, se ejecutan las acciones (escritas en C) que van al lado de la definición de dicha expresión. Cuando se emplea el término Lex, se mencionan dos posibles significados: a. Una notación para especificar las características lexicográficas de un lenguaje de programación, b. Un traductor de especificaciones lexicográficas. Esta misma dualidad también es de aplicación al término Yacc. Lex crea yylex, una variable que contendrá un número, el cual se corresponde con el token de cada expresión regular. También, instancia una variable global yytext, que contiene el lexema que acaba de reconocer. Así, por ejemplo, para el siguiente código fuente de entrada: %%expresión1 {acción1}expresión2 {acción2}... ...expresiónn {acciónn}%% Lex genera un programa en C (generalmente denominado lex.yy.c) que incluye, entre otras, la función yylex(): Int yylex(){ while(!eof()) switch(...) { { case -1: ... ; break; case 0: ... ; break; case 1: {acción1}; break; case n: {acciónn}; break; } ... ... ... }...} Esta función recorre el texto de entrada. Al descubrir algún lexema que se corresponde con alguna expresión regular, construye el token, y realiza la acción correspondiente. Cuando se alcanza el fin de fichero, devolverá -1. La función yylex() podrá ser invocada desde cualquier lugar del programa. Cómo escribir expresiones regulares en Lex Docente: Ing. Mirko Manrique Ronceros ~ 52 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Deberán cumplir los siguientes requisitos: Las expresiones regulares (ER, de aquí en adelante) han de aparecer en la primera columna. Alfabeto de entrada: Caracteres ASCII 0 al 127. Concatenación: Sin carácter especial, se ponen los caracteres juntos. Caracteres normales: Se representan a ellos mismos. Caracteres especiales: Se les pone la barra '\' delante. Éstos son: *+?|[]()"\.{}^$/<> Caracteres especiales dentro de los corchetes ( '[' y ']' ): - \ ^ Las equivalencias entre ER normales y las que se usan en Lex se muestran con ejemplos en esta tabla: Caracteres Ejemplo Significado Concatenación Xy El patrón consiste en x seguido de y. Unión x|y El patrón consiste en x o en y. Repetición x* El patrón consiste en x repetido cero a más veces. Clases de [0-9] caracteres Alternancia indicado, de en caracteres este caso en el rango 0|1|2|...|9. Más de un rango se puede especificar, como por ejemplo: [0-9A-Za-z] para caracteres alfanuméricos. Operador [^0-9] negación El primer carácter en una clase de caracteres deberá ser ^ para indicar el complemento del conjunto de caracteres especificado. Así, [^0-9] especifica cualquier carácter que no sea un dígito. Carácter . Con un único carácter, excepto \n. x? Cero o una ocurrencia de x. arbitrario Repetición única Repetición no x+ Una o más ocurrencias de x. nula Docente: Ing. Mirko Manrique Ronceros ~ 53 ~ Universidad Nacional del Santa Repetición Curso: Teoría de Compiladores x{n,m} x repetido entre n y m veces. especificada Comienzo de ^x Unifica x sólo al comienzo de una línea línea Fin de línea x$ Unifica x sólo al final de una línea Sensibilidad al ab/cd Unifica ab, pero sólo seguido de bc. contexto (operador "look ahead") Cadenas de "x" Cuando x tenga un significado especial. literales Caracteres \x Cuando x es un operador que se representa a literales él mismo. También para el caso de \n, \t, etc. Definiciones {nombre_var} Pueden definirse subpatrones. Esto significa incluir el patrón predefinido llamado nombre_var. Definiciones en Lex La sección de definiciones permite predefinir cadenas que serán útiles en la sección de las reglas. Por ejemplo: comentario"//".*limitador [ \t\n]espblanco {limitador}+letramay [A-Z]letramin {letramay}|{letramin}carascii [a-z]letra [^\"\n]caresc \\n|\\\"digito [0-9]variable {letramin}({letramin}|{digito})*entero {digito}+texto \"{(carascii}|{caresc})*\" Cada regla está compuesta de un nombre que se define en la parte izquierda, y su definición se coloca en la derecha. Así, podemos definir comentario como // (con la barra puesto que es un carácter especial), seguido por un número arbitrario de caracteres excepto el de fin de línea. Un limitador será un espacio, tabulador o fin de línea, y un espblanco será uno o más limitadores. Nótese que la definición de espblanco usa la definición anterior de limitador. Reglas para eliminar ambigüedades Docente: Ing. Mirko Manrique Ronceros ~ 54 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Se producen cuando varias ER's son aplicables a un mismo lexema reconocido. Lex seleccionará siempre la ocurrencia más larga posible. Si dos ocurrencias tienen la misma longitud, tomará la primera. Formalmente, podemos definir a lex como una herramienta para construir analizadores léxicos o "lexers". Un lexer lee de un flujo de entrada cualquiera, y la divide en unidades léxicas (la tokeniza), para ser procesada por otro programa o como producto final. La entrada es tomada de yyin, que por defecto su valor es stdin, es decir, la pantalla o terminal, pero este valor puede ser modificado por cualquier apuntador a un archivo. También es posible leer la entrada desde un arreglo de caracteres u otros medios, para cual es necesario implementar algunas funciones de lex mismas que definiremos en la última parte de esta sección (Agregar Funcionalidad). Expresiones regulares usadas en lex Para poder crear expresiones regulares y patrones para las reglas, es necesario saber que la concatenación de expresiones se logra simplemente juntando dos expresiones, sin dejar espacio entre ellas y que es bueno declarar una expresión muy compleja por partes como definiciones, y así evitar tener errores difíciles de encontrar y corregir. A continuación una lista de las expresiones regulares mas usadas en lex. Ops Ejemplo Explicación [] [a-z] Una clase de Caracteres, coincide con un carácter perteneciente a la clase, pueden usarse rangos, como en el ejemplo, cualquier carácter, excepto aquellos especiales o de control son tomados literalmente, en el caso de los que no, pueden usarse secuencias de escape como las de C, \t, \n etcétera. Si su primer carácter es un "^", entonces coincidirá con cualquier carácter fuera de la clase. * [ \n\t]* Todas las cadenas que se puedan formar, se puede decir que este operador indica que se va a coincidir con cadenas formadas por ninguna o varias apariciones del patrón que lo Docente: Ing. Mirko Manrique Ronceros ~ 55 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores antecede. El ejemplo coincide con cualquier combinación de símbolos usados para separar, el espacio, retorno y tabulador. + [0-9]+ Todas las cadenas que se puedan formar, excepto cadenas vacías. En el ejemplo se aceptan a todos los números naturales y al cero. . .+ Este es una expresión regular que coincide con cualquier entrada excepto el retorno de carro ("\n"). El ejemplo acepta cualquier cadena no vacía. {} a{3,6} Indica un rango de repetición cuando contiene dos números separados por comas, como en el ejemplo, la cadena aceptada será aquella con longitud 3, 4, 5 o 6 formada por el carácter 'a'. Indica una repetición fija cuando contiene un solo numero, por ejemplo, a{5}, aceptaría cualquier cadena formada por 5 a's sucesivas. En caso de contener un nombre, indica una sustitución por una declaración en la sección de declaraciones (Revisar el ejemplo1). ? -?[0-9]+ Indica que el patrón que lo antecede es opcional, es decir, puede existir o no. En el ejemplo, el patrón coincide con todos los números enteros, positivos o negativos por igual, ya que el signo es opcional. | (-|+|~)?[0- Este hace coincidir, al patrón que lo precede o lo antecede y 9]+ puede usarse consecutivamente. En el ejemplo tenemos un patrón que coincidirá con un entero positivo, negativo o con signo de complemento. "" "bye" Las cadenas encerradas entre " y " son aceptadas literalmente, es decir tal como aparecen dentro de las comillas, para incluir caracteres de control o no imprimibles, pueden usarse dentro de ellas secuencias de escape de C. En el ejemplo la única cadena que coincide es 'bye'. \ \. Indica a lex que el carácter a continuación será tomado literalmente, como una secuencia de escape, este funciona para todos los caracteres reservados para lex y para C por Docente: Ing. Mirko Manrique Ronceros ~ 56 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores igual. En el ejemplo, el patrón coincide solo con el carácter "." (punto), en lugar de coincidir con cualquier carácter, como seria el casi sin el uso de "\". <<EOF>> [a-z] Solo en flex, este patrón coincide con el fin de archivo. Ampliación de las expresiones regulares Las expresiones regulares (propiamente dichas, en un sentido estricto), tal y como se estudian en la teoría de lenguajes para especificar los lenguajes regulares, están constituidas por símbolos de un alfabeto Σ, relacionados mediante los operadores binarios alternativa (|) y concatenación (·) y el operador unitario estrella (*); en la escritura de una expresión regular también se pueden emplear paréntesis para precisar el orden de aplicación de los operadores. El asterisco de la operación estrella suele colocarse como exponente de la parte de la expresión regular afectada. La precedencia de los operadores es la definida por la siguiente jerarquía, relacionada de mayor a menor precedencia: 1. operaciones entre paréntesis 2. operador estrella 3. operador concatenación 4. operador alternativa Así, por ejemplo, son expresiones regulares definidas sobre el alfabeto Σ = {a, b} b·a·a|b·b a*·(b|b·a) La primera denota el lenguaje regular formado por dos palabras {baa, bb} y la segunda denota el lenguaje regular de infinitas palabras {b, ba, ab, aba, aab, aaba, }. Entre los símbolos que aparecen en una expresión regular cabe distinguir los caracteres y los metacaracteres; los caracteres son los símbolos que pertenecen al alfabeto sobre el que está definida la expresión regular; los metacaracteres son los símbolos que no pertenecen a ese alfabeto: los operadores y los paréntesis. En la escritura de las expresiones regulares el punto representativo de la concatenación entre símbolos del alfabeto suele suprimirse; de acuerdo con esta notación simplificada, las anteriores expresiones suelen escribirse así: baa|bb a*(b|ba) Docente: Ing. Mirko Manrique Ronceros ~ 57 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Dado que el espacio en blanco no es un símbolo perteneciente al alfabeto Σ sobre el que están definidas las expresiones regulares anteriores, también podrían escribirse (sin ocasionar confusión y con la pretensión de favorecer la legibilidad) de esta manera: baa | bb a* ( b | ba ) En una especificación Lex se incluyen expresiones regulares, pero escritas con una notación que es una ampliación de la notación empleada en la definición (en sentido estricto) anterior. Esta ampliación tiene como principales objetivos: - hacer más cómoda y escueta la escritura de las expresiones regulares, - distinguir de manera precisa los caracteres del alfabeto y los metacaracteres empleados en la escritura de las expresiones regulares. Sea el alfabeto Σ = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, la expresión regular que denota las palabras de longitud uno es: 0|1|2|3|4|5|6|7|8|9 Con la notación ampliada de las expresiones regulares Lex (empleando unos nuevos metacaracteres: el guión y los corchetes de abrir y de cerrar), esa misma expresión puede escribirse así: [0-9] Las expresiones regulares de una especificación Lex han de procesarse mediante un programa y, por ello, han de estar grabadas en un fichero de tipo texto. En estas condiciones no resulta adecuado el convenio según el cual la operación estrella se escribe en forma de exponente; por ejemplo, la expresión regular ab* Quedaría grabada en el fichero mediante la secuencia de tres caracteres consecutivos ab*. Si Σ = {+, -, *, /} es el alfabeto sobre el que definen las expresiones regulares, ¿qué lenguaje denota la expresión regular +* grabada como una secuencia de dos caracteres? La respuesta depende de si el asterisco se considera como carácter del alfabeto o como operador (metacarácter). Las expresiones regulares empleadas en las especificaciones Lex no tienen conceptualmente ninguna diferencia con las expresiones regulares (en sentido estricto) que definen los lenguajes formales regulares; lo único que aportan son modificaciones en la notación empleada en la escritura de las expresiones en forma de secuencias de caracteres consecutivos susceptibles de grabarse en un fichero de tipo texto. Esta notación ampliada alcanza cierta dificultad por las siguientes causas: Para facilitar y acortar la escritura de las expresiones se introducen bastantes metacaracteres que se intercalan con los caracteres Docente: Ing. Mirko Manrique Ronceros ~ 58 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Es habitual que cualquier carácter del alfabeto ASCII forme parte del alfabeto sobre el que se definen las expresiones regulares y que, por lo tanto, pueda aparecer en ellas (incluso el espacio En blanco, el tabulador o el fin de línea). Se precisa la definición de convenios para distinguir entre los caracteres y los metacaracteres. En lo que sigue se emplea la notación para indicar que lo que se pone a continuación de esos símbolos es la descripción del conjunto de palabras denotadas por la expresión regular que les precede. Variables y Funciones, que se usan dentro de un programa generado por lex FILE *yyin Este es un apuntador declarado globalmente que apunta al lugar de donde se van a leer los datos, por ser un file pointer, este solo puede leer de flujos como archivos, para leer de una cadena es necesario reimplementar el macro input() como se vera mas adelante. FILE *yyout Este es el lugar al que se escriben por default todos los mensajes, al igual que yyin esta declarado globalmente y es un apuntador. int input(void) El objetivo de esta Macro es alimentar a yylex() carácter por carácter, devuelve el siguiente carácter de la entrada, la intención más común para modificar esta función, es cambiar el origen de la entrada de manera mas flexible que con yyin, ya que no solo es posible leer de otro archivo, sino que también es posible leer el flujo para parsear una cadena cualquiera, o un grupo de cadenas como una línea de comandos. Para reimplementar esta macro, es necesario primero eliminarla del archivo por lo que es necesario incluir un comando del preprocesador de C el sección de declaraciones : %{ #undef input %} y en la parte de subrutinas, implementar nuestro nuevo input() con el prototipo mostrado anteriormente. void unput(int) Docente: Ing. Mirko Manrique Ronceros ~ 59 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores El objetivo de esta macro, es regresar un carácter a la entrada de datos, es útil para yylex() tener una de estas, ya que para identificar un patrón puede ser necesario saber que carácter es el que sigue. La intención de reimplementar esta es complementar el uso de la reimplementacion de input(), ya que input() y unput() deben ser congruentes entre si. Antes de reimplementar esta, también es necesario eliminarla antes usando una instrucción del preprocesador de C: %{ #undef unput %} int yywrap(void) Esta función, es auxiliar en el manejo de condiciones de final de archivo, su misión es proporcionarle al programador la posibilidad de hacer algo con estas condiciones, como continuar leyendo pero desde otro archivo etcétera. int yylex(void) Esta función, es casi totalmente implementada por el usuario en la sección de reglas, donde como ya vimos, puede agregarse código encerrado entre %{ y %} así como en las reglas mismas. int yyleng; Contiene la longitud del token leido, su valor es equivalente a yyleng = strlen(yytext);. char *yytext; Contiene el token que acaba de ser reconocido, su uso es principalmente dentro de las reglas, donde es común hacer modificaciones al token que acaba de ser leído o usarlo con algún otro fin. En el ejemplo 1 este token es usado para dar echo en la pantalla. void output(int); Esta macro, escribe su argumento en yyout. void yyinput(void); Es una interfaz para la macro input(). void yyunput(int); Es una interfaz para la macro unput(). void yyoutput(int); Docente: Ing. Mirko Manrique Ronceros ~ 60 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Es una interfaz para la macro output(). Partes del un programa Lex Un programa Lex consta de tres secciones: <declaraciones> %% <reglas de traducción> %% <procedimientos auxiliares> La sección de declaraciones incluye declaraciones de variables, constantes y definiciones regulares. Las definiciones regulares son sentencias usadas como componentes de las expresiones regulares que aparecen en las reglas. Las reglas de traducción de un programa Lex son sentencias de la forma: p1 { acción1 } p2 { acción2 } ... ... pn { acciónn } donde cada pi es una expresión regular y cada accióni es un fragmento de programa, describiendo qué acción debe realizar el analizador léxico cuando el patrón pi se corresponde con un lexema. En Lex, las acciones están escritas en C. La tercera sección contiene cualesquiera procedimientos auxiliares que sean requeridos por las acciones. Alternativamente, estos procedimientos pueden ser compilados separadamente y montados junto con el analizador léxico. Un analizador léxico creado por Lex funciona en concierto con un analizador sintáctico de la siguiente manera. Cuando es activado por el analizador sintáctico, el analizador léxico comienza leyendo de su entrada un carácter a la vez, hasta que encuentre el prefijo más largo de la entrada que ha correspondido con una de las expresiones regulares pi. Entonces, ejecuta accióni, que típicamente devolverá el control al parser. Pero, si no lo hace, entonces el analizador léxico procede a buscar más lexemas, hasta que una acción contenga una sentencia return o se lea el fichero completo. La búsqueda repetida de lexemas hasta una devolución explícita del control permite que el analizador léxico procese los espacios en blanco y comentarios convenientemente. El analizador léxico devuelve un entero, que representa el token, al analizador sintáctico. Para pasar un valor de atributo con información sobre el lexema, se puede usar una variable global llamada yylval. Esto se hace cuando se use Yacc como generador del analizador sintáctico. Docente: Ing. Mirko Manrique Ronceros ~ 61 ~ Universidad Nacional del Santa Los analizadores léxicos, Curso: Teoría de Compiladores para ciertas construcciones de lenguajes de programación, necesitan ver adelantadamente más allá del final de un lexema antes de que puedan determinar un token con certeza. En Lex, se puede escribir un patrón de la forma r1/r2, donde r1 y r2 son expresiones regulares, que significa que una cadena se corresponde con r1, pero sólo si está seguida por una cadena que se corresponde con r2. La expresión regular r2, después del operador lookahead "/", indica el contexto derecho para una correspondencia; se usa únicamente para restringir una correspondencia, no para ser parte de la correspondencia. Programación de analizadores mediante LEX Lex suele ser usado según la siguiente figura: Primero, se prepara una especificación de un analizador léxico creando un programa contenido, por ejemplo en el fichero prog.l, en lenguaje Lex. Entonces, prog.l se pasa a través del compilador Lex para producir un programa en C, que por defecto se denomina lex.yy.c en el sistema operativo UNIX. Éste consiste en una representación tabular de un diagrama de transición construido a partir de las expresiones regulares de prog.l, junto con una rutina estándar que usa la tabla de reconocimiento de lexemas. Las acciones asociadas con expresiones regulares en prog.l son trozos de código C, y son transcritas directamente a lex.yy.c. Finalmente, lex.yy.c se pasa a través del compilador C para producir un programa objeto, que por defecto se llama a.out, el cual es el analizador léxico que transforma una entrada en una secuencia de tokens. Docente: Ing. Mirko Manrique Ronceros ~ 62 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores RECUPERACIÓN DE ERRORES LEXICOGRÁFICOS Los programas pueden contener diversos tipos de errores, que pueden ser: Errores lexicográficos: Que veremos a continuación. Errores sintácticos: Por ejemplo, una expresión aritmética con mayor numero de paréntesis de apertura que de cierre. Errores semánticas: Por ejemplo, la aplicación de un operador a un tipo de datos incompatible con el mismo. Errores lógicos: Por ejemplo, un bucle sin final. Cuando se detecta un error, un compilador puede detenerse en ese punto e informar al usuario, o bien desechar una serie de caracteres del texto fuente y continuar con el análisis, dando al final una lista completa de todos los errores detectados. En ciertas ocasiones es incluso posible que el compilador corrija el error, haciendo una interpretación coherente de los caracteres leídos. En estos casos, el compilador emite una advertencia, indicando la suposición que ha tomado, y continúa el proceso sin afectar a las sucesivas fases de compilación. Los errores lexicográficos se producen cuando el analizador no es capaz de generar un token tras leer una determinada secuencia de caracteres. En general, puede decirse que los errores lexicográficos son a los lenguajes de programación lo que las faltas de ortografía a los lenguajes naturales. Las siguientes situaciones producen con frecuencia la aparición de errores lexicográficos: 1. Lectura de un carácter que no pertenece al vocabulario terminal previsto para el autómata. Lo más normal en este caso es que el autómata ignore estos caracteres extraños y continue el proceso normalmente. Por ejemplo, pueden dar error en la fase de análisis lexicográfico la inclusión de caracteres de control de la impresora en el programa fuente para facilitar su listado. 2. Omisión de un carácter. Por ejemplo, si se ha escrito ELS en lugar de ELSE. 3. Se ha introducido un nuevo caracter. Por ejemplo, si escribimos ELSSE en lugar de ELSE. 4. Han sido permutados dos caracteres en el token analizado. Por ejemplo, si escribiéramos ESLE en lugar de ELSE. 5. Un carácter ha sido cambiado. Por ejemplo, si se escribiera ELZE en vez de ELSE. Las técnicas de recuperación de errores lexicográficos se basan, en general, en la obtención de los distintos sinónimos de una determinada cadena que hemos detectado Docente: Ing. Mirko Manrique Ronceros ~ 63 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores como errónea. Por otra parte, el analizador sintáctico es capaz en muchos casos de avisar al analizador lexicográfico de cuál es el token que espera que éste lea. Análogamente, podemos incluir rutinas para los demás casos. Por ejemplo, si el analizador lee el lexema ESLE, y no puede construir un token correcto para él mismo, procedería a generar los sinónimos por intercambio de caracteres (es decir, SELE, ELSE o ESEL) y comprobaría si alguno de ellos es reconocible. En caso afirmativo, genera el token correspondiente y advierte al usuario del posible error y de su interpretación automática, continuando con el proceso. Todos los procedimientos para la recuperación de errores lexicográficos son en la práctica métodos específicos, y muy dependientes del lenguaje que se pretende compilar. Reglas de Lex Esta sección también puede incluir código de C encerrado por %{ y %}, que será copiado dentro de la función yylex(), su alcance es local dentro de la misma función. Las reglas de lex, tienen el siguiente formato : <Expresión regular><Al menos un espacio>{Código en C} En el ejemplo podemos ver que : "bye" {bye();return 0;} "quit" {bye();return 0;} "resume" {bye();return 0;} {Palabra} {printf("Se leyó la palabra : %s", yytext);palabra++;} {Numero} {printf("Se leyó el numero : %d", atoi(yytext));numero++;} . printf("%s",yytext[0]); Como ya vimos en la segunda columna se escriben acciones en C a realizar cada que se acepta una cadena con ese patrón, misma que es almacenada en un array apuntado por yytext, podemos ver que las acciones están encerradas entre "{" y "}" lo que indica que se incluye más de un statement de C por regla, el contra ejemplo es la ultima regla, que reconoce cualquier carácter y lo imprime a la pantalla mediante el uso de printf(). Entonces, podemos decir que una regla de lex esta formada por una expresión regular y la acción correspondiente, típicamente encerrada entre "{" y "}". Docente: Ing. Mirko Manrique Ronceros ~ 64 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Notas complementarias sobre Lex Cada vez que se realice una de las acciones, la variable char *yytext contendrá el lexema reconocido. La variable int yyleng contiene la longitud del lexema reconocido. Entrada y salida: FILE *yyin, *yyout. Por defecto, se usan los predefinidos en C. Cuando Lex reconoce el carácter de fin de fichero, llama a la función int yywrap(), que por defecto devuelve 1. Si devuelve 0, significará que está disponible una entrada anterior, con lo cuál aún no se habrá terminado la lectura. Contextos: Permiten especificar cúando se usarán ciertas reglas. Veremos mediante un ejemplo de eliminación de comentarios cómo se usan los contextos: %start COMENTARIO%%\/\* {BEGIN COMENTARIO;} /* activa COMENTARIO */<COMENTARIO>\*\/ {BEGIN 0;} /* desactiva todos */<COMENTARIO>. ; /* nothing to do! ;-P */[a-zA-Z][a-z0-9]+ ;...%% Ejemplos en lex EJEMPLO: A continuación se presenta un ejemplo que ilustra de manera general el uso de lex para reconocer patrones de expresiones regulares básicas, que reconoce cualquier numero entero y cualquier palabra formada por letras mayúsculas de la "a" a la "z", sin importar si son mayúsculas o minúsculas. Download %{ #include int palabra=0, numero=0; %} Numero -?[0-9]+ Palabra [a-zA-Z]+ %% "bye" {bye();return 0;} "quit" {bye();return 0;} "resume" {bye();return 0;} Docente: Ing. Mirko Manrique Ronceros ~ 65 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores {Palabra} {printf("Se leyó la palabra : %s", yytext);palabra++;} {Numero} {printf("Se leyó el numero : %d", atoi(yytext));numero++;} . printf("%s",yytext[0]); %% main(){ printf("ejem1.l\nEste ejemplo, distingue entre un numero entero y palabras.\n Introduzca bye, quit o resume para terminar.\n"); yylex(); } bye(){ printf("Se leyeron %d entradas, reconocieron\n%d\tEnteros\ny\n%d\tPalabras.\n", de las cuales (palabra+numero), se numero, palabra); } En este ejemplo, una de las primeras cosas a notar, son las dos líneas "%%" que sirven como separadores para las tres secciones de una especificación lex, la primera, la de definiciones, sirve para definir cosas que se van a usar en el programa resultante o en la misma especificación: %{ #include int palabra=0, numero=0; %} Numero -?[0-9]+ Palabra [a-zA-Z]+ Podemos ver dos tipos de declaraciones, declaraciones de C y declaraciones de lex, las de C son aquellas encerradas entre dos líneas %{ y %} respectivamente que le indican a lex, cuando se incluye código que será copiado sin modificar al archivo generado en C (típicamente lex.yy.c). Docente: Ing. Mirko Manrique Ronceros ~ 66 ~ Universidad Nacional del Santa Curso: Teoría de Compiladores Las declaraciones de lex están formadas por un nombre o identificador y su respectiva expresión regular, su funcionamiento es análogo a aquel del "#define" del preprocesador de C, cada vez que aparecen es como si en ese lugar estuviera escrita la expresión regular equivalente, también se pueden usar estas para formar nuevas expresiones regulares. Docente: Ing. Mirko Manrique Ronceros ~ 67 ~