La firma de una función es la primera forma de comunicación entre quien escribe el código y quien lo usa. Si una función recibe demasiados parámetros, banderas booleanas ambiguas o argumentos en un orden difícil de recordar, aumenta la posibilidad de errores.
En este tema aprenderemos a detectar firmas problemáticas y a reemplazarlas por alternativas más claras: argumentos nombrados, objetos de configuración, funciones separadas y estructuras de datos explícitas.
Una firma difícil de usar obliga al programador a recordar detalles que el código debería expresar. Esto ocurre cuando hay muchos parámetros del mismo tipo, argumentos opcionales poco claros o valores booleanos que cambian mucho el comportamiento.
Ejemplo problemático:
total = calcular_total(3000, 2, "vip", "AR", True, False, True)
Al leer la llamada no sabemos qué significan True, False y True. Tampoco queda claro si 3000 es precio, subtotal, impuesto o límite.
Una función con muchos parámetros suele indicar que recibe datos que pertenecen a un mismo concepto o que hace demasiadas cosas.
def crear_usuario(nombre, apellido, email, edad, pais, ciudad, activo, rol):
return {
"nombre": nombre,
"apellido": apellido,
"email": email,
"edad": edad,
"pais": pais,
"ciudad": ciudad,
"activo": activo,
"rol": rol,
}
La función no es larga, pero su firma ya es incómoda. Es fácil invertir pais y ciudad, olvidar un parámetro o pasar valores en orden incorrecto.
Una mejora inmediata es llamar a la función con argumentos nombrados. No cambia la firma, pero hace la llamada más explícita.
usuario = crear_usuario(
nombre="Ana",
apellido="Pérez",
email="ana@example.com",
edad=28,
pais="AR",
ciudad="Córdoba",
activo=True,
rol="cliente",
)
Los argumentos nombrados reducen errores de orden y hacen que la llamada se lea como datos estructurados.
Cuando varios parámetros pertenecen al mismo concepto, conviene agruparlos. Una opción simple es usar un diccionario con claves claras.
datos_usuario = {
"nombre": "Ana",
"apellido": "Pérez",
"email": "ana@example.com",
"edad": 28,
"pais": "AR",
"ciudad": "Córdoba",
"activo": True,
"rol": "cliente",
}
usuario = crear_usuario(datos_usuario)
Esto mejora la llamada, aunque todavía requiere cuidado: un diccionario no valida por sí mismo qué claves existen ni qué tipos se esperan.
Una alternativa más explícita es usar dataclass. Esto permite representar datos relacionados con una estructura clara.
from dataclasses import dataclass
@dataclass
class DatosUsuario:
nombre: str
apellido: str
email: str
edad: int
pais: str
ciudad: str
activo: bool
rol: str
def crear_usuario(datos: DatosUsuario):
return {
"nombre": datos.nombre,
"apellido": datos.apellido,
"email": datos.email,
"edad": datos.edad,
"pais": datos.pais,
"ciudad": datos.ciudad,
"activo": datos.activo,
"rol": datos.rol,
}
Más adelante profundizaremos en dataclasses. Por ahora nos interesa ver cómo ayudan a reducir firmas enormes.
Una bandera booleana cambia el comportamiento de una función según un True o False. No siempre está mal, pero puede ser una señal de que la función hace más de una cosa.
def generar_reporte(ventas, incluir_impuestos):
total = sum(ventas)
if incluir_impuestos:
total = total * 1.21
return total
La llamada puede ser ambigua:
total = generar_reporte(ventas, True)
Al leerla, no sabemos qué significa True sin ir a la definición de la función.
Si la bandera booleana es razonable, al menos conviene usar argumento nombrado:
total = generar_reporte(ventas, incluir_impuestos=True)
Esto no elimina el smell por completo, pero reduce ambigüedad en la llamada.
Si la bandera indica dos comportamientos distintos, suele ser más claro crear dos funciones.
def calcular_total_sin_impuestos(ventas):
return sum(ventas)
def calcular_total_con_impuestos(ventas):
total = calcular_total_sin_impuestos(ventas)
return total * 1.21
La llamada ahora expresa la intención:
total = calcular_total_con_impuestos(ventas)
Varias banderas booleanas juntas son una señal más fuerte de problema.
def exportar_reporte(datos, incluir_impuestos, incluir_descuentos, formato_pdf):
# Mucha lógica condicional según las combinaciones de True y False.
pass
Con tres banderas ya existen ocho combinaciones posibles. Algunas tal vez no tengan sentido, pero la firma permite llamarlas igual.
Cuando hay varias opciones relacionadas, un objeto de configuración puede ser más claro.
from dataclasses import dataclass
@dataclass
class OpcionesReporte:
incluir_impuestos: bool = True
incluir_descuentos: bool = True
formato: str = "pdf"
def exportar_reporte(datos, opciones: OpcionesReporte):
if opciones.incluir_impuestos:
datos = aplicar_impuestos(datos)
if opciones.incluir_descuentos:
datos = aplicar_descuentos(datos)
return generar_archivo(datos, opciones.formato)
La firma queda más estable y las opciones se agrupan en un concepto con nombre propio.
Un parámetro opcional puede ser útil, pero también puede ocultar caminos de ejecución muy distintos.
def guardar_datos(datos, ruta=None):
if ruta is None:
print(datos)
else:
with open(ruta, "w", encoding="utf-8") as archivo:
archivo.write(str(datos))
Esta función imprime o escribe un archivo según un parámetro. Tal vez convenga separar dos funciones: una para mostrar y otra para guardar.
Cuando varios parámetros son del mismo tipo, es fácil invertirlos sin que Python lo detecte.
def calcular_periodo(fecha_inicio, fecha_fin, fecha_pago):
return {
"inicio": fecha_inicio,
"fin": fecha_fin,
"pago": fecha_pago,
}
Esta llamada puede compilar aunque esté mal ordenada:
periodo = calcular_periodo(fecha_pago, fecha_inicio, fecha_fin)
En estos casos, los argumentos nombrados o una estructura de datos reducen mucho el riesgo.
En el proyecto ventas_demo, la función principal puede tener una firma similar a esta:
def calcular_total_venta(productos, cliente, pais):
...
Por ahora es razonable, pero podría complicarse si agregamos más opciones:
def calcular_total_venta(
productos,
cliente,
pais,
incluir_impuestos,
incluir_envio,
redondear,
):
...
Si aparecen esos parámetros, conviene detenerse y diseñar una alternativa más clara antes de que la firma crezca sin control.
Podemos agrupar opciones en una estructura explícita:
from dataclasses import dataclass
@dataclass
class OpcionesCalculo:
incluir_impuestos: bool = True
incluir_envio: bool = True
redondear: bool = True
def calcular_total_venta(productos, cliente, pais, opciones=None):
if opciones is None:
opciones = OpcionesCalculo()
subtotal = calcular_subtotal(productos)
descuento = obtener_descuento(cliente)
total = subtotal * (1 - descuento)
if opciones.incluir_impuestos:
total = aplicar_impuesto(total, pais)
if opciones.incluir_envio:
total = aplicar_envio(total)
if opciones.redondear:
total = round(total, 2)
return total
Esta versión concentra las opciones en un objeto. Aun así, si cada opción activa caminos muy distintos, tal vez convenga separar casos de uso.
En Python, usar listas o diccionarios como valores por defecto es un error común que puede producir comportamiento inesperado.
Evita esto:
def agregar_producto(producto, productos=[]):
productos.append(producto)
return productos
Prefiere esto:
def agregar_producto(producto, productos=None):
if productos is None:
productos = []
productos.append(producto)
return productos
Este problema es de comportamiento, pero también afecta calidad porque la firma parece inocente y es fácil de usar mal.
Python permite obligar a que ciertos argumentos se pasen por nombre usando * en la firma.
def calcular_total_venta(productos, *, cliente, pais):
subtotal = calcular_subtotal(productos)
descuento = obtener_descuento(cliente)
impuesto = obtener_impuesto(pais)
return round(subtotal * (1 - descuento) * (1 + impuesto), 2)
Ahora esta llamada es explícita:
total = calcular_total_venta(productos, cliente="vip", pais="AR")
Y esta llamada no es válida:
total = calcular_total_venta(productos, "vip", "AR")
Analiza esta función:
def enviar_email(destino, asunto, cuerpo, urgente, copia, html):
if urgente:
asunto = "[URGENTE] " + asunto
if copia:
destino = destino + "," + copia
if html:
cuerpo = f"<html><body>{cuerpo}</body></html>"
return f"Para: {destino}\nAsunto: {asunto}\n{cuerpo}"
Problemas posibles:
copia puede ser ambiguo si no hay copia.from dataclasses import dataclass
@dataclass
class Email:
destino: str
asunto: str
cuerpo: str
copia: str | None = None
es_urgente: bool = False
formato: str = "texto"
def preparar_asunto(email):
if email.es_urgente:
return f"[URGENTE] {email.asunto}"
return email.asunto
def preparar_destino(email):
if email.copia:
return f"{email.destino},{email.copia}"
return email.destino
def preparar_cuerpo(email):
if email.formato == "html":
return f"<html><body>{email.cuerpo}</body></html>"
return email.cuerpo
def enviar_email(email):
destino = preparar_destino(email)
asunto = preparar_asunto(email)
cuerpo = preparar_cuerpo(email)
return f"Para: {destino}\nAsunto: {asunto}\n{cuerpo}"
La mejora no consiste solo en usar una clase de datos. También separamos pequeñas decisiones para que cada parte tenga un nombre claro.
Mejora la firma de esta función sin cambiar el comportamiento esperado:
def registrar_pago(usuario, importe, moneda, fecha, enviar_recibo, aplicar_descuento, guardar_log):
if aplicar_descuento:
importe = importe * 0.9
mensaje = f"{usuario} pagó {importe} {moneda} el {fecha}"
if enviar_recibo:
mensaje = mensaje + " - recibo enviado"
if guardar_log:
mensaje = "[LOG] " + mensaje
return mensaje
Realiza estas tareas:
dataclass para datos o para opciones.Antes de continuar, verifica que puedes hacer lo siguiente:
En este tema vimos que una firma difícil de usar también es un code smell. Muchos parámetros, banderas booleanas y valores opcionales ambiguos aumentan el riesgo de llamadas incorrectas y dificultan las pruebas.
En el próximo tema estudiaremos la duplicación de código y la duplicación de conocimiento, dos problemas que suelen crecer silenciosamente en proyectos reales.