9. Reemplazar condicionales complejos por funciones con nombres claros

9.1 Objetivo del tema

Los condicionales complejos son una de las causas más comunes de código difícil de modificar. Una expresión con varios and, or, negaciones y comparaciones obliga al lector a interpretar reglas de negocio mezcladas con detalles técnicos.

En este tema practicaremos cómo reemplazar condicionales complejos por funciones con nombres claros. La idea es extraer predicados: funciones que devuelven True o False y cuyo nombre expresa una condición importante del dominio.

Objetivo práctico: simplificar condicionales en Python extrayendo funciones booleanas con nombres expresivos y verificando el comportamiento con pruebas.

9.2 Qué es un predicado

Un predicado es una función que responde una pregunta. En Python suele devolver un booleano. Por ejemplo:

def es_cliente_vip(cliente):
    return cliente["tipo"] == "vip"

El nombre debe leerse como una pregunta o afirmación: es_cliente_vip, tiene_saldo_suficiente, requiere_aprobacion_manual. Esto permite reemplazar expresiones largas por lenguaje del negocio.

9.3 Código inicial

Crea el archivo src/creditos.py:

def evaluar_credito(solicitud):
    if (
        solicitud["edad"] >= 18
        and solicitud["ingresos"] >= 300000
        and solicitud["deuda"] / solicitud["ingresos"] <= 0.35
        and (solicitud["historial"] == "bueno" or solicitud["garantia"])
        and not solicitud["bloqueado"]
    ):
        return "aprobado"

    if (
        solicitud["edad"] >= 18
        and solicitud["ingresos"] >= 180000
        and solicitud["deuda"] / solicitud["ingresos"] <= 0.5
        and not solicitud["bloqueado"]
    ):
        return "revision"

    return "rechazado"

El código funciona, pero las reglas quedan escondidas dentro de expresiones largas.

9.4 Pruebas antes de refactorizar

Crea tests/test_creditos.py:

from creditos import evaluar_credito


def test_aprueba_credito_con_buen_historial():
    solicitud = {
        "edad": 30,
        "ingresos": 400000,
        "deuda": 100000,
        "historial": "bueno",
        "garantia": False,
        "bloqueado": False,
    }

    assert evaluar_credito(solicitud) == "aprobado"


def test_envia_a_revision_si_cumple_requisitos_intermedios():
    solicitud = {
        "edad": 25,
        "ingresos": 200000,
        "deuda": 90000,
        "historial": "malo",
        "garantia": False,
        "bloqueado": False,
    }

    assert evaluar_credito(solicitud) == "revision"


def test_rechaza_si_esta_bloqueado():
    solicitud = {
        "edad": 40,
        "ingresos": 800000,
        "deuda": 10000,
        "historial": "bueno",
        "garantia": True,
        "bloqueado": True,
    }

    assert evaluar_credito(solicitud) == "rechazado"

Ejecuta:

python -m pytest tests/test_creditos.py

9.5 Identificar las preguntas escondidas

Antes de extraer funciones, conviene traducir cada parte del condicional a preguntas del dominio:

  • ¿La persona es mayor de edad?
  • ¿Tiene ingresos suficientes para aprobación directa?
  • ¿Tiene ingresos suficientes para revisión?
  • ¿La relación deuda-ingresos está dentro del límite?
  • ¿Tiene respaldo por historial o garantía?
  • ¿La solicitud está bloqueada?

Estas preguntas serán la base de los nombres de las funciones.

9.6 Extraer predicados simples

Empezamos por condiciones pequeñas y fáciles de verificar:

def es_mayor_de_edad(solicitud):
    return solicitud["edad"] >= 18


def esta_bloqueada(solicitud):
    return solicitud["bloqueado"]


def tiene_respaldo_crediticio(solicitud):
    return solicitud["historial"] == "bueno" or solicitud["garantia"]

Luego ejecuta las pruebas. Aunque todavía no usemos todas las funciones, podemos incorporarlas en pasos pequeños.

9.7 Extraer relación deuda-ingresos

La expresión solicitud["deuda"] / solicitud["ingresos"] aparece más de una vez. Podemos darle nombre:

def calcular_relacion_deuda_ingresos(solicitud):
    return solicitud["deuda"] / solicitud["ingresos"]


def tiene_deuda_aceptable_para_aprobacion(solicitud):
    return calcular_relacion_deuda_ingresos(solicitud) <= 0.35


def tiene_deuda_aceptable_para_revision(solicitud):
    return calcular_relacion_deuda_ingresos(solicitud) <= 0.5

El número sigue allí, pero ahora está asociado a una regla con nombre.

9.8 Extraer ingresos suficientes

También podemos separar los umbrales de ingresos:

INGRESOS_MINIMOS_APROBACION = 300000
INGRESOS_MINIMOS_REVISION = 180000


def tiene_ingresos_para_aprobacion(solicitud):
    return solicitud["ingresos"] >= INGRESOS_MINIMOS_APROBACION


def tiene_ingresos_para_revision(solicitud):
    return solicitud["ingresos"] >= INGRESOS_MINIMOS_REVISION

Las constantes ayudan a quitar números mágicos de las expresiones principales.

9.9 Crear predicados de nivel superior

Ahora podemos expresar reglas completas con funciones de nivel superior:

def cumple_requisitos_aprobacion(solicitud):
    return (
        es_mayor_de_edad(solicitud)
        and tiene_ingresos_para_aprobacion(solicitud)
        and tiene_deuda_aceptable_para_aprobacion(solicitud)
        and tiene_respaldo_crediticio(solicitud)
        and not esta_bloqueada(solicitud)
    )


def cumple_requisitos_revision(solicitud):
    return (
        es_mayor_de_edad(solicitud)
        and tiene_ingresos_para_revision(solicitud)
        and tiene_deuda_aceptable_para_revision(solicitud)
        and not esta_bloqueada(solicitud)
    )

La complejidad no desaparece mágicamente, pero ahora está organizada en reglas nombradas.

9.10 Reescribir la función principal

La función principal puede quedar así:

def evaluar_credito(solicitud):
    if cumple_requisitos_aprobacion(solicitud):
        return "aprobado"

    if cumple_requisitos_revision(solicitud):
        return "revision"

    return "rechazado"

Esta versión es más corta y expresa las decisiones principales sin obligar a leer todas las comparaciones.

9.11 Código refactorizado completo

El módulo puede quedar así:

INGRESOS_MINIMOS_APROBACION = 300000
INGRESOS_MINIMOS_REVISION = 180000


def es_mayor_de_edad(solicitud):
    return solicitud["edad"] >= 18


def esta_bloqueada(solicitud):
    return solicitud["bloqueado"]


def tiene_respaldo_crediticio(solicitud):
    return solicitud["historial"] == "bueno" or solicitud["garantia"]


def calcular_relacion_deuda_ingresos(solicitud):
    return solicitud["deuda"] / solicitud["ingresos"]


def tiene_deuda_aceptable_para_aprobacion(solicitud):
    return calcular_relacion_deuda_ingresos(solicitud) <= 0.35


def tiene_deuda_aceptable_para_revision(solicitud):
    return calcular_relacion_deuda_ingresos(solicitud) <= 0.5


def tiene_ingresos_para_aprobacion(solicitud):
    return solicitud["ingresos"] >= INGRESOS_MINIMOS_APROBACION


def tiene_ingresos_para_revision(solicitud):
    return solicitud["ingresos"] >= INGRESOS_MINIMOS_REVISION


def cumple_requisitos_aprobacion(solicitud):
    return (
        es_mayor_de_edad(solicitud)
        and tiene_ingresos_para_aprobacion(solicitud)
        and tiene_deuda_aceptable_para_aprobacion(solicitud)
        and tiene_respaldo_crediticio(solicitud)
        and not esta_bloqueada(solicitud)
    )


def cumple_requisitos_revision(solicitud):
    return (
        es_mayor_de_edad(solicitud)
        and tiene_ingresos_para_revision(solicitud)
        and tiene_deuda_aceptable_para_revision(solicitud)
        and not esta_bloqueada(solicitud)
    )


def evaluar_credito(solicitud):
    if cumple_requisitos_aprobacion(solicitud):
        return "aprobado"

    if cumple_requisitos_revision(solicitud):
        return "revision"

    return "rechazado"

Después de aplicar este cambio, ejecuta python -m pytest tests/test_creditos.py.

9.12 Probar predicados directamente

A veces conviene probar una regla compleja por separado. Por ejemplo:

from creditos import cumple_requisitos_aprobacion


def test_no_cumple_aprobacion_si_no_tiene_respaldo_crediticio():
    solicitud = {
        "edad": 30,
        "ingresos": 400000,
        "deuda": 100000,
        "historial": "malo",
        "garantia": False,
        "bloqueado": False,
    }

    assert cumple_requisitos_aprobacion(solicitud) is False

Esto ayuda cuando una condición de negocio es importante y queremos documentarla explícitamente.

9.13 Cuidado con nombres demasiado técnicos

Un nombre como condicion_1 o validar_flags no mejora mucho la lectura. El objetivo es expresar una regla del dominio, no solo mover la complejidad a otro lugar.

# Poco útil
def condicion_credito(solicitud):
    return solicitud["edad"] >= 18 and solicitud["ingresos"] >= 300000


# Más claro
def tiene_edad_e_ingresos_para_aprobacion(solicitud):
    return solicitud["edad"] >= 18 and solicitud["ingresos"] >= 300000

9.14 Evitar predicados con efectos secundarios

Un predicado debe responder una pregunta. No debería modificar datos, escribir archivos ni cambiar estado global.

# Evitar
def esta_aprobado(solicitud):
    solicitud["evaluado"] = True
    return solicitud["estado"] == "aprobado"

Si necesitamos marcar una solicitud como evaluada, conviene hacerlo en otra función con nombre de comando.

9.15 Ejercicio propuesto

Crea el archivo src/becas.py:

def evaluar_beca(alumno):
    if (
        alumno["promedio"] >= 8
        and alumno["ingresos_familiares"] <= 250000
        and alumno["materias_aprobadas"] >= 6
        and not alumno["sancionado"]
    ):
        return "beca completa"

    if (
        alumno["promedio"] >= 7
        and alumno["ingresos_familiares"] <= 400000
        and alumno["materias_aprobadas"] >= 4
        and not alumno["sancionado"]
    ):
        return "media beca"

    return "sin beca"

Realiza estas tareas:

  • Escribe pruebas para beca completa, media beca y sin beca.
  • Extrae constantes para los umbrales.
  • Extrae predicados simples con nombres claros.
  • Crea predicados de nivel superior para cada tipo de beca.
  • Ejecuta python -m pytest después de cada grupo de cambios.

9.16 Una posible solución

Una versión refactorizada puede ser:

PROMEDIO_BECA_COMPLETA = 8
PROMEDIO_MEDIA_BECA = 7
INGRESOS_BECA_COMPLETA = 250000
INGRESOS_MEDIA_BECA = 400000
MATERIAS_BECA_COMPLETA = 6
MATERIAS_MEDIA_BECA = 4


def no_tiene_sanciones(alumno):
    return not alumno["sancionado"]


def cumple_requisitos_academicos_completos(alumno):
    return (
        alumno["promedio"] >= PROMEDIO_BECA_COMPLETA
        and alumno["materias_aprobadas"] >= MATERIAS_BECA_COMPLETA
    )


def cumple_requisitos_academicos_parciales(alumno):
    return (
        alumno["promedio"] >= PROMEDIO_MEDIA_BECA
        and alumno["materias_aprobadas"] >= MATERIAS_MEDIA_BECA
    )


def cumple_requisitos_economicos_completos(alumno):
    return alumno["ingresos_familiares"] <= INGRESOS_BECA_COMPLETA


def cumple_requisitos_economicos_parciales(alumno):
    return alumno["ingresos_familiares"] <= INGRESOS_MEDIA_BECA


def califica_para_beca_completa(alumno):
    return (
        cumple_requisitos_academicos_completos(alumno)
        and cumple_requisitos_economicos_completos(alumno)
        and no_tiene_sanciones(alumno)
    )


def califica_para_media_beca(alumno):
    return (
        cumple_requisitos_academicos_parciales(alumno)
        and cumple_requisitos_economicos_parciales(alumno)
        and no_tiene_sanciones(alumno)
    )


def evaluar_beca(alumno):
    if califica_para_beca_completa(alumno):
        return "beca completa"
    if califica_para_media_beca(alumno):
        return "media beca"
    return "sin beca"

9.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué problema producen los condicionales complejos.
  • Qué es un predicado y cómo nombrarlo.
  • Cómo extraer condiciones simples y condiciones de nivel superior.
  • Por qué los nombres deben expresar reglas del dominio.
  • Por qué un predicado no debería tener efectos secundarios.
  • Cuándo conviene probar un predicado directamente.

9.18 Conclusión

En este tema reemplazamos condicionales complejos por funciones con nombres claros. El beneficio principal es que la función principal queda escrita en términos del dominio, mientras que los detalles de cada regla viven en predicados pequeños y verificables.

En el próximo tema simplificaremos anidamientos usando cláusulas de guarda y retornos tempranos.