10. Simplificar anidamientos con cláusulas de guarda y retornos tempranos

10.1 Objetivo del tema

Los condicionales anidados obligan a seguir muchas ramas mentales al mismo tiempo. Cuando una función tiene varios niveles de indentación, suele ser difícil distinguir el flujo principal de los casos excepcionales.

En este tema practicaremos cláusulas de guarda y retornos tempranos. La idea es resolver primero los casos que impiden continuar y dejar el flujo principal con menos anidamiento.

Objetivo práctico: refactorizar funciones Python con condicionales anidados usando retornos tempranos, sin cambiar su comportamiento.

10.2 Qué es una cláusula de guarda

Una cláusula de guarda es una condición al comienzo de una función o bloque que detecta un caso especial y termina la ejecución temprano.

def calcular_descuento(cliente):
    if not cliente["activo"]:
        return 0

    return cliente["compras"] * 0.05

El caso que impide continuar queda claro al principio. Luego el flujo normal puede leerse sin un else innecesario.

10.3 Código inicial

Crea el archivo src/registro.py:

def registrar_usuario(usuario):
    if usuario is not None:
        if usuario.get("email"):
            if "@" in usuario["email"]:
                if usuario.get("edad", 0) >= 18:
                    if usuario.get("password"):
                        if len(usuario["password"]) >= 8:
                            return {
                                "estado": "registrado",
                                "mensaje": "Usuario registrado correctamente",
                            }
                        else:
                            return {
                                "estado": "error",
                                "mensaje": "La contraseña debe tener al menos 8 caracteres",
                            }
                    else:
                        return {"estado": "error", "mensaje": "La contraseña es obligatoria"}
                else:
                    return {"estado": "error", "mensaje": "El usuario debe ser mayor de edad"}
            else:
                return {"estado": "error", "mensaje": "El email no tiene formato válido"}
        else:
            return {"estado": "error", "mensaje": "El email es obligatorio"}
    else:
        return {"estado": "error", "mensaje": "El usuario es obligatorio"}

La función valida datos, pero el camino exitoso está enterrado dentro de seis niveles de indentación.

10.4 Pruebas antes de cambiar

Crea tests/test_registro.py:

from registro import registrar_usuario


def test_registra_usuario_valido():
    usuario = {
        "email": "ana@example.com",
        "edad": 25,
        "password": "secreto123",
    }

    assert registrar_usuario(usuario) == {
        "estado": "registrado",
        "mensaje": "Usuario registrado correctamente",
    }


def test_rechaza_usuario_sin_email():
    usuario = {
        "email": "",
        "edad": 25,
        "password": "secreto123",
    }

    assert registrar_usuario(usuario) == {
        "estado": "error",
        "mensaje": "El email es obligatorio",
    }


def test_rechaza_usuario_menor_de_edad():
    usuario = {
        "email": "ana@example.com",
        "edad": 17,
        "password": "secreto123",
    }

    assert registrar_usuario(usuario) == {
        "estado": "error",
        "mensaje": "El usuario debe ser mayor de edad",
    }

Ejecuta:

python -m pytest tests/test_registro.py

10.5 Identificar los casos que bloquean el flujo

La función tiene un caso exitoso y varios errores que impiden continuar:

  • El usuario no existe.
  • El email está vacío.
  • El email no tiene formato válido.
  • El usuario es menor de edad.
  • La contraseña está vacía.
  • La contraseña es demasiado corta.

Cada uno de esos casos puede transformarse en una cláusula de guarda.

10.6 Primeras cláusulas de guarda

Empezamos por los primeros errores y salimos temprano:

def registrar_usuario(usuario):
    if usuario is None:
        return {"estado": "error", "mensaje": "El usuario es obligatorio"}

    if not usuario.get("email"):
        return {"estado": "error", "mensaje": "El email es obligatorio"}

    if "@" not in usuario["email"]:
        return {"estado": "error", "mensaje": "El email no tiene formato válido"}

    if usuario.get("edad", 0) >= 18:
        if usuario.get("password"):
            if len(usuario["password"]) >= 8:
                return {
                    "estado": "registrado",
                    "mensaje": "Usuario registrado correctamente",
                }
            else:
                return {
                    "estado": "error",
                    "mensaje": "La contraseña debe tener al menos 8 caracteres",
                }
        else:
            return {"estado": "error", "mensaje": "La contraseña es obligatoria"}
    else:
        return {"estado": "error", "mensaje": "El usuario debe ser mayor de edad"}

Después de este paso ejecuta python -m pytest tests/test_registro.py.

10.7 Completar los retornos tempranos

Podemos seguir con las validaciones restantes:

def registrar_usuario(usuario):
    if usuario is None:
        return {"estado": "error", "mensaje": "El usuario es obligatorio"}

    if not usuario.get("email"):
        return {"estado": "error", "mensaje": "El email es obligatorio"}

    if "@" not in usuario["email"]:
        return {"estado": "error", "mensaje": "El email no tiene formato válido"}

    if usuario.get("edad", 0) < 18:
        return {"estado": "error", "mensaje": "El usuario debe ser mayor de edad"}

    if not usuario.get("password"):
        return {"estado": "error", "mensaje": "La contraseña es obligatoria"}

    if len(usuario["password"]) < 8:
        return {
            "estado": "error",
            "mensaje": "La contraseña debe tener al menos 8 caracteres",
        }

    return {
        "estado": "registrado",
        "mensaje": "Usuario registrado correctamente",
    }

La función ahora tiene un flujo lineal: primero descarta errores, luego ejecuta el caso exitoso.

10.8 Eliminar else innecesarios

Cuando un if termina con return, muchas veces el else sobra.

if condicion:
    return "error"
else:
    return "ok"

Puede escribirse así:

if condicion:
    return "error"

return "ok"

La segunda versión reduce indentación y deja más claro el flujo.

10.9 Extraer funciones de respuesta

La función todavía repite diccionarios de error. Podemos extraer pequeñas funciones para mejorar consistencia:

def respuesta_error(mensaje):
    return {"estado": "error", "mensaje": mensaje}


def respuesta_exitosa():
    return {
        "estado": "registrado",
        "mensaje": "Usuario registrado correctamente",
    }

Luego la función queda más breve:

def registrar_usuario(usuario):
    if usuario is None:
        return respuesta_error("El usuario es obligatorio")

    if not usuario.get("email"):
        return respuesta_error("El email es obligatorio")

    if "@" not in usuario["email"]:
        return respuesta_error("El email no tiene formato válido")

    if usuario.get("edad", 0) < 18:
        return respuesta_error("El usuario debe ser mayor de edad")

    if not usuario.get("password"):
        return respuesta_error("La contraseña es obligatoria")

    if len(usuario["password"]) < 8:
        return respuesta_error("La contraseña debe tener al menos 8 caracteres")

    return respuesta_exitosa()

10.10 Extraer predicados de validación

Si queremos dar todavía más intención, podemos extraer predicados:

def tiene_email_valido(usuario):
    return bool(usuario.get("email")) and "@" in usuario["email"]


def es_mayor_de_edad(usuario):
    return usuario.get("edad", 0) >= 18


def tiene_password_segura(usuario):
    return bool(usuario.get("password")) and len(usuario["password"]) >= 8

No siempre hace falta extraer todos los predicados. Se justifica cuando el nombre mejora la comprensión o la regla se reutiliza.

10.11 Cuidado con cambiar el orden de validación

Durante este refactoring el orden importa. Si un usuario tiene email inválido y es menor de edad, la función original devuelve primero el error de email. Cambiar el orden de las cláusulas de guarda podría cambiar el mensaje devuelto.

Por eso conviene tener pruebas para varios errores y respetar el orden del comportamiento actual, salvo que estemos haciendo explícitamente un cambio de reglas.

10.12 Agregar pruebas para el orden de errores

Podemos documentar un caso con múltiples errores:

def test_respeta_el_primer_error_segun_el_orden_actual():
    usuario = {
        "email": "email-sin-arroba",
        "edad": 10,
        "password": "",
    }

    assert registrar_usuario(usuario) == {
        "estado": "error",
        "mensaje": "El email no tiene formato válido",
    }

Esta prueba evita que el refactoring cambie accidentalmente la prioridad de las validaciones.

10.13 Retornos tempranos en bucles

Los retornos tempranos también ayudan en búsquedas. Observa este código:

def buscar_usuario_activo(usuarios, email):
    encontrado = None
    for usuario in usuarios:
        if usuario["email"] == email:
            if usuario["activo"]:
                encontrado = usuario
    return encontrado

Podemos simplificarlo:

def buscar_usuario_activo(usuarios, email):
    for usuario in usuarios:
        if usuario["email"] == email and usuario["activo"]:
            return usuario

    return None

El retorno temprano expresa que la búsqueda termina cuando encuentra el primer resultado válido.

10.14 Cuándo evitar retornos tempranos

Los retornos tempranos ayudan mucho en validaciones y casos excepcionales, pero no conviene abusar. Si una función tiene retornos dispersos en medio de muchas operaciones, puede ser difícil seguir el estado final.

Una regla práctica: usa cláusulas de guarda para cortar casos que impiden continuar. Para el flujo principal, intenta que la función siga siendo lineal y clara.

10.15 Ejercicio propuesto

Crea el archivo src/reservas_validacion.py:

def validar_reserva(reserva):
    if reserva is not None:
        if reserva.get("cliente"):
            if reserva.get("noches", 0) > 0:
                if reserva.get("personas", 0) > 0:
                    if reserva["personas"] <= 6:
                        if reserva.get("pagada"):
                            return "reserva confirmada"
                        else:
                            return "la reserva debe estar pagada"
                    else:
                        return "la reserva supera el máximo de personas"
                else:
                    return "la cantidad de personas debe ser mayor a cero"
            else:
                return "la cantidad de noches debe ser mayor a cero"
        else:
            return "el cliente es obligatorio"
    else:
        return "la reserva es obligatoria"

Realiza estas tareas:

  • Escribe pruebas para una reserva válida y al menos tres errores.
  • Transforma los errores en cláusulas de guarda.
  • Elimina else innecesarios después de retornos.
  • Conserva el orden de los mensajes de error.
  • Ejecuta python -m pytest después de cada paso.

10.16 Una posible solución

Una versión refactorizada puede ser:

def validar_reserva(reserva):
    if reserva is None:
        return "la reserva es obligatoria"

    if not reserva.get("cliente"):
        return "el cliente es obligatorio"

    if reserva.get("noches", 0) <= 0:
        return "la cantidad de noches debe ser mayor a cero"

    if reserva.get("personas", 0) <= 0:
        return "la cantidad de personas debe ser mayor a cero"

    if reserva["personas"] > 6:
        return "la reserva supera el máximo de personas"

    if not reserva.get("pagada"):
        return "la reserva debe estar pagada"

    return "reserva confirmada"

La lógica es la misma, pero el flujo principal queda visible al final.

10.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué es una cláusula de guarda.
  • Por qué los retornos tempranos reducen indentación.
  • Cuándo un else es innecesario después de un return.
  • Por qué el orden de validación puede formar parte del comportamiento.
  • Cómo aplicar retornos tempranos en una búsqueda.
  • Cuándo conviene evitar demasiados retornos en una función.

10.18 Conclusión

En este tema simplificamos condicionales anidados con cláusulas de guarda y retornos tempranos. El resultado fue código con menos indentación, errores tratados de forma explícita y un flujo principal más fácil de leer.

En el próximo tema veremos cómo eliminar duplicación sin caer en abstracciones prematuras.