Ricardo Pérez López
IES Doñana, curso 2024/2025
Los procesos son entidades abstractas que habitan los ordenadores.
Conforme van evolucionando, los procesos manipulan otras entidades abstractas llamadas datos.
La evolución de un proceso está dirigida por un patrón de reglas llamado programa.
Los programadores crean programas para dirigir a los procesos.
Es como decir que los programadores son magos que invocan a los espíritus del ordenador (los procesos) con sus conjuros (los programas) escritos en un lenguaje mágico (el lenguaje de programación).
Una función describe la evolución local de un proceso, es decir, cómo se debe comportar el proceso durante la ejecución de la función.
En cada paso de la ejecución se calcula el siguiente estado del proceso basándonos en el estado actual y en las reglas definidas por la función.
Nos gustaría ser capaces de visualizar y de realizar afirmaciones sobre el comportamiento global del proceso cuya evolución local está definida por la función.
Esto, en general, es muy difícil, pero al menos vamos a describir algunos de los modelos típicos de evolución de los procesos.
Supongamos que queremos diseñar una función llamada permutas
que reciba un número entero
n y que calcule cuántas permutaciones
distintas podemos hacer con n
elementos.
Por ejemplo: si tenemos 3 elementos (digamos, A, B y C), podemos formar con ellos las siguientes permutaciones:
ABC, ACB, BAC, BCA, CAB, CBA
y, por tanto, con 3 elementos podemos formar 6 permutaciones
distintas. En consecuencia, permutas(3)
debe devolver 6
.
La implementación de esa función deberá satisfacer la siguiente especificación:
\left\{\begin{array}{ll} \text{\textbf{Pre}}: & n \geq 0 \\[0.5em] & \texttt{permutas(\(n\):\,int)\;->\;int} \\[0.5em] \text{\textbf{Post}}: & \texttt{permutas(\(n\))} = \text{el número de permutaciones que}\\[0.1em] & \text{podemos formar con \(n\) elementos} \end{array}\right.
Un programador con poca idea de programación (o muy listillo) se podría plantear una implementación parecida a la siguiente:
que se puede escribir mejor usando la barra invertida
(\
) para poder separar una instrucción en varias
líneas:
Pero este algoritmo en realidad es tramposo, porque no calcula nada, sino que se limita a asociar el dato de entrada con el de salida, que se ha tenido que calcular previamente usando otro procedimiento.
Este tipo de algoritmos se denominan algoritmos ad-hoc, y las funciones que los implementan se denominan funciones ad-hoc.
Las funciones ad-hoc no son convenientes porque:
Realmente son tramposos (no calculan nada).
No son útiles, porque al final el cálculo se tiene que hacer con otra cosa.
Generalmente resulta imposible que una función de este tipo abarque todos los posibles datos de entrada, ya que, en principio, puede haber infinitos y, por tanto, su código fuente también tendría que ser infinito.
Usar algoritmos y funciones ad-hoc se penaliza en esta asignatura.
Una función recursiva es aquella que se define en términos de sí misma.
Eso quiere decir que, durante la ejecución de una llamada a la función, se ejecuta otra llamada a la misma función, es decir, que la función se llama a sí misma directa o indirectamente.
La forma más sencilla y habitual de función recursiva es aquella en la que la propia definición de la función contiene una o varias llamadas a ella misma. En tal caso, decimos que la función se llama a sí misma directamente o que hay una recursividad directa.
Ese es el tipo de recursividad que vamos a estudiar.
Las definiciones recursivas son el mecanismo básico para ejecutar repeticiones de instrucciones en un lenguaje de programación funcional.
Por ejemplo: f(n) = n + f(n + 1)
Esta función matemática es recursiva porque aparece ella misma en su propia definición.
Para calcular el valor de f(n) tenemos que volver a utilizar la propia función f.
Por ejemplo: f(1) = 1 + f(2) = 1 + 2 + f(3) = 1 + 2 + 3 + f(4) = \ldots
Cada vez que una función se llama a sí misma decimos que se realiza una llamada recursiva o paso recursivo.
Resulta importante que una definición recursiva se detenga alguna vez y proporcione un resultado, ya que si no, no sería útil (tendríamos lo que se llama una recursión infinita).
Por tanto, en algún momento, la recursión debe alcanzar un punto en el que la función no se llame a sí misma y se detenga.
Para ello, es necesario que la función, en cada paso recursivo, se vaya acercando cada vez más a ese punto.
Ese punto en el que la función recursiva no se llama a sí misma, se denomina caso base, y puede haber más de uno.
Los casos base, por tanto, determinan bajo qué condiciones la función no se llamará a sí misma, o dicho de otra forma, con qué valores de sus argumentos la función devolverá directamente un valor y no provocará una nueva llamada recursiva.
Los demás casos, que sí provocan llamadas recursivas, se denominan casos recursivos.
El ejemplo más típico de función recursiva es el factorial.
El factorial de un número natural n se representa por n! y se define como el producto de todos los números desde 1 hasta n: n! = n\cdot(n-1)\cdot(n-2)\cdot\cdots\cdot1
Por ejemplo: 6! = 6\cdot5\cdot4\cdot3\cdot2\cdot1 = 720
Pero para calcular 6! también se puede calcular 5! y después multiplicar el resultado por 6, ya que: 6! = 6\cdot\overbrace{5\cdot4\cdot3\cdot2\cdot1}^{5!} 6! = 6\cdot5!
Por tanto, el factorial se puede definir de forma recursiva.
Tenemos el caso recursivo, pero necesitamos al menos un caso base para evitar que la recursión se haga infinita.
El caso base del factorial se obtiene sabiendo que el factorial de 0 es directamente 1 (no hay que llamar al factorial recursivamente): 0! = 1
Combinando ambos casos tendríamos:
n! = \begin{cases} 1 & \text{si } n = 0 \text{\quad(caso base)} \\ n\cdot(n-1)! & \text{si } n > 0 \text{\quad(caso recursivo)} \end{cases}
La especificación de una función que calcule el factorial de un número sería:
\left\{\begin{array}{ll} \text{\textbf{Pre}}: & n \geq 0 \\[0.5em] & \texttt{factorial(\(n\):\,int)\;->\;int} \\[0.5em] \text{\textbf{Post}}: & \texttt{factorial(\(n\))} = n! \end{array}\right.
Y su implementación en Python podría ser la siguiente:
que sería prácticamente una traducción literal de la definición recursiva de factorial que acabamos de obtener.
El diseño de funciones recursivas se basa en:
Identificación de casos base
Descomposición (reducción) del problema
Pensamiento optimista
Debemos identificar los ejemplares para los cuales hay una solución directa que no necesita recursividad.
Esos ejemplares representarán los casos base de la función recursiva, y por eso los denominamos ejemplares básicos.
Por ejemplo:
Supongamos que queremos diseñar una función (llamada fact, por ejemplo) que calcule el factorial de un número.
Es decir: fact(n) debe devolver el factorial de n.
Sabemos que 0! = 1, por lo que nuestra función podría devolver directamente 1 cuando se le pida calcular el factorial de 0.
Por tanto, el caso base del factorial es el cálculo del factorial de 0: fact(0) = 1
Reducimos el problema de forma que así tendremos un ejemplar más pequeño del problema.
Un ejemplar más pequeño es aquel que está más cerca del caso base.
De esta forma, cada ejemplar se irá acercando más y más al caso base hasta que finalmente se alcanzará dicho caso base y eso detendrá la recursión.
Es importante comprobar que eso se cumple, es decir, que la reducción que le realizamos al problema produce ejemplares que están más cerca del caso base, porque de lo contrario se produciría una recursión infinita.
En el ejemplo del factorial:
El caso base es fact(0), es decir, el caso en el que queremos calcular el factorial de 0, que ya vimos que es directamente 1 (sin necesidad de llamadas recursivas).
Si queremos resolver el problema de calcular, por ejemplo, el factorial de 5, podríamos intentar reducir el problema a calcular el factorial de 4, que es un número que está más cerca del caso base (que es 0).
A su vez, para calcular el factorial de 4, reduciríamos el problema a calcular el factorial de 3, y así sucesivamente.
De esta forma, podemos reducir el problema de calcular el factorial de n a calcular el factorial de (n - 1), que es un número que está más cerca del 0. Así, cada vez estaremos más cerca del caso base y, al final, siempre lo acabaremos alcanzando.
Consiste en suponer que la función deseada ya existe y que, aunque no sabe resolver el ejemplar original del problema, sí que es capaz de resolver ejemplares más pequeños de ese problema (este paso se denomina hipótesis inductiva o hipótesis de inducción).
Suponiendo que se cumple la hipótesis inductiva, y aprovechando que ya contamos con un método para reducir el ejemplar a uno más pequeño, ahora tratamos de encontrar un patrón común de forma que resolver el ejemplar original implique usar el mismo patrón en un ejemplar más pequeño.
Es decir:
Al reducir el problema, obtenemos un ejemplar más pequeño del mismo problema y, por tanto, podremos usar la función para poder resolver ese ejemplar más pequeño (que sí sabe resolverlo, por hipótesis inductiva).
A continuación, usamos dicha solución parcial para tratar de obtener la solución para el ejemplar original del problema.
En el ejemplo del factorial:
Supongamos que queremos calcular, por ejemplo, el factorial de 6.
Aún no sabemos calcular el factorial de 6, pero suponemos (por hipótesis inductiva) que sí sabemos calcular el factorial de 5.
En ese caso, ¿cómo puedo aprovechar que sé resolver el factorial de 5 para lograr calcular el factorial de 6?
Analizando el problema, observo que se cumple esta propiedad: 6! = 6\cdot\overbrace{5\cdot4\cdot3\cdot2\cdot1}^{5!}=6\cdot 5!
Por tanto, he deducido un método para resolver el problema de calcular el factorial de 6 a partir del factorial de 5: para calcular el factorial de 6 basta con calcular primero el factorial de 5 y luego multiplicar el resultado por 6.
Dicho de otro modo: si yo supiera calcular el factorial de 5, me bastaría con multiplicarlo por 6 para obtener el factorial de 6.
Generalizando para cualquier número, no sólo para el 6:
Si queremos diseñar una función fact(n) que calcule el factorial de n, supondremos que esa función ya existe pero que aún no sabe calcular el factorial de n, aunque sí sabe calcular el factorial de \pmb{(n - 1)}.
Tenemos que creer en que es así y actuar como si fuera así, aunque ahora mismo no sea verdad. Ésta es nuestra hipótesis inductiva.
Por otra parte, sabemos que: n! = n\cdot\overbrace{(n-1)\cdot(n-2)\cdot(n-3)\cdot2\cdot1}^{(n-1)!}=n\cdot(n-1)!
Por tanto, si sabemos calcular el factorial de (n - 1) llamando a fact(n - 1), para calcular fact(n) sólo necesito multiplicar n por el resultado de fact(n - 1).
Resumiendo: si yo supiera calcular el factorial de \pmb{(n - 1)}, me bastaría con multiplicarlo por \pmb{n} para obtener el factorial de \pmb{n}.
Así obtengo el caso recursivo de la función fact, que sería: fact(n) = n\cdot fact(n-1)
fact(n) = \begin{cases} 1 & \text{si } n = 0 \text{\quad(caso base)} \\ n\cdot fact(n-1) & \text{si } n > 0 \text{\quad(caso recursivo)} \end{cases}
Una función tiene recursividad lineal si cada llamada a la función recursiva genera, como mucho, otra llamada recursiva a la misma función.
El factorial definido en el ejemplo anterior es un caso típico de recursividad lineal ya que, cada vez que se llama al factorial se genera, como mucho, otra llamada al factorial.
Eso se aprecia claramente observando que la definición del caso recursivo de la función fact contiene una única llamada a la misma función fact:
fact(n) = n\cdot fact(n-1)\quad \text{si } n > 0\quad \text{(caso recursivo)}
La forma más directa y sencilla de definir una función que calcule el factorial de un número a partir de su definición recursiva podría ser la siguiente:
Utilizaremos el modelo de sustitución para observar el funcionamiento de esta función al calcular 6!:
factorial(6)
= (6 * factorial(5))
= (6 * (5 * factorial(4)))
= (6 * (5 * (4 * factorial(3))))
= (6 * (5 * (4 * (3 * factorial(2)))))
= (6 * (5 * (4 * (3 * (2 * factorial(1))))))
= (6 * (5 * (4 * (3 * (2 * (1 * factorial(0)))))))
= (6 * (5 * (4 * (3 * (2 * (1 * 1))))))
= (6 * (5 * (4 * (3 * (2 * 1)))))
= (6 * (5 * (4 * (3 * 2))))
= (6 * (5 * (4 * 6)))
= (6 * (5 * 24))
= (6 * 120)
= 720
Podemos observar un perfil de expansión seguido de una contracción:
La expansión ocurre conforme el proceso construye una secuencia de operaciones a realizar posteriormente (en este caso, una secuencia de multiplicaciones).
La contracción se realiza conforme se van ejecutando realmente las multiplicaciones.
Llamaremos proceso recursivo a este tipo de proceso caracterizado por una secuencia de operaciones pendientes de completar.
Para poder ejecutar este proceso, el intérprete necesita memorizar, en algún lugar, un registro de las multiplicaciones que se han dejado para más adelante.
En el cálculo de n!, la longitud de la secuencia de operaciones pendientes (y, por tanto, la información que necesita almacenar el intérprete), crece linealmente con n, al igual que el número de pasos de reducción.
A este tipo de procesos lo llamaremos proceso recursivo lineal.
A continuación adoptaremos un enfoque diferente.
Podemos mantener un producto acumulado y un contador desde n hasta 1, de forma que el contador y el producto cambien de un paso al siguiente según la siguiente regla:
\begin{array}{l} acumulador_{nuevo} = acumulador_{viejo} \cdot contador_{viejo} \\[0.5em] contador_{nuevo} = contador_{viejo} - 1 \end{array}
Su traducción a Python podría ser la siguiente, usando una
función auxiliar fact_iter
:
Al igual que antes, usaremos el modelo de sustitución para visualizar el proceso del cálculo de 6!:
Este proceso no tiene expansiones ni contracciones ya que, en
cada instante, toda la información que se necesita almacenar es el valor
actual de los parámetros cont
y
acc
, por lo que el tamaño de la
memoria necesaria es constante.
A este tipo de procesos lo llamaremos proceso iterativo.
El número de pasos necesarios para calcular n! usando esta función crece linealmente con n.
A este tipo de procesos lo llamaremos proceso iterativo lineal.
Tipo de proceso | Número de reducciones | Memoria necesaria |
---|---|---|
Recursivo | Proporcional a \underline{n} | Proporcional a \underline{n} |
Iterativo | Proporcional a \underline{n} | Constante |
* * *
Tipo de proceso | Número de reducciones | Memoria necesaria |
---|---|---|
Recursivo lineal | Linealmente proporcional a \underline{n} |
Linealmente proporcional a \underline{n} |
Iterativo lineal | Linealmente proporcional a \underline{n} |
Constante |
En general, un proceso iterativo es aquel que está definido por una serie de coordenadas de estado junto con una regla fija que describe cómo actualizar dichas coordenadas conforme cambia el proceso de un estado al siguiente.
La diferencia entre los procesos recursivo e iterativo se puede describir de esta otra manera:
En el proceso iterativo, los parámetros dan una descripción completa del estado del proceso en cada instante.
Así, si parásemos el cálculo entre dos pasos, lo único que necesitaríamos hacer para seguir con el cálculo es darle al intérprete el valor de los dos parámetros.
En el proceso recursivo, el intérprete tiene que mantener cierta información oculta que no está almacenada en ningún parámetro y que indica qué operaciones ha realizado hasta ahora y cuáles quedan pendientes por hacer.
No debe confundirse un proceso recursivo con una función recursiva:
Cuando hablamos de función recursiva nos referimos al hecho de que la función se llama a sí misma (directa o indirectamente).
Cuando hablamos de proceso recursivo nos referimos a la forma en como se desenvuelve la ejecución de la función (con una expansión más una contracción).
Puede parecer extraño que digamos que una función recursiva (por
ejemplo, fact_iter
) genera un
proceso iterativo.
Sin embargo, el proceso es realmente iterativo porque su estado está definido completamente por dos parámetros, y para ejecutar el proceso sólo se necesita almacenar el valor de esos dos parámetros.
Aquí hemos visto un ejemplo donde se aprecia claramente que una función sólo puede tener una especificación pero puede tener varias implementaciones distintas.
Eso sí: todas las implementaciones de una función deben satisfacer su especificación.
En este caso, las dos implementaciones son:
y
Y aunque las dos satisfacen la misma especificación (y, por tanto, calculan exactamente los mismos valores), lo hacen de una forma muy diferente, generando incluso procesos de distinto tipo.
Una función tiene recursividad múltiple cuando la misma llamada a la función recursiva puede generar más de una llamada recursiva a la misma función.
El ejemplo clásico es la función que calcula los términos de la sucesión de Fibonacci.
La sucesión comienza con los números 0 y 1, y a partir de éstos, cada término es la suma de los dos anteriores:
0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, …
Podemos definir una función recursiva que devuelva el n-ésimo término de la sucesión de Fibonacci:
fib(n) = \begin{cases} 0 & \text{si } n = 0 \text{\quad (caso base)} \\ 1 & \text{si } n = 1 \text{\quad (caso base)} \\ fib(n - 1) + fib(n - 2) & \text{si } n > 1 \text{\quad (caso recursivo)} \end{cases}
La especificación de una función que devuelva el n-ésimo término de la sucesión de Fibonacci sería:
\left\{\begin{array}{ll} \text{\textbf{Pre}}: & n \geq 0 \\[0.5em] & \texttt{fib(\(n\):\,int)\;->\;int} \\[0.5em] \text{\textbf{Post}}: & \texttt{fib(\(n\))} = \text{el \(n\)-ésimo término de la sucesión de Fibonacci} \end{array}\right.
Y su implementación en Python podría ser:
o bien, separando la definición en varias líneas:
Si vemos el perfil de ejecución de fib(5)
, vemos
que:
Para calcular fib(5)
, antes
debemos calcular fib(4)
y fib(3)
.
Para calcular fib(4)
, antes
debemos calcular fib(3)
y fib(2)
.
Así sucesivamente hasta poner todo en función de fib(0)
y fib(1)
, que se
pueden calcular directamente (son los casos base).
En general, el proceso resultante tiene forma de árbol.
Por eso decimos que las funciones con recursividad múltiple generan procesos recursivos en árbol.
La función anterior es un buen ejemplo de recursión en árbol, pero desde luego es un método horrible para calcular los números de Fibonacci, por la cantidad de operaciones redundantes que efectúa.
Para tener una idea de lo malo que es, se puede observar que fib(n) crece exponencialmente en función de n.
Por lo tanto, el proceso necesita una cantidad de tiempo que crece exponencialmente con n.
Por otro lado, el espacio necesario sólo crece linealmente con n, porque en un cierto momento del cálculo sólo hay que memorizar los nodos que hay por encima.
En general, en un proceso recursivo en árbol el tiempo de ejecución crece con el número de nodos del árbol mientras que el espacio necesario crece con la altura máxima del árbol.
Se puede construir un proceso iterativo para calcular los números de Fibonacci.
La idea consiste en usar dos coordenadas de estado a y b (con valores iniciales 0 y 1, respectivamente) y aplicar repetidamente la siguiente transformación:
\begin{array}{l} a_{nuevo} = b_{viejo} \\[0.5em] b_{nuevo} = b_{viejo} + a_{viejo} \end{array}
Después de n pasos, a y b contendrán fib(n) y fib(n + 1), respectivamente.
En Python sería:
Esta función genera un proceso iterativo lineal, por lo que es mucho más eficiente.
Lo que diferencia al fact_iter
que genera un proceso
iterativo del factorial
que
genera un proceso recursivo, es el hecho de que fact_iter
se llama a sí misma y
devuelve directamente el valor que le ha devuelto su llamada recursiva
sin hacer luego nada más.
En cambio, factorial
tiene que
hacer una multiplicación después de llamarse a sí misma y antes de
terminar de ejecutarse:
Es decir:
fact_iter(cont, acc)
simplemente llama a:
fact_iter(cont - 1, acc * cont)
y luego devuelve directamente el valor que le entrega ésta llamada, sin hacer ninguna otra operación posterior antes de terminar.
En cambio, factorial(n)
hace:
n * factorial(n - 1)
o sea, se llama a sí misma pero el resultado de la llamada recursiva
tiene que multiplicarlo luego por n
antes de devolver el resultado
final.
Por tanto, lo último que hace fact_iter
es llamarse a sí
misma. En cambio, lo último que hace factorial
no es llamarse a sí misma,
porque tiene que hacer más operaciones (en este caso, la multiplicación)
antes de devolver el resultado.
Cuando lo último que hace una función recursiva es llamarse a sí misma y devolver directamente el valor devuelto por esa llamada recursiva, decimos que la función es recursiva final o que tiene recursividad final.
En caso contrario, decimos que la función es recursiva no final o que tiene recursividad no final.
Las funciones recursivas finales generan procesos iterativos.
La función fact_iter
es recursiva final, y por eso
genera un proceso iterativo.
En cambio, la función factorial
es recursiva no
final, y por eso genera un proceso recursivo.
En la práctica, para que un proceso iterativo consuma realmente una cantidad constante de memoria, es necesario que el traductor optimice la recursividad final.
Ese tipo de optimización se denomina tail-call optimization (TCO).
No muchos traductores optimizan la recursividad final.
De hecho, ni el intérprete de Python ni la máquina virtual de Java optimizan la recursividad final.
Por tanto, en estos dos lenguajes, las funciones recursivas finales consumen tanta memoria como las no finales.
La pila de control es una estructura de datos que utiliza el intérprete para llevar la cuenta de las llamadas activas en un determinado momento.
La pila de control es, básicamente, un almacén de marcos.
Cada vez que se hace una nueva llamada a una función, su marco correspondiente se almacena en la cima de la pila sobre los demás marcos que pudiera haber.
Ese marco es el primero de la secuencia de marcos que forman el entorno de la función, que también estarán almacenados en la pila, más abajo.
Los marcos se enlazan entre sí para representar los entornos que actúan en las distintas llamadas activas.
El intérprete almacena en el marco cualquier información que necesite para gestionar las llamadas a funciones, incluyendo:
Las ligaduras entre los parámetros y sus valores (por supuesto).
La ligadura que apunta al valor de retorno de la función.
Cuál es el siguiente marco que le sigue en el entorno.
El punto de retorno, dentro del programa, al que debe devolverse el control cuando finalice la ejecución de la función.
Un marco almacenado en la pila también se denomina registro de activación. Por tanto, también podemos decir que la pila de control almacena registros de activación.
Cada llamada activa está representada por su correspondiente marco en la pila.
En cuanto la llamada finaliza, su marco se saca de la pila y se transfiere el control a la llamada que está inmediatamente debajo (si es que hay alguna).
Del análisis del diagrama del ejemplo anterior se pueden deducir las siguientes conclusiones:
En un momento dado, dentro del ámbito global se ha llamado a la
función uno
, la cual ha llamado a
la función dos
, la cual ha
llamado a la función tres
, la
cual aún no ha terminado de ejecutarse.
El entorno en la función uno
empieza por el marco de uno
, el cual apunta al marco
global.
El entorno en la función dos
empieza por el marco de dos
, el cual apunta al marco
global.
El entorno en la función tres
empieza por el marco de tres
, el cual apunta al marco
global.
Si tenemos ámbitos anidados, los marcos se apuntarán entre sí en el entorno. Por ejemplo:
dos
activadaHemos dicho que habrá un marco por cada nueva llamada que se realice a una función, y que ese marco se mantendrá en la pila hasta que la llamada finalice.
Por tanto, en el caso de una función recursiva, tendremos un marco por cada llamada recursiva.
fact(4)
Los traductores que optimizan la recursividad final lo que hacen es sustituir cada llamada recursiva por la nueva llamada recursiva a la misma función.
De esta forma, el marco que genera cada nueva llamada recursiva no se apila sobre los marcos anteriores en la pila, sino que sustituye al marco de la llamada que la ha llamado a ella.
Por ejemplo, en el siguiente caso:
fact_iter(4, 5)
llama a fact_iter(3, 20)
y devuelve directamente el resultado de ésta.
Es decir: fact_iter(4, 5) == fact_iter(3, 20)
,
así que hacer fact_iter(4, 5)
es lo mismo que hacer fact_iter(3, 20)
.
Por tanto, la llamada a fact_iter(4, 5)
se puede sustituir por la llamada a fact_iter(3, 20)
.
Un intérprete que optimiza la recursividad final no apilaría el marco de la segunda llamada sobre el marco de la primera, sino que el marco de la segunda sustituiría al marco de la primera dentro de la pila.
Así se haría también con las demás llamadas recursivas a fact_iter(2, 60)
,
fact_iter(1, 120)
y fact_iter(0, 120)
.
De este modo, la pila no crecería con cada nueva llamada recursiva.
El paradigma funcional que hemos visto hasta ahora (uno que nos permite definir funciones, componer dichas funciones y aplicar recursividad, junto con el operador ternario condicional) es un lenguaje de programación completo.
Decimos que es Turing completo, lo que significa que puede computar cualquier función que pueda computar una máquina de Turing.
Como las máquinas de Turing son los ordenadores más potentes que podemos construir (ya que describen lo que cualquier ordenador es capaz de hacer), esto significa que nuestro lenguaje puede calcular todo lo que pueda calcular cualquier ordenador.
Un tipo de dato recursivo es aquel que puede definirse en términos de sí mismo.
Un dato recursivo es un dato que pertenece a un tipo recursivo. Por tanto, es un dato que se construye sobre otros datos del mismo tipo.
Como toda estructura recursiva, un tipo de dato recursivo tiene casos base y casos recursivos:
En los casos base, el tipo recursivo se define directamente, sin referirse a sí mismo.
En los casos recursivos, el tipo recursivo se define sobre sí mismo.
La forma más natural de manipular un dato recursivo es usando funciones recursivas.
Las cadenas se pueden considerar tipos
de datos recursivos, ya que podemos decir que toda cadena c
:
o bien es la cadena vacía ''
(caso base),
o bien está formada por dos partes:
El primer carácter de la cadena, al que se
accede mediante c[0]
.
El resto de la cadena (al que se accede mediante
c[1:]
),
que también es una cadena (caso recursivo).
En tal caso, se cumple que c == c[0] + c[1:]
.
Eso significa que podemos acceder al segundo carácter de la
cadena (suponiendo que exista) mediante c[1:][0]
.
Las tuplas (datos de tipo tuple
) son una
generalización de las cadenas.
Una tupla es una secuencia de elementos que no tienen por qué ser caracteres, sino que cada uno de ellos pueden ser de cualquier tipo (números, cadenas, booleanos, …, incluso otras tuplas).
Los literales de tipo tupla se representan enumerando sus elementos separados por comas y encerrados entre paréntesis.
Por ejemplo:
Si sólo tiene un elemento, hay que poner una coma detrás:
Las tuplas también pueden verse como un tipo de datos
recursivo, ya que toda tupla t
:
o bien es la tupla vacía, representada mediante ()
(caso base),
o bien está formada por dos partes:
El primer elemento de la tupla (al que se accede
mediante t[0]
), que
hemos visto que puede ser de cualquier tipo.
El resto de la tupla (al que se accede mediante
t[1:]
),
que también es una tupla (caso recursivo).
Según el ejemplo anterior:
Junto a las operaciones t[0]
y t[1:]
, tenemos
también la operación +
(concatenación), al
igual que ocurre con las cadenas.
Con la concatenación se pueden crear nuevas tuplas a partir de otras tuplas.
Por ejemplo:
Eso significa que, si t
es
una tupla no vacía, se cumple que
t == (t[0],) + t[1:]
.
Esta propiedad es similar (aunque no exactamente igual) a la que se cumple en las cadenas no vacías.
Los rangos (datos de tipo range
) son
valores que representan sencuencias de números
enteros.
Los rangos se crean con la función range
, cuya
signatura es:
\texttt{range(\([\)\(start\):\,int,\(]\)\;\(stop\):\,int\;\([\),\;\(step\):\,int\(]\))\;->\;range}
Cuando se omite start, se entiende que es 0
.
Cuando se omite step, se entiende que es 1
.
El valor de stop no se alcanza nunca.
Cuando start y stop son iguales, representa el rango vacío.
step debe ser siempre distinto de cero.
Cuando start es mayor que stop, el valor de step debería ser negativo. En caso contrario, también representaría el rango vacío.
range(10)
representa la secuencia 0, 1, 2, \ldots,
9.
range(3, 10)
representa la secuencia 3, 4, 5, \ldots,
9.
range(0, 10, 2)
representa la secuencia 0, 2, 4, 6,
8.
range(4, 0, -1)
representa la secuencia 4, 3, 2,
1.
range(3, 3)
representa el rango vacío.
range(4, 3)
también representa el rango vacío.
range
con los
argumentos necesarios para construir el rango:El rango vacío es un valor que no tiene expresión canónica, ya que cualquiera de las siguientes expresiones representan al rango vacío tan bien como cualquier otra:
range(0)
.
range
(
a,
\;a)
, donde a es
cualquier entero.
range
(
a,
\;b,
\;c)
, donde a \geq b y c >
0.
range
(
a,
\;b,
\;c)
, donde a \leq b y c <
0.
Los rangos también pueden verse como un tipo de datos
recursivo, ya que todo rango r
:
o bien es el rango vacío (caso base),
o bien está formado por dos partes:
El primer elemento del rango (al que se accede
mediante r[0]
), que
hemos visto que tiene que ser un número entero.
El resto del rango (al que se accede mediante
r[1:]
),
que también es un rango (caso recursivo).
Según el ejemplo anterior:
tuple
:>>> tuple(range(10))
(0, 1, 2, 3, 4, 5, 6, 7, 8, 9)
>>> tuple(range(1, 11))
(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
>>> tuple(range(0, 30, 5))
(0, 5, 10, 15, 20, 25)
>>> tuple(range(0, 10, 3))
(0, 3, 6, 9)
>>> tuple(range(0, -10, -1))
(0, -1, -2, -3, -4, -5, -6, -7, -8, -9)
>>> tuple(range(0))
()
>>> tuple(range(1, 0))
()
Sabemos que, en programación funcional, las funciones también son valores.
Por tanto, como pasa con cualquier otro valor, las funciones también tienen un tipo, se pueden ligar a identificadores, etcétera.
Pero si las funciones son valores, eso significa que también se pueden pasar como argumentos a otras funciones o se pueden devolver como resultado de otras funciones.
Una función de orden superior es una función que recibe funciones como argumentos o devuelve funciones como resultado.
Por ejemplo, la siguiente función recibe otra función como argumento y devuelve el resultado de aplicar dicha función al número 5:
No hace falta crear las funciones cuadrado
y cubo
para pasárselas a la función aplica5
como argumento. Se pueden pasar
directamente las expresiones lambda, que también son funciones:
Naturalmente, la función que se pasa a aplica5
debe recibir un único argumento
de tipo numérico.
También se puede devolver una función como resultado.
Por ejemplo, la siguiente función suma_o_resta
recibe una cadena y
devuelve una función que suma si la cadena es 'suma'
;
en caso contrario, devuelve una función que resta:
>>> suma_o_resta = lambda s: (lambda x, y: x + y) if s == 'suma' else \
(lambda x, y: x - y)
>>> suma_o_resta('suma')
<function <lambda>.<locals>.<lambda> at 0x7f526ab4a790>
>>> suma = suma_o_resta('suma')
>>> suma(2, 3)
5
>>> resta = suma_o_resta('resta')
>>> resta(4, 3)
1
>>> suma_o_resta('suma')(6, 4)
10
Tanto aplica5
como suma_o_resta
son funciones de
orden superior.
Una función es una abstracción porque agrupa lo que tienen en común determinados casos particulares que siguen el mismo patrón.
El mismo concepto se puede aplicar a casos particulares de funciones, y al hacerlo damos un paso más en nuestro camino hacia un mayor grado de abstracción.
Es decir: muchas veces observamos el mismo patrón en funciones diferentes.
Para poder abstraer, de nuevo, lo que tienen en común dichas funciones, deberíamos ser capaces de manejar funciones que acepten a otras funciones como argumentos o que devuelvan otra función como resultado (es decir, funciones de orden superior).
Supongamos las dos funciones siguientes:
Estas dos funciones comparten claramente un patrón común. Se diferencian solamente en:
El nombre de la función.
La función que se aplica a a
para calcular cada término
de la suma.
Podríamos haber escrito las funciones anteriores rellenando los «casilleros» del siguiente patrón general:
lambda
a, b: 0
if
a > b
else
⟨término⟩(a) + ⟨nombre⟩(a + 1, b)La existencia de este patrón común nos demuestra que hay una abstracción esperando que la saquemos a la superficie.
De hecho, los matemáticos han identificado hace mucho tiempo esta abstracción llamándola sumatorio de una serie, y la expresan así: \sum _ {n=a}^b f(n)
La ventaja que tiene usar la notación anterior es que podemos trabajar directamente con el concepto de sumatorio en vez de trabajar con sumas concretas, y podemos sacar conclusiones generales sobre los sumatorios independientemente de la serie particular con la que estemos trabajando.
Igualmente, como programadores estamos interesados en que nuestro lenguaje tenga la suficiente potencia como para describir directamente el concepto de sumatorio, en vez de funciones particulares que calculen sumas concretas.
En programación funcional lo conseguimos creando funciones que conviertan los «casilleros» en parámetros que recibirían funciones:
De esta forma, las dos funciones suma_enteros
y suma_cubos
anteriores se podrían
definir en términos de esta suma
:
suma
es una abstracción que captura el patrón común
que comparten suma_enteros
y suma_cubos
, las
cuales también son abstracciones que capturan sus respectivos patrones
comunes.
suma
?map
Una forma de hacerlo sería:
¿Y elevar a la cuarta potencia?
Es evidente que hay un patrón subyacente que se podría abstraer
creando una función de orden superior que aplique una función f
a los elementos de una tupla y
devuelva la tupla resultante.
Esa función se llama map
, y viene
definida en Python con la siguiente signatura:
\texttt{map(\(func\),\;\(iterable\))\;->\;Iterator}
donde:
func debe ser una función de un solo argumento.
iterable puede ser cualquier cosa compuesta de elementos que se puedan recorrer de uno en uno, como una tupla, una cadena o un rango (cualquier secuencia de elementos nos vale).
Podemos usarla así:
Lo que devuelve no es una tupla, sino un objeto iterador que examinaremos con más detalle más adelante.
Por ahora, nos basta con saber que un iterador es un flujo de datos que se pueden recorrer de uno en uno.
Lo que haremos aquí será transformar ese iterador en la tupla
correspondiente usando la función tuple
sobre el
resultado de map
:
Además de una tupla, también podemos usar un rango como argumento
para map
:
¿Cómo definirías la función map
de forma
que devolviera una tupla?
Podríamos definirla así:
filter
filter
es una
función de orden superior que devuelve aquellos
elementos de una tupla (o cualquier cosa iterable) que cumplen
una determinada condición.
Su signatura es:
\texttt{filter(\(function\),\;\(iterable\))\;->\;Iterator}
donde function debe ser una función de un solo argumento que devuelva un booleano.
Como map
, también
devuelve un iterador, que se puede convertir a tupla con la
función tuple
.
Por ejemplo:
reduce
reduce
es una
función de orden superior que aplica, de forma
acumulativa, una función a todos los elementos de una tupla (en
general, a cualquier objeto iterable).
Captura un patrón muy frecuente de recursión sobre secuencias.
Por ejemplo, para calcular la suma de todos los elementos de una tupla, haríamos:
Y para calcular el producto:
Como podemos observar, la estrategia de cálculo es esencialmente
la misma; sólo se diferencian en la operación a realizar
(+
o *
) y en el valor inicial o
elemento neutro (0
o 1
).
Si abstraemos ese patrón común, podemos crear una función de orden superior que capture la idea de reducir todos los elementos de una tupla (o cualquier iterable) a un único valor.
Eso es lo que hace la función reduce
.
Su signatura es:
\texttt{reduce(\(function\),\;\(sequence\)\;\([\),\;\(initial\)\(]\))\;->\;Any}
donde:
function debe ser una función que reciba dos argumentos.
sequence debe ser cualquier objeto iterable (normalmente, una secuencia como una cadena, una tupla o un rango).
initial, si se indica, se usará como primer elemento sobre el que realizar el cálculo y servirá como valor por defecto cuando la secuencia esté vacía (si no se indica y la secuencia está vacía, generará un error).
Para usarla, primero tenemos que importarla del
módulo functools
:
No es la primera vez que importamos un módulo. Ya lo hicimos con
el módulo math
.
En su momento estudiaremos con detalle qué son los módulos. Por ahora nos basta con lo que ya sabemos: que contienen definiciones que podemos incorporar a nuestros scripts.
Por ejemplo, para calcular la suma y el producto de (1, 2, 3, 4)
,
podemos definir las funciones suma_de_numeros
y
producto_de_numeros
a partir de reduce
:
También podemos importar y usar las funciones add
y
mul
del módulo operator
, las cuales actúan,
respectivamente, como el operador +
y *
:
from functools import reduce
from operator import add, mul
tupla = (1, 2, 3, 4)
suma_de_numeros = lambda tupla: reduce(add, tupla, 0)
producto_de_numeros = lambda tupla: reduce(mul, tupla, 1)
De esta forma, usamos add
y mul
en lugar de
las expresiones lambda (lambda x, y: x + y)
y (lambda x, y: x * y)
,
respectivamente.
En general, si iterable representa un objeto iterable que contiene los elementos e_1, e_2, \ldots, e_n (en este orden), entonces tenemos que: \texttt{reduce(\(f\),\;\(iterable\),\;\(ini\))} = f(\ldots{}f(f(f(ini, e_1), e_2), e_3), \ldots, e_n)
Por ejemplo, la siguiente llamada a reduce
:
realiza y devuelve el resultado del siguiente cálculo:
lo que, en la práctica, equivale a:
Si iterable representa un iterable vacío, entonces:
\texttt{reduce(\(f\),\;\(iterable\),\;\(ini\))} = ini
Por ejemplo:
devuelve directamente 0
.
Si no se indica un valor inicial, tenemos que: \texttt{reduce(\(f\),\;\((e_1, e_2, \ldots, e_n)\))} = f(\ldots{}f(f(e_1, e_2), e_3), \ldots, e_n)
Es decir: se usará el primer elemento del iterable como valor inicial.
Por ejemplo, la siguiente llamada a reduce
:
realiza y devuelve el resultado del siguiente cálculo:
lo que, en la práctica, equivale a:
Pero si el iterable es vacío, dará un error:
Con lo que acabamos de ver, se demuestra que la implementación de
la función reduce
en Python va reduciendo de
izquierda a derecha y que, por tanto, las operaciones se hacen
agrupándose por la izquierda.
Esto es algo que debemos tener muy en cuenta a la hora de diseñar
la función que se le pasa a reduce
.
Se denomina iteración a cada paso que da la
función reduce
, es decir, cada vez que reduce
visita un nuevo elemento del iterable (la tupla, cadena o lo que sea) y
aplica la función para calcular el resultado parcial.
Esa función, como ya dijimos antes, debe tener dos parámetros, pero de forma que, en cada iteración:
Su primer parámetro va a contener siempre el valor parcial acumulado hasta ahora (por tanto, es un acumulador).
Su segundo parámetro va a contener el valor del elemento que en
este momento está visitando reduce
.
Por tanto, es frecuente que el primer parámetro de esa función se
llame acc
o algo similar, para expresar el hecho de que ahí
se va recibiendo el valor acumulado hasta el momento.
Por ejemplo, en la siguiente llamada:
acc
va a contener la suma parcial acumulada hasta
ahora.
e
va a contener el elemento que en este momento se
está visitando.
Así, durante la ejecución del reduce
, ésta provocará las
siguientes llamadas a la expresión lambda:
reduce
si
recibiera una tupla y no cualquier iterable?Una forma (con valor inicial obligatorio) podría ser así:
Dos operaciones que se realizan con frecuencia sobre una estructura iterable son:
Realizar alguna operación sobre cada elemento (map
).
Seleccionar un subconjunto de elementos que cumplan alguna
condición (filter
).
Las expresiones generadoras son una notación copiada del lenguaje Haskell que nos permite realizar ambas operaciones de una forma muy concisa.
El resultado que devuelve es un iterador que (como ya sabemos)
podemos convertir fácilmente en una tupla usando la función tuple
.
Su sintaxis es:
(
⟨expresión⟩
(for
⟨identificador⟩
in
⟨secuencia⟩
[if
⟨condición⟩])^+)
Los elementos de la salida generada serán los sucesivos valores de ⟨expresión⟩.
Las cláusulas if
son opcionales. Si
están, la ⟨expresión⟩ sólo se
evaluará y añadirá al resultado cuando se cumpla la ⟨condición⟩.
Los paréntesis (
y )
alrededor de la
expresión generadora se pueden quitar si la expresión se usa como único
argumento de una función.
Por ejemplo:
Las expresiones generadoras, al igual que las expresiones lambda, determinan su propio ámbito.
Ese ámbito abarca toda la expresión generadora, de principio a fin.
Los identificadores que aparecen en la cláusula for
se
consideran variables ligadas en la expresión
generadora.
Esos identificadores se van ligando, uno a uno, a cada elemento
de la secuencia indicada en la cláusula in
.
Como son variables ligadas, cumplen estas dos propiedades:
Se pueden renombrar (siempre de forma consistente) sin que la expresión cambie su significado.
Por ejemplo, las dos expresiones generadoras siguientes son equivalentes, puesto que producen el mismo resultado:
No se pueden usar fuera de la expresión generadora, ya que estarían fuera de su ámbito y no serían visibles.
Por ejemplo, lo siguiente daría un error de nombre: