Documentos de Académico
Documentos de Profesional
Documentos de Cultura
Facultad de Ingeniería
Departamento de Ingeniería Informática
APUNTES DE LA ASIGNATURA
COMPILADORES
TABLA DE CONTENIDOS
1 INTRODUCCIÓN ................................................................................................................................. 7
1.1 ¿QUÉ ES UN PROGRAMA? ......................................................................................................... 8
1.2 DEFINICIÓN DE COMPILADOR ................................................................................................ 8
1.3 COMPILADORES E INTÉRPRETES ........................................................................................... 9
1.4 CONTEXTO EN QUE SE SITÚA EL COMPILADOR ................................................................ 9
1.5 FASES DE UN COMPILADOR .................................................................................................. 11
1.6 CLASIFICACIÓN DE LOS COMPILADORES.......................................................................... 13
1.7 HERRAMIENTAS ÚTILES ......................................................................................................... 13
2 ANÁLISIS LÉXICO ............................................................................................................................ 14
2.1 FUNCIÓN DEL ANALIZADOR LÉXICO.................................................................................. 14
2.2 COMPONENTES LÉXICOS, PATRONES Y LEXEMAS ......................................................... 15
2.3 ERRORES LÉXICOS ................................................................................................................... 16
2.4 IMPLEMENTACIÓN DE ANALIZADORES LÉXICOS .......................................................... 17
2.4.1 MÉTODOS GENERALES ..................................................................................................... 17
2.4.2 CONSTRUCCIÓN DE UN ANALIZADOR LÉXICO ......................................................... 17
2.5 EJERCICIOS ................................................................................................................................. 21
3. ANÁLISIS SINTÁCTICO .................................................................................................................. 23
3.1 CONCEPTOS PREVIOS .............................................................................................................. 23
3.1.1 RECURSIVIDAD POR LA IZQUIERDA ............................................................................. 23
3.1.2 FACTORIZACIÓN POR LA IZQUIERDA .......................................................................... 24
3.1.3 CONJUNTOS ANULABLE, PRIMERO Y SIGUIENTE ..................................................... 27
3.1.3.1 Conjunto Anulable ........................................................................................................... 27
3.1.3.2 Conjunto Primero ............................................................................................................. 27
3.1.3.3 Conjunto Siguiente........................................................................................................... 27
3.2 DESCRIPCIÓN GENERAL DEL ANÁLISIS SINTÁCTICO .................................................... 28
3.3 ALGUNOS ASPECTOS DEL MANEJO DE ERRORES SINTÁCTICOS................................. 29
3.4 ANÁLISIS SINTÁCTICO PREDICTIVO ................................................................................... 30
3.4.1 ASPECTOS GENERALES .................................................................................................... 30
3.4.2 CONSTRUCCIÓN Y FUNCIONAMIENTO DE ANALIZADORES SINTÁCTICOS LL(1)
......................................................................................................................................................... 31
1 INTRODUCCIÓN
Para poder hablar de lenguaje es importante conocer algunos elementos. Por ejemplo, ¿cómo se crean
los conceptos? ¿Cómo hacer que todos entiendan lo mismo? El primer paso para responder esta
pregunta está en el concepto de símbolo. En el caso del castellano, se puede pensar en las letras como
símbolos elementales, que se emplean para construir conceptos sencillos denominados palabras. El
conjunto de todos los símbolos se conoce como alfabeto. Ahora bien, las palabras también pueden
formar parte de estructuras más complejas: frases y oraciones. A su vez, éstas pueden conformar
párrafos, etc.
A partir de los conceptos anteriores podría pensarse que cualquier secuencia de letras es una palabra o
que una oración podría ser una secuencia aleatoria de palabras. Pero de nuestra experiencia previa
sabemos que no es así. Existe una serie de reglas que limitan la forma de las palabras o la estructura de
un texto:
Reglas léxicas: corresponden a las reglas de ortografía de un lenguaje, e indican la forma que deben
tener las palabras. Por ejemplo, en castellano no puede haber palabras que contengan una n seguida
de una b.
Reglas sintácticas: definen la forma en que se debe estructurar un texto. Por ejemplo, una oración
debe tener sujeto y predicado. El predicado debe tener un núcleo que puede ir acompañado de
diversos complementos.
Reglas semánticas: determinan el significado de lo que se dice en el texto sintácticamente correcto
y guardan relación con el contexto. Por ejemplo, la frase “ése árbol” puede referirse a un árbol de
un parque, o bien a una estructura de datos empleada en la resolución de un problema
computacional.
Con todo lo anterior, ya tenemos una primera noción de qué es un lenguaje. Ahora bien, sabemos que
existen muchos lenguajes y que en ocasiones es necesario pasar un mensaje o texto de un lenguaje a
otro. De aquí surgen los conceptos de traducción (generar una copia escrita del mensaje original en un
idioma distinto) e interpretación (repetir verbalmente un mensaje en un lenguaje diferente al empleado
por el emisor).
Existen diferentes tipos de lenguajes. Por ahora, nos basta distinguir entre:
Lenguajes naturales: son aquellos que las personas utilizan para comunicarse entre ellas, como el
castellano, el inglés o el chino. Son muy complejos, tienen una gran cantidad de reglas y sin
embargo presentan situaciones de ambigüedad que los hablantes resuelven recurriendo al contexto
tanto de espacio como de tiempo.
Lenguajes formales: son lenguajes artificiales, diseñados para lograr una comunicación
(unidireccional) entre personas y máquinas. Estas últimas deben comprender los mensajes y
ejecutarlos, por lo que las reglas de los lenguajes formales deben estar muy bien definidas y no
pueden dar lugar a ambigüedades.
En este curso daremos respuesta a una pregunta que se desprende de todo lo expuesto anteriormente:
¿cómo aplicar la teoría de lenguajes formales para poder comunicarnos con las máquinas?
Sin embargo, también podemos considerar que esta gran palabra contiene subsecuencias que, a su vez,
son palabras pertenecientes a lenguajes más sencillos que conforman las denominadas categorías
léxicas. Cada una de estas categorías puede ser definida por un lenguaje más sencillo (casi siempre un
lenguaje regular) y da origen a un tipo específico de palabras. Entre los más habituales podemos
encontrar, por ejemplo:
Identificadores predefinidos para elementos propios del lenguaje (palabras reservadas, operadores,
etc.).
Identificadores definidos por el programador.
Representación literal de valores.
Delimitadores.
Comentarios.
Existe una gran diversidad de lenguajes fuente, que van desde los más populares (Java, C/C++, Visual
Basic, etc.) hasta aquellos altamente especializados. De igual manera existen muchos lenguajes objeto,
que abarcan desde el lenguaje de máquina de cualquier computador hasta otros lenguajes de
programación.
No es difícil imaginar que la tarea de convertir un programa fuente escrito en un lenguaje de alto nivel
en un programa objeto cuyo lenguaje podría ser el de la máquina en que será ejecutado no es sencilla.
Pero, ¿para qué hacer esta traducción? Aho et al. afirman que: “el 90 % del tiempo de ejecución de un
programa se encuentra en el 10 % del mismo”. En consecuencia, una de las grandes ventajas de usar un
compilador es la optimización de código que realizan. Dichas optimizaciones no solo se traducen en un
menor tiempo de ejecución, sino también en una reducción del consumo de energía por parte del
procesador y en una menor cantidad de accesos a memoria.
El programa objeto creado por el compilador puede requerir procesamiento adicional para poder ser
ejecutado. La figura 1.2 muestra, a modo de ejemplo, un compilador que crea código en lenguaje
ensamblador, el cual debe ser traducido a código de máquina por un ensamblador y luego enlazado a
algunas rutinas de biblioteca para finalmente generar el código que podrá ser ejecutado en la máquina.
El lenguaje ensamblador es una versión mnemotécnica del lenguaje de máquina, donde se usan
nombres para las operaciones en lugar de códigos binarios y también se usan nombres para las
direcciones de memoria. Además, un ensamblador es un compilador cuyo lenguaje fuente es el
lenguaje ensamblador.
2. Proceso de carga: consiste en tomar el código de máquina relocalizable, modificar las direcciones
de memoria y colocar las instrucciones y los datos modificados en las posiciones apropiadas de la
memoria.
3. Editor de enlace: permite formar un único programa a partir de varios archivos de código de
máquina relocalizable.
El análisis separa al programa fuente en los diversos componentes léxicos (tokens) que lo componen y
luego crea una representación intermedia. Posteriormente la síntesis se sirve de la representación
intermedia generada durante el análisis para construir el programa objeto.
La figura 1.4 muestra las distintas fases de un compilador, donde las primeras cuatro etapas que recorre
el programa fuente constituyen el análisis y las siguientes, la síntesis.
2. Análisis sintáctico: agrupa los componentes léxicos del programa fuente en frases gramaticales que
el compilador utiliza para sintetizar la salida.
3. Análisis semántico: se revisan las frases gramaticales que conforman el programa para detectar
errores semánticos, es decir, que guarden relación con el significado de los elementos, y reúne la
información sobre los tipos para la posterior fase de generación de código. El análisis semántico
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, donde el compilador verifica que cada operador tenga
operandos permitidos por la especificación del lenguaje fuente. Sin embargo, la especificación del
lenguaje puede permitir ciertas conversiones.
6. Generación de código: corresponde a la fase final de un compilador y genera código objeto, que
habitualmente consiste en código de máquina relocalizable o código ensamblador. Aquí se
seleccionan las posiciones de memoria para cada una de las variables empleadas por el programa.
Un aspecto decisivo de esta etapa es la asignación de variables a registros.
8. Tabla de símbolos: es una estructura de datos que contiene un registro por cada identificador, donde
los campos son llenados con los atributos de este último.
9. Manejador de errores: cada una de las fases descritas puede encontrar errores. No obstante, cada
fase debe tratar adecuadamente el error detectado para poder continuar la compilación y así permitir
la detección de más errores en el programa fuente.
2 ANÁLISIS LÉXICO
El análisis léxico corresponde a la primera etapa del proceso de compilación. Puede pensarse como una
corrección ortográfica del programa fuente.
La figura 2.1 hace mención de la tabla de símbolos, encargada de almacenar información relativa a
cada uno de los nombres de variables y procedimientos declarados en el programa fuente. Será
estudiada con mayor profundidad en capítulos posteriores.
Como el analizador léxico es la parte del compilador que lee el texto fuente, también puede realizar
ciertas funciones secundarias en la interfaz del usuario:
Eliminar los comentarios del programa fuente.
Eliminar espacios en blanco, tabulaciones y saltos de línea.
Relacionar los mensajes de error entregados por el compilador con el programa fuente. Por
ejemplo, el analizador léxico suele contar los saltos de línea detectados, por lo que puede asociar
los errores encontrados al número de línea del programa fuente correspondiente. Incluso existen
compiladores en que el analizador léxico se encarga de hacer una copia del programa fuente donde
se marcan los mensajes de error.
Se señaló anteriormente que el analizador léxico suele ser una subrutina del analizador sintáctico.
Existen varias razones para separar estos dos tipos de análisis en etapas diferentes:
Quizá la más importante de las consideraciones sea la de lograr un diseño sencillo. Separar el
análisis léxico del análisis sintáctico a menudo permite simplificar una u otra de estas etapas. Por
ejemplo, un analizador sintáctico que incluya las convenciones de espacios en blanco y comentarios
resulta mucho más complejo que otro que solo deba comprobar que éstos ya hayan sido eliminados
por el analizador léxico. En el caso de la creación de un lenguaje nuevo, la separación de las
convenciones léxicas de las sintácticas puede traducirse en un diseño más claro del lenguaje.
Mejorar la eficiencia del compilador. Un analizador léxico independiente permite construir un
procesador especializado y potencialmente más eficiente para esta función. Gran parte del tiempo
se consume en leer el programa fuente y dividirlo en componentes léxicos. Con técnicas
especializadas de manejo de buffers para la lectura de caracteres de entrada y procesamiento de
componentes léxicos se puede mejorar significativamente el rendimiento de un compilador.
Mejorar la portabilidad del compilador. Las peculiaridades del alfabeto de entrada y otras
anomalías propias de los dispositivos pueden limitarse al analizador léxico. Por ejemplo, la
representación de símbolos especiales o no estándar (como ↑ en Pascal) puede ser aislada en esta
etapa.
Un lenguaje clasifica los componentes léxicos en un conjunto finito de tipos. En la mayoría de los
lenguajes de programación se consideran como componentes léxicos las siguientes construcciones:
Palabras clave.
Operadores.
Identificadores.
Constantes.
Cadenas literales.
Signos de puntuación.
La tabla 2.1 muestra ejemplos para algunos de los tipos de componentes léxicos típicos de un lenguaje
de programación. Puede verse en estos ejemplos que en muchos casos existe un conjunto de cadenas
(strings) para las cuales se produce un mismo componente léxico. Dicho conjunto de cadenas se
describe mediante una regla llamada patrón, para cuya descripción precisa se utiliza la notación de
expresiones regulares. Se dice que el patrón concuerda con cada cadena del conjunto.
Una cadena de caracteres en el programa fuente con la que concuerde el patrón correspondiente a un
componente léxico dado recibe el nombre de lexema. La tabla 2.2 muestra ejemplos de componentes
léxicos, lexemas y patrones.
Tipo Ejemplo
ID foo n14 sum
NUM 73 0 00 515 082
REAL 66.1 .5 10. 1e67 5.5e-10
IF If
COMMA ,
NOTEQ !=
LPAREN (
RPAREN )
En muchos lenguajes de programación existen ciertas cadenas de caracteres que son reservadas, es
decir, tienen un significado predefinido que no puede ser modificado por el usuario. Esto suele ser así
para las palabras clave (for, char, break, etc.). Si las palabras clave no son reservadas, corresponde al
analizador léxico la tarea de distinguir estas palabras de los identificadores, lo que puede resultar
bastante complejo. Por ejemplo, en PL/1 es permitida la siguiente sentencia:
fi(a == f(x)) …
Si surge una situación en la que el analizador léxico no puede continuar porque ninguno de los patrones
concuerda con el prefijo de la entrada restante se pueden efectuar diferentes acciones de recuperación
de error:
Borrar caracteres hasta encontrar algún componente léxico bien formado.
Insertar caracteres faltantes.
Reemplazar un carácter incorrecto por otro correcto.
Intercambiar caracteres adyacentes.
Las tres opciones han sido presentadas en orden de dificultad creciente para quien deba
implementarlas. Lamentablemente, muchas veces los enfoques más difíciles de implementar dan como
resultado analizadores léxicos más rápidos.
Independientemente del método empleado, las herramientas teóricas útiles para la construcción de
analizadores léxicos son las expresiones regulares, para expresar los componentes léxicos en forma
sencilla, y los autómatas finitos, para la implementación de analizadores léxicos.
Muchos generadores de analizadores léxicos trabajan tomando como base las expresiones regulares
correspondientes a los patrones de los diversos componentes léxicos de un lenguaje.
Lo primero que debe hacerse es ordenar los diferentes patrones (representados como expresiones
regulares) de acuerdo a su prioridad. Esto debe hacerse cuidadosamente, puesto que si el analizador
léxico resultante detecta que un prefijo de la entrada se ajusta a dos o más patrones diferentes, asignará
a ese lexema el componente léxico de mayor prioridad.
Una vez asignadas las prioridades, se construyen los AFND-ε para cada patrón (una buena idea es usar
el método de construcción de Thompson), donde el estado final de cada autómata tendrá asociada una
acción. Esta acción corresponde a lo que debe hacerse al momento de detectar un lexema que
concuerda con el patrón asociado al AFND-ε.
A continuación se unen los autómatas obtenidos para dar lugar a un único AFND-ε. Para efectuar esta
unión basta con crear un estado inicial con transiciones vacías hacia cada uno de los estados iniciales
de los AFND-ε de cada patrón. Los estados finales conservan sus acciones asociadas.
Cuando ya se ha construido el AFND-ε que reconoce todos los componentes léxicos del lenguaje, se
debe proceder a convertirlo en un AFD mínimo. Al momento de minimizar se debe recordar que
estados finales que tengan diferentes acciones asociadas no pueden ser equivalentes. El AFD mínimo
obtenido corresponde al analizador léxico para el lenguaje especificado.
Ejemplo 2.1:
Construir el léxico mínimo que reconoce los patrones dados en la tabla 2.3 y les asigna el
componente léxico correspondiente.
Se debe comenzar por ordenar jerárquicamente los patrones, según su precedencia. Se asigna
mayor precedencia a aquellos patrones que representen conjuntos más pequeños de palabras.
Esto es de vital importancia porque, en el caso de que dos patrones reconozcan un mismo
lexema, deberá ejecutarse la acción de mayor precedencia. En este caso, se tiene que el patrón
solo coincide con el lexema a. Similarmente, el patrón solo coincide con el lexema abb. El
patrón , en cambio, coincide con un conjunto más amplio de lexemas: todos aquellos que
tengan cero o más a seguidas de una o más b. En este caso, podemos observar que el lexema
abb coincide con dos patrones: y . Como el patrón es más limitado, debe tener
una precedencia más alta que . El patrón , en cambio, no tiene conflictos con los demás
patrones, por lo que no importa qué lugar ocupe en la precedencia.
A continuación, necesitamos construir los AFND-ε reconocedores para los patrones dados,
siguiendo el método de Thompson (ver anexo A). Luego unimos estos tres AFND- ε con un
nuevo estado inicial que tenga transiciones vacías hacia los estados iniciales de nuestros tres
autómatas. El AFND- ε resultante se muestra en la figura 2.2.
Es importante señalar que el reconocimiento de un lexema válido va a estar dado por un estado
final, y que cada estado final corresponde a un patrón diferente. Así, a cada estado final se le
asocia el componente léxico para el patrón correspondiente. Como reconoce lexemas que
coincidan con el patrón , se le asocia el componente léxico . De manera similar, a se le
asocia el componente léxico y a
, el componente léxico .
,
,
, ,
,
,
, , ,
,
, ,
,
# #
#
,
, ,
",
# #
#
",
, ,
, , ,
$
,
# #
#
,
$,
# #
#
$,
Los estados finales del AFD también llevan asociada una acción. En caso de que uno de los
estrados finales comprenda más de una acción, se escoge aquella de mayor prioridad. Por
ejemplo, el estado $ del AFD comprende los estados finales correspondientes a las acciones
y , pero se le asocia solamente por tener una precedencia más alta.
Es frecuente que el método empleado para obtener el AFD equivalente entregue estados
redundantes, por lo que se requiere minimizar el AFD obtenido. Si se considera el método de
las particiones para la minimización (ver anexo A), la teoría de autómatas indica que la
partición inicial viene dada por la separación de estados finales y no finales. No obstante, en el
caso de los analizadores léxicos los estados finales tienen asociadas diferentes acciones con
diferentes prioridades, por lo que es necesario particionar también de acuerdo a la acción a
realizar. Así, aquellos estados finales en que se ejecute una acción no serán equivalentes a los
estados finales que ejecuten una acción diferente. La figura 2.4 muestra el AFD mínimo.
!$&*, *("
%
+
) ' *
% % ,-
2.5 EJERCICIOS
1. Dados los patrones de la tabla 2.4 y sus componentes léxicos asociados:
a. Asigne las prioridades a considerar para construir el analizador léxico correspondiente.
b. Indique, para cada uno de los siguientes lexemas, el componente léxico que debiera asociarle el
analizador léxico. En caso de no corresponder con ninguno, señale la existencia de un error
léxico.
.
.
.
.
.
2. Construya el analizador léxico mínimo para los siguientes patrones (El símbolo entre paréntesis
asociado a cada patrón corresponde al componente léxico asociado).
. ( ).
( ).
( ).
3. Construya el analizador léxico mínimo para los siguientes patrones (El símbolo entre paréntesis
asociado a cada patrón corresponde al componente léxico asociado).
/ . 0/ . 0 ( ).
/ . 0 ( ).
// ( ).
3. ANÁLISIS SINTÁCTICO
Así como en la construcción de analizadores léxicos trabajamos con lenguajes regulares, los
analizadores sintácticos se construyen sobre la base de una gramática libre de contexto o GLC (ver
anexo A). Aquí la gramática corresponde a la especificación del lenguaje de programación, y se usan
autómatas apiladores (ver anexo A) como reconocedores de programas sintácticamente correctos.
Una gramática es recursiva por la izquierda si tiene un no terminal tal que existe una derivación
1 2 para alguna cadena 2. Para eliminar las recursiones directas, se agrupan las producciones del
no terminal A de la siguiente manera:
1 2 | 2 | … | 25 | 6 | 6 | … | 67
Donde ninguna de las producciones 68 comienza por el no terminal . Luego se sustituyen las
producciones de por:
1 6 9 | 6 : | … | 67 :
9 1 2 9 | 2 : | … | 25 :
Muchas veces no basta con eliminar las recursiones directas. Pueden existir recursiones por la izquierda
que tarden más en aparecer. Para eliminar este tipo de recursiones se puede usar el algoritmo que se
muestra a continuación, siempre que la gramática no contenga ciclos (derivaciones de la forma
1 ) ni producciones . Nótese, sin embargo, que la gramática resultante sí puede contener
producciones :
1. Ordenar los no terminales en un cierto orden fijo (cualquiera) , , … , 7 .
2. Para (;
1 hasta =):
Para (>
1 hasta ; 1):
Si las producciones de ? son ? 1 |… | @ , reemplazar cada producción 8 1 ? A por
8 1 A |… | @ A.
Eliminar recursividad directa por la izquierda para 8 .
Ejemplo 3.1:
Elimine toda recursión por la izquierda para $
Σ, N, %, C, donde:
Σ
, , , .
N
C, D.
P
C 1 D | ,
D 1 DC | C.
C
C.
C 1 D | : como C es el primer no terminal, solo se debe eliminar la recursión directa. En este
caso no existe recursión.
D 1 DC | C: en primer lugar, se busca cada producción de D que comience con algún no
terminal ya revisado (en este caso C) y se crean nuevas producciones para D en que C sea
sustituido por sus producciones. Así, se obtiene D 1 DC | D | .
D 1 DC | D | : una vez realizado el primer paso, se debe eliminar la recursión directa por la
izquierda, con lo que se obtiene D 1 D | , 1 C | .
Así, la gramática sin recursión por la izquierda es $9
Σ, N9, %9, C, donde:
Σ
, , , .
N9
C, D, .
P9
C 1 D | ,
D 1 D | ,
1 C | .
C
C.
Ejemplo 3.2:
Factorice por la izquierda la gramática $
Σ, N, P, S:
Σ
a, b
N
A, B, C, D
P
1 | ! | | ! | ,
1 | ! | | ! | ,
1 | ! | | !,
! 1 | ! | | ! | |
| ! | !! | ! | !! | !
S
A.
Nótese que no se creó un no terminal nuevo porque ya existía uno con exactamente las mismas
producciones.
Existen dos funciones asociadas a una gramática $ que facilitan la construcción de analizadores
sintácticos: Primero y Siguiente. Ambas aportan información relativa al contexto de un símbolo dado.
Antes de definir las dos funciones ya mencionadas es necesario introducir el conjunto anulable, _` ,
definido como el conjunto de todos aquellos no terminales que pueden, en uno o más pasos, derivar a .
Es decir, _`
a _: 1 .
Dada una cadena de símbolos gramaticales 2, se define su Conjunto Primero, %2, como el conjunto
de terminales que pueden iniciar las secuencias derivadas a partir de 2.
Dado un no terminal , se define su Conjunto Siguiente, C, como el conjunto de terminales que
pueden aparecer inmediatamente a la derecha de en alguna forma de frase, es decir, el conjunto de
terminales tales que exista una derivación de la forma C 1 26 para algún 2 y algún 6.
Adicionalmente, si es posible que sea el símbolo situado más a la derecha en alguna forma de frase, o
sea, no puede venir nada más a continuación de , entonces $ a C, donde $ indica el término de la
secuencia (puede pensarse en $ de una manera similar al carácter especial de fin de archivo, "e).
Note que la regla 4 es una combinación de las dos reglas anteriores. En ella, 6 podría no anularse, en cuyo
caso debemos considerar la regla 2. Pero también puede darse que 6 se anule, en cuyo caso se cumple la regla 3.
En consecuencia, debemos considerar todas las posibilidades.
Ejemplo 3.3:
Determine el Conjunto Anulable y los conjuntos %c y Cc, c a _, para $
Σ, N, %, C,
donde:
Σ
.,, , , /.
N
, , , !, ".
P
1 !,
1 ",
1 | /,
! 1 .! | ,
" 1 " | .
C
.
El conjunto anulable está conformado por todos aquellos terminales que, en una o más
derivaciones, pueden generar la secuencia vacía. Así, el conjunto anulable para $ está dado por
_`
!, ". g _` pues todas sus producciones contienen terminales. g _` pues su única
producción comienza por , que tampoco lo es. Lo mismo ocurre para .
Según el diccionario de la Real Academia Española (2011), la sintaxis es la “parte de la gramática que
enseña a coordinar y unir las palabras para formar las oraciones y expresar conceptos.” En una segunda
acepción, define sintaxis como un “conjunto de reglas que definen las secuencias correctas de los
elementos de un lenguaje de programación”. Esta última definición implica que todo lenguaje de
programación tiene reglas que prescriben la estructura de programas bien formados. Generalmente, un
programa está formado por bloques. A su vez, los bloques están conformados por proposiciones, las
cuales se forman a partir de expresiones, y estas últimas se forman con componentes léxicos:
Anteriormente vimos que el analizador léxico y el analizador sintáctico trabajan en conjunto y que este
último obtiene como entrada una cadena de componentes léxicos entregada por el primero (ver figura
3.1). En este punto el analizador sintáctico comprueba que la cadena pueda ser generada por la
gramática del lenguaje fuente. También informa de cualquier error de sintaxis de manera inteligible, y
debería recuperarse de los errores que ocurren frecuentemente para poder continuar procesando el resto
de su entrada. Como resultado del análisis sintáctico se obtiene un árbol de derivación que genera la
secuencia de componentes léxicos a partir del símbolo inicial de la gramática.
Los métodos descendentes y ascendentes más eficientes trabajan sólo con subclases de las gramáticas
libres de contexto, pero varias de estas subclases, como las gramáticas LL y LR, son lo suficientemente
expresivas para describir la mayoría de las construcciones sintácticas de los lenguajes de programación.
Los analizadores sintácticos implementados a mano a menudo trabajan con gramáticas LL(1). Los
analizadores sintácticos para la clase más grande de gramáticas LR se construyen normalmente con
herramientas automatizadas.
Como ya se señaló, la salida del analizador sintáctico es una representación del árbol de análisis
sintáctico para la cadena de componentes léxicos producida por el analizador léxico. En la práctica, no
obstante, hay varias tareas que se pueden realizar durante el análisis sintáctico, como recoger
información sobre distintos componentes léxicos en una tabla de símbolos, realizar la verificación de
tipo y otras clases de análisis semántico, y generar código intermedio. No obstante, estas actividades se
verán más adelante.
compilador debería ayudar al programador a identificar y localizar errores. Estos errores pueden ser de
diversos tipos, por ejemplo:
Léxicos, como escribir mal un identificador, palabra clave u operador.
Sintácticos, como una expresión aritmética con paréntesis no equilibrados.
Semánticos, como un operador aplicado a un operando incompatible.
Lógicos, como una llamada infinitamente recursiva.
El análisis sintáctico descendente puede verse como un intento de encontrar, usando las producciones
de una gramática dada y comenzando por el símbolo inicial, una derivación por la izquierda para una
cadena dada. Se estudiará en forma particular el análisis sintáctico predictivo (también denominado
análisis sintáctico LL(1) o análisis sintáctico por descenso recursivo), que es el más utilizado gracias a
su eficiencia.
De lo anterior se desprende que no cualquier gramática libre de contexto es apropiada para el análisis
sintáctico predictivo, sino que se requieren gramáticas que no contengan recursiones por la izquierda y
que estén factorizadas también por la izquierda.
Se señaló anteriormente que los analizadores sintácticos descendentes construyen el árbol de análisis
sintáctico comenzando desde la raíz, es decir, desde el símbolo inicial de $. Para este fin se sirve de
una tabla de análisis sintáctico que indica qué producción debe emplearse al momento de reemplazar
un no terminal. Para la construcción de dicha tabla se emplea el algoritmo siguiente:
1. Para cada producción 1 2, 2 J , hacer:
a. Para cada terminal en %2, añadir la producción 1 2 en jk, l.
b. Si a _` , para cada terminal en C, añadir la producción 1 en jk, l.
3. Hacer que cada entrada no definida de la tabla corresponda a un error.
Si en alguna entrada de la tabla queda más de una producción, entonces se dice que no es posible
construir un analizador sintáctico LL(1).
Cabe destacar que el analizador sintáctico así construido es un autómata apilador. Inicialmente, la pila
contiene al símbolo inicial de la gramática al tope, seguido del delimitador de la entrada. Cada vez que
se tenga un no terminal al tope de la pila y un terminal al comienzo de la entrada, el no terminal
de la pila es reemplazado por el lado derecho de la producción contenida en jk, l. Adicionalmente,
es necesario considerar transiciones que permitan eliminar un símbolo terminal de la pila si éste
coincide con el de la entrada. La aceptación solo ocurre si tanto la pila como la entrada quedan vacías
(solo con el delimitador de la entrada).
Ejemplo 3.4:
Construya la tabla de análisis sintáctico predictivo para $
Σ, N, %, C, donde:
Σ
.,, , , /.
N
, , , !, ".
P
1 !,
1 ",
1 | /,
! 1 .! | ,
Sabemos que:
_`
!, ".
%
%
%
, /
%!
.
%"
C
C!
$,
C
C"
$, , .
C
$, , .,
En primer lugar, para cada producción 1 2 no vacía, debemos añadir dicha producción en la
posición jk, l de la tabla para cada terminal a %2:
Las figuras 3.2 y 3.3 muestran la traza para las dos entradas solicitadas con sus respectivos
árboles sintácticos.
Ejemplo 3.5:
Construya la tabla de análisis sintáctico LL(1) para $
Σ, N, %, C, donde:
n
k, l, o, p, .,, , #, /.
N
, , , !.
%
1 kl | ,
1 ! | ,
1 ! | ,
! 1 #! | o p | /
C
.
$ No requiere eliminación de recursión ni factorización, Por lo que podemos trabajar con ella
tal como está.
%
%kl b %
k b % b % b %
k b #, o, /, b #, o, /
#, o, /, , k
%
% ! b %
% b % b #
#, o, / b
#, o, /,
%
%! b %
%! b #
#, o, /
%!
%#! b %o p b %/
# b o b /
#, o, /
Ahora, para cada producción 1 2, debemos añadir dicha producción en la posición jk, l de
la tabla para cada terminal a %2, como muestra la tabla 3.3:
El algoritmo de construcción para analizadores sintácticos LL(1) puede ser aplicado a cualquier
gramática $. No obstante, si $ es ambigua o recursiva por la izquierda se tendrá al menos una entrada
de la tabla con más de una definición.
Como para el análisis sintáctico predictivo se requiere poder determinar sin ambigüedad qué
producción utilizar, será necesario utilizar un subconjunto de las gramáticas libres de contexto: las
gramáticas LL(1). La primera L del nombre hace referencia a que el análisis se efectúa de izquierda a
derecha. La segunda, a que se deriva por a izquierda. El 1 indica que se examina un único símbolo de la
entrada antes de decidir qué producción ocupar.
Se dice que una gramática es LL(1) cuando no es ambigua ni recursiva por la izquierda. Se puede
demostrar que una gramática es de este tipo si y solo si, cuando 1 2 | 6 sean dos producciones
diferentes de $, se cumplen las siguientes condiciones:
Para ningún terminal , tanto 2 como 6 derivan a la vez cadenas que comiencen por .
A lo sumo una de las producciones 2 o 6 pueden derivar la cadena vacía.
Si 6 1 , 2 no genera ninguna cadena que comience con un terminal en C.
La pila de un analizador sintáctico no recursivo hace explícitos los terminales y no terminales que el
analizador espera emparejar con el resto de la tabla. En consecuencia, durante las siguientes
explicaciones se hará referencia a los símbolos de la pila.
La detección de un error se produce cuando el terminal al tope de la pila no concuerda con el siguiente
símbolo de la entrada o bien cuando en el tope de la pila se encuentra el no terminal , el siguiente
símbolo de la entrada es y la entrada jk, l de la tabla no se encuentra definida.
Como se explicó con anterioridad, este tipo de recuperación de error consiste en eliminar símbolos de
la entrada hasta encontrar algún componente léxico que pertenezca a un conjunto de componentes
léxicos de sincronización. No obstante, se debe tener cuidado al elegir estos componentes de
sincronización. Algunas técnicas que se podrían emplear son:
Colocar dentro del conjunto de sincronización para el no terminal todos los símbolos contenidos
en C. Probablemente el análisis sintáctico podrá continuar si se eliminan componentes de la
entrada hasta encontrar algún elemento de C y se elimina el no terminal de la pila.
En ocasiones puede ser necesario un conjunto de elementos de sincronización más grande. Por
ejemplo, podría ser útil añadir al conjunto de sincronización de una construcción de menor
jerarquía aquellos componentes que inician las construcciones de una jerarquía mayor.
Pueden añadirse al conjunto de sincronización aquellos símbolos terminales contenidos en %.
Si un no terminal puede generar , puede usarse esta producción por omisión.
Si no se puede descartar un terminal de la pila, puede eliminarse dicho terminal.
Ejemplo 3.6:
Considere el analizador sintáctico LL(1) del ejemplo 3.4. Muestre la traza para la entrada
m
/ / usando recuperación de errores en modo de pánico.
TABLA 3.5: Traza con recuperación de errores en modo de pánico para la entrada m
/ / usando
el analizador sintáctico de la tabla 3.2.
Consiste en llenar las entradas en blanco (entradas de error) de la tabla de análisis sintáctico con
punteros a funciones de error. Estas funciones podrían cambiar, insertar o eliminar símbolos de la
entrada y enviar los mensajes de error apropiados. No obstante, no será tratada a fondo en este curso.
Este tipo de análisis sintáctico intenta construir un árbol de análisis sintáctico para una secuencia de
componentes léxicos comenzando desde las hojas y avanzando hacia la raíz. En otras palabras, se
reduce la secuencia de componentes léxicos de la entrada hasta tener solamente el símbolo inicial de la
gramática. En cada paso de reducción se sustituye una subcadena de la entrada que concuerde con el
lado derecho de una producción por el no terminal del lado izquierdo de la misma (en otras palabras, si
se tiene la producción 1 2, la reducción reemplaza la secuencia 2 por el no terminal ). Si en cada
paso se escoge correctamente la subcadena a reemplazar, el resultado final es la traza de una derivación
por la derecha en sentido inverso.
Ejemplo 3.7:
Sea $
Σ, N, %, C, donde:
n
, , ), ', *.
N
C, , .
%
C 1 *,
1 ) | ,
1 '
C
C.
Una manera de implementar el análisis sintáctico por desplazamiento y reducción es mediante un AFD
con una pila asociada. Inicialmente, la pila solo contiene el estado inicial del AA, mientras que la
cadena m a analizar se encuentra en la entrada seguida del delimitador (es decir, m$). El analizador
sintáctico desplaza cero o más símbolos de la entrada a la pila hasta que se reconozca el lado derecho 2
de una producción 1 2 y entonces se reduce 2 reemplazándolo por el lado izquierdo de la
producción correspondiente (el no terminal ). Se repite este proceso hasta encontrar un error o hasta
que la pila solo contenga al símbolo inicial de la gramática y la entrada esté vacía.
Aunque las principales operaciones de este tipo de analizador sintáctico son el desplazamiento y la
reducción, existen en realidad cuatro acciones diferentes:
Desplazar: se desplaza el siguiente símbolo de la entrada al tope de la pila, seguido del nuevo
estado del AFD.
Reducir: se sustituye el lado derecho de una producción, contenido en la pila, por su lado izquierdo.
Ahora bien, existen algunas GLC en que un analizador sintáctico por desplazamiento y reducción
puede alcanzar una configuración donde, conociendo el contenido de la pila y el siguiente símbolo de
la entrada, sea imposible decidir si efectuar un desplazamiento o una reducción (conflicto
desplazamiento/reducción), o bien qué reducción efectuar (conflicto reducción/reducción). Para evitar
este tipo de problemas se utilizará un subconjunto de las gramáticas independientes del contexto: la
clase de gramáticas LR(k). En consecuencia, el análisis sintáctico ascendente suele recibir el nombre de
análisis sintáctico LR, por left-to-right parse, rightmost derivation. Lee la entrada de izquierda a
derecha y construye una derivación por la derecha en orden inverso. Adicionalmente, la k entre
paréntesis corresponde al número de símbolos de la entrada que son considerados al momento de
decidir qué acción ejecutar. Cabe señalar que en la práctica no se usa r p 1 para la compilación, pues
se requieren tablas demasiado grandes.
La familia de métodos LR permite analizar un superconjunto de la clase de gramáticas LL(1), es decir,
DD1 s Dt. Es posible construir analizadores sintácticos LR para reconocer prácticamente todas las
construcciones de los lenguajes de programación definidos mediante GLC, por lo que este esquema es
el más utilizado.
Anteriormente se señaló que existen casos en que un analizador sintáctico por desplazamiento y
reducción tiene problemas para decidir qué acción ejecutar. Para que opere correctamente, este tipo de
analizador sintáctico debe ser capaz de decidir si reemplazar o reducir conociendo solamente los
símbolos de la pila y los siguientes k elementos de la entrada.
Sean dos producciones de cuyos lados derechos sean 1 26m | 260, respectivamente. Se puede
observar que dichas producciones comparten el prefijo 26. Supóngase además que los primeros r
símbolos de ambas producciones son los mismos.
Como ambas producciones son iguales tanto en la pila como en la porción de la cadena visible para el
analizador, entonces la gramática será LR(k) si y solo si 26m
260.
El primer método de la familia LR que estudiaremos recibe su nombre por Simple Left-to-Right parser.
No obstante, antes de construir un analizador sintáctico de esta clase es necesario definir algunos
conceptos.
Un elemento LR(0) es una producción de la gramática $ con un punto en algún lugar del lado derecho.
Por ejemplo, la producción 1 cuv produce cuatro elementos LR(0):
1w cuv
1 c w uv
1 cu w v
1 cuv w
Ejemplo 3.8:
Sea $
Σ, N, %, C, donde:
n
.,, , , /.
N
, , , !.
%
1 ,
1 . | ,
1 ! | !,
! 1 | /
C
.
Sea además
1w . Determine xyzy{.
Por regla 1:
xyzy{
1w
La idea central del método SLR es construir, a partir de $, un AFD (considerando la definición no
estricta, en que cada estado tiene a lo más una transición por cada símbolo del alfabeto) con una pila
asociada que permita reconocer los prefijos viables (es decir, que se pueden derivar a partir de alguna
producción). Para este fin se agrupan los elementos LR(0) en conjuntos que conforman los estados del
AFD. Además, el alfabeto del AFD está dado por todos los terminales y no terminales de la gramática.
El primer paso necesario para la construcción del AFD es aumentar la gramática $ con una nueva
producción C9 1 C, donde C9 es un nuevo símbolo inicial, a fin de asegurar que el símbolo inicial tenga
una única producción. Esta modificación tiene por objeto indicar en qué momento se debe detener el
análisis sintáctico y aceptar la cadena: es decir, cuando se está a punto de hacer la reducción de C9 1 C.
Nótese que esta nueva producción no pasa a formar parte de la gramática, sino que se usa
exclusivamente para la construcción del estado inicial del AFD.
El estado inicial del AFD está dado por
xyzy{
C9 1w C. A continuación se determinan
las transiciones con todos aquellos símbolos precedidos por un punto en algún elemento LR(0) de .
Para los nuevos estados, se determina la xyzy{ de los elementos LR(0) que le dan origen, es
decir, aquellos del estado anterior con el punto desplazado en una posición hacia la derecha. Las
transiciones se determinan igual que para . Si nos encontramos ante un grupo idéntico de elementos
LR(0) que avanzan con un mismo símbolo que en algún estado anterior, dicha transición avanza al
estado ya conocido.
Ejemplo 3.9:
Sea $
Σ, N, %, C, donde:
n
|,}, ~, , , , +
N
, , , !
%
1 } | ,
1 | | ,
1 ~ | | | +
C
Construya el AFD asociado al analizador sintáctico SLR.
Comenzamos por agregar la producción C 1 . El estado inicial del AFD queda dado por:
xyzy{
C 1w , es decir:
C 1w
1w }
1w
1w |
1w
1w ~
1w
1w
1w +
Ahora debemos determinar las transiciones de . Como solo podemos tener a lo más una
transición por cada símbolo del alfabeto, tenemos que:
C 1w 1 1
1w } 1 1
1w 1 2
1w | 1 2
1w 1 3
1w ~ ~1 4
1w 1 5
1w 1 6
1w + + 1 7
Ahora es necesario determinar los elementos LR(0) que conforman cada uno de los nuevos
estados, así como las transiciones de estos últimos:
xyzy{
C 1 w, 1 w}
TABLA 3.6: Tabla de transiciones del AFD asociado al analizador sintáctico SLR.
Tomando como base la tabla de transiciones del AFD (tabla 3.6), se construye la tabla de análisis
sintáctico SLR de la siguiente forma:
1. Incorporar la operación de desplazamiento para cada transición con un terminal.
2. Incorporar la aceptación en el estado donde se tenga C9 1 C w con $.
3. Para cada estado en que se tenga algún elemento LR(0) de la forma 1 2 w, incorporar una reducción por
dicha producción en ese estado para cada símbolo en C.
4. Toda casilla no definida de la tabla corresponde a un error.
Ejemplo 3.10:
Construya la tabla de análisis sintáctico SLR para la gramática del ejemplo anterior. Muestre la
traza y el árbol sintáctico para m
~ } + | .
Comenzamos por incorporar los desplazamientos y la aceptación, como muestra la tabla 3.7.
Antes de incorporar las reducciones, necesitamos conocer los conjuntos siguientes para cada no
terminal. Así, tenemos que:
_`
#
%
%~ b %hi b % b %+
~ b b b +
~, , , +
%
% | b %
% b %
%
~, , , +
%
% } b %
% b %
~, , , +
Ahora debemos asignar un nombre a cada producción a fin de poder identificarlas en la tabla de
análisis sintáctico:
%
1 } | , 1 | |
, 1 ~ | | | +
La figura 3.4 muestra el funcionamiento del analizador sintáctico SLR y muestra el árbol
sintáctico resultante para la entrada m
~ } + | .
Ejemplo 3.11:
Construya un analizador sintáctico SLR para $
Σ, N, %, C, donde:
n
, @
N
, ,
%
1 ,
1 ,
1 @ |
C
Muestre la traza para m
@.
C 1w 1 1
1w 1 2
1 w 1 4
1w 1 3 1w @ @ 1 5
1w
C 1 w
1 w
1 w
1 @ w 1 7
1 @ w 1 6
1w 1 3
1w @ @ 1 5
1 @ w
1w
A continuación, necesitamos conocer C, C y C, y asignar nombres a las producciones:
C
$ b % b C
$ b %@ b % b C
$ b @ b # b C
$, @ b C
$, @
C
C b C
$, @ b C b C
$, @
%
1 , 1 , 1 @ |
A continuación, necesitamos conocer C, C y C, y asignar nombres a las producciones:
C
$ b % b C
$ b %@ b % b C
$ b @ b # b C
$, @ b C
$, @
C
C b C
$, @ b C b C
$, @
%
1 , 1 , 1 @ |
La tabla 3.9 muestra el analizador sintáctico SLR resultante, mientras que la traza 3.10 muestra
la traza para la entrada m
@.
Ejemplo 3.12:
Construya un analizador sintáctico SLR para $
Σ, N, %, C, donde:
n
.,, , , /
N
"
%
" 1 " . " | " " |" . " " | " | /
C
"
TABLA 3.10: Traza para la entrada m @ con el analizador sintáctico SLR de la tabla 3.9.
" 1 " w
Ahora construimos la tabla del analizador sintáctico SLR, que se muestra en la tabla 3.11.
Existen casos en que es más cómodo trabajar con gramáticas ambiguas, pues estas ofrecen una
representación más corta y natural que una gramática no ambigua equivalente. Al usar este tipo de
gramáticas se pueden producir dos tipos de conflictos: desplazamiento/reducción y
reducción/reducción. Para que el analizador sintáctico pueda funcionar correctamente es necesario
reducir estos conflictos, a fin de que en cada entrada de la tabla figure a lo más una acción. Esta tarea
resulta muy sencilla al trabajar con gramáticas de operadores. Para todos los analizadores sintácticos de
la famila LR se usan los mismos principios.
Un conflicto reducción/reducción significa que, dado el estado del AFD que se encuentra al tope de la
pila y el siguiente símbolo de la entrada, es posible efectuar dos reducciones diferentes (podrían ser
más). Para eliminar este tipo de conflictos de la tabla de análisis sintáctico LR siempre se escoge la
reducción correspondiente a la producción más larga, pues las reducciones para las producciones más
cortas habrán aparecido ya en otros estados del AFD.
Similarmente, un conflicto desplazamiento/reducción significa que, dado el estado del AFD que se
encuentra al tope de la pila y el siguiente símbolo de la entrada, es posible efectuar tanto un
desplazamiento como una reducción (podrían ser más desplazamientos y reducciones). La resolución
de los conflictos desplazamiento/reducción se hará en base a la precedencia y la asociatividad de los
operadores de la siguiente manera:
Si se tiene la configuración 2 6 w A y tiene mayor precedencia que , entonces se
escoge la reducción. En caso contrario, se escoge el desplazamiento.
Si se tiene la configuración 2 6 w A es asociativo por la derecha, entonces se escoge el
desplazamiento. En caso contrario, se escoge la reducción.
Ejemplo 3.13:
Elimine conflictos en el analizador sintáctico del ejemplo 3.12.
Para los conflictos entre reducciones existentes en , habíamos explicado que se conserva
únicamente la producción más larga. En consecuencia, descartamos t2 y conservamos
únicamente t3.
En jk7, .l nos encontramos ante el escenario " . " w .". En otras palabras, debemos decidir si
reducir la primera suma o terminar de leer la segunda. Como . es asociativo por la izquierda,
conservamos la reducción.
En jk7,l se tiene " . " w ". En otras palabras, debemos decidir si reducir suma o leer la
multiplicación. Como tiene mayor precedencia, conservamos el desplazamiento.
En jk8, .l nos encontramos ante el escenario " " w .". Como tiene mayor precedencia,
conservamos la reducción.
En jk8,l se tiene " " w ". En otras palabras, Como es asociativo por la derecha,
conservamos el desplazamiento.
Los conflictos restantes en jk11, .l y jk11,l son análogos a los de jk8, .l y jk8,l,
respectivamente, por lo que tomamos las mismas decisiones.
TABLA 3.12: Analizador sintáctico SLR del ejemplo 3.12 sin conflictos.
Al igual que en el caso del análisis sintáctico predictivo, existen dos grandes esquemas para la
recuperación de errores en la familia de analizadores sintácticos LR: en modo de pánico y a nivel de
frase.
Ejemplo 3.14:
Muestre la traza para la entrada m
// . / . / con el analizador sintáctico obtenido en el
ejemplo 3.13. Considere recuperación de errores en modo de pánico.
La idea de esta técnica es, basándose en el uso del lenguaje, determinar el error más probable que
pudiera cometer un programador para cada entrada de la tabla correspondiente a un error e implementar
una función que lo corrija.
Ejemplo 3.15:
Muestre la traza para la entrada m
// . / . / con el analizador sintáctico obtenido en el
ejemplo 3.13. Considere recuperación de errores a nivel de frase.
La tabla 3.14 muestra el analizador sintáctico del ejemplo 3.12 sin conflictos y con el esquema
de recuperación de errores a nivel de frase, mientras la tabla 3.15 muestra la traza con este
nuevo analizador sintáctico para la entrada m
// . / . /.
TABLA 3.14: Analizador sintáctico SLR del ejemplo 3.12 sin conflictos.
Un elemento LR(1) está conformado por un elemento LR(0), denominado núcleo, y un símbolo de
anticipación que puede ser un símbolo terminal o el delimitador de la entrada ($). Un mismo núcleo
puede ser común a varios elementos LR(1). Un ejemplo de elemento LR(1) puede ser 1 2 w 6, . El
símbolo de anticipación indica lo que debiera leerse en la entrada tras reducir por la producción
1 26. En otras palabras, el símbolo de anticipación es un elemento de C que puede,
efectivamente, aparecer inmediatamente después de una producción en un contexto dado. Esta es la
característica que hace posible construir analizadores sintácticos LR(1) para un conjunto más grande
que SLR. En consecuencia, DD1 s CDt s Dt1 s $D$.
Sean los elementos LR(1) 1 2 w, y 1 2 w, . En el caso del análisis sintáctico SLR estos
elementos podrían causar un conflicto reducción/reducción. No obstante, la incorporación del símbolo
de anticipación a los elementos permite decidir de manera más eficiente qué reducción ocupar. Dicho
símbolo solo será utilizado directamente al momento de llevar a cabo una acción de reducción, como se
explica más adelante.
La definición de la clausura coincide en muchos aspectos con la utilizada en la construcción del AFD
SLR. La única diferencia radica en la determinación de los símbolos de anticipación.
El proceso de construcción del AFD LR(1) es muy similar al SLR. También es necesario aumentar la
gramática $ con la producción C9 1 C. Luego se determina el estado inicial del AFD haciendo
xyzy{C : 1w C, $. Nótese que se escoge $ como símbolo de anticipación porque C : genera al
símbolo inicial de la gramática. Hay que recordar que C representa cualquier secuencia válida que
pueda ser generada por la gramática. Y Para tener esta validez, no puede venir nada más al término de
la secuencia.
A continuación se determinan las transiciones con todos aquellos símbolos precedidos por un punto en
algún elemento LR(1) de .
Para los nuevos estados, se determina la xyzy{ de los elementos LR(1) que le dan origen, es
decir, aquellos del estado anterior con el punto desplazado en una posición hacia la derecha. Las
transiciones se determinan igual que para . Si nos encontramos ante un grupo idéntico de elementos
LR(1) que avanzan con un mismo símbolo que en algún estado anterior, dicha transición avanza al
estado ya conocido. Nótese que en LR(1) no basta con solo tener las mismas producciones que avancen
con el mismo símbolo para ir a un mismo estado. Los símbolos de anticipación también deben
coincidir.
En la práctica los analizadores sintácticos LR(1) no son muy utilizados, puesto que, en general, los
autómatas son demasiado grandes y requieren de un espacio excesivo de almacenamiento.
En la construcción de la tabla de análisis sintáctico LR(1) para una gramática $ se hará uso del AFD ya
obtenido de la siguiente manera:
1. Incorporar la operación de desplazamiento para cada transición con un terminal.
2. Incorporar la aceptación en el estado donde se tenga C : 1 C w, $ con $.
3. Para cada estado en que se tenga algún elemento LR(1) de la forma 1 2 w, , incorporar una
reducción por dicha producción en ese estado para el símbolo de anticipación .
4. Toda casilla no definida de la tabla corresponde a un error.
Si las reglas anteriores generan acciones contradictorias se dice que la gramática no es LR(1) y no se
puede construir el analizador sintáctico. Una vez más, no obstante, muchas veces es posible obtener un
analizador sintáctico LR(1) para una gramática ambigua mediante reducción de conflictos como ya se
explicó.
Ejemplo 3.16:
Construya un analizador sintáctico LR(1) para $
Σ, N, %, C, donde:
n
,
N
,
%
1 ,
1 |
C
Compare el analizador sintáctico LR(1) con otro SLR para la misma gramática.
Comenzamos por la construcción del AFD LR(1):
C 1w , $ 1
1w , $ 1
1w , 1
1w , 1
1w , 1
1w , 1
C 1w , $ 1
1w , $ 1
1w , / 1
1w , / 1
1 w , $ 1
1 w , $ 1
1w , $ 1 1w , $ 1
1w , $ 1 1w , $ 1
1 w , / 1
1 w, $
1w , / 1
1w , / 1
1 w, /
1 w, /
1 w, $
Asignamos un nombre a las producciones para poder identificarlas al hacer las reducciones:
Asignamos un nombre a las producciones para poder identificarlas al hacer las reducciones:
%
1 , 1 |
1w , 1
1 w,
C 1 w
1 w, $
1 w , $ 1
1w 1
1 w
1w 1
Otra observación importante es que, para este ejemplo, ninguna de las dos tablas presenta
conflictos, por lo que $ es SLR. Debemos recordar que al ser SLR es también LR(1), pues
CDt s Dt1.
Ejemplo 3.17:
Construya un analizador sintáctico LR(1) para $
Σ, N, %, C, donde:
n
,, /
N
C, D, t
%
C 1 D
t | t,
D 1 t | /,
t 1 D
C
C
Compare el analizador sintáctico LR(1) con otro SLR para la misma gramática.
C9 1 C w, $ t 1 D w, /$
C 1 D w
t, $
1
C 1 D
t w, $
t 1 D w, $
t 1 D w, $
C 1 t w, $
D 1w t, $ t 1
t 1w D, $ D 1
D 1w t,
/$ t 1 D 1w t, $ 1
t 1w D,
/$ D 1 D 1w /, $ / 1
D 1w t,
/$ 1
D 1w /, /$ / 1 D 1 / w, $
D 1 / w, /$ D 1 t w, $
Asignamos un nombre a las producciones para poder identificarlas al hacer las reducciones:
Asignamos un nombre a las producciones para poder identificarlas al hacer las reducciones:
%
C 1 D
t |t , D 1 t | /
, t 1 D
D 1w t 1
D 1w / / 1
D 1w / / 1
t 1w D D 1
D 1 / w
C9 1 C w
C 1 D
w t t 1
t 1w D D 1
C 1 D w
t
1 D 1w t 1
t1Dw D 1w / / 1
C 1 t w D 1 t w
t 1 D w
C 1 D t w
Ahora construimos la tabla SLR, que se muestra en la tabla 3.19. Nuevamente la tabla SLR es
más pequeña que la tabla LR(1). No obstante, podemos afirmar que $ no es SLR, puesto dicha
tabla presenta un conflicto, mientras que sí cumple con ser LR(1).
El análisis sintáctico con anticipación o LALR (Lookahead Left to Right Parser) es de los más
utilizados en la práctica, pues sus tablas son del mismo tamaño que las SLR y significativamente más
pequeñas que las LR(1), y sin embargo puede manejar un conjunto de gramáticas más amplio que el
análisis sintáctico SLR: las gramáticas LALR(1), donde DD1 s CDt s DDt s Dt1 s $D.
Los analizadores LR(1) y LALR tienen igual funcionamiento en caso de comprobarse una entrada
correcta. Ante una entrada incorrecta, en cambio, el analizador LR(1) detecta el error de inmediato,
mientras que el analizador LALR puede efectuar algunas reducciones antes de detectarlo. No obstante,
el error será detectado sin necesidad de efectuar un desplazamiento.
Existen dos métodos para construir el AFD asociado a un analizador sintáctico LALR. El primero de
ellos comienza a partir del AFD LR(1) y funde en uno solo aquellos estados que son idénticos excepto
por los símbolos de anticipación. El segundo, en cambio, construye directamente el AFD de manera
similar al caso SLR, incorporando los símbolos de anticipación a medida en que éstos van apareciendo.
En consecuencia, se puede decir que un analizador sintáctico LALR es un analizador SLR con
símbolos de anticipación.
La tabla de análisis sintáctico LALR se construye de acuerdo a las mismas reglas que en el caso de
LALR.
Para comprender mejor este método, resulta más sencillo trabajar con un ejemplo. La idea es fusionar
en uno solo aquellos estados que contienen los mismos elementos LR(0), es decir, que solo se
diferencian por los símbolos de anticipación de los elementos LR(1).
Ejemplo 3.18:
Construya un analizador léxico LALR para la gramática del ejemplo 3.27. Tome como base el
analizador sintáctico LR(1) obtenido en dicho ejemplo.
Si juntamos los pares de estados semejantes, para cada elemento LR(1) tendremos los símbolos
de anticipación de ambos estados. En este caso particular, nos basta simplemente con conservar
, , y . Los estados que no se agrupan con otros quedan tal como están. Así, el AFD
resultante es:
C9 1 C w, $ D 1 t w, /$
C 1 D w
t, $
1
t 1 D w,
/$
t 1 D w, $
C 1 D
t w, $
C 1 t w, $
D 1w t,
/$ t 1
t 1w D,
/$ D 1
D 1w t,
/$ 1
D 1w /, /$ / 1
Una observación importante es que, pese a que $ no es SLR, sí es LALR al no existir conflictos
en la tabla. También es interesante notar que el AFD es idéntico al SLR, y lo único que varía en
la tabla son las reducciones debidas a los símbolos de anticipación.
Ejemplo 3.19:
Construya los analizadores sintácticos LR(1) y LALR para $
Σ, N, %, C, con:
n
, , ), ', *
N
, ,
%
1 | | | ,
1 ) | ',
1 ) | *
C
¿Qué puede concluir acerca de $?
1 w , $ 1
1 w , $ 1
1 w , $ 1
1w ), ) 1
1w ', ' 1
1 ) w,
1w ), ) 1 1 ) w,
1w *, * 1
1 ' w,
1 * w, 1 * w,
1 w , $ 1
1 w, $
1 ) w,
1 w, $
1 ) w,
1 w, $
1 ' w,
Ahora asignamos un nombre a las producciones y construimos la tabla LR(1), que se muestra en
la tabla 3.21.
Para construir el autómata LR(1) podemos encontrar las equivalencias que se muestran en la
tabla 3.22.
Podemos concluir que $ es LR(1) pero no es LALR. Además, al no ser LALR, tampoco puede
ser SLR ni LL(1).
En este caso, se tiene que la construcción del AFD se realiza igual que en SLR, siendo necesario
además incorporar los símbolos de anticipación.
Ejemplo 3.20:
Construya el analizador sintáctico LALR, usando el método directo, para $
Σ, N, %, C, con:
n
, @, , , k, l
N
%
1 @ | | kl |
C
Resuelva conflictos considerando que @ es asociativo por la izquierda.
TABLA 3.25: Analizador sintáctico LALR del ejemplo 3.20 tras la eliminación de conflictos.
3.6 EJERCICIOS
1. Determine los conjuntos Anulable, Primero y Siguiente para $
Σ, N, %, C, con:
n
,
N
, , , !
%
1 @ | | @/ |, 1 . | 0 | 0 | , 1 kl| !/ | 0, ! 1 # |
C
2. Construya un analizador sintáctico predictivo para la gramática del ejercicio anterior. Modifíquela
en caso necesario.
3. Construya analizadores sintácticos SLR, LR(1) y LALR para la gramática del ejercicio 1 y la
gramática modificada del ejercicio 2.
a. Haga las modificaciones necesarias y construya un analizador sintáctico LL(1). ¿Es posible?
b. Construya analizadores sintácticos SLR, LR(1) y LALR para la gramática original. En caso de
existir conflictos, considere que las precedencias de los operadores, de más alta a más baja, son:
, ., .. Asuma que . y . son asociativos por la izquierda.
c. Muestre la traza y el árbol resultante para m
.. con cada uno de los analizadores del
punto anterior.
d. Construya analizadores sintácticos SLR, LR(1) y LALR para la gramática obtenida en a.
5. Considere el analizador sintáctico SLR de la tabla 3.12 y la entrada m
/ /. ¿Qué ocurre al
hacer la traza usando recuperación de errores en modo de pánico? ¿Por qué?
6. ¿En qué casos se tiene que las tablas SLR y LALR son iguales? ¿Por qué?
7. Compruebe la equivalencia de los dos métodos para construie analizadores sintácticos LALR
repitiendo el ejercicio del ejemplo 3.20 usando la construcción a partir del analizador sintáctico
LR(1).
4 ANÁLISIS SEMÁNTICO
La palabra semántica, proveniente del griego semantikos (lo que tiene significado), se refiere a los
aspectos del significado o interpretación de un determinado código simbólico, lenguaje o
representación formal. Etapa del proceso de compilación se asocia información a una cierta
construcción del lenguaje de programación proporcionando atributos a los símbolos de la gramática
que la conforman.
A nivel conceptual, esta etapa está situada a continuación del análisis sintáctico, donde se construye el
árbol de análisis sintáctico. Durante el análisis semántico se recorre el árbol para evaluar las reglas
semánticas presentes en sus nodos (es decir, las reglas semánticas correspondientes a la producción
empleada en dicho nodo). Al evaluar una regla semántica se pueden efectuar diversas actividades, entre
ellas generar código, almacenar información en una tabla de símbolos y emitir mensajes de error. Al
finalizar esta etapa se obtiene como resultado la traducción de la cadena de componentes léxicos.
En la práctica, las tareas del análisis semántico se realizan simultáneamente con el análisis sintáctico,
en una dependencia similar a la que existe entre este último y el análisis léxico. Como las reglas
semánticas están asociadas a las producciones de la gramática, pueden ser ejecutadas una vez que se ha
reconocido la producción correspondiente.
Una definición dirigida por la sintaxis es una generalización de una gramática independiente del
contexto en la que cada símbolo de la gramática tiene un conjunto de atributos asociado, que se divide
en dos subconjuntos: atributos sintetizados y atributos heredados. Un atributo puede representar
cualquier cosa: una cadena de caracteres, un número, un tipo, una posición de memoria, etc.
El valor de un atributo en un nodo del árbol de análisis sintáctico se define mediante una regla
semántica asociada a la producción empleada en dicho nodo. Para un atributo sintetizado, se calcula a
partir de los valores de los atributos de los hijos del nodo en cuestión. Para un atributo heredado, se
calcula a partir de los valores de los atributos de los hermanos y el padre del nodo.
Las reglas semánticas establecen las dependencias entre los atributos, que se representan mediante un
grafo de dependencias. De este grafo se obtiene un orden de evaluación de las reglas semánticas. La
evaluación de las reglas semánticas permite conocer el valor de los atributos, pero además puede tener
efectos colaterales como imprimir un valor o actualizar una variable global.
En una definición dirigida por la sintaxis, cada producción gramatical 1 2 tiene asociado un
conjunto de reglas semánticas, de la forma +) , ) , … , )@ , donde:
+ es una función.
)8 son atributos pertenecientes a los símbolos gramaticales de la producción.
es un atributo sintetizado de o un atributo heredado de los símbolos del lado derecho de la
producción. Se dice que depende de los atributos )8 .
Se asume que los símbolos terminales solo tienen atributos sintetizados, ya que la definición dirigida
por la sintaxis no proporciona reglas semánticas para estos símbolos. El valor de estos atributos es, en
general, proporcionado por el analizador léxico. También se asume que el símbolo inicial de la
gramática no tiene atributos heredados, a menos que se indique lo contrario.
Las funciones de las reglas semánticas a menudo se escriben como expresiones. No obstante, cuando el
único propósito de una regla semántica es crear un efecto colateral, la regla se escribe como una
llamada a un procedimiento o un fragmento de programa.
Ejemplo 4.1:
La tabla 4.1 muestra la definición dirigida por la sintaxis para un programa que efectúa las
operaciones de adición y multiplicación. A cada uno de los no terminales ", j y se le asocia
un atributo sintetizado llamado x y la regla semántica correspondiente calcula el valor del
atributo x del no terminal del lado izquierdo de la producción a partir de los atributos x de
los no terminales del lado derecho. El componente léxico í tiene un atributo sintetizado
x*/, cuyo valor es proporcionado por el analizador léxico. La regla semántica asociada a la
producción D 1 " para el no terminal D es un procedimiento que imprime el valor de la
expresión aritmética generada por ". Obsérvese que es simplemente un carácter de salto de
línea.
TABLA 4.1: Definición dirigida por la sintaxis para efectuar las operaciones de adición y
multiplicación.
Este tipo de atributos es muy utilizado en la práctica. Una definición dirigida por la sintaxis que solo
emplee este tipo de atributos se denomina definición con atributos sintetizados y siempre es posible
generar un árbol de análisis sintáctico con anotaciones para este tipo de definiciones. La construcción
se efectúa evaluando en forma ascendente las reglas semánticas para los atributos en cada nodo.
Ejemplo 4.2:
La figura 4.1 muestra el árbol de análisis sintáctico con anotaciones para la entrada 3 5 . 4
obtenido con la definición dirigida por la sintaxis de la tabla 4.1.
Para comprender cómo se calculan los atributos sintetizados, considere primero el nodo interior
más bajo de la izquierda, correspondiente al uso de la producción 1 í. La regla
semántica correspondiente, . x í. x*/, establece el atributo . x con un valor de
3, que es el valor de í. x*/. De manera similar, se asigna también valor 3 al atributo
j. x. A continuación se procede del mismo modo con el siguiente subárbol izquierdo, de
donde se obtiene que . x 5. Al usar la producción j 1 j , se tiene que j. x
j . x . x
3 5
15. Finalmente, la regla semántica asociada a D 1 " imprime por
pantalla el valor de la expresión generada por ".
FIGURA 4.1: Árbol de análisis sintáctico con anotaciones para la entrada 3 5 . 4.
Son atributos cuyo valor en un nodo del árbol de análisis sintáctico se define a partir de los atributos
del padre y los hermanos de dicho nodo. Sirven para expresar la dependencia de una construcción de un
lenguaje de programación de acuerdo al contexto en que aparece. Por ejemplo, se puede determinar si
un identificador aparece en el lado izquierdo o derecho de una asignación para saber si se necesita la
dirección o el valor de dicho identificador.
Ejemplo 4.3:
La figura 4.2 muestra una definición dirigida por la sintaxis en que el no terminal ! genera una
declaración conformada por la palabra clave o seguida de una lista de identificadores.
El no terminal j tiene un atributo sintetizado tipo, cuyo valor se determina a partir de la palabra
clave de la declaración. La regla semántica D. *{ j. ;, asociada a la producción ! 1 jD,
asigna el tipo de la declaración al atributo heredado D. *{. Las reglas semánticas asociadas con
las producciones de D llaman a la función añadetipo para ingresar el tipo de cada identificador
a su entrada en la tabla de símbolos (apuntada por el atributo *={').
La figura 4.2 muestra el árbol de análisis sintáctico con anotaciones para la entrada
, , .
TABLA 4.2: Definición dirigida por la sintaxis para declarar variables reales y enteras.
FIGURA 4.2: Árbol de análisis sintáctico con anotaciones para la entrada , , .
Las dependencias se pueden representar mediante un grafo dirigido llamado grafo de dependencias, en
el que existe un nodo por cada atributo y una arista del nodo de ) al nodo de si el atributo depende
del atributo ). El algoritmo 4.1 muestra cómo se construye el grafo de dependencias.
FIGURA 4.3: ". x se sintetiza a partir de " . x y " . x.
Ejemplo 4.5:
Considere el árbol de análisis sintáctico con anotaciones de la figura 4.2. Su grafo de
dependencias se muestra en la figura 4.4.
FIGURA 4.4: Grafo de dependencias para el árbol de análisis sintáctico de la figura 4.2.
Un árbol sintáctico abstracto (AST) es una forma condensada de un árbol sintáctico para representar
construcciones de un lenguaje en su forma más simple o esencial. Algunas simplificaciones que se
observan en los AST son:
Operadores y palabras clave no aparecen ya como hojas, sino que son llevadas a un nodo interior
padre de los operadores asociados.
Omitir cadenas de producciones simples, por ejemplo 1 1 1 .
Se omiten detalles sintácticos tales como paréntesis y signos de puntuación, entre otros.
Ejemplo 4.6:
La figura 4.5 muestra el árbol de análisis sintáctico y el AST para la estructura if–else.
Ejemplo 4.7:
El árbol de análisis sintáctico de la figura 4.1, sin considerar las anotaciones, puede
simplificarse bastante al ser llevado a un AST, como muestra la figura 4.6.
La construcción de un AST para una expresión es similar a la traducción de la expresión a una forma
postfija. Es decir, se construyen subárboles para cada subexpresión mediante la creación de un nodo
por cada operador y cada operando, donde los hijos de un nodo correspondiente a un operador son las
raíces de los nodos que representan las subexpresiones que conforman los operandos de dicho
operador.
Cada nodo puede implementarse como un registro con varios campos. En un nodo de un operador, el
primer campo identifica el operador mientras el resto contiene punteros a los nodos de los operandos. A
modo de ejemplo, se consideran aquí las siguientes funciones para crear los nodos del árbol sintáctico,
donde cada una de ellas devuelve un puntero a un nuevo nodo recién creado:
crearNodo(, ;y;*{', '*{*)): crea un nodo para un operador con etiqueta y dos
campos con punteros a ;y;*{' y '*{*).
crearHoja(, *={'): crea un nodo para un identificador con etiqueta y un campo que
contiene *={', que es un puntero a la entrada de la tabla de símbolos correspondiente al
identificador.
crearHoja(ú , x ): crea un nodo para un número con etiqueta ú y un campo que contiene
x, el valor del número.
Ejemplo 4.8:
Sea la expresión 4 . ). La construcción del AST se lleva a cabo en cinco pasos, en forma
ascendente (el árbol resultante se muestra en la figura 4.6):
1. crearHoja(, *={'_ )
2. crearHoja(ú , 4)
3. crearNodo(9 9, , )
4.
crearHoja(, *={'_))
5. crearNodo(9 . 9, ,
)
Donde:
8 son punteros a nodos.
*={'_ y *={'_) son punteros a las entradas de la tabla de símbolos para los
identificadores y ) respectivamente.
Para el caso de sentencias que no corresponden a expresiones se procede en forma similar, como
muestra la tabla 4.3.
TABLA 4.3: Creación de nodos del AST para sentencias de flujo de control.
Se debe efectuar la comprobación en cada nodo del AST donde se utilice la información de los tipos
presentes en una expresión, e informar al usuario en aquellos casos en que no pueda darse una solución
a un conflicto. Este proceso consta de dos partes:
Llenar el AST con los tipos correspondientes para los literales (variables o valores).
Propagar los tipos por los nodos restantes del AST considerando:
• Tipos correctos para los operadores y operandos.
• Tipos correctos para los argumentos de función.
• Tipos correctos de retorno.
• Aplicar coerción en caso de que los tipos no coincidan.
Inicialmente se debe recorrer el AST para encontrar todos los identificadores literales o valores que se
encuentren presentes y recurrir a la tabla de símbolos para conocer sus tipos. Es recomendable efectuar
el recorrido con el algoritmo post-orden, que visita primero las hojas (identificadores literales y
valores) y luego los nodos intermedios (operadores), pues así se puede efectuar la comprobación de
tipos en una sola pasada.
Ejemplo 4.9:
Considere el AST para la expresión
. 1.0, que se muestra en la figura 4.8 (a). El
recorrido post-orden visita primero las hojas del árbol incorporando su tipo, con lo que se
obtiene el AST de la figura 4.8 (b).
Una vez conocidos los tipos para las hojas del AST, se deben determinar los tipos asociados a los
nodos intermedios, es decir, a los operadores. Si todos los operandos son de un mismo tipo, esta tarea
resulta ser trivial. No obstante, se debe efectuar una coerción en caso de que los tipos no sean iguales.
Para el caso en que los operandos tienen tipos diferentes, una estrategia es construir una tabla de
prioridades de conversión. Así, para cada operador se determina qué tipo de datos tiene preferencia,
viéndose en la tabla como el primer tipo que figura para el operador. Así, en caso de existir un conflicto
de tipos, se escoge el del operando cuyo tipo tiene mayor prioridad.
Ejemplo 4.10:
Considere nuevamente la expresión
. 1.0 y sea la tabla de prioridades de conversión que
se muestra en la tabla 4.4.
La figura 4.9 (a) muestra el AST con los tipos para los valores e identificadores literales
obtenido en el ejemplo anterior. Ahí se puede observar que el operador de adición tiene un
operando entero y otro real. De acuerdo a la tabla de prioridades de conversión, se determina
que el tipo float tiene mayor prioridad que el tipo int. En consecuencia, se utiliza la suma de
números reales para efectuar la operación. Como el resultado de la suma es de tipo float y la
variable a la que se asigna este valor es del mismo tipo, no es necesario efectuar ninguna
conversión para efectuar la asignación. La figura 4.9 (b) muestra el AST con los tipos para
operandos y operadores.
Si en el ejemplo 4.10 la variable fuese de tipo float y la suma de tipo int, de acuerdo a la tabla de
prioridades de conversión se tiene que la suma debe ser llevada al tipo float. No ocurre lo mismo, sin
embargo, si la variable ha sido declarada con un tipo de menor prioridad que el valor a asignarle. En
este caso se produce un error que debe ser notificado al usuario, pues no es posible cambiar el tipo
original con que una variable fue declarada.
Ahora bien, aunque ya se ha determinado el tipo para los operadores, aún no es posible efectuar la
operación pues los operandos siguen teniendo tipos diferentes. Para igualar los tipos de los operandos
es necesario llevar a cabo una conversión de tipos, llamada coerción.
Para determinar la conversión automática de un tipo a otro se construye una tabla de coerción, que
indica qué nodo es necesario agregar al AST para llevar a cabo la conversión. Estos nodos que se
agregan se llaman nodos de coerción. La conversión de tipos, no obstante, no puede ser arbitraria. Debe
tener en consideración el rango de valores posibles (por ejemplo, char s int s float).
Ejemplo 4.11:
Continuando con la expresión
. 1.0, sea la tabla de coerciones que se muestra en la tabla
4.5. En la figura 4.10 (a) se puede observar la necesidad de llevar el tipo de la variable b a float
en lugar de int. Esta coerción es incorporada en la figura 4.10 (b).
En esta etapa se comprueba que las asignaciones efectuadas sean válidas. La tabla 4.6 muestra un
conjunto de asignaciones válidas e inválidas, donde f() es una función.
Se define lvalue como la abreviación de left hand value, es decir, valor del lado izquierdo. Corresponde
a una expresión o referencia que puede ser puesta en el lado izquierdo de una asignación. Para que un
lvalue sea válido, debe ser una entidad modificable (como por ejemplo una variable).
Una estrategia para determinar si una asignación es válida o no consiste en crear una lista de lvaues
válidos y después comprobar si cada lvalue presente en el AST es o no válido de acuerdo a la lista,
usando para ello el algoritmo 4.2.
Esta comprobación se encarga de verificar diferentes aspectos relacionados con las funciones:
Que la cantidad de parámetros sea correcta.
Que los tipos de los parámetros sean correctos.
Que no existan múltiples main.
Para estas comprobaciones se utiliza una lista cuyos elementos son una estructura con los siguientes
elementos:
Nombre de la función.
Cantidad de parámetros.
Lista con el nombre y tipo de cada parámetro.
La obtención de los encabezados de función y de la lista de parámetros se efectúa a partir del AST.
Ejemplo 4.12:
Considérense las siguientes funciones:
function suma(int a, int b) {…}
function minimo(int a, int b, int c) {…}
function ordenar(float a, float b, float c, float d) {…}
La figura 4.11 muestra la lista para efectuar la comprobación al momento de usar dichas
funciones.
En el caso de aquellas funciones cuyo tipo de retorno sea distinto de void y puede, en consecuencia, ser
asignado a alguna variable, es necesario verificar que el tipo retornado sea correcto. En consecuencia,
es necesario comprobar que toda función que deba retornar algo finalice con una sentencia con la
palabra clave return. Si no se encuentra dicha sentencia, se debe notificar al usuario por medio de una
advertencia (warning).
Para evitar estos conflictos, se debe recorrer el bloque de código de la función y verificar la existencia
de una sentencia con la palabra clave return. Esta tarea se efectúa recorriendo el AST con el algoritmo
pre-orden, buscando la palabra return en todo el bloque de código de la función. Cuando la sentencia
de retorno se encuentre en un bloque if-then-else se debe comprobar la existencia de una sentencia de
retorno en ambos sub-bloques de código (o bien fuera del bloque), ya que se ejecutan
condicionalmente. Se debe proceder de manera similar para la sentencia switch.
Otra comprobación que se debe efectuar es verificar que, si corresponde, se retorne un valor que
indique que se produjo un error. El no señalar un error puede ocasionar consecuencias indeseadas a raíz
de una ejecución errónea del programa.
Por último se debe verificar que no exista código inalcanzable, que corresponde al código situado
después de la sentencia de retorno.
Una sentencia switch siempre contiene uno o más bloques de código: uno por cada caso definido y,
eventualmente, un bloque por defecto que se ejecutará en cualquier otro caso.
No obstante, también es sintácticamente correcto definir múltiples bloques para un mismo caso, es
decir, tener valores de casos duplicados. En tal caso no hay forma de determinar cuál de los bloques se
debe ejecutar, por lo que se debe advertir al usuario (warning). Generalmente, ante este conflicto se
genera el código correspondiente al primer bloque para el valor de caso en cuestión.
La comprobación para este error es muy sencilla y trabaja de manera recursiva. Se recorre el AST
comenzando por la raíz, en busca de todos los nodos switch. Por cada nodo switch que se encuentre, se
examinan sus nodos hijos (uno por cada bloque correspondiente a un caso) para ver si existe algún caso
duplicado. En caso afirmativo, se genera la advertencia correspondiente y se continúa con el análisis.
Muchas veces el uso de la sentencia goto se traduce en programas con un comportamiento extraño y no
deseado. Al igual que ocurre con las variables, es necesario declarar una etiqueta (label) goto para
poder usarla.
Una implementación adecuada para comprobar que existan las etiquetas goto es incorporar dichas
etiquetas en la tabla de símbolos. A continuación se debe recorrer el AST y, por cada nodo goto,
comprobar si la etiqueta de destino se encuentra en la tabla de símbolos. En caso de no encontrarse la
etiqueta, se debe reportar el error.
4.5 EJERCICOS
1. Considere la tabla de prioridades y la tabla de coerción que se muestran en las tablas 4.7 y 4.8.
Suponga, para este ejercicio, que la precedencia de los operadores, de mayor a menor, es: ^, /, , ..
El operador ^ corresponde a la exponenciación. Sea la expresión :
^)/' * . +/£ ^;,
donde es de tipo Complex; , ), +, £ y son de tipo Natural; ' e ; son de tipo Int; * es de tipo
Float.
a. Construya el AST para la expresión dada.
b. Construya el AST con coerciones y etiquetas de tipos.
c. Indique si existe algún tipo de error semántico en la expresión dada y justifique su respuesta.
d. ¿Qué ocurriría si ahora es de tipo Int? ¿Por qué?
e. ¿Qué ocurriría si ahora es de tipo Complex y ' es de tipo String? ¿Por qué?
2. Considere la tabla de prioridades y la tabla de coerción que se muestran en las tablas 4.7 y 4.8.
Considere además las mismas operaciones y precedencia de operadores del ejercicio 1. Sea la
expresión /:
/^) ' . * . +^£/ . ; >, donde + es de tipo Complex; , ; y / son de
tipo Float; , ), £ y > son de tipo Int; , ' y * son de tipo Natural.
a. Construya el AST para la expresión dada.
b. Construya el AST con coerciones y etiquetas de tipos.
c. Indique si existe algún tipo de error semántico en la expresión dada y justifique su respuesta.
3. Dado el fragmento de código del listado 4.1, efectúe las siguientes comprobaciones semánticas:
a. Código inalcanzable.
b. Retorno de funciones cuyo tipo es distinto de void.
c. Cuando un goto es encontrado, la etiqueta debe existir.
d. Que no haya trazas para las que no haya un retorno.
// no esta vacia
numeroPaquete *indice = plpqLista->primero;
numeroPaquete *antecesor = NULL;
while (indice) {
1: if (indice->intNumero < pintPaquete) {
antecesor = indice;
indice = indice->siguiente;
}
else {
pnpqAuxiliar->siguiente = indice;
plpqLista->primero = pnpqAuxiliar;
return;
}
}
pnpqAuxiliar->siguiente = NULL;
plpqLista->ultimo->siguiente = pnpqAuxiliar;
plpqLista->ultimo = plpqLista->ultimo->siguiente;
goto 1
}
4. Dadas las siguientes funciones, dibuje la estructura de datos que permite efectuar la comprobación
de sus parámetros.
elipse(float eje1, float eje2, int x0, int y0, float orientacion).
circulo(float radio, int x0, int y0) .
5.1.1 PROCEDIMIENTOS
Una definición de un procedimiento es una declaración que, en su forma más básica, asocia un
identificador con una proposición. El identificador se denomina nombre del procedimiento, mientras la
proposición conforma su cuerpo.
Cuando aparece el nombre de un procedimiento dentro de una proposición ejecutable, se dice que el
procedimiento es llamado. Cuando esto ocurre, la llamada provoca que el procedimiento se ejecute. Es
importante recordar que las llamadas a procedimientos también pueden ocurrir dentro de expresiones.
Durante la ejecución de un programa rigen ciertos supuestos sobre el sobre el flujo de control:
1. El control fluye secuencialmente.
2. Cada ejecución de un procedimiento comienza al inicio del cuerpo de éste y en algún momento
devuelve el control al punto situado inmediatamente después de la llamada.
En un árbol de activación:
Cada nodo representa la activación de un procedimiento.
La raíz representa la activación del programa principal.
El nodo es el padre del nodo si y solo si el control fluye de la activación a la .
El nodo está a la izquierda del nodo si y sólo si la duración de ocurre antes que la de .
Ejemplo 5.1:
La figura 5.1 muestra el árbol de activación correspondiente al programa en Pascal del listado
5.1.
procedure leemartriz;
var i: integer;
begin
for i:=1 to 9 do read(a[i])
end;
begin
a[0]:=-9999; a[10]:=9999;
leematriz;
clasificacion_por_particiones(1, 9);
end.
El flujo de control del programa corresponde al recorrido en profundidad del árbol de activación. Se
comienza desde la raíz y se visita cada nodo antes que a sus hijos. Los hijos son visitados
recursivamente de izquierda a derecha.
La pila de control permite llevar un registro de las activaciones de los procedimientos en curso. Se
introduce el nodo a la pila cuando comienza su activación y se saca cuando ésta termina. Los
contenidos de la pila se relacionan con los caminos hasta la raíz del árbol de activaciones: cuando un
nodo n está al tope de la pila de control, la pila contendrá a todos los nodos en el camino desde n hasta
la raíz.
FIGURA 5.1: Árbol de activación correspondiente a la salida de la ejecución del programa del
listado 5.1.
Ejemplo 5.2:
La figura 5.2 muestra la pila de control para un instante dado de la ejecución del programa del
listado 5.1, demarcado por las líneas continuas del árbol de activación.
FIGURA 5.2: Pila de control para el momento de ejecución demarcado con líneas
continuas en el árbol de activación.
Una declaración es una construcción sintáctica que asocia información a un nombre. Por otra parte,
toda declaración tiene un ámbito, es decir, una parte del programa donde puede ser aplicada.
Al momento de compilar se puede usar la tabla de símbolos para encontrar la declaración que aplica a
un nombre. Al declararse un nombre, se crea la entrada correspondiente en la tabla de símbolos. Se
retornará dicha entrada al buscar el nombre mientras dure el ámbito de la declaración.
Aunque un nombre se declare solo una vez en el programa, ese nombre puede indicar diferentes objetos
de datos durante la ejecución. Un objeto de datos se refiere a una posición de memoria que puede
contener valores.
Se define como ambiente una función que transforma un nombre en una posición de memoria. Por otra
parte, estado es una función que transforma una posición de memoria en el valor en ella contenido.
Nótese que ambas funciones son diferentes: una asignación modifica el estado, pero no el ambiente.
Estos conceptos se ilustran en la figura 5.3.
Se dice que un nombre x está enlazado a una posición de memoria s cuando está asociado a dicha
posición de memoria. Un enlace es la contrapartida dinámica de una declaración (puede haber más de
una activación de un procedimiento recursivo), como se muestra en la tabla 5.1.
Un compilador trabaja con un bloque de memoria asignado por el sistema operativo. Esta memoria
debe ser subdividida para que pueda albergar diferentes elementos (ver figura 5.4):
El código objeto generado.
Los objetos de datos.
Una contrapartida de la pila de control para registrar las activaciones de procedimientos.
El código objeto generado tiene un tamaño fijo al momento de la compilación, por lo que se puede
colocar estáticamente en una zona de la memoria. Lo mismo ocurre con algunos objetos de datos.
Resulta conveniente asignar en forma estática la mayor cantidad posible de datos, pues así éstos pueden
ser compilados al código objeto.
La pila de control se almacena en una porción diferente de memoria. Se deja además un bloque llamado
montículo, que almacena toda la información restante.
Tanto la pila como el montículo tienen tamaños variables, por lo que se sitúan en extremos opuestos de
la memoria a fin de que ambas puedan crecer según sea necesario.
Los registros de activación son bloques contiguos de memoria que almacenan toda la información
necesaria para una sola ejecución de un cierto procedimiento. La figura 5.5 muestra los campos
habituales de un registro de activación, aunque no todos los lenguajes ni todos los compiladores hacen
uso de todos esos campos.
Los tamaños de cada uno de estos campos pueden ser determinados en el momento en que es llamado
un procedimiento o incluso durante la compilación. La única excepción se produce cuando el
procedimiento contiene una matriz local cuyo tamaño venga dado por un parámetro actual.
Los diferentes campos del registro de activación pueden describirse como sigue:
Valor devuelto: sirve para devolver un valor al autor de la llamada. Para mayor eficiencia, se suele
trasladar este valor a un registro de la máquina.
Parámetros actuales: este campo es utilizado por el autor de la llamada para proporcionar
parámetros al procedimiento llamado. Es habitual, no obstante, pasar los parámetros por medio de
un registro.
Enlace de acceso opcional: sirve para hacer referencia a los datos no locales guardados en otros
registros de activación.
Estado guardado de la máquina: mantiene la información del estado de la máquina justo antes de
que el procedimiento fuese llamado (valor del contador del programa, valores de los registros, etc.).
Estos valores deben reponerse cuando el control regresa al procedimiento llamador.
Temporales: almacena valores temporales como los que surgen de la evaluación de expresiones.
La cantidad de memoria que se asigna a un nombre viene dada por el tipo de éste. Los tipos de datos
elementales generalmente pueden ser almacenados en un número entero de bytes. En el caso de datos
agregados, como matrices y registros, se debe asignar un bloque de memoria lo suficientemente grande
como para almacenar todos los componentes. Lo más usual es que se asignen bloques contiguos de
bytes para facilitar el acceso a cada uno de los datos.
En la sección anterior se mostró el registro de activación. En él, el campo para los datos locales se
determina durante la compilación, a medida que se examinan las declaraciones al interior del
procedimiento (no se incluyen aquí los datos cuya longitud es variable). La dirección relativa o
desplazamiento de un valor local con respecto a una posición de memoria, como el primer byte del
registro de activación, es la diferencia entre la posición del objeto y la dirección considerada como
referencia.
Este tipo de asignación se encarga de disponer la memoria para todos los objetos de datos durante el
proceso de compilación. Esto se realiza enlazando los nombres a las posiciones de memoria.
La asignación de memoria se efectúa tal como se describió en la sección 5.2.3. El compilador debe
decidir la ubicación de los registros de activación con respecto al código objeto y a los demás registros
de activación, con lo que la posición de cada uno de dichos registros queda determinada y, en
consecuencia, quedan determinadas también las posiciones de cada nombre dentro del registro. Todo lo
anterior hace posible que sea posible entregar al código objeto, durante la compilación, las posiciones
de memoria donde se encuentran los valores que requiere para su operación y las direcciones donde se
almacena la información al producirse una llamada a un procedimiento.
Como los enlaces no cambian durante la ejecución, cada vez que se activa un procedimiento sus
nombres se enlazan a las mismas posiciones de memoria. Esta característica hace posible que los
valores de los nombres locales sean retenidos de una activación a otra. No obstante, esta técnica de
asignación tiene asociadas algunas limitaciones:
El tamaño de un objeto de datos y sus limitaciones en cuanto a ubicación deben ser conocidos en el
momento de la compilación.
Es muy difícil crear procedimientos recursivos, pues todas las activaciones hacen uso de los
mismos enlaces para los nombres locales.
No es posible crear estructuras de datos dinámicamente porque no existe un mecanismo de
asignación de memoria durante la ejecución.
Este tipo de asignación está basado en la idea de una pila de control. Se da a la memoria la
organización de una pila, y en ella se agregan y se retiran los registros de activación cuando las
llamadas a procedimientos comienzan y terminan, respectivamente. La figura 5.6 muestra un ejemplo
de este de asignación mediante una pila.
Este esquema de asignación permite que para cada activación las variables locales se enlacen a nueva
memoria, puesto que se introduce un nuevo registro de activación a la pila. Además, al terminar la
activación las variables locales son eliminadas pues se quita el registro de activación de la pila.
En este punto es importante definir un problema muy habitual: las referencias suspendidas, que se
producen cuando se hace referencia a memoria desasignada. Este tipo de error corresponde a un error
lógico y no puede ser detectado mediante ninguno de los analizadores que se emplean en un
compilador. Esto se debe a que los lenguajes no consideran en su semántica el valor de la memoria
desasignada y a que muchas veces la memoria puede asignarse posteriormente a otro dato y crear así
errores ocultos en el programa.
La asignación por medio de una pila no puede utilizarse cuando ocurre alguna de las siguientes
situaciones, en que la desasignación de memoria no tiene por qué ser de la forma último en entrar,
primero en salir:
Se debe retener los valores de los nombres locales cuando finaliza una activación.
Una activación llamada sobrevive al autor de la llamada. Este caso no es posible en aquellos
lenguajes en que los árboles de activación representan correctamente el flujo de control.
La asignación por medio de un montículo divide partes de memoria contigua, conforme las necesiten
los registros de activación u otros objetos. Las distintas partes se pueden desasignar en cualquier orden,
de modo que con el paso del tiempo el montículo constará de áreas libres y ocupadas. En consecuencia,
se debe tener mucho cuidado con el manejo del montículo. Más adelante se muestran algunas técnicas
adecuadas.
FIGURA 5.6: Asignación de registros de activación por medio de una pila que crece hacia abajo.
5.4.1 BLOQUES
Un bloque es una proposición que contiene sus propias declaraciones de datos locales. En C, por
ejemplo, un bloque tiene la siguiente sintaxis:
{declaraciones proposiciones}
Una característica de los bloques es su estructura de anidamiento. Los delimitadores marcan el inicio y
el fin de un bloque (llaves en C). Éstos garantizan que un bloque sea independiente de otro o bien que
esté anidado dentro de otro. Esta propiedad de anidamiento recibe el nombre de estructura de bloques.
En un lenguaje con estructura de bloques el ámbito de una declaración viene dado por la regla de
anidamiento más cercano. Ésta se ejemplifica en la figura 5.7, donde se muestra un programa en C y se
señalan claramente los bloques y el ámbito de cada variable. La regla de anidamiento más cercano es:
1. El ámbito de una declaración en un bloque B incluye B.
2. Si un nombre x no está declarado en un bloque B, entonces un caso de x en B está en el ámbito de
una declaración de x en un bloque abarcador B’ que cumple las siguientes condiciones:
a. B’ tiene una declaración x.
b. B’ está anidado más cerca alrededor de B que cualquier otro bloque con una declaración de x.
Nótese que un bloque no es lo mismo que un procedimiento. Estos últimos son más simples, pues no
hay paso de parámetros y el control cumple las siguientes condiciones:
Fluye a un bloque desde el punto inmediatamente anterior a él en el texto fuente.
Fluye desde el bloque al punto inmediatamente posterior a él en el texto fuente.
Existen diferentes formas para implementar la estructura de bloques. Una de ellas es usar una pila.
Algunos lenguajes tienen reglas de ámbito léxico más complejas que otros. Por ejemplo, C no permite
anidar procedimientos, mientras que Pascal si lo permite. Esto significa que en C no es posible definir
un procedimiento dentro de otro y, en consecuencia, en caso de existir una referencia no local el
nombre debe declararse fuera de cualquier procedimiento. En este caso, el ámbito de una declaración
hecha fuera de alguna función consta de los cuerpos de todas las funciones que aparezcan después de
dicho nombre, excepto aquellas funciones que contengan una declaración homónima.
Una ventaja de este esquema es que los nombres no locales pueden pasarse como parámetros y
devolverse como resultado.
Lenguajes como Pascal difieren de C en el hecho de que es posible declarar un procedimiento dentro de
otro. Un ejemplo de esto es el código que se muestra en el listado 5.1. Aquí, un caso no local de un
nombre a se encuentra dentro del alcance de la declaración anidada más cercana de a en el texto del
programa fuente. En este tipo de lenguajes se tiene también que la regla de anidamiento más cercano se
aplica también a los nombres de procedimientos.
Cuando se trabaja con procedimientos anidados se debe manejar un nuevo concepto: profundidad de
anidamiento. La profundidad del programa principal se define como 1, y cada vez que de un
procedimiento se pase a otro abarcado por él se suma 1 a la profundidad. Por ejemplo, en el listado 5.1
la función partición está a profundidad de anidamiento 3.
Una manera directa de implementar el ámbito léxico para procedimientos anidados se obtiene al
incorporar a cada registro de activación un puntero llamado enlace de acceso. Así, si un procedimiento
está anidado inmediatamente dentro de c en el programa fuente, entonces el enlace de acceso de un
registro de activación para apunta al enlace de acceso del registro de activación más reciente de ).
Esto puede verse más claramente en la figura 5.8.
En este caso, una nueva activación hereda los enlaces ya existentes entre nombres no locales y la
memoria. Existen dos enfoques para implementar el ámbito dinámico: acceso profundo y acceso
superficial.
Acceso profundo: en este caso se prescinde de los enlaces de acceso y se utiliza el control para buscar
el primer registro de activación que contenga el nombre no local. El nombre se refiere a que se efectúa
una búsqueda en profundidad al interior de la pila y no es posible determinar la profundidad durante la
compilación.
Acceso superficial: en este caso se conserva el valor en curso de cada nombre en memoria asignado
estáticamente. Así, cuando se lleva a cabo una nueva activación de un procedimiento, un nombre = al
interior de él usará la memoria asignada estáticamente para dicho nombre. En este caso es necesario
guardar el valor previo de = para restaurarlo una vez terminada la ejecución del procedimiento.
FIGURA 5.8: Enlaces de acceso para encontrar las posiciones de memoria de los nombres no locales.
LISTADO 5.2: Procedimiento en Pascal que opera con parámetros y nombres no locales.
Existen diferentes métodos para asociar parámetros actuales y formales, de los cuales se estudiarán solo
los tres primeros por ser de uso más frecuente:
Llamada por valor.
Llamada por referencia.
Copia y restauración.
Llamada por nombre o macroexpansión.
Es importante conocer el método de paso de parámetros que utiliza un lenguaje (o compilador), pues el
resultado de un programa puede depender del método empleado.
Es el método más sencillo para pasar parámetros. Aquí se evalúan los parámetros actuales y se pasan
sus valores de lado derecho al procedimiento llamado. Una posible implementación para la llamado por
valor es la siguiente:
1. Un parámetro formal se considera como un nombre local, de modo que las direcciones de memoria
para los parámetros formales se encuentran en el registro de activación del procedimiento llamado.
2. El procedimiento autor de la llamada evalúa los parámetros actuales y coloca sus valores de lado
derecho en las direcciones de memoria de los parámetros formales.
Un ejemplo para este esquema de paso de parámetros se muestra en el listado 5.3. La ejecución de la
llamada permuta(a, b) es equivalente a la siguiente secuencia de pasos:
x := a
y := b
temp := x
x := y
y := temp
LISTADO 5.3: Programa en Pascal con un procedimiento que recibe parámetros por valor.
Una característica distintiva de la llamada por valor es que las operaciones sobre los parámetros
formales no afectan a los valores en el registro de activación del autor de la llamada.
El listado 5.4 muestra un segundo ejemplo, esta vez en C, donde se hace uso de punteros para pasar
parámetros por valor. En este caso se emula el comportamiento de la llamada por referencia.
LISTADO 5.4: Programa en C con un procedimiento que usa punteros y llamada por valor.
void permuta(x,y)
{
int *x, *y;
int temp;
temp = *x;
*x = *y;
*y = temp;
}
main()
{
int a = 1, b = 2;
permuta(&a, &b);
printf(“a es ahora %d, b es ahora %d\n”, a, b);
}
Cuando se pasan parámetros por referencia, el autor de la llamada pasa al procedimiento llamado un
puntero a la dirección de memoria de cada parámetro actual:
1. Si un parámetro actual es un nombre o una expresión que tenga un valor de lado izquierdo
(dirección en memoria), entonces se pasa ese mismo valor de lado izquierdo.
2. Sin embargo, si el parámetro actual es una expresión, como a + b ó 2, que no tiene ningún valor de
lado izquierdo, entonces la expresión se evalúa en una nueva posición y se pasa la dirección de
dicha posición.
Para un ejemplo de la llamada por referencia, considérese la función en C del listado 5.5. En este caso,
al efectuar la llamada cuadrado(3, y) se almacena el valor 9 en la variable y. Este estilo de paso de
parámetros resulta muy útil para poder retornar valores de forma implícita.
Este método de paso se parámetros es un híbrido entre las llamadas por valor y por referencia. Opera en
dos pasos, que se muestran a continuación:
1. Los parámetros actuales se evalúan antes de que el control fluya al procedimiento llamado. Los
valores del lado derecho de éstos se pasan a dicho procedimiento al igual que en la llamada por
valor. La diferencia radica en que, cuando es posible, los valores de lado izquierdo (posiciones de
memoria) de los parámetros actuales se determinan antes de la llamada.
2. Cuando el control retorna, se copian los valores de lado derecho en curso para los parámetros
formales en los valores de lado izquierdo de los parámetros actuales, para lo cual se utilizan los
valores de lado izquierdo calculados antes de la llamada. Esta copia se efectúa solo para aquellos
parámetros actuales con valor de lado izquierdo (es decir, aquellos parámetros que son un nombre
y, por ende, tienen un enlace en memoria).
El listado 5.6 muestra un procedimiento en Pascal en que el resultado cambia según qué método de
paso de parámetros se emplee. En este caso, existen dos maneras de acceder a la posición de a en el
registro de activación de copiaafuera() cuando éste llama a inseguro(): como nombre no local o
mediante el parámetro formal x. Así, al usar las formas mencionadas de paso de parámetros se obtienen
los siguientes resultados:
Llamada por referencia: en este caso, las asignaciones hechas a x y a a afectan de inmediato a a,
por lo que el valor final para este nombre que se muestra por pantalla es 0.
Copia y restauración: aquí el valor 1 del parámetro actual a se copia en el parámetro formal x.
Justo antes de terminar la ejecución de inseguro(), se copia el valor final de x (que es 2) en el valor
de lado izquierdo de a, por lo que el valor para este nombre que se muestra por pantalla es 2.
LISTADO 5.6: Programa en Pascal para el cual cambian los resultados según si se pasan parámetros
por referencia o mediante copia y restauración.
program copiaafuera(input,output);
var a : integer;
procedure inseguro(var x : integer);
begin
x := 2;
a := 0
end
begin
a := 1;
inseguro(a);
writeln(a)
end.
En otras palabras, se necesita una estructura que permita llevar un registro de la información sobre el
ámbito y el enlace de los nombres; si corresponden a variables, conocer su valor, su tipo o dónde se
encuentran almacenadas; para funciones, determinar si han sido declaradas previamente, etc.
Esta estructura recibe el nombre de tabla de símbolos. Cada vez que se encuentra un nombre en el
programa fuente se debe examinar esta tabla y, si es un nombre nuevo, se crea una nueva entrada; si se
encuentra nueva información sobre un nombre ya existente, se debe incorporar dicha información a la
entrada correspondiente. En consecuencia, es necesario implementar la tabla de símbolos que permita
añadir nuevas entradas y encontrar las ya existentes de manera eficiente.
Como se puede desprender de la descripción anterior, cada entrada de la tabla de símbolos corresponde
a la declaración de un nombre. Ahora bien, los nombres pueden corresponder a distintos tipos de datos
o a funciones y procedimientos, por lo que no todos los nombres tendrán los mismos atributos. Esto
hace que sea difícil e ineficiente crear un registro de tamaño uniforme para poder almacenar la
información para cada tipo de datos, por lo que una buena alternativa es almacenar esta información en
algún lugar de la memoria fuera de la tabla de símbolos y en ésta mantener un puntero a dicha
información.
Otro problema importante en la construcción de la tabla de símbolos es que no todas las entradas se
agregan a la vez:
Si el analizador léxico no reconoce palabras clave o reservadas, éstas deben estar presentes en la
tabla de símbolos antes de comenzar esta etapa de análisis.
Una entrada de la tabla de símbolos solo puede establecerse cuando se conoce claramente el papel
que juega el nombre en el programa y los valores de los atributos solo se van agregando a medida
que se conoce esa información.
Cuando un nombre puede ocuparse solo una vez, es posible crear la entrada de la tabla de símbolos
durante el análisis sintáctico. En otro caso, las diferentes entradas para un mismo nombre se van
creando a medida que se descubre el rol sintáctico de cada instancia del nombre.
Los atributos de los símbolos se introducen frecuentemente como consecuencia de su declaración, que
puede ser implícita. Además, es la sintaxis de las declaraciones de procedimientos la que especifica que
algunos identificadores corresponden a parámetros formales.
Un último problema a considerar es que los nombres, vistos como lexemas, pueden ser difíciles de
manejar debido a las diferentes longitudes de los nombres. En consecuencia, se debe implementar
alguna representación de un nombre de longitud fija que pueda ser incorporado en la tabla de símbolos.
No obstante, se debe tener cuidado de conservar en memoria, fuera de la tabla de símbolos, el
identificador completo a fin de poder comprobar si dicho lexema ya ha aparecido.
Existen tablas de símbolos estáticas y dinámicas, y es importante poder determinar qué tipo de tabla se
debe ocupar:
Estática: es útil cuando se debe acceder a los símbolos en múltiples ocasiones.
Dinámica: se usa cuando la información de los símbolos es utilizada en solo una pasada.
La figura 5.9 muestra un pequeño programa en C y los elementos presentes en la tabla de símbolos
tanto para el caso estático como para el dinámico. Se puede observar que en el caso estático se
incorporan los nombres de todos los procedimientos a medida que van apareciendo, pero no se
eliminan. En el caso dinámico, en cambio, solo figuran aquellos elementos pertenecientes a los
procedimientos activos.
Listas enlazadas:
• Similares a las pilas.
• La implementación de operaciones resulta más sencilla.
• Al igual que en los arreglos, las búsquedas toman un tiempo lineal.
Árboles binarios:
• Si los elementos se encuentran ordenados, se reduce el tiempo de búsqueda en forma
significativa.
• El manejo del alcance de los símbolos se vuelve más complejo.
• Las operaciones de inserción y eliminación de elementos son muy costosas en tiempo.
Árboles n-arios:
• Todo nodo es un alcance, y todos los símbolos de ese nivel de alcance están contenidos en
dicho nodo.
• Cada nodo tiene n hijos, y cada hijo implica un nuevo alcance de su alcance padre.
Tablas de dispersión (tablas hash): corresponden a una de las técnicas más utilizadas para la
implementación de tablas de símbolos y se ilustran en la figura 5.10. Se construye de la siguiente
manera:
• Se crea en primer lugar una tabla de dispersión, que es una matriz fija con ¤ punteros a
entradas de la tabla.
• Las entradas de la tabla se organizan en ¤ listas enlazadas, independientes entre sí, donde cada
registro de la tabla de símbolos aparece solo en una de estas listas. Para determinar en qué lista
debe ir la entrada para el nombre z se utiliza una función de dispersión z que devuelve un
entero entre 0 y ¤ 1. Si s se encuentra en la tabla de símbolos, estará en la lista numerada con
z. En caso contrario, se debe crear una nueva entrada en dicha lista.
Muchas veces resulta difícil manejar el ámbito de un nombre si se trabaja con una única tabla de
símbolos. Una buena manera de resolver este problema es construir una tabla de símbolos diferente
para cada procedimiento, con punteros para llegar a ellas y volver a la tabla anterior.
Ejemplo 5.3:
Considere el fragmento de código con procedimientos anidados del listado 5.7 (se listan solo las
declaraciones y llamadas a procedimientos). En la tabla de símbolos dicho fragmento de código
(figura 5.11) se puede observar que cada tabla contiene al inicio un campo con un puntero al
lugar desde donde se hizo la llamada al procedimiento. Además, en las entradas
correspondientes a nombres de procedimientos, se tienen punteros que apuntan a la tabla de
símbolos correspondiente.
// Main
var nota;
calcular_promedio();
// calcular_promedio
var n_cat;
var n_lab;
calcular_n_cat();
calcular_n_lab();
// calcular n_cat
var n_peps;
var n_controles;
ponderar_peps();
promediar_controles();
// calcular n_lab
var i;
var j;
// ponderar_peps
var ponderacion;
var p1;
var p2;
var p3;
determinar_por();
// promediar_controles
var c1;
var c2;
var c3;
var c4;
// determinar_por
var aprueba;
var promedio;
var reprueba;
Algunos lenguajes proporcionan facilidades para la asignación dinámica de memoria para los datos,
para lo cual generalmente se utiliza un montículo. No obstante, la asignación puede ser de dos tipos:
Explícita: se utilizan los métodos estándar para este fin. Por ejemplo, la ejecución de new(p) en
Pascal asigna memoria para el tipo de objeto señalado por p y p apunta al objeto recién asignado.
Implícita: se produce cuando la evaluación de una expresión tiene como resultado la obtención de
memoria para guardar los valores de la expresión. Un ejemplo de esto se puede encontrar en
Snobol, que permite que la longitud de una cadena varíe durante la ejecución y administra el
espacio del montículo necesario para almacenarla.
Un concepto importante que se debe definir es el de basura. Corresponde a todas aquellas posiciones
de memoria asignadas que ya no pueden alcanzarse. Por ejemplo, considere una lista enlazada en C. Si
se asigna NULL a la cabeza de la lista, ya no es posible alcanzar ninguno de los nodos restantes aún
cuando éstos ya han sido asignados.
Algunos lenguajes, como Lisp y Java, realizan un proceso de recolección de basura y reclaman la
memoria inaccesible. Otros lenguajes, como C y Pascal, no tienen esta funcionalidad, por lo que el
programador debe liberar explícitamente la memoria que ya no se ocupará. Estos lenguajes permiten
reutilizar la memoria liberada, pero la basura se mantiene hasta el término de la ejecución del
programa.
Al asignar y desasignar bloques puede producirse una fragmentación de la memoria, como se muestra
en la figura 5.13. Esta no causa problemas si se trabaja con bloques de tamaño fijo, pero si se tienen
bloques de tamaño variable no es posible asignar una porción de memoria mayor que el más grande de
los bloques disponibles, aún cuando se cuente con el espacio necesario.
Para las dos técnicas anteriores, si el bloque asignado es de mayor tamaño que el requerido se divide en
dos bloques de menor tamaño: uno del tamaño requerido, donde se hará la asignación; el otro, con el
espacio restante, que quedará libre para ser asignado posteriormente.
Una buena forma de evitar una fragmentación mayor que lo necesario es, cada vez que se desasigne un
bloque, comprobar si es adyacente a otro bloque libre. De ser así, se funden ambos bloques en uno solo.
Se debe considerar también que existe un compromiso entre tiempo, espacio y disponibilidad de
bloques.
Para llevar a cabo esta tarea se requiere saber cuándo un bloque de memoria ha dejado de funcionar.
Esto puede lograrse fijando el formato de los bloques de memoria, como se muestra en la figura 5.14.
En primer lugar, se necesita reconocer las fronteras del bloque. Para bloques de tamaño fijo, se puede
determinar simplemente conociendo su posición (índice del bloque, como en la figura 5.12). Si se
trabaja con bloques de tamaño variable, en cambio se debe reservar un fragmento del bloque para
almacenar su tamaño y así poder determinar dónde comienza el bloque siguiente.
También es necesario saber si un bloque está en uso. Se dice que es así si el programa del usuario
puede hacer referencia a la información contenida en dicho bloque, ya sea mediante un puntero o una
secuencia de ellos. Por ende, el compilador necesita conocer la posición donde se encuentra cada uno
de esos punteros. Estos pueden guardarse en una posición fija dentro del bloque. Se supone que el área
del bloque con información del usuario no contiene punteros.
Para llevar a cabo la desasignación implícita pueden emplearse dos enfoques diferentes, que se
describen en los puntos siguientes.
Se cuenta la cantidad de bloques que hacen referencia al bloque actual. Si la cuenta es igual a 0, este
último puede ser liberado.
Esta técnica requiere que se conozcan todos los punteros del montículo. Consiste en detener por un
momento la ejecución del programa de usuario y hacer un seguimiento de todos los punteros para
determinar qué bloques pueden ser alcanzados y, en consecuencia, están en uso.
Una forma de implementarlo anterior es marcar inicialmente los bloques como no ocupados. A
continuación, se hace un seguimiento de cada uno de los punteros del montículo y los bloques
alcanzados se marcan como ocupados. Cuando ya no quedan punteros por revisar, se borran todos los
bloques marcados como no ocupados.
Al usar este enfoque con bloques de tamaño variable, es posible llevar a cabo una compactación de los
datos para eliminar la fragmentación de la memoria. Esta consiste en desplazar todos los bloques en
uso a un extremo del montículo, con la correspondiente actualización de todos los punteros.
5.8 EJERCICIOS
1. Dado el fragmento de código del listado 5.8:
a. Muestre su árbol de activación.
b. Muestre el estado de la pila de control cuando se efectúa la llamada a factorial(2).
2. Considere la porción de memoria que se muestra en la figura 5.15, donde los bloques más oscuros
corresponden a fragmentos utilizados:
a. Muestre el estado final de la memoria tras efectuar las siguientes asignaciones considerando los
métodos de primer ajuste y mejor ajuste.
Un bloque de 6 bytes.
Un bloque de 2 bytes.
Un bloque de 4 bytes.
Un bloque de 8 bytes.
int fibonacci(int n) {
if(n == 0) {
return 0;
}
if(n == 1) {
return 1;
}
return fibonacci(n - 2) + fibonacci(n - 1);
}
int factorial(int n) {
if(n == 0) {
return 1;
}
return n * factorial(n - 1);
}
void main() {
unsigned long x = fibonacci(5);
unsigned long y = factorial(4);
}
Hasta ahora se había explicado que la traducción llevaba el programa fuente a un programa escrito en
el lenguaje objeto. Ahora bien, en muchas ocasiones es preferible traducir primero a un lenguaje
intermedio.
Considere, por ejemplo, la figura 6.1. En ella se puede observar la enorme cantidad de trabajo necesaria
para traducir un programa escrito en cada uno de los lenguajes de alto nivel a cada uno de los lenguajes
de máquina. En el caso del lado izquierdo se puede ver que, para cada lenguaje fuente, se debe
construir un compilador diferente por cada lenguaje objeto. El lado derecho, en cambio, muestra la gran
ventaja de la redestinación: se puede crear un compilador para una máquina distinta uniendo una fase
de análisis que genere código intermedio a una fase de síntesis que genere código objeto para la
máquina destino.
Una segunda ventaja que otorga el uso de un lenguaje intermedio es que se puede aplicar un
optimizador de código independiente de la máquina en que va a ser usado el programa.
Los AST para las proposiciones de asignación de un lenguaje se pueden producir mediante las
definiciones dirigidas por la sintaxis de la tabla 6.1. Esta misma definición se puede usar para construir
los GDA, siempre y cuando las funciones )*{_' y )*{_'¥={; retornen un puntero a un
nodo ya existente siempre que sea posible.
TABLA 6.1: Definición dirigida por la sintaxis para construir AST para proposiciones de asignación.
Una tercera representación gráfica es la representación postfija, que es una representación lineal del
árbol sintáctico.
Nótese que no se permiten operaciones aritméticas compuestas, pues solo se puede tener un operador al
lado derecho de una proposición. En consecuencia, una expresión de la forma / . 0 se puede
traducir en una secuencia:
:
0
:
/ .
Donde y son variables temporales generadas por el compilador. Al hacer esto se debe tener
cuidado de respetar la precedencia de los operadores.
El listado 6.1 muestra el código de tres direcciones que se obtiene para el AST y el GDA
correspondientes a la expresión
) . ).
El código de tres direcciones recibe ese nombre porque en cada proposición suele contener tres
direcciones de memoria: una por cada operando y otra para el resultado.
Las proposiciones de tres direcciones son análogas al código ensamblador. Pueden tener etiquetas
simbólicas, y existen también proposiciones para el flujo de control. Las proposiciones de tres
direcciones comunes son:
1. Proposiciones de asignación de la forma /:
0 ¦ , donde ¦ es una operación lógica o
aritmética.
4. El salto incondicional ", donde " es la etiqueta de la proposición de tres direcciones que
debe ejecutarse a continuación.
/ es un parámetro.
corresponde a un procedimiento o función.
= es la cantidad de parámetros del procedimiento.
0 representa el valor devuelto (es opcional).
7. Las asignaciones con índices de la forma /:
0k;l y /k;l:
0. La primera asigna a / el valor de la
posición en ; unidades de memoria más allá de la posición 0. La segunda asigna el contenido de la
posición en ; unidades de memoria más allá de la posición / al valor de 0.
Una proposición de 3 direcciones es una forma abstracta de código intermedio. En un compilador, estas
proposiciones pueden implementarse como registros con campos para el operador y los operandos. A
continuación se muestran tres implementaciones diferentes.
6.1.2.3.1 Cuádruplos
Corresponden a estructuras de tipo registro con cuatro campos: , que contiene un código interno para
el operador, {£ y {£ contienen los operandos y {*zyx', como su nombre lo indica, contiene el
resultado de la operación. Por supuesto, solo se usan todos los campos en el paso de los operadores
binarios, por lo que se debe aclarar qué ocurre con los demás tipos de instrucciones:
Operadores unarios: no se utiliza {£.
param y similares: no se utilizan {£ ni {*zyx'.
Saltos condicionales e incondicionales: se pone la etiqueta objeto en {*zyx'.
En general, los contenidos de los campos {£, {£ y {*zyx' son punteros a las entradas
correspondientes de la tabla de símbolos. Adicionalmente, se debe señalar que los nombres temporales
deben introducirse en la tabla de símbolos conforme van siendo creados.
La tabla 6.2 muestra la representación mediante cuádruplos del código de tres direcciones para la
expresión :
) . ).
6.1.2.3.2 Triples
Esta representación tiene la ventaja de no introducir nombres temporales a la tabla de símbolos. Para
esto, los valores temporales son referenciados de acuerdo a la posición de la proposición en que son
calculados. Como consecuencia, las proposiciones de tres direcciones se pueden representar mediante
registros con solo tres campos: , {£ y {£. Los campos {£ y {£ contienen punteros a la
tabla de símbolos para las variables y constantes definidas por el programador o bien punteros dentro
de la misma estructura de triples para hacer referencia a los valores temporales.
La tabla 6.3 muestra la representación mediante triples del código de tres direcciones para la expresión
:
) . ).
En la práctica, la información necesaria para interpretar las distintas clases de entrada en los campos
{£ y {£ se puede codificar dentro del campo o en campos adicionales.
Obsérvese que las operaciones ternarias, como /k;l:
0 requieren dos entradas en la estructura de
triples, como se puede ver en la tabla 6.4 (a). La figura 6.4 (b) muestra que la operación / 0k;l se
representa mediante dos entradas en forma natural.
En este caso, en lugar de hacer una lista de los triples mismos, se construye una lista de punteros a
triples. Así, por ejemplo, se puede usar una matriz proposición para listar los punteros a triples en el
orden que se desee. La tabla 6.5 muestra la representación mediante triples indirectos de los triples de
la tabla 6.3.
Si bien la utilización de triples indirectas requiere más espacio de almacenamiento que el uso de triples,
tiene la ventaja de que la reubicación de código se vuelve mucho más sencilla. Esto resulta útil, por
ejemplo, para la optimización.
6.2.1 DECLARACIONES
La sintaxis de lenguajes como C y Pascal permite que todas las declaraciones en un mismo
procedimiento se procesen como un grupo. Para este fin se utiliza una variable global, por ejemplo
'*zx¤;*=, que indica la siguiente dirección relativa de memoria disponible.
La tabla 6.6 muestra una gramática que permite efectuar declaraciones junto a un esquema de
traducción. La variable '*zx¤;*= es inicializada en 0 antes de considerar la primera
declaración. Los nuevos nombres son incorporados a la tabla de símbolos a medida que van
apareciendo, en una posición relativa igual a '*zx¤;*=. Al incorporar un nuevo símbolo, es
necesario aumentar '*zx¤;*= en el ancho (usualmente en bytes) del objeto de datos indicado
por el nombre.
En un lenguaje con procedimientos anidados, se pueden asignar direcciones relativas a los nombres
locales a cada procedimiento. Se puede suponer que existe una tabla de símbolos diferente para cada
procedimiento, mecanismo que puede ser implementado, por ejemplo, mediante una lista enlazada.
La figura 6.3 muestra el esquema de procedimientos anidados, mientras que la tabla 6.7 muestra un
esquema de traducción (que se agrega al de la tabla 6.6) que permite crear las diferentes tablas de
símbolos y sus punteros. El esquema hace uso de las siguientes funciones:
){*{jx{*;: crea una nueva tabla de símbolos y retorna un puntero a la nueva tabla. El
argumento {*; corresponde a un puntero a la tabla de símbolos del procedimiento que abarca al
nuevo procedimiento declarado. En la figura 6.3, {*; podría ser, por ejemplo, el puntero desde
{;);ó= hacia *=)*'.
;={'y);{x, =¤{*, ;, '*zx¤;*=: crea en la tabla de símbolos apuntada por
x una nueva entrada para =¤{*, con su tipo ; y su posición relativa '*zx¤;*=.
ñ';{=)x, =): registra el ancho acumulado de todas las entradas de tabla en el
encabezamiento asociado a dicha tabla de símbolos.
;={'y);{%{)x, =¤{*, x_y*: crea en la tabla de símbolos apuntada por x
una nueva entrada para el procedimiento =¤{*. El puntero x_y* apunta a la tabla de
símbolos del procedimiento nombre.
'*zx¤;*= es una pila que almacena la siguiente posición relativa disponible para un
nombre local del procedimiento en curso. Convertir '*zx¤;*= en una pila es la extensión
natural para ajustar el esquema de la tabla 6.6 para procedimientos anidados.
Otro tipo habitual de declaraciones son los registros o estructuras. Para poder tenerlo en cuenta, es
necesario incorporar al esquema de traducción de la tabla 6.7 la producción j 1 ¨ D! , que
permite generar registros. La tabla 6.8 muestra cómo se construye la tabla de símbolos para los
nombres de los campos de un registro.
Para esta sección se asume que las expresiones pueden contener elementos de tipo entero, real, matriz,
registro y puntero. Como parte de la generación de código de tres direcciones, se explica además cómo
buscar los nombres en la tabla de símbolos y acceder a los elementos de matrices y registros.
Al generar código de tres direcciones se crean nombres temporales para los nodos interiores del árbol
sintáctico. El valor del no terminal " al lado izquierdo de la producción " 1 " . " se calcula en un
temporal . Se creará un nuevo temporal cada vez que sea necesario.
En el caso de que una expresión contenga solamente un identificador, por ejemplo 0, entonces 0
contiene el valor de la expresión.
TABLA 6.8: Creación de la tabla de símbolos para los nombres de los campos de un registro.
La tabla 6.9 muestra las reglas semánticas que permiten crear código de tres direcciones para
proposiciones de asignación. Si se toma la entrada
) . ), el código resultante es el del
listado 6.1 (a).
Hasta ahora se habían formado las proposiciones de tres direcciones utilizando los nombres de las
variables considerados como punteros hacia sus entradas en la tabla de símbolos. El esquema de
traducción de la tabla 6.10 muestra cómo se puede efectuar la búsqueda de las entradas en la tabla de
símbolos. En dicha tabla:
El atributo . =¤{* corresponde al lexema del nombre.
La operación yz){=¤{* verifica la existencia de una entrada en la tabla de símbolos para
=¤{*. En caso afirmativo retorna un puntero a la entrada, mientras que en caso contrario retorna
un puntero nulo.
La función *¤;;{)'*= escribe las proposiciones de tres direcciones generadas (representadas
por )'*=) a un archivo de salida. Esto elimina la necesidad del atributo )';£ de la tabla 6.9.
Se había señalado que la función *¤_y* crea un nuevo temporal cada vez que sea necesario. Esto
resulta especialmente útil en compiladores optimizadores. No obstante, los temporales que almacenan
valores intermedios requieren espacio de almacenamiento y tienden a obstruir la tabla de símbolos.
Esta función puede ser modificada para que utilice una pequeña matriz en un área de datos del
procedimiento como si fuera una pila para guardar los temporales. Como la mayoría de los temporales
solo se ocupan una vez, se puede crear un contador ), inicializado en 0, que se decrementa en 1 cada
vez que se utilice como operando un nombre temporal. Cuando se genera un temporal nuevo, se utiliza
la posición $) y se incrementa ) en 1. A aquellos poco frecuentes temporales que se usan más de una
vez se les puede asignar un nombre propio.
TABLA 6.9: Definición dirigida por la sintaxis que permite producir código de tres direcciones para
las asignaciones.
Ejemplo 6.1:
Genere código intermedio para la expresión /
15 43 . 3 /2 . 5.
¤*=zy 43
.
15
/2
3
/ . 5
Las expresiones booleanas tienen dos grandes propósitos en los lenguajes de programación. Se utilizan
para calcular valores lógicos y como expresiones condicionales para alterar el flujo de control.
Las expresiones booleanas pueden ser generadas por una gramática con las siguientes producciones:
" 1 " " | " " | " | " | ¦ | © | §¯, donde se considera un atributo
para determinar el operador de comparación representado por ¦. Se asume que tanto como
asocian por la izquierda, y que la precedencia de operadores (de mayor a menor) es , y .
Es habitual usar una representación numérica para los valores booleanos. En este caso se considera el
valor 1 como © y el valor 0 como §¯. La evaluación de las expresiones se efectúa de izquierda
a derecha y siguiendo la precedencia de operadores, de manera similar a las expresiones aritméticas.
TABLA 6.10: Esquema de traducción para generar código de tres direcciones para asignaciones.
Ejemplo 6.2:
Genere código intermedio para la expresión /
) '.
LISTADO 6.3: Código intermedio para la expresión / ) '.
) '
/
La tabla 6.11 muestra un esquema de traducción a código de tres direcciones que usa una
representación numérica para los valores booleanos y utiliza el esquema condicional para evaluar
operaciones relacionales.
En el esquema de la tabla 6.11 se tiene, una vez más, que *¤;;{ escribe proposiciones de código de
tres direcciones a un archivo de salida. Además, z£* da el índice de la siguiente proposición de tres
direcciones en la secuencia de salida (*¤;;{ debe incrementar z£* después de generar una
proposición de tres direcciones).
Ejemplo 6.3:
La expresión o ) p
!
) se traduce a código de tres direcciones,
usando el esquema de la tabla 6.11, como muestra el listado 6.4.
Se puede acceder rápidamente a los elementos de una matriz si éstos se guardan en un bloque de
posiciones consecutivas. Si el ancho de cada elemento de la matriz es , entonces el ;-ésimo elemento
de la matriz comienza en la posición z* . ; ;=+ , donde ;=+ es el límite inferior de los
subíndices y z* es la dirección relativa de la posición de memoria asignada a la matriz.
En el caso de una matriz bidimensional almacenada por filas, la dirección relativa de k; lk; l se puede
calcular como z* . h; ;=+ = . ; ;=+ i , donde ;=+ e ;=+ son los límites inferiores
para los valores de ; e ; y = corresponde a la cantidad de valores que puede tomar ; (cantidad de
columnas de la matriz).
El principal problema de generar código para referencias de matrices es relacionar los cálculos con una
gramática para referencias de matrices. La tabla 6.12 muestra una de estas gramáticas con sus acciones
semánticas, donde:
D. '*zx¤;*=
© significa que D corresponde a un simple.
Para las expresiones aritméticas se usa el esquema de la tabla 6.10.
Cuando se hace la reducción " 1 D, siendo D un arreglo, se usa la indización para obtener el
contenido de D. xy£{kD. '*zx¤;*=l.
La función =)x;z". ¤{; devuelve el tamaño de los elementos de la matriz, mientras que
)x;z". ¤{; entrega la base de la matriz.
Ejemplo 6.4:
Sea una matriz de 10 20 almacenada a partir de la posición 1000 de la memoria. Además,
sean ;=+
;=+
1 y
4. El código de tres direcciones para la asignación / k0lkl se
muestra en el listado 6.5.
0: 1000 // base
1: 0 1 // y - ;=+
2: 20 // ; ;=+ =
3: . // ; ;=+ = . ;
4:
1 // ; ;=+ = . ; ;=+
5:
4 // h; ;=+ = . ; ;=+ i
6: / k
l
Se usan los esquemas anteriores, considerando los nombres de los campos como entradas de la tabla de
símbolos pertinente.
En general, los programas están no solo por expresiones y asignaciones, sino que tienen también
proposiciones de flujo de control. Estas últimas también deben ser traducidas a código intermedio. En
consecuencia, se deben incorporar las reglas semánticas necesarias a la definición dirigida por la
sintaxis construida hasta ahora. El ejercicio de diseñar las reglas semánticas queda para los alumnos,
por lo que en esta sección se muestra cómo debe quedar el código generado para diferentes sentencias
de esta clase.
6.2.4.2 Sentencia if
Esta sentencia, en primer lugar, debe verificar el cumplimiento de una condición. Si es verdadera, se
ejecutan las sentencias anidadas. En caso contrario, se salta a la primera sentencia fuera del bloque §.
En el ejemplo 6.5, las sentencias etiquetadas de 0 a 3 evalúan la condición. La sentencia 4 determina si
la condición es verdadera, y en caso de ser así salta al código contenido en el bloque (sentencia 6),
continuando luego en forma secuencial con el código posterior (sentencia 7). En caso contrario, salta a
la primera instrucción posterior al bloque §.
Ejemplo 6.5:
Considere el fragmento de código en C del listado 6.6 y tradúzcalo a código de tres direcciones.
if(x!=0) {
y=3;
}
z=x*y;
0: § /!
0 3 4: § 6
1: 0 5: 7
2: 4 6: 0 3
3: 1 7: / 0
Similar a la sentencia if, § ¯ también comienza por verificar el cumplimiento de una condición.
Si es verdadera, se ejecutan las sentencias anidadas dentro del bloque § para luego saltar a la primera
instrucción fuera del bloque ¯. En caso contrario, se salta a la primera sentencia del bloque ¯
para luego continuar secuencialmente con el código posterior.
Ejemplo 6.6:
Considere el fragmento de código en C del listado 6.8 y tradúzcalo a código de tres direcciones.
if(x!=0) {
y=3;
}
else{
y=5;
}
z=x*y;
0: § /!
0 3 5: 8
1: 0 6: 0 3
2: 4 7: 9
3: 1 8: 0 5
4: § 6 9: / 0
Existen diversas maneras de implementar el código de tres direcciones para esta sentencia, y algunas de
ellas dependen de las especificaciones del lenguaje fuente. Un mecanismo sencillo, no obstante, puede
ser el de evaluar la expresión de prueba e ir evaluando el resultado: si es distinto del caso comprobado,
saltar al siguiente, hasta llegar al valor buscado, ejecutando entonces su código asociado y saltando
luego fuera del bloque ¯²¨°, o al final del bloque ¯²¨°. Si existe un valor por defecto, el código
asociado a él se ejecuta siempre que no se haya encontrado un valor coincidente.
Ejemplo 6.7:
Considere el fragmento de código en C del listado 6.10 y tradúzcalo a código de tres
direcciones.
c=2*y-1;
switch(c) {
case 1:
z=5;
case 3:
z=36;
case 5:
z=8;
default:
z=0;
}
x=y+z;
0: 2 0 7: 12
1: ) 1 8: § )!
5 11
2: § )!
1 5 9: 8
3: 5 10: 12
4: 12 11: 0
5: § )!
3 8 12: / 0
6: 36
En el caso real del lenguaje C, la traducción anterior es incorrecta. En C, se ejecuta el caso válido y
todos los casos siguientes a menos que se encuentre un salto al exterior del bloque switch, señalado por
la palabra reservada ³´.
Ejemplo 6.8:
Considere el fragmento de código en C del listado 6.12 y tradúzcalo a código de tres
direcciones.
Z=0;
c=2*y-1;
switch(c) {
case 1:
z=5;
case 3:
z=z+2;
break;
case 5:
z=8;
}
x=y+z;
LISTADO 6.13: Código de tres direcciones para una sentencia ¯²¨° según el
funcionamiento de esta sentencia en C.
0: 0 7: . 1
1: 2 0 8: 12
2: ) 1 9: § )!
5 12
3: § )!
1 6 10: 8
4: 5 11: 12
5: 7 12: 13: / 0
6: § )!
3 8
En este caso, se evalúa una condición. Si ésta se cumple, se ejecutan las sentencias anidadas y se
vuelve a evaluar la condición. Las repeticiones continúan mientras la condición se siga cumpliendo,
para finalmente saltar fuera del bloque ²°. Si dentro del bloque aparece la palabra reservada
³´, se traduce como un salto fuera del ²°.
Ejemplo 6.9:
Considere el fragmento de código en C del listado 6.14 y tradúzcalo a código de tres
direcciones.
x=0;
i=0;
while(i<10){
x=x*i;
i++;
}
El listado 6.15 muestra el código intermedio resultante.
0: / 0 4: / / ;
1: ; 0 5: ; ; . 1
2: § ; o 10 4 6: 2
3: 7
Muy similar a la sentencia anterior, solo difiere en que la condición se evalúa al final, por lo que el
código anidado se ejecuta a lo menos una vez. La palabra reservada ³´ actúa igual que en la
sentencia anterior.
Ejemplo 6.10:
Considere el fragmento de código en C del listado 6.16 y tradúzcalo a código de tres
direcciones.
x=0;
i=0;
do{
x=x*i;
i++;
} while(i<10);
0: / 0 3: ; ; . 1
1: ; 0 4: § ; o 10 2
2: / / ; 5: 6
Análoga a ²°, la sentencia ¦ © solo difiere de la anterior en que el bloque
anidado se repite mientras la condición continúe siendo falsa. Este mecanismo existe en lenguajes
como Pascal. La palabra reservada ³´ actúa igual que en los demás bucles.
Ejemplo 6.11:
Considere el fragmento de código en pseudo-C del listado 6.18 y tradúzcalo a código de tres
direcciones.
LISTADO 6.18: Un fragmento de código en pseudo-C.
x=0;
i=0;
repeat{
x=x*i;
i++;
} until(i==10);
LISTADO 6.18: Código de tres direcciones para una sentencia ¦ ©.
0: / 0 3: ; ; . 1
1: ; 0 4: § ;
10 6
2: / / ; 5: 2
Ejemplo 6.12:
Considere el fragmento de código en C del listado 6.20 y tradúzcalo a código de tres
direcciones.
x=0;
0: / 0 4: / ;
1: ; 0 5: / / .
2: § ; o 10 4 6: ; ; . 1
3: 8 7: 2
Un procedimiento es una construcción de programación tan importante y utilizada tan a menudo que es
fundamental que un compilador genere buen código para llamarlo.
Considere, por ejemplo, una gramática sencilla para llamar procedimientos que contenga las siguientes
producciones: C 1 ¨ D;z", x;z" 1 x;z", " | ". Una posible implementación puede ser
mediante una cola de argumentos (parámetros), pues no siempre se tiene la misma cantidad de
argumentos. La tabla 6.13 muestra un posible esquema de traducción.
6.3 EJERCICIOS
4. Determine las reglas semánticas para traducir las sentencias de flojo de control.
5. Genere código de tres direcciones para el fragmento de código del listado 6.22.
y=25;
z=26;
x=(5+4*z)-(y/2+x*y-z);
switch(c) {
case 0:
y=56*x/2;
break;
case 1:
do{
x=z*(x-4);
} while(y>200 && (3-5*x!=z);
case 1:
z=0;
default:
x=z*z-4*z+9;
}
y=x+z*2-y;
BIBLIOGRAFÍA
1. AHO, A. V.; LAM, M. S.; SETHI, R.; ULLMAN, J. D. (2008) Compiladores. Principios, técnicas
y herramientas (2ª ed.), Pearson Educación, México. ISBN 978-970-26-1133-2.
En Ciencias de la Computación solo nos interesan aquellos lenguajes que podemos representar. Este
universo puede además ser dividido en clases o familias que comparten alguna propiedad. La principal
clasificación es la jerarquía de Chomsky, que establece 4 tipos de lenguajes representables de acuerdo a
los tipos de gramáticas que las generan:
Generales o tipo 0.
Sensibles al contexto (recursivamente enumerables) o tipo 1.
Libres de contexto o tipo 2.
Regulares o tipo 3.
Entre los tipos de lenguajes de esta clasificación existe además una jerarquía de inclusión, como se
puede apreciar en la figura A.1, donde j; 3 s j; 2 s j; 1 s j; 0.
A.2 GRAMÁTICAS
Un mecanismo para representar lenguajes de manera finita son las gramáticas. Éstas pueden definirse
como un sistema o algoritmo para la generación de palabras basado en el reemplazo de subsecuencias
de acuerdo a determinadas reglas o producciones.
Una producción está conformada por un lado izquierdo o antecedente y un lado derecho o consecuente.
El mecanismo de funcionamiento es simple: cada vez que se encuentre el antecedente en alguna
palabra parcialmente generada, éste debe ser reemplazado por el consecuente, formando así una nueva
palabra total o parcialmente generada.
Ejemplo A.1:
La siguiente gramática genera palíndromos sobre el alfabeto binario. Es importante destacar que
la palabra vacía no pertenece al vocabulario de $.
$
Σ, N, P, S:
Σ
0, 1
N
A
P
A 1 0A0 | 1A1 | 0 | 1 | ε
S
A
3. Se usan letras griegas minúsculas (2, 6, A, …) para designar indistintamente símbolos terminales o
no terminales, o incluso secuencias de ellos.
Ahora que está bien definida la idea de gramática, es importante estudiar cómo se generan las palabras.
Para una gramática $, se dice que una secuencia 2 :
2 22 a Σ b N genera o deriva otra
secuencia 6 :
2 62 a Σ b N , operación denotada por 29 Ä 69, si y solo si Å2 1 6 a % para
2 , 2 , 2, 6 a Σ b N .
Observación:
1. ÄL es una derivación de n pasos y Ä corresponde a la clausura reflexiva y transitiva de Ä.
2. El conjunto de las palabras generadas sobre Σ b N a partir del símbolo inicial C se denota por
C$.
Ejemplo A.2:
Retomemos la gramática del ejemplo anterior, $
0, 1, A, A 1 0A0 | 1A1 | 0 | 1 | ε, A.
También es posible representar las derivaciones en forma gráfica mediante un árbol de análisis
sintáctico, el cual tiene las siguientes propiedades:
La raíz está etiquetada con el símbolo inicial.
Cada hoja está etiquetada con un componente léxico o con ε.
Cada nodo interior está etiquetado con un no terminal.
Si A es el no terminal que etiqueta a algún nodo interior y X , X , ... XL son las etiquetas de los
hijos de ese nodo, de izquierda a derecha, entonces 1 X1 X2 … Xn es una producción.
Si bien en ocasiones este mecanismo resulta más cómodo y claro, puede ser inadecuado en ocasiones
porque no muestra el orden en que se realizan las sustituciones. La figura A.2 muestra esta
representación para las secuencias generadas en el ejemplo anterior.
FIGURA A.2: Árboles de derivación para las palabras (a) 001101100 y (b) 11 con
$
0, 1, A, A 1 0A0 | 1A1 | 0 | 1 | ε, A.
Los ejemplos A.1 y A.2 son bastante sencillos, puesto que se trata de una gramática con un único no
terminal. Pero podemos tener una gran cantidad de ellos, como ocurre al crear la gramática de un
lenguaje de programación. Así pues, el ejemplo A.3 muestra una gramática que genera los números
naturales.
Ejemplo A.3:
La gramática $ genera el lenguaje de todos los números naturales, respetando las convenciones
de notación habituales al no permitir ceros a la izquierda. La figura A.3 muestra los árboles de
derivación para generar varios números.
$
Σ, N, P, S:
Σ
0, 1, 2, 3, 4, 5, 6, 7, 8, 9
N
¸signiÈicativo¿, ¸digito¿, ¸secuencia¿, ¸natural¿
P
¸signiÈicativo¿ 1 1 | 2 | 3 | 4 | 5 |6 | 7 |8 | 9, ¸digito¿ 1 0 |1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9,
secuencia1digitosecuencia ε, natural10 | significativosecuencia
S
¸natural¿
La noción de derivación de palabras conduce a la definición de lenguaje generado por una gramática $,
denotado por D$. Corresponde al conjunto de palabras generadas a partir del símbolo inicial C y que
solo contienen símbolos terminales. Matemáticamente: D$
C$ Í Σ
m a Σ : C Ä w.
En general, pueden existir varias gramáticas que generen un mismo lenguaje, lo que las hace
equivalentes. Así, $ - $ : zz; D$
D$9.
FIGURA A.3: Árboles de derivación para las palabras (a) 0, (b) 5 y (c) 3907.
Se dice que una gramática $
Σ, N, P, S es lineal si y solo si en cada regla de producción existe a lo
más un no terminal en el lado derecho. En otras palabras, todas las producciones son de la forma
A 1 uBv o bien A 1 w, con A, B a _ y y, , m a Σ . Hay dos casos interesantes a considerar en esta
definición:
y
ε: las producciones son de la forma A 1 B o bien A 1 w. En este caso, se dice que $ es
lineal izquierda.
ε: las producciones son de la forma A 1 uB o bien A 1 w. En este caso, se dice que $ es lineal
derecha.
Las gramáticas lineales derechas se denominan también gramáticas regulares, y son las que generan los
lenguajes regulares.
Ejemplo A.4:
$
a, b, S, A, B, C, S 1 aC, A 1 aA | b, B 1 bB | b, C 1 A | B, S
Esta gramática genera el lenguaje sobre el alfabeto Σ
a, b cuyas palabras comienzan con y
terminan con , y entre medio pueden contener solo o solo :
Podemos ver que, en este ejemplo, están presentes las tres operaciones regulares. Los no
terminales A, B y C tienen dos producciones cada uno, separadas por |, con lo que con cada uno
de ellos tenemos la unión entre dos subsecuencias.
La concatenación está presente en toda producción que contenga dos o más símbolos en su lado
derecho. En el caso de la producción de S tenemos una a concatenada con alguna subsecuencia
generada a partir de C.
La sintaxis de los lenguajes de programación puede ser descrita por medio de gramáticas libres de
contexto (GLC). Éstas ofrecen ventajas significativas al momento de diseñar un lenguaje o escribir un
compilador:
Especificación sencilla y fácil de entender para lenguajes de programación.
Facilitar la tarea de construir analizadores sintácticos. Para algunas clases de gramáticas incluso
pueden ser generados automáticamente.
Una gramática adecuada da al lenguaje de programación una estructura útil para la traducción de
código fuente a código objeto y para la detección de errores.
Resulta más fácil añadir nuevas construcciones a un lenguaje de programación ya existente
(evolución).
Una gramática es un modelo que permite generar secuencias sintácticamente válidas en algún lenguaje,
aunque sin tener en cuenta su significado. Por ejemplo, en castellano podemos decir que una oración
simple tiene un artículo, un sustantivo, un verbo y un punto. De acuerdo a esta definición, son
oraciones “El niño corre.” y “Unos planeta piensa.” También podríamos pensar en un párrafo como una
secuencia de una o más oraciones.
Para llevar esta noción al modelo anterior, digamos que el símbolo A corresponde a párrafo; B, a
oración; C a artículo, D a sustantivo, y E, a verbo. Así, tendríamos que:
Σ
. , El, La, … , Unos, niño, planeta, casa, … , lápiz, corre, salta, piensa, … , vuela.
N
A, B, C, D, E.
P
1 | ,
1 !".,
1 El | La | … | Unos,
! 1 niño | planeta | casa | … | lápiz,
" 1 corre | salta | piensa | … | vuela.
S
A.
Las gramáticas libres de contexto (GLC) son aquellas en que todas las producciones tienen la forma
1 2, con a _ y 2 a Σ b _ . Así, el lado izquierdo de cada producción contiene únicamente un
símbolo no terminal. Esta restricción no es trivial, puesto que no todos los lenguajes pueden ser
generados por una gramática independiente del contexto. En consecuencia, los lenguajes generados por
este tipo de gramáticas se denominan lenguajes independientes del contexto. Además se tiene que las
producciones de los no terminales pueden tener cualquier secuencia de terminales y no terminales,
dejando fuera la restricción de tener a lo más un no terminal al extremo derecho de la producción. Cabe
destacar que el conjunto de los lenguajes regulares es un subconjunto de los lenguajes libres de
contexto. Las gramáticas de los ejemplos A.1 y A.3 son, en consecuencia, libres de contexto.
En el caso de las gramáticas lineales (y en particular de las gramáticas regulares), en que toda
producción tiene a lo más un no terminal, no es necesario preocuparse del orden en que se realicen las
derivaciones o de si existen árboles distintos para generar una misma palabra, pues hay uno solo por
cada secuencia. En el caso de las GLC, como podemos tener más no terminales en una producción, sí
es necesario tomar en consideración el orden en que se efectúan las derivaciones.
Por la derecha: siempre se reemplaza el no terminal de más a la derecha.
Por la izquierda: siempre se reemplaza el no terminal de más a la izquierda.
Ejemplo A.5:
Dada la GLC $
/, , , .,, ", " 1 " . " | " " |"| /, ", muestre una derivación
por la derecha y otra por la izquierda para la palabra / . / /. Muestre además sus árboles de
derivación.
Las dos derivaciones anteriores pueden representarse por medio del mismo árbol de derivación,
que se muestra en la figura A.4.
Ahora que está clara la idea de derivación por la derecha y por la izquierda, es importante introducir el
concepto de gramática ambigua. Una gramática $ aGLC es ambigua Ö Åm a D$ × m tiene dos
derivaciones por la izquierda } m tiene dos derivaciones por la derecha m } tiene dos árboles de
derivación.
Ejemplo A.6:
Consideremos como base el ejemplo A.5. Ahora podemos buscar nuevas derivaciones por la
izquierda y por la derecha para la misma palabra, así como un nuevo árbol de derivación, y así
probar que $ es ambigua.
Al igual que en el ejemplo A.5, las dos derivaciones anteriores pueden representarse por medio
del mismo árbol de derivación (figura A.5).
Los elementos anteriores se relacionan entre sí por medio de las operaciones regulares:
Clausura o estrella de Kleen, denotada por como superíndice. En ocaciones, se usa también la
variante denotada por un . como superíndice, que indica una o más repeticiones del operando.
Un ejemplo adecuado para comprender esta idea es el funcionamiento de un ascensor: Cada piso
corresponde a un estado, y en todo momento el ascensor sabe dónde se encuentra. Al apretarse un
botón, el ascensor debe dirigirse a un nuevo piso de acuerdo a la entrada recibida.
Además de los AF descritos, existen máquinas semejantes que incorporan la capacidad de proporcionar
una salida. Éstas últimas reciben el nombre de transductores finitos.
Desde una perspectiva de implementación física, un AF puede verse como un dispositivo conformado
por los siguientes elementos, algunos de los cuales se ilustran en la figura A.6:
Una cinta infinita que contiene símbolos pertenecientes a un alfabeto, formando una secuencia de
entrada.
Un cabezal Un cabezal capaz de leer un símbolo de la cinta y desplazarse automáticamente hacia la
derecha.
Un control finito, compuesto de una cantidad, también finita, de estados y una especificación
(transición) que permite determinar el estado siguiente de acuerdo a la lectura realizada en la cinta.
Algunos estados distinguibles dentro del conjunto de estados:
• Un estado inicial.
• Uno o más estados finales o de aceptación (que indican que la secuencia pertenece a un
lenguaje dado).
Una vez conocidos los elementos de un AF, estamos en condiciones de describir el proceso de
aceptación o reconocimiento de una palabra como parte de un lenguaje regular dado, aunque no se
tocará aún el tema de la construcción de estas máquinas.
Inicialmente, el cabezal lector debe estar situado en el primer símbolo de la cadena de entrada y el AF
debe encontrarse en su estado inicial. A continuación, el cabezal lee el símbolo y se desplaza hacia la
derecha en una posición, y el AF modifica su estado de acuerdo a la entrada recibida y a una
especificación de transición. La lectura de símbolos y el cambio de estado se repiten sucesivamente
hasta que no queden símbolos por leer en la entrada y, en consecuencia, el AF no realice nuevos
cambios de estado. Una vez alcanzado este punto, es el observador quien debe determinar si la palabra
leída pertenece o no al lenguaje regular dado. Para este fin, basta observar el estado en que se encuentra
el autómata. Si es uno de los distinguidos como final o de aceptación, entonces la palabra de la entrada
pertenece al lenguaje. En caso contrario, la palabra no se acepta.
Podemos distinguir dos grandes clases de AF, dependiendo de la forma que tenga la definición de
transición : determinísticos y no determinísticos.
Ejemplo A.8:
Considere el AFD
, Σ, , , , donde:
, , ,
Σ
,
,
, ,
, ,
, ,
, ,
,
2,
2, 3,
3, 3,
3
Otra manera de representar la función de transición es mediante una tabla, como se muestra en
la tabla A.1.
\Ð
Gráficamente, se puede mostrar M como en la figura A.7. Resulta interesante destacar que la
figura A.7 ilustra todos los elementos presentes en la definición formal de M:
El conjunto de estados está denotado por el conjunto de círculos rotulados.
El alfabeto Σ aparece tácitamente reflejado en las transiciones.
Cada transición de tiene un estado de origen, señalado por la base de la flecha; un estado
de destino, señalado por la cabeza de la flecha, y un símbolo asociado.
El estado inicial está señalado con una flecha no rotulada que incide en él.
Cada estado final de , en este caso solo , se denota por un doble círculo.
Como punto de partida para algunas definiciones debemos considerar que el concepto básico necesario
para describir la operación de un AFD es la configuración o descripción instantánea, es decir, el estado
en que se encuentra el sistema completo en un instante de tiempo. Así, la configuración del AFD en un
instante dado está determinada por dos variables: el estado del AFD en que se encuentra el control
finito y la posición del cabezal de lectura, dada por el sufijo aún no leído de la palabra de entrada
(incluido el símbolo en que se encuentra el cabezal). Así, una configuración es un par ordenado
, m a Σ , donde es el estado actual y m es el sufijo de la entrada que aún no ha sido leído,
incluyendo el símbolo señalado por el cabezal. La configuración inicial siempre está dada por el estado
inicial del autómata y el primer símbolo de la entrada.
Cabe destacar que Ü7 denota = pasos de computación y que Ü corresponde a la clausura reflexiva y
transitiva de Ü.
En tercer lugar, es necesario definir el concepto de aceptación de una palabra m a Σ . Se dice que es
aceptada por un AFD M si y solo si, comenzando desde la configuración inicial, al terminar de leer m
el AFD se encuentra en un estado final, es decir:
Ejemplo A.9:
Consideremos el AFD del ejemplo A.8 y la entrada m
abbaab. La configuración inicial de M
está dada por , abbaab. Tras el primer paso de computación, se tiene:
, bbaab Ü , baab Ü , aab Ü , ab Ü , b Ü ,
Ejemplo A.10:
Consideremos ahora el AFD del ejemplo A.8 y la entrada m
bab. La configuración inicial de
M está dada por , bab. La secuencia de pasos de computación para determinar la aceptación
de m es:
La noción de aceptación de una palabra conduce a la noción de lenguaje aceptado por un AFD M,
denotado por D, que corresponde al conjunto de todas las palabras aceptadas por M, es decir
D
m a Σ : m es aceptada por M.
Ejemplo A.11:
Si estudiamos el AFD del ejemplo A.8, podemos notar que D corresponde al lenguaje
representado por la ER {
. .
Una vez conocido D, es posible definir la equivalencia entre dos AF: se dice que dos AF y
son equivalentes si aceptan el mismo lenguaje, es decir:
- Ö D D
Así, se tiene que un estado 9 es alcanzable desde otro estado si existe una palabra m a Σ tal que la
secuencia de pasos de computación comenzando desde y el primer símbolo de m culmina en 9
cuando no quedan símbolos por leer. Cabe destacar que : tiene las siguientes propiedades:
: ,
: , Ð
, Ð
: , Ðm
: , Ð, m
: , m
: 9, , m
Habíamos estudiado que el estado al que va un AFD al leer un símbolo de la entrada está
completamente determinado, puesto que cada estado cuenta con una y solo una transición por cada
símbolo del alfabeto. En el caso del AFND, en cambio, el cambio de estado solo está parcialmente
especificado, puesto que puede ocurrir lo siguiente:
Las transiciones entre estados están dadas por palabras más que por símbolos individuales,
pudiendo incluso haber transiciones con , denominadas transiciones vacías, en que el AF puede
cambiar de estado sin necesidad de haber leído algo desde la entrada.
Puede haber más de una transición definida desde un estado determinado con alguna lectura dada
(transiciones múltiples). En consecuencia, el AFND debe ir a alguno cualquiera de los estados de
destino, de manera no determinada.
De lo anterior podemos concluir que, en realidad, el AFD estudiado no es más que un caso particular
del AFND. En estos últimos, la especificación de transición es una relación Ú Σ ,
conformada por un conjunto de tríos , m, 9 donde cada trío indica que, estando en el estado , al
leer en la entrada una secuencia de símbolos m, el AFND puede quedar en el estado 9. No obstante,
como pueden existir dos o más transiciones que compartan los dos primeros componentes, por ejemplo
, m, 9, , m, 99, esto puede reformularse de modo tal que el tercer elemento del trío sea un
conjunto de estados: , m, : , 99.
En el caso de los AFND, las nociones de configuración, paso de computación, aceptación de palabra,
aceptación de lenguaje y equivalencia son semejantes al caso de los AFD. No obstante, la existencia de
transiciones vacías da origen a una nueva definición: la clausura- . Corresponde al conjunto de estados
alcanzables desde el estado mediante transiciones vacías, es decir:
z a : , Ü z,
j Þ f a j
Ejemplo A.12:
Considere el AFND
, Σ, , , , donde:
, , , ,
Σ
,
, , , , , , , , , , , , , , ,
1, ,3, 2, ,0, 2, ,3, 3, ,2,3, ,3, 3, ,4,4, ,1,4, ,3
,
Otra manera de representar la función de transición es mediante una tabla, como se muestra en
la tabla A.2.
\m
, Ï Ï
Ï Ï
Ï Ï
,
Ï Ï
Ï Ï
Para construir un AFD equivalente a un AFND dado, se deben seguir dos grandes pasos:
1. Eliminar aquellas transiciones que avanzan con secuencias cuya longitud es mayor que 1.
2. Eliminar transiciones vacías y transiciones múltiples.
Para eliminar las transiciones con secuencias de longitud mayor a 1, se incorporan nuevos estados al
AFND a fin de descomponer las transiciones para que solo tengan un símbolo.
Sea
, Σ, , , un AFND tal que sus transiciones son de la forma , y, 9, donde , 9 a y
u a Σ . Entonces, existe un AFND 9
9, Σ, 9, , tal que:
9 Ú 9 b Σ 9.
:
b %8 , con %8
, , … , @ß f, y, 9 a tal que |y|
r p 1.
Note que y 9 comparten Σ, y .
El segundo paso, entonces, consiste en eliminar las transiciones vacías y las transiciones múltiples.
Para este fin, creamos un conjunto de estados para el AFD ::
9, Σ, 99, 99, 99 tal que cada uno
de sus estados corresponde a un conjunto de estados del AFND 9
9, Σ, 9, , , que sintetiza
todos los caminos posibles para reconocer una determinada secuencia. Se puede garantizar que es
posible realizar esta tarea, ya que para un conjunto de estados de tamaño = se tienen 27 subconjuntos.
El primer estado del AFD 99 que debemos determinar es el inicial, 99 dado por la clausura- de ,
el estado inicial de 9.
Los nuevos estados de 99 se determinan junto con las transiciones. Para cada símbolo de Σ se
determina el conjunto de estados de 9 que es posible alcanzar desde 99 y se les incorporan sus
respectivas clausuras-. Cada nuevo conjunto forma un nuevo estado del AFD. Se repite este proceso
hasta que no queden estados sin transiciones definidas. Los estados finales de 99 serán todos aquellos
conjuntos de estados que contengan algún estado final de 9.
Ejemplo A.13:
Consideremos el AFND 9 de la figura A.9 y determinemos su AFD equivalente 99. El estado
inicial de 99 está dado por la clausura- del estado inicial de 9:
, 0
Ï Ï
, 1
Ï Ï
, 0
! !
!, "
, 1
, 0
Ï Ï
, 1
, 0
Ï Ï
, 1
" "
"
, 0
Ï Ï
, 1
, 0
Ï Ï
, 1
, 0
Ï Ï
, 1
, ,
,
, 0
Ï Ï
, 1
", ",
Note que es el conjunto vacío, por lo que al haber leído una secuencia no válida se convierte
en un estado trampa.
En muchas ocasiones, al construir un AFD obtenemos una cantidad de estados superior a la necesaria,
por lo que resulta útil determinar el AFD equivalente más pequeño. Para este fin, debemos determinar
si hay estados que tengan igual significado.
Uno de los métodos existentes para minimizar un AFD es el de las particiones, en que se van creando
grupos cada vez más pequeños de estados potencialmente equivalentes. Para explicar este método,
tomaremos como ejemplo el AFD obtenido en el ejemplo A.13.
Para la partición, sabemos que un estado final no puede ser equivalente a otro no final, por lo que
creamos un grupo para los estados finales y otro para los no finales:
%
, , , ,
, , , , , , ,
Ahora asignamos un nombre a cada grupo y estudiamos el comportamiento de cada estado para cada
uno de los símbolos del alfabeto. Llamemos 1 al grupo que contiene a los estados no finales y 2 al
grupo de estados finales. va a al leer un 0, y está en el grupo b. Además, va a al leer un
0, y está en el grupo a.
á , , , , , , , , , â
%
, , â
á
Para la siguiente partición, podemos descomponer los grupos de acuerdo a su comportamiento. Son
potencialmente equivalentes aquellos estados de un mismo grupo que vayan a las mismas particiones
con los mismos símbolos. Es importante destacar que lo que ya estaba separado no puede juntarse
nuevamente. Así, tenemos que todos los estados se comportan de manera diferente en el grupo a,
mientras que hay algunos del grupo b que siguen siendo potencialmente equivalentes. Ahora, tras la
nueva partición, repetimos el proceso para ver si hay más grupos que se puedan separar:
'* '' +* £ £ + *
á , , âá+), +âá , , , â
%
) £
' * +
x x
á , â
%
) ' * + £ > r x
;
Tras el análisis de comportamiento de los grupos, se tiene que %
% , por lo que - . En
consecuencia, podemos tener el AFD mínimo quitando y redirigiendo sus transiciones entrantes a
.
Resulta lógico que si las ER representan lenguajes regulares y los AF los reconocen, existe una
equivalencia entre ellos. Existen diversos métodos para pasar de AF a ER y viceversa. No obstante,
para este curso solo nos interesa poder construir un AF a partir de una ER, para lo cual utilizaremos el
método de Thompson.
El método de Thompson opera de manera constructiva, creando AFNDs para subexpresiones sencillas
y combinándolos luego de acuerdo a ciertas reglas. Una ventaja de este método es que resulta sencillo
de implementar computacionalmente, y facilita las operaciones de unión, concatenación y clausura al
garantizar que cada AFND tiene un único estado final. De más está decir que este método se
complementa con otros ya estudiados a fin de obtener el AFD mínimo reconocedor.
Sea Ð a Σ y sean ã y ä AFNDs reconocedores de los lenguajes representados por las expresiones
regulares {ã y {ä , respectivamente. Ambos AF han sido construidos mediante el método de Thompson,
por lo que solo tienen un estado final.
{
: el AFND reconocedor para la palabra vacía se construye creando dos estados, uno de ellos
inicial que no es de aceptación y el otro final. Para llegar desde el estado inicial al final, se crea una
transición vacía. La figura A.10 muestra este esquema.
{
Ð: análogo al anterior, cuando la expresión regular consiste en un solo símbolo, basta con que la
transición del estado inicial al estado final tenga asociado el mismo símbolo (figura A.11).
FIGURA A.11: AFND reconocedor para palabras de largo 1 según el método de Thompson.
{
{ã {ä : para la concatenación, basta con hacer que el estado final del primer autómata se fusione con
el estado inicial del segundo en un estado no final (figura A.13).
FIGURA A.14: Esquema para construir la estrella de Kleene según el método de Thompson.
Ejemplo A.14:
Construir un AF reconocedor para el lenguaje representado por la siguiente expresión regular:
{
01100 . 11 . 01 . 0.
La figura A.15 muestra los pasos seguidos para construir el AFND reconocedor para la ER
dada.
{
{ 0
{ 1
{
{ {
{
{
{
{ { {
{ { {
FIGURA A.15: Esquema para construir la estrella de Kleene según el método de Thompson.
{ { . {
{ {
{ { {
{ { . {
{
{
{
{ { { . {
Los lenguajes que no son regulares no pueden ser reconocidos usando los AF que ya conocemos. Por lo
tanto, para el reconocimiento de lenguajes libres de contexto no regulares (recuerde que Dt s DD) y
que representan estructuras con anidamientos se requiere de un mecanismo que tenga la capacidad de
recordar la historia de la entrada leída, en forma explícita y accesible, para tomar una decisión del
movimiento a efectuar.
Esta capacidad de memoria se logra dotando al AF de una memoria de pila, en la cual se van
almacenando símbolos de la entrada que en algún momento son recuperados para su utilización. Este
modelo de autómata se denomina autómata apilador (AA) o Push-Down Automaton (PDA), pues al
momento de leer un símbolo de la entrada, puede empujarlo a la pila (push) o bien extraer información
de esta última (pop), además de efectuar una transición de un estado a otro. La figura A.16 muestra el
modelo de AA como dispositivo físico.
Para comprender adecuadamente este modelo es necesario explicar con detenimiento la relación de
transición . Por definición, un AA corresponde a un AFND con una pila asociada. Además, si se tiene
8 , m, 2
h? , 6i, entonces:
El estado actual del AA es 8 , debe leer desde la entrada la secuencia m y la secuencia 2 se
encuentra al tope de la pila.
Una vez efectuada la lectura, pasa al estado ? y reemplaza 2 por 6 al tope de la pila.
Cabe destacar como casos particulares a dos operaciones especiales:
Push(a): 8 , m,
h? , i, inserta el símbolo al tope de la pila.
Pop: 8 , m,
h? , i, quita el símbolo que se encuentre al tope de la pila.
Se dice que un AA acepta o reconoce una entrada m a Σ si y solo si, comenzando desde el estado
inicial y al momento de terminar de leer m, cumple con a lo menos uno de los siguientes criterios:
1. se encuentra en un estado final. Es decir, , m, Ü 8 , , 2, con 8 a .
2. tiene su pila vacía, es decir, , m, Ü 8 , , .
Resulta interesante la conclusión de que, al bastar con uno solo de los criterios para la aceptación,
puede darse que para un AA se tenga
Ï.
Otra observación interesante es que, puesto que Dt s DD, se tiene que un AF corresponde a un caso
especial de AA en que no se realizan operaciones sobre la pila.
Ejemplo A.15:
Sea el AA
, Σ, Γ, , , , , donde:
, .
Σ
0, 1, ).
Γ
t, , $.
.
t.
#.
:
• , 0, t
, t
• , 0,
,
• , 0, $
, $
• , 1, t
, $t
• , 1,
, $
• , 1, $
, $$
• , ), t
, t
• , ),
,
• , ), $
, $
• , 0,
,
• , 1, $
,
• , , t
,
Nótese que este AA solo puede aceptar una palabra por medio de la pila vacía. Para ver su
funcionamiento, veamos en primer lugar la traza para la entrada m
011)110, que se muestra
en la tabla A.3.
La tabla A.4 muestra que la entrada m
01)11 genera un error, por lo que finalmente
tenemos que m a D, pero m g D.