2.5 Pilas
2.5.6 Pilas, llamadas a procedimientos y recursión
Uno de los usos más trascendentales de la pila es la gestión de llamadas a procedimientos de un programa. Consideremos el siguiente fragmento de código:
int fct_1(int i) { /* ... */ }
int fct_2(int i) {
int x; x = fct_1(i); ... } int fct_3(int j) { int x; x = fct_2(j); ... } void main() { int y; y = fct_3(3); ... }
Ahora examinemos los eventos involucrados a la llamada a fct_3() desde main(). Cuando se alcanza la llamada a fct_3(), el ujo de control salta hacia la dirección de memoria de fct_3() y comienza a ejecutarla. Posteriormente, cuando se alcanza la llamada a fct_2(), y el ujo de control salta de nuevo hacia la dirección de fct_2(). Finalmente, se alcanza la función fct_1() y el ujo salta de nuevo hacia la dirección de fct_1().
Cuando fct_1() naliza, el ujo de programa recorre el camino inverso hasta main(). Planteemos, entonces, las siguientes preguntas:
Las funciones fct_2() y fct_3() tienen parámetros de nombre i y variables locales de nombre x. ¾Cómo se diferencian entre si los parámetros y variables?
¾Cómo se gestiona la memoria para los parámetros y variables locales de un proce- dimiento?
¾Cómo hace el ujo de control para saltar a una función y regresar cuando culmina la llamada a la función?
Estos problemas son resueltos por el compilador, el sistema operativo y el hardware a través de una pila. La mayoría de los procesadores manejan dos registros especiales comúnmente denominados SB y SP. Estos registros manejan la denominada pila del sistema. SB es la dirección base de una pila y SP es la dirección actual del tope de la pila. Cuando se hace un push sobre la pila del sistema, SP es decrementado. Del mismo modo, cuando se hace un pop(), SP es incrementado.
Cuando el compilador procesa una llamada a una función, por ejemplo, fct_1() desde main(), se genera, antes de llamar a la función, la secuencia de pseudocódigo sobre la pila sistema mostrada en el cuadro 2.5.1.
El compilador también genera código adicional al principio y al nal de la función fct_1() bajo el esquema mostrado en el cuadro2.5.2.
Generalmente, un push() equivale a restar al registro SP el tamaño de lo que se le inserta. Simétricamente, un pop() equivale a una suma. Para las funciones fct_2() y fct_3(), el compilador genera códigos similares a 2.5.1 y 2.5.2.
1. Un push() para el espacio del resultado que retorna fct_1(). 2. Varios pushes para los parámetros de fct_1().
3. Un push() para la dirección actual del ujo del programa.
4. Una instrucción de salto hacia la dirección de la función fct_1(), el cual lleva el ujo de control hacia el inicio de la función fct_1().
5. Una vez que fct_1() haya sido ejecutada, el ujo de programa regresará al punto posterior donde se efectuó el salto. Para ello, el compilador genera un pop() que recupera la dirección de retorno de la pila. Al regresar a main() se efectúan varios pops correspondientes a la cantidad de pushes que se hizo para pasar los parámetros.
6. Finalmente, se hace un último pop() que recupera el resultado de fct_1().
.
Pseudocódigo 2.5.1: Estructura de código generada para la llamada a funcion_1()
1. Al inicio de la función se efectúan varios pushes proporcionales al número de variables locales de fct_1().
2. El código de la función se genera con referencias a las variables locales apartadas en el punto anterior y los parámetros apartados y copiados en 2.5.1.
3. El resultado de la función también se guarda en el espacio apartado en el punto 1 de 2.5.1. 4. Al nal de la función, el compilador añade una cantidad de pops igual a la cantidad de pushes
usada para las variables locales. Estos pops liberan la memoria usada por las variables locales. 5. Se hace un pop() que recupera la dirección de retorno al invocante de fct_1() y se salta al
punto 5 de 2.5.1.
Pseudocódigo 2.5.2: Estructura de código generada para la llamada a la funcion_1() La información pertinente a una llamada a procedimiento que se guarda en la pila sistema es contigua. El bloque compuesto por el valor de retorno, parámetros, dirección de retorno y variables locales se denomina registro de activación.
El soporte ejecutivo para las llamadas a procedimientos se implanta con una pila porque el programa uye a través de las funciones bajo una disciplina UEPS. Una vez que se alcanza fct_3(), el programa debe regresar por el camino inverso a main() pasando por fct_2() y fct_1().
Aunque la recursión es una técnica de programación bastante poderosa, es impor- tante aprehender que conlleva costes importantes en espacio y tiempo. Como ejemplo, consideremos la función factorial implantada recursivamente:
114 hFactorial recursivo 114i≡
int fact(const int & n) {
if (n <= 1) return 1;
return n*fact(n-1); }
La versión recurrente del factorial es un mal ejemplo del uso ineciente de la recursión, pues aparte de ser extremadamente ineciente, la contraparte iterativa es muy eciente
y más fácil de implantar. Sin embargo, a efectos didácticos, su codicación recurrente es muy fácil de comprender porque reeja idénticamente la denición matemática.
SB SP Resultado 1 Dirección de retorno Resultado 2 Dirección de retorno Resultado 3 Dirección de retorno Resultado 4 Dirección de retorno fact(4) fact(3) fact(2) fact(1)
Figura 2.21: Capa de la pila sistema con la llamada a fact(4)
La gura 2.21 ilustra el estado de la pila sistema cuando se alcanza la llamada a fact(1). Cada vez que se efectúa una llamada recursiva se gasta en espacio de pila el resultado de la llamada recursiva, el parámetro y la dirección de retorno. Del mismo modo, por cada llamada se gasta en tiempo los tres pushes y los tres pops.
2.5.6.1 Consejos para la recursión
Si estamos diseñando un algoritmo y nos es más fácil trabajar con la recursión, entonces deben tenerse en cuenta las siguientes consideraciones:
1. Todo algoritmo recursivo debe tener un caso base donde no ocurra ninguna llamada recursiva.
2. El algoritmo debe progresar y converger en tiempo nito a encontrar la solución. Por cada llamada recursiva, la entrada debe disminuir. Si este no es el caso, hay bastantes probabilidades de que el razonamiento subyacente al algoritmo esté errado.
3. Evite cálculos repetidos. Este es el principal peligro de la recursión. Un ejemplo notable son los números de Fibonacci cuya denición matemática es la siguiente:
Fib(n) =
1 si n ≤ 1
Fib(n − 1) + Fib(n − 2) si n > 1
La codicación recursiva que calcula el i-ésimo número de Fibonacci se desprende de su denición matemática:
115 hFunción de Fibonacci 115i≡
int fib(const int & n) {
if (n <= 1) return 1;
return fib(n - 1) + fib(n - 2); }
Notemos que Fib(n − 2) se calcula 2 veces. La primera cuando se llama explícitamente a Fib(n − 2); la segunda, dentro de la llamada a Fib(n − 1). Esta duplicidad de llamadas
fib(1) fib(0) fib(2) fib(3) fib(1) fib(4) fib(2) fib(1) fib(0) fib(2) fib(1) fib(0) fib(1) fib(3) fib(5)
Figura 2.22: Llamadas a fib() para n = 5
se expande exponencialmente a medida que aumenta n. La gura 2.22 ilustra la cantidad de llamadas a fib() para la pequeña escala n = 5.
Cuando se diseña un algoritmo recursivo debemos cerciorarnos de que no hayan cálcu- los redundantes. Por lo general, la redundancia requiere anotar los cálculos en alguna estructura de datos especial.
2.5.6.2 Eliminación de la recursión
Hay tres maneras conocidas para eliminar la recursión, la cuales mencionaremos en los subpárrafos subsiguientes.
Eliminación de la recursión cola
Si un procedimiento P(x) tiene como última instrucción una llamada a P(y), entonces es posible reemplazar P(y) por una asignación x = y y un salto al inicio del procedimiento. Por ejemplo, el recorrido innito de los nodos de una lista enlazada podría denirse, recursiva y erróneamente, del siguiente modo:
116a hRecorrido recursivo de lista enlazada 116ai≡
template <typename T>
void recorrer(Snode<T> * head, void (*visitar)(Snode<T>*)) { if (head->is_empty()) return; (*visitar)(head->get_next()); recorrer(head->get_next()); } Uses Snode 68b.
Este procedimiento puede transformarse mediante eliminación de la recursión cola en: 116b hRecorrido no-recursivo de lista enlazada 116bi≡
template <typename T>
void recorrer(Snode<T> * head, void (*visitar)(Snode<T>*)) { start: if (head->is_empty()) return; (*visitar)(head->get_next()); head = head->get_next(); goto start;
}
Uses Snode 68b.
Muchas veces es posible eliminar la recursión cola aun si la última instrucción no es una llamada recursiva. Por ejemplo, la última instrucción de la versión recursiva del factorial
hFactorial recursivo 114i no es la llamada a fact(n - 1), sino la llamada a return. En
este caso podemos eliminar la recursión cola y obtener la siguiente versión: 117 hFactorial no recursivo 117i≡
int fact(const int & n) { int result = 1; start: if (n <= 1) return result; result *= n; n; goto start; }
Emulación de los registros de activación
Podemos suprimir la recursión si emulamos las llamadas recursivas y los registros de activación. Para ello debemos denir una pila con los siguientes atributos:
1. Los valores de los parámetros. En caso de que se trate de una función, se incluye el valor de retorno.
2. Los valores de las variables locales.
3. Una indicación que señale la dirección de retorno. Cada valor de indicación debe corresponder a un punto de retorno en el procedimiento recursivo.
Una vez denida la pila, se modica el procedimiento recursivo como sigue: 1. Se añade a la fase de inicio la declaración e inicialización de la pila.
2. Se ponen etiquetas de salto en los puntos de salida de las llamadas recursivas. 3. A la excepción de la fase de inicialización, el cuerpo de la función se envuelve con
un lazo repetitivo cuya condición de parada es que la pila esté vacía.
4. Los puntos donde hay llamadas recursivas se sustituyen por un push() de lo que sería el registro de activación y un salto al inicio del lazo.
5. Los puntos donde el procedimiento recursivo retorne se sustituyen por un pop() seguido de un switch que determina, según la indicación de salto del registro de activación, la dirección de salto.
Por ejemplo, para el cálculo del i-ésimo número de Fibonacci modicamos la hFunción
de Fibonacci 115i del siguiente modo:
118a hVersión recursiva mejorada de Fibonacci 118ai≡
int fib(const int & n) {
if (n <= 1) return 1;
const int f1 = fib(n - 1); const int f2 = fib(n - 2); return f1 + f2;
}
Esta forma facilita la eliminación de la recursión.
Denimos la siguiente estructura para el registro de activación:
118b hRegistro activación 118bi≡ (118d) # define P1 1 # define P2 2 struct Activation_Record { int n; int f1; int result; char return_point; }; Denes:
Activation_Record, used in chunk 118e.
Con miras a la claridad, el acceso a cualquiera de los campos se efectúa a través de alguno de los siguientes macros:
118c hacceso registro activación 118ci≡ (118d)
# define NUM(p) ((p)->n) # define F1(p) ((p)->f1) # define RESULT(p) ((p)->result) # define RETURN_POINT(p) ((p)->return_point)
La versión no recursiva, con pila, que calcula el i-ésimo número de Fibonacci, la de-
nimos en el archivo hb_stack.C 118di cuya denición es la siguiente:
118d hb_stack.C 118di≡
hRegistro activación 118bi hacceso registro activación 118ci int fib_st(const int & n) {
hcuerpo de b_st 118ei }
Ciñéndonos al método, lo primero es denir el preámbulo de la rutina:
118e hcuerpo de b_st 118ei≡ (118d)
ArrayStack<Activation_Record> stack;
Activation_Record * caller_ar = &stack.pushn(); Activation_Record * current_ar = &stack.pushn(); NUM(current_ar) = n;
Uses Activation_Record 118b and ArrayStack 101a.
A lo largo de la rutina manejaremos dos apuntadores. caller_ar representa el regis- tro de activación del invocante. Requerimos este registro de activación porque debemos guardar el resultado de la llamada actual que emulamos, el cual se guarda en el registro de activación del invocante y no del invocado. current_ar es el registro de activación de la llamada actual.
Ahora emulamos el cuerpo del procedimiento recursivo. Para ello comenzamos por
colocar el código mostrado en hVersión recursiva mejorada de Fibonacci 118ai y luego
sustituimos por segmentos de código que emulan la recursión:
119a hemulación de recursión 119ai≡ (118e)
start:
hcaso base de recursión119ci hllamada a b(n - 1) 120ai
p1: /* punto de retorno de fib(n - 1) */ hretorno desde b(n - 1)120bi
hllamada a b(n - 2) 120ci
p2: /* punto de retorno de fib(n - 2) */ hretorno desde b(n - 2)120di
return_from_fib: hretornar desde b120ei
Distinguimos cuatro etiquetas de salto del ujo de ejecución:
1. start es por donde se inicia el método. Cada vez que se emule una llamada recursiva se actualizan los punteros a los registros de activación y se salta hacia este punto.
119b hllamada recursiva a b119bi≡ (120)
caller_ar = &stack.top(1); current_ar = &stack.top(); goto start;
Este segmento de código asume que un nuevo registro de activación ha sido previamente insertado en la pila. Por esa razón actualizamos los apuntadores a los registros de activación antes de saltar al inicio del programa.
La primera línea del programa recursivo procesa el caso base, el cual es muy similar en la versión no recursiva:
119c hcaso base de recursión119ci≡ (119a)
if (NUM(current_ar) <= 1) {
RESULT(caller_ar) = 1; goto return_from_fib; }
Al igual que en la versión recursiva, la condición se evalúa sobre el parámetro n del registro actual. Si caemos al caso base, entonces ponemos el resultado en el registro de activación del invocante y enviamos el ujo de programa hacia la parte que emula el retornar desde la función recursiva.
Si no se cae en el proceso base, entonces hay que emular la llamada afib(n - 1), la cual se realiza como sigue:
120a hllamada a b(n - 1) 120ai≡ (119a)
RETURN_POINT(current_ar) = P1;
NUM(&stack.pushn()) = NUM(current_ar) - 1; // crea reg. act. hllamada recursiva a b 119bi
La primera línea marca la dirección de retorno. La segunda aparta el registro de activación de la nueva llamada e inicia su parámetro n según la llamada fib(n - 1).
2. Cuando se haya calculado fib(n - 1), hretornar desde b 120ei inspeccionará el
valor RETURN_VALUE del registro actual de activación y se percatará de que hay que saltar el ujo hacia la etiqueta p1. En este momento debemos emular la asignación int f1 = fib(n - 1):
120b hretorno desde b(n - 1) 120bi≡ (119a)
F1(current_ar) = RESULT(current_ar);
Luego, emulamos la llamada a fib(n - 2):
120c hllamada a b(n - 2) 120ci≡ (119a)
NUM(&stack.pushn()) = NUM(current_ar) - 2; // crea reg. act. RETURN_POINT(current_ar) = P2;
hllamada recursiva a b 119bi
hllamada a b(n - 2) 120ci es casi idéntica a hllamada a b(n - 1) 120ai. Lo único que
cambia es que el valor del parámetro n corresponde a n − 2.
3. Una vez calculado fib(n - 2), hretornar desde b 120ei saltará el ujo hacia p2. En este
momento emulamos la asignación int result = f1 + f2:
120d hretorno desde b(n - 2) 120di≡ (119a)
RESULT(caller_ar) = F1(current_ar) + RESULT(current_ar);
4. Finalmente, return_from_fib es el punto donde se emula la instrucción return del pro- cedimiento recursivo. Tal emulación consiste en:
120e hretornar desde b 120ei≡ (119a)
stack.pop(); /* cae en el registro del invocante */ if (stack.size() == 1) return RESULT(caller_ar); caller_ar = &stack.top(1); current_ar = &stack.top(); switch (RETURN_POINT(current_ar)) { case P1: goto p1; case P2: goto p2; }
Cada vez que se retorna de una función se elimina un registro de activación, ese es el cometido del pop(). Si la pila contiene un registro, entonces el ujo está terminando la llamada inicial y el resultado denitivo se encuentra en RESULT(caller_ar). De lo contrario se actualizan los apuntadores a los registros de activación y se determina donde se debe retornar.
La rutina anterior exhibe un desempeño inferior a su versión recursiva. De entrada, el TAD ArrayStack<T> añade un sobrecoste de validación de desborde que no tiene la versión recursiva. Hay otras fuentes de sobrecoste, la indicación de salto es una que no tiene la versión recursiva.
Podemos efectuar mejoras sobre la versión no recursiva. En primer lugar podemos eli- minar el campo f1 del registro de activación y utilizar result. De este modo disminuimos la cantidad de pushes. Otra mejora es disminuir la cantidad de observaciones a la pila
-instrucción top()-. La parte hllamada recursiva a b 119bi podría denirse como:
caller_ar = current_ar; ++current_ar;
goto start;
Puesto que el registro del invocante pasará a ser el actual, no hay necesidad de observar la pila. Puesto que la pila está implantada con un arreglo, el siguiente registro de activación será el vecino de current_ar; por eso lo incrementamos.
Una optimización nal consiste en colocar direcciones reales de salto en lugar de eti-
quetas. Este método eliminaría el switch y el if de hretornar desde b 120ei, pues el
ujo saltaría directamente a la dirección dada. Desafortunadamente, ni el lenguaje C ni
el C++ poseen mecanismos que permitan almacenar direcciones de salto17. Tendríamos,
entonces que programar algunas partes de la rutina en ensamblador. Eliminación total de la recursión
Como ya hemos visto, eliminar la recursión no es trivial. La experiencia indica que es muy difícil obtener una versión no recursiva que sea más eciente que su equivalente recursivo. Por esa razón, la eliminación de la recursión de un algoritmo naturalmente recursivo sólo debe hacerse si la parte recursiva está dentro del camino crítico de ejecución y es vital aumentar el desempeño. En la mayoría de las ocasiones es preferible trabajar con la versión recursiva.
Existen situaciones en las que el tamaño de la pila del sistema es pequeño. En estos casos, la recursión es peligrosa, pues aumenta las posibilidades de desborde de pila. Situa- ciones de este tipo son programas empotrados, de tiempo real o aplicaciones paralelas con varios ujos (threads) de ejecución. En este caso, a n de proteger la pila del sistema es justicable -algunas veces esencial- disponer de una versión no recursiva que maneje su propia pila.
En la práctica, el uso de la recursión es una cuestión de comodidad. Si los conceptos inherentes al algoritmo son recursivos, entonces es preferible trabajar en términos recur- sivos, pues hay más seguridad de entendimiento. Si por razones de desempeño o espacio se
17A la excepción del ensamblador, el autor no conoce ningún lenguaje que posea este mecanismo. Las
primitivas longjmp() y setjmp() de la biblioteca estándar del lenguaje C permiten guardar direcciones de ujo y saltar a ellas. Lamentablemente, a nuestros efectos, sus costes en tiempo y en espacio son superiores que el guardar indicaciones.
hace necesario eliminar la recursión, entonces es preferible formular conceptos iterativos, en lugar de eliminar la recursión de un algoritmo recursivo. Un ejemplo notable es la misma función factorial cuya denición iterativa es:
n! = n Y
i=1 i .
Esta denición nos conduce a una versión iterativa sin ningún razonamiento recursivo. Algunas veces se puede encontrar una solución analítica. Por ejemplo, como de- mostraremos en 6.4.2 (Pág. 483), el i-ésimo número de Fibonacci puede denirse como:
Fib(n) = √1 5 " 1 +√5 2 !n − 1 − √ 5 2 !n# .
Un algoritmo basado en esta expresión no requiere ninguna iteración ni recursión.
En denitiva, lo más simple es a menudo lo idóneo. Si pensar recursivamente es más simple, entonces transe por algoritmos recursivos. Si la versión recursiva es muy ineciente, el factorial o los números de Fibonacci, por ejemplos, entonces busque soluciones que disminuyan los cálculos repetidos o utilice conceptos iterativos que le conduzcan hacia algoritmos iterativos.