Saltar a contenido

1.8 · Funciones

Objetivos. Al terminar este capítulo podrás: definir, llamar y documentar funciones con parámetros, valores por defecto y retorno; reconocer *args/**kwargs, lambda y los ámbitos de las variables; y comprender, como ampliación, el funcionamiento de decoradores, recursión y generadores.

Evidencia de logro. Escribirás un módulo con funciones reutilizables, docstrings y pruebas manuales de sus contratos. Como ampliación aplicarás al menos una de estas técnicas: un decorador, un generador o una función recursiva.

Contexto y motivación

Repetir código aumenta la probabilidad de cometer errores. Las funciones permiten definir la lógica una vez y reutilizarla mediante parámetros. Son una herramienta fundamental para organizar programas y librerías: cuando usas len() o print(), estás llamando funciones.

Además de lo básico, este capítulo introduce dos herramientas muy usadas en IA y en el resto del módulo:

  • Decoradores: "envolver" una función para añadirle comportamiento (medir tiempo, registrar, validar). Los verás en FastAPI (UD2).
  • Generadores: producir resultados de uno en uno sin guardarlos todos en memoria (esencial para datos grandes).

Vocabulario

Término Significado
Función Bloque con nombre que recibe entradas y devuelve salidas
Parámetro Variable que la función espera al llamarla
Argumento Valor que le pasas al llamarla
Retorno (return) Valor que la función devuelve
Ámbito (scope) Dónde existe una variable
Decorador Función que envuelve otra para extenderla
Generador Función que produce valores de uno en uno con yield
lambda Función anónima de una línea

Prerrequisitos

1.5 · Bucles, 1.6 · Listas y tuplas y 1.7 · Diccionarios y conjuntos, además de saber manejar variables.

Ruta recomendada

Este capítulo reúne más herramientas de las que conviene abordar en una sola sesión:

  • Núcleo obligatorio: secciones 1–3 y 7 (definir, llamar, devolver y documentar funciones).
  • Ampliación guiada: secciones 4–6 (*args, **kwargs, ámbitos y lambda).
  • Profundización: secciones 8–10 (recursión, decoradores y generadores).

La práctica debe demostrar primero el núcleo. Después elige una profundización según el ritmo del grupo; el resto puede quedar como lectura o demostración del docente. En FastAPI y en el trabajo con datos volverás a estas ideas cuando sean necesarias.

1. Definir y llamar funciones

def saludar():
    print("Hola")

saludar()

Salida esperada:

Hola

def nombre(): define la función; el cuerpo va indentado, igual que en un if. Definir no ejecuta nada: solo le da nombre a un bloque de código. El código se ejecuta cuando llamas a la función escribiendo su nombre con paréntesis, saludar(), y puedes llamarla tantas veces como quieras. Por eso la definición tiene que aparecer antes que la primera llamada.

2. Parámetros y retorno

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

print(suma(3, 4))

Salida esperada:

7

a y b son los parámetros: variables que reciben los valores con los que se llama (3 y 4, los argumentos). La diferencia con imprimir dentro es el return: la función devuelve un valor que quien la llama puede guardar y seguir usando. print muestra; return comunica.

resultado = suma(3, 4)        # el valor devuelto se guarda en una variable
doble = suma(resultado, resultado)
print(resultado, doble)

Salida esperada:

7 14

return no imprime

Si escribes la función sin return, devuelve None y la suma se pierde:

def suma_sin_return(a, b):
    a + b             # se calcula, pero no se devuelve

print(suma_sin_return(3, 4))

Salida esperada:

None

3. Valores por defecto y argumentos con nombre

def saludo(nombre="mundo"):
    return f"Hola, {nombre}"

print(saludo())
print(saludo("Ana"))

Salida esperada:

Hola, mundo
Hola, Ana
def info(nombre, edad):
    return f"{nombre} ({edad})"

print(info(edad=20, nombre="Ana"))   # por nombre: orden indiferente

Salida esperada:

Ana (20)

Los argumentos con nombre (edad=20) hacen la llamada más clara y no dependen del orden. Los valores por defecto permiten omitir parámetros.

Nunca uses una lista mutable como valor por defecto

El valor por defecto se crea una sola vez, al definir la función. Si es una lista, todas las llamadas comparten la misma lista y acumulan datos:

def agregar_mal(elemento, items=[]):
    items.append(elemento)
    return items

print(agregar_mal("a"))
print(agregar_mal("b"))    # esperabas ['b']

Salida esperada:

['a']
['a', 'b']

La solución es usar None como valor por defecto y crear la lista dentro:

def agregar(elemento, items=None):
    if items is None:
        items = []
    items.append(elemento)
    return items

print(agregar("a"))
print(agregar("b"))

Salida esperada:

['a']
['b']

4. Ampliación: *args y **kwargs

Antes de nada, recuerda que hay dos maneras de pasar argumentos a una función:

  • Posicionales: en el orden que define la función, sin nombre. f(1, 2).
  • Con nombre (keyword): indicando nombre=valor. En el apartado 3 ya lo usaste: info(edad=20, nombre="Ana").

Con *args y **kwargs una función recibe una cantidad variable de argumentos. La diferencia es qué tipo captura cada uno:

4.1 *args: posicionales sobrantes → una tupla

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

print(total(1, 2, 3))

Salida esperada:

6

*args empaqueta todos los argumentos posicionales (los "sin nombre") que queden sin asignar en una tupla. Aquí args = (1, 2, 3), y sum() los suma.

4.2 **kwargs: con nombre sobrantes → un diccionario

def config(**kwargs):
    return kwargs

print(config(a=1, b=2))

Salida esperada:

{'a': 1, 'b': 2}

**kwargs empaqueta todos los argumentos con nombre que queden sin asignar en un diccionario. Aquí kwargs = {"a": 1, "b": 2}.

Los nombres args y kwargs son convención

Lo que importa son los asteriscos, no el nombre. Podrían ser *valores y **opciones:

  • *algo → recoge posicionales en tupla.
  • **algo → recoge con nombre en dict.

4.3 Combinar ambos

Puedes definir parámetros fijos, *args y **kwargs a la vez:

def f(a, b, *args, **kwargs):
    return a, b, args, kwargs

print(f(1, 2, 3, 4, x=5))

Salida esperada:

(1, 2, (3, 4), {'x': 5})

Analiza la llamada f(1, 2, 3, 4, x=5) paso a paso:

  • 1 y 2 son posicionales → van a a y b (los primeros parámetros).
  • 3 y 4 sobran como posicionales → a args = (3, 4) (tupla).
  • x=5 es un argumento con nombre no previsto → a kwargs = {"x": 5}.

El resultado es la tupla (a, b, args, kwargs) = (1, 2, (3, 4), {"x": 5}).

Lo usarás al escribir decoradores y funciones wrapper, que reciben argumentos cualesquiera y los reenvían a otra función.

4.4 Regla de orden (importante)

Al combinar, Python exige un orden fijo en la firma. Si lo incumples, da SyntaxError:

def f(parámetros, *args, solo_por_clave, **kwargs):

Es decir, de izquierda a derecha:

  1. Parámetros normales (posicionales o con nombre): a, b.
  2. *args (posicionales sobrantes → tupla).
  3. Parámetros solo-por-clave (keyword-only), que van detrás de *args.
  4. **kwargs al final (con nombre sobrantes → dict).
def j(a, *args, c, **kwargs):
    return a, args, c, kwargs

print(j(1, 2, 3, c=4, x=5))

Salida esperada:

(1, (2, 3), 4, {'x': 5})

Fíjate en c: al ir después de *args, solo puede pasarse por nombre (c=4). Si escribes j(1, 2, 3, 4, x=5), ese 4 NO va a c: por ser posicional, acaba en args.

def k(a, *args, **kwargs): ...        # OK
def j(a, *args, c, **kwargs): ...     # OK
def h(a, **kwargs, b): ...            # ✗ SyntaxError: un parámetro tras **kwargs

Y hay una segunda regla en la llamada: los argumentos posicionales van antes que los con nombre:

f(1, b=2)    # OK
f(a=1, 2)    # ✗ SyntaxError: positional argument follows keyword argument

Clave para recordarlo

Orden de definición: normales → *args → solo-por-clave → **kwargs. **kwargs siempre al final; tras *args, todo lo que viene es solo-por-clave. Y un * solo, sin nombre (p. ej. def f(a, *, c)), también obliga a pasar c por nombre. En la llamada, primero posicionales, luego con nombre.

5. Ampliación: ámbito de variables (scope)

x = 10               # global

def f():
    x = 5            # local (otra variable)
    return x

print(f(), x)

Salida esperada:

5 10

La x dentro de f es local: solo existe mientras se ejecuta la función y no toca la de fuera. Asignar dentro de una función crea una variable local, no modifica la global. Una función puede leer una variable global, pero para modificarla necesita declararla con global x, y eso es mala práctica.

Regla: no dependas de variables globales dentro de las funciones; pásalas como parámetro y devuelve resultados. Hace el código predecible y testeable.

5.1 ¿Y si quiero modificar una global? global

Si dentro de una función escribes x = 99, Python crea una variable local y no toca la global x:

x = 10

def f():
    x = 99       # local, no modifica la de fuera
    return x

f()
print(x)          # sigue siendo 10

Salida esperada:

10

Y si la función lee la variable y luego la asigna, Python la considera local en toda la función, así que la lectura falla porque aún no tiene valor:

contador = 0

def incrementar_mal():
    contador = contador + 1   # la lee antes de haberla creado como local
    return contador

incrementar_mal()

Salida esperada (error):

UnboundLocalError: cannot access local variable 'contador' where it is not associated with a value

Para modificar la variable global existe global x:

y = 10

def cambiar():
    global y
    y = 99

cambiar()
print(y)          # ahora es 99

Salida esperada:

99

Entiéndelo, pero evítalo

global funciona, pero es mala práctica: hace que el resultado de la función dependa de un estado externo oculto, y es difícil de depurar y de probar. La alternativa limpia es pasar el valor como parámetro y devolver el resultado:

def incrementar(valor):
    return valor + 1

y = 99
y = incrementar(y)   # el resultado vuelve a la variable

5.2 Ampliación: nonlocal en funciones anidadas

Si tienes una función dentro de otra y quieres modificar una variable del ámbito intermedio (ni local, ni global), usa nonlocal:

def contador():
    n = 0

    def subir():
        nonlocal n
        n += 1
        return n

    return subir

c = contador()
print(c(), c(), c())

Salida esperada:

1 2 3

nonlocal busca la variable en la función envolvente (no en la global). Es útil en closures (funciones que "recuerdan" estado) y en decoradores.

6. Ampliación: funciones lambda (anónimas)

Una lambda es una función anónima de una sola expresión, sin nombre asociado. La guardas en una variable y la llamas como a cualquier función:

cuadrado = lambda x: x ** 2
print(cuadrado(5))

Salida esperada:

25

Una lambda no permite hacer nada que no pudieras expresar con def cuad(x): return x**2; resulta útil cuando necesitas una función puntual sin definirla con un nombre. Es habitual usarla con sorted, map y filter, donde la función se necesita solo para esa operación:

personas = [("Ana", 20), ("Luis", 25), ("Marta", 18)]

print(sorted(personas, key=lambda p: p[1]))   # por edad
print(sorted(personas, key=lambda p: p[0], reverse=True))  # por nombre, desc
print(list(map(lambda x: x ** 2, [1, 2, 3])))
print(list(filter(lambda x: x % 2 == 0, range(10))))

Salida esperada:

[('Marta', 18), ('Ana', 20), ('Luis', 25)]
[('Marta', 18), ('Luis', 25), ('Ana', 20)]
[1, 4, 9]
[0, 2, 4, 6, 8]

key=lambda p: p[1] dice "ordena por el segundo elemento". La lambda recibe un elemento y devuelve la clave por la que ordenar.

Puede recibir varios argumentos:

area = lambda base, altura: base * altura / 2
print(area(6, 4))

Salida esperada:

12.0

Cuándo NO usar lambda

Una lambda solo admite una expresión (no sentencias como if/for ni asignaciones). Si la lógica necesita más de una línea, usa def, que además se puede documentar y probar. Regla: lambda para una función diminuta y de un solo uso; def para todo lo demás.

7. Documentación con docstrings

Los docstrings son las cadenas que van justo después de def y documentan la función. Se consultan con help(f) o f.__doc__:

def promedio(notas):
    """Calcula el promedio de una lista de notas.

    Args:
        notas: lista de números.

    Returns:
        float: la media.
    """
    return sum(notas) / len(notas)

help(promedio)

Salida esperada:

Help on function promedio in module __main__:

promedio(notas)
    Calcula el promedio de una lista de notas.

    Args:
        notas: lista de números.

    Returns:
        float: la media.

Un docstring no es un comentario: es legible desde el código (help, el autocompletado del editor) y aparece donde se documenta. Es buena práctica escribirlo desde el principio, sobre todo en funciones que reutilizarás.

Una línea o varias

Para funciones cortas basta una línea: """Devuelve la suma de a y b.""". Para funciones importantes, documenta los args y el return. Lo verás al escribir tus propios módulos (capítulo 1.12).

8. Profundización: recursividad

Una función recursiva se llama a sí misma. El ejemplo clásico, el factorial, tiene dos partes esenciales:

def factorial(n):
    if n <= 1:                 # caso base
        return 1
    return n * factorial(n - 1)  # caso recursivo (se acerca al base)

print(factorial(5))

Salida esperada:

120

Sin el caso base (if n <= 1) la función se llamaría para siempre y terminaría en RecursionError. El caso recursivo reduce el problema hasta llegar al base.

La recursión brilla en estructuras anidadas (listas de listas, árboles, ficheros), donde el problema se repite dentro de sí mismo:

def suma_anidada(lista):
    total = 0
    for elem in lista:
        if isinstance(elem, list):
            total += suma_anidada(elem)   # bajamos un nivel
        else:
            total += elem
    return total

print(suma_anidada([1, [2, [3, 4]], 5]))

Salida esperada:

15

suma_anidada recorre la lista; si un elemento es otra lista, se llama a sí misma. Es la misma idea que recorrer carpetas dentro de carpetas.

Límite de recursión

Python limita la profundidad de recursión (por defecto 1000) para proteger la memoria: import sys; sys.getrecursionlimit() → 1000. Si te acercas al límite con datos grandes, revisa si un bucle es mejor.

¿Recursión o bucle?

Para recorridos lineales, un bucle suele ser más legible y sin límite de profundidad. Usa recursión cuando la estructura sea naturalmente anidada (árboles, JSON, ficheros). Es común que la recursión se pueda reescribir con un bucle explícito.

9. Profundización: decoradores

Un decorador envuelve una función para añadirle comportamiento sin modificar su código. En términos sencillos, es una función que recibe una función y devuelve otra que la envuelve.

import time

def crono(func):
    def envoltura(*args, **kwargs):
        t0 = time.perf_counter()
        resultado = func(*args, **kwargs)
        t1 = time.perf_counter()
        print(f"[{func.__name__}] {t1 - t0:.6f}s")
        return resultado
    return envoltura

@crono
def trabajo():
    return sum(range(1000))

print("Resultado:", trabajo())

Salida esperada:

[trabajo] <un tiempo como 0.000045s>
Resultado: 499500

El bloque es de solo sintaxis porque el tiempo cambia según el equipo, la carga de la CPU y la versión de Python: no hay nada que comparar. Lo que sí es fiable es Resultado: 499500, la suma de los números del 0 al 999.

Qué hace el decorador: @crono es azúcar sintáctico de trabajo = crono(trabajo). Sustituye trabajo por envoltura, que mide, llama a la original y devuelve su resultado. Por eso el decorador debe devolver el valor de func: si se olvidara, la función devolvería None.

9.1 functools.wraps: conservar nombre y docstring

Por defecto, envoltura "oculta" el nombre y el docstring de la función original. Para conservarlos, decora la envoltura con @functools.wraps:

import functools
import time

def crono(func):
    @functools.wraps(func)
    def envoltura(*args, **kwargs):
        t0 = time.perf_counter()
        r = func(*args, **kwargs)
        t1 = time.perf_counter()
        print(f"[{func.__name__}] {t1 - t0:.6f}s")
        return r
    return envoltura

@crono
def trabajo():
    return sum(range(1000))

print(trabajo.__name__)   # sigue llamándose "trabajo"

Salida esperada:

trabajo

9.2 Decorador de registro (logging)

import functools

def registrar(func):
    @functools.wraps(func)
    def envoltura(*args, **kwargs):
        print(f"Llamando a {func.__name__}{args}{kwargs}")
        return func(*args, **kwargs)
    return envoltura

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

print("suma:", suma(3, 4))

Salida esperada:

Llamando a suma(3, 4){}
suma: 7

Este patrón (medir, registrar, validar) es exactamente lo que usarán FastAPI y el logging en la UD2. Suele acompañarse de *args/**kwargs para que la envoltura acepte cualquier firma.

9.3 Ampliación: functools.lru_cache (caché)

Para funciones costosas y repetidas, lru_cache guarda los resultados:

import functools

@functools.lru_cache(maxsize=None)
def fib(n):
    return n if n <= 1 else fib(n - 1) + fib(n - 2)

print("fib(30):", fib(30))

Salida esperada:

fib(30): 832040

Sin caché, fib(30) haría millones de llamadas recursivas; con ella, cada resultado se calcula una vez. Este decorador de la biblioteca estándar muestra cómo una función puede añadir un comportamiento reutilizable a otra.

10. Profundización: generadores (yield)

Un generador produce valores de uno en uno y no guarda todos en memoria. La diferencia con return es que yield pausa la función y conserva su estado; la siguiente llamada continúa donde se quedó.

def contar_hasta(n):
    for i in range(1, n + 1):
        print("genero", i)
        yield i

g = contar_hasta(3)
print("Primer next:", next(g))
print("Segundo next:", next(g))

Salida esperada:

genero 1
Primer next: 1
genero 2
Segundo next: 2

Fíjate en el orden: justo antes de cada next, se ejecuta la línea print("genero", i). Eso demuestra que el generador no corre todo de golpe: se pausa en cada yield y retoma al pedir el siguiente valor. Es el comportamiento lazy (perezoso).

10.1 Expresión generadora

Como las comprehensions, pero sin memoria (con () en vez de []):

cuadrados = (x ** 2 for x in range(5))
print("Suma:", sum(cuadrados))

print(list(x * x for x in range(4)))

Salida esperada:

Suma: 30
[0, 1, 4, 9]

sum(), max() y min() consumen el generador. A diferencia de una lista, solo se puede recorrer una vez: tras sum(cuadrados), el generador queda agotado.

def fibonacci():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b

gen = fibonacci()
print([next(gen) for _ in range(8)])

Salida esperada:

[0, 1, 1, 2, 3, 5, 8, 13]

Este generador es infinito (en teoría), pero solo construye los 8 primeros: produce bajo demanda y siempre "queda" para el siguiente.

Generador vs lista

list(...) materializa todo y ocupa memoria; el generador produce de uno en uno. Esto es la diferencia entre procesar un fichero de millones de líneas (generador, línea a línea) y quedarte sin memoria al cargarlo entero. Usa el generador cuando no necesites todos los valores a la vez.

Errores frecuentes

  • Olvidar return y que la función devuelva None.
  • Usar una lista/dict mutable como valor por defecto (se comparte).
  • Confundir print (muestra) con return (devuelve).
  • Modificar una variable global dentro de una función sin global y tener un UnboundLocalError.
  • Pasarse de lambda: si hay que hacer mucho, usa def.
  • Creer que una lambda admite varias líneas (solo acepta una expresión).
  • Recursión sin caso base, o con datos demasiado profundos → RecursionError.
  • No entender que yield pausa y return termina.
  • Reutilizar un generador después de consumirlo (queda agotado; hay que crear uno nuevo).

Práctica de transferencia

  1. Retoma tu caja registradora del proyecto 1.7b. Extrae a funciones las cuatro partes que ahora están en el while: mostrar_menu(), anadir_al_carrito(), calcular_total() y aplicar_descuento(). Cada una con su docstring y su return cuando toque. El bucle principal debe quedar en unas pocas líneas: esta es la prueba de que la división sirve para algo.
  2. Escribe area_circulo(radio) que devuelva el área (3.14159 * r**2).
  3. Escribe promedio(*notas) que devuelva la media (usa sum/len).
  4. Crea un decorador registrar que imprima "Llamando a <nombre>" antes de cada llamada y lo pruebes en una función.
  5. Escribe un generador pares(limite) que produzca números pares hasta limite. (Evita llamar max al parámetro: taparía la función max().)
  6. Reescribe el factorial (apartado 8) con un bucle en lugar de recursión y compara.

Qué debes comprobar: en el punto 1, que la caja produce exactamente el mismo ticket que antes de extraer las funciones (misma sesión, misma salida); en el 3, qué ocurre si llamas a promedio() sin notas y cómo lo evitarías.

Producto evaluable

Cuaderno 06_funciones.py con:

  1. Funciones con return, valores por defecto y docstrings (área, promedio, formato), cada una con su celda Markdown que interprete el resultado.
  2. Una demostración de *args o **kwargs, explicando qué valores se empaquetan y en qué estructura.
  3. Una ampliación elegida: decorador crono, generador fibonacci o función recursiva. Elige una y explica por qué es adecuada; las otras pueden quedar como demostración opcional.
  4. Una celda que demuestre el bug de la lista mutable por defecto y su solución con None.

Formato de entrega: cuaderno marimo ejecutado, con salida esperada, interpretación y conclusión.

Resumen y referencia rápida

Herramienta Uso Ejemplo
def f(a, b=1) Función con por defecto def saludo(nombre="mundo")
return Devolver un valor return a + b
*args / **kwargs Argumentos variables def f(*a, **k)
lambda Función anónima corta lambda x: x**2
Docstring Documentar def f(...): """..."""
Decorador @ Envolver una función @crono
@functools.wraps Conservar nombre/doc del decorado @functools.wraps(func)
yield Generador (uno a uno) def g(): yield x
(x for x in ...) Expresión generadora (sin memoria) sum(x**2 for x in r)

Siguiente: 1.9 · Cadenas.