UdelaR  ·  Máster en Análisis de Datos

Introducción a Python

Clase 6a — Introducción a funciones

Asignatura  Introducción a Python Profesor  Daniel Miles Touya Universidad  UdelaR-Uvigo
Parte I

Qué es una función y por qué existen

Por qué existen las funciones: abstracción y reutilización teoría

Hasta ahora hemos escrito código línea a línea — cada instrucción visible, cada paso explícito. Las funciones cambian eso radicalmente: permiten nombrar un bloque de código para ejecutarlo cuando se necesite, tantas veces como se quiera, desde cualquier parte del programa.

Pero las funciones no son solo una herramienta de organización. Representan una forma distinta de pensar sobre el código — dos tipos de abstracción que son la base de toda la programación moderna.

— Process abstraction: olvidarse del cómo —

La abstracción de proceso permite usar una función sin saber cómo está implementada por dentro. Solo necesitas saber qué hace — qué recibe y qué devuelve. El cómo lo hace es irrelevante.

Ya llevas seis clases usando funciones sin haber definido ninguna: len(), sum(), sorted(), round(), print(). Ninguna vez has necesitado saber cómo Python calcula la longitud de una lista o cómo ordena internamente. Confías en que lo hace bien. Eso es abstracción de proceso.

# Usamos round() sin saber cómo funciona por dentro
valor = round(88.5678, 2)
print(valor)   # 88.57 — confiamos en que lo hace bien

# Usamos sorted() sin saber qué algoritmo usa
datos = [5, 2, 8, 1, 9, 3]
ordenados = sorted(datos)
print(ordenados)   # [1, 2, 3, 5, 8, 9]

# Lo mismo ocurrirá con tus funciones
# Una vez definida, quien la use no necesita leer su interior

— Task decomposition: divide y vencerás —

La descomposición de tareas permite partir un problema complejo en partes más pequeñas y manejables. Cada función resuelve una parte concreta. El programa principal las combina.

Esta es exactamente la forma en que deberías usar la IA para programar: no pedir un script monolítico de 200 líneas, sino pedir funciones pequeñas que resuelvan partes concretas — y combinarlas tú. Cada función es una unidad verificable por separado.

Leer el código: ¿qué hace?

# Sin funciones: un bloque largo e indiviso
datos = [22, 25, 19, 30, 18, 28, 21]
total = 0
for d in datos:
    total += d
media = total / len(datos)
maximo = datos[0]
for d in datos:
    if d > maximo:
        maximo = d
print(f"media: {media:.1f}, máximo: {maximo}")

# Con funciones: tareas separadas, verificables, reutilizables
def calcular_media(datos):
    return sum(datos) / len(datos)

def calcular_maximo(datos):
    return max(datos)

def analizar(datos):
    return calcular_media(datos), calcular_maximo(datos)

media, maximo = analizar(datos)
print(f"media: {media:.1f}, máximo: {maximo}")
Las tres ventajas concretas: reutilización — escribe una vez, usa muchas veces; verificación — puedes probar cada función por separado antes de combinarlas; legibilidad — el programa principal lee como pseudocódigo si los nombres de las funciones son descriptivos.

— Funciones e IA: el flujo correcto —

Cuando le pides a la IA que resuelva un problema complejo en un solo bloque, obtienes código difícil de verificar y difícil de corregir cuando falla. Cuando le pides funciones pequeñas con responsabilidades claras, puedes verificar cada una por separado, sustituir las que fallen y combinar las que funcionen.

La abstracción de proceso también aplica aquí: una vez que una función está verificada, el resto del código puede usarla sin preocuparse de su interior. Exactamente igual que tú usas sorted() sin leer su implementación.

  1. ¿Qué es la abstracción de proceso? Da dos ejemplos de funciones de Python que hayas usado en clases anteriores sin saber cómo están implementadas.
  2. En el ejemplo de análisis de datos: ¿qué ventaja tiene la versión con funciones sobre la versión sin funciones si necesitas calcular la media de diez listas distintas?
  3. ¿Por qué es mejor pedirle a la IA funciones pequeñas en lugar de un script completo? ¿Qué permite hacer la descomposición en funciones que no permite un bloque monolítico?
Respuesta orientativa
a

La abstracción de proceso es la capacidad de usar una función conociendo solo qué hace — sus entradas y su salida — sin necesitar saber cómo lo hace internamente. Ejemplos de clases anteriores: sorted() — ordena una lista sin revelar el algoritmo; sum() — suma los elementos sin mostrar el bucle; dict.get() — accede con valor por defecto sin exponer la lógica interna.

b

Con la versión sin funciones, habría que copiar y pegar el bloque completo diez veces — con el riesgo de introducir errores en cada copia. Con calcular_media(datos), basta con llamarla diez veces con cada lista. Si hay un error en el cálculo, se corrige en un único lugar y afecta a todos los usos.

c

Las funciones pequeñas permiten verificar cada parte por separado antes de combinarlas. Si un script de 200 líneas falla, no sabes en qué parte está el error. Si falla una función de 10 líneas, el problema está acotado. Además, si la IA genera una función incorrecta, puedes pedirle que la corrija o sustituirla sin tocar el resto.

El principio general: divide el problema en partes lo suficientemente pequeñas para que puedas verificar cada una manualmente. Una función bien nombrada es también documentación — calcular_media(datos) dice exactamente lo que hace sin necesidad de leer su interior.
Callable types: función, método y atributo teoría

En la Clase 2 establecimos el principio fundamental del data model de Python: todo en Python es un objeto. Cada objeto tiene identidad, tipo y valor. Desde entonces hemos trabajado con objetos de tipo int, str, list, dict y más.

Ahora añadimos un tipo de objeto nuevo: las funciones. En Python, las funciones son objetos como cualquier otro — tienen identidad, tipo y valor. Pero tienen una propiedad especial que la mayoría de objetos no tiene: son callables, es decir, se pueden llamar con () para ejecutar el código que contienen.

— Repaso: objetos, tipos e identidad —

# Todo en Python es un objeto con identidad, tipo y valor
x = 42
print(f"valor: {x}")
print(f"tipo:  {type(x)}")
print(f"id:    {hex(id(x))}")

# Una lista también es un objeto
lista = [1, 2, 3]
print(f"\nvalor: {lista}")
print(f"tipo:  {type(lista)}")
print(f"id:    {hex(id(lista))}")

# Una función también es un objeto
def saludar():
    print("Hola")

print(f"\nvalor: {saludar}")
print(f"tipo:  {type(saludar)}")
print(f"id:    {hex(id(saludar))}")

saludar sin paréntesis es el objeto función — una referencia al bloque de código en memoria. saludar() con paréntesis ejecuta ese bloque. Esta distinción — objeto vs ejecución — es central en Python y en todo el código generado por IA.

— Atributo, método y función: tres cosas distintas —

Cuando escribes objeto.algo accedes a un atributo del objeto — un valor asociado a él. Cuando escribes objeto.algo() llamas a un método — una función que pertenece al objeto y opera sobre él. Cuando escribes funcion(objeto) llamas a una función independiente que recibe el objeto como argumento.

Veremos esto en detalle en la clase siguiente cuando hablemos de modulos

Los tres aparecen constantemente en código de Python y en código generado por IA. Saber distinguirlos permite leer cualquier script sin confusión.

lista = [3, 1, 4, 1, 5]

# ATRIBUTO: valor asociado al objeto — sin paréntesis
print(lista.__class__)       # 
print(lista.__class__.__name__)  # 'list'

# MÉTODO: función que pertenece al objeto — con paréntesis
lista.append(9)             # modifica la lista en lugar
print(lista.count(1))       # cuenta las apariciones de 1
print(lista.index(4))       # posición del 4

# FUNCIÓN: independiente — recibe el objeto como argumento
print(len(lista))            # longitud
print(sorted(lista))        # devuelve lista nueva ordenada
print(type(lista))          # tipo del objeto

# La distinción en pandas — ya la habéis visto
import pandas as pd
df = pd.DataFrame({"a": [1, 2, 3]})

print(df.shape)              # atributo — sin paréntesis
print(df.columns)           # atributo — sin paréntesis
print(df.head())            # método — con paréntesis
print(df["a"].mean())       # método — con paréntesis

— Qué es un callable —

Un callable es cualquier objeto que se puede llamar con () para ejecutar código. Python define esto formalmente: un objeto es callable si implementa el método especial __call__(). Puedes verificarlo con la función callable().

Los callables más frecuentes son: las funciones definidas con def, las funciones built-in como len, los métodos de objetos, las clases (llamar a una clase crea una instancia), y cualquier objeto que tenga __call__() definido.

# callable() verifica si un objeto se puede llamar con ()
def mi_funcion():
    return 42

print(callable(mi_funcion))   # True  — es callable
print(callable(len))          # True  — función built-in
print(callable(print))        # True  — función built-in
print(callable(list))         # True  — clase (callable)
print(callable([1, 2, 3]))    # False — lista no es callable
print(callable(42))           # False — int no es callable
print(callable("hola"))       # False — str no es callable

# La señal visual: los paréntesis () son la ejecución
print(mi_funcion)    # el objeto función — no se ejecuta
print(mi_funcion())  # se ejecuta — devuelve 42

# Error frecuente: llamar a un no-callable
try:
    resultado = lista()   # lista no es callable
except TypeError as e:
    print(f"TypeError: {e}")

— Resumen: atributo, método y función —

Concepto Sintaxis Callable Ejemplo
Atributo obj.nombre No df.shape, lista.__class__
Método obj.nombre() Sí lista.append(x), df.head()
Función nombre(obj) Sí len(lista), sorted(lista)
Clase Nombre() Sí list(), dict()
  1. Ejecuta el bloque de repaso. ¿Qué tipo tiene la función saludar? ¿Y len? ¿Tienen id? ¿Qué confirma eso sobre su naturaleza como objetos?
  2. Dado este código, identifica para cada línea si es acceso a un atributo, llamada a un método o llamada a una función:
    texto = "Python"
    print(len(texto))
    print(texto.upper())
    print(texto.__class__)
    print(texto.replace("P", "J"))
    print(type(texto))
  3. ¿Cuál de estos objetos es callable? Predice antes de ejecutar:
    print(callable(sorted))
    print(callable(3.14))
    print(callable(dict))
    print(callable({"a": 1}))
    print(callable(print))
  4. ¿Por qué df.shape no lleva paréntesis pero df.head() sí? ¿Qué pasaría si escribieras df.shape()? ¿Y df.head sin paréntesis?
Respuesta orientativa
a

saludar tiene tipo <class 'function'>. len tiene tipo <class 'builtin_function_or_method'>. Ambos tienen id — son objetos en memoria como cualquier lista o número. Esto confirma que en Python las funciones son ciudadanos de primera clase — objetos completos con identidad, tipo y valor. Lo desarrollaremos en detalle en la Clase 6b.

b

len(texto) → función independiente. texto.upper() → método del objeto string. texto.__class__ → atributo — sin paréntesis, devuelve <class 'str'>. texto.replace("P", "J") → método del objeto string. type(texto) → función independiente — equivalente a texto.__class__ pero más legible.

c

callable(sorted) → True — función built-in. callable(3.14) → False — float. callable(dict) → True — clase callable. callable({"a": 1}) → False — instancia dict. callable(print) → True — función built-in.

La distinción clave: dict la clase es callable — llamarla crea un diccionario vacío. {"a": 1} una instancia de dict no es callable — no puedes ejecutar un diccionario. La clase y sus instancias son objetos distintos.
d

df.shape es un atributo — devuelve una tupla con las dimensiones. No necesita paréntesis porque no ejecuta ningún cálculo, solo accede a un valor almacenado. df.head() es un método — ejecuta código que construye y devuelve las primeras filas.

df.shape() produciría un TypeError: 'tuple' object is not callable — intentas llamar a una tupla como si fuera una función. df.head sin paréntesis devuelve el objeto método sin ejecutarlo — verías algo como <bound method DataFrame.head ...>.

Parte II

Definición, llamada y return

Definición de una función: sintaxis y estructura teoría

Una función se define con la palabra clave def seguida del nombre, los parámetros entre paréntesis y dos puntos. El cuerpo de la función va indentado — igual que los bloques de if y for.

La definición tiene cinco partes: def, el nombre, los parámetros, el docstring opcional, el cuerpo y el return.

— Anatomía de una función —

# ┌── palabra clave
# │    ┌── nombre
# │    │              ┌── parámetros
# │    │              │
def area_circulo(radio):
    """Calcula el área de un círculo dado su radio.

    Parámetros:
        radio (float): el radio del círculo

    Retorna:
        float: el área del círculo
    """                          # ← docstring
    pi = 3.1416                   # ← cuerpo
    return pi * radio ** 2        # ← return

El docstring es una cadena de texto entre triple comilla justo después de la definición. No es un comentario — es parte del objeto función y se puede acceder a él desde fuera con .__doc__. Es la forma estándar de documentar qué hace una función, qué recibe y qué devuelve.

— El nombre vs la llamada: objeto vs ejecución —

area_circulo sin paréntesis es el objeto función — una referencia al bloque de código almacenado en memoria. area_circulo(5) con paréntesis ejecuta ese bloque pasando 5 como argumento. Esta distinción es fundamental y aparece constantemente en código de Python.

# Sin paréntesis — el objeto función
print(area_circulo)
# 

# Con paréntesis — ejecutar la función
print(area_circulo(5))
# 78.54

# Asignar la función a otra variable
# — las dos apuntan al mismo objeto
calcular_area = area_circulo
print(calcular_area(5))   # mismo resultado
print(calcular_area is area_circulo)   # True

— Dunder methods: los atributos internos de una función —

En Python, todos los objetos tienen atributos especiales cuyos nombres empiezan y terminan con dos guiones bajos: __nombre__. Se llaman dunder methods (de double underscore) o métodos especiales. Son la interfaz interna de Python — definen cómo se comporta el objeto con los operadores, las funciones built-in y otros mecanismos del lenguaje.

Ya los hemos visto en la Clase 2 con __iter__ y __next__ en los iterables. Las funciones también tienen los suyos. Los más útiles para leer e interpretar código son tres — los veremos en detalle en la Clase 6b, pero vale la pena conocerlos ya.

def interes_compuesto(K, r=0.05, n=1):
    """Calcula el valor futuro con interés compuesto."""
    return K * (1 + r) ** n

# __name__ — el nombre de la función como string
print(interes_compuesto.__name__)
# 'interes_compuesto'

# __doc__ — el docstring
print(interes_compuesto.__doc__)
# 'Calcula el valor futuro con interés compuesto.'

# __defaults__ — los valores por defecto de los parámetros
print(interes_compuesto.__defaults__)
# (0.05, 1) — tupla con los valores por defecto

# Sin valores por defecto → None
def suma(a, b):
    return a + b
print(suma.__defaults__)
# None

# Veremos más dunder methods en la Clase 6b:
# __code__, __globals__, __annotations__, __call__
print(dir(interes_compuesto))   # todos los atributos

¿Por qué importan los dunder methods? Porque aparecen constantemente en código generado por IA y en código de librerías. f.__name__ permite saber qué función es sin ejecutarla. f.__doc__ permite leer la documentación desde el código. f.__defaults__ permite ver los valores por defecto sin leer la definición. Son la introspección del lenguaje — Python leyéndose a sí mismo.

Atributo Qué contiene Ejemplo
__name__ Nombre de la función 'interes_compuesto'
__doc__ Docstring 'Calcula el valor...'
__defaults__ Valores por defecto (0.05, 1)
__code__ Objeto con el bytecode Clase 6b
__annotations__ Anotaciones de tipo Clase 6b
__call__ Lo que ejecuta los () Clase 6b
  1. Define una función llamada calcular_imc que reciba peso y altura y devuelva el IMC. Añade un docstring descriptivo. Luego accede a __name__, __doc__ y __defaults__. ¿Qué devuelve __defaults__ si ningún parámetro tiene valor por defecto?
  2. Predice qué imprime cada línea antes de ejecutar:
    def doble(x):
        return x * 2
    
    print(doble)
    print(doble(5))
    print(doble.__name__)
    print(type(doble))
    print(callable(doble))
  3. ¿Qué diferencia hay entre un docstring y un comentario con #? ¿Por qué el docstring es preferible para documentar funciones?
  4. ¿Por qué dir(funcion) devuelve una lista tan larga? ¿Qué nos dice eso sobre la naturaleza de las funciones en Python?
Respuesta orientativa
a
def calcular_imc(peso, altura):
    """Calcula el Índice de Masa Corporal.

    Parámetros:
        peso (float): peso en kilogramos
        altura (float): altura en metros

    Retorna:
        float: el IMC redondeado a 2 decimales
    """
    return round(peso / altura ** 2, 2)

print(calcular_imc.__name__)      # 'calcular_imc'
print(calcular_imc.__doc__)       # el docstring
print(calcular_imc.__defaults__)  # None — sin defaults

__defaults__ devuelve None cuando ningún parámetro tiene valor por defecto. Si al menos uno tiene default, devuelve una tupla con esos valores.

b

print(doble) → <function doble at 0x...> — el objeto función. print(doble(5)) → 10 — resultado de ejecutar. print(doble.__name__) → 'doble'. print(type(doble)) → <class 'function'>. print(callable(doble)) → True.

c

Un comentario con # es ignorado por Python — no existe en tiempo de ejecución y no se puede acceder a él desde el código. Un docstring es una cadena de texto que Python almacena como atributo __doc__ del objeto función. Se puede leer programáticamente, aparece en la ayuda interactiva con help(funcion) y es la forma estándar que usan todas las librerías de Python para documentar sus funciones.

d

dir(funcion) devuelve todos los atributos del objeto — los dunder methods más los atributos propios. La lista es larga porque las funciones en Python son objetos completos con muchos atributos internos. Nos dice que una función no es solo código a ejecutar — es un objeto con estado, con atributos accesibles, con métodos propios. En Python no hay distinción entre "función" y "objeto": toda función es un objeto del tipo function.

Definición vs llamada: cuándo se ejecuta el cuerpo teoría

Hay una distinción fundamental que confunde a todos los principiantes y que produce errores difíciles de detectar: definir una función no es lo mismo que ejecutarla.

Cuando Python encuentra un bloque def, hace exactamente una cosa: crea el objeto función en memoria y lo asocia al nombre que le diste. No entra en el cuerpo. No ejecuta ninguna línea del interior. No detecta ningún error que haya dentro. Solo crea el objeto y sigue adelante.

El cuerpo se ejecuta únicamente cuando llamas a la función con (). Hasta ese momento el interior de la función no existe para Python.

— def crea el objeto, () lo ejecuta —

# Paso 1: Python procesa el def
# Crea el objeto función en memoria
# No ejecuta ninguna línea del interior
def saludar(nombre):
    print(f"Hola, {nombre}")   # ← esta línea no se ejecuta aún

# Aquí el objeto existe pero el cuerpo nunca se ha ejecutado
print("La función existe:", saludar)
print("Pero no se ha ejecutado aún")

# Paso 2: llamamos con () — ahora sí se ejecuta el cuerpo
saludar("Ana")   # ← aquí se ejecuta print(f"Hola, {nombre}")

— La consecuencia directa: errores que no se ven hasta que se llama —

Si el cuerpo de una función contiene un error — de sintaxis en tiempo de ejecución, un nombre que no existe, una operación imposible — Python no lo detecta cuando defines la función. Lo detecta solo cuando la llamas.

Esto es radicalmente distinto a un error en código normal: si escribes 1 / 0 en el nivel superior de un script, falla inmediatamente al ejecutar esa línea. Si lo escribes dentro de una función, no falla hasta que alguien llama a esa función. Y si nadie la llama, el error nunca aparece — aunque esté ahí.

# Un error en código normal — falla inmediatamente
# print(1 / 0)   # ZeroDivisionError en cuanto se ejecuta esta línea

# El mismo error dentro de una función — no falla al definirla
def funcion_con_error():
    return 1 / 0   # ← Python no ve esto al procesar def

# Hasta aquí: ningún error. La función existe.
print("Función definida sin error")
print(funcion_con_error)

# El error aparece solo al llamarla
try:
    funcion_con_error()
except ZeroDivisionError as e:
    print(f"Error al llamar: {e}")

# Si nunca la llamamos, el error nunca aparece
# aunque el código incorrecto esté ahí

— Tres tipos de error que se ocultan dentro de una función —

# Error 1: variable que no existe
def usar_variable_inexistente():
    return variable_que_no_existe * 2

print("Definida sin error")   # ← no falla aquí
try:
    usar_variable_inexistente()
except NameError as e:
    print(f"NameError al llamar: {e}")

print("---")

# Error 2: tipo incorrecto — solo falla con ciertos argumentos
def multiplicar(a, b):
    return a * b

print(multiplicar(3, 4))      # 12 — funciona
print(multiplicar("x", 3))   # 'xxx' — funciona, aunque raro
try:
    multiplicar([1, 2], [3, 4])  # TypeError — listas no se multiplican
except TypeError as e:
    print(f"TypeError al llamar: {e}")

print("---")

# Error 3: lógica incorrecta — no lanza excepción pero el resultado es malo
def area_circulo_mal(radio):
    pi = 3.1416
    return pi * radio   # ← falta ** 2 — error lógico silencioso

print(area_circulo_mal(5))   # 15.708 — no falla, pero es incorrecto
# El área correcta es 78.54 — nadie avisa del error

— La diferencia con código fuera de una función —

Código normal — falla inmediatamente

x = 1 / 0
# ZeroDivisionError
# en esta misma línea

Dentro de función — falla al llamar

def f():
    x = 1 / 0  # no falla aquí
f()  # falla aquí
Implicación directa para código de IA: cuando la IA genera una función, el código puede parecer correcto — no hay errores al pegarlo y ejecutar la definición. Los errores aparecen solo cuando llamas a la función con argumentos concretos. Por eso siempre hay que llamar a cada función generada por IA con varios ejemplos — incluyendo casos límite — antes de confiar en ella. Una función no probada es código cuyo interior no se ha ejecutado nunca.

— Resumen: qué ocurre en cada momento —

Momento Qué hace Python Errores detectados
def funcion(): Crea el objeto función en memoria. Asocia el nombre. Solo errores de sintaxis graves
funcion(args) Ejecuta el cuerpo línea a línea. Vincula argumentos a parámetros. Todos los errores de ejecución
  1. ¿Qué imprime este código y cuándo falla? Predice antes de ejecutar:
    def dividir(x, y):
        return x / y
    
    print("A")
    print(dividir(10, 2))
    print("B")
    print(dividir(10, 0))
    print("C")
  2. ¿Por qué este código no lanza ningún error aunque la función tenga un error dentro? ¿Cuándo aparecería el error?
    def procesar(datos):
        return datos[100]   # índice que no existe
    
    print("Script cargado correctamente")
  3. Este código generado por IA calcula el porcentaje de cambio entre dos valores. ¿Tiene algún error? ¿Cuándo aparecería?
    def pct_cambio(anterior, nuevo):
        return (nuevo - anterior) / anterior * 100
    
    print(pct_cambio(100, 120))   # +20%
    print(pct_cambio(100, 80))    # -20%
    print(pct_cambio(0, 50))     # ¿?
  4. ¿Qué diferencia hay entre un error de ejecución (ZeroDivisionError, NameError) y un error lógico dentro de una función? ¿Cuál es más peligroso en un análisis de datos?
Respuesta orientativa
a

Imprime "A", luego 5.0, luego "B", luego falla con ZeroDivisionError al ejecutar dividir(10, 0). El "C" nunca se imprime. La función se definió sin error — el fallo ocurre solo en la segunda llamada, con los argumentos que producen la división por cero.

b

No lanza error porque Python no ejecuta el cuerpo de procesar al procesarlo con def. Solo crea el objeto función. El error aparecería al llamar procesar(lista) con cualquier lista de menos de 101 elementos. Si nadie llama nunca a procesar, el IndexError nunca aparece — aunque el código incorrecto esté en el fichero.

c

La función tiene un error silencioso: cuando anterior = 0 produce una división por cero. Las dos primeras llamadas funcionan correctamente — +20.0 y -20.0. La tercera lanza ZeroDivisionError.

Es exactamente el tipo de error que la IA no anticipa: el código es correcto para los casos normales pero falla en el caso límite anterior = 0. La corrección:

def pct_cambio(anterior, nuevo):
    if anterior == 0:
        return None   # o raise ValueError
    return (nuevo - anterior) / anterior * 100
d

Un error de ejecución lanza una excepción — Python avisa, el programa se detiene y sabes exactamente dónde está el problema. Un error lógico no lanza ninguna excepción — la función devuelve un resultado incorrecto sin ningún aviso.

El error lógico es más peligroso en análisis de datos porque produce resultados incorrectos que parecen correctos. Un ZeroDivisionError detiene el análisis y lo sabes. Un área calculada sin elevar al cuadrado produce un número plausible que se propaga a todas las conclusiones sin que nadie lo detecte.
return: el valor que devuelve una función teoría

return hace dos cosas a la vez: termina la ejecución de la función y devuelve un valor al lugar donde se hizo la llamada. Ninguna línea del cuerpo después de un return se ejecuta.

Si una función no tiene return — o tiene un return sin valor — Python devuelve None automáticamente. Este comportamiento silencioso es la fuente de uno de los errores más frecuentes en Python: confundir print con return.

— return termina la función y devuelve un valor —

# return termina la función — nada se ejecuta después
def clasificar(nota):
    if nota >= 9:
        return "sobresaliente"   # sale aquí si nota >= 9
    if nota >= 7:
        return "notable"         # sale aquí si 7 <= nota < 9
    if nota >= 5:
        return "aprobado"         # sale aquí si 5 <= nota < 7
    return "suspenso"             # sale aquí si nota < 5

print(clasificar(9.5))   # sobresaliente
print(clasificar(7.2))   # notable
print(clasificar(5.0))   # aprobado
print(clasificar(3.5))   # suspenso

# El return devuelve el valor al lugar de la llamada
resultado = clasificar(8.0)
print(f"Resultado guardado: {resultado}")   # notable

— Sin return la función devuelve None —

None es el valor especial de Python que significa "ausencia de valor". Cuando una función no tiene return, Python añade implícitamente return None al final. Si intentas usar el resultado de esa función — asignarlo, sumarlo, imprimirlo como dato — obtendrás resultados inesperados o un TypeError.

# Función sin return — devuelve None implícitamente
def saludar(nombre):
    print(f"Hola, {nombre}")   # print no es return

resultado = saludar("Ana")
print(f"resultado: {resultado}")   # None
print(f"tipo: {type(resultado)}") # 

print("---")

# return sin valor — también devuelve None
def verificar(x):
    if x < 0:
        print("valor negativo")
        return             # sale sin devolver valor
    print(f"valor: {x}")
    return x * 2

print(verificar(5))    # valor: 5 → 10
print(verificar(-3))   # valor negativo → None

print("---")

# Intentar usar None como número — TypeError
try:
    total = saludar("Ana") + 1
except TypeError as e:
    print(f"TypeError: {e}")

— La trampa más frecuente: print vs return —

print y return parecen hacer lo mismo porque los dos muestran un valor. Pero son radicalmente distintos: print muestra el valor en pantalla y devuelve None. return devuelve el valor al código que llamó la función para que pueda usarlo.

Si solo usas print, el valor desaparece después de mostrarse — no se puede guardar, no se puede pasar a otra función, no se puede usar en un cálculo posterior. La IA comete este error con frecuencia cuando genera funciones — produce resultados con print en lugar de return.

# ❌ Con print — el valor se muestra pero no se puede usar
def area_print(radio):
    pi = 3.1416
    print(pi * radio ** 2)   # muestra el valor, devuelve None

# ✓ Con return — el valor se puede guardar y reutilizar
def area_return(radio):
    pi = 3.1416
    return pi * radio ** 2   # devuelve el valor

# Diferencia en la práctica
a1 = area_print(5)     # imprime 78.54, pero a1 = None
a2 = area_return(5)    # a2 = 78.54

print(f"\na1: {a1}")    # None
print(f"a2: {a2}")    # 78.54

# Solo con return puedes encadenar resultados
radios = [1, 2, 3, 4, 5]
areas = [area_return(r) for r in radios]
print([f"{a:.2f}" for a in areas])

# Con area_print no funcionaría — devuelve lista de None
areas_mal = [area_print(r) for r in radios]
print(areas_mal)   # [None, None, None, None, None]

— Retorno múltiple: devolver varios valores —

Una función puede devolver varios valores separados por coma. Python los empaqueta automáticamente en una tupla. En la llamada puedes desempaquetarlos directamente o recibirlos como tupla.

# Devolver varios valores — Python crea una tupla implícita
def estadisticas(datos):
    return min(datos), max(datos), sum(datos) / len(datos)

# Recibir como tupla
resultado = estadisticas([4, 7, 2, 9, 1])
print(resultado)        # (1, 9, 4.6)
print(type(resultado))  # 

# Desempaquetar directamente — más legible
minimo, maximo, media = estadisticas([4, 7, 2, 9, 1])
print(f"min: {minimo}, max: {maximo}, media: {media:.1f}")

# Ignorar algún valor con _
_, maximo, _ = estadisticas([4, 7, 2, 9, 1])
print(f"solo el máximo: {maximo}")

# Patrón frecuente en pandas y sklearn
import pandas as pd
def resumen_columna(serie):
    return serie.mean(), serie.std(), serie.median()

s = pd.Series([4, 7, 2, 9, 1, 5])
media, desv, mediana = resumen_columna(s)
print(f"media: {media:.2f}, desv: {desv:.2f}, mediana: {mediana:.2f}")
  1. Predice qué imprime este código antes de ejecutarlo:
    def f(x):
        if x > 0:
            return x * 2
        print("negativo")
    
    a = f(5)
    b = f(-3)
    print(a)
    print(b)
    print(a is None)
    print(b is None)
  2. Este código generado por IA tiene un error frecuente. Identifícalo y corrígelo:
    def calcular_descuento(precio, pct):
        descuento = precio * pct / 100
        precio_final = precio - descuento
        print(f"precio final: {precio_final:.2f}€")
    
    precios = [100, 250, 80]
    finales = [calcular_descuento(p, 10) for p in precios]
    print(f"total: {sum(finales):.2f}€")
  3. ¿Qué devuelve esta función? ¿Cuántos valores contiene el resultado?
    def separar(numeros):
        pares = [n for n in numeros if n % 2 == 0]
        impares = [n for n in numeros if n % 2 != 0]
        return pares, impares
    
    resultado = separar([1, 2, 3, 4, 5, 6])
    print(type(resultado))
    print(resultado)
  4. ¿En qué situación tiene sentido usar print dentro de una función en lugar de return? ¿Y cuándo es siempre mejor usar return?
Respuesta orientativa
a

f(5) entra en el if, ejecuta return 10 — no llega al print. a = 10. f(-3) no entra en el if, ejecuta print("negativo") y termina sin return — devuelve None. b = None.

Imprime: "negativo" al ejecutar f(-3), luego 10, luego None, luego False (a no es None), luego True (b es None).

b

El error es que calcular_descuento usa print en lugar de return. Devuelve None y la lista finales es [None, None, None]. sum(finales) lanza un TypeError al intentar sumar None. La corrección:

def calcular_descuento(precio, pct):
    descuento = precio * pct / 100
    return precio - descuento   # ← return en lugar de print

precios = [100, 250, 80]
finales = [calcular_descuento(p, 10) for p in precios]
print(f"total: {sum(finales):.2f}€")   # 387.00€
c

Devuelve una tupla de dos elementos: ([2, 4, 6], [1, 3, 5]). type(resultado) es tuple. Aunque el return no tiene paréntesis, Python empaqueta los dos valores separados por coma en una tupla automáticamente.

Patrón frecuente: devolver múltiples listas o valores relacionados como tupla y desempaquetar en la llamada: pares, impares = separar(numeros). Es más limpio que devolver un diccionario cuando el número de valores es pequeño y fijo.
d

print tiene sentido cuando la función es de diagnóstico — para mostrar información al usuario o para depurar — y el resultado no se necesita para nada más. Por ejemplo, una función mostrar_resumen(df) que imprime estadísticas formateadas sin devolver nada.

return es siempre mejor cuando el resultado se va a usar en un cálculo, guardar en una variable, pasar a otra función o incluir en una comprehension. En general: si no estás seguro, usa return — siempre puedes hacer print del resultado fuera de la función.

Python Tutor: visualizar cómo ejecuta Python una función actividad

Hasta ahora hemos descrito lo que hace Python cuando define y llama una función — la creación del objeto, el frame, la vinculación de argumentos. Python Tutor permite verlo ocurrir paso a paso: qué variables existen en cada momento, dónde viven, cuándo aparecen y cuándo desaparecen.

Es la herramienta más útil para construir el modelo mental correcto de cómo funciona Python — y para detectar exactamente dónde falla un programa.

— Cómo usar Python Tutor —

Entra en pythontutor.com , haz clic en Start visualizing your code now, selecciona Python 3, pega el código y haz clic en Visualize Execution. Luego usa los botones Next y Prev para avanzar y retroceder paso a paso.

En cada paso observa tres zonas: el código con la línea actual resaltada, el stack con los frames activos y sus variables locales, y el heap con los objetos en memoria.

— Qué buscar en cada paso —

Al definir con def

El objeto función aparece en el heap. El nombre aparece en el frame global apuntando al objeto. El cuerpo no se ejecuta.

Al llamar con ()

Aparece un frame nuevo en el stack con los parámetros vinculados a los argumentos. El cuerpo empieza a ejecutarse.

Al ejecutar return

El frame desaparece del stack. Las variables locales dejan de existir. El valor vuelve al frame que hizo la llamada.

— Caso 1: definición y llamada básica —

El caso más simple: una función con un parámetro y un return. Observa cuándo aparece el frame de area_circulo y cuándo desaparece. Comprueba que pi y radio solo existen dentro del frame de la función.

Copia y pega esta función en Python Tutor y dale a Visualize Execution y Next

def area_circulo(radio):
    pi = 3.1416
    return pi * radio ** 2

area_circulo(4)

Qué observar paso a paso:

  1. Al procesar def: aparece area_circulo en el frame global apuntando al objeto función en el heap.
  2. Al llamar area_circulo(4): aparece un frame nuevo con radio = 4.
  3. Al ejecutar pi = 3.1416: aparece pi dentro del frame — no en el global.
  4. Al ejecutar return: el frame desaparece. pi y radio ya no existen.

— Caso 2: guardar el resultado en una variable —

Mismo código pero guardando el resultado en area4. Observa cómo el valor devuelto por return pasa al frame global y se asigna a area4.

def area_circulo(radio):
    pi = 3.1416
    return pi * radio ** 2

area4 = area_circulo(4)

Qué observar paso a paso:

  1. Compara con el Caso 1: la ejecución es idéntica hasta el return.
  2. Al ejecutar return: el valor pasa al frame global y se asigna a area4.
  3. Al finalizar: el frame de la función desaparece pero area4 persiste en el frame global.

— Caso 3: variable local para el resultado —

Introducimos area como variable local antes del return. Observa que area existe en el frame de la función pero desaparece al hacer return — solo el valor que devuelve return llega al frame global (local y global lo iremos viendo en los sucesivo, e.g., scope, pero quedate con la duda y a ver si te la resuelvo...).

def area_circulo(radio):
    pi = 3.1416
    area = pi * radio ** 2
    return area

area4 = area_circulo(4)

Qué observar paso a paso:

  1. Aparece area dentro del frame de la función — es local, no global.
  2. Al hacer return area: el valor de area se copia al frame global como area4.
  3. Al finalizar: area, pi y radio desaparecen con el frame. Solo area4 persiste.

— Caso 4: un error dentro de la función —

Mismo código pero con un error tipográfico deliberado: 'pi' como string en lugar de la variable pi. Observa en qué paso aparece el error y compáralo con el Caso 1 — la función se define sin problema, el error solo aparece al ejecutar la línea incorrecta.

def area_circulo(radio):
    pi = 3.1416
    area = 'pi' * radio ** 2   # 'pi' es un string, no la variable
    return area

area4 = area_circulo(4)

Qué observar paso a paso:

  1. Al procesar def: ningún error — la función se crea normalmente.
  2. Al llamar area_circulo(4): el frame aparece con radio = 4.
  3. Al ejecutar area = 'pi' * radio ** 2: 'pi' * 16 produce 'pipipi...' — no un error, sino un resultado inesperado.
  4. ¿Hay error o resultado incorrecto? ¿Por qué no lanza una excepción? ¿Cómo lo detectarías sin Python Tutor?
  1. Ejecuta los cuatro casos en Python Tutor. En el Caso 1: ¿cuántos pasos tiene la ejecución? ¿En qué paso aparece el frame de area_circulo? ¿En qué paso desaparece?
  2. En el Caso 3: ¿dónde vive la variable area durante la ejecución? ¿Puedes acceder a ella desde fuera de la función después de que termine? ¿Por qué?
  3. En el Caso 4: ¿qué valor tiene area4 al terminar? ¿Por qué Python no lanza ninguna excepción aunque el resultado es incorrecto? Conecta con lo visto en el Bloque 2 sobre errores lógicos.
  4. Escribe en Python Tutor tu propia función con dos parámetros y un return. Llámala dos veces con argumentos distintos. Observa que en cada llamada se crea un frame nuevo con sus propias variables.
Respuesta orientativa
a

El Caso 1 tiene 5 pasos: procesar def, llamar area_circulo(4) con creación del frame, ejecutar pi = 3.1416, ejecutar return y volver al frame global. El frame de area_circulo aparece en el paso 2 — al hacer la llamada — y desaparece en el paso 4 — al ejecutar return.

b

area vive en el frame local de area_circulo — solo existe mientras la función se está ejecutando. No se puede acceder a ella desde fuera después de que termine porque el frame desaparece con todas sus variables. Intentar acceder con print(area) fuera de la función produciría un NameError. Esto es el concepto de scope local que veremos en detalle en la Clase 6b.

c

area4 vale 'pipipipipipipipipipipipipipipipi' — el string 'pi' repetido 16 veces (radio ** 2 = 16). Python no lanza excepción porque multiplicar un string por un entero es una operación válida en Python — repite el string. Es un error lógico silencioso: el código es sintácticamente correcto y no viola ninguna regla de tipos, pero el resultado no es el esperado. Solo lo detectas si verificas el resultado — o si usas Python Tutor para ver el valor en cada paso.

La lección del Caso 4: los errores lógicos son los más peligrosos porque no interrumpen la ejecución. Python Tutor permite verlos en el momento en que ocurren — sin tener que añadir print de diagnóstico en cada línea.
d

Al llamar la función dos veces, Python Tutor muestra que cada llamada crea su propio frame con sus propias variables locales — completamente independiente de la llamada anterior. Los parámetros se vinculan a los nuevos argumentos y las variables locales empiezan desde cero. Esto demuestra que las variables locales de una función no persisten entre llamadas — cada ejecución es un estado limpio.

Parte III

Tipos de argumentos y unpacking

Parámetros y argumentos: posicionales y por defecto teoría

Dos términos que se confunden constantemente pero que significan cosas distintas: un parámetro es el nombre de variable que aparece en la definición de la función — es un placeholder, un nombre reservado para recibir un valor. Un argumento es el valor concreto que se pasa a la función en la llamada.

Cuando llamas a una función, Python vincula cada argumento a su parámetro correspondiente — el argumento pasa a ser el valor de esa variable local dentro del frame de la función.

# parámetros: base y exponente — nombres en la definición
def potencia(base, exponente):
    return base ** exponente

# argumentos: 2 y 3 — valores en la llamada
resultado = potencia(2, 3)
print(resultado)   # 8

# Los parámetros solo existen dentro de la función
# Fuera, solo existe el resultado devuelto por return
try:
    print(base)   # NameError — base no existe fuera
except NameError as e:
    print(f"NameError: {e}")

— Argumentos posicionales: el orden importa —

Un argumento posicional se vincula a su parámetro por la posición en que aparece — el primero al primer parámetro, el segundo al segundo, y así sucesivamente. No se nombra explícitamente. El orden es lo único que determina qué valor recibe cada parámetro.

def dividir(x, y):
    return x / y

# El orden determina qué recibe cada parámetro
print(dividir(10, 2))    # x=10, y=2  → 5.0
print(dividir(2, 10))    # x=2,  y=10 → 0.2
print(dividir(0, 10))    # x=0,  y=10 → 0.0

# Error de orden — resultado incorrecto sin aviso
def restar(total, descuento):
    return total - descuento

print(restar(100, 20))   # 80  — correcto
print(restar(20, 100))   # -80 — incorrecto, sin error

# Tres reglas de los posicionales:
# 1. Se pasan en el orden de la definición
# 2. Son obligatorios salvo que tengan default
# 3. No se nombran explícitamente

— Argumentos por defecto: parámetros opcionales —

Un argumento por defecto es un parámetro con un valor predefinido en la definición. Si la llamada no proporciona ese argumento, Python usa el valor por defecto. Si lo proporciona, el valor por defecto se ignora.

Regla sintáctica obligatoria: los parámetros con valor por defecto deben ir siempre después de los parámetros sin default. Si no, Python no puede determinar qué argumentos se han pasado y cuáles hay que inferir del default.

def interes_compuesto(K, r=0.05, n=1):
    """Calcula el valor futuro con interés compuesto."""
    return K * (1 + r) ** n

# Solo el obligatorio — r y n usan defaults
print(interes_compuesto(1000))
# K=1000, r=0.05, n=1 → 1050.0

# Sobreescribir n por nombre
print(interes_compuesto(1000, n=5))
# K=1000, r=0.05, n=5 → 1276.28

# Sobreescribir r por posición — n sigue con default
print(interes_compuesto(1000, 0.1))
# K=1000, r=0.1, n=1 → 1100.0

# Todos explícitos
print(interes_compuesto(1000, 0.1, 3))
# K=1000, r=0.1, n=3 → 1331.0

print("---")

# La regla: defaults siempre al final
# ✓ Correcto
def f(x, y=0):
    return x + y

# ❌ SyntaxError — default antes que no-default
try:
    exec("def g(x=0, y): return x + y")
except SyntaxError as e:
    print(f"SyntaxError: {e}")
El error silencioso más frecuente con posicionales: invertir el orden de los argumentos. restar(20, 100) en lugar de restar(100, 20) no produce ningún error — Python simplemente vincula los valores en el orden equivocado y devuelve un resultado incorrecto. Con funciones de muchos parámetros este error es muy difícil de detectar. Los keyword arguments — que veremos en el Bloque 2 — son la solución.
  1. ¿Cuál es la diferencia entre parámetro y argumento? Identifica los parámetros y los argumentos en este código:
    def saludar(nombre, saludo="Hola"):
        return f"{saludo}, {nombre}"
    
    print(saludar("Ana"))
    print(saludar("Bob", "Buenos días"))
  2. Predice el resultado de cada llamada antes de ejecutar:
    def interes_compuesto(K, r=0.05, n=1):
        return round(K * (1 + r) ** n, 2)
    
    print(interes_compuesto(1000))
    print(interes_compuesto(1000, n=5))
    print(interes_compuesto(1000, 5))
    print(interes_compuesto(1000, 0.5, 10))
    Atención al tercer caso: ¿qué valor recibe r?
  3. ¿Por qué esta definición produce un SyntaxError? ¿Cómo la corregirías?
    def crear_usuario(nombre, rol="usuario", edad):
        return {"nombre": nombre,
                "rol": rol,
                "edad": edad}
  4. Este código generado por IA tiene un error de orden silencioso. Identifícalo — ¿qué valor recibe cada parámetro en la llamada incorrecta?
    def calcular_ipc(precio_actual, precio_base, año=2024):
        return (precio_actual / precio_base) * 100
    
    # Llamada correcta
    print(calcular_ipc(115, 100))
    
    # Llamada con argumentos invertidos
    print(calcular_ipc(100, 115))
Respuesta orientativa
a

Parámetros: nombre y saludo — son los nombres en la definición. saludo tiene valor por defecto "Hola". Primera llamada: argumentos "Ana" — saludo usa el default. Resultado: "Hola, Ana". Segunda llamada: argumentos "Bob" y "Buenos días" — el default se ignora. Resultado: "Buenos días, Bob".

b

interes_compuesto(1000) → 1050.0 — r=0.05, n=1 por defecto. interes_compuesto(1000, n=5) → 1276.28 — r=0.05 por defecto, n=5 explícito. interes_compuesto(1000, 5) → 6000.0 — r=5 por posición, n=1 por defecto. El tercer caso es la trampa: el 5 se asigna a r por posición, no a n. interes_compuesto(1000, 0.5, 10) → 57665.04 — todos explícitos.

c

Produce SyntaxError porque edad — sin default — aparece después de rol — con default. Python no puede determinar si el segundo argumento en la llamada es para rol o para edad. La corrección es poner los parámetros sin default primero:

def crear_usuario(nombre, edad, rol="usuario"):
    return {"nombre": nombre,
            "rol": rol,
            "edad": edad}
d

Llamada correcta: precio_actual=115, precio_base=100 → 115.0 — correcto. Llamada invertida: precio_actual=100, precio_base=115 → 86.96 — incorrecto pero sin error. El IPC debería ser 115 pero obtenemos 86.96 porque estamos dividiendo al revés. Es un error silencioso típico de posicionales con nombres similares — el resultado es plausible y nadie avisa del problema.

La solución: usar keyword arguments al llamar funciones cuyos parámetros son fáciles de confundir. calcular_ipc(precio_actual=115, precio_base=100) es imposible de invertir accidentalmente. Lo veremos en el Bloque 2.
Keyword arguments y unpacking: * y ** práctica

Un keyword argument es un argumento que se pasa por nombre en lugar de por posición. En lugar de confiar en el orden, especificas explícitamente qué parámetro recibe qué valor. Esto elimina los errores de orden silenciosos que vimos en el Bloque 1 y hace el código mucho más legible.

— Pasar argumentos por nombre —

def interes_compuesto(K, r=0.05, n=1):
    """Calcula el valor futuro con interés compuesto."""
    return round(K * (1 + r) ** n, 2)

# Por posición — el orden importa
print(interes_compuesto(1000, 0.1, 3))

# Por nombre — el orden no importa
print(interes_compuesto(K=1000, r=0.1, n=3))
print(interes_compuesto(n=3, r=0.1, K=1000))   # mismo resultado

# Mezcla — posicionales primero, keywords después
print(interes_compuesto(1000, n=5))   # K por posición, n por nombre
print(interes_compuesto(1000, r=0.1)) # K por posición, r por nombre

# ❌ Keyword antes que posicional — SyntaxError
try:
    exec("interes_compuesto(K=1000, 0.1)")
except SyntaxError as e:
    print(f"SyntaxError: {e}")

# La solución al error de orden del Bloque 1
def calcular_ipc(precio_actual, precio_base, año=2024):
    return (precio_actual / precio_base) * 100

# Imposible de invertir accidentalmente
print(calcular_ipc(precio_actual=115, precio_base=100))
Cuándo usar keyword arguments: siempre que una función tenga más de dos parámetros o parámetros cuyos nombres no sean obvios por el contexto. El código es más largo pero mucho más legible y verificable — exactamente lo que necesitas cuando lees código generado por IA.

— Unpacking: extraer valores de colecciones —

El unpacking permite extraer valores de una secuencia o diccionario y asignarlos directamente a variables. Ya lo vimos en la Clase 3 con tuplas y listas. Ahora lo aplicamos a los argumentos de una función: en lugar de pasar los argumentos uno a uno, pasas directamente una colección y Python la desempaqueta.

* desempaqueta secuencias — listas y tuplas — como argumentos posicionales. ** desempaqueta diccionarios como keyword arguments.

# Repaso de unpacking de la Clase 3
punto = (3, 5)
x, y = punto
print(x, y)   # 3 5

valores = (1, 2, 3, 4)
a, b, _, d = valores
print(a, b, d)   # 1 2 4

a, *b = [10, 20, 30, 40]
print(a, b)   # 10 [20, 30, 40]

print("---")

# * unpacking en argumentos de función
def suma(a, b):
    return a + b

s = (4, 5)
print(suma(*s))         # equivale a suma(4, 5)

nums = [1, 2, 3]
def sumar_tres(a, b, c):
    return a + b + c

print(sumar_tres(*nums))  # equivale a sumar_tres(1, 2, 3)

print("---")

# ** unpacking en argumentos de función
def interes_compuesto(K, r=0.05, n=1):
    return round(K * (1 + r) ** n, 2)

d = {"r": 0.4, "n": 10}
print(interes_compuesto(1000, **d))
# equivale a interes_compuesto(1000, r=0.4, n=10)

# Diccionario completo — todas las claves como keywords
params = {"K": 2000, "r": 0.08, "n": 5}
print(interes_compuesto(**params))
# equivale a interes_compuesto(K=2000, r=0.08, n=5)

— Patrones frecuentes con unpacking —

# ** para pasar configuraciones como diccionario
def saludo(nombre, mensaje):
    print(f"{mensaje}, {nombre}!")

datos = {"nombre": "Ana", "mensaje": "Hola"}
saludo(**datos)   # Hola, Ana!

print("---")

# Patrón frecuente en análisis de datos:
# guardar parámetros de un modelo en un dict
def ajustar_modelo(datos, learning_rate=0.01,
                    epochs=100, batch_size=32):
    print(f"lr={learning_rate}, epochs={epochs}, "
          f"batch={batch_size}")
    # ... entrenamiento

# Configuración guardada como dict — fácil de modificar
config = {
    "learning_rate": 0.001,
    "epochs": 50,
    "batch_size": 64
}
ajustar_modelo([1, 2, 3], **config)

print("---")

# * para pasar lista como argumentos posicionales
# Patrón frecuente con zip y enumerate
pares = [(1, 10), (2, 20), (3, 30)]
for par in pares:
    print(suma(*par))   # equivale a suma(par[0], par[1])
  1. Predice el resultado de cada llamada antes de ejecutar:
    def describir(nombre, edad, ciudad="desconocida"):
        return f"{nombre}, {edad} años, {ciudad}"
    
    print(describir("Ana", 28))
    print(describir(edad=28, nombre="Ana"))
    datos = ["Bob", 35, "Madrid"]
    print(describir(*datos))
    config = {"nombre": "Eva", "edad": 22}
    print(describir(**config))
  2. ¿Por qué el segundo y tercer print producen el mismo resultado aunque los argumentos están en distinto orden? ¿Qué ventaja tiene pasar argumentos por nombre cuando una función tiene muchos parámetros?
  3. Este código generado por IA usa ** para pasar parámetros de configuración. ¿Qué imprime? ¿Qué ventaja tiene este patrón sobre pasar los argumentos uno a uno?
    def graficar(titulo, color="blue",
                 linewidth=1, alpha=1.0):
        print(f"título: {titulo}")
        print(f"color: {color}, lw: {linewidth}, alpha: {alpha}")
    
    estilo = {"color": "red", "linewidth": 2, "alpha": 0.8}
    graficar("Ventas 2024", **estilo)
    graficar("Costes 2024", **estilo)
  4. ¿Qué error produce este código y por qué?
    def f(a, b, c):
        return a + b + c
    
    nums = [1, 2, 3, 4]
    print(f(*nums))
Respuesta orientativa
a

describir("Ana", 28) → "Ana, 28 años, desconocida" — ciudad usa default. describir(edad=28, nombre="Ana") → "Ana, 28 años, desconocida" — mismo resultado, orden distinto. describir(*datos) → "Bob, 35 años, Madrid" — la lista se desempaqueta como posicionales. describir(**config) → "Eva, 22 años, desconocida" — el dict se desempaqueta como keywords, ciudad usa default.

b

El segundo y tercer print producen el mismo resultado porque los keyword arguments se vinculan por nombre — el orden en que aparecen en la llamada no importa, solo importa qué nombre corresponde a qué parámetro. Con funciones de muchos parámetros, los keyword arguments eliminan la necesidad de recordar el orden exacto de la definición y hacen el código autoexplicativo.

c

Imprime para cada llamada:

título: Ventas 2024
color: red, lw: 2, alpha: 0.8
título: Costes 2024
color: red, lw: 2, alpha: 0.8

La ventaja es que el estilo está definido en un solo lugar. Si quieres cambiar el color de todos los gráficos, solo modificas el dict en un punto — no tienes que buscar y cambiar cada llamada. Es el mismo principio que reutilizar una función en lugar de copiar código.

d

Produce un TypeError: f() takes 3 positional arguments but 4 were given. *nums desempaqueta la lista completa como cuatro argumentos posicionales — f(1, 2, 3, 4) — pero f solo acepta tres. La corrección: usar solo los tres primeros elementos f(*nums[:3]) o definir la función para aceptar un número variable de argumentos con *args — que veremos en la Parte IV.

Combinaciones y casos límite: errores frecuentes práctica

Los tres tipos de argumentos que hemos visto — posicionales, por defecto y keyword — pueden combinarse en una misma llamada. Pero hay reglas estrictas sobre el orden en que pueden aparecer. Conocerlas permite leer cualquier llamada a función sin confusión y detectar exactamente por qué falla un código generado por IA.

— Reglas de orden en la llamada —

En una llamada a función los argumentos deben seguir este orden: primero los posicionales, luego los keyword. Un posicional nunca puede aparecer después de un keyword — Python no sabría a qué parámetro asignarlo.

def f(a, b, c=0, d=0):
    return f"a={a}, b={b}, c={c}, d={d}"

# ✓ Posicionales primero, keywords después
print(f(1, 2))                     # a=1, b=2, c=0, d=0
print(f(1, 2, c=3))               # a=1, b=2, c=3, d=0
print(f(1, 2, c=3, d=4))          # a=1, b=2, c=3, d=4
print(f(1, 2, d=4))               # a=1, b=2, c=0, d=4
print(f(a=1, b=2, c=3, d=4))     # todos por nombre

# ❌ Errores de orden
try:
    exec("f(1, b=2, 3)")           # posicional después de keyword
except SyntaxError as e:
    print(f"SyntaxError: {e}")

# ❌ Argumento repetido
try:
    f(1, 2, a=10)                   # a recibe valor dos veces
except TypeError as e:
    print(f"TypeError: {e}")

# ❌ Demasiados argumentos
try:
    f(1, 2, 3, 4, 5)               # más argumentos que parámetros
except TypeError as e:
    print(f"TypeError: {e}")

# ❌ Faltan argumentos obligatorios
try:
    f(1)                            # falta b — sin default
except TypeError as e:
    print(f"TypeError: {e}")

— Errores frecuentes en código generado por IA —

La IA comete tres errores frecuentes con los argumentos: confundir el orden de parámetros con valores por defecto similares, pasar un argumento por nombre con un nombre incorrecto, y usar ** con un diccionario cuyas claves no coinciden exactamente con los parámetros. Los tres producen errores que solo aparecen al llamar la función.

# Error 1: nombre de keyword incorrecto
def calcular(precio, cantidad, descuento=0):
    return precio * cantidad * (1 - descuento / 100)

try:
    calcular(10, 5, descuentos=20)   # 'descuentos' no existe
except TypeError as e:
    print(f"TypeError: {e}")

print("---")

# Error 2: claves del dict no coinciden con parámetros
def crear_perfil(nombre, edad, ciudad):
    return {"nombre": nombre, "edad": edad, "ciudad": ciudad}

datos = {"nombre": "Ana", "age": 28, "ciudad": "Madrid"}
try:
    crear_perfil(**datos)   # 'age' no es un parámetro válido
except TypeError as e:
    print(f"TypeError: {e}")

print("---")

# Error 3: argumento obligatorio con nombre de default similar
def modelo(X, y, test_size=0.2, random_state=42):
    print(f"test={test_size}, seed={random_state}")

# La IA a veces confunde el orden de X e y
# — ambos son obligatorios y sin nombres obvios
modelo([1, 2, 3], [0, 1, 0])             # correcto
modelo(X=[1, 2, 3], y=[0, 1, 0])         # explícito — más seguro
modelo([1, 2, 3], [0, 1, 0],
       random_state=0, test_size=0.3)   # keywords en cualquier orden

— Leer mensajes de error de argumentos —

Python es muy preciso en sus mensajes de error sobre argumentos. Leerlos bien permite identificar el problema exacto sin depurar línea a línea.

def procesar(datos, umbral, metodo="media"):
    pass

# Cada error tiene un mensaje distinto y preciso

# "missing N required positional argument(s): 'x'"
# → falta un argumento obligatorio
try:
    procesar([1, 2])
except TypeError as e:
    print(f"1: {e}")

# "takes N positional arguments but M were given"
# → demasiados argumentos
try:
    procesar([1], 5, "media", "extra")
except TypeError as e:
    print(f"2: {e}")

# "got an unexpected keyword argument 'x'"
# → nombre de keyword incorrecto
try:
    procesar([1], 5, metodos="media")
except TypeError as e:
    print(f"3: {e}")

# "got multiple values for argument 'x'"
# → argumento pasado dos veces
try:
    procesar([1], 5, datos=[2])
except TypeError as e:
    print(f"4: {e}")
  1. Identifica el error en cada llamada y predice el mensaje de error antes de ejecutar:
    def analizar(df, columna, metodo="media", redondear=True):
        pass
    
    analizar()
    analizar("df", "col", "media", True, "extra")
    analizar("df", "col", metodos="media")
    analizar("df", columna="col", columna="col2")
  2. Este código usa ** para pasar parámetros pero tiene un error. Identifícalo y corrígelo:
    def filtrar(datos, minimo, maximo, incluir_bordes=True):
        pass
    
    params = {
        "datos": [1, 2, 3],
        "min": 0,
        "max": 10,
        "incluir_bordes": True
    }
    filtrar(**params)
  3. Reescribe esta llamada de tres formas distintas que produzcan el mismo resultado: una completamente posicional, una completamente por nombre, una mixta:
    def informe(titulo, año, trimestre=1, publicar=False):
        print(f"{titulo} — {año} T{trimestre} — pub: {publicar}")
    
    # Queremos: titulo="Ventas", año=2024, trimestre=3, publicar=True
  4. Lee este fragmento de código generado por IA y localiza todos los problemas antes de ejecutarlo:
    def normalizar(datos, metodo="minmax", rango=(0, 1)):
        pass
    
    config = {"method": "zscore", "rango": (-1, 1)}
    normalizar(**config)
    normalizar(metodo="minmax", [1, 2, 3])
    normalizar([1, 2, 3], "zscore", (0, 1), True)
Respuesta orientativa
a

analizar() → TypeError: missing 2 required positional arguments: 'df' and 'columna' — faltan los dos obligatorios.

analizar("df", "col", "media", True, "extra") → TypeError: takes from 2 to 4 positional arguments but 5 were given — un argumento de más.

analizar("df", "col", metodos="media") → TypeError: got an unexpected keyword argument 'metodos' — nombre incorrecto (metodos en lugar de metodo).

analizar("df", columna="col", columna="col2") → SyntaxError: keyword argument repeated — columna aparece dos veces.

b

Las claves "min" y "max" no coinciden con los parámetros minimo y maximo. Produce: TypeError: got an unexpected keyword argument 'min'. La corrección es usar las claves correctas:

params = {
    "datos": [1, 2, 3],
    "minimo": 0,       # ← corregido
    "maximo": 10,      # ← corregido
    "incluir_bordes": True
}
filtrar(**params)
Error frecuente con **: las claves del diccionario deben coincidir exactamente con los nombres de los parámetros — incluyendo mayúsculas, guiones bajos y ortografía. La IA a veces usa nombres abreviados o en inglés que no coinciden.
c
# Completamente posicional
informe("Ventas", 2024, 3, True)

# Completamente por nombre
informe(titulo="Ventas", año=2024,
        trimestre=3, publicar=True)

# Mixta — posicionales primero
informe("Ventas", 2024, trimestre=3, publicar=True)

Las tres producen el mismo output: Ventas — 2024 T3 — pub: True. La versión por nombre es la más legible y la más resistente a cambios en la definición.

d

Tres problemas:

Primera llamada normalizar(**config): la clave "method" no coincide con el parámetro "metodo" — TypeError. Además falta "datos" — argumento obligatorio.

Segunda llamada normalizar(metodo="minmax", [1, 2, 3]): argumento posicional después de keyword — SyntaxError.

Tercera llamada normalizar([1,2,3], "zscore", (0,1), True): cuatro argumentos pero la función solo acepta tres — TypeError.

Patrón de lectura para código de IA: antes de ejecutar, cuenta los parámetros de la definición, verifica que las claves de cualquier **dict coinciden exactamente, y comprueba que no hay posicionales después de keywords. Estos tres checks eliminan la mayoría de errores de argumentos.
Python Tutor: visualizar cómo se vinculan los argumentos actividad

En la Parte II usamos Python Tutor para ver cuándo se crea el frame y cuándo desaparece. Ahora lo usamos para ver cómo se vinculan los argumentos a los parámetros en el frame — qué valor recibe cada parámetro, cuándo aparece el valor por defecto y qué ocurre con el unpacking.

Los cuatro casos son exactamente los de tus slides — la misma función interes_compuesto llamada de cuatro formas distintas. Observa en cada caso qué variables aparecen en el frame y con qué valores.

Entra en pythontutor.com , pega cada caso y avanza paso a paso. Fíjate especialmente en el frame de interes_compuesto: qué variables aparecen, qué valores tienen y si los valores por defecto aparecen o no según la llamada.

— Caso 1: solo el argumento obligatorio —

Solo se pasa K. Los parámetros r y n usan sus valores por defecto. Observa que en el frame aparecen los tres parámetros — aunque solo se pasó uno.

def interes_compuesto(K, r=0.05, n=1):
    """Calcula el valor futuro con interés compuesto."""
    return K * (1 + r) ** n

i_c = interes_compuesto(1000)

Qué observar:

  1. Al llamar: el frame aparece con K=1000, r=0.05, n=1 — los tres vinculados aunque solo se pasó uno.
  2. ¿De dónde vienen los valores de r y n si no se pasaron?
  3. Al hacer return: ¿qué valor se asigna a i_c en el frame global?

— Caso 2: sobreescribir un default por nombre —

Se pasa K por posición y n por nombre. r usa su default. Observa que en el frame n tiene el valor pasado explícitamente aunque no es el segundo parámetro en la definición.

def interes_compuesto(K, r=0.05, n=1):
    """Calcula el valor futuro con interés compuesto."""
    return K * (1 + r) ** n

i_c = interes_compuesto(1000, n=5)

Qué observar:

  1. En el frame: K=1000, r=0.05, n=5. ¿Por qué r sigue siendo 0.05 aunque no se pasó?
  2. Compara con el Caso 1: ¿qué cambia en el frame? ¿Qué valor tiene i_c al final?

— Caso 3: la trampa del posicional —

Se pasan K=1000 y 5 por posición. El 5 va al segundo parámetro en orden — que es r, no n. Este es exactamente el error silencioso del Bloque 1: el código no falla pero el resultado es incorrecto.

def interes_compuesto(K, r=0.05, n=1):
    """Calcula el valor futuro con interés compuesto."""
    return K * (1 + r) ** n

i_c = interes_compuesto(1000, 5)

Qué observar:

  1. En el frame: ¿qué valor tiene r? ¿Y n? Compara con el Caso 2.
  2. ¿Qué valor devuelve la función? ¿Es el esperado si la intención era calcular el interés a 5 años?
  3. ¿Cómo evitar este error usando keyword arguments?

— Caso 4: unpacking con ** —

Los argumentos se pasan desde un diccionario con **. Observa que el frame muestra los mismos valores que si se hubieran pasado como keywords individuales — Python desempaqueta el dict antes de vincular los argumentos.

def interes_compuesto(K, r=0.05, n=1):
    """Calcula el valor futuro con interés compuesto."""
    return K * (1 + r) ** n

d = {"r": 0.4, "n": 10}
i_c = interes_compuesto(1000, **d)

Qué observar:

  1. En el frame: K=1000, r=0.4, n=10. ¿Cómo llegaron los valores del diccionario al frame?
  2. ¿En qué se diferencia el frame de este caso del Caso 2? ¿Producen el mismo resultado?
  3. Modifica el diccionario para añadir una clave incorrecta y observa cuándo falla — ¿al definir el dict, al llamar la función o al ejecutar el cuerpo?
  1. Ejecuta los cuatro casos en Python Tutor. Para cada uno apunta: qué valores tienen K, r y n en el frame y qué valor devuelve la función. ¿Coinciden con tus predicciones del Bloque 1?
  2. En el Caso 3: ¿cuál es el valor de i_c? ¿Por qué es tan alto? ¿Cómo reescribirías la llamada para que n=5 y r=0.05?
  3. En el Caso 4: añade una clave incorrecta al dict — por ejemplo "tasa": 0.1 en lugar de "r": 0.4 — y ejecuta en Python Tutor. ¿En qué paso aparece el error? ¿Qué dice el mensaje?
  4. Escribe en Python Tutor una función propia con tres parámetros — uno obligatorio y dos con default. Llámala de tres formas: solo el obligatorio, sobreescribiendo uno de los defaults por nombre, y con **dict. Observa en los tres casos qué aparece en el frame.
Respuesta orientativa
a

Caso 1: K=1000, r=0.05, n=1 → i_c = 1050.0. Caso 2: K=1000, r=0.05, n=5 → i_c = 1276.28. Caso 3: K=1000, r=5, n=1 → i_c = 6000.0. Caso 4: K=1000, r=0.4, n=10 → i_c = 28925.27.

b

i_c = 6000.0 porque r=5 — un tipo de interés del 500% — en lugar del 5% esperado. El resultado es enorme pero plausible como número, por eso no lanza error. La versión correcta para n=5 y r=0.05:

# Por nombre — imposible de confundir
i_c = interes_compuesto(1000, n=5)
# K=1000, r=0.05 (default), n=5 → 1276.28
c

El error aparece al llamar la función — en el momento en que Python intenta desempaquetar el dict y vincularlo a los parámetros. El mensaje es: TypeError: got an unexpected keyword argument 'tasa'. No falla al definir el dict ni al ejecutar el cuerpo — falla exactamente en la llamada, antes de que el frame se cree completamente.

d

En los tres casos Python Tutor muestra el mismo frame con los tres parámetros vinculados a sus valores — independientemente de cómo se pasaron. Esto confirma que el frame es el estado interno de la función: Python resuelve la vinculación antes de entrar en el cuerpo, y el cuerpo siempre ve los mismos nombres con sus valores ya asignados.

La conclusión clave: el cuerpo de una función no sabe si sus parámetros llegaron por posición, por nombre o por unpacking. Solo ve variables locales con valores asignados. El cómo se pasaron es transparente desde dentro.
Parte IV

*args, **kwargs, * y /

*args: número variable de argumentos posicionales teoría

Hasta ahora todas las funciones que hemos definido tienen un número fijo de parámetros — el programador decide cuántos al escribir el def. Pero hay situaciones donde no se sabe de antemano cuántos argumentos recibirá la función. Para eso existe *args.

*args en la definición de una función captura todos los argumentos posicionales extra — los que no tienen parámetro asignado — y los empaqueta en una tupla. Dentro de la función, args es simplemente una tupla con esos valores.

El nombre args es una convención — puedes usar cualquier nombre con un * delante. Lo que importa es el asterisco, no el nombre.

— El * tiene dos usos opuestos —

* en la llamada — desempaqueta

nums = [1, 2, 3]
f(*nums)
# equivale a f(1, 2, 3)
# expande la lista en argumentos

* en la definición — empaqueta

def f(*args):
    print(args)
f(1, 2, 3)
# args = (1, 2, 3)
# recoge argumentos en tupla

— *args básico —

# Sin *args — número fijo de parámetros
def sumar_dos(a, b):
    return a + b

# Con *args — número variable
def sumar_todo(*args):
    print(f"args es: {args}")
    print(f"tipo: {type(args)}")
    return sum(args)

print(sumar_todo(1, 2))              # args = (1, 2)
print(sumar_todo(1, 2, 3, 4, 5))   # args = (1, 2, 3, 4, 5)
print(sumar_todo(10))               # args = (10,)
print(sumar_todo())                  # args = () — tupla vacía

— *args combinado con parámetros normales —

*args debe ir después de todos los parámetros posicionales normales. Los parámetros antes de *args reciben sus argumentos primero — los restantes van a la tupla.

def mostrar(a, b, *args):
    print(f"a={a}, b={b}")
    print(f"resto: {args}")

mostrar(1, 2)              # a=1, b=2, args=()
mostrar(1, 2, 3)           # a=1, b=2, args=(3,)
mostrar(1, 2, 3, 4, 5)    # a=1, b=2, args=(3, 4, 5)

print("---")

# Iterar sobre args — es una tupla normal
def imprimir_todos(titulo, *args):
    print(f"=== {titulo} ===")
    for i, valor in enumerate(args, start=1):
        print(f"  {i}. {valor}")

imprimir_todos("Frutas", "manzana", "pera", "kiwi")
imprimir_todos("Vacío")   # args = () — no imprime nada

— Ejemplo completo: operación sobre múltiples números —

def operar(operacion, *numeros, redondear=True):
    """
    Aplica una operación a múltiples números.
    operacion: 'suma', 'multiplicacion', 'promedio'
    *numeros:  números a procesar
    redondear: si se redondea el resultado
    """
    if not numeros:
        return None

    if operacion == "suma":
        resultado = sum(numeros)
    elif operacion == "multiplicacion":
        resultado = 1
        for n in numeros:
            resultado *= n
    elif operacion == "promedio":
        resultado = sum(numeros) / len(numeros)
    else:
        return "operación no soportada"

    return round(resultado, 2) if redondear else resultado

print(operar("suma", 1, 2, 3))
print(operar("promedio", 2, 2, 3, 5))
print(operar("promedio", 2, 2, 3, 5, redondear=False))
print(operar("multiplicacion", 2, 3, 4))
print(operar("suma"))              # numeros vacío → None

Observa la firma de operar: operacion, *numeros, redondear=True. operacion es posicional normal, *numeros captura el resto de posicionales, y redondear viene después de *args — lo que lo convierte en un parámetro que solo se puede pasar por nombre. Si escribes operar("suma", 1, 2, False), el False iría a numeros, no a redondear.

  1. Predice qué imprime cada llamada antes de ejecutar:
    def info(primero, *resto):
        print(f"primero: {primero}")
        print(f"resto: {resto}")
        print(f"total: {len(resto) + 1} argumentos")
    
    info("a")
    info("a", "b", "c")
    info(1, 2, 3, 4, 5)
  2. ¿Por qué en la función operar hay que pasar redondear=False por nombre y no por posición? ¿Qué pasaría si escribieras operar("promedio", 1, 2, 3, False)?
  3. Escribe una función maximo_de que reciba cualquier número de argumentos numéricos y devuelva el mayor. Sin usar la función built-in max().
  4. Este código generado por IA usa *args. ¿Qué imprime? ¿Hay algún problema?
    def combinar(separador, *palabras):
        return separador.join(palabras)
    
    print(combinar("-", "python", "es", "genial"))
    print(combinar(" ", "hola", "mundo"))
    print(combinar(","))
    print(combinar("+", "uno", 2, "tres"))
Respuesta orientativa
a

info("a"): primero="a", resto=(), total: 1 argumentos.

info("a", "b", "c"): primero="a", resto=('b', 'c'), total: 3 argumentos.

info(1, 2, 3, 4, 5): primero=1, resto=(2, 3, 4, 5), total: 5 argumentos.

b

redondear viene después de *numeros en la definición. Todo lo que va después de un * solo puede pasarse por nombre. Si escribes operar("promedio", 1, 2, 3, False), el False va a numeros — la tupla sería (1, 2, 3, False) — y redondear usaría su default True. El resultado sería incorrecto porque se intenta hacer sum((1, 2, 3, False)) — que funciona porque False vale 0 — pero no es lo que se quería.

c
def maximo_de(*args):
    if not args:
        return None
    maximo = args[0]
    for n in args:
        if n > maximo:
            maximo = n
    return maximo

print(maximo_de(3, 1, 7, 2))   # 7
print(maximo_de(5))             # 5
print(maximo_de())              # None
d

combinar("-", "python", "es", "genial") → "python-es-genial". combinar(" ", "hola", "mundo") → "hola mundo". combinar(",") → "" — string vacío, palabras es tupla vacía. combinar("+", "uno", 2, "tres") → TypeError — str.join() requiere que todos los elementos sean strings, y 2 es un entero. Ese es el problema: la función no valida que todos los argumentos en *palabras sean strings.

**kwargs: número variable de keyword arguments teoría

**kwargs es el equivalente de *args pero para keyword arguments. Captura todos los argumentos nombrados que no tienen parámetro asignado y los empaqueta en un diccionario. Las claves son los nombres de los argumentos y los valores son los valores pasados.

Al igual que args, el nombre kwargs es solo una convención — lo que importa son los dos asteriscos. Dentro de la función, kwargs es un diccionario normal que puedes recorrer, filtrar o consultar.

— El ** tiene dos usos opuestos —

** en la llamada — desempaqueta

d = {"r": 0.1, "n": 5}
f(**d)
# equivale a f(r=0.1, n=5)
# expande el dict en keywords

** en la definición — empaqueta

def f(**kwargs):
    print(kwargs)
f(r=0.1, n=5)
# kwargs = {"r": 0.1, "n": 5}
# recoge keywords en dict

— **kwargs básico —

# kwargs es un diccionario dentro de la función
def imprimir_datos(**kwargs):
    print(f"kwargs es: {kwargs}")
    print(f"tipo: {type(kwargs)}")

imprimir_datos(nombre="Ana", edad=28)
# kwargs = {'nombre': 'Ana', 'edad': 28}

imprimir_datos(x=1, y=2, z=3)
# kwargs = {'x': 1, 'y': 2, 'z': 3}

imprimir_datos()
# kwargs = {} — dict vacío

print("---")

# Acceder a claves específicas
def saludar(**kwargs):
    if "nombre" in kwargs:
        print(f"Hola, {kwargs['nombre']}!")
    else:
        print("Hola, desconocido!")

saludar(nombre="Luis")
saludar(nombre="Ana", ciudad="Madrid")
saludar(ciudad="Madrid")   # sin nombre

print("---")

# Iterar sobre kwargs como un dict normal
def imprimir_perfil(**kwargs):
    for clave, valor in kwargs.items():
        print(f"  {clave.capitalize()}: {valor}")

imprimir_perfil(nombre="Ana", edad=28,
                ciudad="Madrid", rol="analista")

— **kwargs combinado con parámetros normales —

**kwargs debe ir siempre al final de todos los parámetros. Los parámetros antes de **kwargs reciben sus argumentos primero — los keyword arguments restantes van al diccionario.

# Parámetros obligatorios + kwargs
def imprimir_usuario(nombre, edad, **kwargs):
    print(f"Nombre: {nombre}, Edad: {edad}")
    if kwargs:
        print("Datos adicionales:")
        for k, v in kwargs.items():
            print(f"  {k}: {v}")

imprimir_usuario("Ana", 28)
print("---")
imprimir_usuario("Bob", 35, ciudad="Madrid", rol="analista")
print("---")

# *args y **kwargs juntos — el orden es obligatorio
# def f(normal, *args, **kwargs)
def todo(primero, *args, **kwargs):
    print(f"primero: {primero}")
    print(f"args:   {args}")
    print(f"kwargs: {kwargs}")

todo(1, 2, 3, x=10, y=20)
# primero=1, args=(2,3), kwargs={'x':10,'y':20}

— Por qué las librerías usan **kwargs —

Funciones como plt.plot(), df.to_csv() o sklearn usan **kwargs porque ofrecen decenas de opciones de personalización. Definir un parámetro explícito por cada opción sería inmanejable. Con **kwargs el usuario pasa solo las opciones que necesita — el resto usa valores por defecto internos.

# Simular cómo plt.plot usa **kwargs
def graficar(x, y, **kwargs):
    # kwargs puede contener: color, linewidth,
    # linestyle, marker, alpha, label...
    color     = kwargs.get("color",     "blue")
    linewidth = kwargs.get("linewidth", 1)
    label     = kwargs.get("label",     "sin etiqueta")
    print(f"Graficando '{label}': "
          f"color={color}, lw={linewidth}")
    # plt.plot(x, y, **kwargs)  ← pasa todo a matplotlib

# Cada llamada puede personalizar lo que necesita
graficar([1, 2], [3, 4],
         color="red", linewidth=2, label="Ventas")

graficar([1, 2], [3, 4],
         color="green", label="Costes")

graficar([1, 2], [3, 4])   # todo por defecto

print("---")

# kwargs.get() — acceso seguro con valor por defecto
# si la clave no existe, devuelve el segundo argumento
def configurar(**kwargs):
    verbose  = kwargs.get("verbose",  False)
    max_iter = kwargs.get("max_iter", 100)
    tol      = kwargs.get("tol",      1e-4)
    print(f"verbose={verbose}, max_iter={max_iter}, tol={tol}")

configurar(verbose=True)
configurar(max_iter=500, tol=1e-6)
configurar()   # todos por defecto
  1. Predice qué imprime cada llamada antes de ejecutar:
    def analizar(datos, **opciones):
        print(f"datos: {datos}")
        print(f"opciones: {opciones}")
    
    analizar([1, 2, 3])
    analizar([1, 2], metodo="media", redondear=True)
    analizar([1], **{"umbral": 0.5, "verbose": False})
  2. ¿Cuál es la diferencia entre estos dos usos de **? ¿Cuándo se usa cada uno?
    # Uso A
    def f(**kwargs):
        print(kwargs)
    
    # Uso B
    d = {"x": 1, "y": 2}
    f(**d)
  3. Escribe una función crear_etiqueta que reciba un texto obligatorio y cualquier número de opciones de estilo como keyword arguments — color, tamaño, negrita. Si no se pasa una opción, usa valores por defecto.
  4. Lee este código generado por IA que combina *args y **kwargs. ¿Qué imprime cada llamada? ¿Hay algún problema?
    def registrar(*args, **kwargs):
        print(f"posicionales: {args}")
        print(f"nombrados:    {kwargs}")
        print(f"total args:   {len(args) + len(kwargs)}")
    
    registrar("evento1", "evento2",
              usuario="Ana", nivel="info")
    registrar()
    registrar(usuario="Bob")
    registrar("evento", "evento", usuario="Ana",
              usuario="Bob")
Respuesta orientativa
a

analizar([1,2,3]): datos=[1,2,3], opciones={}.

analizar([1,2], metodo="media", redondear=True): datos=[1,2], opciones={'metodo': 'media', 'redondear': True}.

analizar([1], **{"umbral": 0.5, "verbose": False}): datos=[1], opciones={'umbral': 0.5, 'verbose': False} — el dict se desempaqueta como keywords antes de llegar a la función.

b

Uso A — **kwargs en la definición: la función acepta cualquier número de keyword arguments y los empaqueta en un dict. Se usa cuando diseñas una función flexible.

Uso B — **d en la llamada: desempaqueta el diccionario como keyword arguments antes de pasarlos. Se usa cuando tienes los argumentos en un dict y quieres pasarlos a una función.

Son operaciones inversas: **kwargs en la definición empaqueta keywords en un dict. **d en la llamada desempaqueta un dict en keywords. El mismo operador, el mismo símbolo, significados opuestos según el contexto.
c
def crear_etiqueta(texto, **kwargs):
    color   = kwargs.get("color",   "negro")
    tamaño  = kwargs.get("tamaño",  12)
    negrita = kwargs.get("negrita", False)
    estilo  = "negrita" if negrita else "normal"
    return (f"[{texto}] "
            f"color={color}, "
            f"tamaño={tamaño}, "
            f"estilo={estilo}")

print(crear_etiqueta("Título"))
print(crear_etiqueta("Alerta", color="rojo", negrita=True))
print(crear_etiqueta("Nota", tamaño=10, color="gris"))
d

Primera llamada: posicionales: ('evento1', 'evento2'), nombrados: {'usuario': 'Ana', 'nivel': 'info'}, total args: 4.

Segunda llamada: posicionales: (), nombrados: {}, total args: 0.

Tercera llamada: posicionales: (), nombrados: {'usuario': 'Bob'}, total args: 1.

Cuarta llamada: SyntaxError: keyword argument repeated: usuario — usuario aparece dos veces como keyword. Los diccionarios no permiten claves duplicadas y Python detecta este error antes de ejecutar.

* y / en la definición: forzar posicional o keyword teoría

Python permite controlar con precisión cómo deben pasarse los argumentos a una función — no solo cuántos, sino si deben ir por posición, por nombre o cualquiera de los dos. Para eso existen dos marcadores especiales en la definición: / y *.

Son opcionales — la mayoría de funciones no los necesitan. Pero aparecen con frecuencia en librerías como pandas, numpy y sklearn, y en código generado por IA para funciones complejas. Saber leerlos es esencial para entender exactamente cómo se puede llamar una función.

— * solo: forzar keyword arguments —

Un * solo — sin nombre — en la definición marca una frontera: todo lo que viene después del * debe pasarse obligatoriamente por nombre. No se puede pasar por posición.

Se usa para mejorar la claridad del código — obliga al usuario a ser explícito con ciertos argumentos, eliminando errores de orden silenciosos.

# Sin * — edad y email pueden pasarse por posición
def crear_usuario_v1(nombre, edad, email):
    print(f"{nombre}, {edad}, {email}")

crear_usuario_v1("Ana", 28, "ana@mail.com")    # funciona
crear_usuario_v1("Ana", "ana@mail.com", 28)    # error silencioso

print("---")

# Con * — edad y email deben pasarse por nombre
def crear_usuario_v2(nombre, *, edad, email):
    print(f"{nombre}, {edad}, {email}")

# ✓ nombre por posición, edad y email por nombre
crear_usuario_v2("Ana", edad=28, email="ana@mail.com")

# ❌ intentar pasar edad por posición
try:
    crear_usuario_v2("Ana", 28, "ana@mail.com")
except TypeError as e:
    print(f"TypeError: {e}")

print("---")

# * con default — keyword obligatorio pero opcional
def procesar(datos, *, metodo="media", redondear=True):
    print(f"datos={datos}, metodo={metodo}, redondear={redondear}")

procesar([1, 2, 3])                          # defaults
procesar([1, 2, 3], metodo="mediana")       # un keyword
procesar([1, 2, 3], redondear=False,
         metodo="moda")                       # orden libre

— / solo: forzar argumentos posicionales —

Una / en la definición marca otra frontera: todo lo que viene antes de la / debe pasarse obligatoriamente por posición. No se puede pasar por nombre.

Se usa menos frecuentemente — principalmente en funciones built-in de Python y en librerías científicas. Aparece cuando el nombre del parámetro es un detalle de implementación que no debería ser parte de la interfaz pública.

# Con / — nombre y edad deben pasarse por posición
def mostrar(nombre, edad, /, saludo="Hola"):
    print(f"{saludo}, {nombre} ({edad} años)")

# ✓ nombre y edad por posición
mostrar("Ana", 28)
mostrar("Ana", 28, saludo="Buenos días")

# ❌ intentar pasar nombre por keyword
try:
    mostrar(nombre="Ana", edad=28)
except TypeError as e:
    print(f"TypeError: {e}")

print("---")

# / aparece en funciones built-in de Python
# Por ejemplo, len() solo acepta posicional
try:
    len(obj=[1, 2, 3])   # TypeError
except TypeError as e:
    print(f"TypeError: {e}")

— La firma completa: /, pos_o_kwd, *, solo_kwd, **kwargs —

Python permite combinar todos estos mecanismos en una sola función. La regla es que deben aparecer en este orden exacto: parámetros antes de /, luego parámetros flexibles, luego * o *args, luego parámetros solo-keyword, luego **kwargs.

# La firma más completa posible
def funcion_completa(pos1, pos2, /, flexible, *, solo_kwd, **kwargs):
    print(f"pos1={pos1}, pos2={pos2}")         # solo posición
    print(f"flexible={flexible}")               # posición o keyword
    print(f"solo_kwd={solo_kwd}")               # solo keyword
    print(f"kwargs={kwargs}")                   # keywords extra

funcion_completa(1, 2, 3, solo_kwd=4)
funcion_completa(1, 2, flexible=3, solo_kwd=4)
funcion_completa(1, 2, 3, solo_kwd=4, extra=5)

print("---")

# Ejemplo real: una función de análisis bien diseñada
def analizar_serie(serie, /, *,
                   metodo="media",
                   redondear=True,
                   **opciones):
    print(f"serie: {serie}")
    print(f"metodo: {metodo}, redondear: {redondear}")
    if opciones:
        print(f"opciones extra: {opciones}")

analizar_serie([1, 2, 3])
analizar_serie([1, 2, 3], metodo="mediana")
analizar_serie([1, 2, 3], metodo="media",
               umbral=0.5, verbose=True)

— Tabla resumen: todos los tipos de parámetros —

Sección Significado Cómo se pasa
pos1, pos2, / Antes de / Solo por posición
flexible Entre / y * Posición o keyword
*args Captura posicionales extra Solo por posición → tupla
*, solo_kwd Después de * Solo por keyword
**kwargs Captura keywords extra Solo por keyword → dict
  1. Lee esta definición y clasifica cada parámetro según cómo debe pasarse:
    def modelo(X, y, /, test_size=0.2, *,
               random_state=42, verbose=False):
        pass
    ¿Cuáles son solo posicionales? ¿Cuáles son solo keyword? ¿Cuáles son flexibles?
  2. ¿Cuál de estas llamadas es válida y cuál produce error? Predice antes de ejecutar:
    def f(a, b, /, c, *, d):
        print(a, b, c, d)
    
    f(1, 2, 3, d=4)
    f(1, 2, c=3, d=4)
    f(a=1, b=2, c=3, d=4)
    f(1, 2, 3, 4)
  3. ¿Por qué len() no acepta argumentos por nombre? ¿Qué ventaja tiene eso para una función tan básica?
  4. Este código generado por IA define una función de análisis. Lee la firma e identifica qué argumentos son obligatorios, cuáles opcionales, cuáles solo posicionales y cuáles solo keyword:
    def analizar(df, columna, /, *,
                 metodo="media",
                 umbral=None,
                 **opciones):
        pass
    
    # ¿Cuáles de estas llamadas son válidas?
    analizar(df, "ventas")
    analizar(df, "ventas", metodo="mediana")
    analizar(df, columna="ventas")
    analizar(df, "ventas", "media")
    analizar(df, "ventas", verbose=True)
Respuesta orientativa
a

X y y están antes de / → solo posicionales — obligatorios. test_size está entre / y * → flexible — posición o keyword, tiene default por lo que es opcional. random_state y verbose están después de * → solo keyword — opcionales con default.

Patrón reconocible: esta firma es exactamente la que usan muchas funciones de sklearn — los datos X e y siempre posicionales, los hiperparámetros siempre por nombre.
b

f(1, 2, 3, d=4) → válida: a=1, b=2 por posición, c=3 flexible, d=4 keyword.

f(1, 2, c=3, d=4) → válida: a=1, b=2 por posición, c=3 y d=4 por keyword.

f(a=1, b=2, c=3, d=4) → TypeError: a y b están antes de / — solo pueden ir por posición.

f(1, 2, 3, 4) → TypeError: d está después de * — solo puede ir por keyword, no por posición.

c

len() no acepta argumentos por nombre porque su parámetro interno se llama obj en CPython — un detalle de implementación que no es parte de la interfaz. Si se pudiera llamar con len(obj=[1,2,3]), cambiar el nombre interno en una nueva versión de Python rompería todo el código que lo usara así. Forzarlo posicional protege la implementación interna.

d

df y columna antes de / → solo posicionales, obligatorios. metodo y umbral después de * → solo keyword, opcionales. **opciones → cualquier keyword extra.

Llamadas válidas: analizar(df, "ventas") ✓, analizar(df, "ventas", metodo="mediana") ✓, analizar(df, "ventas", verbose=True) ✓ — verbose va a **opciones.

Llamadas inválidas: analizar(df, columna="ventas") → TypeError — columna es solo posicional. analizar(df, "ventas", "media") → TypeError — "media" es posicional pero no hay tercer parámetro posicional.