Universidad Simón Bolívar Departamento de Computación y Tecnología de la Información CI3641 Lenguajes de Programación I Guía Corta: Recursión de Cola Esta guía presenta algunos conceptos básicos y ejemplos de Recursión de Cola, incluyendo como reconocerla y transformaciones de código asociadas a la misma. Nota: Es importante recordar que esta guía es un complemento, cuyo objetivo es ayudar al entendimiento de estos conceptos y su aplicación en la práctica. No debe considerarse un reemplazo para la bibliografía ocial del curso. 1. Preliminares: Repetición vs. Recursión La repetición y la recursión son técnicas ofrecidas por algunos lenguajes de programación que permiten ejecutar una cantidad determinada (o posiblemente indeterminada) de veces un conjunto de instrucciones. La recursión es natural de los lenguajes de programación declarativos, mientras que la repetición es natural en lenguajes de programación imperativos. A pesar de esto, muchos lenguajes imperativos ofrecen facilidades para escribir funciones y procedimientos recursivos, ya que algunos problemas son más fáciles de expresar recursivamente. Los lenguajes declarativos puros (en donde existe transparencia referencial) no necesitan suministrar repeticiones, pues los mismos no serían útiles sin efectos de borde. Factorial Recursivo Factorial Iterativo int fact (int n) { int ret = 1; while (n-- > 0) ret *= n return ret; } int fact (int n) { if (n == 0) return 1; return n * fact(n - 1); } Nota: Un programa con ciclos es iterativo. Cada uno de estos ciclos se llaman repeticiones y cada una de las vueltas que realizan dichas repeticiones es una iteración. 2. Recursión de Cola Los defensores de los lenguajes de programación imperativos, tras no poder convencer vendiendo la facilidad de implementación, atacan a los defensores de lenguajes funcionales con que la repetición es naturalmente más eciente que la recursión. Sin embargo, tal armación no está completa: Una repetición es más eciente que una recursión ingenua. 1 ¾Qué podría hacer más lenta la recursión que la repetición? Una respuesta razonable es que la invocación de funciones supone empilar y desempilar parámetros, variables locales, valores de retorno, etc. Esto es cierto y sin duda podría presentar un overhead importante, si no fuera porque dicho problema se puede evitar con un poco de astucia de parte de los compiladores e intérpretes. Si una llamada recursiva en particular es la última acción que debe hacer una procedimiento (o el último valor que debe evaluar una función), entonces el marco de pila del invocador puede ser descartado y reutilizado por el invocado. El marco de pila antiguo no tiene por que conservarse, ya que el estado que guarda el mismo no es utilizado al regresar de la invocación recursiva. 2.1. La Receta: Recursión de Cola → Repetición Si se tiene una subrutina recursiva de cola, la misma puede ser traducida directamente en una versión iterativa de la misma. Para esto, se debe analizar el programa en cuestión. 1. El cuerpo de la función es reemplazado por una repetición indeterminada, en donde el guardia es la disyunción de todas las condiciones que puedan llevar a una llamada recursiva de cola. 2. Las llamadas recursivas de cola serán reemplazadas por asignaciones, en donde al iésimo parámetro formal de la subrutina se le asigna el iésimo argumento de la llamada en cuestión. 3. Las demás vías de cómputo de la subrutina (los casos bases) se moverán al nal de la repetición, justamente saliendo de la misma. Como ejemplo, consideremos un programa que calcula el máximo común divisor de dos números usando el Algoritmo de Euclides. MCD Recursivo de Cola MCD Iterativo int mcd (int a, int b) { while ( b != 0 ) { int t = a; a = b; b = t % b; } return a; } int mcd (int a, int b) { if ( b == 0 ) return a; return mcd(b, a % b); } Nótese que en la transformación propuesta, se creó una variable temporal t para conservar el valor anterior de a en la asignación. En un lenguaje que permita asignación múltiple, se podría reescribir simplemente como: (a, b = b, a % b). Además, el guardia de la repetición es (b != 0), ya que es la condición implícita necesaria para que se ejecute la llamada recursiva de cola. 2 2.2. La Receta: Recursión → Recursión de Cola Convertir un programa recursivo en uno que tenga recursión de cola es altamente no trivial y cada caso se resuelve de una manera distinta. Sin embargo, para funciones que reciben un solo argumento y donde la operación g que evita la recursión de cola es la misma en cada caso, la transformación necesaria para tener recursión de cola es similar. 1. Se dene una subrutina auxiliar con el mismo parámetro que la original, mas un acumulador (del tipo de retorno en el caso de funciones), donde el cuerpo de la función es el mismo de la función original. 2. Las llamadas recursivas serán reemplazadas por llamadas a la subrutina auxiliar, donde la acción g que evitaba la recursión de cola es pasada al acumulador. Esto es: return g(f(args), x) return f_aux(args, g(acum, x)) → 3. Cada caso no recursivo es reemplazado por la operación g aplicado al mismo y el acumulador. Esto es: return x return g(acum, x) → 4. El cuerpo de la subrutina original es substituido por una invocación a la subrutina auxiliar, con el mismo argumento y con el valor neutro de la operación g como acumulador. En el caso de la segunda y tercera transformación debe tenerse cuidado con el orden de los argumentos para g . En el caso de operaciones no conmutativas (como la concatenación de listas) puede ser necesario invertir el orden de los argumentos. Como ejemplo, consideremos un programa que calcula el factorial de un número. Factorial Recursivo Factorial Recursivo de Cola int fact (int n) { return fact(n, 1 ); } int fact (int n) { if (n == 0) return 1 ; return n * fact( n - 1 ); } int fact_aux(int n, int acum) { if (n == 0) return acum ; return fact_aux( n - 1 , n * acum); } Nótese que el caso no recursivo, las reglas planteadas sugerirían tener 1 * acum. Pero siempre se pueden simplicar las expresiones que son triviales (las cuales aparecerán usualmente). 3 Como un segundo ejemplo, consideremos un programa que hace el reverso de una lista. Revertir Recursivo [T] revertir ([T] lista) { if (lista == []) return [] ; return revertir( lista.cola ) ++ [lista.cabeza] ; } Revertir Recursivo de Cola [T] revertir ([T] lista) { return revertir(lista, [] ); } [T] revertir_aux([T] lista, [T] acum) { if (lista == []) return acum ; return revertir_aux( lista.cola , [lista.cabeza] ++ acum); } En este caso, la concatenación debió cambiar el orden de los argumentos al pasar a recursión de cola. ¾Qué pasaría si no se hubiera invertido el orden de la concatenación? 3. Ejericios sugeridos Casi todos los segundos exámenes de la materia, como se ha dado durante los últimos años por el prof. Ernesto HernandezNovich y mi persona, tienen una o más preguntas sobre transformación de programas concernientes a la recursión de cola. Les recomiendo busquen esos parciales e intenten resolverlos usando la receta correspondiente en cada caso. Pueden escribirme si tienen cualquier duda al respecto. Ricardo Monascal / Mayo 2014 4