3  Funciones en Python

Objetivo

Aprender a definir y utilizar funciones en Python, incluyendo el manejo de argumentos por posición y por nombre, el uso de *args y *kwargs, la creación de funciones lambda, la implementación de recursividad y la documentación de funciones con docstrings.

Anteriormente hemos usado funciones nativas que vienen con Python como len() para calcular la longitud de una lista, pero al igual que en otros lenguajes de programación, también podemos definir nuestras propias funciones. Para ello hacemos uso de def.

def nombre_funcion(argumentos):
    código
    return retorno

Cualquier función tendrá un nombre, unos argumentos de entrada, un código a ejecutar y unos parámetros de salida. Al igual que las funciones matemáticas, en programación nos permiten realizar diferentes operaciones con la entrada, para entregar una determinada salida que dependerá del código que escribamos dentro. Por lo tanto, es totalmente análogo al clásico \(y=f(x)\) de las matemáticas.

def f(x):
    return 2*x
y = f(3)
print(y)

Algo que diferencia en cierto modo las funciones en el mundo de la programación, es que no sólo realizan una operación con sus entradas, sino que también parten de los siguientes principios:

  • El principio de reusabilidad, que nos dice que si por ejemplo tenemos un fragmento de código usado en muchos sitios, la mejor solución sería pasarlo a una función. Esto nos evitaría tener código repetido, y que modificarlo fuera más fácil, ya que bastaría con cambiar la función una vez.
  • Y el principio de modularidad, que defiende que en vez de escribir largos trozos de código, es mejor crear módulos o funciones que agrupen ciertos fragmentos de código en funcionalidades específicas, haciendo que el código resultante sea más fácil de leer.

3.1 Manejo de argumentos de entrada

Empecemos por la función más sencilla de todas. Una función sin parámetros de entrada ni parámetros de salida.

def di_hola():
    print("Hola")

Hemos declarado o definido la función. El siguiente paso es llamarla con di_hola(). Si lo realizamos veremos que se imprime Hola.

di_hola()

Vamos a complicar un poco las cosas pasando un argumento de entrada. Ahora si pasamos como entrada un nombre, se imprimirá Hola y el nombre.

def di_hola(nombre):
    print("Hola", nombre)
di_hola("Juan")

Python permite pasar argumentos también de otras formas. A continuación las explicamos todas.

3.1.1 Argumentos por posición

Los argumentos por posición o posicionales son la forma más básica e intuitiva de pasar parámetros. Si tenemos una función resta() que acepta dos parámetros, se puede llamar como se muestra a continuación.

def resta(a, b):
    return a-b
resta(5, 3)

Al tratarse de parámetros posicionales, se interpretará que el primer número es la a y el segundo la b. El número de parámetros es fijo, por lo que si intentamos llamar a la función con solo uno, dará error.

resta(1)

Tampoco es posible usar mas argumentos de los tiene la función definidos, ya que no sabría que hacer con ellos. Por lo tanto si lo intentamos, Python nos dirá que toma 2 posicionales y estamos pasando 3, lo que no es posible.

resta(5,4,3)

3.1.2 Argumentos por nombre

Otra forma de llamar a una función, es usando el nombre del argumento con = y su valor. El siguiente código hace lo mismo que el código anterior, con la diferencia de que los argumentos no son posicionales.

resta(a=3, b=5)

Al indicar en la llamada a la función el nombre de la variable y el valor, el orden ya no importa, y se podría llamar de la siguiente forma.

resta(b=5, a=3)

Como es de esperar, si indicamos un argumento que no ha sido definido como parámetro de entrada, tendremos un error.

resta(b=5, c=3)

3.1.3 Argumentos por defecto

Tal vez queramos tener una función con algún parámetro opcional, que pueda ser usado o no dependiendo de diferentes circunstancias. Para ello, lo que podemos hacer es asignar un valor por defecto a la función. En el siguiente caso c valdría cero salvo que se indique lo contrario.

def suma(a, b, c=0):
    return a+b+c
suma(5,5,3)

Dado que el parámetro c tiene un valor por defecto, la función puede ser llamada sin ese valor.

suma(4,3)

Podemos incluso asignar un valor por defecto a todos los parámetros, por lo que se podría llamar a la función sin ningún argumento de entrada.

def suma(a=3, b=5, c=0):
    return a+b+c
suma()

Las siguientes llamadas a la función también son válidas

suma(1)
suma(4,5)
suma(5,3,2)

O haciendo uso de lo que hemos visto antes y usando los nombres de los argumentos.

3.2 Argumentos de longitud variable (*args y *kwargs)

Si alguna vez has tenido que definir una función con un número variable de argumentos y no has sabido como hacerlo, a continuación te explicamos cómo gracias a los args y kwargs en Python.

Vamos a suponer que queremos una función que sume un conjunto de números, pero no sabemos a priori la cantidad de números que se quieren sumar. Si por ejemplo tuviéramos tres, la función sería tan sencilla como la siguiente.

def suma(a, b, c):
    return a+b+c

suma(2, 4, 6)

El problema surge si por ejemplo queremos sumar cuatro números. Como es evidente, la siguiente llamada a la función anterior daría un error ya que estamos usando cuatro argumentos mientras que la función sólo soporta tres.

suma(2, 4, 6, 1)

Introducida ya la problemática, veamos como podemos resolver este problema con *args y **kwargs en Python.

3.2.1 Uso de *args

Gracias a los *args en Python, podemos definir funciones cuyo número de argumentos es variable. Es decir, podemos definir funciones genéricas que no aceptan un número determinado de parámetros, sino que se “adaptan” al número de argumentos con los que son llamados.

De hecho, el args viene de arguments en Inglés, o argumentos. Haciendo uso de *args en la declaración de la función podemos hacer que el número de parámetros que acepte sea variable.

def suma(*args):
    s = 0
    for arg in args:
        s += arg
    return s

print(suma(1, 3, 4, 2))

print(suma(1, 1))

Antes de nada, el uso del nombre args es totalmente arbitrario, por lo que podrías haberlo llamado como quisieras. Es una mera convención entre los usuarios de Python y resulta frecuente darle ese nombre. Lo que si es un requisito, es usar * junto al nombre.

En el ejemplo anterior hemos visto como *args puede ser iterado, ya que en realidad es una tupla. Por lo tanto iterando la tupla podemos acceder a todos los argumentos de entrada, y en nuestro caso sumarlos y devolverlos.

Nótese que es un mero ejemplo didáctico. En realidad podríamos hacer algo como lo siguiente, lo que sería mucho más sencillo.

def suma(*args):
    return sum(args)

suma(5, 5, 3)

Con esto resolvemos nuestro problema inicial, en el que necesitábamos un número variable de argumentos. Sin embargo, hay otra forma que nos proporciona además un nombre asociado al argumento, con el uso de **kwargs. La explicamos a continuación.

3.2.2 Uso de *kwargs

Al igual que en *args, en **kwargs el nombre es una mera convención entre los usuarios de Python. Puedes usar cualquier otro nombre siempre y cuando respetes el **.

En este caso, en vez de tener una tupla tenemos un diccionario. Puedes verificarlo de la siguiente forma con type().

def suma(**kwargs):
    print(type(kwargs))
    
suma(x = 3)

Pero veamos un ejemplo más completo. A diferencia de *args, los **kwargs nos permiten dar un nombre a cada argumento de entrada, pudiendo acceder a ellos dentro de la función a través de un diccionario.

def suma(**kwargs):
    s = 0
    for key, value in kwargs.items():
        print(key, "=", value)
        s += value
    return s
    
suma(a=3, b=10, c=3)

Como podemos ver, es posible iterar los argumentos de entrada con items(), y podemos acceder a la clave key y el valor o value de cada argumento.

El uso de los **kwargs es muy útil si además de querer acceder al valor de las variables dentro de la función, quieres darles un nombre que brinde una información extra.

3.2.3 Uso combinado de *args y *kwargs

Una vez entendemos el uso de *args y **kwargs, podemos complicar las cosas un poco más. Es posible mezclar argumentos normales con *args y **kwargs dentro de la misma función. Lo único que necesitas saber es que debes definir la función en el siguiente orden:

  • Primero argumentos por posición.
  • Luego, los argumentos por defecto.
  • Después los *args.
  • Y por último los **kwargs.

Veamos un ejemplo.

def funcion(a, b = 0, *args, **kwargs):
    print("a =", a)
    print("b =", b)
    for arg in args:
        print("args =", arg)
    for key, value in kwargs.items():
        print(key, "=", value)

funcion(10, 35, 1, 2, 3, 4, x="Hola", y="Que", z="Tal")

Y por último un truco que no podemos dejar sin mencionar es lo que se conoce como tuple unpacking. Haciendo uso de *, podemos extraer los valores de una lista o tupla y que sean pasados como argumentos a la función.

def funcion(a, b, *args, **kwargs):
    print("a =", a)
    print("b =", b)
    for arg in args:
        print("args =", arg)
    for key, value in kwargs.items():
        print(key, "=", value)

args = [1, 2, 3, 4]
kwargs = {'x':"Hola", 'y':"Que", 'z':"Tal"}

funcion(10, 20, *args, **kwargs)

3.3 Sentencia return

El uso de la sentencia return permite realizar dos cosas:

  • Salir de la función y transferir la ejecución de vuelta a donde se realizó la llamada.
  • Devolver uno o varios parámetros, fruto de la ejecución de la función.

En lo relativo a lo primero, una vez se llama a return se para la ejecución de la función y se vuelve o retorna al punto donde fue llamada. Es por ello por lo que el código que va después del return no es ejecutado en el siguiente ejemplo.

def mi_funcion():
    print("Entra en mi_funcion")
    return
    print("No llega")
mi_funcion()

Por ello, sólo llamamos a return una vez hemos acabado de hacer lo que teníamos que hacer en la función.

Por otro lado, se pueden devolver parámetros. Normalmente las funciones son llamadas para realizar unos cálculos con base en una entrada, por lo que es interesante poder devolver ese resultado a quien llamó a la función.

def di_hola():
    return "Hola"
di_hola()

También es posible devolver mas de una variable, separadas por ,. En el siguiente ejemplo tenemos una función que calcula la suma y media de tres números, y devuelve su resultado.

def suma_y_media(a, b, c):
    suma = a+b+c
    media = suma/3
    return suma, media
suma, media = suma_y_media(9, 6, 3)
print(suma)
print(media)

3.4 Documentación

Ahora que ya tenemos nuestras propias funciones creadas, tal vez alguien se interese en ellas y podamos compartírselas. Las funciones pueden ser muy complejas y leer código ajeno no es tarea fácil. Es por ello por lo que es importante documentar las funciones. Es decir, añadir comentarios para indicar como deben ser usadas.

def mi_funcion_suma(a, b):
    '''
    Descripción de la función. Como debe ser usada,
    que parámetros acepta y que devuelve
    '''
    return a+b

Para ello debemos usar la triple comilla """ o triple apóstrofo ''' al principio de la función. Se trata de una especie de comentario que podemos usar para indicar como la función debe ser usada. No se trata de código, es un simple comentario un tanto especial, conocido como docstring.

Ahora cualquier persona que tenga nuestra función, podrá llamar a la función help() y obtener la ayuda de como debe ser usada.

help(mi_funcion_suma)

Otra forma de acceder a la documentación es la siguiente.

print(mi_funcion_suma.__doc__)

Para saber más: Las descripciones de las funciones suelen ser un poco mas detalladas de lo que hemos mostrado. En la PEP257 se define en detalle como debería ser.

3.5 Paso por valor y referencia

En muchos lenguajes de programación existen los conceptos de paso por valor y por referencia que aplican a la hora de como trata una función a los parámetros que se le pasan como entrada. Su comportamiento es el siguiente:

  • Si usamos un parámetro pasado por valor, se creará una copia local de la variable, lo que implica que cualquier modificación sobre la misma no tendrá efecto sobre la original.
  • Con una variable pasada como referencia, se actuará directamente sobre la variable pasada, por lo que las modificaciones afectarán a la variable original.

En Python las cosas son un poco distintas y el comportamiento estará definido por el tipo de variable con la que estamos tratando. Veamos un ejemplo de paso por valor.

x = 10
def funcion(entrada):
    entrada = 0
funcion(x)

print(x)

Iniciamos la x a 10 y se la pasamos a funcion(). Dentro de la función hacemos que la variable valga 0. Dado que Python trata a los int como pasados por valor, dentro de la función se crea una copia local de x, por lo que la variable original no es modificada.

No pasa lo mismo si por ejemplo x es una lista como en el siguiente ejemplo. En este caso Python lo trata como si estuviese pasada por referencia, lo que hace que se modifique la variable original. La variable original x ha sido modificada.

x = [10, 20, 30]
def funcion(entrada):
    entrada.append(40)

funcion(x)
print(x)

El ejemplo anterior nos podría llevar a pensar que si en vez de añadir un elemento a x, hacemos x = [], estaríamos destruyendo la lista original. Sin embargo esto no es cierto.

x = [10, 20, 30]
def funcion(entrada):
    entrada = []

funcion(x)
print(x)

Una forma muy útil de saber lo que pasa por debajo de Python, es haciendo uso de la función id(). Esta función nos devuelve un identificador único para cada objeto. Volviendo al primer ejemplo podemos ver como los objetos a los que “apuntan” x y entrada son distintos.

x = 10
print(id(x))
def funcion(entrada):
    entrada = 0
    print(id(entrada))

funcion(x)

Sin embargo si hacemos lo mismo cuando la variable de entrada es una lista, podemos ver que en este caso el objeto con el que se trabaja dentro de la función es el mismo que tenemos fuera. Y al acceder al identificador despues de hacer la asignación entrada = [] podemos notar que se crea un nuevo objeto con identificador diferente al original.

x = [10, 20, 30]
print(id(x))
def funcion(entrada):
    entrada.append(40)
    print(id(entrada))
    entrada = []
    print(id(entrada))

funcion(x)
print(x)

3.6 Funciones lambda

Las funciones lambda o anónimas son un tipo de funciones en Python que típicamente se definen en una línea y cuyo código a ejecutar suele ser pequeño. Resulta complicado explicar las diferencias y para que te hagas una idea de ello te dejamos con la siguiente cita sacada de la documentación oficial.

Python lambdas are only a shorthand notation if you’re too lazy to define a function.

Lo que significa algo así como, “las funciones lambda son simplemente una versión acortada, que puedes usar si te da pereza escribir una función”

Lo que sería una función que suma dos números como la siguiente.

def suma(a, b):
    return a+b

Se podría expresar en forma de una función lambda de la siguiente manera.

lambda a, b : a + b

La primera diferencia es que una función lambda no tiene un nombre y por lo tanto, salvo que sea asignada a una variable, es totalmente inútil. Para ello debemos.

suma = lambda a, b: a + b
suma(3,5)

Si es una función que solo queremos usar una vez, tal vez no tenga sentido almacenarla en una variable. Es posible declarar la función y llamarla en la misma línea.

(lambda a, b: a + b)(2, 4)

Una función lambda puede ser la entrada a una función normal.

def mi_funcion(lambda_func):
    return lambda_func(2,4)

mi_funcion(lambda a, b: a + b)

Y una función normal también puede ser la entrada de una función lambda. Nótese que son ejemplo didácticos y sin demasiada utilidad práctica per se.

def mi_otra_funcion(a, b):
    return a + b

(lambda a, b: mi_otra_funcion(a, b))(2, 4)

Además, se puede usar una función lambda como parámetro de retorno de una función normal.

def myfunc(n):
  return lambda a : a * n

mydoubler = myfunc(2)

mydoubler(11)

A pesar de que las funciones lambda tienen muchas limitaciones frente a las funciones normales, comparten gran cantidad de funcionalidades. Es posible tener argumentos con valor asignado por defecto.

(lambda a, b, c=3: a + b + c)(1, 2)

También se pueden pasar los parámetros indicando su nombre.

(lambda a, b, c: a + b + c)(a=1, b=2, c=3)

Al igual que en las funciones se puede tener un número variable de argumentos haciendo uso de * y user tuple unpacking.

(lambda *args: sum(args))(1, 2, 3)

Y si tenemos los parámetros de entrada almacenados en forma de key y value como si fuera un diccionario, también es posible llamar a la función.

(lambda **kwargs: sum(kwargs.values()))(a=1, b=2, c=3)

Por último, es posible devolver más de un valor.

x = lambda a, b: (b, a)
print(x(3, 9))

3.7 Recursividad

¿En qué trabajas?

Estoy intentando arreglar los problemas que creé cuando intentaba arreglar los problemas que creé cuando intentaba arreglar los problemas que creé. Y así nació la recursividad.

La recursividad o recursión es un concepto que proviene de las matemáticas y que, aplicado al mundo de la programación, nos permite resolver problemas o tareas donde las mismas pueden ser divididas en subtareas cuya funcionalidad es la misma. Dado que los subproblemas a resolver son de la misma naturaleza, se puede usar la misma función para resolverlos. Dicho de otra manera, una función recursiva es aquella que está definida en función de sí misma, por lo que se llama repetidamente a sí misma hasta llegar a un punto de salida.

Cualquier función recursiva tiene dos secciones de código claramente divididas:

  • Por un lado tenemos la sección en la que la función se llama a sí misma.
  • Por otro lado, tiene que existir siempre una condición en la que la función retorna sin volver a llamarse. Es muy importante porque de lo contrario, la función se llamaría de manera indefinida.

Veamos unos ejemplos con el factorial y la serie de fibonacci.

3.7.1 Cálculo del factorial

Uno de los ejemplos mas usados para entender la recursividad, es el cálculo del factorial de un número \(n!\). El factorial de un número \(n\) se define como la multiplicación de todos sus números predecesores hasta llegar a uno. Por lo tanto \(5!\), leído como cinco factorial, sería \(5 \times 4 \times 3 \times 2 \times 1\). Además, por definición \(0! = 1\).

Utilizando un enfoque tradicional no recursivo, podríamos calcular el factorial de la siguiente manera.

def factorial_normal(n):
    r = 1
    i = 2
    while i <= n:
        r *= i
        i += 1
    return r

factorial_normal(5)

Dado que el factorial es una tarea que puede ser dividida en subtareas del mismo tipo (multiplicaciones), podemos darle un enfoque recursivo y escribir la función de otra manera.

def factorial_recursivo(n):
    
    # Validación del argumento
    if n < 0:
        raise ValueError("El número debe ser entero no negativo")
    elif not isinstance(n, int):
        raise TypeError("El número debe ser entero")
    
    if n == 1 or n == 0:
        return 1
    else:
        return n * factorial_recursivo(n-1)

factorial_recursivo(10)

Lo que realmente hacemos con el código anterior es llamar a la función factorial_recursivo() múltiples veces. Es decir, 5! es igual a 5 * 4! y 4! es igual a 4 * 3! y así sucesivamente hasta llegar a 1.

Algo muy importante a tener en cuenta es que si realizamos demasiadas llamadas a la función, podríamos llegar a tener un error del tipo RecursionError. Esto se debe a que todas las llamadas van apilándose y creando un contexto de ejecución, algo que podría llegar a causar un stack overflow. Es por eso por lo que Python lanza ese error, para protegernos de llegar a ese punto.

3.7.2 Serie de Fibonacci

Otro ejemplo muy usado para ilustrar las posibilidades de la recursividad, es calcular la serie de fibonacci. Dicha serie calcula como \[a_n=a_{n-1}+a_{n-2}\]

Se supone que los dos primeros elementos son \(a_0=0\) y \(a_1=1\).

def fibonacci_recursivo(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fibonacci_recursivo(n-1) + fibonacci_recursivo(n-2)

Podemos ver que siempre que \(n \neq {0,1}\) se llamará recursivamente a la función, buscando los dos elementos anteriores. Cuando \(n = {0,1}\) se dejará de llamar a la función y se devolverá un valor concreto. Podemos calcular el elemento 7, que será \(0,1,1,2,3,5,8,13\), es decir, 13.

fibonacci_recursivo(7)

3.8 Ejercicios prácticos

  1. Cree un nuevo Notebook.
  2. Guarde el archivo como Ejercicios_practicos_clase_3.ipynb.
  3. Asigne un título H1 con su nombre.

3.8.1 Ejercicio práctico 1

  1. Escribe una función generar_historial_temperaturas que acepte dos parámetros: num_dias (número de días) y **kwargs (opciones adicionales).
  2. La función debe generar una lista de temperaturas aleatorias para los días especificados.
  3. Permite al usuario especificar a través de **kwargs el rango de temperaturas (min_temp y max_temp). En caso de no especificarse min_temp, se debe usar un valor por defecto de 0 y para max_temp un valor por defecto de 40.
  4. Utiliza una función lambda para calcular la media de las temperaturas generadas.
  5. Devuelve un diccionario con el historial de temperaturas y la media calculada.

3.8.2 Ejercicio práctico 2

  1. Escribe una función recursiva fibonacci que acepte un número entero \(n\) y devuelva el \(n\)-ésimo número en la secuencia de Fibonacci.
  2. Escribe una función generar_fibonacci que acepte un número entero \(n\) y use una función lambda para generar una lista con los primeros \(n\) números de la secuencia de Fibonacci.
  3. Documenta ambas funciones utilizando docstring.