Informática
Lenguajes y Desarrollo de Software
Contenido:
¿ Qué es Control de Secuencia entre Enunciados ?
¿ Qué es Control de Secuencia en Subprogramas ?
¿ En qué consiste el Control de Datos ?
-
Ambiente de referencia.
-
Identificadores y seudónimos.
-
Alcances.
-
¿ Cómo comparten los datos los Subprogramas?
-
Parámetros.
-
Ambientes comunes.
-
Alcances.
-
¿ Qué son Excepciones ?
-
¿Qué son Corrutinas?
-
¿Qué es Programación Paralela? ¿Cuáles son sus principios?
-
¿Qué es Ejecución Concurrente?
-
¿Qué son Mandatos Protegidos?
-
i se declara como un entero;
-
p se declara como un apuntador a un entero;
-
p se fija de modo que apunte a i, es decir, el valor 1 de i se convierte en un valor r (&i) y este valor r se guarda como el valor r de p;
-
El valor r de p se convierte para que sea un valor 1 (*p); es el valor 1 de i, de modo que el valor r de i se fija en 7.
-
Composición. Los enunciados se pueden disponer en una serie textual, de modo que se ejecuten en orden siempre que se ejecute la estructura mayor del programa que contiene la serie.
-
Alternancia. Dos series de enunciados pueden formar alternativas de modo que se ejecute una u otra serie, pero no ambas, siempre que se ejecute la estructura mayor de programa que contiene la serie.
-
Iteración. Una serie de enunciados se puede ejecutar en forma repetida, cero o más veces (cero significa que la ejecución se puede omitir del todo), siempre que se ejecuta la estructura mayor del programa que contiene la serie.
-
¿Qué es Control de Secuencia en Subprogramas?
-
¿En qué consiste el Control de Datos?
-
Ambiente de referencia.
-
Identificadores y seudónimos.
-
Alcances.
-
¿Cómo comparten los datos los Subprogramas?
-
Parámetros.
-
En el programa de llamada, cada expresión de parámetro real se evalúa para dar un apuntador al objeto de datos del parámetro real (es decir, su valor 1). Se guarda una lista de estos apuntadores en un área de almacenamiento común que también es accesible para el programa que se está llamando (a menudo en un conjunto de registros internos o en la pila del programa). El control se transfiere luego al subprograma, como se describe en el capitulo anterior; es decir, se crea el registro de activación para el subprograma (si es necesario), se establece el punto de regreso, y así sucesivamente.
-
En el subprograma llamado, se tiene acceso a la lista de apuntadores a parámetros reales para recuperar los valores r apropiados de los parámetros reales.
-
Ambientes comunes.
-
Alcances.
-
¿Qué son Excepciones?
-
manejador para todas las demás excepciones
-
¿Qué son Corrutinas?
-
¿Qué es Programación Paralela? ¿Cuáles son sus principios?
-
¿Qué es Ejecución Concurrente?
-
¿Qué son Mandatos Protegidos?
-
¿Qué son Tareas? ¿Cómo se gestionan?
-
Resto de la definición en la misma forma que antes
1. ¿Qué es Control de Secuencia entre Enunciados?
Son mecanismos básicos que controlan el orden de ejecución de los enunciados dentro de un programa.
A. Enunciados básicos
Los resultados de cualquier programa están determinados por sus enunciados básicos que aplican operaciones a objetos de datos. Son ejemplos de estos enunciados básicos los enunciados de asignación, llamadas de subprograma, y enunciados de entrada y salida. Dentro de un enunciado básico, se pueden invocar series de operaciones usando expresiones, como se expuso en la sección anterior, pero, para nuestros fines actuales, cada enunciado básico se puede considerar como una unidad que representa un solo paso en el cómputo.
Asignaciones a objetos de datos: Los cambios al estado del cómputo por asignación de valores a objetos de datos es el mecanismo más importante que afecta el estado de un cómputo que realiza un programa. varias formas de esta clase de enunciado:
Enunciado de asignación. El propósito primordial de una asignación es atribuir al valor l de un objetos de datos (es decir, a su localidad de memoria) el valor r (es decir, el valor del objeto de datos) de cierta expresión. La asignación es una operación fundamental definida para todos los elementales de datos. La sintaxis para una asignación específica varía ampliamente:
En C, la asignación es simplemente un operador, de modo que se puede escribir: c = b = 1 que significa (c = (b = 1)), y que sufre el proceso de evaluación siguiente:
1. Se asigna a b el valor 1.
2. La expresión (b = 1) devuelve el valor 1.
3. Se da a c el valor 1.
Es más frecuente, sin embargo, que la asignación se considere como un enunciado por separado. Puesto que la operación de asignación de Pascal no devuelve un resultado explícito, se usa sólo en el nivel de enunciados, en un enunciado de asignación explícito:
X : = B + 2*C
Y : = A + X;
Casi todos los lenguajes tienen un solo operador de asignación. C, sin embargo, tiene varios:
I
A=B | Asignar valor r de B a valor 1 de A, devolver valor r |
A+=B (- =) | Incrementar (o disminuir) A en B (A = A + B o A = A - B), devolver valor nuevo |
++A (- -A) | Incrementar (o disminuir) A, luego devolver valor nuevo (p. ej., A = A + 1, devolver valor r de A) |
A++ (A- -) | Devolver valor de A, luego incrementar (disminuir) A (devolver valor r, luego A A + 1) |
Si bien todos éstos son similares, cada uno tiene una semántica ligeramente distinta y puede tener un efecto diferente en diversas situaciones. Puesto que la operación básica de asignación consiste en asignar el valor r de una expresión al valor 1 de otra expresión, se puede conseguir mayor flexibilidad incluyendo operadores que afectan el valor 1 y el valor r de las variables. En C, por ejemplo, el operador unario * es un operador de "indirección" que hace que el valor r de una variable se comporte como si fuera un valor 1, y el operador unario & es un operador de "dirección" que convierte un valor 1 en un valor r. Por ejemplo, en:
int i, *p
p = &i:
*p = 7
tenemos que:
El programa siguiente en C es un ejemplo más completo:
main ( )
{ int *p, *q, i, j; /*p y q apuntan a ints */
int **qq; /* qq es apuntador a apuntador a int */
i=1 ; j=2; printf("I= %d; J= %d;\n", i,j); /* Imprimir i y j */
p = & i; /* p= valor 1 de i */
q = & j; /* q= valor 1 de j */
*p =*q; printf("I=%d; J=%d;\n”, i,j); /* igual que i = j */
qq = & p; /* qq apunta a p */
**qq 7; printf("l=%d; J=%d;\n", i,j);} /* igual que i = | */
con la salida:
I=1; J=2;
I=2; J=2;
I=7; J=2;
Enunciado de entrada. Casi todos los lenguajes de programación incluyen una forma de enunciados para leer datos del usuario en una terminal de archivos o de una línea de comunicaciones. Estos enunciados también cambian los valores de variables a través de asignaciones. Típicamente, la sintaxis es de la forma: leer(archivo,datos). En C, una llamada de la función printf causa asignación a la "variable buffer" de un archivo.
Otras operaciones de asignación. La transmisión de parámetros se suele definir como una asignación del valor de argumento al parámetro formal. También se encuentran diversas formas de asignación implícita; por ejemplo, en SNOBOL4, cada referencia a la variable INPUT hace que se le asigne un nuevo valor a ella, la concordancia de metas en Prolog (resolución) ocasiona la asignación implícita a variables. Se suele poder asignar un valor inicial a una variable como parte de su declaración.
Formas de control de secuencia al nivel de enunciados
Se distinguen por lo común tres ~ principales de control de secuencia al nivel dc enunciados:
Al construir programas, uno se ocupa de armar los enunciados básicos que llevan a cabo el cómputo en los órdenes apropiados usando en forma repetida composición, alternancia e iteración para conseguir el efecto deseado. Dentro de cada una de estas categorías generales de formas de control, se suelen usar variantes apropiadas para fines particulares. Por ejemplo, en vez de una alternancia consistente en sólo dos alternativas, con frecuencia se requiere una compuesta de varias alternativas. Por lo común, el lenguaje de programación suministra diversas estructuras de control de secuencia destinadas a permitir la fácil expresión de estas formas de control.
Control explícito de secuencia
Los primeros lenguajes de programación tenían como modelo la máquina real subyacente encargada de ejecutar el programa. Puesto que las máquinas se componían de posiciones de memoria, los primeros lenguajes (por ejemplo, FORTRAN, ALGOL) las modelaban con tipos de datos simples traducibles directamente a objetos de máquina (por ejemplo, float de C y real de FORTRAN a punto flotante de hardware, int de C a entero de hardware) y con enunciados simples compuestos de etiquetas y bifurcaciones. La transferencia del control se indica más a menudo por el uso de un enunciado goto a un enunciado explícito con un nombre de etiqueta dado.
Enunciado goto. En muchos lenguajes están presentes dos formas de enunciado goto (ir a):
Goto incondicional. Dentro de una serie de enunciados, un goto incondicional, como:
goto PRÓXIMO
transfiere el control al enunciado con etiqueta PRÓXIMO. El enunciado que sigue a goto no se ejecuta como parte de la serie.
Goto condicional. Dentro de una serie de enunciados, un goto condicional, como:
if A = 0 then goto PRÓXIMO
transfiere el control al enunciado con etiqueta PRÓXIMO sólo si se cumple la condición especificada. Aunque es una forma de enunciado simple, el goto la ha pasado difícil durante los últimos 25 años. Su uso en programación ha sido blanco de muchas críticas. El uso de enunciados goto conduce a un diseño no estructurado del programa, como se describe en seguida. Por ejemplo, gran parte del modelado formal que conduce a un modelo de corrección axiomática del diseño de programas depende de un estructura razonable de control para un programa. De hecho, el modelo axiomático dado en esa sección no permite incluir con facilidad un enunciado goto.
Algo más importante es que se ha demostrado que el enunciado goto es supe programas se pueden escribir con la misma facilidad sin enunciados goto y la mayoría de los estudiantes aprenden a programar en C o Pascal sin habérseles enseñado nunca que ambos lenguajes contienen enunciados goto.
En tanto los lenguajes contengan estructuras de control anidables, como las construcciones while e if, las cuales se analizarán en breve, el goto es un artefacto que se puede pasar por alto sin dificultad. Desafortunadamente, en ciertos lenguajes, como las primeras versiones de FORTRAN y APL, la transferencia explícita del control es necesaria, puesto que faltan las estructuras compuestas apropiadas.
Enunciado break. Ciertos lenguajes, como C, incluyen un enunciado break (escapar) como una forma de control explícito estructurado. Por lo común, el break causa que el control pase más adelante en el programa hasta un punto explícito al final de una estructura de control dada. Este break en C hace que el control salga del enunciado while, for o switch que lo encierra directamente. Esto todavía proporciona una estructura de control de una salida y una entrada, que permite desarrollar las propiedades formales de un programa.
El C también incluye un enunciado continue (continuar) relacionado. Este hace que el control pase al inicio del cuerpo de la iteración vigente en un enunciado while o for. Así pues, un break causa la salida desde una iteración, mientras que el continue ocasiona una nueva iteración.
Diseño de programación estructurada
Aunque casi todos los programas definen etiquetas y enunciados goto, la década de 1970 fue testigo de considerable controversia acerca de su uso. En ciertos lenguajes nuevos (por ejemplo, ML), los goto se eliminan por completo. Algunas de las ventajas aparentes de los goto son: (1) manejo directo por hardware para una ejecución eficiente si las etiquetas son simplemente marcas sintácticas locales en los enunciados, (2) sencillos y fáciles de usar en programas pequeños, (3) familiares para los programadores capacitados en lenguaje ensamblador o lenguajes más antiguos, y (4) de uso completamente general como bloque de construcción para representar (simular) cualquiera de las otras formas de control que se analizan más adelante.
Sin embargo, las desventajas del uso de enunciados goto sobrepasan con mucho cualquiera de estas ventajas:
Carencia de estructura jerárquica de programas. La ejecución en computadora se ha vuelto económica, en tanto que los salarios de los programadores van en aumento. Además, las computadoras se están usando en aplicaciones más críticas, como el control de aviones, maquinaria y automóviles. Es más importante que el programa se ejecute correctamente en vez de tener la máxima eficiencia. El diseño de programación debe reflejar estas necesidades.
El concepto de la estructura de control con un punto de entrada y uno de salida contribuye a un diseño más inteligible. Un programa de más de unos pocos enunciados es difícil de entender a menos que los enunciados estén organizados jerárquicamente en grupos, donde cada grupo represente una unidad conceptual del cómputo subyacente. A su vez, cada uno de estos grupos está organizado como unos cuantos subgrupos que utilizan una de las formas de control, y así sucesivamente. En el diseño de un programa, esta clase de organización jerárquica es esencial para que el programador pueda entender cómo encajan entre si todas las partes del programa. Cuando un diseño de programa que está organizado jerárquicamente en esta forma se escribe como un programa que contiene sólo enunciados básicos y goto, la estructura jerárquica queda oculta en gran medida. El programador original puede ver todavía la estructura, pero, para otros, resulta difícil encontrarla. Se dice que el programa tiene una estructura muy “plana”; todos los enunciados parecen estar en un mismo nivel en lugar de hallarse organizados jerárquicamente
No es necesario que el orden de los enunciados en el programa corresponda al orden de ejecución. Mediante el uso de enunciados goto, es fácil escribir programas en los cuales el control salta entre diferentes series de enunciados siguiendo patrones irregulares. En este caso, el orden en el que los enunciados aparecen en el programa tiene poca relación con el orden de ejecución de los mismos. Para entender un programa, se debe comprender el orden e ejecución de los enunciados, y es mucho más sencillo entender un programa donde los enunciados aparecen aproximadamente en el orden en el cual se ejecutan. Llamamos código espagueti a los programas que carecen de esta propiedad.
Los grupos de enunciados pueden servir parafines múltiples. Un programa se entiende con más facilidad si cada grupo de enunciados sirve para un solo propósito dentro de la estructura global del programa, es decir, computa una parte individual claramente definida del cómputo completo. Suele ocurrir que dos grupos individuales de enunciados contengan varios enunciados que son idénticos en ambos. A través del uso de enunciados goto, se pueden combinar dos grupos de enunciados de este tipo de manera que los enunciados idénticos se escriban una sola vez y el control se transfiera a este conjunto común durante la ejecución de cada grupo. El hecho de que los enunciados tengan propósitos múltiples hace que se dificulte modificar el programa. Cuando se cambia un enunciado de un grupo de propósitos múltiples para corregir o modificar el grupo para un fin (por ejemplo, una ruta de ejecución), el uso del mismo grupo en otras secuencias de ejecución puede sufrir un deterioro sutil.
Programación estructurada. Este término se usa para el diseño de programas que hace énfasis en (1) el diseño jerárquico de estructuras de programa usando sólo las formas simples de control, de composición, alternancia e iteración ya descritas. (2) la representación directa del diseño jerárquico en el texto del programa, usando los enunciados de control "estructurados" que se describen más adelante; (3) el texto de programa en el cual el orden textual de los enunciados corresponde al orden de ejecución; y (4) el uso de grupos de enunciados de propósito único, aun cuando sea necesario copiar los enunciados. Cuando un programa se escribe siguiendo estos principios de programación estructurada, por lo común es mucho más fácil de entender, depurar, verificar que es correcto, y más adelante modificarlo y verificarlo otra vez.
B. Control de secuencia estructurado
Casi todos los lenguajes suministran un conjunto de enunciados de control para expresar las formas básicas de control: composición, alternancia e iteración. Un aspecto importante de los enunciados que se analizan en seguida es que cada uno es un enunciado de entrada por salida, lo que significa que en cada enunciado hay un solo punto de entrada al enunciado y un solo punto de salida del mismo. Si uno de estos enunciados se coloca en serie con algunos otros enunciados, entonces el orden de ejecución avanzará necesariamente desde el enunciado precedente al interior del enunciado de entrada por salida, a través del mismo y saliendo hacia el enunciado siguiente (siempre y cuando el enunciado no pueda incluir un enunciado goto interno que envíe el control a otra parte). Al leer un programa construido exclusivamente a partir de enunciados de entrada por salida, el flujo de la ejecución del programa deberá ¡coincidir con el orden de los enunciados en el texto del programa. Cada enunciado de control de entrada por salida puede incluir bifurcaciones e iteraciones internas, pero el control sólo puede salir del enunciado a través de su único punto de salida.
Los lenguajes más antiguos, como COBOL y FORTRAN, contienen algunos enunciados
de control de entrada por salida, pero todavía se apoyan fuertemente en enunciados goto y
etiquetas de enunciado. Ha sido difícil adaptar ambos lenguajes a los conceptos modernos de lenguaje.
Enunciados compuestos
Un enunciado compuesto es una serie de enunciados que se puede tratar como un solo enunciado en la construcción de enunciados más grandes. Un enunciado compuesto se suele escribir así:
begin
. . . - Serie de enunciados (uno o más)
cnd
En C se escribe simplemente como {...}.
Dentro del enunciado compuesto los enunciados se escriben en el orden en que se ejecutan. Así pues, el enunciado compuesto es la estructura básica para representar la composición de enunciados. Puesto que un enunciado compuesto es él mismo un enunciado, los grupos de enunciados que representan unidades conceptuales de cómputo individuales se pueden conservar juntos como una unidad encerrándolos entre los corchetes begin... end, y se pueden construir jerarquías de estos grupos.
Un enunciado compuesto se implementa en una computadora convencional colocando los bloques de código ejecutable que representan cada enunciado constitutivo en serie en la memoria. El orden en que aparecen en la memoria determina el orden en que se ejecutan.
Enunciados condicionales
Un enunciado condicional es uno que expresa alternancia de dos o más enunciados, o ejecución opcional de un solo enunciado; donde enunciado significa ya sea un enunciado básico individual, un enunciado compuesto u otro enunciado de control. La elección de la alternativa es controlada por una prueba sobre cierta condición, la cual se escribe ordinariamente como Lina expresión en la que intervienen operaciones relacionales y booleanas. Las formas más comunes de enunciado condicional son los enunciados if (si) y case (en caso de).
Enunciados if. La ejecución opcional de un enunciado se expresa como un if de una bifurcación:
if condición then enunciado endif
en tanto que una opción entre dos alternativas emplea un if de dos bifurcaciones:
if condición then enunciado1 else enunciado2 endif
En el primer caso, una condición cuya evaluación es `cierta' causa la ejecución del enunciado, en tanto que una condición 'falsa' hace que el enunciado se pase por alto. En el if de dos bifurcaciones, se ejecuta el enunciado1 o el enunciado2, en función de si la condición es cierta o falsa.
Una opción entre muchas alternativas se puede expresar anidando enunciados if adicionales dentro de los enunciados alternativos de un solo if, o por medio de un if (de ramificaciones múltiples:
if condición1 then en unciado1
elsif condición2 then enunciado2
. . .
elsif condiciónn then enunciadon
else enunciadon+1 endif
Enunciados case. En un if de ramificaciones múltiples las condiciones suelen adoptar la forma de prueba repetida del valor de una variable, por ejemplo:
if Marca = 0 then enunciado0
elsif Marca = 1 then enunciado1
elsif Marca = 2 then enunciado2
else enunciado3
endif
Esta estructura común se expresa de manera más concisa como un enunciado case, como en
Ada:
case Marca is
when 0=> begin
enunciado0
end;
when 1=> begin
enunciado1
end;
when 2=> begin
enunciado2
end;
when others => begin
enunciado3
end;
end case
En general, la variable Marca se puede sustituir por cualquier expresión cuya evaluación sea un solo valor, y entonces las acciones para cada uno de los valores posibles se representan mediante un enunciado compuesto precedido por el valor de la expresión que causaría la ejecución de ese enunciado compuesto. Los tipos de enumeración y los subámbitos de enteros son particularmente útiles para establecer los valores posibles que la expresión puede devolver en un enunciado case. Por ejemplo, si la variable Marca antes citada se define con el subámbito 0.5 como su tipo, entonces, durante la ejecución del enunciado case, los valores para Marca de 0, 1 o 2 causarán la ejecución del enunciado0, enunciado1 o enunciado2, respectivamente, y cualquier otro valor hará que se ejecute el enunciado
lmplementación. Los enunciados if se implementan con facilidad usando las instrucciones usuales de bifurcación y salto manejadas por hardware (la forma de hardware de goto condicional y no condicional). Los enunciados case se implementan por lo común usando una tabla de saltos para evitar la prueba repetida del valor de la misma variable. Una tabla de saltos es un vector, guardado en forma secuencial en la memoria, cada uno de cuyos componentes es una instrucción de salto no condicional. Se evalúa la expresión que forma la condición del enunciado case, y el resultado se transforma en un entero pequeño que representa el desplazamiento al interior de la tabla de saltos desde su dirección base. La instrucción de salto en ese desplazamiento, cuando se ejecuta conduce al principio del bloque de código que representa el código por ejecutar si se elige esa alternativa..
Enunciados de iteración
La iteración suministra el mecanismo básico para los cálculos repetidos en casi todos los pro-gramas. (Los subprogramas recursivos del próximo capítulo constituyen el otro.) La estructura básica de un enunciado de iteración consiste en una cabeza y un cuerpo. La cabeza controla el número de veces que el cuerpo se va a ejecutar, en tanto que el cuerpo es ordinariamente un enunciado (compuesto) que proporciona la acción del enunciado. Aunque los cuerpos de los enunciados de iteración están bastante libres de restricciones, por lo común sólo se usan unas pocas variantes de estructura de cabeza. Examinemos algunas que son representativas.
Repetición simple. El tipo más sencillo de cabeza de enunciado de iteración especifica que el cuerpo se debe ejecutar cierto número fijo de veces. El PERFORM de COBOL es representativo de esta construcción:
perform cuerpo K times
que hace que se evalúe K y luego se ejecute el cuerpo del enunciado ese número de veces.
Sin embargo, incluso este simple enunciado plantea ciertas cuestiones sutiles. ¿Se puede volver a evaluar K en el cuerpo y cambiar el número de iteraciones? ¿Qué ocurre si K es 0 o negativo? ¿Cómo afecta esto la ejecución?
Aunque estas preguntas pueden parecer sutilezas para este sencillo enunciado de iteración, las mismas surgen en todas las formas del enunciado, por lo cual es importante examinarlas aquí en su forma más sencilla. En cada caso es importante preguntar: (1) ¿cuándo se hace la prueba de terminación? y (2) ¿cuándo se evalúan las variables que se usan en la cabeza del enunciado?
Repetición mientras se cumple la condición. Se puede construir una iteración algo más compleja usando una cabeza de repetir mientras. Una forma típica es:
while prueba do cuerpo
En esta forma de enunciado de iteración, la expresión de prueba se reevalúa después de cada vez que se ha ejecutado el cuerpo. Adviértase también que en este caso es de esperar que la ejecución del cuerpo cambie algunos de los valores de las variables que aparecen en la expresión de prueba; de lo contrario la iteración, una vez iniciada, nunca terminaría.
Repetición mientras se incrementa un contador. La tercera forma alternativa de enunciado de iteración es el enunciado cuya cabeza especifica una variable que sirve como un contador o índice durante la iteración. En la cabeza se especifican un valor inicial, un valor final y un incremento, y el cuerpo se ejecuta repetidamente usando primero el valor inicial como valor de la variable índice, luego el valor inicial más el incremento, después el valor inicial más dos veces el incremento, y así sucesivamente, hasta que se alcanza el valor final. En FORTRAN-77 ésta es la única forma de enunciado de iteración disponible. El enunciado for en Algol ilustra la estructura típica:
for I := 1 step 2 until 30 do cuerpo
En su forma general, tanto el valor inicial como el valor final y el incremento pueden estar dados por expresiones arbitrarias, como en:
for K := N-l step 2 x (W-l) until M x N do cuerpo
Surge una vez más la pregunta respecto a cuándo se hace la prueba de terminación y cuándo y con qué frecuencia se evalúan las diversas expresiones. En este caso, la cuestión es de importancia fundamental también para el implementador del lenguaje, porque estos enunciados de iteración son candidatos primarios a optimización, y las respuestas pueden afectar en gran medida las clases de optimizaciones que se pueden llevar a cabo.
Repetición indefinida. Cuando las condiciones para la salida de la iteración son complejas y 110 fácilmente expresables en la cabeza de la iteración usual, suele emplearse una iteración sin una prueba explícita de terminación en la cabeza como en este ejemplo en Ada:
loop
. . .
exit when condición; . . .
end loop;
o en Pascal, usando una iteración while con una condición que siempre es cierta:
while cierta do begin ... end
El enunciado for de C permite todos estos conceptos en una sola construcción:
for ( expresión1; expresión2; expresión3; ){ cuerpo }
donde expresión1 es el valor inicial expresión2 es la condición de repetición y expresión3 es el incremento. Todas estas expresiones son opcionales, lo cual permite mucha flexibilidad en las iteraciones en C. Algunas iteraciones de muestra en C se pueden especificar como:
contador simple de 1 a 10: for (i= 1; i<= 10; i ++) { cuerpo}
iteración infinito: for (;;){ cuerpo}
Contador con condición de salida: for (i =1; i<= 100 && NoFinArchivo ; i++) { cuerpo}
Implementación de enunciados iterativos. La implementación de enunciados (de control iterativos usando la instrucción bifurcar/saltar de hardware es sencilla. Para implementar una iteración for, las expresiones de la cabeza de la iteración que definen el valor final y el incremento se deben evaluar a la entrada inicial de la iteración y guardarse en áreas especiales de almacenamiento temporal, de donde se pueden recuperar al inicio de cada iteración para usarse en la prueba e incremento de la variable controlada.
Problemas en el control de secuencia estructurado
Un enunciado goto se suele considerar como un último recurso cuando los enunciados de control estructurado antes descritos resultan inadecuados para la expresión de una estructura difícil de control de secuencia. Aunque en teoría siempre es posible expresar cualquier estructura de control de secuencia usando sólo las formas de enunciados estructurados, en la práctica una forma difícil puede no tener directamente una expresión natural si se usan sólo esos enunciados. Se conocen varias de esas áreas problema, y con frecuencia se suministran construcciones especiales de control para estos casos, las cuales hacen innecesario el uso de un enunciado goto. Las más comunes son:
Iteraciones de salidas múltiples. Con frecuencia, varias condiciones pueden requerir la terminación de una iteración. La iteración de búsqueda es un ejemplo común. Un vector de K elementos se va a examinar en busca del primer elemento que satisface cierta condición. La iteración termina si se alcanza el final del vector o si se encuentra el elemento apropiado. La iteración a través de los elementos de un vector se expresa de manera natural usando una iteración for:
for I :- 1 to K do
if VECT[I] = 0 thcn goto { afuera de la iteración}
En un lenguaje como Pascal, sin embargo, o bien se debe usar un enunciado goto para escapar de la parte media de la iteración, como en el caso anterior, o la iteración for se debe reemplazar por una iteración while, el cual oculta la información que contiene la cabeza de la iteración for acerca de la existencia y ámbito de la variable índice 1.
El enunciado exit en Ada y el enunciado break de C proporcionan una construcción alternativa para expresar estas salidas de la iteración sin el uso de un enunciado goto:
for 1 in 1 .. K iteración
exit when VECT(I) = 0;
end loop;
do -while-do. El lugar más natural para probar si se debe salir de una iteración no suele estar al principio o al final de la iteración, sino a la mitad, después de que se ha hecho cierto procesamiento, como en:
loop
read(X)
if fin-de-archivo then goto a {afuera de la iteración~
process(X)
end loop;
A esta forma se le conoce a veces como una "do while do" (hacer mientras hacer), puesto que un while "intermedio" puede manejar esta secuencia:
dowhiledo
read(X)
while (no final_de_archivo)
process(X)
end dowhiledo
Desafortunadamente, ningún lenguaje común implementa esta~estructura, aunque, en C, if (condición) break se acerca y exit when de Ada es similar.
Condiciones excepcionales. Las excepciones pueden representar diversas condiciones de error, tales como condiciones inesperadas de final de archivo, errores de ámbito de subíndices
o datos malos por procesar. Los enunciados que manejan el procesamiento de estas condiciones excepcionales se suelen agrupar en un lugar especial en el programa, como al final del subprograma en el cual se podría detectar la excepción, o posiblemente en otro subprograma que se usa sólo para manejar excepciones. La transferencia desde el punto donde se detecta la condición excepcional hasta el manejador de excepciones (grupo de enunciados) se suele representar mejor usando un enunciado goto en lenguajes que no implementan enunciados especiales para manejo de excepciones. Sin embargo, Ada y ML suministran mecanismos especiales de lenguaje para definir manejadores de excepciones y para especificar la transferencia de control que se necesita cuando se detecta una excepción. El enunciado raise de Ada es típico de este manejo de excepciones:
raise MAL_VALOR_CAR
Este enunciado transfiere el control a los enunciados de manejo de excepciones que están asociados con el nombre de excepción MAL_VALOR_CAR.
Son mecanismos sencillos para establecer la secuencia entre subprogramas, es decir, de cómo un subprograma invoca a otro subprograma llamado regresa el control al primero. La estructura de los enunciados simples de llamada y regreso de subprogramas es común a casi todos los lenguajes de programación. Las estructuras de control más complejas, en las que intervienen corrutinas, excepciones, tareas y organización de llamadas de subprograma también son importantes y se tratarán más adelante.
Llamada/regreso simple de subprogramas. En programación estamos acostumbrados a ver los programas como jerarquías. Un programa se compone de un solo programa principal, el cual, durante la ejecución, puede llamar varios subprogramas, los que a su vez pueden llamar otros subsubprogramas, y así sucesivamente, a cualquier profundidad. Se espera que cada subprograma termine su ejecución en cierto punto y regrese el control al programa que lo llamó. Durante la ejecución de un subprograma, la ejecución del programa que lo llamó se detiene temporalmente. Cuando se completa la ejecución del subprograma, la ejecución del programa que lo llamó se reanuda en el punto que sigue de inmediato a la llamada del subprograma. Esta estructura de control se suele explicar por la regla de copia: el efecto del enunciado call (llamar) del subprograma es el mismo que se obtendría si el enunciado call se reemplazara por una copia del cuerpo del subprograma (con las sustituciones apropiadas de parámetros e identificadores en conflicto) antes de la ejecución. Vistas en esta forma, las llamadas de subprograma se pueden considerar como estructuras de control que simplemente hacen innecesario copiar grandes números de enunciados idénticos o casi idénticos que ocurren en más de un lugar en un programa.
Antes de examinar la implementación de la estructura simple de llamada-regreso que se emplea para el punto de vista de regla de copia de subprogramas, echaremos un breve vistazo a algunos de los supuestos implícitos presentes en esta perspectiva y que se pueden flexibilizar para obtener estructuras más generales de control de subprogramas:
1. Los subprogramas no pueden ser recursivos. Un subprograma es directamente recursivo si contiene una llamada a sí mismo (por ejemplo, si el subprograma B contiene el enunciado call B); es indirectamente recursivo si llama otro subprograma que llama al subprograma original o que inicia una cadena adicional de llamadas de subprograma que conduce finalmente de regreso a una llamada al subprograma original.
En el caso de llamadas simples no recursivas de subprograma, se puede aplicar la regla de copia durante la traducción para reemplazar llamadas de subprograma por copias del cuerpo del subprograma y eliminar por completo la necesidad del subprograma independiente (en principio, no en la práctica). Pero si el subprograma es directamente recursivo, entonces esto no es posible incluso en principio, porque la sustitución de llamada de subprograma por cuerpo de subprograma es obviamente interminable. Cada sustitución que elimina un enunciado call introduce una nueva llamada al mismo subprograma, para la cual es necesaria otra sustitución, y así sucesivamente. La recursión indirecta puede permitir la eliminación de ciertos subprogramas, pero debe conducir en último término a hacer a otros directamente recursivos. Sin embargo, muchos algoritmos son recursivos y llevan en forma natural a estructuras de subprograma recursivas.
2. Se requieren enunciados call explícitos. Para que la regla de copia sea aplicable, cada punto de llamada de un subprograma se debe indicar de manera explícita en el programa que se va a traducir. Pero, para un subprograma que se usa como manejador de excepciones, no puede haber llamadas explícitas presentes.
3. Los subprogramas se deben ejecutar por completo a cada llamada. En la regla de copia está implícito el supuesto de que cada subprograma se ejecuta desde el principio hasta su fin lógico cada vez que se le llama. Si se le llama una segunda vez, el subprograma inicia una nueva ejecución y se ejecuta una vez más hasta su fin lógico antes de devolver el control. Pero un subprograma que se usa como corrutina continúa su ejecución desde el punto de su última terminación cada vez que se le llama.
4. Transferencia inmediata del control en el punto de llamada. Un enunciado cali explícito en un programa indica que el control deberá transferirse directamente al subprograma en ese punto, así que copiar el cuerpo en el programa que hace la llamada tiene el mismo efecto. Pero, para una llamada planificada de subprograma, la ejecución del subprograma se puede diferir hasta un momento posterior.
5. Secuencia única de ejecución. En cualquier punto durante la ejecución de unajerarquía de subprogramas, exactamente un programa tiene el control. La ejecución avanza en una única secuencia, desde el programa que llama al subprograma invocado y de regreso al programa que hace la llamada. Si se detiene la ejecución en algún punto, siempre se puede identificar un programa que está en ejecución (es decir, que tiene el control), un conjunto de otros cuya ejecución se ha suspendido temporalmente (el programa de llamada, el programa que llama a éste, etc.), y el resto, los cuales o nunca han sido llamados o ya se han ejecutado cabalmente. Pero los subprogramas que se usan como tareas se pueden ejecutar en forma simultánea, de manera que varios estén en ejecución a la vez.
Son aquellas partes que tiene que ver con accesibilidad de datos en diferentes puntos durante la ejecución del programa. Las características de control de datos de un lenguaje determinan cómo se pueden suministrar datos a cada operación, y cómo se puede guardar el resultado de una operación y recuperarlo para uso posterior como operando de una operación subsiguiente.
Cada programa o subprograma tiene un conjunto de asociaciones de identificador disponibles para su uso al hacer referencias durante su ejecución. Este conjunto de asociaciones de identificador se conoce como el ambiente de referencia del subprograma (o programa). El ambiente de referencia de un subprograma es ordinariamente invariable durante su ejecución. Se establece cuando se crea la activación del subprograma, y permanece 5111 cambio durante el tiempo de vida de la activación. Los valores contenidos en los diversos objetos de datos pueden cambiar, pero no así las asociaciones de nombres con objetos de datos y subprogramas. El ambiente de referencia de un subprograma puede tener varios componentes:
1. Ambiente local de referencia (o simplemente ambiente local). El conjunto de asociaciones creadas al entrar a un subprograma y que representan parámetros formales, variables locales y subprogramas definidos sólo dentro de ese subprograma conforma el ambiente local de referencia de esa activación del subprograma. El significado de una referencia a un nombre en el ambiente local se puede determinar sin salir de la activación del subprograma.
2. Ambiente no local de referencia. El conjunto de asociaciones para identificadores que se pueden usar dentro de un subprograma pero que no se crean al entrar a él se conoce como el ambiente no local de referencia del subprograma.
3. Ambiente global de referencia. Si las asociaciones creadas al inicio de la ejecución del programa principal están disponibles para usarse en un subprograma entonces estas asociaciones forman el ambiente global de referencia de ese subprograma. El ambiente global es parte del ambiente no local.
4. Ambiente predefinido de referencia. Ciertos identificadores tienen una asociación predefinida, la cual se define directamente en la definición del lenguaje. Cualquier programa o subprograma puede usar estas asociaciones sin crearlas en forma explícita.
Visibilidad. Se dice que una asociación para un identificador es visible dentro de un subprograma si es parte del ambiente de referencia para ese subprograma. Una asociación que existe pero no forma parte del ambiente de referencia del subprograma actualmente en ejecución se dice que está oculta de ese subprograma. Con frecuencia una asociación está oculta cuando se entra a un subprograma que redefine un identificador que ya está en uso en otra parte del programa.
Alcance dinámico. Cada asociación tiene un alcance dinámico, el cual es la parte de la ejecución del programa durante la que esa asociación existe como parte de un ambiente de referencia. Así, el alcance dinámico de una asociación se compone del conjunto de activaciones de subprograma dentro de las cuales es visible.
Operaciones de referencia. Una operación de referencia es una operación con la signatura:
Ref_op : id x ambiente_de_ referencia ! objeto de datos o subprograma
donde ref_op, dado un identificador y un ambiente de referencia, encuentra la asociación apropiada para ese identificador en el ambiente y regresa el objeto de datos o la definición de subprograma asociada.
Referencias locales, no locales y globales. Una referencia a un identificador es una referencia local si la operación de referencia encuentra la asociación en el ambiente local: es Lina referencia no local o global si la asociación se encuentra en el ambiente no local o global, respectivamente. (Los términos no local y global se suelen usar de manera indistinta para indicar cualquier referencia que no es local.)
Seudónimos para objetos de datos
Durante su tiempo de vida un objeto de datos puede tener más de un nombre, por ejemplo, puede haber varias asociaciones en diferentes ambientes de referencia, donde cada uno proporciona un nombre distinto para el objeto de datos. Por ejemplo, cuando un objeto de datos se transmite "por referencia" a un subprograma como parámetro, se puede hacer referencia a él a través de un nombre de parámetro formal en el subprograma, y también conserva su nombre original en el programa que llama. Alternativamente, un objeto de datos puede convertirse en componente de varios objetos de datos a través de vínculos de apuntador, y tener así varios nombres compuestos a través de los cuales se puede tener acceso a él. Los nombres múltiples para el mismo objeto de datos son posibles de diversas maneras en casi todos los lenguajes de programación.
Cuando un objeto de datos es visible a través de más de un nombre (simple o compuesto) en un solo ambiente de referencia, cada uno de los nombres se conoce como un seudónimo del objeto de datos. Cuando un objeto de datos tiene múltiples nombres, pero un único nombre en cada ambiente de referencia en el cual aparece, no surgen problemas. Sin embargo, la capacidad para hacer referencia al mismo objeto de datos usando nombres distintos dentro del mismo ambiente de referencia plantea serios problemas tanto para el usuario como para el implementador de un lenguaje
Los seudónimos son problemáticos para el programador porque dificultan la comprensión de un programa. Por ejemplo, si dentro de un programa uno encuentra la serie de enunciados:
X := A + B;
Y := C + D;
las asignaciones a X y Y son en apariencia independientes y podrían tener lugar en cualquier orden, o si no se hiciera referencia a la variable X más tarde, la primera asignación se podría eliminar del todo. Sin embargo, supóngase que X y C son seudónimos del mismo objeto de datos. Entonces los enunciados son de hecho interdependientes, y no es posible un reordenamiento o eliminación alguna sin introducir un error difícil de detectar. La posibilidad de creación de seudónimos dificulta verificar que un programa es correcto porque no se puede suponer que un par cualquiera de nombres de variable se refiere necesariamente a objetos de datos distintos.
Los problemas que los seudónimos causan al implementador son similares. Como parte de la optimización del código de programa durante la traducción, suele ser deseable reordenar los pasos de un cómputo o eliminar pasos innecesarios. Cuando es posible la creación de seudónimos, esto no se puede hacer sin un análisis adicional para asegurar que dos pasos de cómputo aparentemente independientes no son dependientes a causa de la creación de seudónimos, como en el caso anterior. A causa de los problemas que ocasionan los seudónimos, los nuevos diseños de lenguajes intentan a veces restringir o eliminar del todo las características que permiten construir seudónimos.
Alcance estático y dinámico
El alcance estático de una asociación para un identificador, según se definió en la sección anterior, es el conjunto de activaciones de subprograma en las cuales la asociación es visible durante la ejecución. El alcance dinámico de una asociación siempre incluye la activación de subprograma, en la cual esa asociación se crea como parte del ambiente local. También puede ser visible como una asociación no local en otras activaciones de subprograma.
Una regla de alcance dinámico define el alcance dinámico de cada asociación en términos del curso dinámico de la ejecución del programa. Por ejemplo, una regla de alcance dinámico típica afirma que el alcance de una asociación creada durante una activación del subprograma P incluye no sólo esa activación, sino también cualquier activación de un subprograma llamado por P o por un subprograma llamado por P, y así sucesivamente, a menos que esa última activación de subprograma defina una nueva asociación local para el identificador que oculte la asociación original. Con esta regla, el alcance dinámico de una asociación está ligado a la cadena dinámica de activaciones de subprograma.
Cuando se examina la forma escrita de un programa, el texto del programa, se advierte que la asociación entre referencias a identificadores y declaraciones particulares o definiciones del significado de esos identificadores también es un problema. Cada declaración u otra definición de un identificador dentro del texto del programa tiene un cierto alcance, llamado alcance estático.
Para simplificar, el término declaración se usa aquí para referirse a una declaración, definición de subprograma. definición de tipo, definición de constante u otro medio de definir un significado para un identificador particular dentro de un texto de programa. Una declaración crea una asociación en el texto del programa entre un identificador y cierta información acerca del objeto de datos o subprograma que va a ser nombrado por ese identificador durante la ejecución del programa. El alcance estático de una declaración es la parte del texto del programa donde un LISO del identificador es una referencia a esa declaración particular del identificador. Una regla de alcance estático es una regla para determinar el alcance estático de una declaración. En Pascal, por ejemplo. se usa una regla de alcance estático para especificar que una referencia a una variable X en un subprograma P se refiere a la declaración de X al inicio del subprograma Q cuya declaración contiene la declaración de P. y así sucesivamente.
Las reglas de alcance estático relacionan referencias con declaraciones de nombres en el texto del programa. las reglas de alcance dinámico relacionan referencias con asociaciones para nombres durante la ejecución del programa. ¿Cuál debe ser la relación entre las dos?. Es claro que las reglas deben ser congruentes.
La importancia del alcance estático. Supóngase que un lenguaje no usa las reglas de alcance estático. Considérese un enunciado como X : = X + Máx que se presenta en un subprograma. Sin reglas de alcance estático, nada se puede determinar respecto a los nombres X y Máx durante la traducción del programa. En el transcurso de su ejecución, cuando se alcanza el enunciado, una operación de referencia debe encontrar primero las asociaciones pertinentes para X y Máx, y luego se debe determinar el tipo y otros atributos de X y Máx. ¿Existe Lina asociación para cada identificador? ¿Es Máx un nombre de subprograma, nombre de variable, etiqueta de enunciado, nombre de tipo o nombre de parámetro formal? Si Xes un nombre de variable, ¿es de un tipo que se puede sumar a Máx? Ninguna de estas preguntas se puede responder hasta que se intente hacer referencia a los nombres X y Máx durante la ejecución. Más aún, cada vez que el enunciado se ejecuta, debe repetirse el proceso completo porque las asociaciones para X y Máx pueden cambiar entre dos ejecuciones del enunciado. LISP, SNOBOL4 y APL casi no utilizan las reglas de alcance estático, y por tanto cada referencia a un nombre en estos lenguajes requiere que durante la ejecución se invoque un proceso bastante complejo y costoso de interpretación, el cual primero encuentra la asociación pertinente para el nombre (en su caso ) y luego determina el tipo y atributos del objeto de datos o subprograma asociado.
Las reglas de alcance estático permiten llevar a cabo gran parte de este proceso una vez durante la traducción del programa en lugar de hacerlo repetidamente durante la ejecución para casi todas las referencias a nombres en un programa. Por ejemplo, si el enunciado de asignación X: = X + Máx aparece en Pascal, y Máx se define por medio de una declaración de constante const Máx = 30 en alguna parte del programa, entonces las reglas de alcance estático de Pascal permiten relacionar la referencia a Máx a esta (o alguna otra) declaración durante la traducción. El compilador de Pascal puede determinar entonces que Máx siempre tiene el valor 30 cuando este enunciado se ejecuta y puede traducir el enunciado a código ejecutable que simplemente suma 30 a X, sin una operación de referencia para el nombre Máx. En forma similar, si las reglas de alcance estático de Pascal permiten relacionar la referencia a X con la declaración X: real en alguna parte del texto del programa, entonces el compilador de Pascal puede efectuar Lina verificación estática de tipos; es decir, puede determinar que, cuando el enunciado se ejecuta, (1) existirá una asociación que relaciona X con un objeto de datos, (2) ese objeto de datos será de tipo real, y (3) su valor será del tipo correcto para servir como argumento ante la operación de adición. El compilador no puede conocer a partir de la declaración la localidad del objeto de datos al que X hace referencia (puesto que la localidad se determina dinámicamente durante la ejecución y puede ser diferente en distintas ejecuciones del enunciado), ni puede determinar el valor de X (porque también se determina dinámicamente durante la ejecución). Sin embargo, la verificación estática de tipos hace que la ejecución del programa sea mucho más rápida y también más fiable (puesto que los errores de tipo se detectan para todas las rutas del programa durante la traducción).
Las reglas de alcance estático permiten establecer muchas clases distintas de conexiones entre referencias a nombres y sus declaraciones durante la traducción. Ya se han mencionado dos de ellas: relacionar un nombre de variable con una declaración para la variable y relacionar un nombre de constante con una declaración para una constante. Otras conexiones incluyen relacionar nombres de tipo con declaraciones de tipo, relacionar parámetros formales con especificaciones de parámetros formales, relacionar llamadas de subprograma con declaraciones de subprograma, y relacionar etiquetas de enunciado a las que se hace referencia en enunciados goto con etiquetas en enunciados particulares. En cada uno de estos casos, se puede hacer un conjunto distinto de simplificaciones durante la traducción, las cuales hacen más eficiente la ejecución del programa.
Las reglas de alcance estático también son importantes para el programador al leer un programa, porque permiten relacionar un nombre al que se hace referencia en el programa con una declaración para el nombre sin rastrear el curso de la ejecución del programa. Por ejemplo, las reglas de alcance estático de Pascal permiten relacionar una referencia a X en un enunciado con una declaración para X en otra parte del programa sin considerar en absoluto la serie de llamadas de subprograma que conducen del programa principal a la ejecución efectiva del enunciado. En esta forma, las reglas de alcance estático hacen que el programa sea más fácil de entender. Estas reglas desempeñan un papel importante en el diseño e implementación de casi todos los lenguajes de programación, por ejemplo, Ada, C, FORTRAN, Pascal y COBOL.
Un objeto de datos que es estrictamente local es usado por operaciones sólo dentro de un único ambiente local de referencia, por ejemplo, dentro de un solo subprograma. Los objetos de datos, sin embargo, suelen compartirse entre varios subprogramas para que las operaciones de cada uno de los subprogramas puedan utilizar los datos. Un objeto de datos se puede transmitir como un parámetro explícito entre subprogramas, pero son numerosas las ocasiones donde el uso de parámetros explícitos es engorroso. Por ejemplo, considérese un conjunto de subprogramas en el cual todos hacen uso de una tabla de datos común. Cada subprograma requiere acceso a la tabla, pero transmitir la tabla como un parámetro explícito cada vez que se la necesita es tedioso. Este uso común de datos se basa ordinariamente en la compartición de asociaciones de identificador. Si los subprogramas P, Q y R necesitan todos ellos acceso a la misma variable X, entonces es apropiado permitir simplemente que el identificador X tenga la misma asociación en cada subprograma. La asociación para X se vuelve parte del ambiente local para uno de los subprogramas, y se convierte en parte común del ambiente no local de los otros. La compartición de objetos de datos a través de ambientes no locales es una alternativa importante al uso de la compartición directa a través de la transmisión de parámetros.
Parámetros y transmisión de parámetros
Los parámetros y resultados transmitidos de manera explícita constituyen el método alternativo principal para compartir objetos de datos entre subprogramas. En contraste con el uso de ambientes no locales de referencia, donde la compartición se consigue haciendo visibles ciertos nombres no locales para un subprograma, los objetos de datos transmitidos como parámetros y resultados se transmiten sin un nombre anexo. En el subprograma receptor, a cada objeto de datos se le da un nuevo nombre local a través del cual se puede hacer referencia a él. La compartición de datos a través de parámetros resulta más útil cuando a un subprograma se le van a entregar datos distintos para procesar cada vez que se le llama. La compartición a través de un ambiente no local es más apropiada cuando se usan los mismos objetos de datos en cada llamada. Por ejemplo, si el subprograma P se usa en cada llamada para introducir un nuevo elemento de datos en una tabla que se comparte con otros subprogramas, entonces la tabla se compartiría típicamente a través de referencias a un ambiente no local y el elemento de datos mismo se transmitiría como un parámetro explícito en cada llamada de P.
Parámetros reales y formales
Consideraremos primero la compartición de datos a través de parámetros, y más adelante el uso de parámetros para transmitir subprogramas y etiquetas de enunciado. En los capítulos anteriores, el término argumento se usa para un objeto de datos (o un valor) que se envía a un subprograma u operación primitiva como uno de sus operandos, es decir, como un dato para usarse en el procesamiento. El término resultado se refiere a un dato (objeto de datos o valor) que es regresado desde una operación al final de su ejecución. El argumento de un subprograma se puede obtener tanto a través de parámetros como a través de referencias no locales (y menos comúnmente a través de archivos externos). En forma similar, los resultados de un subprograma se pueden regresar a través de parámetros, a través de asignaciones a variables no locales (o archivos), o a través de valores explícitos de función. Así pues, los términos argumento y resultado se aplican a datos enviados al subprograma y regresados por el mismo a través de diversos mecanismos de lenguaje. Al concentrar la atención en los parámetros y su transmisión, los términos parámetro real y parámetro formal se vuelven fundamentales.
Un parámetro formal es una clase particular de objeto de datos local dentro de un subprograma. La definición del subprograma enumera ordinariamente los nombres y declaraciones para parámetros formales como parte de la sección de especificación (encabezamiento). Un nombre de parámetro formal es un simple identificador, y la declaración proporciona ordinariamente el tipo y otros atributos, como en el caso de una declaración de variable local ordinaria. Por ejemplo, el encabezamiento4e procedimiento en C:
SUB(int X; char Y);
define dos parámetros formales llamados X y Y y declara el tipo de cada uno. Sin embargo, la declaración de un parámetro formal no significa lo mismo que una declaración para una variable. El parámetro formal, de acuerdo con el mecanismo de transmisión que se analizará en breve, puede ser un seudónimo del objeto de datos de parámetro real o puede contener simplemente una copia del valor de ese objeto de datos.
Un parámetro real es un objeto de datos que se comparte con el subprograma de llamada. Un parámetro real puede ser un objeto de datos local perteneciente al que llama, puede ser un parámetro formal del que llama, puede ser un objeto de datos no local visible para el que llama, o puede ser un resultado regresado por una función invocada por el que llama y transmitido de inmediato al subprograma llamado. Un parámetro real se representa en el punto de llamada del subprograma por medio de una expresión, llamada expresión de parámetro real, que ordinariamente tiene la misma forma que cualquier otra expresión en el lenguaje (por ejemplo, como una expresión que podría aparecer en un enunciado de asignación). Por ejemplo, el subprograma SUB antes especificado podría ser llamado con cualquiera de los tipos siguientes de expresiones de parámetro real:
Llamada de procedimiento en P | Parámetro real en P |
SUB | 1, B: variables locales de P |
SUB | 27, true (cierto): constantes |
SUB | Pl, P2: parámetros formales de P |
SUB | G1, G2: variables globales o no locales en P |
SUB | Componentes de arreglos y registros |
SUB | Resultados de funciones primitivas o definidas |
La sintaxis para las llamadas de procedimiento en C es representativa de muchos lenguajes. La llamada de subprograma se escribe en forma prefija, como se expuso en la sección 6.2, con el nombre del subprograma primero, seguido de una lista de expresiones de parámetro real entre paréntesis (sin embargo, se usan otras notaciones, por ejemplo, notación infija en APL, polaca de Cambridge en LISP). Para simplificar, se adoptará la representación prefija convencional de C y se hablará de listas de parámetros reales y listas de parámetros formales para indicar la serie de parámetros reales y formales designados en una llamada de subprograma y en una definición de subprograma, respectivamente.
Cuando se llama un subprograma con una expresión de parámetro real de cualquiera de las formas anteriores, la expresión se evalúa en el momento de la llamada, antes de éntrar al subprograma. Los objetos de datos que son resultado de la evaluación de las expresi6nes de parámetro real se convierten entonces en los parámetros reales que se transmiten al subprograma. El caso especial donde las expresiones de parámetro real no se evalúan en el momento de la llamada, sino que se pasan sin evaluar al subprograma, se trata por separado más adelante.
Establecimiento de la correspondencia
Cuando se llama un subprograma con una lista de parámetros reales, se debe establecer una correspondencia entre los parámetros reales y los parámetros formales enumerados en la definición del subprograma. Dos métodos son los siguientes:
Correspondencia de posición. La correspondencia se establece apareando parámetros reales y formales con base en sus posiciones respectivas en las listas de parámetros reales y
formales; se aparean el primer parámetro real y el primero formal, luego el segundo de cada lista, y así sucesivamente.
Correspondencia por nombre explícito. En Ada y en algunos otros lenguajes, el parámetro formal que se va a aparear con cada parámetro real se puede nombrar de manera explícita en el enunciado de llamada. Por ejemplo, en Ada, se podría llamar a Sub con el enunciado:
Sub(Y => B, X => 27);
el cual aparea el parámetro real B con el parámetro formal Y y el parámetro real 27 con el parámetro formal X durante la llamada de Sub.
Casi todos los lenguajes usan correspondencia de posición de manera exclusiva, de modo que los ejemplos emplean aquí este método. Ordinariamente, el número de parámetros reales y formales debe corresponder para que el apareamiento sea único; sin embargo, ciertos lenguajes relajan esta restricción y suministran convenciones especiales para interpretar parámetros reales faltantes o adicionales. Para simplificar, aquí se supone que todos los parámetros están apareados.
Métodos para transmitir parámetros
Cuando un subprograma transfiere el control a otro subprograma, debe haber una asociación del parámetro real del subprograma que llama con el parámetro formal del programa llamado. Suelen emplearse dos enfoques: se puede evaluar el parámetro real y pasar ese valor al parámetro formal, o el objeto de datos real puede pasarse al parámetro formal. Describiremos esto a través de un proceso en dos pasos:
1. Describir los detalles de implementación del mecanismo de transmisión de parámetros.
2. Describir la semántica de cómo se habrán de usar los parámetros.
Se han ideado en general varios métodos para pasar parámetros reales como parámetros formales. Los primeros cuatro que se describen en seguida son los más comunes: llamada por nombre, llamada por referencia, llamada por valor y llamada por valor-resultado.
Llamada por nombre. Este modelo de transmisión de parámetros contempla una llamada de subprograma como una sustitución del cuerpo completo del subprograma. Con esta interpretación, cada parámetro formal representa la evaluación real del parámetro real particular. Precisamente como si se hicieran las sustituciones reales, cada referencia a un parámetro formal requiere una reevaluación del parámetro real correspondiente.
Esto requiere que en el punto de llamada del subprograma no se evalúen los parámetros reales sino hasta que efectivamente se haga referencia a ellos en el subprograma. Los parámetros se transmiten sin evaluar, y el subprograma llamado determina cuando, en su caso, se evalúan efectivamente. Recuérdese, del análisis previo de las reglas de evaluación uniforme, que esta posibilidad era útil para tratar operaciones como la condicional ¡f-then-else como operaciones ordinarias. En operaciones primitivas, la técnica resulta útil en ocasiones; en
subprogramas definidos por el programador su utilidad es más problemática a causa del costo de implementación. La transmisión de parámetros por nombre desempeña un papel importante en ALGOL y es de considerable importancia teórica, pero tiene un costo importante de estructura de ejecución y por ello no se considera como un método popular en cuanto a su uso.
La regla básica de llamada por nombre se puede enunciar en términos de sustitución: el parámetro formal del cuerpo del programa llamado se debe sustituir en todas partes por el parámetro real antes de que se inicie la ejecución del subprograma. Aunque esto parece sencillo, considérese el problema de incluso la sencilla call Sub(X). Si el parámetro formal de Sub es Y, entonces Y se va a sustituir por X en todo Sub antes de que Sub se ejecute. Pero esto no basta, porque cuando se llega a una referencia a X durante la ejecución de Sub, la asociación para X a la que se hace referencia es la que está en el programa que llama, no la asociación en Sub (en su caso). Cuando Y se sustituye por X, también se debe indicar un ambiente de referencia distinto para usarse en la referencia de X. También se puede introducir ambigiledad si X ya es una variable conocida para Sub.
No es sorprendente que la técnica básica para implementar la llamada por nombre sea tratar los parámetros reales como subprogramas simples sin parámetros (tradicionalmente llamados en inglés thunks). Siempre que se hace referencia en un subprograma a un parámetro formal que corresponde a un parámetro real por nombre, se ejecuta el thunk compilado para ese parámetro, lo que da por resultado la evaluación del parámetro real en el ambiente de referencia apropiado y el regreso del valor resultante como valor del thunk.
Llamada por referencia. La llamada por referencia es tal vez el mecanismo más común de transmisión de parámetros. Transmitir un objeto de datos como parámetro de llamada por referencia significa que se pone a disposición del subprograma un apuntador a la localidad del objeto de datos (es decir, su valor 1). El objeto de datos mismo no cambia de posición en la memoria. Al comienzo de la ejecución del subprograma, los valores 1 de los parámetros reales se usan para inicializar localidades locales de almacenamiento para los parámetros formales.
El paso de parámetros de llamada por referencia tiene lugar en dos etapas:
Durante la ejecución del subprograma, las referencias a nombres de parámetros formales se tratan como referencias ordinarias a variables locales (excepto en casos que pudiera haber una selección de apuntador oculta; véase más adelante). Al terminar el subprograma, los resultados son regresados al programa de llamada también a través de los objetos de datos de parámetro real.
Llamada por valor. Si un parámetro se transmite por valor (se llama por copia en Ada 95), el valor (es decir, el valor r) del parámetro real se pasa al parámetro formal llamado. El mecanismo de implementación es similar al modelo de llamada por referencia, excepto que:
• Al invocar un subprograma, un parámetro de llamada por referencia pasa su valor 1, en tanto que un parámetro de llamada por valor pasa su valor r.
• Cuando se hace la referencia en el subprograma, un parámetro de llamada por referencia utiliza el valor 1 guardado en el parámetro formal para tener acceso al objeto de datos real, mientras que, en la llamada por valor, el parámetro formal contiene el valor que se usa.
Con base en este análisis, debe quedar claro que con la llamada por referencia se tiene un sinónimo del parámetro real, en tanto que en la llamada por valor no se tiene esta clase de referencia. Una vez que un parámetro real se pasa por valor, el parámetro formal no tiene acceso a modificar el valor del parámetro real. Todos los cambios hechos a los valores del parámetro formal durante la ejecución del subprograma se pierden cuando el subprograma termina.
Llamada por valor-resultado. Si un parámetro se transmite por valor-resultado, el parámetro formal es una variable (objeto de datos) local del mismo tipo de datos que el parámetro real. El valor (es decir, el valor r) del parámetro real se copia en el objeto de datos de parámetro formal en el momento de la llamada, de modo que el efecto es el mismo que si se ejecutara una asignación explícita de parámetro real a parámetro formal. Durante la ejecución del subprograma, todas las referencias al nombre del parámetro formal se tratan igual que una referencia ordinaria a una variable local, como en el caso de la llamada por valor. Cuando el subprograma termina, el contenido final del objeto de datos de parámetro formal se copia en el objeto de datos de parámetro real, de manera similar que si se ejecutara una asignación explícita de parámetro formal a parámetro real. En esta forma, el parámetro real conserva su valor original hasta la terminación del subprograma, cuando se asigna su nuevo valor como resultado del subprograma.
La transmisión de parámetros de valor-resultado se desarrolló a partir del Algol-W, un lenguaje desarrollado por Nicklaus Wirth como sucesor del ALGOL durante los años sesenta, antes de que desarrollara el Pascal. Implementada en una computadora IBM 360, la llamada por referencia era relativamente ineficiente, puesto que no había manera de tener acceso al valor r de un parámetro real en una instrucción. No había una operación de "carga indirecta de memoria". Para acelerar la ejecución, la transmisión de valor-resultado hacía de todos los parámetros "variables locales" directamente direccionables por el apuntador del registro de activación actual.
Tanto la llamada por referencia como por valor-resultado se usan ampliamente y, en cas¡ todas las situaciones "normales", dan el mismo resultado. Sin embargo, las cuatro técnicas, llamada por nombre, llamada por referencia, llamada por valor y llamada por valor-resultado, tienen diferencias que pueden conducir a que programas similares tengan semánticas diferentes, como se muestra más adelante.
Llamada por valor constante. Si un parámetro se transmite por valor constante, entonces no se permite cambio alguno en el valor del parámetro formal durante la ejecución del programa; es decir, no se permite asignar un nuevo valor u otra modificación del valor del pa-rámetro, y el parámetro formal no se puede transmitir a otro subprograma excepto como parámetro de valor constante. El parámetro formal actúa así como una constante local durante la ejecución del subprograma. Puesto que ningún cambio en cuanto a su valor es permisible, hay dos implementaciones posibles. El parámetro formal se puede tratar exactamente como un parámetro transmitido por valor para que sea un objeto de datos local cuyo valor inicial es una copia del valor del parámetro real. Alternativamente, se le puede tratar como un parámetro transmitido por referencia de modo que el parámetro formal contenga un apuntador al objeto de datos de parámetro real.
Tanto la llamada por valor como la llamada por valor constante tienen el efecto de proteger al programa que llama contra cambios en el parámetro real. Asi pues, desde el punto de vista del programa que llama, el parámetro real es sólo un argumento de entrada para el subprograma. Su valor no puede ser modificado por el subprograma, ya sea inadvertidamente o para transmitir los resultados de regreso.
Llamada por resultado. Un parámetro transmitido por resultado se usa sólo para transmitir un resultado de regreso desde un subprograma. El valor inicial del objeto de datos de parámetro real no importa y el subprograma no lo puede usar. El parámetro formal es una variable (objeto de datos) local sin valor inicial (o con la inicialización usual que se proporciona para variables locales). Cuando el subprograma termina, el valor final del parámetro formal se asigna como el nuevo valor del parámetro real, justamente como en la llamada por valor-resultado.
Casi todos los lenguajes implementan uno o dos de los mecanismos anteriores. FORTRAN implementa llamada por referencia, en tanto que Pascal implementa tanto la llamada por valor como la llamada por referencia. Un parámetro X: entero es un parámetro de llamada por valor, mientras que var X: entero es una llamada por parámetro de referencia. (Adviértase, sin embargo, que esto es una fuente de muchos errores en Pascal. Cuando se olvida incluir la palabra clave var, el parámetro es un parámetro de valor y todo cambio que se haga a su valor no se reflejará de regreso en el subprograma que llama. Este error es sutil y extremadamente difícil de encontrar a veces.) Por otra parte, C sólo implementa llamada por valor. Sin embargo, el uso de apuntadores permite al programador construir argumentos de llamada por referencia. El paso de un argumento i por referencia al procedimiento misubrutina es a través de la expresión (&i), la cual pasa el valor ¡ de i. La llamada se escribirá como misubrutina (&i) a un procedimiento declarado como miprocedimiento (int * x), el cual declara x como un apuntador a un entero. Si se olvida incluir los operadores apropiados para derreferenciar (& y *) puede conducir a muchos errores de programación en C.
Ambientes comunes explícitos
Un ambiente común establecido de manera explícita para compartir objetos de datos es el método más sencillo para compartir datos. A un conjunto de objetos de datos que se van a compartir entre un conjunto de subprogramas se le asigna almacenamiento en un bloque con nombre por separado. Cada subprograma contiene una declaración que nombra explícitamente el bloque compartido. Los objetos de datos dentro del bloque son entonces visibles dentro del subprograma y se puede hacer referencia a ellos por nombre en la forma usual. Un bloque compartido de este tipo se conoce por diversos nombres: bloque COMMON (COMÚN) en FORTRAN; en Ada es una forma de paquete; en C las variables individuales marcadas como extern (externas) se comparten de esta manera. Las clases de C++ y Smalltalk proporcionan esta característica, pero no es ése el propósito normal de las clases. El término ambiente común es apropiado aquí, en el presente contexto.
Especificación. Un ambiente común es idéntico a un ambiente local para un subprograma, excepto que no es parte de un subprograma individual. Puede contener definiciones de variables, constantes y tipos, pero no subprogramas o parámetros formales. La especificación de package (paquete) en Ada es un ejemplo:
package Tablas_Compartidas is
Tamaño_Tab: constant integer := l00;
type Tabla is array (1.. Tamaño.Tab) of real;
Tablal, Tabla 2: Tabla;
Entrada_Act: integer range 1. . Tamaño_Tab;
end
La especificación de paquete define un tipo, una constante, dos tablas y una variable entera que juntos representan un grupo de objetos de datos (y definiciones de tipo) que varios subprogramas necesitan. La definición del paquete se da fuera de los subprogramas que utilizan las variables.
Si un subprograma P requiere acceso al ambiente común definido por este paquete, entonces se incluye un enunciado with explícito entre las declaraciones de P:
with Tablas_Compartidas;
Dentro del cuerpo de P, cualquier nombre contenido en el paquete se puede usar ahora directamente, como si fuera parte del ambiente local para P. Usaremos el nombre calificado nombre de paquete. nombre de variable para hacer referencia a estos nombres. Así pues, podemos escribir, en P:
ablas_Compartidas.Tablal (Tablas~Compartidas.Entrada~Act) :=
Tablas_Compartidas.Tabla2(Tablas~Compartidas.Entrada~Act) + 1
sin más declaraciones de cualquiera de estos nombres. El nombre del paquete se debe usar como prefijo en cada nombre porque un subprograma puede usar muchos paquetes, algunos de los cuales pueden declarar el mismo nombre. (Si no hay este tipo de conflictos, el prefijo de nombre de paquete se puede evitar por medio de un enunciado use en Ada, por ejemplo, with Tablas_Compartidas use Tablas_Compartidas.) En general cualquier cantidad de otros subprogramas pueden emplear ~l mismo ambiente común si se incluye el enunciado with Tablas_Compartidas, y un solo subprograma puede usar cualquier número de ambientes comunes.
Alcance dinámico
Una alternativa al uso de ambientes comunes para datos compartidos es la asociación de un ambiente no local con cada subprograma en ejecución. El ambiente no local para un subprograma P consiste en un conjunto de ambientes locales de otras activaciones de subprograma que se hacen accesibles para P durante su ejecución. Cuando se hace referencia a una variable X en P y X carece de asociaciones locales, entonces el ambiente no local se usa para determinar la asociación correspondiente a X. ¿Cuál debe ser el ambiente no local para P? En lenguajes estructurados en bloques, las reglas de alcance estático determinan el ambiente no local implícito para cada subprograma; este enfoque más bien complejo se considera en la sección siguiente. Aquí se examina una alternativa más sencilla, aunque de uso menos extendido: el uso de los ambientes locales para subprogramas de la cadena dinámica actual.
Considérese un lenguaje en el cual los ambientes locales se eliminan a la salida del programa y donde las definiciones de subprograma no están anidadas una dentro de otra. Cada una está definida por separado respecto a las otras. Este caso, presente en APL, LISP y SNOBOL4, carece de una estructura estática de programa en la cual basar reglas de alcance para referencias a identificadores no locales. Por ejemplo, si un subprograma P contiene una referencia a X, y X no está definido localmente dentro de P, entonces, ¿cuál definición para X en algún otro subprograma se debe usar? La respuesta natural se encuentra considerando la cadena dinámica de activaciones de subprograma que conduce a la activación de P. Considérese el curso de la ejecución del programa: supóngase que el programa principal llama un subprograma A, el cual llama a 8, el cual llama a P. Si P hace referencia a X y no existe una asociación para X en P, entonces es natural recurrir al subprograma B que llamó a P y preguntar si B tiene una asociación para X. Si B la tiene, entonces se emplea esa asociación; en caso contrario, se recurre al subprograma A que llamó a B y se verifica si A tiene una asociación para X. Se ha usado la asociación de creación más reciente para X en la cadena dinámica de llamadas de subprograma que conduce a P. Este significado para una referencia no local se conoce como regla de la asociación más reciente; es una regla de referencia que se basa en el alcance dinámico.
Si el ambiente no local se determina por la regla de la asociación más reciente, no se usan reglas de alcance estático; es decir, no se hace intento alguno durante la traducción del programa para determinar la definición asociada con una referencia a un identificador que no está definido localmente en el subprograma. Durante la ejecución del programa, cuando se crea un objeto de datos X como parte de la activación del subprograma P, el alcance dinámico de la asociación para X se vuelve todas las activaciones de subprograma llamadas por P o por esos subprogramas, y así sucesivamente. X es visible dentro de este alcance dinámico (excepto cuando está oculto por un subprograma posterior que tiene su propia asociación local para x). Visto desde el otro sentido, el ambiente no local de una activación de subprograma P se compone de la cadena dinámica completa de activaciones de subprograma que conduce a ella.
El aspecto más problemático de este ambiente no local es que puede cambiar entre activaciones de P. Así, en una activación de P, donde se hace una referencia no local a X, la asociación más reciente en la cadena de llamada puede tener a X como nombre de un arreglo. En una segunda activación de P, invocada a través de una serie distinta de llamadas de subprograma previas, la cadena dinámica puede cambiar de modo que la asociación más reciente para X es como el nombre de una cadena de caracteres. En una tercera activación de P, puede no haber en absoluto una asociación para X en la cadena de llamada, con lo cual la referencia a X es un error. Esta variabilidad en cuanto a la asociación para X significa que se requiere verificación dinámica de tipos; por tanto, este método se usa sólo en lenguajes como LISP, APL y SNOBOL4 donde la verificación dinámica de tipos se emplea por otras razones.
Alcance estático y estructura de bloques
En lenguajes como Pascal y Ada, que utilizan una estructura de bloque para los programas, el manejo de referencias no locales a datos compartidos es más complejo. Si se examinan de nuevo las reglas de alcance estático para programas estructurados en bloques, se advertirá que cada referencia a un identificador dentro de un subprograma está asociada con una definición para ese identificador en el texto del programa, incluso si el identificador no es local para el subprograma. Por tanto, el ambiente no local de referencia de cada subprograma durante la ejecución ya está determinado por las reglas de alcance estático empleadas durante la traducción. El problema de la implementación es conservar la congruencia entre las reglas de alcance estático y dinámico, de modo que una referencia no local durante la ejecución del programa se relacione correctamente con el objeto de datos que corresponde a la definición para ese identificador en el texto del programa.
La implementación de las reglas de alcance estático en el compilador es sencilla. Conforme cada definición de subprograma se procesa durante la compilación, se crea una tabla local de declaraciones y se anexa a una cadena de tablas locales de esta clase que representan los ambientes locales del programa principal y de otros subprogramas dentro de los cuales este subprograma está anidado. Así, pues, al compilar R el compilador agrega la tabla local de declaraciones para 1? a la cadena que contiene sólo la definición del programa principal. Durante la compilación, se busca en esta cadena hasta encontrar una declaración para una referencia a X, comenzando por las declaraciones locales para R y retrocediendo a lo largo de la cadena hasta las declaraciones del programa principal. Cuando se completa la compilación de R, el compilador elimina de la cadena la tabla local para R. Adviértase la similitud con la búsqueda de un significado para X hecha con la regla de la asociación más reciente descrita en la sección 7.3.3; sin embargo, esta búsqueda de una declaración para X se hace durante la compilación, no durante la ejecución. Las cadenas de tablas locales de declaraciones representan el anidamiento estático de definiciones de subprograma en el texto del programa, en vez de la cadena dinámica de llamado de subprogramas durante la ejecución.
En el transcurso de la ejecución del programa en un lenguaje estructurado en bloques, se utiliza una pila central para registros de activación de subprograma. El ambiente local para cada subprograma se guarda en su registro de activación. La dificultad para mantener el alcance estático usando regias de alcance dinámico se hace manifiesta en la figura 7.19, la cual muestra el contenido de la pila central durante la ejecución del subprograma R de la figura 7.18. Cuando R se está ejecutando y se encuentra una referencia no local aX, la operación de referencia debe encontrar la asociación para Xen el programa principal, más bien que la que se halla en el subprograma Q, el cual llamó a R. Desafortunadamente, una simple búsqueda descendiente en la pila conduce a una asociación para Xen Q. El problema es que la serie de tablas locales de la pila representa el anidamiento dinámico de activaciones de subprograma, el anidamiento que se basa en la cadena de llamado en tiempo de ejecución. Pero es el anidamiento estático de definiciones de subprograma el que ahora determina el ambiente no local, y la pila, tal como está estructurada actualmente, no contiene información acerca del anidamiento estático.
Para completar la implementación es necesario representar la estructura estática de bloques durante la ejecución de tal manera que se pueda usar para controlar la referencia no local.
Obsérvese que en muchos aspectos la regla para referencia no local es en este caso similar a la correspondiente a referencia no local usando la regla de la asociación más reciente: para encontrar la asociación para satisfacer una referencia a X, se busca en una cadena de tablas de ambiente local hasta que se encuentra una asociación para X. Sin embargo, la cadena de tablas de ambiente local por examinar no se compone de todas las tablas locales que están actualmente en la pila, sino sólo de aquellas que representan bloques o subprogramas cuya definición encierra estáticamente la definición del subprograma actual en el texto del programa original. Por tanto, la búsqueda se hace todavía en algunas de las tablas de la pila, pero sólo en aquellas que son efectivamente parte del ambiente de referencia.
A condition, often an error, that causes the program or microprocessor to branch to a different routine. The terms interrupt and exception are very close in meaning. Both can be used to refer to either hardware or software. The only real difference is that an exception usually indicates an error condition.
Durante la ejecución de un programa, suelen ocurrir sucesos o condiciones que se podrían considerar como "excepcionales". En vez de continuar con la ejecución normal del programa, se necesita llamar un subprograma para llevar a cabo cierto procesamiento especial. Esto incluye:
Condiciones de error que obligan a procesar un error, como exceder la capacidad de una operación aritmética o hacer referencia a un elemento de arreglo con un subíndice fuera de límites.
Condiciones impredecibles que surgen durante la ejecución normal del programa, como la producción de encabezamientos especiales de salida al final de una página de impresora o un indicador de final de archivo en un archivo de entrada.
Rastreo y monitoreo durante la prueba de programas, como la salida de rastreo de impresión durante la prueba de programas cuando el valor de la variable cambia.
Aunque por lo común es posible insertar una prueba explícita en el programa para probar en busca de estas condiciones excepcionales, estos enunciados adicionales pueden ocultar la estructura básica del programa. Es más sencillo relajar el requisito de que los subprogramas se deben llamar por medio de llamadas explícitas y suministrar una forma de invocar un subprograma cuando ocurre una condición o suceso particular. A una condición o suceso de esta clase se le suele llamar excepción o señal, y al subprograma que lleva a cabo el procesamiento especial se le conoce como manejador de excepciones. A la acción de advertir la excepción, interrumpir la ejecución del programa y transferir el control al manejador de excepciones se le describe como plantear la excepción. En Ada, existe una clase de excepciones llamadas checks (verificaciones) o condiciones que requieren que se ejecute código en tiempo de ejecución. Por ejemplo, Index-Check es una excepción de Constraint_Error (Error_De_Restricción) que se plantea cuando un límite de arreglo no es válido.
Manejadores de excepciones
Puesto que un manejador de excepciones se invoca sin una llamada explícita, ordinariamente no requiere un nombre o parámetros. La definición de un manejador de excepciones contiene típicamente sólo:
1. Un conjunto de declaraciones de variables locales (en su caso), y
2.Una serie de enunciados ejecutables.
Para suministrar la conexión entre las excepciones y sus manejadores, a cada clase de excepciones se le da un nombre. Ciertas excepciones pueden estar predefinidas en el lenguaje, por ejemplo Constraint_Error (Error_de_restricción), Program~Error (Error~De~Programa)
o Numeric_Error (Error_Numérico) en Ada. Otras pueden ser definidas por el programador, por ejemplo, el programa puede incluir una declaración "Subdesbordamiento: exception" o "Desbordamiento: exception". Cada manejador de excepciones se aparea entonces con el nombre (o nombres) de la excepción o excepciones que va a manejar. Por lo común, todos los manejadores de excepciones se agrupan al principio o al final del programa o subprograma mayor donde la excepción podría ocurrir. Esta estructura en Ada es típica:
procedure Sub is
Valor_De_Dato_Malo: exception;
- otras declaraciones para Sub
begin
- enunciados para procesamiento normal de Sub
exception
when Valor_De_Dato_Malo =>
- manejador para valores de datos malos when Constraint_Error =>
- manejador para excepción predefinida Constraint_Error
when others =>
end;
Planteamiento de una excepción. Una excepción puede ser planteada mediante una operación primitiva definida por el lenguaje; por ejemplo, una operación de adición o multiplicación podría plantear la excepción Constraint_Error. Alternativamente, el programador podría plantear de manera explicita una restricción usando un enunciado suministrado para ese fin, como éste en Ada:
raise Valor_De_Dato_Malo;
el cual se podría ejecutar en un subprograma después de determinar que una variable o archivo de entrada contiene un valor impropio.
En un subprograma, si se usa un enunciado raise explícito y el subprograma mismo contiene un manejador para la excepción planteada, como por ejemplo cuando el enunciado:
if X = O then raise Valor_De_Dato Malo end if;
aparece dentro del cuerpo del procedimiento Sub antes citado, entonces el enunciado raise tiene el efecto de transferir el control al manejador asociado, el cual abandona después el procedimiento. Uno de los usos principales de los enunciados goto en lenguajes sin
características explícitas de manejo de excepciones es proporcionar transferencias a código para manejo de excepciones, como se expone en la sección 6.4.2.
Se pueden pasar datos al manejador de excepciones, como lo demuestra el ejemplo siguiente en ML. La acción normal en ML es terminar el procedimiento, pero si se suministra un manejador, éste se ejecuta y el control regresa al punto del error, como en:
exception DenominadorMalo of int;
fun lnnerDivide(a:int,b:int):real=
if b=0 then raise DenominadorMalo(b)
else real(a)/real(b);
fun Divide(a,b)= InnerDivide(a,b) handle
DenominadorMalo(b) => (print(b); "es denominador malo, se usó 0"; 0.0);
En este caso, el programa invoca la función Divide (Dividir), la cual llama la función InnerDivide (Dividirinterior). InnerDivide invoca la ejecución de la operación misma de dividir para ser manejada por la excepción DenominadorMalo. En este caso, el manejador imprime un mensaje y regresa el valor real 0.0 para la función Divide y la ejecución continúa. (Adviértase que no es necesario especificar tipos para los argumentos y el valor regresado de la función Divide. ML intenta inferir el tipo correcto, y en este caso puede hacerlo a partir de los tipos explícitos dados para InnerDivide.)
En C++ las excepciones se procesan (aunque no todo traductor de C++ tiene excepciones
implementadas) a través de la cláusula try. C++ plantea una excepción lanzando la excepción,
y la excepción se maneja atrapando la excepción. La sintaxis es similar a la de ML, aunque, a diferencia del ML, la ejecución se detiene después de manejar una excepción:
try {
enunciado1 ;
enunciado2 ;
if CondiciónMala { throw NombreDeExcepción};
}
catch NombreDeExcepción { // Hacer algo por la excepción
} // Fin de Excepción
}
Propagación de una excepción. Cuando se construye un programa, con frecuencia el lugar donde ocurre una excepción no es el mejor lugar para manejarla. Por ejemplo, un subprograma puede tener la función de leer valores de datos de un archivo y pasarlos a un nido de subprogramas para su procesamiento. Supóngase que se pueden encontrar varios tipos distintos de valores de datos malos en un archivo y que cada subprograma prueba en busca de una clase diferente de estos errores, pero la respuesta es en todos los casos la misma: imprimir un mensaje de error y adelantar el archivo más allá de los datos malos. En este caso, el manejador
podría correctamente ser parte del subprograma que lee el archivo, y cada subprograma podría plantear adecuadamente la excepción Valor_De_Dato_Malo. Cuando una excepción se maneja en un subprograma distinto del subprograma en el cual se planteó, se dice que la excepción se ha propagado desde el punto en el cual se planteó hasta el punto donde se maneja.
La regla para determinar cuál manejador se encarga de una excepción particular se define por lo común en términos de la cadena dinámica de activaciones de subprograma que conduce al subprograma que plantea la excepción. Cuando una excepción P se plantea en el sub-programa C, entonces P es manipulada por un manejador definido en C, si es que existe uno. Si no lo hay, entonces C termina. Si el subprograma B llamó a C, entonces la excepción se propaga a B y se plantea de nuevo en el punto en B donde B llamó a C. Si B no suministra un manejador para P, entonces B se termina, la excepción se propaga al que llamó a B, y así sucesivamente. Si ningún subprograma ni el programa principal suministran un manejador, entonces el programa completo se termina y se invoca un manejador estándar definido por el lenguaje. Por ejemplo, en el ejemplo anterior en ML, la función InnerDivide planteó la excepción Denominador_Malo, la cual se propagó al manejador de la función Divide, misma que devolvió entonces un valor definido por el programador como resultado de manejar esa excepción
.
Un efecto importante de esta regla para propagar excepciones es que permite que un subprograma permanezca como una operación abstracta definida por el programador, incluso al procesar excepciones. Una operación primitiva o un subprograma puede interrumpir súbitamente su procesamiento normal y plantear una excepción. Para el que llama, el efecto de que un subprograma plantee una excepción es precisamente el mismo que el efecto del planteamiento de una excepción por parte de una operación primitiva, si el subprograma no maneja él mismo la excepción. Si la excepción se maneja dentro del subprograma, entonces el mismo regresa en la forma normal y el que llama nunca se da cuenta de que se ha planteado una excepción.
Después de que se ha manejado una excepción. Después de que un manejador completa el procesamiento de una excepción, existe una duda constante en cuanto a dónde se va a transferir el control porque no hubo una llamada explícita del manejador. ¿Debe volver el control al punto donde se planteó la excepción (el cual puede estar a varios niveles de subprogramas de distancia)? ¿Debe volver el control al enunciado del subprograma que contiene el manejador donde la excepción se planteó después de propagarse? ¿Debe terminarse el subprograma que contiene el manejador mismo, pero terminarse de manera normal, para que parezca al que lo llamó como si nada hubiera ocurrido? Esta última solución es la que se adopta en Ada; ML proporciona varias opciones y otros lenguajes han elegido alternativas distintas.
Implementación
Las excepciones provienen de dos fuentes: (1) condiciones que detecta la computadora virtual, y (2) condiciones que genera la semántica del lenguaje de programación. En el primer caso, las excepciones de sistema operativo pueden ser planteadas directamente por interrupciones o trampas de hardware, como el desbordamiento aritmético, o se pueden plantear en software de apoyo, como una condición de final de archivo. En C, el programador tiene acceso directo a estas señales que procesa el sistema operativo. El programador puede habilitar una interrupción, por ejemplo, la función sigaction en Unix, la cual especifica un procedimiento que se invoca cuando se plantea la señal dada.
El lenguaje de programación puede contar con providencias para excepciones adicionales haciendo que el traductor del lenguaje inserte instrucciones adicionales en el código ejecutable. Por ejemplo, para detectar la excepción lndex-Check causada por un subíndice de arreglo demasiado grande o demasiado pequeño, el traductor inserta una serie explícita de instrucciones en cada referencia a un arreglo, como A[I, J], para determinar silos valores de I y J están dentro de los límites declarados. Por tanto, a menos que el hardware o el sistema operativo suministre la verificación de excepciones, la verificación en busca de una excepción requiere cierta simulación por software. El costo de esta verificación de software, tanto en almacenamiento de código como en tiempo de ejecución, suele ser grande. Por ejemplo, puede tomar más tiempo llevar a cabo la verificación de límites de subíndices en A[I, J] que el que se requiere para tener acceso al elemento del arreglo. A causa de este costo adicional, casi todos los lenguajes proporcionan un medio para desactivar la verificación en busca de excepciones en partes del programa donde el programador determina que esto no es peligroso [por ejemplo, pragma Supress(Index.Check) en AdaJ.
Una vez que se plantea la excepción, la transferencia de control a un manejador en el mismo programa se implementa comúnmente por medio de un salto directo al inicio del código del manejador. La propagación de excepciones de regreso por la cadena dinámica de llamadas de subprograma puede hacer uso de la cadena dinámica formada por los puntos de devolución de los registros de activación de subprograma en la pila central, como ya se ha expuesto. Al avanzar hacia arriba por la cadena dinámica, se debe terminar cada activación de subprograma usando una forma especial de instrucción return tanto para devolver el control al que llama como para plantear de nuevo la excepción en el mismo. La serie de devoluciones continúa hacia arriba de la cadena dinámica hasta que se alcanza un subprograma que tiene un manejador para la excepción planteada.
Una vez que se encuentra el manejador apropiado, se le invoca como en una llamada de subprograma ordinaria. Sin embargo, cuando el manejador termina, también puede terminar el subprograma que lo contiene, lo cual conduce a dos devoluciones normales de subprograma, una inmediatamente después de la otra. Cuando la cadena dinámica se ha desenrollado en esta forma hasta una devolución normal a un subprograma, ese subprograma continúa su ejecución en la forma usual.
Asertos
El concepto de aserto está relacionado con las excepciones. Un aserto es simplemente un enunciado que implica una relación entre objetos de datos en un programa, como el siguiente en C++:
#include <assert.h>
…
assert (x>y+ 1);
La macro predefinida en C++ genera simplemente:
if (x>y + 1) { /* Imprimir mensaje de error */ }
Es una forma eficaz de probar en cuanto a errores sin que codificaciones complejas abarroten el diseño del programa fuente. Después de desarrollar el programa, el assert puede permanecer como documentación dentro del programa (véase la sección 9.4.2) y la macro se puede alterar para que no genere enunciados de programa y permanezca simplemente como un comentario.
Supóngase que se permite que los subprogramas regresen a su programa de llamada antes de que se complete su ejecución. Estos subprogramas se llaman corrutinas. Cuando una corrutina recibe el control de otro subprograma, se ejecuta parcialmente y luego se suspende cuando regresa el control. En un punto posterior el programa que llama puede reanudar la ejecución de la corrutina a partir del punto en el cual se suspendió previamente la ejecución.
Adviértase la simetría que se ha introducido ahora en la estructura del programa de llamada y del llamado. Si A llama el subprograma B como corrutina, B se ejecuta un tiempo y regresa el control a A, justo como lo haría cualquier subprograma ordinario. Cuando A pasa de nuevo el control a B a través de un resume (reanudar) B, B se ejecuta otra vez por un tiempo y regresa el control a A, como un subprograma ordinario. Para A, por tanto, B parece un subprograma ordinario. Sin embargo, la situación es muy similar cuando se mira desde el subprograma . B, a la mitad de la ejecución, reanuda la ejecución de A. A se ejecuta por un tiempo y regresa el control a B. B continúa su ejecución un tiempo y regresa el control a A. A se ejecuta por algún tiempo más y regresa el control a B. Desde el subprograma B, A se parece mucho a un subprograma ordinario. El nombre corrutina proviene de esta simetría. En vez de que se tenga una relación de progenitor a hijo o de llamador a llamado entre los dos subprogramas, ambos parecen más bien iguales, dos subprogramas que intercambian el control en un sentido y otro conforme cada uno se ejecuta y donde ninguno de los dos controla claramente al otro.
Las corrutinas no son actualmente una estructura común de control en los lenguajes de programación, fuera de los lenguajes de simulación discreta (véase la sección 9.1.3). Sin
embargo, suministran una estructura de control en muchos algoritmos, la cual es más natural que la jerarquía ordinaria de subprogramas. Más aún, la estructura sencilla de corrutina se puede simular fácilmente en muchos lenguajes usando el enunciado goto y una variable de punto de reanudación que especifica la etiqueta del enunciado en el cual se debe reanudar la ejecución.
Implementación. La instrucción resume, que transfiere el control entre corrutinas, especifica la reanudación de cierta activación particular de la corrutina. Si existen múltiples activaciones recursivas de una corrutina B, entonces el enunciado resume B no tiene un significado claro. Por esta razón, es más sencillo pensar en las corrutinas en un contexto donde, cuando mucho, existe sólo una activación de una corrutina dada a la vez. Esta restricción permite utilizar una implementación para corrutinas que es similar a la que se usó para la estructura sencilla de llamada-regreso en la sección 6.4.2. A un solo registro de activación se le asigna almacenamiento en forma estática, al principio de la ejecución, como una extensión del segmento de código para la corrutina. En el registro de activación se reserva una sola localidad, que ahora se llama punto de reanudación, para guardar el antiguo valor de pi del CIP cuando una instrucción resume transfiere el control a otra corrutina. Sin embargo, a diferencia del punto de regreso en un subprograma sencillo, esta localidad del punto de reanudación en una corrutina B se usa para guardar el valor de pi para B mismo. La ejecución de una instrucción resume B en la corrutina A comprende por tanto dos pasos:
1. El valor actual del CIP se guarda en la localidad del punto de reanudación del registro de activación para A.
2 El valor de pi en la localidad del punto de reanudación de B se obtiene del registro de activación de B y se asigna al CIP para efectuar la transferencia del control a la instrucción apropiada en B.
Puesto que no hay una instrucción explícita de regreso, B no necesita saber que A le dio el control.
Las estructuras de control en las cuales los subprogramas se pueden invocar como corrutinas o como subprogramas ordinarios, y donde las corrutinas pueden ser recursivas (es decir, pueden tener múltiples activaciones simultáneas), requieren implementaciones más complejas.
En las secuencias de control descritas hasta ahora, se ha supuesto que siempre existe una orla de control predeterminada que describe la ejecución de un programa. Esto se ajusta en gran medida a la arquitectura de von Neumann que se ha supuesto para la computadora real de hardware. Sin embargo, los diseños en paralelo desempeñan un papel importante en programación.
Los sistemas de computadora capaces de ejecutar varios programas en forma simultánea son ahora bastante comunes. Un sistemas de multiprocesadores tiene varias unidades centrales de procesamiento (CPU) que comparten una memoria común. Un sistema de computadoras distribuidas o en paralelo tiene varias computadoras (posiblemente cientos), cada una con su propia memoria y CPU, conectadas con vínculos de comunicación en una red en la cual cada una se puede comunicar con las otras. En esta clase de sistemas, se pueden ejecutar muchas tareas independientes que se ejecutan en forma concurrente en la computadora virtual, no obstante que en computadora real sólo se puedan estar ejecutando una en un momento dado. La ilusión de ejecución concurrente en un solo procesador se obtiene intercalando la ejecución de las tareas individuales, de manera que cada una ejecute una porción de su código, luego se intercambie para ser sustituida por otra tarea que ejecuta multiprogramación y tiempo compartido proporciona este tipo de ejecución concurrente para programas de usuario individuales. Lo que aquí no ocupa, sin embargo, es la ejecución concurrente de tareas dentro de un solo programa.
El obstáculo principal es la carencia de construcciones para elaborar esta clase de sistemas. En la mayoría de los casos, los lenguajes normales como C se usan con llamadas de función adicionales al sistema operativo para habilitar tareas en paralelo. Sin embargo, el diseño del lenguaje de programación puede ayudar.
Incluso en una sola computadora, suele ser útil proyectar un programa de manera que se componga de muchas tareas independientes que se ejecutan
Principios de lenguajes de programación en paralelo
Las construcciones de programación en paralelo aumentan la complejidad del diseño del lenguaje, puesto que varios procesadores pueden estar teniendo acceso a los mismos datos de manera simultánea. Para estudiar el paralelismo en lenguajes de programación, es necesario tratar los cuatro conceptos siguientes:
1. Definiciones de variables. Las variables pueden ser mutables o de definición. Las variables mutables son las variables comunes que se declaran en casi todos los lenguajes secuenciales. Se pueden asignar valores a las variables y cambiarse durante la ejecución del programa. A una variable de definición se le puede asignar un valor una sola vez. La ventaja de esta clase de variable es que no existen problemas de sincronización. Una vez asignado un valor, cualquier tarea puede tener acceso a la variable y obtener el valor correcto.
2. Composición en paralelo. La ejecución avanza de un enunciado al siguiente. Además de los enunciados secuenciales y condicionales de los lenguajes de programación secuenciales, es necesario agregar el enunciado en paralelo, el cual causa que se comiencen a ejecutar orlas de control adicionales. El enunciado and de la sección 9.2.1 y la función fork (bifurcación) de sistema operativo que se llama desde C son ejemplos de esta clase de estructuras.
3. Estructura del programa. Los programas en paralelo se apegan en general a uno de los dos modelos de ejecución siguientes: (a) Pueden ser transformativos cuando la meta es transformar los datos de entrada en un valor de salida apropiado. El paralelismo se aplica para acelerar el proceso; por ejemplo, efectuar rápidamente la multiplicación de
una matriz multiplicando varias secciones de la misma en paralelo. (b) Pueden ser reactivos cuando el programa reacciona ante estímulos externos, llamados sucesos. Los sistemas en tiempo real y de comando y control son ejemplos de sistemas reactivos. Un sistema operativo y un sistema de procesamiento de transacciones, como un sistema de reservaciones, son ejemplos representativos de esta clase de sistemas reactivos. Se caracterizan por tener en general un comportamiento no determinista, puesto que nunca es explícito el momento exacto en que va a ocurrir un suceso.
4. Comunicación. Los programas en paralelo se deben comunicar unos con otros. Esta comunicación tiene lugar típicamente a través de memoria compartida con objetos de datos comunes a los que tienen acceso todos los programas en paralelo, o por medio de mensajes, donde cada programa en paralelo tiene su propia copia del objeto de datos y pasa valores de datos entre los otros programas en paralelo.
El mecanismo principal para instalar la ejecución en paralelo en un lenguaje de programación consiste en crear una construcción que dé lugar a ejecución en paralelo. El enunciado and lleva a cabo esta tarea.
enunciado1 and enunciado2 ...... and enunciadon
y tiene la semántica que hace que cada uno de los diversos enunciado se ejecuten en paralelo; el enunciado que sigue al enunciado and no se inicia hasta que terminan todos los enunciados en paralelo.
Aunque es sencillo desde el punto de vista conceptual, proporciona el pleno poder paralelo que se necesita para la ejecución en paralelo. Por ejemplo, si un sistema operativo consiste en una tarea de leer desde una terminal, una tarea de escribir en una pantalla y un proceso de ejecutar un programa de usuario, se podría especificar este sistema operativo como:
call LeerProceso and
call EscribirProceso and
call EjecutarProgramaDeusuario;
Las secuencias para ejecución en paralelo son sólo parte del problema. El manejo correcto de los datos es otra preocupación. Considérese lo siguiente:
x:=1;
x:=2 and y := x+x;(*)
print(y);
Puesto que los dos enunciados rotulados con (*) se pueden ejecutar en paralelo, no es posible predecir cuál va a terminar primero. Por consiguiente, a y se le puede asignar el valor 2 (si la asignación a y se ejecuta primero), 4 (si la asignación a x se ejecuta primero), o incluso 3 (si la asignación a x se hace entre los dos accesos de x en la asignación a y). Es necesario coordinar el acceso a datos en los programas concurrentes.
Implementación. Existen dos formas básicas de implementar una construcción and. Adviértase que, si todas las tareas en paralelo se pueden ejecutar en paralelo, no existe ningún supuesto en cuanto a su orden de ejecución. Se podrían ejecutar simplemente en orden y, si la construcción and original es correcta, entonces el "reemplazo" de and por el ";"secuencial y un enunciado while daría lugar a una ejecución correcta. Por ejemplo, el compilador podría reescribir el ejemplo anterior como:
while MásPorHacer do
MásPorliacer := false;
call LeerProceso;
call EscribirProceso;
call EjecutarProgramaDeUsuario
end
Si, por ejemplo, el LeerProceso anterior intentara leer datos que todavía no estuvieran listos para ser procesados, podría simplemente fijar MásPorHacer como cierto y regresar. Esta iteración se repetirá en tanto cada subprograma no haya terminado todavía.
Una manera más directa de implementar esta construcción consiste en usar las primitivas de ejecución en paralelo del sistema operativo subyacente. Por ejemplo, en C, el compilador podría ejecutar una función fork, la cual tiene el efecto de crear dos procesos que se ejecutan en paralelo. Cada proceso continuaría ejecutándose hasta terminar. El código que genera el compilador de C sería algo así como:1
fork LeerProceso;
fork EscribirProceso;
fork EjecutarProgramaDeusuario;
wait /* a que terminen los 3*/
Las facilidades para el manejo de tareas concurrentes son todavía poco comunes en lenguajes de programación de alto nivel. Ningún lenguaje de uso extendido da cabida a la construcción and que aquí se ha mencionado. De los lenguajes que se describen en la parte II, sólo Ada suministra tareas y ejecución concurrente, aunque la estrecha asociación de C con el sistema operativo permite que los programas en C invoquen la función fork de sistema operativo para crear tareas concurrentes.
Una segunda clase de construcciones tiene que ver con la ejecución no determinista, donde no se puede determinar cuál es el próximo enunciado por ejecutar. La construcción and anterior es en general determinista, puesto que sólo uno de los enunciados en paralelo se va a ejecutar efectivamente en seguida en una computadora dada; sin embargo, el concepto del and es la ejecución en paralelo, y su implementación suele ser un paso secuencial a través de cada uno de los enunciados.
El no determinismo auténtico fue propuesto a principios de los años setenta por Dijkstra con el concepto del comando protegido como medio para simplificar tanto el desarrollo de programas como su verificación [DJIKSTRA 1975]. Hasta aquí, todas las estructuras de control son deterministas. Ello significa que cada parte del enunciado tiene un orden específico de ejecución. Sin embargo, hay momentos en los cuales el no determinismo facilita el diseño de software, en forma muy parecida a la exposición con autómatas de estados finitos no deterministas como se hizo en la sección 3.3.2.
Una ejecución no determinista es aquella donde son posibles caminos de ejecución alternativos. Esta condición surge con frecuencia en la ejecución de programas concurrentes. Si existen n procesos listos para ejecutarse en un sistema, no siempre es claro cuál va a ser el siguiente que se ejecute. Si bien éste es un problema común en el diseño de sistemas operativos, no suele presentarse en el desarrollo de programas individuales, principalmente porque no se. ha contado con las herramientas (por ejemplo, enunciados de lenguaje de programación) para pensar en formas no deterministas.
Considérese como un ejemplo sencillo una ciudad compuesta de avenidas que corren de norte a sur y calles que corren de este a oeste (figura 9.2). Si una persona está en la Avenida Tres y la Calle Doce, ¿cuál es el camino para llegar a la Avenida Diez y la Calle Veinte?
Si se pide programar esto en un lenguaje convencional, surgirán típicamente dos diseños de programa distintos:
Diseño 1 Diseño 2
Ir al este de Ave. 3 a 10 Ir al norte de Calle 12 a 20
Ir al norte de Calle 12 a 20 Ir al este de Ave. 3 a 10
Sin embargo, son posibles muchos otros caminos. Si la ciudad tiene puntos de interés, entonces el transeúnte nunca verá los lugares de interés de la Avenida Ocho o de la Calle Dieciséis, por ejemplo. De hecho, para este programa se pueden especificar todos los caminos como:
while no en destino do arbitrarily
if noenAve. 10, iral estelo
if no en Calle 20, ir al norte 1
end
Cualquier solución que siga esta ruta arbitraria (es decir, no determinista) es una solución válida del problema original. Pronto veremos que los comandos protegidos de Dijkstra son una solución general para este problema.
Guardas. El concepto básico es la guarda, la cual se escribe como : !. Si B es una guarda (es decir, condición) y S es un comando (es decir, un enunciado), entonces un comando protegido, B S, significa que el enunciado S está habilitado o listo para ejecutarse si la guarda B es cierta. Se usan guardas en los enunciados guarded if y repetitive:
Enunciado guarded if. Si Bi es un conjunto de condiciones y Si es un conjunto de enunciados, el guarded if se escribe así:
If B1! S1|| B2! S2|| … || Bn! Sn fi
cuyo significado es que al menos una de las guardas debe ser cierta, y la ejecución es la de un comando correspondiente.
Adviértase que esto difiere de las construcciones anteriores que hemos estudiado: difiere de if-then normal, puesto que el caso usual no requiere que una guarda sea cierta, y no es lo mismo que el cond de LISP, donde el enunciado por ejecutar era el primero de la serie con una condición cierta. El if protegido está definido con auténtico no determinismo. Se elige arbitrariamente cualquier Si para ejecutarlo en tanto Bi sea cierto.
Enunciado de repetición protegida. Se trata de una generalización del enunciado while secuencial y es similar al guarded if: Si Bi es un conjunto de guardas y Si es un conjunto de comandos, el do protegido se escribe así:
do B1! S1|| B2! S2|| … || Bn! Sn od
La ejecución tiene lugar como sigue: si algunas guardas Bi son ciertas, se ejecuta uno de los Si correspondientes. Este proceso se repite en tanto alguna guarda sea cierta.
Si ninguna guarda es cierta al principio, el do se pasa por alto, lo cual es similar al comportamiento del enunciado while usual. Como en el caso del if protegido, se introduce el no determinismo en la ejecución si más de una guarda es cierta al mismo tiempo.
Los comandos protegidos suelen facilitar el desarrollo o la comprensión de muchos algoritmos. Considérese el problema anterior de caminar por la ciudad. Se puede especificar la solución general de manera no determinante como sigue:
Ave = 3; {En Avenida 3}
Calle = 12; {En Ave 12}
do Ave < 10 =* Ave Ave + 1
| | Calle <20 =+ Calle = Calle + 1 od
Todas las ejecuciones que comienzan en 3 x 12 y terminan en 10 x 20 son ejecuciones válidas de este programa.
Ningún lenguaje común ha implementado verdaderamente comandos protegidos según los definió Dijkstra; sin embargo, el concepto de ejecución no determinista surge en el diseño de software de sistemas operativos. El papel de esta clase de comandos protegidos en el uso de tareas y subprogramas se trata más cabalmente en la subsección siguiente.
La idea básica que está atrás de las tareas es simple. Considérese un subprograma A que se está ejecutando en la forma normal. Si A llama el subprograma B, entonces ordinariamente la ejecución de A se suspende mientras B se ejecuta. Sin embargo, si B se inicia como una tarea, entonces la ejecución de A continúa mientras B se ejecuta. Ya sea A o B, o ambos, pueden iniciar ahora tareas adicionales, lo cual permite que coexista cualquier número de secuencias de ejecución en paralelo.
En general, cada tarea se considera como una dependiente de la tarea que la inició. Cuando una tarea está lista para terminar, debe esperar hasta que todas sus dependientes hayan concluido para que pueda finalizar. Así pues, la división en múltiples secuencias de ejecución se invierte conforme las tareas terminan, fundiéndose en cada vez menos secuencias hasta que finalmente sólo queda una secuencia. En circunstancias normales cada una de estas tareas de máximo nivel controla una parte importante del sistema (que actualmente suele ser un sistema de computadoras distribuidas) y, una vez iniciada, se espera que se ejecute "para siempre".
Gestión de tareas
La definición de una tarea en un programa difiere poco de la definición de un subprograma ordinario, excepto en cuanto a definir cómo se sincroniza la tarea y de qué manera se comunica con otros programas. La mayor parte del cuerpo de una definición de tarea contiene declaraciones y enunciados ordinarios que se ocupan del procesamiento que la tarea lleva a cabo mientras trabaja en forma independiente de otras tareas. En Ada, que es aquí nuestro ejemplo primordial, una definición de tarea toma la forma siguiente:
task Nombre is
- Declaraciones especiales que permiten sincronización y comunicación con otras tareas
end;
task body Nombre is
- Declaraciones locales usuales según se encuentran en cualquier subprograma
begin
- Serie de enunciados
end;
La iniciación de la ejecución de una tarea puede adoptar la forma de una llamada de subprograma ordinaria. Por ejemplo, en muchas implementaciones de PLII, una tarea B se inicia ejecutando el enunciado:
call B (parámetros) tarea;
En Ada el método es algo distinto. La definición de tarea, como se dio anteriormente, se incluye entre las declaraciones de alguna estructura de programa más grande, como el programa principal. Cuando se entra a la estructura de programa más grande, todas las tareas declaradas dentro de la misma se inician en forma automática. Por tanto, no se necesita un enunciado call explícito; las tareas comienzan a ejecutarse simultáneamente tan pronto como se entra en la estructura de programa más grande.
Es frecuente que se requieran múltiples activaciones simultáneas de la misma tarea en aplicaciones. Por ejemplo, considérese un sistema de computadora que controla un conjunto de terminales de usuario. La tarea principal podría ser el programa que vigila la condición de todas las terminales. Cuando un usuario se identifica en una terminal, esta tarea, Monitor, inicia una nueva tarea, Terminal, para controlar las interacciones con el usuario de esa terminal particular. Cuando el usuario se retira, la tarea Terminal concluye. La tarea Monitor, desde luego, se ejecuta continuamente excepto en caso de una fal la catastrófica del sistema. Cuando varios usuarios están en comunicación simultáneamente en terminales distintas, se requieren varias activaciones de la tarea Terminal, una para cada usuario.
Si una tarea se inicia usando una llamada de subprograma ordinaria, como en PLII, entonces la ejecución repetida de un call basta para crear múltiples activaciones. En Ada, la definición de tarea antes descrita se puede usar para crear únicamente una sola activación de tarea, gracias a la iniciación implícita de tareas. Así, la tarea Monitor estaría definida probablemente como ya se ha indicado. Por lo que toca a la tarea Terminal, se requieren múltiples activaciones y éstas deben ser creadas e iniciadas por Monitor según sea necesario. En Ada, Terminal se define como un tipo tarea.
task type Terminal is
end;
La definición de Terminal como un tipo de tarea permite tratar una activación de la tarea como un tipo de objeto de datos, en la misma forma en que una definición ordinaria de tipo seusa para definir una clase de objetos de datos, como se describe en la sección 5.2. El establecimiento (es decir, creación e iniciación) de una nueva activación de tarea es entonces lo mismo que si un nuevo objeto de datos utiliza una definición de tipo como plantilla. Para crear varias activaciones y dar los nombres A, B y C, el programador en Ada escribe las declaraciones como declaraciones de variable ordinarias:
A: Terminal:
B, C: Terminal;
Estas declaraciones aparecen al principio de una estructura de programa más grande, y al entrar a este programa mayor se crean e inician las tres activaciones de Terminal. Alternativamente, se puede definir una variable apuntador cuyo valor es un apuntador a una activación de tarea, como en:
type AptTarea is access Terminal; -Define tipo apuntador
TérminoNuevo: AptTarea := new Terminal; -Declara variable apuntador
La variable TérminoNuevo apunta a una activación de una tarea de tipo Terminal que se crea en el momento en que se crea la misma TérminoNuevo.
Una vez que se inicia una tarea, los enunciados de su cuerpo se ejecutan en orden, igual que para un subprograma ordinario. Cuando una tarea termina, no regresa el control. Su secuencia individual de ejecución en paralelo simplemente concluye. Sin embargo, una tarea no puede terminar hasta que sus dependientes han terminado y, cuando termina, cualquier tarea de la cual sea dependiente deberá ser notificada de ello para que esa tarea también pueda terminar. Una tarea termina cuando completa la ejecución de los enunciados de su cuerpo; una tarea que nunca termina se escribe de modo que contenga una iteración infinita que avanza en ciclos continuamente (hasta que ocurre un error).
Descargar
Enviado por: | Alfredo |
Idioma: | castellano |
País: | Venezuela |