La complejidad ciclomática es una métrica que estima cuántos caminos de decisión existen dentro de una función. Cuantos más caminos tiene una función, más difícil suele ser leerla, probarla y modificarla con seguridad.
En este tema veremos qué significa esta métrica, cómo detectarla con Ruff y cómo reducirla con técnicas prácticas: cláusulas de guarda, funciones pequeñas, tablas de reglas y separación de responsabilidades.
La complejidad ciclomática crece cuando una función agrega caminos alternativos. Estructuras como if, elif, for, while, and, or y bloques de excepción pueden aumentar la cantidad de caminos posibles.
No mide si el código está bien diseñado en todos los aspectos, pero sirve como señal de alerta. Una función con mucha complejidad suele requerir más pruebas y más esfuerzo de lectura.
Esta función tiene una decisión:
def obtener_descuento(cliente):
if cliente == "vip":
return 0.15
return 0
Esta otra tiene más caminos:
def obtener_descuento(cliente, total):
if cliente == "vip":
if total > 10000:
return 0.20
return 0.15
if cliente == "regular":
if total > 10000:
return 0.10
return 0.05
return 0
A medida que agregamos condiciones, la función exige más casos de prueba para cubrir sus caminos importantes.
Una función puede tener pocas líneas y ser compleja si concentra muchas decisiones. También puede tener varias líneas y ser simple si solo describe pasos secuenciales.
def puede_comprar(usuario, producto):
return (
usuario["activo"]
and not usuario["bloqueado"]
and usuario["edad"] >= 18
and producto["stock"] > 0
and producto["precio"] <= usuario["saldo"]
)
La función es corta, pero contiene varias condiciones. Conviene revisarla y decidir si algunas reglas deberían tener nombres propios.
Ruff puede detectar funciones con alta complejidad usando reglas de McCabe. Agrega o ajusta esta configuración en pyproject.toml:
[tool.ruff.lint]
select = ["E", "F", "B", "SIM", "I", "C901"]
[tool.ruff.lint.mccabe]
max-complexity = 6
El valor 6 es útil para practicar. En proyectos reales, el límite puede variar según el equipo y el tipo de código.
Ejecuta Ruff sobre el proyecto:
python -m ruff check src tests
Si una función supera el límite configurado, Ruff puede informar una advertencia C901. Esa advertencia no significa que debas cambiar el código automáticamente, pero sí que conviene revisarlo.
Crea src/complejidad_demo.py con este contenido:
def clasificar_compra(usuario, compra):
if not usuario["activo"]:
return "rechazada"
if usuario["bloqueado"]:
return "rechazada"
if compra["total"] <= 0:
return "rechazada"
if usuario["tipo"] == "vip":
if compra["total"] > 20000:
return "vip-premium"
if compra["total"] > 10000:
return "vip"
return "vip-baja"
if usuario["tipo"] == "regular":
if compra["total"] > 15000:
return "regular-alta"
return "regular"
if compra["total"] > 10000:
return "invitado-alta"
return "invitado"
Luego ejecuta:
python -m ruff check src/complejidad_demo.py
La función anterior mezcla validación y clasificación. Podemos separar la validación inicial.
def es_compra_valida(usuario, compra):
return (
usuario["activo"]
and not usuario["bloqueado"]
and compra["total"] > 0
)
def clasificar_compra(usuario, compra):
if not es_compra_valida(usuario, compra):
return "rechazada"
if usuario["tipo"] == "vip":
if compra["total"] > 20000:
return "vip-premium"
if compra["total"] > 10000:
return "vip"
return "vip-baja"
if usuario["tipo"] == "regular":
if compra["total"] > 15000:
return "regular-alta"
return "regular"
if compra["total"] > 10000:
return "invitado-alta"
return "invitado"
La función principal sigue teniendo decisiones, pero ya delega una parte con nombre.
Podemos extraer funciones específicas para cada tipo de usuario:
def clasificar_vip(total):
if total > 20000:
return "vip-premium"
if total > 10000:
return "vip"
return "vip-baja"
def clasificar_regular(total):
if total > 15000:
return "regular-alta"
return "regular"
def clasificar_invitado(total):
if total > 10000:
return "invitado-alta"
return "invitado"
Ahora cada función tiene menos caminos y se puede probar de forma aislada.
def clasificar_compra(usuario, compra):
if not es_compra_valida(usuario, compra):
return "rechazada"
total = compra["total"]
tipo_usuario = usuario["tipo"]
if tipo_usuario == "vip":
return clasificar_vip(total)
if tipo_usuario == "regular":
return clasificar_regular(total)
return clasificar_invitado(total)
La complejidad se distribuye en funciones con responsabilidades más claras. Esto no elimina todas las decisiones, pero las ubica donde son más fáciles de entender.
Cuando una función elige comportamiento según una clave, un diccionario puede reducir condiciones.
CLASIFICADORES = {
"vip": clasificar_vip,
"regular": clasificar_regular,
}
def clasificar_compra(usuario, compra):
if not es_compra_valida(usuario, compra):
return "rechazada"
clasificador = CLASIFICADORES.get(
usuario["tipo"],
clasificar_invitado,
)
return clasificador(compra["total"])
Esta técnica es útil cuando los casos son datos o estrategias intercambiables. No conviene usarla si oculta reglas simples que se leen mejor con if.
Una función con muchos caminos necesita más casos de prueba. Después de separar funciones, puedes probar cada parte con menos combinaciones.
def test_clasificar_vip_premium():
assert clasificar_vip(25000) == "vip-premium"
def test_clasificar_regular_alta():
assert clasificar_regular(16000) == "regular-alta"
def test_clasificar_invitado():
assert clasificar_invitado(5000) == "invitado"
Las pruebas quedan más enfocadas y ayudan a documentar reglas específicas.
Revisa calcular_total_venta y sus funciones auxiliares. Si una función tiene demasiadas decisiones, pregúntate:
Reducir complejidad ciclomática no garantiza buen diseño. A veces una función con varias condiciones explícitas es más clara que una abstracción demasiado indirecta.
Reduce la complejidad de esta función:
def calcular_beneficio(usuario, compra):
if not usuario["activo"]:
return 0
if usuario["bloqueado"]:
return 0
if compra["total"] <= 0:
return 0
if usuario["tipo"] == "vip":
if compra["total"] > 20000:
return 0.20
if compra["total"] > 10000:
return 0.15
return 0.10
if usuario["tipo"] == "regular":
if compra["total"] > 10000:
return 0.05
return 0
return 0
Empieza separando validación y luego separa reglas por tipo de usuario.
def puede_recibir_beneficio(usuario, compra):
return (
usuario["activo"]
and not usuario["bloqueado"]
and compra["total"] > 0
)
def beneficio_vip(total):
if total > 20000:
return 0.20
if total > 10000:
return 0.15
return 0.10
def beneficio_regular(total):
if total > 10000:
return 0.05
return 0
def calcular_beneficio(usuario, compra):
if not puede_recibir_beneficio(usuario, compra):
return 0
if usuario["tipo"] == "vip":
return beneficio_vip(compra["total"])
if usuario["tipo"] == "regular":
return beneficio_regular(compra["total"])
return 0
La función principal queda más pequeña y cada regla se puede probar por separado.
En ventas_demo, configura Ruff para detectar complejidad y realiza estas tareas:
src y tests.python -m ruff check src tests
python -m pytest
Antes de continuar, verifica que puedes hacer lo siguiente:
C901.En este tema vimos que la complejidad ciclomática ayuda a detectar funciones con demasiados caminos de decisión. Aprendimos a medirla con Ruff y a reducirla separando validaciones, reglas y clasificadores.
En el próximo tema estudiaremos acoplamiento y cohesión: módulos que saben demasiado de otros módulos.