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.
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.
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.
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
Antes de extraer funciones, conviene traducir cada parte del condicional a preguntas del dominio:
Estas preguntas serán la base de los nombres de las funciones.
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.
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.
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.
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.
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.
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.
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.
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
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.
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:
python -m pytest después de cada grupo de cambios.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"
Antes de continuar, verifica que puedes explicar estos puntos:
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.