15. Complejidad ciclomática: medirla y reducirla en funciones Python

15.1 Objetivo del tema

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.

Objetivo práctico: medir la complejidad de funciones Python y reducirla sin cambiar el comportamiento esperado.

15.2 Qué mide la complejidad ciclomática

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.

15.3 Ejemplo simple

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.

15.4 Complejidad no es lo mismo que cantidad de líneas

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.

15.5 Configurar Ruff para detectar complejidad

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.

15.6 Ejecutar Ruff

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.

15.7 Crear un ejemplo complejo

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

15.8 Primer paso: separar validación

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.

15.9 Segundo paso: separar clasificación por tipo

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.

15.10 Función principal reducida

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.

15.11 Usar tablas de decisión

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.

15.12 Complejidad y pruebas

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.

15.13 Aplicación sobre ventas_demo

Revisa calcular_total_venta y sus funciones auxiliares. Si una función tiene demasiadas decisiones, pregúntate:

  • ¿Hay validaciones mezcladas con cálculos?
  • ¿Hay decisiones por tipo de cliente o país?
  • ¿Una tabla de reglas sería más clara?
  • ¿Hay condiciones que podrían tener nombre propio?
  • ¿Puedo probar una regla en una función más pequeña?

15.14 No perseguir la métrica ciegamente

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.

La métrica sirve como alarma. El criterio humano decide si el cambio mejora realmente la comprensión.

15.15 Ejercicio guiado

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.

15.16 Una posible solución

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.

15.17 Ejercicio propuesto

En ventas_demo, configura Ruff para detectar complejidad y realiza estas tareas:

  • Ejecuta Ruff sobre src y tests.
  • Elige una función con varias decisiones.
  • Agrega pruebas antes de modificar.
  • Reduce complejidad extrayendo funciones o tablas de reglas.
  • Ejecuta Ruff y pruebas al final.
python -m ruff check src tests
python -m pytest

15.18 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Explicar qué mide la complejidad ciclomática.
  • Configurar Ruff para detectar funciones complejas.
  • Interpretar una advertencia C901.
  • Reducir complejidad con funciones pequeñas.
  • Usar cláusulas de guarda para simplificar caminos.
  • Aplicar tablas de decisión cuando mejoran claridad.
  • Evitar perseguir métricas sin criterio.

15.19 Conclusión

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.