8. Separar consulta y modificación para reducir efectos secundarios

8.1 Objetivo del tema

Una fuente frecuente de errores aparece cuando una función parece consultar información, pero además modifica datos. Ese efecto secundario puede sorprender a quien llama a la función y hacer que las pruebas sean más difíciles de escribir.

En este tema practicaremos la separación entre consultas y modificaciones. Una consulta devuelve información sin cambiar estado. Una modificación cambia estado, pero no debería esconder cálculos importantes detrás de un nombre ambiguo.

Objetivo práctico: refactorizar funciones Python que calculan y modifican datos a la vez, separando responsabilidades y reduciendo efectos secundarios.

8.2 Qué es un efecto secundario

Un efecto secundario ocurre cuando una función cambia algo fuera de su resultado de retorno. Puede modificar una lista, un diccionario, un atributo, un archivo, una base de datos, la consola o una variable global.

Los efectos secundarios no son siempre malos. Guardar un pedido, enviar un email o registrar un pago requiere modificar algo. El problema aparece cuando el efecto está escondido en una función que parece ser solo una consulta.

8.3 Consulta y comando

Una forma práctica de pensar el diseño es separar:

  • Consulta: responde una pregunta y no modifica estado.
  • Comando: modifica estado y deja claro que produce un cambio.

Por ejemplo, calcular_total() debería calcular y devolver un total. En cambio, registrar_pago() puede modificar una factura. Si una función se llama obtener_total() y además cambia el pedido, el nombre oculta un riesgo.

8.4 Código inicial

Crea el archivo src/carrito.py:

def obtener_total(carrito):
    total = 0
    for item in carrito["items"]:
        total = total + item["precio"] * item["cantidad"]

    if carrito["cliente"] == "vip":
        total = total * 0.9

    carrito["total"] = round(total, 2)
    carrito["procesado"] = True

    return carrito["total"]

El nombre obtener_total sugiere una consulta, pero la función modifica el diccionario carrito agregando "total" y "procesado".

8.5 Pruebas de caracterización

Primero capturamos el comportamiento actual. Crea tests/test_carrito.py:

from carrito import obtener_total


def test_obtener_total_devuelve_total_y_modifica_carrito_vip():
    carrito = {
        "cliente": "vip",
        "items": [
            {"precio": 1000, "cantidad": 2},
            {"precio": 500, "cantidad": 1},
        ],
    }

    assert obtener_total(carrito) == 2250.0
    assert carrito["total"] == 2250.0
    assert carrito["procesado"] is True


def test_obtener_total_devuelve_total_regular():
    carrito = {
        "cliente": "regular",
        "items": [
            {"precio": 800, "cantidad": 3},
        ],
    }

    assert obtener_total(carrito) == 2400
    assert carrito["total"] == 2400
    assert carrito["procesado"] is True

Ejecuta:

python -m pytest tests/test_carrito.py

8.6 Separar primero el cálculo

El primer refactoring será extraer una consulta pura: una función que calcule el total sin modificar el carrito.

def calcular_total(carrito):
    total = 0
    for item in carrito["items"]:
        total = total + item["precio"] * item["cantidad"]

    if carrito["cliente"] == "vip":
        total = total * 0.9

    return round(total, 2)

Todavía no eliminamos la función anterior. La hacemos delegar en la nueva consulta y conservar la modificación existente.

8.7 Mantener compatibilidad temporal

Podemos modificar obtener_total así:

def calcular_total(carrito):
    total = 0
    for item in carrito["items"]:
        total = total + item["precio"] * item["cantidad"]

    if carrito["cliente"] == "vip":
        total = total * 0.9

    return round(total, 2)


def obtener_total(carrito):
    total = calcular_total(carrito)
    carrito["total"] = total
    carrito["procesado"] = True
    return total

El comportamiento público de obtener_total sigue igual, pero ahora tenemos una función pura que podemos probar y reutilizar. Ejecuta python -m pytest tests/test_carrito.py.

8.8 Probar que la consulta no modifica datos

Agregamos una prueba específica para la nueva función:

from carrito import calcular_total, obtener_total


def test_calcular_total_no_modifica_el_carrito():
    carrito = {
        "cliente": "vip",
        "items": [
            {"precio": 1000, "cantidad": 2},
        ],
    }

    assert calcular_total(carrito) == 1800.0
    assert "total" not in carrito
    assert "procesado" not in carrito

Esta prueba documenta una propiedad importante del nuevo diseño: calcular no debería modificar.

8.9 Renombrar la función con efecto secundario

El nombre obtener_total ya no es honesto. Si una función modifica el carrito, conviene que el nombre lo diga. Podemos crear una función nueva:

def marcar_carrito_procesado(carrito):
    total = calcular_total(carrito)
    carrito["total"] = total
    carrito["procesado"] = True
    return total

Luego actualizamos las llamadas internas y las pruebas nuevas para usar marcar_carrito_procesado. Si necesitamos compatibilidad temporal, podemos dejar obtener_total como alias durante una migración.

8.10 Compatibilidad con una función delegada

Si otros módulos todavía llaman a obtener_total, podemos mantenerla temporalmente:

def obtener_total(carrito):
    return marcar_carrito_procesado(carrito)

Esto conserva el comportamiento anterior, pero permite que el código nuevo use nombres más precisos. Más adelante, cuando todas las llamadas hayan migrado, se podrá eliminar la función vieja.

8.11 Código resultante

Una versión ordenada del módulo puede quedar así:

def calcular_total(carrito):
    total = 0
    for item in carrito["items"]:
        total = total + item["precio"] * item["cantidad"]

    if carrito["cliente"] == "vip":
        total = total * 0.9

    return round(total, 2)


def marcar_carrito_procesado(carrito):
    total = calcular_total(carrito)
    carrito["total"] = total
    carrito["procesado"] = True
    return total


def obtener_total(carrito):
    return marcar_carrito_procesado(carrito)

Ahora distinguimos claramente la consulta pura y la modificación del diccionario.

8.12 Evitar mutaciones accidentales en listas

Los efectos secundarios también aparecen cuando una función modifica una lista recibida como parámetro.

def obtener_items_confirmados(items):
    for item in items:
        if item["estado"] != "confirmado":
            items.remove(item)
    return items

El nombre parece una consulta, pero la función modifica la lista original. Una alternativa más clara es construir una nueva lista:

def obtener_items_confirmados(items):
    confirmados = []
    for item in items:
        if item["estado"] == "confirmado":
            confirmados.append(item)
    return confirmados

Esta versión devuelve información sin alterar la colección recibida.

8.13 Probar que una lista no fue modificada

Podemos verificarlo con una prueba sencilla:

def test_obtener_items_confirmados_no_modifica_lista_original():
    items = [
        {"estado": "confirmado", "nombre": "A"},
        {"estado": "pendiente", "nombre": "B"},
    ]

    resultado = obtener_items_confirmados(items)

    assert resultado == [{"estado": "confirmado", "nombre": "A"}]
    assert items == [
        {"estado": "confirmado", "nombre": "A"},
        {"estado": "pendiente", "nombre": "B"},
    ]

Esta prueba protege contra regresiones: si alguien vuelve a modificar la lista original, la prueba fallará.

8.14 Comandos que sí deben modificar

No toda mutación debe eliminarse. A veces necesitamos una función que cambie estado. Lo importante es que el nombre sea claro.

def aplicar_descuento_al_carrito(carrito, porcentaje):
    carrito["descuento"] = porcentaje
    carrito["total"] = carrito["total"] * (1 - porcentaje)

El nombre aplicar_descuento_al_carrito comunica una acción. No promete ser una consulta.

8.15 Ejercicio propuesto

Crea el archivo src/inventario.py:

def obtener_disponible(producto, cantidad):
    disponible = producto["stock"] >= cantidad
    if disponible:
        producto["stock"] = producto["stock"] - cantidad
        producto["reservado"] = producto.get("reservado", 0) + cantidad
    return disponible

Realiza estas tareas:

  • Escribe pruebas que documenten el comportamiento actual.
  • Extrae una consulta llamada hay_stock_disponible que no modifique el producto.
  • Crea un comando llamado reservar_stock que sí modifique el producto.
  • Agrega una prueba que confirme que la consulta no cambia el diccionario.
  • Ejecuta python -m pytest después de cada paso.

8.16 Una posible solución

Una versión más clara puede ser:

def hay_stock_disponible(producto, cantidad):
    return producto["stock"] >= cantidad


def reservar_stock(producto, cantidad):
    if not hay_stock_disponible(producto, cantidad):
        return False

    producto["stock"] = producto["stock"] - cantidad
    producto["reservado"] = producto.get("reservado", 0) + cantidad
    return True


def obtener_disponible(producto, cantidad):
    return reservar_stock(producto, cantidad)

La función obtener_disponible queda como compatibilidad temporal. El código nuevo debería preferir hay_stock_disponible para consultar y reservar_stock para modificar.

8.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué es un efecto secundario.
  • Cómo distinguir una consulta de un comando.
  • Por qué una función con nombre de consulta no debería modificar datos.
  • Cómo extraer una función pura desde una función que modifica estado.
  • Cómo probar que una lista o diccionario no fue modificado.
  • Cuándo conviene mantener una función delegada por compatibilidad.

8.18 Conclusión

En este tema separamos consultas y modificaciones para reducir efectos secundarios ocultos. Vimos que una función puede modificar estado, pero debe expresarlo con claridad en su nombre y no mezclarse innecesariamente con cálculos reutilizables.

En el próximo tema trabajaremos con condicionales complejos y cómo reemplazarlos por funciones con nombres claros.