Está en la página 1de 28

TEMA VII. ANÁLISIS SEMÁMTICO.

Prof. Joel Ayala de la Vega

Introducción.

La especificación de la semántica de un lenguaje de programación es una tarea mas


difícil que la especificación de su sintaxis. Cuando se habla del significado en
contraposición a forma o a estructura. Existen varias formas de especificar la
semántica:

1. Mediante un manual de referencia de lenguaje Este es el método más común.


La experiencia con el uso de descripciones en inglés ha hechos más claros
y más precisos los manuales de referencia a través de los años, pero siguen
sufriendo la falta de precisión inherente en las descripciones naturales del
lenguaje y también pueden tener omisiones y ambigüedades.
2. Mediante un traductor definidor. Esto tiene la ventaja de que las preguntas
con respecto a un lenguaje pueden contestarse mediante un experimento
(igual que química o física). Un inconveniente es que las preguntas
relacionadas con el comportamiento de un programa no pueden contestarse
por adelantado – debemos de ejecutar un programa para descubrir lo que
hace. Otro inconveniente es que los errores y las dependencias con la
máquina en el traductor se convierten en parte de la semántica del lenguaje,
posiblemente de forma no intencional. También, el traductor quizás no pueda
ser portable a todas las máquinas y tal vez en general no esté disponible.
3. Mediante una definición formal. Estos métodos matemáticos son precisos,
pero también son complejos y abstractos, y requieren de estudio para su
comprensión. Existen diferentes métodos formales disponibles; la elección
depende del uso que se pretenda darle. Quizá el mejor método formal para
usar en la descripción de la traducción y ejecución de programas es la
semántica denotacional, que describe la semántica con una serie de
funciones.
En este capítulo se utilizará una adaptación de la descripción informal como pudiera
presentarse en un manual, junto con un uso simplificado de funciones como en las
descripciones denotacionales. Se darán abstracciones de las operaciones que
ocurren durante la traducción y ejecución de un programa en general.

ATRIBUTOS, LIGADURAS Y FUNCIONES SEMÁNTICAS.

Un mecanismo fundamental de abstracción en un lenguaje de programación es el


uso de nombres, es decir, identificadores, para denotar entidades o construcciones
del lenguaje. En la mayoría de los lenguajes, las variables, los procedimientos y las
constantes pueden tener nombres asignados por el programador. Un paso
fundamental de la descripción de la semántica del lenguaje es describir las reglas
convencionales que determinan el significado de cada uno de los nombres utilizados
en un programa.

Además de los nombres, descripción de la semántica de un lenguaje de


programación requiere de los conceptos de localización y de valor. Los valores son
cualquier cantidad almacenable, como los enteros, los números reales e incluso
valores matriciales formados de una secuencia de valores almacenados en cada
uno de los elementos de la matriz. Las locaciones son lugares donde se pueden
almacenar estos valores. Las locaciones son las direcciones de la memoria de la
computadora,

El significado de un nombre queda determinado por las propiedades o atributos


asociados con el mismo. Por ejemplo, la declaración en C:

const int n=5;

convierte a “n” en una constante entera con valor 5. En C, el atributo “constante”


indica que “n” no puede cambiar nunca de su valor, esto forma parte del tipo de
datos, en otro lenguaje esto puede ser distinto. La declaración en C

int x;

asocia el atributo “variable” y el tipo de dato “entero” al nombre “x”. La declaración


en C
double f(int n){

}
Asocia el atributo “función” al nombre “f” y los siguientes atributos adicionales:

• La cantidad, nombre y tipos de datos de sus parámetros (en este caso un


parámetro con el nombre “n” y el tipo de dato “entero”).
• El tipo de dato del valor devuelto (en este caso, “doble”).
• El cuerpo del código a ejecutarse cuando se llama a “f” (en este caso, no se
ha escrito el código, pero se ha indicado mediante tres puntos).
Las declaraciones no son los únicos constructores del lenguaje que pueden asociar
atributos a los nombres. Por ejemplo, la asignación

x=2;

Asocia el nuevo atributo “valor 2” a la variable “x”. Y, en el caso que “y” sea una
variable apuntador declarado como

int * y;

el enunciado C++

y=new int;

asigna memoria para una variable entera (esto es, asocia un atributo de localización
a la misma) y asigna esta localización a *y, es decir, asocia un nuevo atributo de
valor a y.

El proceso de asociación de atributo a un nombre se conoce como ligadura. En


algunos lenguajes, los constructores que hacen que los valores sean vinculados con
nombres (igual que en el caso de la declaración de constantes C anterior).

Un ambiente puede clasificarse de acuerdo con el tiempo que se está calculando y


vinculando con un nombre el proceso de traducción/ejecución. Esto se llama “tiempo
de ligadura” del atributo. Los tiempos de ligadura pueden clasificarse en ligaduras
estáticas y ligaduras dinámicas. Las ligaduras estáticas tienen lugar antes de la
ejecución, las ligaduras dinámicas ocurren durante la ejecución.
A menudo, los lenguajes funcionales tienen más ligaduras dinámicas que los
lenguajes imperativos. Los tiempos de ligadura también dependen del traductor. Los
interpretes traducen y ejecutan el código en forma simultánea, y por lo tanto llevarán
las ligaduras en forma dinámica, en tanto los compiladores llevan muchas de sus
ligaduras en forma estática. Para hacer que el análisis de los o y de las ligaduras
sean independientes de estos problemas del traductor, por lo general se hace
referencia al tiempo de ligadura de un atributo como el tiempo más corto que las
reglas del lenguaje permiten que el atributo esté vinculado. Por ejemplo, un atributo
que pudiera quedar vinculado estáticamente, pero es vinculado dinámicamente por
un traductor, sigue considerado como un atributo estático.

Por ejemplo. Considere a

int x;

el valor entero se vincula en forma estática al nombre “x”. Por otra parte, la
asignación “x=2;” se vincula el número ”2” en forma dinámica con “x” cuando el
enunciado de asignación se ejecuta. Y el enunciado en C++

y = new int;

vincula dinámicamente una localización de almacenamiento a “*y” asignando dicha


localización como el valor “y”.

Un atributo estático puede vincularse durante el análisis gramatical o durante el


análisis semántico (tiempo de traducción), durante el encadenamiento del programa
con la biblioteca (tiempo de ligado) o durante la carga del programa para su
ejecución (tiempo de carga). Por ejemplo, el cuerpo de una función definida
externamente se vincula en tiempo de ligado, la locación de una variable global se
vincula en tiempo de carga. Un atributo dinámico se vincula en tiempo diferente
durante la ejecución. Por ejemplo, en la entrada o en la salida de un procedimiento,
o en la entrada o salida de todo el programa.

Los nombres pueden vincularse con atributos aún antes del tiempo de traducción.
Algunos identificadores predefinidos como el tipo de dato integer o la constante
maxint tienen especificados sus atributos mediante la especificación del lenguaje y
mediante la implementación. La definición del lenguaje especifica que el tipo del
dato integer tiene valores consistentes de un subconjunto de enteros y que maxint
es una constante, en tanto la implementación especifica el valor de maxint y el rango
real del tipo de dato integer.

Por lo que se tienen los siguientes tiempos de ligadura:

• Tiempo de definición del lenguaje


• Tiempo de implementación del lenguaje
• Tiempo de traducción
• Tiempo de ligado
• Tiempo de carga
• Tiempo de ejecución
Todos los tiempos de ligadura de esta lista, con excepción del último, representan
ligaduras estáticas.

El traductor debe conservar la ligadura de tal manera que se den significados


apropiados a los nombres durante la traducción y ejecución. Un traductor hace lo
anterior creando Una estructura de datos para mantener la información. Dado que
no estamos interesados en los detalles de esta estructura de datos, sino solo de sus
propiedades, podemos pensar en ellos de una manera abstracta cómo una función
que expresa la ligadura de los atributos a los nombres. Esta función es parte
fundamental de la semántica del lenguaje y normalmente se conoce como la tabla
de símbolos. Matemáticamente, la tabla de símbolos es una función De nombres
hacia atributos, qué podríamos escribir de la forma Tabla de símbolos:
Nombres→Atributos.

Esta función cambiara Conforme avanza la traducción y/o ejecución para reflejar
adiciones o eliminaciones de ligaduras dentro del programa qué se está traduciendo
y/o Ejecutando.

Existe una diferencia fundamental entre la forma en la que se mantiene una tabla
de símbolos a través de un intérprete y la forma en la que la mantiene un compilador.
Un compilador puede, por definición, procesar únicamente atributos estáticos, ya
que el programa no será ejecutado sino hasta después de qué se complete la
compilación.

Durante la ejecución de un programa compilado deben mantenerse ciertos


atributos, por ejemplo, localizaciones y valores. Un compilador genera código qué
conserva estos atributos en estructuras de datos durante la ejecución. La parte de
asignación de memoria de este proceso (esto es, La ligadura de los nombres a la
localización de almacenamiento) Por lo general Se considera por separado y es
indicado por el entorno.

NOMBRE ENTORNO LOCALIZACIÓN

Finalmente, las ligaduras de las localizaciones de almacenamiento con los valores


se conocen cómo la memoria, ya qué hacen una abstracción de la memoria de una
computadora real (algunas veces también se conoce como en el almacén o el
estado)

LOCALIZACIONES MEMORIA VALORES

En un intérprete, por otra parte, se combina tablas de símbolos y entorno, ya que


durante la ejecución se procesan ambos atributos estáticos y dinámicos. Por lo
general, en esta función También se incluye la memoria, por lo que contiene la
siguiente imagen:

NOMBRE ENTORNO ATRIBUTOS (incluyendo localizaciones y valores)

Las declaraciones son un método primordial para establecer las ligaduras. Las
ligaduras pueden determinarse mediante una declaración, ya sea de manera
implícita o de manera explícita. Por ejemplo, La declaración en C

int x;

Establece explícitamente el tipo de datos de x utilizando la palabra clave int, pero la


localización exacta de la variable durante la ejecución está únicamente vinculada
de manera implícita y de hecho podría ser estática o dinámica, dependiendo de la
localización de esta declaración dentro del programa. De manera similar, el valor de
x Implícitamente es cero o no queda definido, dependiendo de la localización de la
declaración. Por otra parte, La declaración

int x=0;

Vincula de manera explícita a 0 Cómo valor inicial de x.

Algunas veces un lenguaje tendrá nombres diferentes para declaraciones que


vinculan ciertos atributos, pero no otros. Por ejemplo En C, declaraciones que
vinculan a todos los atributos potenciales se conocen cómo definiciones, en tanto
que declaraciones qué especifican sólo parcialmente atributos se conocen
simplemente cómo declaraciones. Por ejemplo, La declaración de función ( O
prototipo de la función)

double f (int);

especifica únicamente el tipo de dato de la función f ( por ejemplo, los tipos de sus
parámetros y el valor de retorno), pero no especifica el código para implementar f.

Las declaraciones comúnmente están asociadas con constructores específicos de


lenguaje y están agrupadas sintáctica y semánticamente con dichos constructores.

Típicamente uno de estos constructores estándar del lenguaje se conoce cómo un


bloque, y consiste en una secuencia de declaraciones seguidas por una secuencia
de enunciados y rodeado por marcadores sintácticos cómo son llaves o pares inicio-
terminación. En C, Los bloques se conocen cómo Enunciados Compuestos, y
aparecen cómo el cuerpo de funciones en las definiciones de función, así como en
cualquier otra parte en la que podría aparecer un enunciado ordinario del programa.
Por lo tanto,

void p (void){
double r,z; // el bloque de p

{ int x,y; //otro bloque anidado

}

}
Establece dos bloques, uno de los cuales representa el cuerpo de p y el otro está
Anidado dentro del cuerpo de p.
Las declaraciones asociadas con un bloque específico se conocen cómo locales,
en cambio declaraciones en bloque que rodean se conocen cómo no locales. Por
ejemplo, En la definición anterior del procedimiento p, Las variables r y z son locales
con respecto a p, Pero no son locales desde el interior del segundo bloque anidado
en p.
De manera similar, En lenguajes orientados a objetos, La clase es una fuente
importante de declaraciones – de hecho, lenguajes orientados a objetos Cómo Java
La clase es la única declaración que no necesita estar en el interior de otra
declaración de clase, siendo entonces la función principal de declaraciones.
Finalmente, Las declaraciones pueden también reunirse en grupos más grandes
como una manera de organizar los programas y de obtener un comportamiento
especial. Los paquetes encajan en esta categoría.
Las declaraciones vinculan varios atributos a los nombres despendiendo del tipo de
declaración. Cada una estás ligaduras tiene por sí misma un atributo que queda
determinado por la posición dentro de la declaración en el programa y por Las reglas
del lenguaje correspondientes a la vinculación. El alcance de un vínculo es la
región del programa sobre la cual se conserva el vínculo.
En lenguajes estructurados por bloques dónde los bloques pueden anidarse, el
alcance de un vínculo queda limitado al bloque en el cual aparece su declaración
asociada. Estas reglas de alcance se conocen cómo alcance léxico ya que siguen
la estructura de los bloques Conforme aparece en el código escrito. Se trata de la
regla estándar de alcance en la mayoría de los lenguajes. Es la figura 7.1 se da un
ejemplo simple de alcance en C.
int x;
void p( ){
char y;

}

void q( ){

}
main( ){

}
Figura 7.1. Un programa C simple mostrando el alcance.
En la figura 7.1, las declaraciones de la variable “x” y los procedimientos “p”. “q” y
main son globales. Las declaraciones de “y”, “z” y “w”, por otra parte, están
asociadas con los bloques de procedimiento p, q y main, respectivamente. Son
locales con respecto a estas funciones y sus declaraciones son solamente válidas
para dichas funciones. C tiene la regla adicional de que el alcance de una
declaración se inicia en el punto de la declaración misma (ésta es la regla conocida
como declaración antes de uso), de tal manera que podemos enunciar la siguiente
regla básica de alcance en C: el alcance de una declaración se extiende en el punto
justo después de la misma hasta el fin del bloque en el que está localizado.
Una característica de la estructura de bloques es que las declaraciones en los
bloques anidados toman precedencia sobre declaraciones anteriores. Veamos el
ejemplo de la figura 7.2.
int x;
void p( ){
char x;
x=’a’;

}

main( ){
x=2;

}
Fig. 7.2
La declaración de x en p tiene precedencia sobre la declaración global de x en el
cuerpo de p. Por lo tanto, el entero global de x no puede ser accesado desde dentro
de p. La declaración global de x se dice que tiene una apertura en el alcance dentro
de p. Por esta razón, a veces se hace una distinción lo que es el alcance y lo que
es la visibilidad de una declaración: la visibilidad incluye únicamente aquellas
regiones de un programa donde las ligaduras de una declaración son aplicables, en
tanto que el alcance incluye a los agujeros en le alcance (dado que las ligaduras
existen pero están ocultas a la vista). En C++ el operador de resolución de
alcance :: (doble dos puntos) puede utilizarse para tener acceso a estas
declaraciones ocultas (siempre y cuando sean globales);
int x;
void p( ){
char x=’a’; //asignación a variable local
::x=42; //asignación a variable global

}
main( ){
x=2; //asignación a variable global.

}

Este es un ejemplo de un fenómeno difundido en los lenguajes de programación:


los operadores y los modificadores de declaraciones pueden alterar el acceso y el
alcance de las declaraciones.
Las reglas de alcance necesitan también construirse de manera tal que las
declaraciones recursivas o de referencia a sí mismas sean posibles cuando tengan
sentido. Debe permitirse que las funciones sean recursivas; esto significa que la
declaración de un nombre de función tiene un alcance que empieza antes de que
se introduzca el bloque del cuerpo de la función.

int factorial(int x)
//el alcance del factorial inicia aquí
{
//el factorial se puede llamar aquí

}
Por otra parte, las declaraciones de variables recursivas generalmente no tienen
sentido.

TABLA DE SIMBOLOS.

La tabla de símbolos es como un diccionario variable: debe dar el apoyo a la


inserción, búsqueda y cancelación de nombres con sus atributos asociados
representando la vinculación en declaraciones. Un símbolo puede mantenerse con
cualquier cantidad de estructuras de datos para permitir un acceso y mantenimiento
eficientes. La tabla Hash, los árboles y las listas son algunas de las estructuras de
datos que han sido utilizadas. Sin embargo, el mantenimiento de información de
alcance en un lenguaje con alcance léxico y estructura de bloques requieren que
las declaraciones sean procesadas en forma de pila: a la entrada de un bloque,
todas las declaraciones de bloque se procesan y agregan las vinculaciones
correspondientes a la tabla de símbolos; Entonces, a la salida del bloque, se elimina
las ligaduras proporcionadas por las declaraciones, restaurando cualquier vínculo
anterior que pudiera haber existido. Sin limitar nuestra imagen de una tabla de
símbolos a ninguna estructura de datos en particular, podemos sin embargo
considerar esquemáticamente la tabla de símbolos como un conjunto de nombres,
cada uno de los cuales tiene una pila de declaraciones asociada con ellos, de
manera que la declaración en la parte superior de la pila es aquella cuyo alcance
actualmente está activo. Para ver la forma en que esto funciona, piense en el
programa C, de la figura 7.3.

1) int x;
2) char y;
3) void p(){
4) double x;
5) …
6) { int y[10];
7) …
8) }
9) …
10) }
11) void q( ){
12) int y;
13) …
14) }
15) main(){
16) char x;
17) …
18) }
Figura 7.3. Programa en C demostrando la estructura de la tabla de símbolos
Los nombres dentro de este programa son x, y, p, q y main, pero “x” y “y” están
asociados con tres declaraciones diferentes y con alcances distintos.
Inmediatamente después del procedimiento de la declaración de variable al principio
del cuerpo de “p” (línea 5 de la figura 7.3), la tabla de símbolos puede representarse
como se ve en la figura 7.4 (Dado que en C todas las declaraciones de función son
globales, no indicamos esto de manera explícita en los atributos de la figura 7.4, ni
en las figuras subsecuentes).

Fig. 7.4. Estructura de la tabla de símbolos en la línea 5 de la figura 7.3.

Después del procedimiento de la declaración del bloque anidado en “p”, con la


declaración local de “y” (es decir, en la línea 7 de la figura 7.3), la tabla de símbolos
aparece como en la figura 7.5.

Figura 7.5. Estructura de la tabla de símbolos en la línea 7 de la figura 7.3.


Una vez terminado el procedimiento de “p” (línea 10 de la figura 7.3), la tabla de
símbolos cambia como en la figura 7.6 (con la declaración local de “y” extraída de
la pila de declaraciones de “y” en la línea 8 y entonces la declaración local de “x”
extraída de la pila “x” en la línea 10),

Figura 7.6. Estructura de la tabla de símbolos en la línea 10 de la figura 7.3.

Una vez introducido q (línea 13), la tabla de símbolos se convierte como en la figura
7.7.

Figura 7.7 Estructura de la tabla de símbolos en la línea 13 de la figura 7.3.

Y cuando q sale (línea 14), la tabla de símbolos es como se muestra en la figura


7.8.
Fig. 7.8. Estructura de la tabla de símbolos en la línea 14 de la figura 5.3.

Finalmente, después de introducir main (línea 17), la tabla de símbolos queda


como se muestra en la figura 7.9.

Figura 7.9. Estructura de la tabla de símbolos en la línea 17 de la figura 7.3.

Este proceso conserva la información apropiada de alcances, incluyendo los


agujeros de alcance para la declaración global de “x” en el interior de “p” y la
declaración global de “y” en el interior de “q”.

Esta representación de la tabla de símbolos supone que la tabla procesa las


declaraciones de manera estática, esto es, antes de ejecución. Este es el caso,
siempre que la tabla de símbolos sea manejada por un compilador y que las
ligaduras de las declaraciones sean estáticas. Si la tabla de símbolos está
administrada de esta forma, pero dinámicamente, esto es, durante la ejecución,
entonces las declaraciones se procesan conforme se van encontrando a través del
programa a lo largo de la trayectoria de la ejecución. Esto da como resultado una
regla de alcance diferente, lo que por lo general se conoce como alcance dinámico,
y la regla de alcance léxico anterior a veces se conoce como alcance estático.

Para mostrar la diferencia entre ambos alcances se agregará más código al


programa de la figura 7.3, de manera que se establece una trayectoria de ejecución,
y también se dan algunos valores a las variables, de modo que se pueda generar
alguna salida con el objeto de enfatizar esta restricción. El ejemplo revisado aparece
en la figura 7.10.

1. include <stdio.h>
2. int x=1;
3. char y=’a’;
4. void p( ){
5. double x=2.5;
6. printf(“%c”,y);
7. { int y[10]
8. }
9. }
10. void q( ){
11. { int y=42;
12. printf(“%d\n”,x);
13. p();
14. }
15. main( ){
16. char x=’b’;
17. q( );
18. return 0;
19. }
Figura 7.10. Programa en C.

Qué pasa si la tabla de símbolos para el programa de la figura 7.10 es construida


en forma dinámica conforme avanza la ejecución. Primero la ejecución se inicia en
la función main( ), y todas las variables globales deberán ser procesadas antes de
dicha ejecución, ya que main( ) debe conocer todo con respecto a las declaraciones
que ocurren antes de ésta. Así, la tabla de símbolos al principio de la ejecución
(línea 17) se presenta en la figura 7.11.
Figura 7.11. Estructura de la tabla de símbolos en la línea 17 de la figura 7.10.

Esta es la misma tabla de símbolos cuando main( ) se procesa en forma estática


(figura 7.9), solo que ahora se agregan atributos de valor a la imagen. Todavía se
tienen que procesar los cuerpos de las demás funciones.

Ahora main( ) procede a llamar a q( ), la entrada en q() (línea 12) se observa en la


figura 7.12.

Fig. 7.12. Entorno de la tabla de símbolos en la línea 12 de la figura 7.10 utilizando


alcance dinámico.

Ahora, esto es muy diferente a la tabla de símbolos a la entrada de q( ) utilizando


procesamiento estático (figura 7.7). Note que cada una de las llamadas de q( )
puede tener una tabla de símbolos diferente en su entrada, dependiendo de la
trayectoria de ejecución a dicha llamada, en tanto utilizando el alcance léxico, cada
procedimiento sólo tiene una tabla de símbolos asociada con su entrada (Dado que
en este ejemplo existe una sola llamada q( ), no se aplica en este caso.)
Para continuar con el ejemplo q( ) llama a p( ), y la tabla de símbolos se convierte
en la forma que se muestra en la figura 7.13.
Ahora se considera la forma en la que el alcance dinámico afectará la semántica de
este programa y producirá un resultado diferente. Primero se nota que la salida real
de este programa utilizando alcance léxico que es el estándar para la mayoría de
los lenguajes , incluyendo a C, es:
1
a
dado en el primer enunciado printf (línea 6), la referencia es hacia y global,
independientemente de la trayectoria de ejecución, y en el segundo enunciado printf
(línea 12) la referencia es a la x global de nuevo, independientemente de la
trayectoria de ejecución, y los valores de estas variables son ‘a’ y 1 a todo lo largo
del programa.

Figura 7.13. Estructura de datos en la línea 6 de la figura 7.10 utilizando alcance


dinámico

Sin embargo, utilizando el alcance dinámico, las referencias no locales hacia “y” y
hacia “x” en el interior de p( ) y de q( ), respectivamente, pueden modificarse,
dependiendo de la trayectoria de ejecución. En este programa, el enunciado printf
en la línea 12 de la figura 7.10 es alcanzado con la tabla de símbolos como en la
figura 7.12 (con una nueva declaración de “x” como el carácter ‘b’ en el interior del
main) y, por lo tanto, el enunciado printf imprimirá este carácter interpretado como
un entero (debido al formato %d), siendo el valor ASCII 98. Segundo, la referencia
printf hacia “y” en el interior de p( ) (línea 6 de la figura 7.10 es ahora entero con
valor 42 definido dentro de q( ) según se muestra en la figura 7.13. Este valor ahora
es interpretado como carácter (ASCII 42= ‘*’), y el programa imprimirá
92
*
Este ejemplo muestra los problemas que hace difícil el alcance dinámico y es la
razón por la cual pocos lenguajes lo usan. El primer problema es que, bajo el
alcance dinámico, cuando un nombre no local es utilizado en una expresión o un
enunciado, la declaración que se aplica a ese nombre no puede determinarse
mediante la simple lectura del programa. En vez de ello el programa deberá ser
ejecutado. Diferentes ejecuciones producirán resultados diferentes y la semántica
de una función puede cambiar conforme avanza la ejecución. Ya que la referencia
a variables locales no puede predecirse antes de la ejecución. De esta forma, la
ligadura estática de los tipos de datos (tipificado estático) y el alcance dinámico son
inherentemente incompatibles.
El alcance dinámico es una buena opción para lenguajes dinámicos interpretados,
por lo que, lenguajes como APL, Snobol, Perl, Lisp han tenido alcance dinámico
Independientemente de la cuestión del alcance léxico en contraste con el dinámico,
existe una complejidad adicional significativa que todas las estructuras y el
comportamiento de la tabla de símbolos que todavía no se ha analizado. La tabla
de símbolos única para un programa, como se ha estudiado hasta este momento,
es adecuada para lenguajes simples como C o Pascal con un requisito de
declaraciones estática antes del uso, y donde los alcances no pueden ser
ingresados de nuevo una vez que se ha salido de ellos durante el procedimiento
mediante el traductor.
Incluso esta descripción no cubre todas las situaciones de C o Pascal. Por ejemplo,
la declaración “struct” en C tal como se observa en la figura 7.14.
1. struct{
2. int a;
3. int b;
4. double c;
5. } x= {1,’a’,2.5};
6. void p( ){
7. struct{
8. double a;
9. int b;
10. char c;
11. } y={1.2,2,’b’};
12. printf(“%d, %c, %g\n”,x.a,x.b,x.c);
13. printf(“%f, %d, %c\n”,y.a,y.b,y.c);
14. }
15. main( ){
16. p( );
17. return 0;
18. }
Figura. 7.14. Ejemplo de código ilustrando el alcance de las declaraciones locales
con “struct” de C.

Está claro que cada una de las declaraciones struct en éste código (líneas 1-5 y 7-
11 en la figura 7.14) debe contener declaraciones adicionales de los campos de
datos dentro de cada struct, y que éstas deben ser accesibles (utilizando la notación
punto de la selección de miembros) siempre y cuando las mismas variables struct
(x,y) estén en el alcance. Esto quiere decir dos cosas: 1. Una declaración struct
realmente contiene una tabla de símbolos local y es en sí un atributo (que contiene
las declaraciones miembro), y 2. Esta tabla de símbolos local no puede eliminarse
hasta que la variable tipo struct misma sea eliminada de la tabla de símbolos.
“global” del programa.
Por lo que dentro de p( ) (línea 12 de la figura 7.14) la tabla de símbolos por el
código arriba citado puede verse como se muestra en la figura 7.15.

Figura 7.15. Estructura de la tabla de símbolos en la línea 12 de la figura 7.14.


Cualquier estructura de alcance que pueda ser referenciada directamente en un
lenguaje debe tener su propia tabla de símbolos. Los ejemplos incluyen todos los
alcances de las clases y los paquetes en Java. Por lo que una estructura más típica
para la tabla de símbolos de un programa es tener una tabla de símbolos para cada
uno de los alcances que a su vez tienen que estar anidados con sus propias tablas
que los encierran. De nuevo, pueden mantenerse en un estilo basado en pilas.

ASIGNACIÓN, TIEMPO DE VIDA Y ENTORNOS.

Es importante estudiar el entorno que mantiene las ligaduras de los nombres con
las localizaciones. Se presentarán los fundamentos de los entornos sin funciones,
como una comparación con la tabla de símbolos.
Dependiendo del lenguaje, el entorno puede construirse como estático (en tiempo
de carga), dinámico (en tiempo de ejecución), o una mezcla de ambos. Un lenguaje
que utiliza un entorno completamente estático es FORTRAN donde todas las
localizaciones están vinculadas estáticamente. Un lenguaje que utiliza un entorno
totalmente dinámico es Lisp donde todas las localizaciones se vinculan durante la
ejecución. C, C++, Java, Ada y otros lenguajes estilo Algol se quedan en medio.
En un lenguaje compilado, los nombres de las constantes y de los tipos de datos
pueden representar puramente cantidades en tiempo de compilación que no tienen
existencia en el tiempo de carga o en el tiempo de ejecución. Por ejemplo, la
declaración constante global en C
const int Max=10;
puede ser utilizado por un compilador para reemplazar todos los usos de Max por
el valor de 10. El nombre Max nunca se asigna a localización y de hecho desaparece
completamente del programa cuando está en ejecución.
Las declaraciones se utilizan para construir el entorno, así como la tabla de
símbolos. En un compilador, las declaraciones son utilizadas para indicar el código
de asignación que el compilador tiene que generar conforme se procesa la
declaración. En un intérprete, se combinan la tabla de símbolos y el entorno, por lo
que la ligadura de atributos por una declaración incluye la ligadura de las
localizaciones.
Típicamente, en un lenguaje con estructura de bloques las variables globales se
asignan estáticamente, en vista de que su significado es fijo a lo largo del programa.
Las variables locales, sin embargo, se asignan dinámicamente cuando la ejecución
llega al bloque en cuestión. El entorno en un lenguaje por bloques vincula las
localizaciones a las variables locales en forma de pila. Observar el programa en C
de la figura 7.16.
1. A: { int x;
2. char y;
3. …
4. B: { double x;
5. int a;
6. …
7. } //fin de B.
8. C: { char y;
9. int b;
10. …
11. D: { int x;
12. Double y;
13. …
14. }//fin de D.
15. …
16. }//fin de C.
17. …
18. }//fin de A.
Fig. 7.16. Programa en C con bloques anidados para demostrar el entorno.

Durante la ejecución de este código, cuando se entra a cada uno de estos bloques,
las variables declaradas al principio de cada bloque se asignan y cuando se sale de
cada bloque, estas mismas variables se desasignan. Si vemos el entorno como una
estructura lineal de localizaciones de almacenamiento, con las localizaciones
asignadas a partir de la parte superior en orden descendente, entonces el entorno
de la línea 3 de la figura 7.16 tiene la apariencia que sigue (ignorando el tamaño de
la variable asignada):
Y el entorno después de la entrada en B:

A la salida del bloque B (línea 7) el entorno vuelve a solo a la ligadura de asignación


A. Entonces, al ejecutarse el bloque C el entorno queda de la siguiente forma:

Se observa que las variables del bloque C están ahora asignadas al mismo espacio
previamente asignado a las variables “x” y “a” del bloque B. Esto es correcto, dado
que se está fuera del alcance de esas variables.

Finalmente, la entrada del bloque D (línea 11 y 12), el entorno se convierte en:


A la salida de cada uno de los bloques las ligaduras de localización de dicho bloque
quedan designadas sucesivamente, hasta que, antes de la salida del bloque A, de
nuevo se ha recobrado el entorno original de A. De esta manera el entorno se
comporta como una pila.

Este comportamiento de entorno en la asignación y designación de espacio para los


bloques es relativamente simple. Los bloques de procedimientos y de función son
más complicados. Véase la siguiente declaración de procedimiento en la sintaxis de
C.

Void p( ){
Int x;
Double y;

}

Durante la ejecución esta declaración por sí misma no disparará una ejecución del
bloque de p( ) y las variables “x” y “y” de p( ) no serán asignadas en el punto de las
declaraciones. Más bien, se asignarán en el punto de la llamada de p( ). Por lo que,
cada vez que se invoca a p( ) resulta en una región de la memoria asignada en el
entorno. A cada llamada a p( ) se refiere como una activación de p( ) y la región
correspondiente de la memoria asignada como un registro de activación. Por
ejemplo, en el siguiente programa se realiza una llamada recursiva a la misma
función 4 veces, por lo que cada llamada tendrá su propio registro de activación

void main( ){
int x=4;
recursivo(x);
}
void recursivo(int x){
int y;
if (x>0)
recursivo(x-1)
}

Quedando los bloques de la siguiente manera


P
x=0
P
x=1
p
x=2
p
x=3
p
x=4
main
x=4

Queda claro que un lenguaje estructurado por bloques y alcance léxico, se puede
asociar el mismo nombre con varias locaciones diferentes (aunque sólo una de ellas
pueda ser accedida en cualquier momento). Por lo que se debe de distinguir entre
un nombre con locaciones diferentes. Debemos por lo tanto distinguir entre nombre,
localización asignada y declaración que hace que queden vinculadas. Se llama la
localización asignada un objeto. Esto es, un objeto es un área de almacenamiento
asignada en el entorno como resultado del procesamiento de una declaración. De
acuerdo a esta definición, las variables y los procedimientos son objetos en C, pero
las constantes y los tipos de datos no lo son (dado que las declaraciones de
constantes y de tipos de datos no dan como resultado una asignación de
almacenamiento). El tiempo de vida o extensión de un objeto es la duración de su
asignación en el entorno. Observando la figura 7.16, en el caso de bloques previos,
la declaración del entero “x” en el bloque A define un objeto cuya duración se
extiende a lo largo del bloque B, aun cuando la declaración tiene un agujero de
alcance en B, y el objeto no es accesible desde el interior de B. De manera similar,
es posible que ocurra lo inverso: un objeto puede ser accesible más allá de un
tiempo de vida.
Cuando están disponibles los apuntadores en un lenguaje, resulta necesaria una
extensión de la estructura del entorno. Un apuntador es un objeto cuyo valor
almacenado es una referencia a otro objeto.
En C, el procedimiento de la declaración
int * x;
por parte del entorno genera la asignación de una variable apuntador “x”, pero no la
asignación de un objeto hacia el cual apunta “x”. Ciertamente, “x” puede tener un
valor no definido, que podría ser cualquier localidad arbitraria de la memoria. Para
permitir la inicialización de los apuntadores que no apuntan a un objeto asignado, y
para permitir que un programa determine si una variable apuntador apunta a una
memoria asignada, C permite el uso del entero 0 como una dirección que no puede
ser la dirección de cualquier objeto asignado, y varias bibliotecas de C le dan el
nombre de NULL a 0, por lo que uno puede escribir en C
int * x=NULL;
Con esta inicialización, es posible probar a “x” para ver si apunta a un objeto
asignado
If (x!=NULL) *x=2;
Para que “x” apunte a un objeto asignado, se debe asignar manualmente mediante
el uso de una rutina de asignación. En C se tiene el módulo de biblioteca “stdlib”
donde se tienen varias funciones para asignar y desasignar memoria. La más usada
es “malloc” (memory allocation) y la función “free”.
x=(int*)malloc(sizeof(int));
asigna una nueva variable entera y al mismo tiempo su locación en el valor “x”. La
función “malloc” devuelve la localización que asigna y debe darsele el tamaño de
los datos al cual asigna espacio. Esto se puede dar en forma independiente de la
implementación utilizando la función incorporada “sizeof”, a la cual se le da el tipo
de dato y retorna su tamaño en bytes. La función “malloc” debe adecuarse al tipo
de datos de la variable cuyos resultados le son asignados al colocar el tipo de datos
(antes de la función), dado que la dirección que devuelve es un apuntador anónimo
(un void * en C).
Una vez asignada “x” a la división de una variable entera asignada, esta nueva
variable entera se puede accesar mediante el uso de la expresión “*x”. Se dice que
la variable “x” se puede desreferenciar utilizando el operador unitario “*”. Entonces
se pueden asignar valores enteros a “*x” y referirnos a valores como se haría en
una variable ordinaria, como en
*x=2;
printf(“%d”,*x);
*x también se puede desasignar utilizando el procedimiento free
Free(x);
En este caso se está liberando la memoria del elemento referenciado *x, no la
memoria de la variable “x”.
Para permitir la asignación y la desasignación arbitraria en tiempo de ejecución
utilizando malloc y free, el entorno debe tener un área en la memoria a partir de la
cual se puede asignar las localizaciones en respuesta a las llamadas malloc, y la
devolución de la localización de memoria con la instrucción free. Tradicionalmente
ésta área de memoria se conoce como montículo o montón. La asignación en el
montón se conoce como asignación dinámica, aún cuando la asignación de
variables locales es dinámica. Para distinguir entre estas dos formas de asignación
dinámica, a veces se conoce como basada en pilas o automática a la asignación
de variables locales ya que ocurre de manera automática bajo el control del sistema
en tiempo de ejecución. En cambio, los apuntadores se ejecutan de manera manual
bajo el control del programador.
En una implementación típica del entorno, la pila (por la asignación automática) y el
montículo (por la asignación dinámica) se mantienen en secciones diferentes de la
memoria, y las variables globales también se mantienen en otra sección de la
memoria. Aunque estas tres secciones se podrían colocar en cualquier parte de la
memoria, una estrategia común es colocar las tres adyacentes, en primer término,
las variables globales, la pila en segundo término y el montículo al final, creciendo
el montículo y la pila en direcciones opuestas para evitar colisiones pila/montículo
en caso de que no existan límites fijos entre ambos. Esto se observa en la figura
7.17.
Se observa que a pesar de que el montículo aparentemente crece en una sola
dirección, el almacenamiento se puede liberar en cualquier parte del área asignada
del montículo, y necesita ser reutilizable, por lo que un protocolo de pila simple no
funciona para el montículo.
En muchos lenguajes se requiere que la desasignación de la memoria del montículo
sea realizada en forma automática, contrario a la asignación que es controlada por
el usuario.

Área estática
(Global)

Pila

No asignada

Monticulo

Figura 7.17 Entorno de una estructura típica pila/montículo

Resumiendo, en un lenguaje con estructura por bloques y asignación de memoria


de muntículo tiene tres tipos de asignación en el entorno: estático (para variables
globales), automático (para variables locales) y dinámico (para asignación en el
montículo). Estas categorías también se conocen como clases de
almacenamiento de la variable. Algunos lenguajes como C permiten que una
declaración especifique una clase de almacenamiento, así como un tipo de datos.
Típicamente, esto sirve para cambiar la asignación de una variable local a estática.
Int f(){
Static int x;
}
Ahora x se asigna únicamente una vez, y tiene el mismo significado (y valor) en
todas las llamadas a f. Esta combinación de alcance local con duración de vida
global se puede utilizar para conservar el estado local en todas las llamadas a f,
mientras que al mismo tiempo se impide que el código fuera de función cambie de
estado. Por ejemplo, en la figura 7.18 mientras el código para una función en C que
devuelve el número de veces que ha sido llamada, junto con algo de código de
programa principal interesante.
int p( ){
static int p_count=0;
// se inicia sólo una vez, no en cada llamada.
p_count+=1;
return p_count;
}
main( ){
int i;
for (i=0;i<10;i++)
if (p( )%3)
printf(“%d\n”,p( ));
return 0;
}

También podría gustarte