Está en la página 1de 19

Diseño de Compiladores I

YACC: Yet Another Compiler-Compiler

Introducción:

Yacc provee una herramienta general para analizar estructuralmente una entrada. El
usuario de Yacc prepara una especificación que incluye:
• un conjunto de reglas que describen los elementos de la entrada
• un código a ser invocado cuando una regla es reconocida
• una o más rutinas para examinar la entrada
Luego Yacc convierte la especificación en una función en C que examina la entrada. Esta
función, un parser, trabaja mediante la invocación de un analizador léxico que extrae tokens de la
entrada. Los tokens son comparados con las reglas de construcción de la entrada, llamadas
reglas gramaticales. Cuando una de las reglas es reconocida, el código provisto por el usuario
para esa regla (una acción) es invocado. Las acciones son fragmentos de código C, que pueden
retornar valores y usar los valores retornados por otras acciones.
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.
Lex genera el código C para un analizador léxico, y Yacc genera el código para un parser.
Tanto Lex como Yacc toman como entrada un archivo de especificaciones que es típicamente
más corto que un programa hecho a medida y más fácil de leer y entender. Por convención, la
extensión del archivo de las especificaciones para Lex es .l y para Yacc es .y. La salida de Lex
y Yacc es código fuente C. Lex crea una rutina llamada yylex en un archivo llamado lexyy.c.
Yacc crea una rutina llamada yyparse en un archivo llamado y_tab.c.
Estas rutinas son combinadas con código fuente C provisto por el usuario, que se ubica
típicamente en un archivo separado pero puede ser ubicado en el archivo de especificaciones de
Yacc. El código provisto por el usuario consiste de una rutina main que llama a yyparse, que en
su momento, llama a yylex. Todas estas rutinas deben ser compiladas, y en la mayoría de los
casos, las librerías Lex y Yacc deben ser cargadas en tiempo de compilación. Estas librerías
contienen un número de rutinas de soporte que son requeridas, si no son provistas por el usuario.
El siguiente diagrama permite observar los pasos en el desarrollo de un compilador usando Lex y
Yacc:

nombre_archivo.l Especific. Especific. nombre_archivo.y


Lex Yacc

Lex Yacc

lexyy.c
yylex() yyparse() y_tab.c Rutinas C

Compilador C
Librerías
COMPILADOR

1
Los elementos de una gramática

La sintaxis de un programa puede ser definida por una gramática libre de contexto. Ésta es
esencialmente una estructura jerárquica que indica las relaciones de las construcciones del
lenguaje. La notación más común usada para describir una gramática libre de contexto es BNF.
La especificación de YACC se hace en BNF.
La descripción se hace en la forma de reglas de producción, que consisten de un nombre
de no terminal, el lado izquierdo, seguido por su definición. La definición, o lado derecho, consiste
de cero a más símbolos que son no terminales que apuntan a otras reglas o terminales que
corresponden a tokens. Por ejemplo, una gramática simple:
lista ← objeto | lista objeto
objeto ← cadena | numero
cadena ← texto | comentario | comando
numero ← numero | ´+´ numero | ´-´ numero | numero ´.´ numero

En este ejemplo, las palabras en negrita representan los terminales y el resto los no
terminales. La primera regla establece que una lista se forma de un objeto o de una lista y un
objeto.
La segunda forma de la regla es una definición recursiva. Esto permite que un concepto
complejo, como una lista, sea descripto en una forma compacta. Puesto que no sabemos de
antemano cuántos elementos formarán la lista, no podríamos definirla convenientemente sin esta
forma recursiva. Así, se establece que una lista es una secuencia de objetos, uno después de
otro. Si quisiéramos describir una lista separada por comas, la regla sería:
lista ← objeto | lista ´,´ objeto

| es un operador de unión. Notar por ejemplo, su uso en la última regla. Así, un número es o
un numero, o un numero con un mas (+) adelante, un numero con un menos (-) adelante, o
dos números separados por un punto decimal (.). Así, se pueden listar muchas elecciones
posibles en una forma compacta.
La construcción de una gramática es un proceso botton-up, incluyendo cada grupo en
grupos más grandes hasta llegar a un solo grupo de más alto nivel que incluye a todos los otros
grupos. Esta construcción de más alto nivel se llama el símbolo start. En el ejemplo, este símbolo
es ¨lista¨. Cuando este símbolo es reconocido y no hay más entrada, el parser sabe que ha visto
un programa completo. El parser creado por Yacc devuelve un 0 si toda la entrada es válida, y
un 1 si se ha encontrado un error de sintaxis.
Se puede hacer un parser que haga algo más que reconocer la sintaxis correcta de un
programa. Se puede hacer que reconozca secuencias erróneas, y emita mensajes de error
razonables.

Interacción entre las rutinas Léxica y de Parsing

Una rutina main invoca a yyparse para evaluar si la entrada es válida o no. yyparse invoca
a una rutina llamada yylex cada vez que necesita un token. (yylex puede ser generado
manualmente o generado por Lex). Esta rutina léxica lee la entrada, y por cada token que
reconoce, retorna el número de token al parser. El léxico puede también pasar el valor del token
usando la variable externa yylval. Las rutinas de acción del parser, así como la rutinas provistas
por el usuario pueden usar ese valor. Las rutinas de acción pueden llamar a otras funciones que
pueden estar ubicadas en la sección de código del usuario o en otros archivos de código fuente.

2
main()

retorna 0 si la
entrada es válida,
1 si no lo es
pedir próximo token
yyparse()

las acciones retorna el número de


procesan el valor token o 0 si es EOF Lee caracteres
de la entrada
yylex() entrada
yylval
pasa el valor del
token

Veamos cómo la rutina léxica y el parser trabajan juntos analizando un pequeño fragmento
de código C.
if (i)
return (i);

El analizador léxico convierte esta entrada de bytes en tokens reconociendo patrones


particulares. Por ejemplo, cuando el analizador léxico ve ¨return (i)¨, podría reconocer los
siguientes elementos:
return token RETURN
( literal ´(´
i token ID
) literal ´)´
; literal ´;´

Si el analizador léxico ve “i” retornará el token ID al parser, y podrá pasarle también el


valor real del token (en este caso “i”). El parser podría pasar el nombre de la variable a una
rutina como acción, que buscaría en la tabla de símbolos para determinar si el identificador era
válido.
La especificación Yacc podría tener la siguiente regla para definir una sentencia válida:
sent: RETURN expr ´;´
;

El token RETURN es un símbolo terminal identificado por el analizador léxico. expr es un


no terminal definido por una regla de la gramática. Una regla para expr sería:
expr: ID
| ´(´ expr ´)´
;

La barra vertical indica que hay definiciones alternativas para la misma regla. Esta regla
establece que una expr puede ser un ID o una expr entre paréntesis.
Cualquier regla de la gramática puede tener una acción asociada para evaluar los tokens
que fueron reconocidos. Una acción es una o más sentencias C que pueden ejecutar una

3
variedad de tareas, incluyendo producir salidas o alterar variables externas. Frecuentemente, la
acción actúa sobre el valor de los tokens de la construcción.
El analizador léxico asigna el valor de cada token a la variable externa yylval. Yacc provee
una notación posicional, $n, para acceder al valor del enésimo token en una expresión:
expr: NUM ´+´ NUM { printf (¨%d¨, $1 + $3); }

En este caso, la acción imprime la suma del valor del primero y tercer token. El valor
retornado por una acción puede ser asignado a la variable ¨$$¨. Veamos el siguiente ejemplo:
expr: ID { $$ = $1; }
| ´(´ expr ´)´ { $$ = $2; }
;

La primera acción es invocada cuando se reconoce ¨ID¨, y retorna el valor del token ID
como el valor de la regla expr. Realmente, esta el la acción por defecto, y puede ser omitida
opcionalmente. La segunda acción selecciona el valor de expr que es el segundo elemento en la
construcción.

Tokens

La función básica de la rutina léxica es detectar un token y retornar un número de token a la


rutina del parser que llamó a aquélla. El número de token es definido por un símbolo que el
parser usa para identificar un token. Además, la rutina léxica puede pasar el valor real del token
mismo.
Las acciones de la especificación Lex consiste de sentencias C que retornan el número de
token y su valor. Los números de token son definidos por Yacc cuando éste procesa los tokens
declarados en la especificación Yacc. La sentencia #define es usada para definir los números de
tokens:
#define NUMERO 257
Estas definiciones son ubicadas en el archivo y_tab.c, junto con la rutina yyparse. (Cada
carácter ASCII es definido como un token cuyo número es su valor ASCII (0 a 256); así, los
tokens definidos por el usuario comienzan en 257). El parser y el léxico deben usar el mismo
conjunto de símbolos para identificar tokens; por lo tanto el léxico debe tener acceso a los
símbolos definidos por el parser. Una forma de hacer esto es decir a Yacc, mediante la opción
–d) que cree el archivo de encabezamiento y_tab.h que puede ser incluido en la especificación
Lex. Por supuesto, se podría definir explícitamente estos símbolos en la especificación Lex,
asegurándose que se correspondan con los números de tokens asignados en Yacc.
Para retornar el número de token en una acción, se debe usar la sentencia return. Por
ejemplo, la siguiente regla que aparea cualquier número y la acción retorna el token NUMERO:
[0-9] + { return NUMERO; }
Para pasar el valor del token al parser, Lex crea una variable externa llamada yytext que
contiene la cadena de caracteres que son reconocidas por la expresión regular. Una variable
externa llamada yylval es seteada por Yacc para pasar el valor del token desde el analizador
léxico al parser. El tipo de yylval es int, por defecto. Por lo tanto, para asignar el valor de yytext
a yylval, se debe convertir el valor desde una string a un int. Se puede cambiar el tipo de yylval
o, como se verá, definir una unión de tipos de datos múltiples para yylval.

4
Por ejemplo, se puede usar la función atoi para convertir un número almacenado como una
cadena en yytext a un int y asignarlo a yylval:
[0-9] +
{ yylval = atoi (yytext);
return NUMERO;
}

Autómatas Pushdown

Los autómatas finitos son suficientes para Lex. Sin embargo, cuando se intenta modelar los
lenguajes que Yacc maneja, no son suficientes, porque no tienen el concepto de estado
anterior. Los autómatas son incapaces de usar el conocimiento de cuál fue el token previo.
Esto hará imposible el reconocimiento de algunos lenguajes.
Un autómata de pila es similar al autómata finito. Tiene un número finito de estados, una
función de transición, y una entrada. La diferencia es que este autómata está equipado con una
pila. Y este es el tipo de autómata que genera Yacc. La función de transición trabaja sobre el
estado actual, el elemento en el tope de la pila, y el token de entrada actual, produciendo un
nuevo estado.
Esta capacidad hace al autómata de pila más útil. Por ejemplo, recordar el estado anterior
permite a Yacc reconocer gramáticas conocidas como la clase de lenguajes LALR (LookAhead
Left Recursive, que pueden ver anticipadamente un token, y son muy similares a una clase de
lenguajes llamados gramáticas libres de contexto). Los lenguajes LALR son esencialmente una
clase de lenguajes que dependen del contexto sobre no más que un solo token.
El parser generado por Yacc también es capaz de leer y recordar el próximo token en la
entrada (token anticipado). El estado actual es siempre el del tope de la pila. Inicialmente, la
máquina de estados está en el estado 0 (la pila contiene sólo el estado 0) y no se ha leído ningún
token anticipado.
El autómata tiene sólo cuatro acciones disponibles: shift, reduce, accept, y error. Un paso
en el parser se hace de la siguiente manera:
De acuerdo con el estado actual, el parser decide si necesita un token anticipado para
decidir la próxima acción. Si necesita un token y no lo tiene, llama a yylex para obtener el
próximo token.
De acuerdo con el estado actual y el token anticipado, si es necesario, el parser decide su
próxima acción y la lleva a cabo. Esto puede provocar que se apilen y desapilen estados en la
pila, y que el token anticipado sea procesado o no.
Cuando se lleva a cabo la acción shift, hay siempre un token anticipado. Por ejemplo, en el
estado 56, puede haber una acción:
IF shift 34
que dice que si el token anticipado es IF, el estado 34 se convertirá en el estado actual (en el
tope de la pila), cubriendo al estado actual (56). El token anticipado se borra.
La acción reduce evita que la pila crezca sin límites. Las acciones reduce son apropiadas
cuando el parser ha visto el lado derecho de un regla de la gramática y está preparado para
anunciar que ha visto una instancia de la regla reemplazando el lado derecho con el izquierdo.
Puede ser necesario consultar el token anticipado para decidir si reducir o no. Usualmente, esto
no es necesario. En efecto, la acción por defecto (representada mediante un punto) es a menudo
una acción reduce.
Las acciones reduce son asociadas con reglas individuales de la gramática.

5
Debido a que un mismo número puede ser usado para identificar a una regla y también a un
estado, puede producirse alguna confusión. La acción:
. reduce 18
se refiere a la regla 18 de la gramática, mientras que la acción:
IF shift 34
se refiere al estado 34.
Supongamos que la regla:
A : x y z ;
está siendo reducida. La acción reduce depende del símbolo a la izquierda (A, en este caso)
y el número de símbolos en el lado derecho (tres, en este caso). Al reducir, las tres estados en el
tope de la pila se desapilan. (En general, el número de estados desapilados iguala al número de
símbolos en el lado derecho de la regla). En efecto, estos estados eran los que se apilaron al
reconocer x, y y z, y ya no servirán para ningún propósito. Al desapilar estos estados, queda
descubierto el estado en que el parser estaba al comenzar a procesar la regla. Usando esta
estado y el símbolo del lado izquierdo de la regla, se ejecuta un shift de un nuevo estado a pila, y
el parsing continúa. Sin embargo, hay diferencias entre un shift normal y el procesamiento del
símbolo del lado izquierdo, así que a esta acción se la llama goto. En particular, el token
anticipado es borrado por un shift, pero no es afectado por un goto. Así que el estado
descubierto al hacer el reduce contendrá una acción como la siguiente:
A goto 20
que produce que el estado 20 se apile y se convierta en el estado actual.
En efecto, la acción reduce vuelve atrás el reloj del parser, desapilando estados hasta
descubrir el estado en el que el lado derecho de la regla fue visto por primera vez. El parser se
comporta, entonces, como si hubiera visto el lado izquierdo en ese momento.
Las otras dos acciones del parser son conceptualmente mucho más simples. La acción
accept indica que el parser ha visto la entrada completa y que ésta cumple con la
especificación. Esta acción aparece sólo cuando el token anticipado es la marca de fin de
archivo, e indica que parser ha completado su trabajo. La acción error representa un lugar
donde el parser no puede continuar el proceso de acuerdo con la especificación. Los tokens que
ha visto, junto con el anticipado, no pueden ser seguidos por nada que constituya una entrada
legal. El parser reporta el error e intenta recuperar la situación y continuar con el parsing.
Sea la siguiente especificación Yacc:
%token A B C
%%
lista : inicio fin
;
inicio : A B
;
fin : C
;
Cuando Yacc es ejecutado con la opción -v, produce un archivo llamado y.out que contiene
una descripción legible del parser. El archivo y.out correspondiente a la gramática anterior (con
algunas estadísticas al final) es el siguiente:

6
state 0
$accept : _lista $end

A shift 3
. error

lista goto 1
inicio goto 2

state 1
$accept : lista_$end

$end accept
. error

state 2
lista : inicio_fin

C shift 5
. error

fin goto 4

state 3
inicio : A_B

B shift 6
. error

state 4
lista : inicio fin_ (1)

. reduce 1

state 5
fin : C_ (3)

. reduce 3

state 6
inicio : A B_ (2)

. reduce 2

Para cada estado se especifican las acciones y las reglas procesadas. El carácter _ se usa
para indicar que parte de la regla ya ha sido vista y qué tiene que venir.
Vamos a analizar el comportamiento del parser ante la siguiente entrada:
A B C
Inicialmente, el estado actual es el estado 0. El parser necesita referirse a la entrada para
decidir entre las acciones disponibles en ese estado. El primer token, A, es leído y se convierte
en el token anticipado. La acción en el estado 0 para A es shift 3; se apila el estado 3, y se borra
el token anticipado. El estado 3 se convierte en el estado actual. Se lee el próximo token, B, que

7
se convierte en token anticipado. La acción en el estado 3 para el token B es shift 6; se apila el
estado 6, y se borra el token anticipado. La pila ahora contiene los estados 0, 3, y 6. En el estado
6, sin consultar el token anticipado, el parser reduce por:
inicio : A B
que es la regla 2. Dos estados, el 6 y el 3, son desapilados, dejando descubierto el estado 0.
La acción en ese estado para inicio es un goto:
inicio goto 2
Se apila el estado 2 que se convierte en el estado actual. En el estado 2, el próximo token, C,
debe ser leído. La acción es shift 5, así que el estado 5 es apilado, así que ahora la pila tiene los
estados 0, 2, y 5, y se borra el token anticipado. En el estado 5, la única acción es reducir por la
regla 3. Esta tiene un símbolo en el lado derecho, así que un estado, el 5, es desapilado, y el
estado 2 queda descubierto. La acción para fin (el lado izquierdo de la regla 3) en el estado 2 es
un goto al estado 4. Ahora, la pila contiene los estados 0, 2, y 4. En el estado 4, la única acción
es reducir por la regla 1. Hay dos símbolos a la derecha, así que los dos estados de arriba son
desapilados, descubriendo de nuevo el estado 0. En el estado 0, hay un goto para lista; causando
que el parser entre al estado 1. En este estado, se lee la entrada, y se obtiene la marca de fin de
archivo, indicada por $end en el archivo y.out. La acción en el estado 1 (cuando se encuentra la
marca de fin) finaliza exitosamente el parsing.

Usando Yacc

Hay cuatro pasos para crear un parser:


1. Escribir una especificación Yacc que describe la gramática. Este archivo usa la
extensión .y por convención.
2. Escribir un analizador léxico que puede producir una corriente de tokens. Esta rutina
puede ser generada por Lex o codificada a mano en C. El nombre de la rutina léxica es
yylex.
3. Ejecutar Yacc sobre la especificación para generar el código fuente del parser. El
archivo de salida es nombrado y_tab.c por Yacc.
4. Compilar y vincular los archivos fuentes del parser y del analizador léxico y cualquier
archivo de programa relacionado.
El archivo de salida y_tab.c contiene una rutina de parsing llamada yyparse que llama a la
rutina léxica yylex cada vez que necesita un token. Como Lex, Yacc no genera un programa
completo; yyparse debe ser llamado desde una rutina main. Un programa completo también
requiere una rutina de errores llamada yyerror que es llamada cuando yyparse encuentra un
error. Tanto la rutina main como yyerror pueden ser provistas por el programador, aunque se
proveen versiones por defecto de esas rutinas en las librerías de Yacc, y estas librerías pueden
ser vinculadas al parser usando la opción -ly durante la compilación.

Escribiendo una Especificación Yacc

Una especificación Yacc describe una gramática libre de contexto que puede ser usada
para generar un parser. Esta gramática tiene cuatro clases de elementos:
1. Tokens, que son un conjunto de símbolos terminales
2. Elementos sintácticos, que son un conjunto de símbolos no terminales
3. Reglas de producción que definen un símbolo no terminal (el lado izq.) en términos de
una secuencia de no terminales y terminales (lado derecho)
8
4. Una regla start que reduce todos los elementos de la gramática a una sola regla.
El corazón de una especificación Yacc es un conjunto de reglas de producción de la
siguiente forma:
símbolo: definición
{acción}
;
Un (:) separa el lado izq. del derecho de la regla, y un (;) termina la regla. Por convención,
la definición sigue dos tabs después del (:). También por legibilidad, el (;) se ubica solo en una
línea.
Cada regla de la gramática lleva el nombre de un símbolo, un no terminal. La definición
consiste de cero o más nombres de terminales, tales como tokens o caracteres literales, y otros
símbolos no terminales. Los tokens, que son símbolos terminales reconocidos por el analizador
léxico, son permitidos sólo en el lado derecho. Cada definición puede tener una acción escrita en
C asociada con ella. Esta acción es ubicada entre llaves.
Las reglas que comparten el mismo lado izquierdo pueden ser combinadas usando una barra
vertical (|). Esto permite definiciones alternativas dentro de una regla.
El nombre de un símbolo puede ser de cualquier longitud, consistiendo en letras, punto, guión
bajo, y dígitos (en cualquier lugar excepto en la primera posición). Se distingue entre mayúsculas
y minúsculas. Los nombres de símbolos no terminales van en minúsculas y los tokens en
mayúsculas por convención.
Si la entrada no responde a la gramática, entonces el parser imprimirá el mensaje ¨syntax
error¨. Este mensaje emitido por la rutina yyerror, que puede ser redefinida por el programador
para proveer más información.
La mínima especificación Yacc consiste en una sección de reglas precedidas por una
declaración de los tokens usados en la gramática.
El formato completo tiene los siguientes elementos:
declaraciones
%%
reglas gramaticales
%%
código C

Sección de declaraciones:

La sección de declaraciones contiene información que afecta la operación de Yacc. Esta


sección usa varias palabras claves para definir tokens y sus características. Cada una de estas
palabras claves es seguida por una lista de tokens o caracteres literales entre apóstrofes.
% union Declara múltiples tipos de datos para valores semánticos (tokens)
Ejemplo:
% union {
int entero;
char *cadena;
}
% token Declara los nombres de los tokens. Si se usa union la sintaxis es:
% token <elem. de la union que corresponde a este grupo de tokens > lista de
tokens
9
% left Define operadores asociativos a izquierda
% right Define operadores asociativos a derecha
El orden en que se pongan estas declaraciones indica la precedencia (mayor
precedencia a medida que bajamos) Si se utiliza esta declaración, decla rar los
operadores como tokens sería redundante
% nonassoc Define operadores no asociativos
% type Declara el tipo de no terminales, cuando se uso union, y en las
acciones asociadas a las reglas, se hacen asignaciones a $$
% start Declara el símbolo de start. El defecto es la primera regla
% prec Asigna precedencia a una regla
La sección de declaraciones también puede contener código C para declarar variables o
tipos así como macros definidas. Puede también contener sentencias #include para incluir
archivos de encabezamiento. Esto se hace del modo siguiente:
%{
declaraciones C
%}
Cualquier cosa entre %{ y %} es copiada directamente al archivo generado por Yacc.

Sección de reglas:

La sección de reglas contiene las reglas de producción que describen la gramática. En


general una regla consiste de uno o más conjuntos de tokens y no terminales con una acción
opcional para cada conjunto de tokens. En estas reglas, un carácter literal se encierra entre
apóstrofes. La \ tiene un significado especial, como en C, para secuencias escape:
´\n´ newline
´\r´ return
´\´´ comilla simple
´\\´ barra invertida
´\t´ tab
´\b´ backspace
´\f´ form feed
´\xxx´ carácter cuyo valor es xxx

Token error

Se puede usar en las reglas un símbolo llamado error. No existe una regla que lo defina, ni
se incluye en la declaración de tokens. Es un token definido especialmente por Yacc que
significa que cualquier token que no aparee ninguna de las otras reglas, apareará la que contiene
error.
Conviene utilizarlo con otro token, que sirve de carácter de sincronización. A partir del
token erróneo, Yacc tirará todos hasta encontrar ese carácter (por ej. un newline). Esto permite,
de algún modo, recuperar errores. Se puede asociar una acción que permita informar que el
token es erróneo, y toda la información que se desee agregar.

10
Acciones

El parser generado por Yacc guarda los valores de cada token en una variable de trabajo
(yyval del mismo tipo que yylval). Estas variables de trabajo están disponibles para ser usadas
dentro de las acciones de las reglas. En el código real, son reemplazadas con las referencias
Yacc correctas. Las variables son rotuladas $1, $2, $3, etc. La pseudo-variable $$ es el valor a
ser retornado por esa invocación de la regla.
Se pueden ejecutar acciones después de cualquier elemento de un conjunto de tokens (no
sólo al final)

Sección de código:

La sección de código C es opcional, pero puede contener cualquier código C provisto por el
usuario. Allí se pueden especificar la rutina de análisis léxico yylex, una rutina main, o subrutinas
usadas por acciones de la sección de reglas.
Tres rutinas son requeridas: main, yylex, y yyerror, aunque estas también pueden ser
vinculadas externamente.
Se pueden usar comentarios como en C (/* ... */). Blancos, tabs, y newlines se ignoran.
Veamos una simple gramática con un solo token: ENTERO. La función de esta
especificación es generar un programa que imprime cualquier número que reciba como entrada:

% token ENTERO
%%
lineas: /* vacía */
| lineas linea
{ printf (¨%d\n¨, $2); }
;
linea: ENTERO ´\n´
{ $$ = $1; }
;
%%
#include ¨lex.yy.c¨

En la sección de declaraciones, se declara el token ENTERO. Este se traducirá en una


sentencia #define que asocia una constante numérica con este símbolo. Este símbolo es usado
para comunicación entre el analizador léxico y el parser.
En la sección de reglas, se especifica una gramática hecha de dos grupos: líneas y línea.
La primera regla define lineas como cero o más líneas de entrada. La primera de las dos
definiciones alternativas es vacía. Esta es una definición convencional que significa que la
cadena vacía es permitida como entrada. (Eso no significa que las líneas en blanco sean válidas).
La segunda definición alternativa es recursiva, estableciendo que la entrada consiste de una o
más líneas. El símbolo no terminal linea está definido en la segunda regla. Esta consiste de un
token ENTERO seguido por un newline.
Ahora consideremos los acciones asociadas con estas reglas. Yacc provee pseudo
variables adicionales que hace más fácil obtener el valor de un símbolo en una acción o setear
el valor del símbolo retornado por la acción. El signo $ tiene un significado especial para Yacc.
El valor de cada elemento de una definición puede ser recuperado usando notación posicional: $1
para el primer token, $2 para el segundo, etc. El valor retornado por la acción es seteado
asignando ese valor a $$. Miremos la acción asociada con la regla linea. Esta acción retorna el
valor del token ENTERO. Hay dos elementos en la definición, pero el newline no es retornado.
11
El valor del token ENTERO es pasado a la acción asociada con la regla lineas . En la acción de
ésta, $2 se refiere al valor de linea.
La tercera parte del ejemplo contiene una sentencia #include que incluye el código fuente
del analizador léxico.

Una especificación para una simple Máquina de Sumar

Se trata de una máquina que lleva una cuenta total y permite sumar o restar de ese total.
También se puede resetear el total a cero o a cualquier otro valor. La entrada consiste en un
número opcionalmente precedido por un +, un -, o un =. Por ejemplo, si la primera entrada es 4 o
+4, se imprime =4. Si la próxima entrada es -3, se imprime =1. Si la entrada es = o =0, el total se
resetea a 0, y se imprime =0.
Especificación para Yacc:

%{
int sum_total = 0;
%}

%token ENTERO

%%

lineas: /* vacía */
| lineas linea
;

linea: ´\n´
| expr´\n´
{ printf(¨= %d\n¨, sum_total); }
;
expr: ENTERO {sum_total += $1; }
| ´+´ ENTERO {sum_total += $2; }
| ´-´ ENTERO {sum_total -= $2; }
| ´=´ ENTERO {sum_total = $2; }
| ´=´ {sum_total = 0; }
;

%%

#include ¨lexyy.c¨

La acción principal en esta especificación es setear la variable sum_total de acuerdo con la


entrada y luego imprimir el nuevo valor. La sección de declaraciones contiene la declaración e
inicialización de sum_total. Se crea esta variable para llevar el total. Luego, se declara un único
token, ENTERO.
La primera regla es la misma que la del ejemplo anterior. Esta vez, sin embargo, no hay
acción asociada. Permite leer una serie de líneas, no sólo una.
La regla para linea tiene definiciones alternativas. Un newline o una expr seguida por un
newline son aceptadas. Así, se pueden ingresar líneas en blanco al programa sin causar un error
de sintaxis. Un newline o una expr seguida por un newline ejecuta la acción de imprimir el total
actual.
12
La regla para expr también tiene definiciones alternativas. Un token ENTERO, un +, un -, o
un = seguido por un token ENTERO, y un = solo son aceptados. Cada acción asociada con una
definición asigna un nuevo valor a sum_total. Notar que no asignamos el nuevo valor a ¨$$¨,
porque necesitamos acumular este valor desde una entrada a la próxima.

Ambigüedades y Conflictos en gramáticas Yacc

Un conjunto de reglas gramaticales es ambiguo si hay alguna cadena de entrada que puede
ser estructurada en dos o más formas diferentes. Por ejemplo, la regla:

expr : expr ´-´ expr

es una forma natural de expresar que una forma de construir una expresión aritmética es juntar
otras dos expresiones con un signo menos. Desafortunadamente, esta regla no especifica
completamente como se deberían estructurar las entradas complejas. Por eje mplo, si la entrada
es:

expr - expr - expr

la regla permite que esta entrada sea estructurada como:

( expr - expr ) - expr

o como

expr - ( expr - expr )

(La primera es llamada asociatividad a izquierda; la segunda, asociatividad a derecha)

El programa Yacc detecta tales ambigüedades cuando intenta construir el parser. Dada la
entrada:

expr - expr - expr

el parser enfrenta el siguiente problema. Cuando el parser ha leído la segunda expresión expr, la
entrada visible

expr - expr

aparea el lado derecho de la regla de arriba. El parser podría reducir la entrada aplicando esta
regla. Después de aplicarla, la entrada es reducida a expr (el lado izquierdo de la regla). El
parser lee entonces la parte final de la entrada:

- expr

y reduce nuevamente. El efecto de esto es tomar la interpretación correspondiente a la


asociatividad a izquierda.

Alternativamente, si el parser ve:

expr - expr

podría diferir la aplicación inmediata de la regla, y continuar leyendo la entrada hasta que ve:

13
expr - expr - expr

El parser podría entonces aplicar la regla a los tres símbolos de más a la derecha,
reduciendo entonces a expr, dejando:

expr - expr

Ahora la regla puede ser reducida una vez más. El efecto es tomar la interpretación
correspondiente a la asociatividad a derecha. Así, habiendo leído:

expr - expr

el parser puede hacer una de dos acciones legales, un shift o una reducción. No tiene forma de
decidir entre ambas. Esto es un conflicto shift-reduce. El parser puede también tener que elegir
entre dos reducciones legales. Este es un conflicto reduce-reduce. Notar que nunca hay
conflictos shift-shift.

Cuando hay conflictos shift-reduce o reduce-reduce, Yacc de todos modos produce un


parser. Lo hace seleccionando una de las acciones legales cuando tiene que elegir. Para ello, el
programa Yacc provee dos reglas de desambiguación:

1. En un conflicto shift-reduce, la acción por defecto es el shift

2. En un conflicto reduce-reduce, el defecto es reducir por la primera regla (en la


especificación Yacc)

La regla 1 implica que las reducciones son diferidas en favor de los shifts cuando es
necesario elegir entre ambas acciones. La regla 2 le da al usuario el control sobre el
comportamiento del parser en esta situación, aunque los conflictos reduce-reduce deberían ser
evitados en lo posible.
El uso de acciones dentro de las reglas puede también causar conflictos si la acción debe
hacerse antes que el parser pueda estar seguro de cual regla está reconociendo. En estos casos,
la aplicación de las reglas de desambiguación es inapropiada, y llevaría a un parser incorrecto.
Por esta razón, Yacc siempre reporta el número de conflictos shift-reduce y reduce-reduce
resueltos por la Regla 1 y por la Regla 2.
En general, si es posible aplicar las reglas de desambiguación para producir un parser
correcto, también es posible reescribir las reglas de la gramática de modo que las mismas
entradas puedan ser leídas sin conflictos. Por esta razón, la mayoría de los generadores de
parsers previos, consideraban los conflictos como errores fatales. Sin embargo, Yacc producirá
parsers aún en presencia de conflictos.
Como un ejemplo del poder de las reglas de desambiguación, consideremos:

sent : IF ´(´ cond ´)´ sent


| IF ´(´ cond ´)´ sent ELSE sent
;

que es un fragmento de un lenguaje de programación correspondiente a una sentencia if-then-


else. En estas reglas, IF y ELSE son tokens, cond es un símbolo no terminal describiendo
expresiones condicionales (lógicas), y sent es un símbolo no terminal describiendo sentencias.
Llamaremos regla if simple a la primera, y regla if-else, a la segunda.
Estas dos reglas forman una construcción ambigua porque una entrada de la forma:
14
IF ( C1 ) IF ( C2 ) S1 ELSE S2

puede ser estructurada de acuerdo con las reglas anteriores como:

IF ( C1 ) o como: IF ( C1 )
{ {
IF ( C2 ) IF ( C2 )
S1 S1
} ELSE
ELSE S2
S2 }

donde la segunda interpretación es la que consideran la mayoría de los lenguajes de


programación que incluyen esta construcción; cada ELSE se asocia con el último IF sin ELSE
precedente. En este ejemplo, consideremos la situación cuando el parser ha visto:

IF ( C1 ) IF ( C2 ) S1

y está viendo el ELSE. Puede reducir inmediatamente por la regla if simple para obtener:

IF ( C1 ) sent

y luego leer la entrada restante

ELSE S2

y reducir:

IF ( C1 ) sent ELSE S2

por la regla if-else. Esto conduce a la primera de las interpretaciones anteriores.


De otro modo, su puede hacer un shift del ELSE y entonces leer S2; entonces la porción de
la derecha de:

IF ( C1 ) IF ( C2 ) S1 ELSE S2

puede ser reducida por la regla if-else para obtener:

IF ( C1 ) sent

que pude ser reducido por la regla if simple, conduciendo a la segunda de las interpretaciones
anteriores, que es la deseada usualmente.
Nuevamente, el parser puede ejecutar dos acciones válidas; hay un conflicto shift-reduce.
La aplicación de la regla 1 de desambiguación, en este caso, le dice al parser que ejecute el shift,
que lleva a la interpretación deseada.
Este conflicto shift-reduce surge sólo cuando hay un símbolo de entrada particular, ELSE, y
en la entrada se ha visto una combinación particular, como:

IF ( C1 ) IF ( C2 ) S1

15
En general, puede haber muchos conflictos, y cada uno se asociará con un símbolo de
entrada y un conjunto de entradas leídas previamente. Estas son caracterizadas por el estado del
parser.
Los mensajes de conflicto de Yacc son entendidos mejor examinando el archivo de salida
generado con la opción -v. Por ejemplo, la salida correspondiente al estado de conflicto anterior
podría ser:

23: shift-reduce conflict (shift 45, reduce 18) on ELSE

state 23

sent : IF ( cond ) sent_ (18)


sent : IF ( cond ) sent_ELSE sent

ELSE shift 45
. reduce 18

donde la primera línea describe el conflicto; dando el estado y el símbolo de entrada. La


descripción normal del estado da las reglas gramaticales activas en el estado y las acciones del
parser. El símbolo _ marca la porción de las reglas que ya se ha visto. Así, en el ejemplo, en el
estado 23, el parser ha visto la entrada correspondiente a:

IF ( cond ) sent

y las dos reglas gramaticales que aparecen están activas en ese momento. El parser puede
hacer dos cosas. Si el símbolo de entrada es ELSE, es posible hacer un shift al estado 45. El
estado 45 tendrá, como parte de su descripción, la línea:

sent : IF ( cond ) sent ELSE_sent

porque el ELSE habrá producido un shift a este estado. En el estado 23, la acción alternativa,
indicada con un punto, tiene que ser ejecutada si el símbolo de entrada no se menciona
explícitamente en las acciones. En este caso, si el símbolo de entrada no es ELSE, el parser
reduce a:

sent : IF ´(´ cond ´)´ sent

por la regla gramatical 18.


Nuevamente, notar que los números que siguen a los comandos shift se refieren a otros
estados, mientras que los números que siguen a comandos reduce se refieren a reglas. En el
archivo y.out, los números de regla aparecen entre paréntesis después de aquellas reglas que
pueden ser reducidas. En la mayoría de los estados, una acción reduce es posible en el estado, y
este es el comando por defecto. El usuario que encuentra conflictos shift-reduce inesperados
probablemente deseará el archivo y.out para decidir si las acciones por defecto son las
adecuadas.

Precedencia

Hay una situación común donde las reglas dadas anteriormente para resolver conflictos no
son suficientes. Esto es en el parsing de expresiones aritméticas. La mayoría de las
construcciones comúnmente usadas para expresiones aritméticas pueden ser naturalmente
descriptas por la noción de niveles de precedencia para los operadores, junto con información

16
acerca de la asociatividad a izquierda o derecha. Esto hace que gramáticas ambiguas con reglas
de desambiguación apropiadas puedan ser usadas para crear parser que son más rápidos y más
fáciles de escribir que aquellos construidos desde gramáticas no ambiguas. La noción básica es
escribir reglas de la forma:

expr : expr OP expr

y:

expr : UNARY expr

para todos los operadores binarios y unarios. Esto crea una gramática muy ambigua con muchos
conflictos de parsing. Para evitar ambigüedad, el usuario especifica la precedencia de todos los
operadores y la asociatividad de los operadores binarios. Esta información es suficiente para
permitir a Yacc resolver los conflictos de parsing de acuerdo con estas regla s, y construir un
parser que tenga en cuenta las precedencias y asociatividades.
Las precedencias y asociatividades se conectan a los tokens en la sección de declaraciones.
Esto se hace con una serie de líneas, comenzando con una palabra clave Yacc: %left, %right,
o %nonassoc, seguidas por una lista de tokens. Todos los tokens en la misma líneas se
considera que tienen el mismo nivel de precedencia y asociatividad; las líneas se listan en orden
de precedencia creciente. Así:

%left ´+´ ´-´


%left ´*´ ´/´

describe la precedencia y asociatividad de los cuatro operadores aritméticos. Más y menos son
asociativos a izquierda y tienen menor precedencia que multiplicación y división, que son también
asociativos a izquierda. La palabra clave %right es usada para describir operadores asociativos
a derecha, y la palabra clave %nonassoc es usada para describir operadores, como .LT. en
FORTRAN, que no pueden asociarse con ellos mismos.
Como un ejemplo del comportamiento de estas declaraciones, la descripción:

%right ´=´
%left ´+´ ´-´
%left ´*´ ´/´

%%

expr : expr ´=´ expr


| expr ´+´ expr
| expr ´-´ expr
| expr ´*´ expr
| expr ´/´ expr
| NAME
;

podría usarse para estructurar la siguiente entrada:

a = b = c*d - e - f*g

17
como sigue:

a = ( b = ( ( (c*d) - e) - (f*g) ) )

para lograr la precedencia correcta de los operadores. Cuando se usa este mecanismo, se debe
dar en general, a los operadores unarios, una precedencia. A veces un operador binario y un
operador unario tienen la misma representación simbólica pero distinta precedencia. Un ejemplo
es el menos unario y el menos binario (-).
Al menos unario se le debe dar la misma precedencia que a la multiplicación, o aún más alta,
mientras que el menos binario tiene una precedencia más baja que la multiplicación. la palabra
clave %prec cambia el nivel de precedencia asociado con una regla particular. La palabra clave
%prec aparece inmediatamente después del cuerpo de la regla, antes de la acción o punto y
coma de cierre, y es seguido por un nombre de token o literal. Esto hace que la precedencia de
la regla se haga igual a la del nombre de token o literal que se indica.
Por ejemplo, las reglas:

%left ´+´ ´-´


%left ´*´ ´/´

%%

expr : expr ´+´ expr


| expr ´-´ expr
| expr ´*´ expr
| expr ´/´ expr
| ´-´ expr %prec ´*´
| NAME
;

podrían ser usadas para dar al menos unario la misma precedencia que la multiplicación.
Un token declarado por %left, %right, y %nonassoc no necesitan ser declaradas por
%token.
Las precedencias y asociatividades son usadas por Yacc para resolver conflictos de
parsing. Esto dan lugar a las siguientes reglas de desambiguación:
1. Se registran las precedencias y asociatividades para aquellos tokens y literales que las tengan
2. Una precedencia y asociatividad es asociada con cada regla de la gramática. Esta será la
precedencia y asociatividad del último token o literal en el cuerpo de la regla . Si se usa la
construcción %prec, esto sobreescribe el defecto. Algunas reglas de la gramática pueden no
tener precedencia y asociatividad asociadas con ellas.
3. Cuando hay un conflicto reduce-reduce o un conflicto shift-reduce y, ni el símbolo de
entrada ni la regla tienen precedencia y asociatividad, entonces se usan las dos reglas de
desambiguación descriptas anteriormente, y los conflictos son reportados.
4. Si hay un conflicto shift-reduce, y tanto la regla de la gramática como el carácter de entrada
tienen precedencia y asociatividad asociadas con ellos, el conflicto se resuelve en favor de la
acción (shift o reduce) asociada con la precedencia más alta. Si las precedencias son
iguales, se usa la asociatividad. La asociatividad a izquierda implica reduce; la asociatividad
a derecha implica shift; la no asociatividad implica error.

18
Los conflictos que se resuelven por precedencia no se cuentan en el número de conflictos
shift-reduce y reduce-reduce reportados por Yacc. Esto significa que errores en la
especificación de la precedencia puede disimular errores en la gramática. El archivo y.out es
muy útil para decidir si el parser está haciendo realmente lo que se desea.

19

También podría gustarte