Una función con demasiados parámetros es difícil de leer, llamar y modificar. Cuando varios argumentos siempre viajan juntos, suelen estar representando un concepto que todavía no tiene nombre en el código.
En este tema refactorizaremos firmas de funciones en Python. Usaremos objetos de datos, dataclass, valores por defecto y parámetros con nombre para que las llamadas sean más claras y menos propensas a errores.
Conviene revisar una firma cuando aparecen estas señales:
Crea el archivo src/cotizaciones.py:
def calcular_cotizacion(
nombre_cliente,
tipo_cliente,
provincia,
items,
aplica_envio,
envio_express,
cupon,
):
subtotal = 0
for item in items:
subtotal = subtotal + item["precio"] * item["cantidad"]
if tipo_cliente == "vip":
subtotal = subtotal * 0.9
if cupon == "DESC5":
subtotal = subtotal - 5
if provincia == "CABA":
subtotal = subtotal + subtotal * 0.02
if aplica_envio:
if envio_express:
subtotal = subtotal + 2500
else:
subtotal = subtotal + 1000
return {
"cliente": nombre_cliente,
"total": round(subtotal, 2),
}
La función funciona, pero la firma es larga y mezcla datos del cliente, ubicación, items, envío y cupón.
Crea tests/test_cotizaciones.py:
from cotizaciones import calcular_cotizacion
def test_calcula_cotizacion_vip_con_envio_express_y_cupon():
items = [
{"precio": 1000, "cantidad": 2},
{"precio": 500, "cantidad": 1},
]
assert calcular_cotizacion(
"Ana",
"vip",
"CABA",
items,
True,
True,
"DESC5",
) == {"cliente": "Ana", "total": 4789.9}
def test_calcula_cotizacion_regular_sin_envio():
items = [
{"precio": 800, "cantidad": 3},
]
assert calcular_cotizacion(
"Luis",
"regular",
"Mendoza",
items,
False,
False,
"",
) == {"cliente": "Luis", "total": 2400}
Ejecuta:
python -m pytest tests/test_cotizaciones.py
Antes de cambiar la implementación, podemos mejorar la legibilidad de las llamadas usando argumentos con nombre:
calcular_cotizacion(
nombre_cliente="Ana",
tipo_cliente="vip",
provincia="CABA",
items=items,
aplica_envio=True,
envio_express=True,
cupon="DESC5",
)
Esto no cambia la firma, pero reduce errores por orden incorrecto. Es un primer paso útil cuando no podemos modificar muchas llamadas de golpe.
Los parámetros pueden agruparse en conceptos:
nombre_cliente y tipo_cliente pertenecen al cliente.provincia pertenece al destino o ubicación.aplica_envio y envio_express pertenecen al envío.items y cupon pertenecen a la cotización.Cuando un grupo aparece naturalmente, podemos introducir un objeto de datos.
Agregamos dataclass para representar cliente y envío:
from dataclasses import dataclass
@dataclass
class Cliente:
nombre: str
tipo: str
@dataclass
class Envio:
aplica: bool = False
express: bool = False
Estas clases no agregan comportamiento todavía. Solo dan nombre y estructura a datos que viajaban juntos.
Para evitar romper llamadas existentes de golpe, creamos una función nueva:
def calcular_cotizacion_v2(cliente, provincia, items, envio=None, cupon=""):
if envio is None:
envio = Envio()
subtotal = 0
for item in items:
subtotal = subtotal + item["precio"] * item["cantidad"]
if cliente.tipo == "vip":
subtotal = subtotal * 0.9
if cupon == "DESC5":
subtotal = subtotal - 5
if provincia == "CABA":
subtotal = subtotal + subtotal * 0.02
if envio.aplica:
if envio.express:
subtotal = subtotal + 2500
else:
subtotal = subtotal + 1000
return {
"cliente": cliente.nombre,
"total": round(subtotal, 2),
}
La firma ya comunica mejor qué datos espera la función.
Agregamos pruebas para la nueva función sin eliminar las anteriores:
from cotizaciones import Cliente, Envio, calcular_cotizacion, calcular_cotizacion_v2
def test_calcula_cotizacion_v2_vip_con_envio_express_y_cupon():
items = [
{"precio": 1000, "cantidad": 2},
{"precio": 500, "cantidad": 1},
]
cliente = Cliente(nombre="Ana", tipo="vip")
envio = Envio(aplica=True, express=True)
assert calcular_cotizacion_v2(
cliente=cliente,
provincia="CABA",
items=items,
envio=envio,
cupon="DESC5",
) == {"cliente": "Ana", "total": 4789.9}
Ejecuta python -m pytest tests/test_cotizaciones.py y confirma que conviven ambas interfaces.
Podemos hacer que la función vieja delegue en la nueva:
def calcular_cotizacion(
nombre_cliente,
tipo_cliente,
provincia,
items,
aplica_envio,
envio_express,
cupon,
):
cliente = Cliente(nombre=nombre_cliente, tipo=tipo_cliente)
envio = Envio(aplica=aplica_envio, express=envio_express)
return calcular_cotizacion_v2(
cliente=cliente,
provincia=provincia,
items=items,
envio=envio,
cupon=cupon,
)
Así podemos migrar llamadas poco a poco. El comportamiento anterior sigue disponible mientras el código nuevo usa la firma mejorada.
Los valores por defecto ayudan cuando representan el caso común:
def calcular_cotizacion_v2(cliente, provincia, items, envio=None, cupon=""):
Pero hay que evitar valores mutables como listas o diccionarios por defecto:
# Evitar
def agregar_item(item, items=[]):
items.append(item)
return items
En Python, ese valor se comparte entre llamadas. Para listas, usa None y crea la lista dentro de la función.
Los parámetros booleanos suelen indicar que una función hace dos cosas distintas. En nuestro ejemplo, aplica_envio y envio_express pueden expresarse mejor con un objeto Envio.
Envio(aplica=True, express=True)
Envio(aplica=True, express=False)
Envio()
La llamada queda más explícita que una secuencia de True y False difíciles de interpretar.
Un objeto de datos puede empezar solo con atributos. Si una regla pertenece claramente a ese concepto, podemos moverla allí.
@dataclass
class Envio:
aplica: bool = False
express: bool = False
def costo(self):
if not self.aplica:
return 0
if self.express:
return 2500
return 1000
Entonces la función principal usa:
subtotal = subtotal + envio.costo()
Una versión más clara del módulo puede quedar así:
from dataclasses import dataclass
@dataclass
class Cliente:
nombre: str
tipo: str
@dataclass
class Envio:
aplica: bool = False
express: bool = False
def costo(self):
if not self.aplica:
return 0
if self.express:
return 2500
return 1000
def calcular_cotizacion_v2(cliente, provincia, items, envio=None, cupon=""):
if envio is None:
envio = Envio()
subtotal = 0
for item in items:
subtotal = subtotal + item["precio"] * item["cantidad"]
if cliente.tipo == "vip":
subtotal = subtotal * 0.9
if cupon == "DESC5":
subtotal = subtotal - 5
if provincia == "CABA":
subtotal = subtotal + subtotal * 0.02
subtotal = subtotal + envio.costo()
return {
"cliente": cliente.nombre,
"total": round(subtotal, 2),
}
El cálculo principal ya no recibe una cadena larga de parámetros sueltos.
Crea el archivo src/reservas_parametros.py:
def calcular_reserva(
nombre_cliente,
email_cliente,
noches,
precio_noche,
personas,
incluye_desayuno,
late_checkout,
):
total = noches * precio_noche
if incluye_desayuno:
total = total + personas * noches * 3000
if late_checkout:
total = total + 5000
return {
"cliente": nombre_cliente,
"email": email_cliente,
"total": round(total, 2),
}
Realiza estas tareas:
dataclass para el cliente.dataclass para las opciones de reserva.python -m pytest después de cada paso.Una solución posible es:
from dataclasses import dataclass
@dataclass
class ClienteReserva:
nombre: str
email: str
@dataclass
class OpcionesReserva:
incluye_desayuno: bool = False
late_checkout: bool = False
def calcular_reserva_v2(cliente, noches, precio_noche, personas, opciones=None):
if opciones is None:
opciones = OpcionesReserva()
total = noches * precio_noche
if opciones.incluye_desayuno:
total = total + personas * noches * 3000
if opciones.late_checkout:
total = total + 5000
return {
"cliente": cliente.nombre,
"email": cliente.email,
"total": round(total, 2),
}
La nueva firma separa datos del cliente, datos de estadía y opciones. Cada llamada puede leerse con menos esfuerzo.
Antes de continuar, verifica que puedes explicar estos puntos:
dataclass para representar datos relacionados.En este tema simplificamos firmas largas agrupando datos relacionados, usando valores por defecto y manteniendo compatibilidad durante la migración. La meta no es crear clases para todo, sino dar nombre a conceptos que ya existen en los parámetros.
En el próximo tema reemplazaremos códigos mágicos por constantes, enumeraciones y objetos de valor.