Sei sulla pagina 1di 43

UNIDAD IV - AUTÓMATAS PUSHDOWN Y LENGUAJES LIBRES DE CONTEXTO

En esta Unidad se estudiarán las Gramáticas Libres de Contexto, de tipo 2, y los autómatas que
reconocen los lenguajes generados por ellas, los Autómatas Push-Down –también conocidos como
Autómatas a Pila–. Este tipo de gramáticas y autómatas son importantes desde el punto de vista de la
Informática, debido a que permiten la descripción de la mayoría de los lenguajes de programación, por lo
que posibilitan la construcción de compiladores.

I.- AUTÓMATAS PUSH-DOWN

1. Introducción

Los Autómatas Push-Down son los autómatas más importantes entre las máquinas de estados finitos y
las de Turing. Su operación está íntimamente relacionada con muchos procesos computacionales, en
especial con el análisis y la traducción de lenguajes artificiales.

Estos autómatas permiten analizar strings para reconocer si pertenecen o no a lenguajes de tipo 2, libres
de contexto. Tienen una estructura similar a la de los Autómatas Finitos, pero se les agrega una pila
(memoria auxiliar) para poder almacenar información que podrá ser útil en momentos posteriores del
análisis. Por ejemplo, en el caso de muchas gramáticas de lenguajes de programación existe la posibilidad
de utilizar paréntesis y estos deben estar balanceados (por cada paréntesis de apertura debe existir
posteriormente un paréntesis de cierre). Este aspecto es imposible de controlar con una Gramática Regular
(de tipo 3) debido a que, como no se conoce de antemano el número de paréntesis, se necesitarían infinitos
estados para llevar la cuenta de los paréntesis de apertura que se han leído para poder saber luego si todos
se cerraron o no. Los Autómatas Push-Down, al disponer de la memoria auxiliar (pila), pueden introducir en
ella un elemento por cada paréntesis de apertura leído, e ir eliminando de la pila un elemento por cada
paréntesis de cierre leído, y , así, llevar el control de si se han cerrado todos o no.

En la siguiente figura se muestra el esquema de este tipo de autómatas. Se dispone como entrada de
una cinta con los símbolos que forman el string que hay que analizar. El autómata tiene un cabezal de
lectura posicionado en cada momento en un símbolo de entrada; este cabezal sólo puede moverse hacia la
derecha y termina de moverse cuando llega al final de la cinta. Además, este tipo de autómatas, como los
anteriores, lleva el control del estado en el que está mediante una unidad de control de estados. Por último,
cuenta con una cinta de almacenamiento (pila), sobre la cual opera un cabezal de lectura-escritura.

Figura 1: Aceptor "Push-Down" en la Configuración (q, , )

La máquina comienza con la cinta de almacenamiento completamente en blanco (# en cada segmento),


escribe un símbolo en esta cinta cada vez que mueve su cabezal de almacenamiento hacia la derecha, y lee
un símbolo desde esta cinta de almacenamiento cada vez que mueve el cabezal de almacenamiento hacia
la izquierda. Toda información que se encuentre a la derecha de este cabezal es irrecuperable, ya que será
sobre-escrita cuando el cabezal se mueva hacia la derecha.

Dado que el string escrito en la cinta de almacenamiento, que puede ser infinitamente largo, afecta el
comportamiento del autómata, es necesario que el aceptor Push-Down tenga una memoria ilimitada. Sin
embargo, la información más recientemente escrita debe ser la primera en ser recuperada. Este mecanismo
de acceso limitado a lo que se encuentra almacenado, es muy común en la práctica computacional y se
conoce como una pila "Push-Down" debido a que tiene como regla de recuperación la siguiente: "último en
entrar, primero en salir" ("Last In, First Out").

63
2. Aceptores Push-Down

2.1. Definiciones

Definición: Un aceptor Push-Down (APD) es una tupla de seis elementos:


M = (Q, S, U, P, , F)
en la cual:
Q es el conjunto finito de estados de la unidad de control
S es el alfabeto finito de entrada
U es el alfabeto finito de la pila o "stack"
P es el programa de M

F  Q es el conjunto de estados finales o estados de aceptación

El programa P es una secuencia finita de instrucciones, cada una de las cuales tiene una de las
siguientes formas:
q] scan (s, q’) q, q’  Q
q] write (u, q’) sS
q] read (u, q’) uU

En cada caso, el estado q es la etiqueta o rótulo de la instrucción, y el estado q' es el estado sucesor.
Cada estado en Q rotula un único tipo de instrucción: scan, write o read.

Si q es un estado cualquiera,  es un string cualquiera de S* y  es cualquier string en U*, luego (q, , )


es una configuración de M. El string  se llama usualmente pila o "stack" y el símbolo que se encuentra al
tope de la misma se conoce como símbolo tope de la pila.

Una configuración (q, , ) es una descripción completa del estado total de un aceptor Push-Down en
algún punto en su análisis de una cinta de entrada. Esto se interpreta como se muestra en la Figura 1: La
unidad de control se encuentra en el estado q; el prefijo  del string de entrada ya ha sido explorado y el
cabezal de entrada se posiciona en el último símbolo de ; la pila  es el contenido de la cinta de
almacenamiento, y el cabezal de almacenamiento se posiciona en el último símbolo de .

La definición siguiente va a especificar cómo la ejecución de una instrucción de un APD, lo hace


evolucionar de una configuración a otra, tal como lo ilustra la Figura 2. Las instrucciones scan leen símbolos
de la cinta de entrada, las instrucciones write almacenan símbolos en la pila, y las instrucciones read
recuperan símbolos de la pila.

Definición: Sea M un aceptor Push-Down con un programa P, y supongamos que el string   S* está
escrito sobre la cinta de entrada. Las instrucciones del programa de la máquina M son aplicables de acuerdo
a las siguientes reglas:

1.- Una instrucción q] scan (s, q') es aplicable en una configuración (q,
, ) si s es un prefijo de . Al ejecutar esta instrucción, M mueve
su cabezal de entrada un segmento a la derecha, explora el
símbolo s inscripto en él, y pasa al estado q'. Representamos un
movimiento de scan con la siguiente notación:
(q, , ) (q', s, ) Figura 2.a: Movimiento “scan”

2.- Una instrucción q] write (u, q') es aplicable en cualquier configura-


ción (q, , ). Al ejecutar esta instrucción, M mueve el cabezal en
la pila un segmento a la derecha, escribe allí el símbolo u, y pasa
al estado q'. Representamos un movimiento write con la siguiente
notación:
(q, , ) (q', , u) Figura 2.b: Movimiento “write”

3.- Una instrucción q] read (u, q') es aplicable en una configuración


cualquiera (q, , ) en la cual  = 'u. Al ejecutar esta instrucción,
M lee el símbolo u bajo el cabezal de la pila, mueve este cabezal
un segmento a la izquierda, y pasa al estado q'. Representamos un
movimiento de read con la siguiente notación:
(q, , 'u) (q', , ') Figura 2.c: Movimiento “read”

64
Si M tiene la siguiente secuencia de movimientos:
(q0, 0, 0)  (q1, 1, 1)  …  (qk, k, k)
(donde cada movimiento es un scan, read o write), la misma puede sintetizarse de la siguiente forma:
(q0, 0, 0)  (qk, k, k)

Un aceptor Push-Down comienza su operación con el control en un estado inicial y los cabezales
posicionados en los numerales iniciales de sus respectivas cintas, como se muestra en la figura siguiente:

Figura 3.a: Configuración Inicial de un aceptor

La máquina pasa a través de una secuencia de operaciones, cada una resultante de la ejecución de una
instrucción aplicable en la configuración precedente. La operación continúa hasta que se alcanza una
configuración en la cual no hay instrucciones aplicables. Si en un dado punto se ha explorado un string de
entrada (completo o en parte), la pila está vacía y la unidad de control está en un estado final, se dice que el
string  explorado es aceptado por M. La configuración de aceptación se observa en la siguiente figura:

Figura 3.b: Configuración de Aceptación de un aceptor

Definición: Una configuración inicial de un aceptor Push-Down es cualquier configuración (q, , ) en la cual
q es un estado inicial de M. Una configuración final de M es cualquier configuración (q', , ), donde q' es un
estado final de M y  es un prefijo del string escrito sobre la cinta de entrada de M.

El string  es aceptado por M sí y sólo sí M tiene una secuencia de movimientos:


(q, , )  (q', , ) q  , q'  F

Luego el lenguaje reconocido por M es el conjunto de strings aceptados por M:


L(M) = {   S* / (q, , )  (q', , ) ; q  , q'  F }

2.2. Ejemplos: Lenguajes de tipo Imagen Especular

La Figura 4 muestra el programa de un aceptor Push-Down Mcm con alfabeto de entrada S = { a, b, c } y


alfabeto de pila U = { a, b }. El estado 1 será el estado inicial. Los nodos del diagrama de estados
representan los estados de la unidad de control, y cada nodo tiene en su interior las letras S, R o W de
acuerdo al tipo de instrucción que el estado rotula. Los estados iniciales y de aceptación son identificados de
la misma manera que en el caso de los autómatas de estados finitos.
1] scan (a,2) (b,3) (c,4)
2] write (a,1)
3] write (b,1)
4] scan (a,5) (b,6)
5] read (a,4)
6] read (b,4)
={1} F={4}
Programa de Mcm

Figura 4: Programa y diagrama de estados de un aceptor Push-Down

65
Un estudio del programa o del diagrama de estados del autómata revela que una única instrucción es
aplicable a una configuración cualquiera de Mcm; por lo tanto, el comportamiento de la máquina es
determinado inequívocamente por el string de entrada . La máquina comienza su operación en el estado 1
y pasa a través de dos etapas:
1.- En la etapa de almacenamiento, copia en la pila la porción de  previa a encontrar el símbolo c.
2.- En la etapa de comparación trata de encontrar igualdades entre los símbolos almacenados en la pila y los
restantes símbolos del string de entrada.

Si la porción de  que sigue a la letra c es exactamente el reverso del string almacenado en la pila, la
máquina vaciará la cinta de almacenamiento inmediatamente después de explorar la última letra de ,
dejando a Mcm en la configuración de aceptación (4, , ). Vemos entonces que Mcm acepta cada string que
tenga la forma  =  c  , donde   (a  b)*. Por ejemplo, la secuencia de movimientos por la cual Mcm
R

acepta el string  = abcba es:


(1, , ) (2, a, ) (1, a, a) (3, ab, a) (1, ab, ab) (4, abc, ab)
(6, abcb, ab) (4, abcb, a) (5, abcba, a) (4, abcba, )

Si la letra explorada por Mcm en el estado 4 no coincide con la última letra de la pila, o si quedan símbolos
en la pila luego de que  ha sido explorado, Mcm se detendrá con una pila no vacía y se rechazará la entrada.
El rechazo de  = abca se ilustra con la siguiente secuencia de movimientos:
(1, , ) (2, a, ) (1, a, a) (3, ab, a) (1, ab, ab) (4, abc, ab) (5, abca, ab)

Concluimos que el lenguaje reconocido por Mcm es:


Lcm = {  c  /   (a  b)* }
R

Este lenguaje se conoce como lenguaje imagen especular con marcador central. Es generado por la
siguiente gramática libre de contexto:
Gcm:   S S  bSb
S  aSa Sc

El aceptor Push-Down Mcm es determinístico, ya que para una configuración cualquiera nunca hay
opción a más de un movimiento. Una leve modificación del lenguaje Lcm requerirá un aceptor Push-Down no
determinístico. Consideremos el lenguaje:
Lmi = {   /   (a  b)* -  }
R

que es conocido simplemente como lenguaje imagen especular.

A diferencia del lenguaje Lcm, no hay un símbolo especial en los strings de Lmi que indique cuándo el
aceptor Push-Down debe cambiar del modo "almacenamiento" al modo "comparación". La pregunta es
entones: existe un aceptor Push-Down para Lmi? y la respuesta es SÍ. En realidad es fácil modificar Mcm para
obtener un autómata que tenga una secuencia de aceptación para cada string de L mi. La modificación es la
siguiente: en lugar de aguardar el símbolo "c", permitimos que la máquina M mi cambie al modo
“comparación” cada vez que escribe en la pila un símbolo que ha explorado. La máquina resultante es:
1] scan (a,2) (b,3)
2] write (a,1) (a,4)
3] write (b,1) (b,4)
4] scan (a,5) (b,6)
5] read (a,4)
6] read (b,4)
={1} F={4}
Programa de Mmi

Figura 5: Aceptor Push-Down No Determinístico

Este comportamiento debe permitirse puesto que no hay forma de que la máquina determine cuándo ha
examinado la primera mitad del string "imagen especular". Es decir, le permitiremos a la máquina "probar"
luego de cada símbolo explorado si debe cambiar o no al modo comparación.

Las posibles secuencias de movimientos de Mmi para el string de entrada  = aabbaa se muestran en la
siguiente figura, donde se ve que se alcanzan configuraciones de aceptación para los strings aa y aabbaa
por medio de secuencias de movimientos únicas; todas las otras secuencias terminan en configuraciones de
rechazo.

66
Figura 6: Comportamiento No Determinístico de un aceptor Push-Down

3. Aceptores Push-Down Propios

Con el objeto de hacer más simple el tratamiento de ciertos temas relacionados con los aceptores Push-
Down, es conveniente restringir nuestra atención a la subclase de aceptores cuya habilidad para reconocer
lenguajes es equivalente a la de todos los aceptores Push-Down; en particular, trataremos la clase
denominada “Aceptores Push-Down Propios". Con la idea de demostrar que no hay ninguna pérdida de
generalidad, desarrollaremos una construcción por medio de la cual cualquier APD puede ser transformado
en un Aceptor Push-Down Propio (APDP).

Nuestra definición de APD permite ciertos comportamientos anómalos o no productivos. En particular


puede ocurrir que un movimiento de read ocurra inmediatamente después de un movimiento de write, lo
cual puede conducir a dificultades en el análisis de las propiedades de aceptores Push-Down.

Supongamos que un APD M tiene una secuencia de movimientos en la cual un movimiento de write es
seguido por un movimiento de read, y en esta secuencia no intervienen movimientos de scan:
(q, , ) (q', , u) (q”, , ) …

Este par de movimientos no tiene un efecto neto además de hacer que la unidad de control cambie entre
los estados q y q". Esta secuencia de movimientos inconsistentes puede ocurrir solamente si M tiene una
instrucción de escritura:
q] write (u, q')
para la cual el estado sucesor q' es el rótulo de una instrucción read.

Definición: En el programa de un APD M, una instrucción write:


q] write (u, q')
es impropia si q' es el rótulo de una instrucción read cualquiera.
Una APD es propio si su programa no contiene instrucciones impropias.

Las instrucciones impropias son fácilmente identificables en el diagrama de estados de un APD, y pueden
ser eliminadas sin modificar el comportamiento de la máquina en lo referente al lenguaje reconocido. En
particular, si M es un APD cualquiera, es posible construir un APDP mediante la adición y el borrado de
instrucciones tal como sigue.

Transformación de un APD en un APDP


Etapa 1: Sean q y q' un par de estados cualesquiera para los cuales M tiene una secuencia de movimientos

para cualquier k1


- Por cada instrucción
p] move ( – , q)
se agrega la instrucción
p] move ( – , q') , donde move es cualquier instrucción y – cualquier símbolo
- Si q   en M, luego q'   en M'.
- Esta etapa se repite para cada par de estados q y q' para los cuales M tiene una secuencia de
movimientos como la especificada anteriormente.
Etapa 2: Se borran las instrucciones impropias. Las instrucciones que quedan forman el programa de M'.

Proposición 1: Sea M un APD cualquiera. Se puede construir un APDP M', tal que verifica que L(M')=L(M).

67
Ejemplo 1: En la figura siguiente se muestra un aceptor Push-Down que escribe un string perteneciente al
conjunto (b*ba)* en su pila, y posteriormente emplea a ésta para controlar la exploración de un string de
entrada definido sobre (x  y)*.

1] write (b,1) (b,2)


2] write (a,1) (a,5)
3] scan (x,4) (y,5)
4] read (b,3) (b,5)
5] read (a,4)
={1} F={5}

(a)
La siguiente instrucción es impropia, ya que el estado 5 rotula una instrucción de read.
2] write (a , 5)
Etapa 1: Este aceptor tiene las siguientes secuencias de movimientos "sin resultado neto":
(2, , ) (5, , a) (4, , )
(1, , ) (2, , b) (5, , ba) (4, , b) (3, , )
(1, , ) (2, , b) (5, , ba) (4, , b) (5, , )
Por lo tanto, para obtener un APDP M', cada instrucción de M que tenga a los estados 1 o 2 como estados
sucesores, se duplica reemplazando al estado 1 (como sucesor) por los estados 3 y 5, y el estado 2 por 4.
 Por cada instrucción p] move (-, 2) se agrega p] move (-, 4)
p] move (-, 1) p] move (-, 3)
p] move (-, 1) p] move (-, 5)
Siguiendo con la aplicación de la primer etapa del procedimiento de generación de M', como el estado 1
es inicial, los estados 3 y 5 serán estados iniciales en M'. La figura (b) muestra con líneas de trazos las
nuevas instrucciones y los nuevos estados iniciales.
 En M, es 1 luego en M' 3
1 5

1] write (b,1) (b,2) (b,4) (b,3) (b,5)


2] write (a,1) (a,5) (a,3) (a,5)
3] scan (x,4) (y,5)
4] read (b,3) (b,5)
5] read (a,4)
 = { 1 }  { 3, 5 } F={5}

La próxima etapa es la eliminación de las (b)


instrucciones impropias (aquellas en líneas de
trazos en la figura (c)). Estas son borradas para
obtener el aceptor M' mostrado en la figura (d).

1] write (b,1) (b,2) (b,4) (b,3) (b,5)


2] write (a,1) (a,5) (a,3) (a,5)
3] scan (x,4) (y,5)
4] read (b,3) (b,5)
5] read (a,4)
 = { 1 }  { 3, 5 } F={5}
(c)

1] write (b,1) (b,2) (b,3)


2] write (a,1) (a,3)
3] scan (x,4) (y,5)
4] read (b,3) (b,5)
5] read (a,4)
 = { 1, 3, 5 } F={5}

(d)

68
Mediante la Proposición 1 sabemos que L(M')=L(M). Por ejemplo, la secuencia de movimientos
(1, , ) (2, , b) (5, , ba) (4, , b) (3, , ) (5, y, )
por la cual M acepta el símbolo y, se transforma en la máquina M’ simplemente en:
(3, , ) (5, y, )

Es importante aquí detenerse a analizar cómo un APDP acepta el string vacío. En un APD cualquiera,
una secuencia que acepta a  no deberá contener movimientos de scan. Mas aún, cualquiera de estas
secuencias deberá contener un número equivalente de movimientos de read y write y además, al menos un
movimiento de write debe preceder cualquier movimiento de read.
En un APDP un movimiento de write no puede estar inmediatamente seguido de un movimiento de read;
esto lleva a concluir que  debe ser aceptado sin ningún tipo de movimiento, lo que equivale a decir que un
APDP acepta a  sólo si su estado inicial es de aceptación.

4. Aceptores Push-Down para Lenguajes Libres de Contexto

4.1. Análisis Sintáctico

Los traductores de lenguajes artificiales (por ej., compiladores de programas) emplean varias etapas en
su procesamiento. Cuando las sentencias válidas del lenguaje son especificadas por medio de una
gramática con estructura de frases, la primer etapa del proceso de traducción construye un árbol de
derivación para una dada sentencia. Cuando la sentencia no es ambigua, el único árbol de derivación asigna
un tipo sintáctico a cada una de sus frases; luego el "anidamiento" de frases tipo en la derivación es
empleado por el traductor para asignar significado a la oración. La construcción de un árbol de derivación de
[Ver III]
acuerdo a una gramática específica se conoce como Análisis Sintáctico.

Consideremos la gramática GE, la cual es una porción de la gramática para la sentencia de asignación
del ALGOL ya vista al comenzar el curso.
GE: EA AT+A
E  if B then A else E Tx
BA=A Ty
AT Tz
Consideremos ahora la forma metódica de construir un árbol de derivación para la oración
if x=y then z else x+y
que es una frase de tipo sintáctico E.
Nuestro procedimiento seguirá cada posible secuencia de pasos de derivación por izquierda que la
gramática permite. Este procedimiento se ilustra en la siguiente figura, donde las letras i, t y e son las
abreviaturas para los símbolos terminales if, then y else.

Figura 7: Análisis Sintáctico "Top-Down"

69
Cada secuencia de pasos de derivación comienza con la forma sentencial E. Primeramente, exploramos
la gramática buscando una producción con E como su parte izquierda y encontramos las siguientes reglas
que son candidatas:
EA EiBtAeE
Determinemos las consecuencias de aplicar la producción E  A. Debemos recordar que cuando el
símbolo * aparece, indica que hay producciones alternativas que pueden ser aplicadas en un punto dado.
Por ejemplo, si aplicamos la primera de las producciones anteriores, obtenemos la línea (2), y de nuevo
surgen dos producciones que son posibles de utilizar:
AT AT+A
Probando A  T [se obtiene la línea (3)], encontramos que T puede ser reemplazada por las letras
terminales x, y o z [línea (4)], ninguna de las cuales coincide con la primer letra de la sentencia dada.
Retrocedemos (haciendo "backtracking") a la línea (2) y probamos la alternativa mostrada en la línea (5).
Una vez más, todas las posibilidades fallan en la línea (6). Debemos retroceder a la línea (1), ya que hemos
agotado todas las posibles reglas aplicables a A. Dado que era posible aplicar otra regla a E, la aplicamos y
generamos la línea (7). Dado que el primer símbolo de la línea (7) coincide con el primer símbolo de la
sentencia bajo estudio, este símbolo es subrayado.
Ahora el primer símbolo no terminal de la línea (7) es B, y hay una sola regla para B; luego la línea (8) se
obtiene inmediatamente. En la línea (8) probamos la primer regla aplicable a A para obtener la línea (9), y
nuevamente marcamos este punto con * para indicar que una regla alternativa podría ser aplicada.
Continuamos con esta metodología hasta la línea (17), marcando cada línea donde existe una alternativa con *,
y subrayando la porción de la oración que coincide con la sentencia dada. En la línea (17) descubrimos que
i x=y t z e x
es una frase de tipo E. Sin embargo, los dos últimos símbolos de la oración dada no coinciden con la
hallada. Debemos retroceder a la línea (15) en donde la decisión más reciente fue tomada, y aplicamos la
otra regla asociada con A. Esto da como resultado la línea (18) que nos conduce a la línea (21) que
finalmente es exitosa.
La secuencia de pasos de derivación exitosos especifican el siguiente árbol de derivación:

Este método de análisis sintáctico es llamado procedimiento "Top-Down" debido a que el árbol de
derivación para una sentencia es originado a partir del nodo raíz (ubicado en el tope del árbol),
evolucionando hacia las hojas, o sea hacia abajo. Existen también procedimientos "Bottom-Up" que
construyen el árbol de derivación partiendo de los nodos hoja y evolucionando hacia la raíz.

4.2. Construcción de Analizadores Sintácticos Push-Down

Es sencillo construir un APD que lleve a cabo la operación de análisis sintáctico "Top-Down". Sea
G = (N, T, P, ) una gramática libre de contexto arbitraria. Mostraremos cómo construir un APD M que
"derive" cualquier string definido en L(G).

Supongamos que i  T*
  1A11  …  kAkk   es la derivación por izquierda de  con Ai  N 1ik
i  (N  T)*
Las formas sentenciales pertenecientes a esta derivación están representadas
por configuraciones de M como las que se muestran en la siguiente figura:

Figura 8: Representación de una forma sentencial iAi i en un aceptor Push-Down

70
Específicamente, una forma sentencial iAii está representada por una configuración
(qR, i, i Ai)
R

en la cual el string de símbolos de entrada que ha sido explorado es i , el contenido de la pila o "stack" es el
reverso de Aii y qR es un estado especial de M.
Cuando a M se le presenta una cinta de entrada conteniendo un string  definido en L(G), el
comportamiento de M será el de asumir una sucesión de configuraciones correspondientes a las formas
sentenciales de cualquier derivación por izquierda de .
(q, , )  (qR, , )  (qR, 1, 1 A1)  …  (qR, k, k Ak)  (qR, , )
R R

Debemos suministrar al aceptor un programa de instrucciones que lleve a cabo cada movimiento de la
secuencia:
(qR, i, i Ai)  (qR, i+1, i+1 Ai+1)
R R

correspondiente a un paso de derivación en la gramática G. Cada vez que haya producciones alternativas
aplicables a una forma sentencial, el aceptor también deberá tener un conjunto de movimientos alternativos.
Por lo tanto, M deberá ser por lo general un autómata no determinístico.

El programa de M se realiza con dos operaciones básicas: "expansión" y "matching". La expansión


corresponde a la aplicación de una producción Ai   (psi) a la forma sentencial iAii . El "matching"
determina si los símbolos terminales ubicados al principio de  coinciden con los próximos símbolos de la
cinta de entrada. El símbolo tope de la pila determina cuál de estas operaciones ocurre: una letra no terminal
indica expansión, mientras que una letra terminal indica "matching". El estado qR se usa para leer el símbolo
tope de la pila.

1.- Expansión: Reemplaza la letra no terminal A ubicada al tope de la pila por  , el reverso del lado derecho
R

de alguna producción A   definida en G. Retorna al estado qR. Esto se muestra en la siguiente figura:

La expansión de acuerdo a la regla A   se implementa con las instrucciones:


qR] read (A, qA)
R
qA] write ( , qR)
Aquí, se ha empleado una instrucción write generalizada, que es una abreviación obvia para una
secuencia de movimientos de write que agregan el string  a la pila.
R

2.- Matching: Si el símbolo tope de la pila es una letra terminal t, se deberá explorar la próxima letra s
ubicada en la cinta de entrada. Si s=t el "matching" (coincidencia) es exitoso y puede continuar. De otra
forma, el matching falla y la operación finaliza. Esta operación se ilustra en la siguiente figura:

y se implementa por medio de las siguientes instrucciones:


qR] read (t, qt)
qt] scan (t, qR) ambas para cada t  T.

El reconocedor de L(G) se completa mediante la adición de la instrucción inicial:


q] write (, qR)
la cual inicializa la pila con la forma sentencial .

Los estados inicial y final de M son:


 = { q } y F = { qR }

Debemos incluir q en el conjunto F si    es una producción de G.

Estas reglas de construcción se resumen en la Tabla 1 y la forma general de M se muestra en la figura


ubicada a continuación.

71
Tabla 1: Construcción de un Analizador Push-Down
Dada: Una gramática libre de contexto G = (N, T, PG, )
Construir: Un aceptor Push-Down M = (Q, T, U, PM, , F) tal que L(M) = L(G)
Sean: U = N  T  {  } Q = { q , qR }  { qx / x  U }  = { q } qR  F
Las instrucciones de M son:

Para inicializar: q] write (, qR)


Para expandir:
AN{} qR] read (A, qA)
AG
  (N  T)* -  R
qA] write ( , qR)

Para hacer "matching":


tT qR] read (t, qt)
qt] scan (t, qR)
Para aceptar :
Si     G q  F

A modo de ejemplo aplicamos el procedimiento de esta tabla a la gramática GE usada para ejemplificar
análisis sintáctico.

Ejemplo 2: Construcción de un Aceptor Push-Down para L(GE)


Dada GE: EA BA=A
EiBtAeE Tx
AT Ty
AT+A Tz

Las instrucciones de ME son:


Para inicializar: q] write (E, qR)
Para expandir: qR] read (E, qE) (A, qA) (B, qB) (T, qT)
qE] write (A, qR) (EeAtBi, qR)
qA] write (T, qR) (A+T, qR)
qB] write (A=A, qR)
qT] write (x, qR) (y, qR) (z, qR)
Para hacer "matching": qR] read (i, qi ) (t, qt ) (e, qe ) (+, q+) (=, q=) (x, qx) (y, qy) (z, qz)
qi ] scan (i , qR)
qt ] scan (t , qR)
qe] scan (e, qR)
q+] scan (+, qR)
q=] scan (=, qR)
qx] scan (x, qR)
qy] scan (y, qR)
qz] scan (z, qR)

Teorema: Por cada gramática libre de contexto G, uno puede construir un APD M, tal que L(M) = L(G).

5. Gramáticas Libres de Contexto a Partir de un Aceptor Push-Down

5.1. Conjuntos Traverse

Sea M un APD construido a partir de una GLC


arbitraria, y sea la siguiente secuencia de
movimientos realizada por M:
(q, , )  (q', , )
donde  es el contenido inicial de la pila y el estado
q rotula una instrucción write que agrega el
símbolo no terminal A a la pila.

Figura 9: Un "traverse" que observa el string 

72
Esta secuencia de movimientos, en la cual el cabezal sobre la pila retorna a una posición inicial sin
haberse movido hacia la izquierda de dicha posición, es llamada un "TRAVERSE", y el string  sobre el cual ha
avanzado el cabezal en la cinta de entrada se denomina STRING OBSERVADO POR EL TRAVERSE.
1
Según se vio , esta secuencia de movimientos se corresponde únicamente con la derivación:
A 
R R

en la gramática G. Esto es, el string terminal  es derivable a partir del símbolo no terminal A.
A 

Esto sugiere que, en general, el string observado por un "traverse" de un APD es derivable a partir de un
símbolo no terminal en una dada gramática. La construcción de una gramática a partir de un APD está
basada en esta idea. De la misma forma que la noción de "conjunto final" ayudó en la construcción de
gramáticas lineales por derecha a partir de aceptores de estados finitos (AEF), trataremos de utilizar la
noción de “conjunto traverse" para la construcción de gramáticas libres de contexto (GLC) a partir de
aceptores Push-Down, mediante la asociación de símbolos no terminales con los conjuntos "traverse".

Definición: Sea M = (Q, S, U, P, , F) un APDP. Una secuencia de movimientos de M


(q, , )  (q', ', ')
es llamada un traverse de  desde el estado q al estado q' si se cumple que:
1.-  = '
2.-  = '
3.- Cada configuración (qi, i, i) que ocurre en la secuencia de movimientos satisface que |i |  ||, es decir
 (qi, i, i): | i |  |  |
En tal caso se dice que  es el string observado por el "traverse".
Para indicar que una secuencia de movimientos es un "traverse" se escribe
(q, , ) (q', , )

El conjunto Traverse T(q,q') es el conjunto de todos los strings observados por movimientos traverse que
van de q a q'.
T(q,q') = {   S* / (q, , ) (q', , );   S*,   U* }

Un traverse que no involucra ningún movimiento observa el string vacío y es llamado Traverse Trivial.
(q, , ) (q, , )
Así,   T(q,q) para cada q  Q.

Esta definición se ha ilustrado en la figura anterior. A medida que el cabezal sobre la cinta de entrada
explora los símbolos de , el cabezal sobre la pila realiza excursiones arbitrarias sobre la cinta de
almacenamiento, pero jamás se mueve a la izquierda de la posición en la cual comienza y termina el
traverse.

Ejemplo 3: Consideremos el lenguaje de doble paréntesis Ldp, compuesto por todos aquellos strings con
paréntesis balanceados, que pueden contener dos tipos de paréntesis. Por ejemplo, el string [( )]( ) pertenece a
Ldp. El lenguaje tiene la siguiente definición:
1.- ( ) y [ ] están definidos en Ldp.
2.- Si  está definido en Ldp, también lo están () y [].
3.- Si  y  están definidos en Ldp, lo mismo ocurre con .

Un aceptor Push-Down para este lenguaje se muestra en la siguiente figura:

1
Recordar que la producción A    G se implementa en un APD mediante las instrucciones qR] read (A, qA) y
qA] write (R, qR), las cuales generan el siguiente cambio de configuración: (q R, , A)  (qR, , R), y asumiendo
que   T*, luego mediante las instrucciones de matching qR] read (t, qt) y qt] scan (t, qR), se producirá el cambio
(qR, , R)  (qR, , ). En resumen, (qR, , A)  (qR, , ), lo cual corresponde a la aplicación de la
producción A   en el paso de derivación AR  R.
73
1] scan ( ( ,2) ( [ ,3) ( ) ,4) ( ] ,5)
2] write (a,1)
3] write (b,1)
4] read (a,1)
5] read (b,1)
={1} F={1}

APD para el lenguaje Ldp

El "traverse" por el cual este aceptor acepta [( )]( ) es:

Por lo tanto escribimos:


(1, , ) (1, [( )]( ), ) [( )]( )  T(1,1)

Este "traverse" contiene subsecuencias de movimientos que a su vez son "traverse":


(1, , ) (1, [( )], ) [( )]  T(1,1)
(1, [( )], ) (1, [( )]( ), ) ( )  T(1,1)
(1, [, b) (1, [( ), b) ( )  T(1,1)

Por lo tanto T(1,1) contiene los strings [( )]( ), [( )] y ( ). También tenemos, entre otros:
(1, , ) (3, [, ) [  T(1,3)
(1, [(, ba) (4, [(), ba) )  T(1,4)

5.2. Propiedades de los Conjuntos Traverse

De la figura que acompaña la definición de "traverse" surge claramente que las elecciones del string 
inicialmente explorado y de la pila inicial  son arbitrarias en un "traverse".

Proposición 2: Si
(q, , ) (q', , )
es un "traverse" de  para algún   S* y algún   U*, luego éste es un traverse de  para cualquier   S*
y cualquier   U*.
Si arbitrariamente asumimos que los strings  y  son vacíos para algún traverse de , tenemos que:
(q, , ) (q', , )
es una secuencia de movimientos de aceptación de , si q   y q'  F.

Por cierto, cualquier secuencia de aceptación de  es un "traverse" de , ya que la pila comienza y


termina vacía en una configuración inicial y final, respectivamente.

Proposición 3: Un APD M acepta un string  sí y sólo sí éste observa el string  en un "traverse" desde algún
estado inicial a algún estado final.
  L(M)    T(q,q'); q  , q'  F
por lo tanto el lenguaje reconocido por M es:

Consideremos ahora un "traverse" efectuado por M para el string vacío . Cualquiera de estos "traverse"
no debe contener ningún movimiento de scan, y por lo tanto consiste de una secuencia de writes y reads.
Dado que el "traverse" debe dejar al cabezal que se encuentra sobre la pila en su posición inicial, los
movimientos de lectura y escritura deben ser de igual número. Más aún, el primer movimiento de un traverse
de este tipo debe ser de write, ya que un read movería el cabezal a la izquierda de su posición inicial.
Consecuentemente, es imposible completar el "traverse" sin dejar de tener en algún lugar un movimiento de
read que siga inmediatamente a uno de write. Ya que esto no sería posible si M fuese un aceptor propio,
tenemos:
Proposición 4: Sea M un aceptor Push-Down propio. Los únicos "traverses" posibles del string  realizados
por M son los "traverses" triviales.
(q, , ) (q, , ) q  Q,   S*,   U*
Es decir,
  T(q,q')  q = q'

Por lo tanto, cada "traverse" no trivial llevado a cabo por un APDP debe contener como mínimo un
movimiento de scan.

5.3. Construcción de una Gramática

En la construcción de una gramática a partir de un APD, queremos asociar una letra no terminal N(q,q')
con ciertos conjuntos "traverse" T(q,q') del autómata (en forma análoga al uso de conjuntos finales para la
construcción de GRLD a partir de AEF). Cada letra no terminal denotará exactamente aquellos strings que
son miembros de los conjuntos "traverse" correspondientes. Es posible notar la analogía de las siguientes
expresiones:
En AEF: N(q)  sí y sólo sí   E(q)
En APD: N(q,q')  sí y sólo sí   T(q,q')

Con el propósito de obtener las producciones gramaticales, debemos relacionar la pertenencia de un


string a un conjunto "traverse" con la pertenencia de sus substrings a otros conjuntos "traverse". Será
necesario introducir una terminología adicional para desarrollar estas ideas.

Definición: Sea (q, , ) (q', , ) un "traverse" realizado por un APD, donde  el contenido inicial y final
de la pila. Un movimiento de write en el "traverse" es llamado movimiento de write básico si es realizado
desde una configuración en la cual la pila contiene  y un movimiento de read es llamado movimiento de
read básico si deja a la pila conteniendo .
Un movimiento de write básico y un movimiento de read básico forman un par de igualdad o coincidencia
si el movimiento de write precede el movimiento de read, y ningún otro write o read básico interviene; es
decir, en un par de igualdad o coincidencia de movimientos básicos, el movimiento de read lee el símbolo
colocado en la pila por el movimiento de write.

Ejemplo 3: (cont.) En el Ejemplo 3, el "traverse":


(1, , ) (1, [( )]( ), )
consiste de la siguiente secuencia de movimientos:

En este "traverse" hay dos pares de igualdad, que están indicados por las líneas continuas. La secuencia
de movimientos dentro del primer par de igualdad o coincidencia es el "traverse":
(1, [, b) (5, [( )], b)
el cual contiene en sí mismo el par de igualdad indicado por la línea de trazos.

En este ejemplo, cada read básico coincide con un write básico en el mismo "traverse". Realmente, esto
debe ocurrir siempre. Para que en un "traverse" el cabezal sobre la pila retorne a su posición inicial, el
aceptor debe leer todos los símbolos que han sido escritos en la pila desde el comienzo del "traverse". Si un
símbolo ha sido escrito por un movimiento de write básico del "traverse", el movimiento que lee el símbolo
debe ser el siguiente movimiento de read básico. Similarmente, cualquier símbolo leído por un read básico
en un "traverse" debe haber sido escrito por el write básico precedente. Por lo tanto tenemos la siguiente
proposición:

Proposición 5: Un "traverse" que contiene un read (write) básico también contiene el correspondiente write
(read) básico.

Ahora estamos preparados para los resultados básicos que relacionan la pertenencia de un string a un
conjunto "traverse" con la pertenencia de strings más simples a otros conjuntos "traverse". La Proposición 6
expresa formalmente el hecho que un "traverse" debe asumir una de cuatro formas mutuamente excluyentes.

75
Proposición 6: Sea M un APDP y sea (q, , ) (q', , ) un "traverse" del string  realizado por M; esto
es,   T(q,q'). Luego, exactamente una de las siguientes afirmaciones es cierta:
1.- El "traverse" consiste de un movimiento de scan simple.
(q, , ) (q’, s, ) donde  = s

2.- El "traverse" tiene más de un movimiento, y el movimiento final es un scan.


(q, , ) (q”, , ) (q’, s, ) donde  = s y   T(q,q”)

3.- El "traverse" tiene más de un movimiento, el movimiento final es un read, y el movimiento de write que
corresponde al mismo no es el primero.
(q, , ) (q”, , ) (p, , u) (p’, , u) (q’, , ) donde  =  y   T(q,q”);   T(p,p’)

4.- El "traverse" tiene más de un movimiento, el movimiento final es un read, y el write coincidente es el
primer movimiento del "traverse".
(q, , ) (p, , u) (p’, , u) (q’, , ) donde  =  y   T(p,p’)

Figura 10: Las cuatro formas de un "traverse" que observa el string 

La demostración de esta proposición no será efectuada. Sin embargo es claro que las cuatro
afirmaciones son mutuamente excluyentes y que todos los posibles casos están cubiertos.

Relación entre conjuntos "traverse" y conjuntos de strings derivables a partir de las letras no
terminales de la gramática

El principio por el cual vamos a relacionar gramáticas con aceptores es el mismo ya empleado con
anterioridad; durante la construcción de una gramática lineal por derecha a partir de un AEF se estableció la
relación siguiente:
N(q)  en Gsí y sólo sí   E(q) en M

Para construir una gramática libre de contexto G a partir de un APD, se obtendrán las producciones de la
gramática a partir del programa de M de tal forma que para cualquier string   S* se verifique:
N(q,q')  en G sí y sólo sí   T(q,q') en M
donde T(q,q') es un conjunto "traverse" y N(q,q') es el símbolo no terminal correspondiente.

76
Cada uno de los cuatro posibles tipos de "traverse" vistos en la proposición 6 especifica relaciones entre
conjuntos "traverse" que se corresponden con relaciones entre conjuntos de strings derivables a partir de las
letras no terminales de la gramática. Estas relaciones se muestran en la figura siguiente:

(1) (q, , ) (q’, s, )


N(q, q’)  s

(2) (q, , ) (q”, , ) (q’, s, )

N(q, q”) 
N(q, q’)  N(q, q”)s

(3) (q, , ) (q”, , ) (p, , u) (p’, , u) (q’, , )

N(q, q”)  N(p, p’) 


N(q, q’)  N(q, q”)N(p,p’)

(4) (q, , ) (p, , u) (p’, , u) (q’, , )

N(p, p’) 
N(q, q’)  N(p,p’)
Figura 11: Relaciones entre tipos de "traverse" y etapas de derivación

Por lo tanto ahora nos preguntamos: Con qué pares de estados (q,q') debemos asociar las letras no
terminales N(q,q') de G? Dado que cada N(q,q') es asociado con un "traverse", necesitamos identificar el
conjunto de estados B sobre los cuales los "traverse" pueden comenzar. Los conjuntos "traverse" relevantes
a la construcción de una gramática son entonces:
{ T(q,q') / q  B, q'  Q }
A partir de la proposición 3 sabemos que un "traverse" mediante el cual se acepta un string siempre
comienza en un estado inicial de M. Más aún, la figura anterior muestra que, cuando un "traverse" se
descompone en "traverses" más simples de acuerdo a la proposición 6, cada "traverse" más simple
comienza, ya sea desde el mismo estado inicial que el "traverse" que lo engloba, o desde el estado sucesor
de un movimiento de write. De acuerdo a esto, el conjunto B es:
B =   { q' / q] write (u,q')  P; q  Q, u  U }
El conjunto de símbolos no terminales de G es entonces:
N = { N(q,q') / q  B, q'  Q }

Cada uno de los cuatro posibles tipos de "traverse" vistos en la proposición 6 resulta de ciertas
instrucciones específicas en el programa de la máquina M; a partir de esto y de la figura anterior podemos
deducir las siguientes reglas de construcción de la gramática G.

Regla 1: Supongamos que M tiene la instrucción: q] scan (s,q') qB


Por lo tanto s  T(q,q') y G tiene la producción:
N(q,q')  s

Regla 2: Supongamos que M tiene la instrucción: q"] scan (s,q')


Si  es un string en T(q,q"), se sigue que s está en T(q,q'). Esto se expresa mediante la siguiente
producción definida en G:
N(q,q')  N(q,q")s

Regla 3: Supongamos que M tiene las instrucciones: q"] write (u,p)


p'] read (u,q')
Si  y  son strings no vacíos definidos en T(q,q") y T(p,p'), entonces  =  es un string en T(q,q'). Esto
es expresado mediante la producción:
N(q,q')  N(q,q")N(p,p')

Regla 4: Supongamos que M tiene las instrucciones: q] write (u,p) qB


p'] read (u,q')
Cualquier string definido en T(p,p') también lo está en T(q,q'), como se expresa en la producción:
N(q,q')  N(p,p')

77
Las reglas de construcción se resumen en la Tabla 2. La Regla 5 relaciona "traverses" entre estados
iniciales y finales en M con strings derivables a partir de  en G, lo cual surge de lo establecido por la
proposición 3. La Regla 6 brinda la posibilidad de que G genere el string vacío , si es que  es aceptado por M.

Tabla 2: Construcción de una GLC a partir de un APDP


Dado: Un APDP M = (Q, S, U, PM, , F)
Construir: Una GLC G = (N, S, PG, ) tal que L(G) = L(M)
Sean: B =   { q' / q] write (u,q')  PM; q  Q, u  U } N = { N(q,q') / q  B, q'  Q }
Las producciones de G son las siguientes:
Regla Si M tiene luego G tiene
q] scan (s, q')
1 N(q,q')  s
qB
q"] scan (s, q')
2 N(q,q')  N(q,q")s
qB
q"] write (u, p)
3 p'] read (u, q') N(q,q')  N(q,q")N(p,p')
qB
q] write (u, p)
4 p'] read (u, q') N(q,q')  N(p,p')
qB
5 q  , q'  F   N(q,q')
6   F  

Durante el estudio de los AEF encontramos que, de las propiedades del diagrama de estados, surgen
naturalmente dos tipos de gramáticas. La construcción de gramáticas lineales por derecha se basa en una
secuencia de movimientos que vincula algún estado especificado con algún estado final. Similarmente, la
construcción de gramáticas lineales por izquierda se basa en una secuencia de movimientos que conectan
un estado inicial cualquiera con un estado especificado. Cualquiera de estos métodos podría generalizarse y
aplicarse a la construcción de gramáticas a partir de aceptores Push-Down.
En consecuencia, también es posible obtener una gramática libre de contexto a partir de un
procedimiento análogo al uso de conjuntos finales para el caso de gramáticas regulares. En este caso los
conjuntos "traverse" de interés son aquellos en los cuales el estado terminal está en el conjunto:
E = F  { q / q] read (u,q')  P; q’  Q, u  U }

5.4. Ejemplo de Construcción

Ejemplo 4: Construir una gramática a partir del APD Mdp que reconoce el lenguaje de doble paréntesis
Ldp (Esta es la misma máquina que fuera presentada en el Ejemplo 3).

1] scan ( ( ,2) ( [ ,3) ( ) ,4) ( ] ,5)


2] write (a,1)
3] write (b,1)
4] read (a,1)
5] read (b,1)
={1} F={1}

El estado 1 es el único estado inicial y además es el sucesor de las instrucciones de write. Por lo tanto:
B={1}
y los conjuntos "traverses" de interés son { T(1,1), T(1,2), T(1,3), T(1,4), T(1,5) }; tenemos entonces:
N = { N(1,1), N(1,2), N(1,3), N(1,4), N(1,5) }
Dado que el estado 1 de Mdp es además su único estado final, tenemos que L(Mdp) = T(1,1).
Las producciones resultantes de la aplicación de cada regla de la Tabla 2 son las siguientes:

78
Regla 1: 1] scan ( (, 2) N(1,2)  (
1] scan ( [, 3) N(1,3)  [
1B 1] scan ( ), 4) N(1,4)  )
1] scan ( ], 5) N(1,5)  ]

Regla 2: 1] scan ( (, 2) N(1,2)  N(1,1) (


1] scan ( [, 3) N(1,3)  N(1,1) [
1B 1] scan ( ), 4) N(1,4)  N(1,1) )
1] scan ( ], 5) N(1,5)  N(1,1) ]

Regla 3: 2] write (a, 1) N(1,1)  N(1,2) N(1,4)


4] read (a, 1)
1B 3] write (b, 1) N(1,1)  N(1,3) N(1,5)
5] read (b, 1)

Regla 4: No aplicable

Regla 5: 1;1F   N(1,1)

Regla 6:   F = { 1 }    

Por conveniencia emplearemos las siguientes letras mayúsculas simples para los símbolos no terminales:
N(1,1) = S N(1,2) = A N(1,3) = B N(1,4) = C N(1,5) = D

Gdp es: A( A  S( S  AC (1)


B[ B  S[ S  BD (2)
C) C  S) S
D] D  S] 

Relación entre las derivaciones de esta gramática y las secuencias de movimientos de Mdp

Secuencia de movimientos para el string de paréntesis [( )]( )

La gramática dada anteriormente puede ser colocada en una forma más entendible: Sustituiremos los no
terminales A, B, C y D en las producciones (1) y (2) por los correspondientes lados derechos de las
producciones obtenidas a partir de la aplicación de las reglas 1 y 2. Dado que hay dos alternativas para cada
uno de los no terminales, las producciones (1) y (2) se convierten en cuatro producciones cada una, con lo
cual la gramática Gdp es:
 S() S[]
S SS() SS[]
   S(S) S[S]
   SS(S) SS[S]

Esta no es la gramática más simple para Ldp; la siguiente gramática también genera Ldp:
 S  SS S(S) S[S]
S S() S[]

Sin embargo, esta gramática simple es ambigua, mientras que la gramática obtenida a partir de la Tabla
2 no lo es (se lo puede garantizar) dado que Mdp opera determinísticamente.

Teorema: Por cada APD M, se puede construir una gramática libre de contexto G, tal que L(G) = L(M).

79
II.- LENGUAJES LIBRES DE CONTEXTO

1. Introducción

El modelo libre de contexto para lenguajes fue propuesto por lingüistas para entender y caracterizar la
estructura de oraciones en lenguajes naturales. A pesar de que éste ha influido en el trabajo de investigación
que realizan los lingüistas, ha probado ser mucho más útil como modelo para lenguajes de programación
que como modelo para lenguajes naturales.
Durante el procesamiento de un programa escrito en un lenguaje de programación, un compilador actúa
en dos roles:
1.- Determina si un programa es sintácticamente correcto: Un compilador trata de construir una derivación
(descripción estructural) del programa de acuerdo a una gramática formal; en este rol el compilador actúa
como un aceptor.
2.- Produce un programa objeto: El compilador genera instrucciones de máquina a partir de la descripción
estructural del programa fuente; en este rol el compilador actúa como un traductor.
Por lo tanto, la elección de un método para realizar la descripción estructural del programa fuente –para
realizar el análisis sintáctico– es un problema central en el diseño de compiladores.
La caracterización de sentencias válidas de un lenguaje como el conjunto de strings generado por alguna
gramática libre de contexto ha sido un paso esencial en la aplicación de muchos métodos prácticos de
análisis sintáctico.

A continuación desarrollaremos un número de importantes resultados relacionados con Gramáticas


Libres de Contexto y los lenguajes que ellas definen. Comenzaremos con varias transformaciones sobre
Gramáticas Libres de Contexto (GLC) que no alteran el lenguaje generado. Estas transformaciones permiten
remover elementos no necesarios de la gramática y convertir gramáticas en formas canónicas bien
conformadas para análisis sintáctico.
Introduciremos algunas definiciones que serán usadas en los próximos temas a exponer.

Definición: Sea G una gramática libre de contexto. Se define:


- Regla A de G: Una producción A   A  N  {}
  (N  T)*
A  N  {}
- "Handle": El símbolo x en la producción A  x x  (N  T)
  (N  T)*
- Producción útil e inútil de G: Una producción A   es útil si G permite una derivación
 A  ,   T*; de otra forma es inútil
- No terminal útil e inútil de G: El no terminal A es útil si es la parte izquierda de una producción útil; de otra
forma, es inútil
- Producción no generativa de G: Una producción A  B A, B  N
- No terminal recursivo por izquierda en G: El no terminal A, si G permite una derivación A A
- No terminal recursivo por derecha en G: El no terminal A, si G permite una derivación A A
- No terminal cíclico de G: El no terminal A, si G permite una derivación A A

2. Transformaciones Gramaticales

Hasta este punto hemos considerado que dos gramáticas son equivalentes si ellas generan el mismo
lenguaje. Sin embargo, en el estudio de la transformación de gramáticas en nuevas formas, uno
frecuentemente está interesado en preservar la estructura del lenguaje generado, que está representado por
los árboles de derivación de sus sentencias. Por esta razón, no es siempre apropiado considerar dos
gramáticas como equivalentes simplemente porque ellas generan el mismo lenguaje.
Puede ocurrir, por ejemplo, que una gramática para un lenguaje permita muchas derivaciones de una
sentencia, mientras que otra gramática para el mismo lenguaje permita solamente una única derivación de
cada sentencia del lenguaje. Desde el punto de vista del análisis sintáctico, las dos gramáticas no pueden
considerarse equivalentes: una es ambigua, y la otra no. Para estudiar transformaciones gramaticales
aplicables a análisis sintáctico, necesitamos una noción de equivalencia que implique una correspondencia
entre derivaciones en gramáticas equivalentes.

80
2.1. Equivalencia de Gramáticas

El ejemplo siguiente muestra que dos gramáticas pueden generar el mismo lenguaje, aunque provean
estructuras sumamente diferentes a las sentencias del lenguaje.

Ejemplo 1: Consideremos las siguientes gramáticas G1 y G2:


G1:   S G2:   S
S  S01 S  S0S
S1 S1
Ambas gramáticas generan el lenguaje L = 1(01)*. Sin embargo, G2 es ambigua, mientras que G1 no lo es.
No deseamos considerar a G1 y G2 como estructuralmente equivalentes, ya que G2 asocia varios árboles de
derivación con las sentencias de L, mientras que G1 provee un único árbol de derivación para cada sentencia.

Una definición de equivalencia adecuada para transformaciones gramaticales debe igualar dos
gramáticas G1 y G2 solamente si la misma sentencia es no ambigua en cada gramática. Esto se puede
conseguir requiriendo que las derivaciones por izquierda de G 1 y G2 tengan una relación uno a uno para
cada sentencia generada. Este criterio puede ser muy estricto; sin embargo, existe cierta forma de
ambigüedad estructural que es trivial y fácilmente removible.

Ejemplo 2: Sean G1 y G2 dos gramáticas:


G1:   S G2:   S
S  S01 SS
S1 S  S01
S1
La gramática G2 tiene todas las producciones de G1 más la regla S  S. Ya que esta regla puede ser
aplicada un número arbitrario de veces a cualquier forma sentencial conteniendo S, cualquier string
generado por G2 tiene un número infinito de derivaciones distintas. Por el contrario G1 genera cada string
mediante una única derivación. A pesar de que G2 es muy ambigua, la única diferencia entre la descripción
estructural de una sentencia de acuerdo a las dos gramáticas es el número de repeticiones de ciertas
formas sentenciales en cada derivación.

Las derivaciones que contienen repeticiones de formas sentenciales surgen solamente cuando las
gramáticas contienen no terminales cíclicos. Ya que los no terminales cíclicos son no productivos, y
desearíamos eliminarlos –de hecho, vamos a hacerlo–, nuestro criterio de equivalencia debe permitir la
remoción de la ambigüedad asociada a ellos. De acuerdo a esto, dejamos las derivaciones por izquierda que
no contienen formas sentenciales repetidas como proveedoras de las descripciones estructurales correctas
de las sentencias generadas por una gramática.

Definición: Una derivación por izquierda permitida por una GLC es "mínima" si ninguna forma sentencial se
repite en la derivación.

Definición: Dos GLC, G1 y G2, son débilmente equivalentes si L(G1) = L(G2). Ellas son fuertemente
equivalentes si son débilmente equivalentes, y, para cada string terminal , las derivaciones por izquierda
mínimas de  en G1 pueden ser colocadas en correspondencia uno a uno con aquellas permitidas por G2.

En gramáticas que no contienen letras no terminales cíclicas cada derivación por izquierda es "mínima";
consecuentemente, la equivalencia fuerte depende solamente de la existencia de una correspondencia uno
a uno entre derivaciones por izquierda. El siguiente ejemplo ilustra un caso de equivalencia fuerte.

Ejemplo 3: Consideremos las gramáticas:


G1:   A G2:   A G3:   A
Aa Aa Aa
A  aB A  abcA A  aB
B  bC B  bC
C  cA C  cA
A  abcA
La gramática G2 se obtiene reemplazando las reglas de G1 usadas en la derivación:
A  aB  abC  abcA
por la regla: A  abcA
Claramente, L(G1) = L(G2) = a(bca)*.

81
Puede construirse una única derivación de un string terminal  de acuerdo a la gramática G1, a partir de
una derivación de  de acuerdo a G2 reemplazando cada aplicación de la regla A  abcA con aplicaciones
de las reglas:
A  aB B  bC C  cA
Por lo tanto G1 y G2 son fuertemente equivalentes.

Las producciones de la gramática G3 surgen a partir de la unión de las producciones de G 1 y G2. A pesar
de que L(G3) = L(G1) = L(G2), G3 no puede ser fuertemente equivalente a G1 o a G2, debido a que G3 es
ambigua mientras que G1 y G2 no lo son.

2.2. Transformación de Gramáticas

En muchas situaciones se considera conveniente modificar las producciones de la gramática, de manera


que éstas cumplan con propiedades tales como ser generativas, o bien simplemente por cuestiones de
estandarización o facilidad de implementación computacional. Desde luego, cuando hablamos de “modificar
las producciones de la gramática”, se entiende que esto debe hacerse sin modificar el lenguaje generado.

En las próximas secciones, presentaremos siete transformaciones elementales aplicables a GLC:


1.- Sustitución
2.- Expansión
3.- Eliminación de producciones inútiles
4.- Eliminación de producciones no generativas
5.- Factorización por izquierda
6.- Eliminación de producciones 
7.- Eliminación de no terminales recursivos por izquierda

Para cada transformación, describiremos las condiciones bajo las cuales la gramática transformada es
equivalente a la original.

2.2.1. Sustitución

Una gramática es transformada por sustitución cuando, para algún símbolo no terminal B, en las partes
derechas de las reglas que contienen a B, se lo sustituye por las partes derechas de las reglas B.
Sea G una gramática libre de contexto arbitraria, la cual tiene las producciones:
A  B donde B  N y ,   (N  T)*
B  1, … , B  n
La transformación de G por sustitución de B en la producción A  B, es la gramática G' obtenida de
acuerdo a las siguientes reglas:

Regla 1: La producción A  B no está en G'.


Regla 2: Todas las otras producciones de G están en G'.
Regla 3: Cada producción A  i está en G' con i = 1, 2, …, n.

Ejemplo 4: Consideremos las gramáticas:


G1:   S G2:   S S  ST
S  TT TS S  aSbT
TS T  aSb S  cT
T  aSb Tc
Tc
La gramática G2 es el resultado de sustituir en la regla S  TT de G1 el T situado más a la izquierda por
la parte derecha de las reglas T. La producción S  TT se omite en G2, pero las reglas T de G1 se
conservan. La gramática G2 es fuertemente equivalente a G1. Se puede verificar, por ejemplo, que el string
accbcc tiene dos derivaciones por izquierda en cada gramática.

Si una producción introducida por sustitución es un duplicado de una producción en la gramática original,
la gramática transformada no será necesariamente fuertemente equivalente a la original.

82
Sea G una GLC, la cual tiene las producciones:
A  B
B
A  
y se sustituye B en la primera regla A, se obtienen las producciones:
A  
B
en la gramática transformada G'. Mientras G permite dos derivaciones de la forma sentencial  desde A:
A  B  
A  
la segunda de estas es la única derivación permitida en G'.

Proposición 1: Sea G una gramática libre de contexto, y sea G' generada a partir de G por sustitución de
alguna letra no terminal B en una producción A  B de G. Si ninguna producción de G' introducida por la
Regla 3 de sustitución ha sido previamente introducida a través de la Regla 2, G y G' son fuertemente
equivalentes.

2.2.2. Expansión

La operación inversa a la sustitución es la expansión.

Sea G una GLC arbitraria, la cual tiene la producción:


A   donde ,   (N  T)* - 
y se la reemplaza con:
A  X X  
ó A  X X
en donde X es un nuevo símbolo no terminal. En este caso, se expandió la regla A  .

Si una gramática G' es obtenida a partir de G por expansión de la regla A  , entonces G puede ser
obtenida a partir de G' por sustitución de X en la regla A  X o A  X. Por lo tanto, la Proposición 1 y la
simetría de equivalencia fuerte muestran que G y G' son fuertemente equivalentes.

Proposición 2: Si la gramática G' se obtiene a partir de la gramática libre de contexto G por expansión de
alguna regla de G, G y G' son fuertemente equivalentes.

2.2.3. Eliminación de Producciones Inútiles

Una gramática puede contener producciones y no terminales que no son útiles ya que no aparecen en la
derivación de ningún string terminal.

Definición: Sea G = (N, T, P, ) una GLC cualquiera. Una producción A   de G es útil si G permite una
derivación:
 A    T*
De otra forma A   es inútil. Un no terminal de G es útil si es la parte izquierda de una producción útil;
de otra forma, es un no terminal inútil.

Una producción útil de una GLC G puede ser identificada mediante la aplicación de dos procedimientos
de marcado.

El procedimiento de marcado T, realizado en primer lugar, marca cada regla de G con el símbolo T sólo
si hay una derivación de un string terminal usando la regla en el primer paso. El conjunto de las
producciones marcadas con T se denomina PT. En términos de PT, el conjunto NT de no terminales
conectados a terminales es:
NT = { A  N  {  } / hay una regla A   en PT }
Obviamente, cada producción útil de G debe estar en PT, y cada símbolo no terminal útil debe estar
conectado a un terminal. Sin embargo, el hecho de que una producción esté en PT o un símbolo esté en NT
no es en sí mismo suficiente para asegurar que el símbolo o la producción sean útiles; un no terminal A en
NT o una producción A   en PT no son útiles a menos que una forma sentencial conteniendo el símbolo A
pueda ser derivada a partir de . Para determinar si esa forma puede ser derivada, realizaremos un segundo
procedimiento de marcado.

83
El procedimiento de marcado  marca cada regla A   en PT con el símbolo  si G permite la derivación:
 A
usando solamente reglas contenidas en PT. En tal caso, G debe permitir una derivación:
 A    T*
ya que cada no terminal en  o en  está conectado en T. Si denominamos P al conjunto de producciones
marcadas con , éste contiene las producciones útiles de la gramática.

Los procedimientos de marcado T y marcado  se llevan a cabo de la siguiente forma:

Procedimiento de marcado T:
Sea G una gramática libre de contexto con producciones P. Se construye una secuencia P 0, P1, … de
subconjuntos de P y una secuencia N0, N1, … de subconjuntos de N de acuerdo al siguiente algoritmo:
1.- Sean P0 = N0 = ; i = 0
2.- Sea Pi+1 = { A    P /   (Ni  T)* }
3.- Sea Ni+1 = { A  N / A   Pi+1 }
4.- Si Pi+1  Pi, sea i = i+1; ir al paso 2
5.- Sea PT = Pi+1 y NT = Ni+1

Cada conjunto Pi contiene los no terminales de G a partir de los cuales se puede derivar un string
terminal en i pasos. Dado que Pi  Pi+1  P para cada i, el procedimiento terminará luego de un número de
pasos no mayor que el número de producciones en G.
Dado que   (Ni  T)*, cuando i = 0, N0 = , lo cual implica que necesariamente deberá cumplirse que
  T*, o sea  es un string de símbolos terminales.

Procedimiento de marcado :
Sea G una GLC, y sea PT el conjunto de producciones marcadas con T. Se construye una secuencia Q 1,
Q2, … de subconjuntos de PT de acuerdo al siguiente algoritmo:
1.- Sea Q1 = {     PT }; i = 1
2.- Sea Qi+1 = Qi  { A    PT / B  A  Qi }
3.- Si Qi+1  Qi, sea i = i+1; ir al paso 2
4.- Sea P = Qi+1

Cada conjunto Qi contiene una producción A   sí y sólo sí G permite una derivación:


 A
en no más de i pasos, usando solamente producciones que están en PT. Dado que Qi  Qi+1  PT este
procedimiento se detiene luego de un número de etapas no mayor que el número de producciones en PT.

Hemos visto que una producción es útil si la misma está en P luego de que los procedimientos de
marcado se han terminado. Dado que los procedimientos de marcado T y  terminan siempre en un número
finito de etapas, podemos establecer la siguiente proposición:

Proposición 3: Sea G una gramática libre de contexto con producciones P. Para cada producción en P, es
posible decidir si la producción es o no útil en G.

Ejemplo 5:
Reglas T  Marcado T:
P0 = N0 = 
(1) S T2 1 P1 = { S  a } N1 = { S }
(2) S  AB P2 = P1  {   S, S  aS, B  Sb } N2 = { , S, B }
P3 = P2 = PT
(3) S  aS T2 2
(4) Sa T1 2 Marcado :
(5) B  Sb T2 Q1 = {   S }
Q2 = Q1  { S  aS, S  a }
(6) C  aC Q3 = Q2 = P

Mediante la aplicación del procedimiento de marcado T, marcamos las producciones en el orden 4, 1, 3 y


5. Los no terminales conectados con T son NT = { , S, B }
El procedimiento de marcado  marca la regla 1, y luego las reglas 3 y 4. Luego, las reglas útiles de G son:
P = {   S, S  aS, S  a }

84
La regla 5 es un ejemplo de una regla inútil cuya parte izquierda está conectada con T. Si bien B es
derivable a partir de  mediante   S  AB, la regla 5 es inútil porque A no está conectada con T, y por lo
tanto ningún string terminal se deriva a partir de AB.

El procedimiento de marcado T nos da una forma de verificar si L(G,A) es vacío para algún A que
pertenezca a N  {  }. Por cierto, L(G,A) es no vacío sí y sólo sí A está en NT. Es decir, tenemos el
siguiente resultado:

Teorema de Test de Vacío: Para cualquier GLC G es posible decidir si L(G,A) =  para cualquier símbolo no
terminal A perteneciente a G. En particular, es posible decidir si la gramática genera o no strings (es decir, si
L(G) = L(G, ) = ).

Es trivialmente cierto que al borrar las producciones inútiles de una gramática obtendremos una nueva
gramática que es fuertemente equivalente a la original.

Proposición 4: Si la gramática G' se obtiene a partir de una GLC G mediante la eliminación de producciones
inútiles, G y G' son fuertemente equivalentes.

2.2.4. Eliminación de Producciones No Generativas

La eliminación de las producciones no generativas de una GLC también elimina los no terminales cíclicos
y es una etapa importante en la conversión de una gramática a una forma canónica. El procedimiento es
similar al que se emplea para remover las transiciones  de un aceptor de estados finitos. Se empleará la
siguiente gramática para ilustrar el principio empleado.

Sea G la gramática con producciones:


A AB A  aB
B AC Ab
BC C  Aa
CB
y consideremos las secuencias de pasos de derivación consecutivos no generativos de G. Estas secuencias
pueden ser representadas por un aceptor de estados finitos M(G) que tiene un estado por cada no terminal
que aparece en una producción no generativa de G, como se muestra en la siguiente figura:
M(G):

Figura 1: AEF que representa las producciones no generativas de la gramática G

Cada una de las transiciones  corresponde a una producción no generativa de G, y los estados iniciales
y finales corresponden a los no terminales en los cuales comienza y termina una serie de pasos no
generativos. Por ejemplo, M(G) tiene un paso o camino desde el estado inicial A al estado final C por medio
de la secuencia de estados:
ABCBC
Esto indica que la siguiente secuencia de pasos:
A  B  C  B  C
puede ocurrir en una derivación permitida en G, tal que la secuencia es precedida y seguida de una serie de
pasos generativos. Este es el caso de la derivación:
  A  B  C  B  C  Aa  ba
que permite derivar el string terminal ba.

Sea G = (N, T, P, ) una GLC con producciones no generativas; se construye un AEF que representa
dichas producciones:
M(G) = (Q, , P', , F)
donde
Q = { A / A  B ó B  A  P; A, B  N }
P' = { A B / A  B  P }
 = { A  Q /   A  P, ó B  A  P con    }
F = { A  Q / A    P con   N }

85
Debemos notar que los conjuntos  y F incluyen todas las letras no terminales en las cuales pueden
empezar o terminar, respectivamente, secuencias de pasos de derivación no generativos, incluyendo
también la secuencia vacía.

Se puede obtener una gramática equivalente a G, pero sin producciones no generativas, reescribiendo
cada producción generativa como varias producciones que incorporan el efecto de los pasos de derivación
no generativos que son permitidos por G.

La nueva gramática G' = (N', T, P", ) incluye un nuevo no terminal por cada secuencia de pasos no
generativos que puede ocurrir como parte de alguna derivación mínima en G. Cada una de estas secuencias
se corresponde con un camino libre de ciclos en M(G) desde algún estado inicial X hasta algún estado final Y:
N' = N  { [X1 X2 … Xn] ; X1=X  , Xn=Y  F, Xi  Xj para i  j, y X1 X2 … Xn en M(G) }
 { [X X] ; X    F }

Las reglas de la nueva gramática se diseñan de tal forma que cada vez que la secuencia de pasos de
derivación:
A  X  …  Y  
es permitida en una derivación mínima de G, la secuencia:
A  [X … Y]  
es permitida en G'.
Las producciones de G' están dadas por las siguientes reglas de construcción:
Regla 1: Cada producción de G que no contiene símbolos correspondientes a estados de Q está en G'.
Regla 2: Por cada producción generativa de G:
A  0 B1 1 … Bk k k1 donde Bi  Q ; 1  i  k
G' contiene producciones:
U  0 V1 1 … Vk k
donde:
{ A } si A  F
U
{ [X … Y] / Y = A } si A  F

{ Bi } si Bi  
Vi 
{ [X … Y] / X = Bi } si Bi  

Ejemplo 6: Consideremos la gramática G, que da lugar a la máquina M(G) vista en la figura anterior. Vemos
que los nuevos no terminales requeridos son:
N’ = { [AA], [AC], [BC], [ABC] }
Siguiendo las reglas de construcción, no se retiene ninguna producción de G en la nueva gramática,
debido a que en cada una de ellas aparece algún no terminal que se corresponde con algún estado de Q.
Las producciones de G' resultantes de la aplicación de la Regla 2 son:

  [AA] [AC]  [AA]a


  [AC] A [AC]  [AC]a
  [ABC] [AC]  [ABC]a
[ABC]  [AA]a
  [BC] B [ABC]  [AC]a C  Aa
[ABC]  [ABC]a
[AA]  a[BC] A  aB [BC]  [AA]a
[BC]  [AC]a
[AA]  b Ab [BC]  [ABC]a

Dada cualquier derivación mínima permitida por G, es posible construir la derivación correspondiente de
acuerdo a G', reemplazando cada secuencia (incluyendo la secuencia vacía) de pasos no generativos con
un solo paso que identifique la secuencia reemplazada.

86
Por ejemplo, G permite la derivación:
  B  C  Aa  Ba  Ca  Aaa  baa
[BC] [ABC]a [AA]a
que contiene tres secuencias de pasos no generativos (la última vacía), separadas en cada caso por un
paso generativo. La derivación correspondiente de acuerdo a G' es:
  [BC]  [ABC]a  [AA]aa  baa

La correspondencia de derivaciones ilustrada en este ejemplo se aplica a las gramáticas G y G' toda vez
que G' es obtenida a partir de G de acuerdo a las reglas dadas.

Proposición 5: Para cualquier gramática libre de contexto G es posible construir una gramática fuertemente
equivalente G' que no contenga producciones no generativas.

2.2.5. Factorización por izquierda

Un problema común cuando se diseña una gramática es el hecho de que aparezcan producciones de un
mismo símbolo no terminal en cuya parte derecha, la primera parte sea común. Por ejemplo, la siguiente es
una gramática para el lenguaje de doble paréntesis Ldp visto con anterioridad:
 (1) S  ( S ) (3) S  [ S ]
S (2) S  ( ) (4) S  [ ]
S  SS
donde las producciones (1) / (2) y (3) / (4) tienen un prefijo común en su partes derechas.

Este problema presenta dificultades al momento de diseñar un compilador para el lenguaje definido por
esa gramática. La transformación a aplicar se denomina factorización por izquierda, que consiste en añadir a
la gramática nuevos no terminales, que generan “lo que sigue después de la parte común”, cuyo
procedimiento se detalla a continuación:
Por cada A  N:
Si A   1, …, A   k k > 1
Cambiar esas producciones por:
A   A’
A’  1, …, A’  k k1

Ejemplo 7: En la gramática presentada, la aplicación de la factorización por izquierda crearía dos nuevos
símbolos no terminales, S’ y S”, y generaría las siguientes producciones:
 S  ( S’ S  [ S”
S S’  S ) S”  S ]
S  SS S’  ) S”  ]

Puesto que la factorización por izquierda se basa exclusivamente en la transformación de expansión, de


acuerdo a la Proposición 2 podemos afirmar que la gramática obtenida es siempre fuertemente equivalente
a la original.

2.2.6. Eliminación de producciones 

Está ampliamente aceptada en la bibliografía la siguiente definición no estricta de las Gramáticas Libres
de Contexto:

Definición: Una gramática libre de contexto G = (N, T, P, ) es una gramática formal en la cual todas las
producciones poseen la siguiente forma: A   donde A  N  {  } y   (N  T)*

Se puede notar que se eliminó la restricción    para las partes derechas de las producciones; como
consecuencia, podría pensarse que se agrega potencia de generación de lenguajes a este tipo de
gramáticas, pero no es así: siempre es posible obtener una gramática equivalente sin producciones  (salvo
que el string vacío pertenezca al lenguaje, en cuyo caso sólo el símbolo distinguido derivará en lambda).
A continuación se verá el procedimiento que permite convertir tales gramáticas a la forma estricta que se
vió en la Jerarquía de Chomsky.

87
Definición: Una producción de la forma A   se llama producción . Un no terminal A se llama anulable si
A 

Procedimiento de identificación de no terminales anulables:


Sea G una gramática libre de contexto con producciones P. Se construye una secuencia ANUL0, ANUL1,
… de subconjuntos de N de acuerdo al siguiente algoritmo:
1.- Sea ANUL1 = { A  N / A    P }; i = 1
2.- Sea ANULi+1 = ANULi  { A  N / A    P;   (ANULi)* }
3.- Si ANULi+1  ANULi, sea i = i+1; ir al paso 2
4.- Sea ANUL = ANULi+1

Cada conjunto ANULi contiene los no terminales de G a partir de los cuales se puede derivar  en i
pasos. Dado que ANULi  ANULi+1  N para cada i, el procedimiento terminará luego de un número de
pasos no mayor que el número de no terminales en G.

Proposición 6: Dada una gramática libre de contexto G es posible construir una gramática equivalente G' sin
producciones , excepto (posiblemente)   .

Una vez determinado el conjunto ANUL de no terminales anulables por medio del procedimiento anterior,
se pueden eliminar las producciones  (excepto   , si   ANUL) añadiendo nuevas producciones que
simulen el efecto de las producciones  eliminadas. Más concretamente, por cada producción A   de G
se añaden producciones de la forma A   obtenidas suprimiendo de la cadena  uno, dos o más no
terminales anulables presentes, de todas las formas posibles.
La gramática G’ así obtenida es equivalente a la gramática original G. Para el análisis de la preservación
o no de la equivalencia fuerte, se debe considerar que de hecho se está llevando a cabo una transformación
por sustitución, lo que conduce a la aplicación de la Proposición 1 a este efecto.

Para ejemplificar el procedimiento descripto, considérese la siguiente gramática que genera el lenguaje
de paréntesis Lp:
Gp: S
S  (S) | SS | 
A fin de obtener una GLC equivalente, pero sin producciones  (como S  ), se analizará “en reversa” la
derivación que dio origen al no terminal S que se quiere cambiar por .
En el caso de S  (S), la solución sería, en vez de hacer la derivación
  …  S  (S)  ( )
hacer directamente la derivación
  …  S  ( )
agregando una producción S  ( ) a la gramática.
En caso de que la S provenga de la producción S  SS, se puede cambiar la derivación
  …  S  SS  S
por la derivación
  …  S  S
usando una nueva producción S  S, o mejor aún, simplemente reemplazarla por
  …  S
sin ninguna producción adicional (la parte de la derivación S  SS  S desaparece por completo,
pues no sirve de nada).
Finalmente, la producción   S daría lugar a la inclusión de la producción    en Gp.

Resumiendo, la idea que permite eliminar las producciones A   es la de irse un paso atrás, para
examinar de dónde provino el no terminal A que se quiere eliminar, y por cada producción B  A de la
gramática agregar una producción B   , en que directamente ya se reemplazó A por . Una vez hecho
esto, se pueden suprimir todas las producciones de la forma A  , pues resultan redundantes.

Aplicando la transformación descripta a Gp, se tiene:


G’p: S|
S  (S) | SS | ( ) | S
La producción S  S es evidentemente no generativa y será eliminada mediante la transformación
correspondiente.

88
A continuación se formaliza el procedimiento ejemplificado, que debe ser aplicado luego de la
identificación de los no terminales anulables mediante el algoritmo dado:

Procedimiento de eliminación de producciones :


Sea G una gramática libre de contexto con producciones P y no terminales anulables ANUL. Se obtiene
una nueva gramática G’ sin producciones  mediante la aplicación del siguiente algoritmo:
1.- Para cada producción de G de la forma
A  1 … n donde i  (N  T)
se agregan producciones de la forma
A  1 … n donde

{ i } si i  ANUL
i 
{ i ,  } si i  ANUL
2.- Se eliminan todas las producciones  de P
2

3.- Si   ANUL, luego     G’

Ejemplo 8: G:   ABb | ABC


A  aA | 
B  bB | 
C  abC | AB
Mediante la aplicación del procedimiento enunciado, se identifican los no terminales anulables de G:
ANUL1 = {A, B}
ANUL2 = {A, B}  {C} = {A, B, C}
ANUL3 = {A, B, C}  {} = {A, B, C, }
ANUL4 = ANUL3
ANUL = {A, B, C, }
Luego se procede a la eliminación de las producciones  mediante la aplicación del procedimiento
respectivo:
En G En G’
  ABb   ABb | Ab | Bb
  ABC   ABC | AB | AC | BC | A | B | C | 
A  aA A  aA | a
A se elimina
B  bB B  bB | b
B se elimina
C  abC C  abC | ab
C  AB C  AB | A | B

Ejemplo 9: G:   AB | ACA | ab
A  aAa | B | CD
B  bB | bA
C  cC | 
D  aDc | CC | ABb
Mediante la aplicación del procedimiento enunciado, se identifican los no terminales anulables de G:
ANUL1 = {C}
ANUL2 = {C}  {D} = {C, D}
ANUL3 = {C, D}  {A} = {C, D, A}
ANUL4 = {C, D, A}  {} = {C, D, A, }
ANUL5 = ANUL4
ANUL = {C, D, A, }

2
Inclusive aquellas que pudieran haberse agregado como consecuencia de la aplicación del paso anterior, cuyo efecto ya
fue contemplado en el proceso de identificación de no terminales anulables.
89
Al eliminar las producciones  (la única es C  ) se obtiene la siguiente gramática equivalente a G:
G’:   AB | ACA | ab | B | CA | AA | AC | A | C | 
A  aAa | B | CD | aa | C | D
B  bB | bA | b
C  cC | c
D  aDc | CC | ABb | ac | C | Bb

3. Formas Canónicas de Gramáticas

Mediante la introducción de condicionamientos en la forma de las producciones permitidas, es posible


definir formas restringidas de gramáticas libres de contexto. Si a partir de una gramática libre de contexto
arbitraria podemos obtener una gramática fuertemente equivalente con la original, y además ésta tiene una
determinada forma restringida, la misma será una forma canónica de la gramática libre de contexto.
Las formas canónicas de gramáticas libres de contexto son útiles por dos razones:
1.- Generalmente es más fácil establecer ciertas propiedades en las formas canónicas de las gramáticas,
que en las GLC en general.
2.- La representación de LLC mediante gramáticas en forma canónica puede facilitar la generación de
métodos de análisis sintáctico de los lenguajes.

Las gramáticas bien conformadas constituyen formas canónicas de GLC que simplifican el estudio
mediante la remoción de producciones inútiles y no generativas. Otras dos formas canónicas de gran
importancia son: (1) la gramática en forma normal, que es particularmente útil para el trabajo teórico debido
a que las producciones se restringen a un número muy pequeño de formas simples; (2) la gramática en
forma stándard, que es una forma útil para el análisis sintáctico ya que no permite recursión por izquierda.

Es importante notar que la forma de Backus-Naur (FBN) empleada frecuentemente para representar la
sintaxis de los lenguajes de programación no es una forma canónica de GLC; ésta es simplemente una
notación alternativa para las producciones de una GLC no restringida.

3.1. Gramáticas Bien Conformadas

Nuestra habilidad para remover producciones inútiles y no generativas de una gramática libre de contexto
motiva la siguiente definición:

Definición: Una GLC G = (N, T, P, ) es bien conformada si cada producción tiene una de las siguientes
formas:
 AN
A   (N  T)* - N
A y cada producción es útil

La ausencia de producciones no generativas en una gramática bien conformada asegura que cada
derivación por izquierda que dicha gramática permita sea mínima.

Sea G una GLC arbitraria. A partir de la Proposición 2 sabemos que podemos expandir todas las reglas 
en la forma   A y obtener una gramática fuertemente equivalente a la original. Por medio de la
Proposición 5 podemos transformar esta gramática en una gramática sin reglas no generativas, fuertemente
equivalente con la original. En base a la Proposición 4, las reglas inútiles pueden ser omitidas,
produciéndose una gramática fuertemente equivalente a la original. Por lo tanto, podemos enunciar el
siguiente teorema:

Teorema 1: Dada una GLC G cualquiera, es posible construir una gramática bien conformada que sea
fuertemente equivalente a G.

En una gramática bien conformada, cada producción que sea distinta de las reglas , tiene la siguiente
forma:
A   donde |  | > 1 ó   T* - 
Por lo tanto, cada paso de derivación que no sea el primero, incrementa la longitud de la forma sentencial
o genera al menos un símbolo del string terminal. De aquí se infiere que la derivación de un string terminal
de longitud n requiere como máximo 2n pasos de derivación. Estas derivaciones consisten de un paso
inicial, de un máximo de n-1 pasos que generan n instancias de no terminales y de un máximo de n pasos
que reemplazan los no terminales con símbolos terminales.

90
3.2. Gramáticas en Forma Normal (GFN)

Esta forma canónica es también llamada "Forma Normal de Chomsky".

Definición: Una GLC G = (N, T, P, ) está en forma normal si cada producción tiene una de las siguientes
formas:
 A  BCA, B, C  N
A Aa aT

En una GFN, cada paso de derivación (excepto el primero) extiende en uno la longitud de la forma
sentencial o bien genera un símbolo terminal; consecuentemente, la derivación de un string terminal de
longitud n requiere exactamente 2n pasos de derivación.

Sea G = (N, T, P, S) una GLC arbitraria; de acuerdo al Teorema 1, no hay restricción para asumir que G
es bien conformada. Por lo tanto, cada producción de G tiene una de las siguientes formas:
 A AN
A Aa   (N  T)*, |  |  2
aT

Es posible construir una gramática G' expandiendo cada regla A  , con |  | > 2, en un conjunto de
reglas cuyas partes derechas contienen dos símbolos. Por cada regla:
A  X1 X2 … X k k>2 donde Xi  N  T
se introducen k-2 nuevos no terminales:
[X2 … Xk ], [X3 … Xk ], …, [Xk-1 Xk ]
y se la reemplaza con el conjunto de producciones:
A  X 1 [ X2 … Xk ]
[ X2 … Xk ]  X 2 [ X3 … Xk ]

[ Xk-1 Xk ]  Xk-1 Xk

La aplicación de la producción A  X1 X2 … Xk en
G se corresponde con la aplicación de estas nuevas
producciones en secuencia, tal como se muestra en
la Figura 2.

Figura 2: Conversión a la Forma Normal

Cada regla de G' se encuentra ahora en una de las siguientes formas:


 A  bC
A A  Bc A, B, C  N
A  BC A  bc a, b, c  T
Aa

Las producciones en la columna izquierda están en forma normal, pero aquellas en la columna derecha
no se encuentran en esa forma. Estas producciones pueden ser colocadas en forma normal por medio de la
introducción de un no terminal [x] por cada terminal x en la gramática, y reemplazando por [x] cada
ocurrencia de x en dichas reglas. Luego se introduce la regla [x]  x en G'.

Por ejemplo, una producción de la forma A  bC se convierte en la producción A  [b]C con la


producción [b]  b incluida en la gramática.

De acuerdo a la Proposición 2 las alteraciones que hemos descripto producen una nueva gramática
fuertemente equivalente a la original. Por lo tanto, tenemos el siguiente teorema:

Teorema 2: Dada una GLC cualquiera, es posible construir una gramática G' en forma normal tal que G y G'
son fuertemente equivalentes.

91
Ejemplo 10: Sea G la siguiente gramática bien conformada:
G:  B  bCb 
A Aa
A  ABBA  Cc
Las producciones marcadas con asterisco () no están en forma normal; aplicando la primera parte del
procedimiento se obtiene la gramática G’:
G':  B  b[Cb] 
A [Cb]  Cb 
A  A[BBA] Aa
[BBA]  B[BA] Cc
[BA]  BA
Cada producción de G' se encuentra en forma normal excepto las marcadas; aplicando la segunda parte
del procedimiento se obtiene la gramática G" en forma normal, fuertemente equivalente a G:
G”:  B  [b][Cb]
A [Cb]  C[b]
A  A[BBA] [b]  b
[BBA]  B[BA] Aa
[BA]  BA Cc

Esta forma normal, aparentemente tan arbitraria, provee un mecanismo sencillo para realizar el análisis
sintáctico de un string de entrada, siguiendo la estrategia siguiente: Se trata de construir el árbol de
derivación de  de arriba hacia abajo (“top-down”), partiendo de la suposición de que el símbolo inicial 
puede producir el string . Se procede entonces a dividir el string  en dos partes,  = , para luego tomar
alguna regla S  AB (tal que   S), y tratar de verificar si se puede derivar  a partir de A y  a partir de B,
es decir:   sí y sólo sí:
1.   T, hay una regla S  
2.  = , hay una regla S  AB, con A , y B 

Por ejemplo, considérese la siguiente gramática para el lenguaje de paréntesis Lp, en forma normal de
Chomsky:
S S  SS Y  SZ
S  XY X(
S  XZ Z)
Sea  = (())(), y queremos verificar si se puede derivar a partir de esta gramática. Hay que dividir el string
en dos partes, y elegir alguna regla que produzca dos no terminales. Elegimos la regla S  SS, y dividimos
el string en (()) y (). Para que SS pueda generar (())() ahora se necesitará que la primera S pueda generar
(()), y la segunda pueda generar (). Estos son subproblemas muy similares al problema inicial. Tomemos el
primero, es decir, a partir de S generar (()). Tomamos la regla S  XY, y dividimos el string en ( y ()). Ahora
X tiene la responsabilidad de generar ( y Y la de generar ()). Por la regla X  (, X genera directamente (.
Ahora tomamos el problema de generar ()) a partir de Y. Elegimos la regla S  SZ, y la separación en las
partes () y ). Entonces Z produce directamente ), y queda por resolver cómo S produce (). Para ello,
elegimos la regla S  XZ, y finalmente X produce ( y Z se encarga de ). El mismo procedimiento se aplica a
la generación de () a partir de la segunda S, con lo que se termina el análisis. El árbol de derivación se
presenta en la siguiente figura.

S S

X Y X Z

( S Z ( )

X Z )

( )

92
Esta manera de generar dos nuevos problemas similares al problema inicial, pero con datos más
pequeños, es típicamente un caso de recursión. Este hecho permite pensar en un sencillo procedimiento
recursivo para “compilar” strings de un lenguaje libre de contexto.

Sea CC(A, ) la función que verifica si A . Entonces un algoritmo de análisis sintáctico sería el
siguiente:
CC(A, ):
1. Si || > 1, dividirlo en  y ,  = ;
Para cada regla de la forma A  UV, intentar CC(U, ) y CC(V, )
2. Si || = 1, buscar una regla A  .

Si en el punto 1 la división del string no nos llevó a una compilación exitosa (es decir, los llamados
recursivos CC(U, ) y CC(V, ) no tuvieron éxito), puede ser necesario dividir el string de otra manera. Dicho
de otra forma, puede ser necesario ensayar todas las formas posibles de dividir un string en dos partes,
antes de convencerse de que éste pertenece o no a nuestro lenguaje. Aún cuando esto puede ser muy
ineficiente computacionalmente, es innegable que el algoritmo es conceptualmente muy sencillo.

En verdad, una de las razones más importantes para el estudio de la forma normal de Chomsky es que
ésta permite aplicar el algoritmo de análisis sintáctico de CYK (Cocke-Younger-Kasami), del cual el algoritmo
presentado es una versión extremadamente simplificada. Dado un string  y una GLC G (en forma normal
de Chomsky), el algoritmo de CYK permite decidir si   L(G).
[Anexo]

3.3. Gramáticas en Forma Stándard (GFS)

Esta forma canónica es también llamada "Forma Normal de Greibach".

Las GLC en forma stándard tienen letras terminales como handle de todas las reglas. Debido a que no
son recursivas por izquierda, juegan un rol muy importante en el análisis sintáctico; la recursión por izquierda
es imposible en una GFS ya que la gramática no permite ninguna derivación del tipo A A.

Definición: Una GLC G = (N, T, P, ) está en forma stándard si cada producción tiene una de las siguientes
formas:
 AN
A   (N  T)*
A  a aT

En una GFS, cada paso de las derivaciones (a excepción del primero) tiene la siguiente forma:
 A    a 
y genera al menos un símbolo terminal. Por lo tanto, la derivación de un string terminal de n símbolos
requiere como máximo n+1 pasos de derivación.

Supongamos que queremos construir una gramática en forma stándard a partir de una GLC G arbitraria.
Según el Teorema 1, podemos asumir que G es bien conformada sin perder generalidad. Cada producción
de G tendrá entonces una de las formas:
 A  a A, B  N
A A  B ,   (N  T)*,   
aT

Cada uno de estos tipos de producción se encuentra en forma stándard excepto las del tipo A  B. Por
lo tanto, nuestra tarea será reemplazar producciones que tengan handles no terminales mediante
producciones en forma stándard.

3.3.1. Conversión de producciones a forma stándard por sustitución

Sea A un símbolo no terminal de G y consideremos una derivación por izquierda a partir de A en la cual
en cada paso de derivación se aplica una producción que tiene un símbolo no terminal Xi como su handle:
A  X11  X221  … Xkk … 21  … (1)
Esta derivación debe ser finita en longitud a menos que algún símbolo no terminal se repita en la
secuencia A, X1, X2, …, Xk, …

93
Ahora si Xi = Xj = X para algún i < j, la derivación:
X X j … i+1
es permitida y X es un símbolo no terminal recursivo por izquierda en G. Por otro lado, si G no tiene
símbolos no terminales recursivos por izquierda, cada derivación del tipo (1) producirá una forma sentencial
que comienza con una letra terminal en un número finito de pasos. En este caso, un número finito de
sustituciones convertirán las reglas de G en forma stándard.

Ejemplo 11: Sea la gramática G1 sin no terminales recursivos por izquierda:


G1: A A  Ba B  Cb C  cC
A  Ca B  CAb Cc

Se obtiene G2 por sustitución de los handles correspondientes a las reglas A:


A  Cba A  cCa
A  Ba se transforma en A  Ca se transforma en
A  CAba A  ca
El resultado es la gramática G2:
G2: A A  Cba  B  Cb C  cC
A  CAba  B  CAb Cc
A  cCa
A  ca inútiles
Las reglas B son ahora inútiles y pueden ser borradas. Dos de las nuevas reglas A no están en forma
stándard, pero una segunda sustitución completa la conversión. El resultado es la gramática G 3:
G3: A A  cCba A  cba
A  cCAba A  cAba
A  cCa C  cC
A  ca Cc

La construcción de la gramática G3 a partir de G1 satisface la condición de la Proposición 1: "Ninguna


producción transformada duplica una producción previamente existente en la gramática". Por lo tanto G 3 es
fuertemente equivalente a G1.

La figura siguiente muestra bajo la forma de un árbol las sustituciones empleadas en la construcción de
G3. Los caminos en el árbol representan derivaciones por izquierda desde A en G 1, que contienen al menos
una aplicación de una producción con un handle no terminal, y finalizando con la aplicación de una
producción en forma stándard:
A

Ba Ca

Cba CAba cCa ca

cCba cba cCAba cAba


Figura 3: Conversión de Producciones a Forma Stándard por Sustitución

Los reemplazos de las reglas A con producciones en forma stándard son equivalentes a introducir en la
nueva gramática reglas A cuya parte derecha son las formas sentenciales que rotulan las hojas del árbol.

Si la gramática a ser convertida en una GFS tiene símbolos no terminales recursivos por izquierda, el
procedimiento empleado en el Ejemplo 11 nunca terminará.

3.3.2. Eliminación de no terminales recursivos por izquierda

En el siguiente ejemplo ilustraremos el principio usado en la eliminación de no terminales recursivos por


izquierda.

Ejemplo 12: Consideremos la siguiente gramática:


G1:   A
A  AbA
Aa

94
la cual tiene la producción recursiva por izquierda A  AbA. Por simplicidad, llamaremos A al conjunto
L(G,A). De acuerdo a la gramática, A debe satisfacer la siguiente ecuación, a la que aplicaremos la regla de
Arden para obtener una expresión alternativa para A:
A = A(bA)  a X = XP  Q
A = a(bA)* X = QP*
A = a (  bA (bA)*)
A = a  a bA (bA)*
A partir de esta expresión, es sencillo encontrar producciones en forma stándard que generan los strings
en A. El conjunto Z = bA (bA)* es generado por las producciones:
Z  bAZ Z  bA
en donde Z es un nuevo símbolo no terminal. Debido a que la clausura se realiza a través de recursión por
derecha en lugar de recursión por izquierda, Z no aparece como handle de estas reglas; en realidad éstas se
encuentran en forma stándard.
El conjunto A = a  a Z es generado por las reglas A:
A  aZ Aa
Por lo tanto la gramática:
G2:   A A = a  aZ
Aa Z bA Z = bA  (bA)Z X = PX  Q
A  aZ Z  bAZ Z = (bA)* bA X = P*Q
A = a  a bA (bA)*
describe el mismo lenguaje que G1.
Más aún, cada derivación en G1 que usa recursión por izquierda sobre A:
  A  AbA  AbAbA  …  a(bA)  …  a(ba) k1
k k

se corresponde unívocamente con la derivación:


  A  aZ  abAZ  …  a(bA) Z  a(bA) bA  …  a(ba) k1
k-1 k-1 k

usando recursión por derecha sobre Z en G2. Esta relación se ilustra con la Figura 4. Por lo tanto, las
gramáticas G1 y G2 son fuertemente equivalentes.

Figura 4: Reemplazo de una regla recursiva por izquierda

Para eliminar la recursión por izquierda en algún no terminal X de una gramática arbitraria puede ser
necesario manejar varias reglas X recursivas. Esto requiere una generalización de la transformación
ilustrada en el ejemplo anterior.

Proposición 7: Sea G una gramática libre de contexto, y sean las reglas X de G:


X  1, , X  n
X  X1, , X  Xm
Sea G' la gramática obtenida al reemplazar estas reglas con:
X  1, , X  n
X  1Z, , X  nZ
Z  1, , Z  m
Z  1Z, , Z  mZ
donde Z es un nuevo símbolo no terminal.
Luego G y G' son fuertemente equivalentes.

95
Prueba: El conjunto de strings representados por X en G es:
X = (1    n)  X ( 1     m)
Aplicando la regla de Arden { X = Q  XP  X = QP* }, resulta:
X = (1    n) ( 1     m)*

El conjunto de strings representados por X en G' es:


X = (1    n)  (1    n) Z
donde:
Z = ( 1     m)  ( 1     m) Z

Resolviendo la ecuación de Z mediante el empleo de la regla de Arden { X = Q  PX  X = P*Q }, y


sustituyendo el resultado en la ecuación de X, resulta:
Z = ( 1     m)* ( 1     m)
X = (1    n)  (1    n) ( 1     m)* ( 1     m)
X = (1    n)  (  ( 1     m)* ( 1     m))
X = (1    n) ( 1     m)*

Por lo tanto X representa los mismos strings en G y G’, y dado que solamente las reglas X de G han sido
reemplazadas, podemos concluir que L(G') = L(G).
Además G y G' son fuertemente equivalentes debido a que las derivaciones:
X  Xj1    Xjkj1  i jkj1 en G
están en correspondencia uno a uno con derivaciones de la forma:
X  i Z  i jk Z    i jk…j2 Z    i jk…j1 en G'

La Proposición 7 forma la base de un procedimiento para convertir cualquier GLC bien conformada en
una gramática en forma stándard equivalente. La descripción del procedimiento se ve simplificada por la
introducción de la noción de subgramáticas.

Definición: Sea G una GLC y sea M un subconjunto de los símbolos no terminales de G. La subgramática
G(M) contiene cada regla de G cuya parte izquierda pertenece a M.
G(M) = { A   / A  M y A    G }

El conjunto M se elige de forma tal que ningún elemento de M es recursivo por izquierda en la
subgramática G(M). Claramente, M no puede contener un símbolo no terminal A si existe una regla A  A
en M. Sin embargo, M puede contener no terminales que son recursivos por izquierda en G.

Ejemplo 13: En la siguiente gramática, tanto A como B son recursivos por izquierda:
G: A A  Ba B  Ab
 Aa
Sin embargo, A no es recursivo por izquierda en la subgramática:
G({A}) = { A  Ba , A  a }
y B no es recursivo por izquierda en la subgramática:
G({B}) = { B  Ab }

Sea G una gramática bien conformada con símbolos no terminales recursivos por izquierda (SNRI), la
cual será puesta en forma stándard. Sea G(M) una subgramática de G en la cual ningún símbolo no terminal
es recursivo por izquierda, y sea X un símbolo no terminal recursivo por izquierda en G.
El siguiente procedimiento transforma a G en una gramática equivalente G' tal que G'(M  {X}) no tiene
SNRI. El procedimiento de eliminación de recursión por izquierda en X consta de dos pasos:

Paso 1: Consideremos cualquier regla X de G tal que su handle pertenezca a M:


X  Y YM
Sean las reglas Y de G:
Y  1, , Y  k
Usando sustitución, reemplazamos la regla X  Y con las nuevas reglas:
X  1, , X  k
y repetimos el procedimiento hasta que G no tenga reglas X con handles en M.

96
Por cada sustitución realizada en el transcurso del Paso 1, la Proposición 1 asegura que la gramática
transformada genera el mismo lenguaje que la gramática original. Dado que solamente las reglas X son
incorporadas a G, el Paso 1 no puede introducir recursión por izquierda a la subgramática G(M).

Paso 2: Luego de completado el Paso 1, las reglas X de G son:


X  1, , X  n (reglas en las cuales X no es recursivo por izquierda)
X  X1, , X  Xm (reglas en las cuales X es recursivo por izquierda)
en las cuales ningún handle es miembro de M. Reemplazamos las reglas X con las reglas:
X  1, , X  n
X  1Z, , X  nZ
Z  1, , Z  m
Z  1Z, , Z  mZ
donde Z es un símbolo no terminal nuevo.
La Proposición 7 asegura que el reemplazo realizado en el Paso 2 no cambia el lenguaje generado por G.
Solamente las reglas X y Z son adicionadas, por lo tanto todos los símbolos no terminales en M se
mantienen no recursivos por izquierda. Dado que X y Z no aparecen como handles en ninguna de las nuevas
reglas, y que ni X ni Z son recursivos por izquierda, por lo tanto G(M  {X}) no contiene SNRI.

Cuando toda recursión por izquierda haya sido removida de G a través de aplicaciones repetidas de los
Pasos 1 y 2, los símbolos no terminales de la gramática serán N  R, en donde R = {Z1, , Zp} contiene los
símbolos no terminales introducidos a través del Paso 2 del procedimiento. La gramática puede contener
aún reglas con símbolos no terminales como handles. Dado que no hay presente recursión por izquierda,
estas reglas pueden ser reemplazadas con reglas en forma stándard mediante un número finito de
sustituciones.

En resumen:
Procedimiento de eliminación de recursión por izquierda en X
Paso 1: Se utiliza sustitución para transformar las reglas X de G de tal forma que sus handles no sean
miembros de M.
En G En G'
X  Y YM X  1, , X  k
Y  1, , Y  k Y  1, , Y  k
Paso 2: Se utiliza la Proposición 7 para reemplazar cualquier regla X recursiva por izquierda.
En G En G'
X  i i = 1n X  i Z  j
X  Xj j = 1m X  iZ Z   jZ

Repitiendo este procedimiento por cada SNRI, se eliminará toda recursión por izquierda en G. Luego se
empleará sustitución para reescribir producciones que no se encuentren en forma stándard.

Hemos mostrado como convertir una GLC bien conformada en GLC en forma stándard. Además, las
Proposiciones 1 y 7 garantizan que la gramática obtenida es fuertemente equivalente a la gramática dada, a
menos que en la sustitución de los handles se introduzcan reglas preexistentes en la gramática (en este
último caso, es posible evitar la duplicación a través del uso de símbolos no terminales adicionales). Por lo
tanto, podemos establecer el siguiente teorema:

Teorema 3: Dada una GLC cualquiera, es posible construir una gramática G' en forma stándard tal que G y
G' son fuertemente equivalentes.

Ejemplo 14: Sea la gramática G1, con A y B recursivos por izquierda:


G1:   A A  AaB B  BaC Cc
 AB BC
Se elimina la recursión por izquierda debida a B: M={C}
En G En G'
Paso 1 BC CM Bc
Paso 2 B  BaC Bc Z  aC
Bc B  cZ Z  aCZ

97
Se elimina la recursión por izquierda debida a A: M = { C, B }
En G En G'
Paso 1 AB BM Ac
A  cZ
Paso 2 Ac Ac Y  aB
A  cZ A  cZ Y  aBY
A  AaB A  cY
A  cZY
Se obtiene la gramática G2 en forma stándard, fuertemente equivalente a G1:
G2:   A Ac Bc Cc
A  cZ B  cZ
A  cY Z  aC
A  cZY Z  aCZ
Y  aB
Y  aBY

[Anexo]
Algoritmo Cocke-Younger-Kasami
Sea  = a1a2 ... an con ai  T i = 1, ... , n
Se denotan los substrings de  mediante su posición inicial y su longitud; es decir, ij es el substring de  que comienza
en la posición i y tiene una longitud j.
El algoritmo construye una tabla T triangular, con elementos tij  N, 1  i  n y 1  j  n-i+1

en donde A  tij  A aiai+1 ... ai+j-1


Entonces   L(G) si   S; S  t1n

Algoritmo Cocke-Younger-Kasami
PARA i = 1  n HACER
ti1 = { A / A  ai }
PARA j = 2  n HACER
PARA i = 1  (n-j+1) HACER
tij = 
PARA k = 1  (j-1) HACER
tij = tij  { A / A  BC, con B  tik y C  ti+k,j-k }
SI   S y S  t1n ENTONCES   L(G)

La aplicación del algoritmo con la gramática siguiente y el string de entrada  = (())() nos da como resultado la tabla
triangular de 6  6 que se muestra a continuación:
1 2 3 4 5 6
S S  SS Y  SZ 1 X   S  S
S  XY X( 2 X S Y  
S  XZ Z) 3 Z   
4 Z  
5 X S
6 Z
Puesto que   S y S  t16, luego   L(G).
98
III.- Otros tipos de Analizadores Sintácticos

1. Construcción de compiladores

La construcción de compiladores de lenguajes de programación de alto nivel se suele realizar utilizando


tanto Autómatas Push-Down como Autómatas Finitos. Las etapas y fases más usuales que conforman un
compilador son:
 Análisis: esta etapa realiza una comprobación de si el programa fuente cumple con las reglas de la
gramática del lenguaje de programación. Normalmente, se suele dividir en tres fases:
- Análisis léxico: lee el fichero fuente e identifica cuáles son los elementos básicos del análisis,
denominados tokens, como, por ejemplo, los identificadores, los símbolos de puntuación, las palabras
reservadas, los números, etc. Esta fase se suele realizar por medio de una Autómata Finito, ya que el
lenguaje que describe a los elementos básicos se puede reducir a un Lenguaje Regular.
- Análisis sintáctico: toma como entrada los tokens y comprueba que la secuencia completa de todos
los tokens como palabra forma parte del lenguaje de la gramática que describe al lenguaje de
programación. A tales efectos, a los tokens se los considera los símbolos terminales de la gramática,
y a la secuencia de tokens la palabra de entrada. Este tipo de análisis se realiza por medio de
Autómatas Push-Down, debido a que la gramática suele ser libre de contexto.
- Análisis semántico: se encarga de comprobar que el programa fuente, además de cumplir con las
reglas sintácticas de la gramática, también cumple las reglas semánticas. Por ejemplo, si una variable
ya ha sido declarada cuando se utiliza, o si en una operación numérica todos los operandos son de
tipo numérico. Debido a que este tipo de análisis necesita tener en cuenta al contexto, no es posible
realizarlo con gramáticas de tipo 2 ó 3, por lo que hay que utilizar gramáticas menos restringidas.
 Generación de código: una vez que el análisis ha sido correcto, los compiladores deben generar código
en el lenguaje destino. Para ello, se recurre a las estructuras generadas durante la fase de análisis,
como, por ejemplo, los árboles de derivación, para generar el código.

Resulta evidente que el Análisis Sintáctico, con su función de de agrupar los componentes léxicos del
programa fuente en frases gramaticales que el compilador utiliza para sintetizar la salida, resulta de vital
importancia en el proceso de compilación, motivo por el cual el método elegido para llevarlo a cabo resultará
en una mayor facilidad o dificultad en la construcción del compilador, así como influirá directamente en la
eficiencia general del mismo.

2. Tipos de Analizadores Sintácticos

Existen tres tipos generales de Analizadores Sintácticos. Los métodos universales de análisis sintáctico,
como el algoritmo de Cocke-Younger-Kasami previamente expuesto y el de Earley, pueden analizar
cualquier gramática; sin embargo, estos métodos son demasiado ineficientes para usarlos en la producción
de compiladores. Los métodos empleados generalmente en los compiladores se clasifican como
descendentes o ascendentes. Como sus nombres indican, los analizadores sintácticos descendentes
construyen árboles de análisis sintáctico desde arriba (la raíz) hasta abajo (las hojas), mientras que los
analizadores sintácticos ascendentes comienzan en las hojas y suben hacia la raíz. En ambos casos, se
examina la entrada al analizador sintáctico de izquierda a derecha, un símbolo a la vez.

La popularidad de los analizadores sintácticos descendentes se deba al hecho de poder construir


manualmente analizadores sintácticos eficientes con mayor facilidad, como se vio en la sección anterior. Por
otra parte, el análisis sintáctico ascendente puede manejar una clase mayor de gramáticas y esquemas de
traducción, de modo que las herramientas de software para generar analizadores sintácticos directamente a
partir de las gramáticas tienden a usar métodos ascendentes.

2.1. Análisis Sintáctico Descendente

A continuación se presentan las ideas básicas del análisis sintáctico descendente y se analiza la
construcción de una forma eficiente sin retroceso de un analizador sintáctico descendente llamada
analizador sintáctico predictivo. Se consideran también analizadores sintácticos predictivos no recursivos, y
se define la clase de gramáticas LL(1), a partir de las cuales se pueden construir de manera automática
analizadores sintácticos predictivos.

2.1.1. Análisis sintáctico por descenso recursivo

Se puede considerar el análisis sintáctico descendente como un intento de encontrar una derivación por
izquierda para una cadena de entrada, o también como un intento de construir un árbol de análisis sintáctico
para la entrada comenzando desde la raíz y creando los nodos del árbol en preorden. La forma más general
de análisis sintáctico descendente, denominado por descenso recursivo, que puede incluir varios retrocesos,
es decir, varios exámenes de la entrada, ya fue desarrollada en el punto 4 de la sección I. Sin embargo, no
99
hay muchos analizadores sintácticos con retroceso, puesto que éste casi nunca es necesario para analizar
sintácticamente las construcciones de los lenguajes de programación.

Una gramática que posea algún no terminal recursivo por izquierda puede hacer que un analizador
sintáctico por descenso recursivo, incluso uno con retroceso, entre en un lazo infinito. Es decir, cuando se
intenta expandir dicho no terminal, es posible que nuevamente se intente expandirlo sin haber consumido
ningún símbolo de la entrada.

2.1.2. Análisis sintáctico predictivo

En muchos casos, escribiendo con cuidado una gramática, eliminando su recursión por izquierda y
factorizando por izquierda la gramática resultante, se puede obtener una gramática analizable con un
analizador sintáctico por descenso recursivo que no necesite retroceso, conocido como analizador sintáctico
predictivo.

Para construir un analizador sintáctico predictivo, se debe conocer, dado el símbolo actual a de la entrada
y el no terminal A a expandir, cuál de las reglas A  1 | 2 | … | n es la única alternativa que da lugar a un
string que comience con a. Es decir, la alternativa apropiada debe ser detectable con sólo ver el primer
símbolo al que da lugar. Así se detectan generalmente las construcciones de flujo de control de la mayoría
de los lenguajes de programación, con sus palabras claves diferenciadoras. Por ejemplo, en el siguiente
conjunto de producciones, las palabras clave if, while y begin indican qué alternativa es la única con
posibilidad de éxito para encontrar una proposición.
<prop>  if <expr> then <prop> else <prop> |
while <expr> do <prop> |
begin <lista_props> end

2.1.3. Análisis sintáctico predictivo no recursivo

Se puede construir un analizador sintáctico predictivo no recursivo explícitamente manteniendo una pila,
en lugar de hacerlo mediante llamadas recursivas. El problema clave durante el análisis sintáctico predictivo
es determinar la producción que debe aplicarse a un no terminal; un analizador sintáctico predictivo no
recursivo busca la producción que debe aplicarse en una tabla de análisis sintáctico, la cual puede ser
directamente construida a partir de ciertas gramáticas. Dicha tabla es una matriz bidimensional M[A,a],
donde A  N y a  T o bien a=$, siendo $ un símbolo especial utilizado para indicar el fin del string de
entrada; cuando el analizador detecta A al tope de la pila y a es el símbolo en curso de la entrada, recurre a
la entrada M[A,a] para expandir el no terminal A (o eventualmente llamar a una rutina de recuperación de
error si fuera necesario).

2.1.4. Gramáticas LL(1)

Existe un algoritmo que puede ser aplicado a cualquier gramática G para producir una tabla de análisis
sintáctico M; sin embargo, para algunas gramáticas, la tabla obtenida puede tener algunas entradas con
definiciones múltiples. Por ejemplo, si G es recursiva por izquierda o ambigua, entonces M tendrá al menos
una entrada con definición múltiple.

Una gramática cuya tabla de análisis sintáctico no tiene entradas con definiciones múltiples se define
como LL(1). La primera “L” (por left, en inglés, izquierda) representa el examen de la entrada de izquierda a
derecha, la segunda “L” representa una derivación por izquierda, y el “1” es por utilizar un símbolo de
entrada de examen por anticipada a cada paso para tomar las decisiones de la acción en el análisis
sintáctico. Ninguna gramática ambigua o recursiva por izquierda puede ser LL(1).

Queda la cuestión de lo que se debe hacer cuando la tabla de análisis sintáctico tiene entradas con
definiciones múltiples; un recurso es transformar la gramática eliminando toda recursión por izquierda y
factorizando por la izquierda siempre que sea posible. Sin embargo, hay algunas gramáticas a las que
ninguna transformación convertirá en LL(1). En general, no hay reglas universales por las que las entradas
con definiciones múltiples se puedan convertir en entradas de un solo valor sin que afecte al lenguaje
reconocido por el analizador.

2.2. Análisis Sintáctico Ascendente

En los puntos siguientes se introduce un estilo general de análisis sintáctico ascendente, conocido como
análisis sintáctico por desplazamiento y reducción, así como también una forma sencilla de aplicarlo llamada
análisis sintáctico por precedencia de operadores. Luego se presenta un método mucho más general de
análisis sintáctico por desplazamiento y reducción llamado análisis sintáctico LR, el cual se utiliza en varios
generadores automáticos de analizadores sintácticos. Por último, se define la clase de gramáticas LR, para
las cuales es posible construir una tabla de análisis sintácticoLR.
100
2.2.1. Análisis sintáctico por desplazamiento y reducción

El análisis sintáctico por desplazamiento y reducción intenta construir un árbol de análisis sintáctico para
una cadena de entrada que comienza por las hojas (el fondo) y avanza hacia la raíz (la cima). Se puede
considerar este proceso como de “reducir” un string  al símbolo inicial de la gramática. En cada paso de
reducción se sustituye un substring determinado que concuerde con el lado derecho de una producción por
el símbolo del lado izquierdo de dicha producción y si en cada paso se elige correctamente el substring, se
traza una derivación por derecha en sentido inverso.

Ejemplo 1: Dada la gramática G, el string =abbcde se puede reducir a  mediante los siguientes pasos:
  aABe abbcde
A  Abc | b aAbcde
Bd aAde
aABe

Estas reducciones trazan la siguiente derivación por derecha en orden inverso:
  aABe  aAde  aAbcde  abbcde

La definición del análisis sintáctico por desplazamiento y reducción incluye dos nociones que se
presentarán someramente a continuación.

A grandes rasgos, un mango de un string es un substring que concuerda con el lado derecho de una
producción y cuya reducción al no terminal del lado izquierdo de la misma representa un paso a lo largo de la
inversa de una derivación por derecha. Obsérvese que se dice “un mango” en lugar de “el mango” porque la
gramática podría ser ambigua, con más de una derivación por derecha para un string. Si una gramática no
es ambigua, entonces toda forma sentencial tiene exactamente un mango. En el ejemplo anterior, se
subrayaron los mangos en cada forma sentencial obtenida durante el proceso de reducción.

El mango A   representa al subárbol completo situado más a la izquierda que


consta de un nodo y todos sus hijos; en la figura, A es el nodo interior situado más
abajo y más a la izquierda con todos sus hijos en el árbol, y el string  a la derecha del
mango contiene sólo símbolos terminales. Se denomina poda del mango la
eliminación de los hijos de A del árbol de análisis sintáctico y su reemplazo por A a fin
de obtener la forma sentencial previa en una derivación por derecha en sentido
inverso.

Si la repetición del proceso de identificación de mango y poda del mismo para obtener la forma sentencial
anterior conduce a una forma sentencial que conste sólo de , el análisis sintáctico resultó exitoso, y la
inversa de la secuencia de producciones utilizada en estas reducciones constituye una derivación por
derecha del string de entrada.

Un modo apropiado de implementar un analizador sintáctico por desplazamiento y reducción es mediante


la utilización de una pila para manejar los símbolos gramaticales y un buffer de entrada para manejar el
string  que se debe analizar. El analizador sintáctico funciona desplazando cero o más símbolos de la
entrada a la pila hasta que un mango esté al tope; luego, el analizador reduce dicho mango al lado izquierdo
de la producción adecuada. Este lazo se repite hasta que tanto la pila como la entrada están vacías –en
cuyo caso se concluyó con éxito el análisis sintáctico– o bien se detecta un error.

Existen gramáticas libres de contexto para las cuales no es posible utilizar analizadores sintácticos por
desplazamiento y reducción. Todo analizador por desplazamiento y reducción para estas gramáticas puede
alcanzar una configuración en la que, conociendo el contenido total de la pila y el siguiente símbolo de
entrada, no puede decidir si desplazar o reducir (un conflicto de desplazamiento/reducción), o no puede
decidir qué tipo de reducción efectuar (un conflicto de reducción/reducción). Técnicamente, estas gramáticas
no están dentro de la clase LR(k) de gramáticas que se definirá en breve; se las denomina gramáticas no
LR. La k de LR(k) se refiere al número de símbolos de preanálisis sobre la entrada. Por lo general, las
gramáticas utilizadas en compilación se incluyen en la clase LR(1), con un símbolo de anticipación.

2.2.2. Análisis sintáctico por precedencia de operadores

En el próximo punto se presentará la mayor clase de gramáticas para las que se pueden construir con
éxito analizadores sintácticos por desplazamiento y reducción (las gramáticas LR). Sin embargo, para una
pequeña, pero importante, clase de gramáticas, se pueden construir con facilidad a mano eficientes
analizadores sintácticos por desplazamiento y reducción; estas gramáticas tienen la propiedad (entre otros
requisitos fundamentales) de que ninguna producción tiene su lado derecho igual a  ni posee dos
terminales adyacentes. Una gramática con tales características se denomina gramática de operadores.

101
Como técnica general de análisis sintáctico, el análisis por precedencia de operadores tiene varios
inconvenientes. Por ejemplo, es difícil manejar componentes léxicos como el signo menos, que tiene dos
precedencias distintas (dependiendo de si es unario o binario); peor aún, como la relación entre una
gramática para el lenguaje que está siendo analizado y el mismo analizador sintáctico por precedencia de
operadores es muy frágil, no siempre se puede tener la seguridad de que el analizador acepta exactamente
el lenguaje deseado. Por último, sólo una pequeña clase de gramáticas puede analizarse usando las
técnicas de precedencia de operadores.

En el análisis sintáctico por precedencia de operadores, se definen tres relaciones de precedencia


disjuntas, <, =, >, entre algunos pares de símbolos terminales; estas relaciones de precedencia guían la
selección de mangos y sus significados son “cede la precedencia a”, “tiene la misma precedencia que” y
“tiene más precedencia que”. La intención de las relaciones de precedencia es delimitar el mango de una
forma sentencial, con < marcando el extremo izquierdo, = apareciendo en el interior del mango y >
marcando el extremo derecho.

Ejemplo 2: Las relaciones de precedencia de operadores que se muestran a continuación son algunas de
las posibles, siendo $ un símbolo especial utilizado para marcar cada extremo del string de entrada.
id + * $
id > > >
+ < > < >
* < > > >
$ < < <

2.2.3. Análisis sintáctico LR(k)

En este punto se analiza una técnica eficiente de análisis sintáctico ascendente que se puede utilizar para
analizar una clase más amplia de gramáticas libres de contexto, denominada análisis sintáctico LR(k); la “L”
es por el examen de la entrada de izquierda a derecha (en inglés, left-to-right), la “R” por construir una
derivación por la derecha (en inglés, rightmost derivation) en orden inverso, y la k por el número de símbolos
de entrada de examen por anticipado utilizados para tomar las decisiones del análisis sintáctico. Cuando se
omite, se asume que k es 1.

El análisis sintáctico LR es atractivo por varias razones:


- Se pueden construir analizadores sintácticos LR para reconocer prácticamente todas las construcciones
de los lenguajes de programación para los que se pueden escribir GLC.
- El método de análisis sintáctico LR es el método de análisis por desplazamiento y reducción sin retroceso
más general que se conoce, y sin embargo se puede aplicar tan eficientemente como los otros métodos de
desplazamiento y reducción.
- La clase de gramáticas que pueden analizarse con los métodos LR es un supraconjunto de la clase de
gramáticas que se pueden analizar con analizadores sintácticos predictivos.
- Un analizador sintáctico LR puede detectar un error sintáctico tan pronto como sea posible hacerlo en un
examen de izquierda a derecha de la entrada.

El principal inconveniente del método es que supone demasiado trabajo construir un analizador sintáctico
LR a mano para un lenguaje de programación típico. Sin embargo, existen generadores de analizadores
sintácticos LR (YACC, por ejemplo), que los producen automáticamente a partir de una GLC.

Existen tres técnicas para construir una tabla de análisis sintáctico LR para una gramática dada. El primer
método, llamado LR sencillo (SLR, en inglés) es el más fácil de implantar pero el menos poderoso de los
tres. Puede que no consiga producir una tabla de análisis sintáctico para algunas gramáticas que los otros
métodos sí consiguen. El segundo método, llamado LR canónico, es el más poderoso y costoso. El tercer
método, llamado LR con examen por anticipado (LALR, en inglés lookahead-LR), está entre los otros dos en
cuanto a poder y costo. El método LALR funciona con las gramáticas de la mayoría de los lenguajes de
programación y, con un poco de esfuerzo, se puede implantar en forma eficiente.

Esquemáticamente, una analizador sintáctico LR consta de una entrada, una salida, una pila, un
programa conductor y una tabla de análisis sintáctico con dos partes (acción e ir_a). El programa conductor
es el mismo para todos los analizadores sintácticos LR; sólo cambian las tablas de una analizador a otro.
El programa analizador lee caracteres de un buffer de
entrada de uno en uno y utiliza la pila para almacenar un
string de la forma s0X1s1X2s2…Xmsm donde sm está al tope.
Cada Xi es un símbolo gramatical y cada si es un símbolo
denominado estado. Cada símbolo de estado resume la
información contenida debajo de él en la pila, y se usan la
combinación del símbolo de estado en el tope de la pila y el
símbolo en curso de la entrada para indexar la tabla de
análisis sintáctico y determinar la decisión de
desplazamiento o reducción del analizador.
102
La tabla de análisis sintáctico consta de dos partes, la función acción, que indica una acción del
analizador, y la función ir_a, que indica las transiciones entre estados (toma un estado y un símbolo
gramatical como argumentos y produce un estado). El programa que maneja el analizador sintáctico
determina sm, el estado al tope de la pila, y ai, el símbolo en curso de la entrada; luego consulta la entrada
acción[sm,ai] de la tabla de acciones del analizador, la que puede tener uno de estos cuatro valores:
1. di, significa desplazar (apilar el símbolo s y el estado i, y avanzar el apuntador de la entrada)
2. rj, reducir por la producción gramatical con número j (siendo A   la producción utilizada, se sacan de la
pila 2*|| símbolos, y se meten en la pila A y el resultado de ir_a[s’,A], donde s’ es el estado que resultó al
tope de pila al desapilar los 2*|| símbolos)
3. aceptar
4. error (indicado por un espacio en blanco en la tabla)

Ejemplo 3: Se muestra a continuación la tabla de análisis sintáctico LR con los valores del las funciones
acción e ir_a, para la siguiente gramática de expresiones aritméticas con los operadores binarios + y *;
luego, se muestran los movimientos del analizador sintáctico LR con el string de entrada  = id * id + id
(1) E  E + T
(2) E  T
(3) T  T * F
(4) T  F
(5) F  (E)
(6) F  id

acción ir_a PILA ENTRADA ACCIÓN


ESTADO
id + * ( ) $ E T F 0 id * id + id $ desplazar
0 d5 d4 1 2 3 0 id 5 * id + id $ reducir por F  id
1 d6 acep 0F3 * id + id $ reducir por T  F
2 r2 d7 r2 r2 0T2 * id + id $ desplazar
3 r4 r4 r4 r4 0T2*7 id + id $ desplazar
4 d5 d4 8 2 3 0 T 2 * 7 id 5 + id $ reducir por F  id
5 r6 r6 r6 r6 0 T 2 * 7 F 10 + id $ reducir por T  T * F
6 d5 d4 9 3 0T2 + id $ reducir por E  T
7 d5 d4 10 0E1 + id $ desplazar
8 d6 d11 0E1+6 desplazar
id $
9 r1 d7 r1 r1
0 E 1 + 6 id 5 $ reducir por F  id
10 r3 r3 r3 r3
0E1+6F3 $ reducir por T  F
11 r5 r5 r5 r5
0E1+6T9 $ reducir por E  E + T
0E1 $ aceptar

2.2.4. Gramáticas LR

Una gramática para la que se puede construir una tabla de análisis sintáctico LR se denomina gramática
LR. Hay gramáticas libres de contexto que no son LR, pero en general se pueden evitar en las
construcciones típicas de los lenguajes de programación. Intuitivamente, para que una gramática sea LR
basta con que un analizador sintáctico por desplazamiento y reducción que opere de izquierda a derecha
pueda reconocer los mangos cuando aparezcan al tope de la pila.

Un analizador LR no tienen que examinar la pila completa para saber cuándo aparecen los mangos en el
tope. Por el contrario, el símbolo del estado en el tope de la pila contiene toda la información necesaria.

Otra fuente de información que puede utilizar un analizador LR como ayuda para tomar las decisiones de
desplazamiento y reducción son los k símbolos siguientes de entrada. Los casos en que k=0 o k=1 son los
que tienen interés práctico. Por ejemplo, la tabla de acciones del Ejemplo 3 utiliza un símbolo de examen
por anticipado. Una gramática que se puede analizar mediante un analizador sintáctico LR que examina
hasta k símbolos de entrada en cada movimiento se denomina gramática LR(k).

Existe una diferencia significativa entre las gramáticas LL y las LR. Para que una gramática sea LR(k),
hay que ser capaza de reconocer la presencia del lado derecho de una producción, habiendo visto todo lo
que deriva de dicho lado derecho con k símbolos de examen por anticipado. Este requisito es mucho menos
riguroso que el de las gramáticas LL(k), donde hay que ser capaz de reconocer el uso de una producción
viendo sólo los primeros k símbolos de los que se deriva su lado derecho. Por consiguiente, las gramáticas
LR pueden describir más lenguajes que las gramáticas LL.

103
IV.- Introducción a las Semánticas

Como se explicó previamente, dentro de la etapa de Análisis llevada a cabo por un compilador, la última
fase considerada corresponde al Análisis Semántico, en el que se realizan ciertas revisiones para asegurar
que los componentes de un programa se ajustan de un modo significativo.

La fase de análisis semántico revisa el programa fuente para tratar de encontrar errores semánticos y
reúne información sobre los tipos para la etapa de generación de código. En ella se utiliza la estructura
jerárquica determinada por la fase de análisis sintáctico para identificar los operadores y operandos de
expresiones y proposiciones.

Un componente importante del análisis semántico es la verificación de tipos. Aquí, el compilador verifica
si cada operador tiene operandos permitidos por la especificación del lenguaje fuente. Por ejemplo, las
definiciones de muchos lenguajes de programación requieren que el compilador indique un error si se utiliza
un número real como índice de una matriz. Sin embargo, la especificación del lenguaje puede permitir
ciertas coerciones a los operandos, por ejemplo, cuando un operador aritmético binario se aplica a un
número entero y a un número real; en este caso, el compilador puede necesitar convertir el entero a real.

Como una muy buena introducción a las Semánticas, se reproduce a continuación el capítulo inicial del
Cuaderno Didáctico Nº 38, escrito por Ortín Soler et al. de la Universidad de Oviedo – España.

ANÁLISIS SEMÁNTICO EN PROCESADORES DE LENGUAJE


La fase de análisis semántico de un procesador de lenguaje es aquélla que computa la
información adicional necesaria para el procesamiento de un lenguaje, una vez que la
estructura sintáctica de un programa haya sido obtenida. Es por tanto la fase posterior a la de
análisis sintáctico y la última dentro del proceso de síntesis de un lenguaje de programación.
Sintaxis de un lenguaje de programación es el conjunto de reglas formales que especifican
la estructura de los programas pertenecientes a dicho lenguaje. Semántica de un lenguaje de
programación es el conjunto de reglas que especifican el significado de cualquier sentencia
sintácticamente válida. Finalmente, el análisis semántico1 de un procesador de lenguaje es la
fase encargada de detectar la validez semántica de las sentencias aceptadas por el analizador
sintáctico.
Ejemplo: Dado el siguiente ejemplo de código en C:
superficie = base * altura / 2;

La sintaxis del lenguaje C indica que las expresiones se pueden formar con un conjunto de
operadores y un conjunto de elementos básicos. Entre los operadores, con sintaxis binaria
infija, se encuentran la asignación, el producto y la división. Entre los elementos básicos de
una expresión existen los identificadores y las constantes enteras sin signo (entre otros).
Su semántica identifica que en el registro asociado al identificador superficie se le va a
asociar el valor resultante del producto de los valores asociados a base y altura, divididos
por dos (la superficie de un triángulo).
Finalmente, el análisis semántico del procesador de lenguaje, tras haber analizado
correctamente que la sintaxis es válida, deberá comprobar que se satisfacen las siguientes
condiciones:
- Que todos los identificadores que aparecen en la expresión hayan sido declarados en el
ámbito actual, o en alguno de sus ámbitos (bloques2) previos.
- Que la subexpresión de la izquierda sea semánticamente válida, es decir, que sea un
lvalue3.
- Que a los tipos de los identificadores base y altura se les pueda aplicar el operador de
multiplicación. Un registro en C, por ejemplo, no sería válido.

1
Semantic Analysis o Contextual Analysis.
2
El lenguaje C es un lenguaje orientado a bloques. Los bloques se especifican mediante la pareja de caracteres { y }.
Dentro de un bloque, es posible declarar variables que ocultan a las variables declaradas en bloques de un nivel menor
de anidamiento.
3
Una expresión es un lvalue (left value, valor a la izquierda) si puede estar a la izquierda en una expresión de
asignación, es decir, si se puede obtener su dirección de memoria y modifica el contenido de ésta. Otros ejemplos de
expresiones lvalue en C, son: *puntero, array[n], registro.campo...
104
- Deberá inferirse el tipo resultante de la multiplicación anterior. Al tipo inferido se le
deberá poder aplicar el operador de dividir, con el tipo entero como multiplicando.
- Deberá inferirse el tipo resultante de la división y comprobarse si éste es compatible con
el tipo de superficie para llevar a cabo la asignación. Como ejemplo, si superficie fuese
entera y division real, no podría llevarse a cabo la asignación.
La fase de análisis semántico obtiene su nombre por requerir información relativa al
significado del lenguaje, que está fuera del alcance de la representatividad de las gramáticas
libres de contexto y los principales algoritmos existentes de análisis; es por ello por lo que se
dice que captura la parte de la fase de análisis considerada fuera del ámbito de la sintaxis.
Dentro de la clasificación jerárquica que Chomsky dio de los lenguajes, la utilización de
gramáticas sensibles al contexto (o de tipo 1) permitirían identificar sintácticamente
características como que la utilización de una variable en el lenguaje Pascal ha de estar
previamente declarada. Sin embargo, la implementación de un analizador sintáctico basado en
una gramática de estas características sería computacionalmente más compleja que un
autómata de pila.
Así, la mayoría de los compiladores utilizan una gramática libre de contexto para describir la
sintaxis del lenguaje y una fase de análisis semántico posterior para restringir las sentencias
que “semánticamente” no pertenecen al lenguaje. En el caso que mencionábamos del empleo
de una variable en Pascal que necesariamente haya tenido que ser declarada, el analizador
sintáctico se limita a comprobar, mediante una gramática libre de contexto, que un
identificador forma parte de una expresión. Una vez comprobado que la sentencia es
sintácticamente correcta, el analizador semántico deberá verificar que el identificador
empleado como parte de una expresión haya sido declarado previamente. Para llevar a cabo
esta tarea, es típica la utilización de una estructura de datos adicional denominada tabla de
símbolos. Ésta poseerá una entrada por cada identificador declarado en el contexto que se
esté analizando. Con este tipo de estructuras de datos adicionales, los desarrolladores de
compiladores acostumbran a suplir las carencias de las gramáticas libres de contexto.
Otro caso que se da en la implementación real de compiladores es ubicar determinadas
comprobaciones en el analizador semántico, aun cuando puedan ser llevadas a cabo por el
analizador sintáctico. Es factible describir una gramática libre de contexto capaz de representar
que toda implementación de una función tenga al menos una sentencia return. Sin embargo,
la gramática sería realmente compleja y su tratamiento en la fase de análisis sintáctico sería
demasiado complicada. Así, es más sencillo transferir dicha responsabilidad al analizador
semántico que sólo deberá contabilizar el número de sentencias return aparecidas en la
implementación de una función.
El objetivo principal del analizador semántico de un procesador de lenguaje es asegurarse
de que el programa analizado satisfaga las reglas requeridas por la especificación del lenguaje,
para garantizar su correcta ejecución. El tipo y dimensión de análisis semántico requerido varía
enormemente de un lenguaje a otro. En lenguajes interpretados como Lisp o Smalltalk casi no
se lleva a cabo análisis semántico previo a su ejecución, mientras que en lenguajes como Ada,
el analizador semántico deberá comprobar numerosas reglas que un programa fuente está
obligado a satisfacer.
Vemos, pues, cómo el análisis semántico de un procesador de lenguaje no modela la
semántica o comportamiento de los distintos programas construidos en el lenguaje de
programación, sino que, haciendo uso de información parcial de su comportamiento, realiza
todas las comprobaciones necesarias –no llevadas a cabo por el analizador sintáctico– para
asegurarse de que el programa pertenece al lenguaje. Otra fase del compilador donde se hace
uso parcial de la semántica del lenguaje es en la optimización de código, en la que analizando
el significado de los programas previamente a su ejecución, se pueden llevar a cabo
transformaciones en los mismos para ganar en eficiencia.

105

Potrebbero piacerti anche