Saltar al contenido
funciones-avanzadas.py · devschool

*args, **kwargs y lambda en Python

Lección 12 de 19 · 11 min de lectura · Actualizado el

En esta lección
  1. El error clásico: valores por defecto mutables
  2. *args: cualquier número de argumentos posicionales
  3. **kwargs: cualquier número de argumentos con nombre
  4. Desempaquetar al llamar
  5. Parámetros solo posicionales y solo con nombre
  6. Funciones lambda
  7. Las funciones son objetos
  8. Funciones anidadas y closures
  9. Ámbito: la regla LEGB
  10. Decoradores
  11. Recursividad
  12. Errores frecuentes
  13. Resumen

En la lección de funciones aprendiste a definirlas con def, devolver valores con return y usar parámetros con valor por defecto. Ahora vas a dar el siguiente paso: funciones que aceptan cualquier número de argumentos, funciones sin nombre (lambda), funciones que crean otras funciones, las reglas exactas del ámbito de las variables y los decoradores. Son herramientas que verás constantemente en código real y en librerías como Flask o Django.

El error clásico: valores por defecto mutables

Empecemos por una trampa en la que cae casi todo el mundo. Esta función añade una tarea a una lista, y si no le pasas lista, crea una nueva… o eso parece:

def agregar_tarea(tarea, lista=[]):
    lista.append(tarea)
    return lista

print(agregar_tarea("estudiar"))   # ['estudiar']
print(agregar_tarea("comprar"))    # ['estudiar', 'comprar']  ¡¿?!

La segunda llamada “recuerda” la tarea de la primera. ¿Por qué? Porque el valor por defecto se crea una sola vez, cuando Python lee el def, no en cada llamada. Todas las llamadas comparten la misma lista.

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

def agregar_tarea(tarea, lista=None):
    if lista is None:
        lista = []
    lista.append(tarea)
    return lista

print(agregar_tarea("estudiar"))   # ['estudiar']
print(agregar_tarea("comprar"))    # ['comprar']

Cuidado: nunca uses como valor por defecto una lista, un diccionario o un conjunto ([], {}, set()). Los números, cadenas, tuplas, True/False y None sí son seguros porque no se pueden modificar.

*args: cualquier número de argumentos posicionales

A veces no sabes cuántos valores te van a pasar. Un asterisco delante del parámetro recoge todos los argumentos posicionales sobrantes en una tupla:

def sumar(*numeros):
    print(numeros)
    return sum(numeros)

print(sumar(1, 2, 3))   # (1, 2, 3)  y luego  6
print(sumar())          # ()  y luego  0

El nombre args es solo una convención; lo importante es el *. Usa un nombre descriptivo cuando puedas. Se puede combinar con parámetros normales, que van antes:

def nota_media(alumno, *notas):
    if not notas:
        return f"{alumno}: sin notas"
    return f"{alumno}: {sum(notas) / len(notas):.2f}"

print(nota_media("Ana", 7, 8.5, 9))   # Ana: 8.17
print(nota_media("Luis"))             # Luis: sin notas

Así funciona el propio print, que admite tantos valores como quieras.

**kwargs: cualquier número de argumentos con nombre

Dos asteriscos recogen los argumentos con nombre sobrantes en un diccionario:

def crear_perfil(nombre, **datos):
    print(datos)
    perfil = {"nombre": nombre}
    perfil.update(datos)
    return perfil

print(crear_perfil("Marta", edad=21, ciudad="Bilbao"))
# {'edad': 21, 'ciudad': 'Bilbao'}
# {'nombre': 'Marta', 'edad': 21, 'ciudad': 'Bilbao'}

Puedes usar los dos a la vez. El orden obligatorio es: normales, *args, **kwargs.

def registrar(evento, *args, **kwargs):
    print(evento, args, kwargs)

registrar("login", "ana", 3, ip="1.2.3.4", ok=True)
# login ('ana', 3) {'ip': '1.2.3.4', 'ok': True}

Desempaquetar al llamar

Los asteriscos también funcionan al revés, al llamar a una función: * reparte una lista o tupla en argumentos posicionales y ** reparte un diccionario en argumentos con nombre.

def area(base, altura):
    return base * altura

medidas = [5, 3]
print(area(*medidas))   # 15  (igual que area(5, 3))

datos = {"altura": 2, "base": 10}
print(area(**datos))    # 20  (igual que area(base=10, altura=2))

print(*["a", "b", "c"], sep=" | ")   # a | b | c

Es muy útil cuando los datos te llegan ya en una lista o un diccionario (por ejemplo, leídos de un fichero JSON).

Parámetros solo posicionales y solo con nombre

Puedes obligar a que ciertos parámetros se pasen de una forma concreta:

  • Todo lo que va antes de / solo se puede pasar por posición (desde Python 3.8).
  • Todo lo que va después de * solo se puede pasar por nombre.
def potencia(base, exponente, /):
    return base ** exponente

print(potencia(2, 10))            # 1024
potencia(base=2, exponente=10)    # TypeError

def conectar(host, *, puerto=5432, ssl=False):
    return f"{host}:{puerto} ssl={ssl}"

print(conectar("localhost", puerto=3306, ssl=True))   # localhost:3306 ssl=True
conectar("localhost", 3306)   # TypeError: takes 1 positional argument but 2 were given

¿Para qué sirve? Los parámetros solo con nombre evitan llamadas confusas como conectar("localhost", 3306, True), donde nadie sabe qué es ese True. Los solo posicionales te permiten cambiar el nombre del parámetro más adelante sin romper el código de quien use la función.

Funciones lambda

Una lambda es una función anónima de una sola expresión. Su resultado se devuelve automáticamente, sin return:

doble = lambda x: x * 2
print(doble(5))   # 10

Asignar una lambda a una variable, como arriba, no tiene sentido: para eso está def. Las lambdas brillan cuando una función necesita recibir otra función pequeña y de usar y tirar. El caso más típico es el parámetro key de sorted, min y max, que indica por qué criterio comparar:

alumnos = [("Ana", 8.5), ("Luis", 6), ("Marta", 9.2), ("Pablo", 7)]

print(sorted(alumnos, key=lambda a: a[1]))
# [('Luis', 6), ('Pablo', 7), ('Ana', 8.5), ('Marta', 9.2)]

print(max(alumnos, key=lambda a: a[1]))   # ('Marta', 9.2)

frutas = ["kiwi", "pera", "fresa", "uva"]
print(sorted(frutas, key=lambda p: (len(p), p)))
# ['uva', 'kiwi', 'pera', 'fresa']  (por longitud y, si empatan, alfabético)

key también admite funciones ya existentes, sin lambda:

nombres = ["marta", "Ana", "Luis", "bea"]
print(sorted(nombres))                  # ['Ana', 'Luis', 'bea', 'marta']
print(sorted(nombres, key=str.lower))   # ['Ana', 'bea', 'Luis', 'marta']

Sin key, las mayúsculas van antes que las minúsculas; con str.lower se ordena sin distinguirlas.

map y filter

map aplica una función a cada elemento y filter se queda con los que devuelven True. Devuelven objetos perezosos, por eso los pasamos a list:

precios = [10, 25, 8]

print(list(map(lambda p: p * 1.21, precios)))   # [12.1, 30.25, 9.68]
print(list(filter(lambda p: p > 9, precios)))   # [10, 25]

En Python se prefieren las comprensiones, que se leen mejor: [p * 1.21 for p in precios] y [p for p in precios if p > 9]. Conviene conocer map y filter porque aparecen en mucho código.

Las funciones son objetos

En Python una función es un valor más, como un número o una lista. Puedes guardarla en una variable, pasarla como argumento o meterla en un diccionario:

def saludar(nombre):
    return f"Hola, {nombre}"

decir = saludar           # sin paréntesis: no la llamas, la referencias
print(decir("Eva"))       # Hola, Eva

def aplicar(funcion, valores):
    return [funcion(v) for v in valores]

print(aplicar(len, ["hola", "sol"]))   # [4, 3]

operaciones = {
    "+": lambda a, b: a + b,
    "-": lambda a, b: a - b,
    "*": lambda a, b: a * b,
}
print(operaciones["*"](6, 7))   # 42

El diccionario de operaciones sustituye a una larga cadena de if/elif: para añadir una operación nueva solo añades una entrada.

Funciones anidadas y closures

Una función puede definirse dentro de otra y devolverse. La función interna recuerda las variables de la externa aunque esta ya haya terminado. Eso se llama closure (clausura):

def crear_descuento(porcentaje):
    def aplicar(precio):
        return round(precio * (1 - porcentaje / 100), 2)
    return aplicar

rebajas = crear_descuento(20)
black_friday = crear_descuento(50)

print(rebajas(50))        # 40.0
print(black_friday(50))   # 25.0

crear_descuento es una “fábrica de funciones”: cada llamada fabrica una función configurada con su propio porcentaje.

Ámbito: la regla LEGB

Cuando usas un nombre, Python lo busca en este orden y se queda con el primero que encuentra:

LetraÁmbitoDónde
LLocalDentro de la función actual
EEnclosingEn las funciones que la contienen
GGlobalEn el nivel superior del módulo (el archivo)
BBuilt-inLos nombres propios de Python: len, print, sum…
x = "global"

def exterior():
    x = "enclosing"
    def interior():
        x = "local"
        print(x)       # local
    interior()
    print(x)           # enclosing

exterior()
print(x)               # global

global y nonlocal

Leer una variable de fuera funciona sin más. Pero si asignas un valor a un nombre dentro de una función, Python lo considera local:

contador = 0

def incrementar():
    contador += 1      # UnboundLocalError

incrementar()

Para modificar una variable global hay que declararlo con global:

def incrementar():
    global contador
    contador += 1

incrementar()
incrementar()
print(contador)   # 2

Y para modificar una variable de la función que te contiene, nonlocal:

def crear_contador():
    cuenta = 0
    def siguiente():
        nonlocal cuenta
        cuenta += 1
        return cuenta
    return siguiente

turno = crear_contador()
print(turno(), turno(), turno())   # 1 2 3

Consejo: evita global siempre que puedas. Una función que cambia variables de fuera es difícil de probar y de entender. Lo limpio es recibir datos por parámetros y devolverlos con return. Si necesitas guardar estado, una clase suele ser mejor opción.

Decoradores

Un decorador es una función que recibe una función y devuelve otra que la envuelve, añadiéndole comportamiento sin tocar su código. Juntando todo lo anterior (funciones como objetos, closures y *args, **kwargs), podemos medir cuánto tarda cualquier función:

import time

def medir_tiempo(funcion):
    def envoltura(*args, **kwargs):
        inicio = time.perf_counter()
        resultado = funcion(*args, **kwargs)
        fin = time.perf_counter()
        print(f"{funcion.__name__} tardó {fin - inicio:.4f} s")
        return resultado
    return envoltura

@medir_tiempo
def sumar_hasta(n):
    return sum(range(n))

print(sumar_hasta(1_000_000))
# sumar_hasta tardó 0.0111 s   (el tiempo varía en cada equipo)
# 499999500000

La línea @medir_tiempo es una forma abreviada de escribir sumar_hasta = medir_tiempo(sumar_hasta). Gracias a *args, **kwargs, la envoltura sirve para funciones con cualquier número de parámetros.

Consejo: en código real añade @functools.wraps(funcion) encima de def envoltura. Así la función decorada conserva su nombre y su docstring originales.

Verás decoradores por todas partes: @property y @classmethod en las clases, @app.route("/") en Flask, @pytest.fixture en los tests.

Recursividad

Una función recursiva se llama a sí misma. Necesita siempre un caso base que detenga las llamadas:

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

print(factorial(5))   # 120  (5 * 4 * 3 * 2 * 1)

Es natural para estructuras anidadas, como sumar listas dentro de listas:

def suma_anidada(datos):
    total = 0
    for d in datos:
        if isinstance(d, list):
            total += suma_anidada(d)
        else:
            total += d
    return total

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

Python limita la profundidad a unas 1000 llamadas. Si olvidas el caso base obtendrás RecursionError: maximum recursion depth exceeded. Para recorridos simples, un bucle es más eficiente.

Errores frecuentes

  • Lista o diccionario como valor por defecto: se comparte entre llamadas. Usa None.
  • Poner **kwargs antes de *args: el orden es normales, *args, **kwargs.
  • Llamar a la función al pasarla: sorted(datos, key=str.lower()) falla; se pasa sin paréntesis: key=str.lower.
  • Lambdas demasiado largas: si no cabe en una línea clara, usa def.
  • Asignar a una variable global sin global: provoca UnboundLocalError.
  • Recursión sin caso base: termina en RecursionError.

Resumen

HerramientaPara qué sirveEjemplo
param=NoneValor por defecto mutable segurodef f(lista=None)
*argsRecoger posicionales en una tupladef sumar(*nums)
**kwargsRecoger con nombre en un diccionariodef perfil(**datos)
* y ** al llamarDesempaquetar lista o diccionarioarea(*medidas)
/ y * en la firmaSolo posicionales / solo con nombredef f(a, /, *, b)
lambdaFunción corta de usar y tirarkey=lambda a: a[1]
ClosureFunción que recuerda su entornocrear_descuento(20)
global / nonlocalModificar variables de fueranonlocal cuenta
DecoradorAñadir comportamiento a una función@medir_tiempo
RecursividadUna función que se llama a sí mismafactorial(n - 1)

En la siguiente lección verás cómo gestionar los errores con excepciones.

Pon a prueba lo que has aprendido

[Python] ¿Qué imprime este código?
def f(a, b=[]):
    b.append(a)
    return len(b)

print(f(1), f(2), f(3))

[Python] ¿Qué imprime este código?
def g(*a, **k):
    return len(a), len(k)

print(g(1, 2, x=3))

[Python] ¿Qué imprime este código?
def h():
    n = 0
    def i():
        nonlocal n
        n += 2
        return n
    return i

c = h()
c()
print(c())

[Python] ¿Qué hace el parámetro * en def conectar(host, *, puerto=5432)?

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.