Final del 11/08/08 (Teoría de Lenguajes) 1. Demuestre la equivalencia entre gramáticas regulares a izquierda y a derecha 2. Demuestre el lema de Pumping para lenguajes independientes de contexto 3. Muchos puntos sobre parsers: Defina gramática de precedencia simple Algo del automata LR(0) Dé un ejemplo de una gramática que no sea de precedencia simple pero si LR(0): ver acá. LR(0) no Precedencia simple Vale que es LR(0) por no generar conflictos, y no es de precedencia simple porque se dan las relaciones b > b (segun la primer produccion) y b = b (segun la segunda). S -> aSb | bb No LR(1) Genera conflicto shift-reduce con las dos últimas producciones, y en ambas el lookahead es el mismo. S -> aSb S -> ab S -> abb No LR(k) Una gramática recursiva a izquierda cualquiera no es LR(k) para ningún k. S -> Ab | Bc A -> Aa | λ B -> Ba | λ Nota: Eso está mal. Es cierto para LL(k) pero los parsers LR si soportan recursión a izquierda y derecha. Además, si ese fuera el caso alcanzaría con la gramática S -> Ab A -> Aa | λ Pero su autómata LR(1) es bastante simple. Es más, creo que es LR(0). 1 LR(2) no LR(1) Si bien la siguiente gramática no es LR(1), notar que siempre es posible generar una gramática LR(1) a partir de una LR(k) que capture el mismo lenguaje. S A B C D E -> -> -> -> -> -> AB a CD | aE ab bb bba LR(0) Gramática LR(0) común y corriente. S -> X$ X -> (X) X -> () SLR no LR(0) Ejemplo de gramática SLR no LR(0), similar a la anterior. S -> X$ X -> (X) X -> λ LR(1) no SLR Similar a la anterior, pero esta además de matchear igual nro de paréntesis abiertos y cerrados, matchea un único paréntesis abierto. S -> X$ X -> Y | ( Y -> (Y)| λ LR(1) no LALR Mismo ejemplo que el apunte de definiciones. S' S A B -> -> -> -> S aAd | bBd | aBe | bAe c c Que genera el lenguaje {acd, ace, bcd, bce}. Esta gramática es LR(1). Si construímos el autómata LR(1), obtenemos el conjunto de items {[A→c.,d],[B→c.,e]} para el prefijo ac y {[A→c.,e],[B→c.,d]} para bc. Si los unimos: 2 A -> c., d/e B -> c., d/e Vemos que introdujimos un conflicto reduce/reduce, dado que tanto [A→c] como [B→c] pueden ser utilizadas para reducir para los caracteres d y e. Como se genera el automata del parser LR(0) 4. Sea L={ww} sobre {a,b}, y G la gramática con las siguientes producciones: ... Dé una DDS para que la gramática G acepte al lenguaje L Final del XX/12/09 (Teoría de Lenguajes) Mirá la verdad que nuestro caso fue bastane particular: Jacobo puso 4 ejercicios en el pizarron -para hacer escritos, aunque cuando son 2 o 3 los que rinden suele tomar oral- y dijo "tienen que hacer 3 bien para aprobar, y 10 min para elegir si se quedan o no"... nadie podía hacer ni dos. Había dos demostraciones muy sacadas de la galera que la verdad no recuerdo bien. Conclusión, antes de que nos fueramos todos sin rendir, acepto tomar Pumping LLC a cambio de uno de Maq de Turing. Entonces de lo que me acuerdo es: Pumping LLC: perfecto al pie de la letra. Maquina de Turing: demostración (recuerdo que era una de "de cuantas cintas se necesitan para probar xxx..."). Un típico ejericio que está en todos los finales de parsers LL1. No lo recuerdo ahora pero si hiciste tres finales de práctica seguro esta en al menos 2. Final del 17/09/10 (Teoría de Lenguajes) 1. (Escrito) Describir y demostrar la equivalencia entre AFND-L y AFND 2. (Escrito) Demostrar que existe un lenguaje LIC que no es LICD 3. (Escrito) Demostrar que todo LSC es recursivo 4. (Oral) Definir gramatica LR(k) (la definicion "nueva") Final del XX/11/10 (Teoría de Lenguajes) 1. Transformaciones entre AP por estado final a AP por pila. 3 2. Demostrar que existe un lenguaje que no es LICD y es LIC. 3. Preguntas sobre el APD y por qué si x pertenece a L entonces no puede pertenecer xa a L en un APD. 4. Pumping para LIC (completo + lema de altura). 5. Preguntas sobre maquina de turing y como se pasa a MTND. 6. Definiciones de Pivote, Prefijo, Item para LR(1) y LR(0). 7. Automata Minimo + el lema auxiliar. Final del XX/12/10 (Teoría de Lenguajes) Se dividió en tres partes: Escrito general: 1. automata reducido es minimo. Para eso habia q enunciar el lema ese de la cant de estados (el q se demuestra c la f inyectiva) y ese podias demostrarlo o no. 2. Demostrar el teorema que dice que los prefijos viables son un lenguaje regular (En lr). en realidad solo pedia demostrarlo p 1 lado. Escrito personal: 3. Pumping para LIC Oral: Cosas sobre maquinas de turing (el del probar que hay un algoritmo, que le haces el grafo) y unas cosas sobre el lenguaje Diagonal. Final del 01/03/11 (Teoría de Lenguajes) Fue oral, a uno le tomaron: 1. Indistinguibilidad de orden k: las propiedades y demostrar la de k+1 = k => k+n = k 2. Pumping para LIC y lema de altura 3. Definición de pivote, prefijo y prefijo viable para LR(0) 4 4. De qué tipo es el conjunto generado por todos los prefijos viables (es regular) 5. Cómo se construye el autómata que se usa en la demostración de los prefijos viables. A otro le tomaron, entre otras cosas: Definición de Máquina de Turing, y MTND => MTD Final del XX/05/11 (Teoría de Lenguajes) 1. Demostrar que efectivamente el autómata obtenido con el alg de minimización es el mínimo (demo del lema y la demo propiamente dicha) 2. Defina prefijo viable 3. Demostrar que el conjunto de prefijos viables es un leng regular (mostrar como se arma el autómata ND y listo) 4. La demo de que es item valido si pertenece a la función de transición para un caso. 5. Cómo son las producciones de los leng de tipo 0 y tipo 1 6. En los ALA (para reconocer leng de tipo 1) como se determina la distancia entre los símbolos que delimitan la cinta? (La respuesta es que se determina con la cadena de entrada) Otro examen oral: 1. El lema y el teorema relacionados con minimizacion 2. Automatas de pila a Gramaticas libres de contexto y viceversa. 3. Automata para ver los estados de un parser LR(0) 4. Pumping para Libres de contexto 5. Maquina de Turing 6. Clasificacion de Chomsky (toda) 7. Qué reconoce un lenguaje tipo 1. 5 Definiciones y teoremas (Teoría de Lenguajes) Gramaticas LL(1) Forma sentencial Cualquier cadena que puedo derivar a partir del símbolo distinguido. Primeros primeros: V* P(Vt) primeros(γ) = { t in Vt / γ -*-> α, α = t α'} Algoritmo para calcular primeros: Para cada x en V Si x esta en Vt primeros(x) := {x} Si x esta en Vn y x Y1Y2...Yk esta en P Si a pertenece a primeros(Yi) con Y1,...,Yi-1 anulables (forall i<=k) poner a en primeros(x) Siguientes siguientes: Vn --> P(Vt) siguientes(N) = { t in Vt / S -*-> ... Nt ... } Algoritmo para calcular siguientes: Agregar $ a siguientes(S) Repetir hasta que siguientes no cambie Si A -> α B β agregar primeros(β) a siguientes(B) Si A -> α B o A -> α B β con β anulable agregar los siguientes(A) a los siguientes(B) Símbolos directrices (SD) 6 Gramática LL(1) Sea G = <Vn, Vt, P, S>, es LL(1) si, siendo N no terminal y β y β' cadenas de V*, vale: Esto implica que la gramatica G debe ser no ambigua ni recursiva a izquierda. Equivale a pedir las siguientes condiciones dado cualquier : No puede pasar que tanto α como β deriven en strings que comiencen con un mismo terminal a. A lo sumo uno de los dos puede derivar en la cadena vacia. Si β deriva en la cadena vacia, entonces α no puede derivar en ninguna cadena que comience con un terminal en siguientes(A). Analizador Sintáctico Descendente de Pila (A. S. D. P.) con tabla Inicialización Agregamos la siguiente producción: S' S# Tenemos una lista w con la cadena a analizar, t es el símbolo apuntado en dicha lista. Sea M una matriz con tantas columnas como símbolos terminales y tantas filas como símbolos no terminales tal que: M(a, s) = s α, si a pertenece a SD(s α) Algoritmo Repetir Si tope in Vt Si tope === t extraigo tope de la pila avanzo 1 en w Si no error Si no Si M(t, tope) === (tope Y1Y2 ... Yk) extraigo tope de la pila apilo YkYk-1 ... Y1 Si no error Hasta que tope === # && t === # 7 Observación: No comerse el detalle de que el lado derecho de la producción Y1Y2 ... Yk se apila al revés de manera que el primer símbolo quede como tope. Oh! Mi gramática no es LL(1)... Y ahora qué hago?!?!? Tenés dos posibles maneras de zafarla. Una es factorizar: A α β1 | α β2 | ... | α βK | γ1 | ... | γN Pasa a ser: A α A' | γ1 | ... | γN A' β1 | β2 | ... | βK O, si tu gramática es recursiva a izquierda, podés "desrecursivizar": A A α1 | ... | A αN | β1 | ... | βK Pasa a ser: A β1 A' | ... | βK A' A' α1 A' | ... | αN A' | λ Gramáticas y parsers LR Los parsers LR(K) son (al igual que los parsers LL) una familia de parsers para analizar una clase grande de gramáticas libres de contexto. Se caracterizan por: Ser analizadores sintácticos ascendentes (analizan la sintaxis de abajo hacia arriba) Por leer la entrada de izquierda a derecha para encontrar y producir una derivación más a la derecha de la cadena de entrada (LR de Left to right Rightmost derivation). La K corresponde a la cantidad de símbolos de entrada de "lookahead" que se tienen en cuenta para tomar las decisiones de parseo (o sea, es como en los analizadores descentes predictivos, espía 0, 1 o más caracteres que vienen para decidir que producción usar). Se usan mucho porque son más potentes que los LL y porque son eficientes, pero tienen la desventaja de que es difícil programarlos a mano, por eso se suele usar generadores de parsers (que se encargan de armar las tablas usadas en los algoritmos). Hay distintos tipos de parsers LR que son más y menos tolerantes a problemas (LR(0), SLR, LR(1) y LALR). Se habla de gramáticas LR(K) como aquellas gramáticas cuyo lenguaje puede ser parseado por un parser LR(K) sin problemas. Entonces hay gramáticas que van a ser LR(1) pero no LR(0), o SLR pero no LR(0), etc. La forma básica del algoritmo LR es la misma para todos, lo que cambia en cada uno es la construcción de una tabla que se usa para tomar decisiones durante el mismo. 8 Forma general de los algoritmos LR Todos los algoritmos LR funcionan con un stack y una tabla de parseo, y las operaciones básicas que hacen hasta aceptar la cadena o generar un error son shift y reduce (desplazamiento y reducción). El algoritmo mantienene en el stack símbolos de la gramática, que tarde o temprano van a formar parte del lado derecho de una producción usada para generar la cadena de entrada. Lo que hace es ir consumiendo símbolos de entrada y agregándolos en el stack (a esto se le llama hacer shifts) hasta que decide que tiene suficiente información e "identifica" en el tope del stack uno o más símbolos que son la parte derecha de una producción. Entonces hace una reducción, que consiste en reemplazar todos los símbolos del tope del stack que coinciden con la parte derecha de la producción por la parte izquierda de la misma. Una vez que hace esto continua haciendo shift/reduce hasta que redujo toda la entrada al símbolo terminal, momento en el cual el parser "acepta" la entrada (y en el camino se enocontró la derivación de la cadena, que se puede reconstruir conociendo todos los reduce que se hicieron). Lo difícil del algoritmo es cómo saber cuando hacer un shift y cuando hacer un reduce. Lo primero que uno piensa es que como hacer "pattern matching" entre el stack y los lados derechos de todas las producciones es fácil, simplemente hago shift hasta tener en el stack algo que matchee y ahí hago una reducción. El problema es que esta actitud golosa de suponer que hay que usar la primer producción que "funcione" (habiendo analizado sólo un prefijo de la cadena de entrada) no siempre anda, ya que por hacer esa reducción podemos llegar a algo en el stack que no hay manera de seguir reduciendo al símbolo S. Cómo se hace entonces? Bueno, lo groso de los parsers LR es que tienen como invariante que en el stack mantienen lo que se conoce como "prefijos viables". Y hay una propiedad que dice que el conjunto de los prefijos viables de un lenguaje LR es un lenguaje regular, con lo cual hay un AFND que puede identificar estos prefijos viables. Entonces, los algoritmos LR lo que hacen es: 1. Construir el AFND de este lenguaje 2. A partir de este calculan una tabla que es lo que usan para decidir si hay que hacer un shift o un reduce. Al calcular esta tabla pueden surgir conflictos que indican que la gramática no es del tipo LR que se estaba tratando de usar. 3. Una vez con la tabla terminada y sin conflictos el algoritmo es muy fácil Los distintos algoritmos LR Construir el AFND ideal o necesario una una gramática cualquiera no es fácil y puede llegar a tener miles de estados, ya que cada estado del AFND representa un momento determinado en el parsing de una cadena de entrada cualquiera, y tiene que tener implícita la información de todo lo se leyó hasta el momento. Hay entonces formas de construir AFND más simples y que sirven en muchas situaciones (aunque no todas). 9 Los más simples son los parsers LR(0), que en el AFND no tienen lookahead. Los SLR usan el mismo AFND que los LR(0), pero cuando usan el AFND para armar la tabla que se usa en el algoritmo, se utiliza la función SIGUIENTES para filtrar algunas reducciones inválidas Los parsers LR(1) tienen un lookahead de un símbolo y construyen un AFND donde hay distintos estados que se diferencian por el símbolo de lookahead. Como consecuencia hay muchísimos más estados que un parser LR(0), y pasan a ser menos eficientes. Los parsers LALR son una solución de compromiso entre LR(1) y LR(0), ya que lo que hacen esencialmente es podar el AFND LR(1) agrupando un montón de estados que representan lo mismo. Consigue así achicar la cantidad de estados drásticamente, pero en contrapartida puede llegar a crear conflictos de tipo reduce-reduce (que antes no había) al agrupar estados. Vale que . La forma de calcular el AFND en los cuatro casos es parecida, y se basa en dos funciones: Clausura y Goto Estas funciones tienen su versión para LR(0) y otra para LR(1). Una vez definidas, cada parser LR tiene pequeñas diferencias en el algoritmo para armar la tabla de parseo. Resumiendo, para hacer el parser LR de una gramática necesitamos conocer: El algoritmo general de parseo LR basado en el stack y la tabla El algoritmo específico para construir la tabla y el AFND. A su vez, este algoritmo dependerá de: o La función clausura o La función Goto Los algoritmos en detalles Para los detalles de estas funciones en cada parser recomiendo altamente leer este ppt: Algoritmos LR0, SLR, LR1 y LALR Versión Online (Slideshare.net) Es didáctico, con ejemplos paso a paso y si bien parece largo por tener cientos de slides, la mayoría son por los ejemplos paso a paso. Recomiendo en serio leerlo si quieren ver como construir los AFND en detalle. Gramaticas de Atributos Una gramatica de atributos es una gramatica en la que se le asignan atributos (variables tipadas) a los no terminales de cada produccion y se opera a partir de los mismos, con asignaciones y funciones asociadas a cada produccion. Si bien los terminales no tienen atributos propiamente dichos, se puede considerar que tienen ciertos valores asignados por el lexer, y a veces manejarlos como atributos. Por ejemplo, la siguiente es una gramatica no 10 ambigua para operaciones de suma y producto de numeros, como ser 4*(1+2), que obtiene en el atributo val de la raiz el valor de la expresion. int L.val, E.val, T.val, F.val L -> E {L.val := E.val} E -> E1 + T {E.val := E1.val + T.val} E -> T {E.val := T.val} T -> T1 * F {T.val := T1.val * F.val} T -> F {T.val := F.val} F -> ( E ) {F.val := E.val} F -> digit {F.val := value(digit)} La funcion value() aplicada al terminal digit devuelve el valor del token (notar que al definir la gramatica, se asume que el lexer ya convirtio los terminales a tokens). Luego, a partir de dicho valor, al evaluar cada produccion se resuelven los atributos incluidos. Es conveniente siempre antes de dar las producciones de la gramatica, indicar cuales son los atributos de cada no terminal, su tipo y si son heredados o sintetizados, para verificar que no se hayan cometido errores. Es importante no confundir gramatica de atributos y definicion dirigida por sintaxis (o DDS). Si bien las dos son iguales en apariencia, la primera no admite funciones con efectos colaterales (como por ejemplo print) mientras que la segunda si. Atributos Sintetizados y Heredados Los atributos pueden ser sintetizados o heredados. Los atributos sintetizados son aquellos que se asignan en funcion de los atributos de los hijos del no terminal al que pertenecen, por ejemplo, val en la gramatica anterior. Un atributo sintetizado que aparezca en el lado izquierdo de una produccion siempre debe ser asignado. Un atributo heredado es aquel cuyo valor depende de los atributos de su padre o hermanos en una produccion. Siempre que un heredado aparece en la parte derecha de una produccion debe ser asignado. Por ejemplo, en producciones como las siguientes, los atributos z e y de Z y de Y son heredados. S -> X Y Z Y -> aZ {Y.y := X.x ; Z.z = X.x} {Z.z := Y.y} Un atributo de un no terminal puede ser solamente sintetizado o heredado. No puede darse que en algunas producciones se comporte como heredado y en otras como sintetizado. Los atributos son funcion siempre de otros atributos o constantes. Pueden usarse todo tipo de funciones: operaciones aritmeticas, operador ternario if-then-else (como el cond ? val1 : val2 de cpp), etc; siempre y cuando no tengan efectos colaterales. 11 A partir de esta clasificacion de atributos, se pueden clasificar las gramaticas. Una gramatica S-atribuida es una gramatica que solamente tiene atributos sintetizados; y una gramatica L-atribuida es una gramatica en la que todos sus atributos heredados dependen de atributos que se encuentran mas a la izquierda en la produccion. Esto permite que parseando de arriba hacia abajo y de izquierda a derecha puedan calcularse todos los atributos en una sola pasada. Notar que el que una gramatica sea S-atribuida implica que sea L-atribuida, pero no al reves. Arbol decorado Dada una gramatica de atributos y una cadena de entrada, el arbol de analisis sintactico es el arbol construido por el parser al analizar la cadena. Dicho arbol se dice decorado si ademas tiene los atributos de cada simbolo y sus valores. Orden de evaluacion Para determinar el orden de evaluacion de los atributos se puede construir un grafo de dependencias. El digrafo tiene en los nodos los atributos de cada simbolo y los arcos representan dependencias (es decir, si A.a := f(B.b, C.c), entonces A.a depende de B.b y C.c). Cabe destacar que el grafo se construye para una cadena de entrada en particular, es decir, no hay un grafo para una gramatica, sino un grafo para una cadena en una gramatica (pensar en el arbol decorado, donde se dejan solamente los atributos, y los arcos marcan dependencias). Una gramatica es circular si existe alguna cadena para la cual su grafo de dependencias tiene algun ciclo dirigido. Esto implica que no es posible calcular los valores de sus atributos. En caso de que el digrafo sea aciclico, entonces se puede determinar un orden de evaluacion para los atributos siguiendo los arcos. Si la gramatica es ademas L-atribuida, no es necesario analizar este grafo pues se sabe que un orden de evaluacion valido para cualquier cadena de entrada es de arriba hacia abajo y de izquierda a derecha. Condition Ademas de las asignaciones, en las gramaticas de atributos es posible incluir una clausula condition. Esta clausula recibe una expresion booleana que depende de los valores de los atributos de los simbolos de la produccion y permite rechazar una cadena. Por ejemplo, la siguiente produccion rechaza la cadena si los atributos size de X e Y no coinciden. S -> X Y { Condition: X.size = Y.size } 12 Traduccion Dirigida por Sintaxis Denominada TDS o esquema de traduccion, es una gramatica en la que se asignan atributos (variables tipadas) a los simbolos de cada produccion, y se agregan bloques de codigo a las partes derechas de las producciones, los cuales pueden estar intercalados entre los simbolos, referenciar a los atributos y tener efectos colaterales. La diferencia entre una TDS y una gramatica de atributos es, entonces, que la primera tiene bloques de codigo ubicados en las partes derechas de las producciones, mientras que la segunda tiene exclusivamente clausulas de asignacion (y condicion) asociadas a cada produccion. La diferencia entre una TDS y DDS es que la TDS permite codigo intercalado en las producciones, y la DDS en rigor solamente permite asignaciones y llamadas a funciones (que pueden generar efectos colaterales) asociadas a una produccion. El siguiente ejemplo toma declaraciones de tipos de la forma tipo var, var, var... y los guarda en una tabla de simbolos tras parsearlos. D T T L L -> -> -> -> -> T {L.tipo := T.tipo} L int {T.tipo := "int"} bool {T.tipo := "bool"} var {add(var.nombre, L.tipo); L2.tipo = L1.tipo} L2 var {add(var.nombre, L.tipo} No hay restriccion para el codigo a utilizar dentro de los bloques. Puede usarse el pseudocodigo que se desee a conveniencia, cuanto mas sencillo mejor. Orden de Ejecucion El arbol de analisis sintactico se genera de arriba hacia abajo y de izquierda a derecha (al igual que como se evaluaban las gramaticas L-atribuidas). A medida que se recorren las producciones, se ejecuta el codigo contienen en el orden correspondiente. Esto impone ciertas restricciones en el uso de los atributos en los bloques de codigo, pues los mismos deben estar inicializados siempre antes de ser usados. Atributos Sintetizados y Heredados La diferenciacion entre atributos sintetizados y heredados se mantiene en las TDS y debe respetarse el equivalente a las reglas de asignacion que se manejaban en las gramaticas de atributos. Un atributo heredado de un simbolo S en la parte derecha de una produccion debe ser asignado en un bloque de codigo antes del simbolo S. 13 Un bloque de codigo no puede referirse a un atributo sintetizado de un simbolo que se encuentre a la derecha del codigo, pues el simbolo aun no fue parseado y sus atributos sintetizados no fueron inicializados. Un atributo sintetizado que aparezca en la parte izquierda de una produccion solo puede asignarse cuando todos los atributos de los que depende han sido asignados Rechazar una cadena Puesto que ya no se manejan clausulas de asignacion y de condicion (como en la gramatica de atributos), para rechazar una cadena basta con llamar a la funcion "error", burbujear un valor "error" dentro de algun atributo o lanzar una excepcion. 14 15 16 17 18