Capítulo 6 de 17 14 secciones 17 min

Compartir

Funciones

Empaquetar trabajo con nombre, devolver resultados, y la trampa del argumento por defecto que muerde en producción.

Una función empaqueta un trozo de trabajo bajo un nombre para no repetirlo. Se define con def, recibe argumentos y devuelve un resultado con return. Yo empiezo a escribir una en cuanto copio el mismo bloque por segunda vez. Y la trampa que más sorprende es poner una lista como valor por defecto: se crea una sola vez y queda compartida entre todas las llamadas.

Hola! El momento de dejar de copiar y pegar

Si llegaste hasta aquí ya escribiste el mismo bloque de código dos o tres veces cambiando un número. A todas nos pasa 😅

Y hazte esta pregunta cada vez que copies algo: ¿este bloque lo estoy pegando por segunda vez? Si la respuesta es sí, ahí va una función. No conozco regla mejor que esa 💛

Una función es trabajo con nombre. Lo escribes una vez, le pones un nombre, y lo usas todas las veces que quieras.

def con_igv(monto):
    return round(monto * 1.18, 2)

print(con_igv(100))
print(con_igv(480.37))
118.0
566.84

Tres piezas y ya está: def para definirla, el nombre y los paréntesis con lo que recibe, y return para devolver el resultado.

Y sí, la sangría manda igual que en el capítulo 5: lo que está dentro de la función lleva sus cuatro espacios.

return no es print

Esta confusión la tiene todo el mundo al principio, así que veámosla de frente.

def suma_mala(a, b):
    print(a + b)

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

resultado_malo = suma_mala(2, 3)
resultado_bueno = suma_buena(2, 3)

print('malo :', resultado_malo)
print('bueno:', resultado_bueno)
5
malo : None
bueno: 5

Las dos "muestran" un 5, pero solo una devuelve algo con lo que puedas seguir trabajando. La primera imprimió y se olvidó, así que resultado_malo quedó en None.

La regla: una función calcula y devuelve; quien la llama decide si imprime. Si la función imprime por su cuenta, no la puedes reutilizar dentro de otro cálculo.

Árbol sobre qué debe hacer una función: si no devuelve nada, que modifique en el sitio. Si devuelve algo y no cambia lo que recibió, se puede probar y volver a usar. Si devuelve y además cambia lo que recibió, ahí se pierden las tardes.
Una función que devuelve un resultado y además modifica lo que le diste parece que funciona, y es la que después te tiene comparando el antes con el después sin entender por qué salen iguales.

Argumentos: por posición y por nombre

def total_venta(unidades, precio, descuento):
    bruto = unidades * precio
    return round(bruto * (1 - descuento), 2)

print(total_venta(10, 45.0, 0.15))
print(total_venta(unidades=10, precio=45.0, descuento=0.15))
print(total_venta(10, descuento=0.15, precio=45.0))
382.5
382.5
382.5

Las tres llamadas hacen lo mismo. Pero mira la primera y dime de memoria qué significa ese 0,15 sin subir a leer la definición 🤔

Por eso, cuando una función recibe más de dos cosas, llámala por nombre. Tu yo del futuro te lo va a agradecer.

Y el orden importa cuando mezclas: los posicionales van primero, siempre.

total_venta(unidades=10, 45.0, 0.15)
SyntaxError: positional argument follows keyword argument

Valores por defecto

def total_venta(unidades, precio, descuento=0, igv=0.18):
    bruto = unidades * precio * (1 - descuento)
    return round(bruto * (1 + igv), 2)

print(total_venta(10, 45.0))
print(total_venta(10, 45.0, descuento=0.15))
print(total_venta(10, 45.0, igv=0))
531.0
451.35
450.0

Lo que casi siempre vale igual se pone por defecto, y quien necesite otra cosa lo dice. Así la llamada normal queda cortita y la excepcional sigue siendo posible.

La trampa del argumento por defecto

Esta te la enseño ahora porque es de las que muerden en producción y casi nadie la explica en español. Presta atención un minuto.

def agrega_venta(monto, historial=[]):
    historial.append(monto)
    return historial

print(agrega_venta(100))
print(agrega_venta(200))
print(agrega_venta(300))
[100]
[100, 200]
[100, 200, 300]

¿Ves lo que pasó? Cada llamada debería empezar con una lista vacía, y en vez de eso se van acumulando 😳

La razón: el valor por defecto se crea una sola vez, cuando se define la función, no en cada llamada. Así que las tres llamadas están usando la misma lista, exactamente como el aliasing del capítulo 4.

La solución es siempre la misma y se escribe así:

def agrega_venta(monto, historial=None):
    if historial is None:
        historial = []
    historial.append(monto)
    return historial

print(agrega_venta(100))
print(agrega_venta(200))
[100]
[200]

Regla dura: nunca pongas una lista, un diccionario ni un conjunto como valor por defecto. Usa None y créalo dentro. Con números, textos y True o False no hay problema, porque esos no se pueden modificar.

Devolver varias cosas

def resumen(montos):
    return len(montos), sum(montos), sum(montos) / len(montos)

cuantas, total, promedio = resumen([480.37, 154.83, 2480.84])

print(cuantas)
print(f'{total:,.2f}')
print(f'{promedio:,.2f}')
3
3,116.04
1,038.68

Por dentro devuelve una tupla, como vimos en el capítulo 4, y al recibirla la desempaquetas en tres variables. Es limpísimo.

Cuando son más de tres cosas, mejor devuelve un diccionario: los nombres sobreviven y no dependes del orden.

def resumen(montos):
    return {
        'cuantas': len(montos),
        'total': sum(montos),
        'promedio': sum(montos) / len(montos),
        'maximo': max(montos),
    }

r = resumen([480.37, 154.83, 2480.84])
print(r['promedio'])
1038.68

Dónde vive cada variable

Lo que creas dentro de una función se muere cuando la función termina. Eso se llama alcance y es una protección, no un capricho.

def calcular():
    interno = 42
    return interno * 2

print(calcular())
print(interno)
NameError: name 'interno' is not defined

Al revés sí funciona: desde dentro se ve lo de fuera.

IGV = 0.18          # constante, en mayusculas por costumbre

def con_impuesto(monto):
    return monto * (1 + IGV)

print(con_impuesto(100))
118.0

Ahora, que se pueda no significa que se deba. Una función que depende de variables de fuera es una función que solo funciona en tu cuaderno. Para constantes de verdad como el IGV está bien; para datos, pásalos como argumento.

Docstrings: explicar para qué sirve

Un texto entre triples comillas justo debajo del def es la documentación de la función. No es adorno: help() la lee, tu editor la muestra, y en un equipo es la diferencia entre que usen tu función o la reescriban.

def clasifica_venta(monto):
    """
    Devuelve la categoria comercial de una venta segun su monto.

    Los cortes los fijo comercial en enero de 2026 y se revisan cada
    semestre, asi que si cambian, se cambian aqui y en un solo sitio.
    """
    if monto > 2000:
        return 'Mayorista'
    if monto > 500:
        return 'Mediana'
    return 'Pequeña'

print(clasifica_venta(780))
print(clasifica_venta.__doc__.strip().splitlines()[0])
Mediana
Devuelve la categoria comercial de una venta segun su monto.

Fíjate en algo de esa función: como cada rama hace return, no hacen falta los elif. Al hacer return se sale de la función ahí mismo. Queda más plano y se lee mejor 🌸

lambda: funciones de una línea

Una lambda es una función chiquita y sin nombre. Se usa para pasarla como argumento a otra cosa, y ahí es donde brilla.

ventas = [
    {'ciudad': 'Lima', 'monto': 480.37},
    {'ciudad': 'Cusco', 'monto': 2480.84},
    {'ciudad': 'Piura', 'monto': 154.83},
]

por_monto = sorted(ventas, key=lambda v: v['monto'], reverse=True)
print([v['ciudad'] for v in por_monto])
['Cusco', 'Lima', 'Piura']

Ese key= le dice a sorted qué mirar de cada elemento para comparar. Sin la lambda tendrías que definir una función entera para algo de una línea.

Mi consejo: usa lambda solo para eso, para pasarla como argumento. Si le vas a poner nombre, entonces era una función normal.

Una función de verdad, con todo junto

def limpia_ciudad(texto, por_defecto='Sin ciudad'):
    """Normaliza el nombre de una ciudad que viene de un sistema externo."""
    if texto is None:
        return por_defecto

    limpio = texto.strip().title()
    if limpio == '':
        return por_defecto

    equivalencias = {'Lma': 'Lima', 'Aqp': 'Arequipa', 'Cuzco': 'Cusco'}
    return equivalencias.get(limpio, limpio)

for entrada in ['  lima ', 'CUZCO', 'Aqp', '', None, 'Piura']:
    print(f'{repr(entrada):10} -> {limpia_ciudad(entrada)}')
'  lima '  -> Lima
'CUZCO'    -> Cusco
'Aqp'      -> Arequipa
''         -> Sin ciudad
None       -> Sin ciudad
'Piura'    -> Piura

Eso es una función lista para producción: tiene su docstring, maneja el nulo, maneja el vacío, tiene un valor por defecto que quien llama puede cambiar, y las equivalencias están en un solo sitio donde se pueden corregir.

Y lo mejor: la puedes probar con seis entradas en dos segundos, que es exactamente lo que acabamos de hacer 💜

Cuando no sabes cuántos argumentos van a venir

Hay funciones a las que quieres pasarle tres cosas hoy y siete mañana. Para eso están el asterisco y el doble asterisco.

def resume(*montos, moneda='S/', decimales=2):
    total = sum(montos)
    return f'{len(montos)} ventas, total {moneda}{total:.{decimales}f}'

print(resume(120.5, 340, 89.9))
print(resume(120.5, 340, moneda='US$', decimales=1))
3 ventas, total S/550.40
2 ventas, total US$460.5

El *montos recoge en una tupla todo lo que llegue por posición. Y lo que va después del asterisco, moneda y decimales, solo se puede pasar por nombre 🏷️

Eso último no es un capricho de Python: es lo que evita que alguien escriba resume(120.5, 340, 'US$') y que ese 'US$' se cuele en la suma.

El doble asterisco hace lo mismo con los argumentos con nombre, y los recoge en un diccionario:

def registra(**datos):
    return ', '.join(f'{k}={v}' for k, v in sorted(datos.items()))

print(registra(cliente='Rosita', monto=340, canal='WhatsApp'))
canal=WhatsApp, cliente=Rosita, monto=340

Y lo mejor es que funciona al revés. Si ya tienes una lista o un diccionario, el asterisco los desempaqueta en la llamada:

ventas = [120.5, 340, 89.9]
print('desempaquetando una lista:', resume(*ventas))

config = {'moneda': 'US$', 'decimales': 0}
print('y un diccionario:', resume(*ventas, **config))
desempaquetando una lista: 3 ventas, total S/550.40
y un diccionario: 3 ventas, total US$550

Esto es lo que hace que puedas guardar la configuración en un diccionario, o leerla de un archivo, y pasársela a la función sin escribir cada parámetro a mano 📦

Lo vas a ver muchísimo en las librerías. Cuando la documentación de pandas dice **kwargs, te está diciendo "pásame lo que quieras, que yo se lo paso a quien corresponda" 🐼

Anotaciones: decir qué entra y qué sale

El docstring explica para qué sirve la función. Las anotaciones dicen de qué tipo es cada cosa, y se escriben en la misma línea de la definición 🏷️

def total_con_igv(monto: float, tasa: float = 0.18) -> float:
    'Devuelve el monto con el IGV incluido, redondeado a dos decimales.'
    return round(monto * (1 + tasa), 2)


print(total_con_igv(480.37))
print(total_con_igv.__annotations__)
566.84
{'monto': <class 'float'>, 'tasa': <class 'float'>, 'return': <class 'float'>}

Los dos puntos después de cada parámetro dicen qué tipo espera, y la flecha del final dice qué devuelve. Se quedan guardadas en __annotations__, así que no son un comentario: son datos.

Para qué sirven de verdad 🎯

  • 👀 Para quien lee. Con la anotación no tienes que bajar a leer el cuerpo para saber si esa función devuelve un número o un texto.
  • 💡 Para tu editor. VS Code y PyCharm las leen y te avisan antes de ejecutar nada.
  • 📄 Para las dataclass del capítulo 9, que las usan de verdad para armar la clase.

Y ahora lo importante, que es lo que casi nunca se dice:

def etiqueta(codigo: str) -> int:
    return 'cliente ' + codigo


print('promete un int y devuelve texto:', repr(etiqueta('C0045')))
print('y Python no dijo absolutamente nada')
promete un int y devuelve texto: 'cliente C0045'
y Python no dijo absolutamente nada

Python no comprueba las anotaciones. Son documentación que se puede leer con código, y nada más 🤷‍♀️

Esa función promete devolver un entero, devuelve un texto, y corre tan contenta. Si quieres que alguien las verifique de verdad hace falta una herramienta aparte, tipo mypy, que las lee y te avisa sin ejecutar el programa.

try:
    total_con_igv('480.37')
except TypeError as e:
    print('TypeError:', e)
TypeError: can't multiply sequence by non-int of type 'float'

Fíjate en dónde falla: en la multiplicación, no en la puerta 🚪

La anotación decía float y le pasé un texto, y a Python le dio igual hasta que llegó el momento de multiplicar. Por eso el mensaje habla de secuencias y no de tu parámetro.

Mi costumbre: anotar las funciones que van a usar otras personas, y no molestarme en los scripts de exploración. Es como el docstring, la pongo cuando la función deja de ser mía y pasa a ser de todos 🌸

Ejercicios

1. Tu primera función

Escribe una función que reciba un monto y devuelva el descuento del 12%, redondeado a dos decimales.

def descuento(monto):
    return round(monto * 0.12, 2)

print(descuento(480.37))
57.64
2. return contra print

Escribe la misma función con print en vez de return e intenta usar su resultado en otra cuenta.

def descuento_print(monto):
    print(round(monto * 0.12, 2))

r = descuento_print(480.37)
print(r)
57.64
None

El 57,64 lo viste, pero no lo tienes. Y None + algo revienta con un TypeError, que es como se descubre este error en la práctica.

3. Con valor por defecto

Haz que el porcentaje de descuento sea un argumento con 12% por defecto, y pruébalo con y sin él.

def descuento(monto, porcentaje=0.12):
    return round(monto * porcentaje, 2)

print(descuento(1000))
print(descuento(1000, porcentaje=0.25))
120.0
250.0
4. Tres valores de vuelta

Una función que reciba una lista de montos y devuelva el mínimo, el máximo y el rango.

def extremos(montos):
    return min(montos), max(montos), max(montos) - min(montos)

bajo, alto, rango = extremos([480.37, 154.83, 2480.84])
print(bajo, alto, round(rango, 2))
154.83 2480.84 2326.01
5. La trampa, provocada

Escribe la función con la lista por defecto, llámala tres veces y explica la salida. Después arréglala.

def mal(venta, carrito=[]):
    carrito.append(venta)
    return carrito

print(mal('Abarrotes'), mal('Bebidas'), mal('Snacks'))

def bien(venta, carrito=None):
    if carrito is None:
        carrito = []
    carrito.append(venta)
    return carrito

print(bien('Abarrotes'), bien('Bebidas'), bien('Snacks'))
['Abarrotes', 'Bebidas', 'Snacks'] ['Abarrotes', 'Bebidas', 'Snacks'] ['Abarrotes', 'Bebidas', 'Snacks']
['Abarrotes'] ['Bebidas'] ['Snacks']

En la primera línea salen tres veces la misma lista porque los tres return devuelven el mismo objeto, ya con todo dentro.

6. Validar antes de calcular

Una función que calcule el ticket promedio y devuelva 0 si la lista viene vacía, en vez de reventar.

def ticket_promedio(montos):
    if not montos:
        return 0
    return round(sum(montos) / len(montos), 2)

print(ticket_promedio([100, 200, 300]))
print(ticket_promedio([]))
200.0
0

Sin ese if te sale un ZeroDivisionError. Y las listas vacías llegan más seguido de lo que uno cree: un filtro que no encontró nada ya te dejó una.

7. El alcance

Comprueba que una variable creada dentro de una función no existe fuera.

def total_con_igv(monto):
    igv = monto * 0.18          # solo existe aqui dentro
    return monto + igv

print(total_con_igv(100))
print(igv)
NameError: name 'igv' is not defined
8. Ordenar con lambda

Ordena una lista de clientes por su nombre sin que las mayúsculas alteren el orden.

clientes = ['bodega rosa', 'Abarrotes Luz', 'MINIMARKET SOL']

print(sorted(clientes, key=lambda c: c.lower()))
['Abarrotes Luz', 'bodega rosa', 'MINIMARKET SOL']
9. Una función que usa otra

Escribe con_igv y con_descuento, y después una tercera que las combine.

def con_igv(monto, igv=0.18):
    return monto * (1 + igv)

def con_descuento(monto, dcto=0.10):
    return monto * (1 - dcto)

def precio_final(base):
    return round(con_igv(con_descuento(base)), 2)

print(precio_final(1000))
1062.0

Funciones chiquitas que se combinan es exactamente la idea. Y de paso: el orden importa, porque el IGV se calcula sobre el monto ya descontado y no al revés.

10. Con su docstring

Escribe una función que clasifique un puntaje de satisfacción del 1 al 5, con docstring, y muéstrala con help.

def clasifica(puntaje):
    """Traduce un puntaje de 1 a 5 a una etiqueta de riesgo."""
    if puntaje >= 4:
        return 'Satisfecho'
    if puntaje == 3:
        return 'Neutral'
    return 'En riesgo'

print(clasifica(5), clasifica(3), clasifica(1))
print(clasifica.__doc__)
Satisfecho Neutral En riesgo
Traduce un puntaje de 1 a 5 a una etiqueta de riesgo.
11. Una función que recibe otra función

En Python las funciones son valores, así que se pueden guardar en variables y pasar como argumento.

def aplica_a_todas(ventas, calculo):
    return [calculo(v) for v in ventas]

def con_igv(monto):
    return round(monto * 1.18, 2)

def redondea_a_soles(monto):
    return round(monto)

ventas = [120.5, 340.0, 89.9]
print('con IGV   :', aplica_a_todas(ventas, con_igv))
print('redondeado:', aplica_a_todas(ventas, redondea_a_soles))
print('y con lambda:', aplica_a_todas(ventas, lambda m: m / 2))
con IGV   : [142.19, 401.2, 106.08]
redondeado: [120, 340, 90]
y con lambda: [60.25, 170.0, 44.95]

Fíjate en que con_igv va sin paréntesis al pasarla. Con paréntesis la llamarías; sin ellos la entregas 🎁

Ese es exactamente el error del ejercicio 3 de este capítulo visto desde el otro lado, y es el mismo patrón que usas cada vez que escribes sorted(lista, key=...).

12. La función que se llama a sí misma

Recorrer un diccionario con diccionarios dentro, sin saber cuántos niveles tiene.

pedido = {
    'cliente': 'Rosita',
    'entrega': {'ciudad': 'Lima', 'distrito': 'Villa El Salvador'},
    'items': {'abarrotes': {'arroz': 12, 'aceite': 4}, 'bebidas': {'gaseosa': 20}},
}

def aplana(d, prefijo=''):
    salida = {}
    for clave, valor in d.items():
        nombre = f'{prefijo}{clave}'
        if isinstance(valor, dict):
            salida.update(aplana(valor, prefijo=f'{nombre}.'))
        else:
            salida[nombre] = valor
    return salida

for clave, valor in aplana(pedido).items():
    print(f'{clave:28s} {valor}')
cliente                      Rosita
entrega.ciudad               Lima
entrega.distrito             Villa El Salvador
items.abarrotes.arroz        12
items.abarrotes.aceite       4
items.bebidas.gaseosa        20

Una función que se llama a sí misma se llama recursiva, y da más miedo del que merece 👻

Lo único que hay que asegurar es que en algún momento pare. Aquí para sola: cuando el valor deja de ser un diccionario, no vuelve a llamarse.

Y este ejercicio concreto lo vas a necesitar de verdad. Los JSON que devuelven las APIs vienen anidados así, y para meterlos en una tabla hay que aplanarlos primero 🗜️

La función que limpia y ensucia a la vez

Falta una sola cosa antes de cerrar funciones, y es la que más veces he visto acabar en una tarde entera de buscar dónde cambió el dato 🔎

La trampa

Escribes una función que limpia una lista de ciudades y devuelve el resultado. La usas, guardas lo que devuelve y sigues.

def limpiar(ciudades):
    for i, c in enumerate(ciudades):
        ciudades[i] = c.strip().lower()
    return ciudades

limpias = limpiar(originales)
Qué está mal

La función modificó originales también 🪞 En Python los argumentos se pasan por referencia, así que dentro de la función ciudades y fuera originales son la misma lista. limpias no es una copia limpia: es otro nombre para lo mismo.

Y como además devuelve la lista, todo aparenta que devolvió algo nuevo. Dos líneas más abajo comparas el antes con el después y salen idénticos, y te vuelves loca buscando por qué.

Una función que devuelve algo no debería modificar lo que recibe. O modifica en el sitio y no devuelve nada, o construye una lista nueva y la devuelve. Las dos cosas a la vez es la receta de las tardes perdidas.

Comprueba que lo tienes

Escribes def guarda(dato, lista=[]) y al llamarla dos veces la lista trae lo de la llamada anterior. ¿Por qué?

  • Porque el valor por defecto se crea una sola vez, al definir
  • Porque falta un return
  • Porque las listas son globales
  • Porque hay que llamarla con lista=[] cada vez

Lo que te llevas

  • 🎁 Una función calcula y return; quien la llama decide si imprime.
  • 🏷️ Con más de dos argumentos, llámalos por nombre.
  • 💣 Nunca una lista o un diccionario como valor por defecto: usa None.
  • 📦 Varias cosas de vuelta se desempaquetan; si son muchas, devuelve un diccionario.
  • 📝 El docstring dice para qué sirve y de dónde salieron los números.

Y si de todo el capítulo te llevas una sola frase, que sea esta:

Una función o devuelve algo o cambia algo. Las dos a la vez es la receta de las tardes perdidas.

Y esto de empaquetar lo que repites para no volver a escribirlo no es solo de Python: es lo mismo que hago con la IA para no repetir la misma instrucción cada semana, que lo conté en skills de Claude 🤖

En el capítulo 7 vamos a lo que pasa cuando algo falla: los errores más comunes con datos, cómo atraparlos sin taparlos, y cómo depurar sin volverte loca.

Que tengas un hermoso día! 🌟

Preguntas frecuentes

¿Cómo se hace una función en Python?

Con def, el nombre, los paréntesis con lo que recibe y dos puntos. Lo que va dentro se escribe indentado, y return devuelve el resultado.

¿Qué hace return en Python?

Devuelve un valor y termina la función ahí mismo. Sin return la función devuelve None, que es el motivo número uno de que algo salga vacío sin dar error.

¿Qué es una función lambda?

Una función de una sola línea y sin nombre. Sirve para pasarla como argumento a otra cosa, y si la necesitas dos veces, mejor escríbela con def.

¿Cuál es la diferencia entre parámetro y argumento?

El parámetro es el nombre que la función usa por dentro; el argumento es el valor que le pasas al llamarla.

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?