12. Refactorizar parámetros: objetos de datos, valores por defecto y firmas simples

12.1 Objetivo del tema

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.

Objetivo práctico: transformar funciones con muchos argumentos en interfaces más simples, manteniendo el comportamiento mediante pruebas.

12.2 Señales de problemas en parámetros

Conviene revisar una firma cuando aparecen estas señales:

  • La función recibe cinco, seis o más parámetros.
  • Varias llamadas repiten los mismos grupos de argumentos.
  • Hay argumentos booleanos que cambian mucho el comportamiento.
  • El orden de los argumentos es fácil de confundir.
  • Algunos parámetros casi siempre usan el mismo valor.
  • La función recibe datos de una misma entidad separados en muchas piezas.

12.3 Código inicial

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.

12.4 Pruebas antes de cambiar la firma

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

12.5 Usar argumentos con nombre

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.

12.6 Identificar grupos de datos

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.

12.7 Crear dataclasses para datos relacionados

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.

12.8 Crear una función nueva con firma más clara

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.

12.9 Probar la nueva firma

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.

12.10 Mantener compatibilidad con una función adaptadora

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.

12.11 Valores por defecto con cuidado

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.

12.12 Reducir booleanos de control

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.

12.13 Extraer comportamiento al objeto de datos

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()

12.14 Código refactorizado

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.

12.15 Ejercicio propuesto

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:

  • Escribe pruebas para una reserva con desayuno y late checkout.
  • Crea una dataclass para el cliente.
  • Crea una dataclass para las opciones de reserva.
  • Define valores por defecto razonables para las opciones.
  • Mantén una función adaptadora si necesitas compatibilidad con la firma anterior.
  • Ejecuta python -m pytest después de cada paso.

12.16 Una posible solución

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.

12.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué problemas genera una función con demasiados parámetros.
  • Cuándo conviene agrupar argumentos en un objeto de datos.
  • Cómo usar dataclass para representar datos relacionados.
  • Por qué los argumentos con nombre reducen errores en llamadas largas.
  • Qué cuidado hay que tener con valores por defecto mutables.
  • Cómo mantener compatibilidad temporal con una función adaptadora.

12.18 Conclusión

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.