8. Funciones largas y funciones que hacen demasiadas cosas

8.1 Objetivo del tema

Una función larga suele ser una señal de que el código concentra demasiadas decisiones. El problema no es contar líneas de forma mecánica, sino detectar cuándo una función obliga al lector a entender muchas responsabilidades al mismo tiempo.

En este tema aprenderemos a diagnosticar funciones largas, reconocer funciones que hacen demasiadas cosas y aplicar separaciones pequeñas sin cambiar el comportamiento. Usaremos ejemplos Python y el proyecto ventas_demo.

Objetivo práctico: dividir una función grande en funciones más pequeñas, con nombres claros y pruebas que confirmen que el resultado no cambió.

8.2 Cuándo una función es demasiado larga

No existe un número mágico de líneas que sirva para todos los casos. Una función de 25 líneas puede estar bien si expresa una única idea; una función de 8 líneas puede estar mal si mezcla validación, cálculo, entrada, salida y persistencia.

Conviene sospechar cuando:

  • La función tiene comentarios que separan etapas internas.
  • Hay varios niveles de condicionales o bucles.
  • El nombre de la función es muy general, como procesar o manejar.
  • La función cambia por motivos distintos.
  • Cuesta escribir una prueba específica para una parte de la lógica.

8.3 Ejemplo inicial

Observa esta función. Calcula una venta, valida datos, aplica reglas, arma un mensaje y escribe un archivo.

def procesar_venta(productos, cliente, pais):
    if not productos:
        return {"ok": False, "mensaje": "No hay productos"}

    total = 0
    for producto in productos:
        if producto["cantidad"] <= 0:
            continue
        total += producto["precio"] * producto["cantidad"]

    if cliente == "vip":
        total -= total * 0.15
    elif cliente == "regular":
        total -= total * 0.05

    if pais == "AR":
        total += total * 0.21
    elif pais == "UY":
        total += total * 0.22
    else:
        total += total * 0.19

    if total < 10000:
        total += 1500

    total = round(total, 2)
    mensaje = f"Total de la venta: {total}"

    with open("ultima_venta.txt", "w", encoding="utf-8") as archivo:
        archivo.write(mensaje)

    return {"ok": True, "mensaje": mensaje, "total": total}

El código puede funcionar, pero una sola función concentra demasiadas razones para cambiar.

8.4 Diagnóstico de responsabilidades

Antes de extraer funciones, identifiquemos responsabilidades:

  • Validar que existan productos.
  • Calcular subtotal.
  • Aplicar descuento por tipo de cliente.
  • Aplicar impuesto por país.
  • Aplicar envío.
  • Redondear el total.
  • Armar un mensaje.
  • Escribir un archivo.
  • Construir la respuesta.
Cuando una función tiene muchas responsabilidades, cualquier cambio pequeño exige revisar toda la función para no romper algo lateral.

8.5 Primer paso: extraer el subtotal

Una mejora pequeña consiste en extraer el cálculo del subtotal. El nombre de la nueva función explica una parte del proceso.

def calcular_subtotal(productos):
    subtotal = 0
    for producto in productos:
        if producto["cantidad"] > 0:
            subtotal += producto["precio"] * producto["cantidad"]
    return subtotal

Esta función se puede probar de manera aislada y ya no obliga a leer descuentos, impuestos o archivos para entender el subtotal.

8.6 Segundo paso: extraer descuentos

Luego podemos separar la regla de descuento:

DESCUENTO_VIP = 0.15
DESCUENTO_REGULAR = 0.05


def obtener_descuento(cliente):
    if cliente == "vip":
        return DESCUENTO_VIP
    if cliente == "regular":
        return DESCUENTO_REGULAR
    return 0

La función no calcula el total. Solo responde qué descuento corresponde a un cliente. Esa claridad reduce el alcance mental del lector.

8.7 Tercer paso: extraer impuestos

La lógica de impuestos también puede separarse:

IMPUESTOS = {
    "AR": 0.21,
    "UY": 0.22,
}
IMPUESTO_PREDETERMINADO = 0.19


def obtener_impuesto(pais):
    return IMPUESTOS.get(pais, IMPUESTO_PREDETERMINADO)

Además de reducir condicionales, esta versión permite agregar países sin hacer crecer una cadena de elif.

8.8 Cuarto paso: extraer envío

La regla de envío se puede expresar con constantes y una función específica:

LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1500


def aplicar_envio(total):
    if total < LIMITE_ENVIO_GRATIS:
        return total + COSTO_ENVIO
    return total

El nombre aplicar_envio comunica la intención mejor que una condición perdida dentro de una función grande.

8.9 Función principal más clara

Con las funciones anteriores, la función principal queda como una secuencia de pasos reconocibles:

def calcular_total_venta(productos, cliente, pais):
    subtotal = calcular_subtotal(productos)
    descuento = obtener_descuento(cliente)
    impuesto = obtener_impuesto(pais)

    total = subtotal * (1 - descuento)
    total = total * (1 + impuesto)
    total = aplicar_envio(total)

    return round(total, 2)

Esta función todavía coordina varias reglas, pero ya no contiene todos los detalles internos. Es más fácil leerla de arriba hacia abajo.

8.10 Separar cálculo de entrada y salida

La escritura en archivo no debería estar mezclada con el cálculo del total. Podemos separar la generación del mensaje y la persistencia.

def crear_mensaje_venta(total):
    return f"Total de la venta: {total}"


def guardar_ultima_venta(mensaje, ruta="ultima_venta.txt"):
    with open(ruta, "w", encoding="utf-8") as archivo:
        archivo.write(mensaje)

Ahora se puede probar el cálculo sin tocar el sistema de archivos, y se puede probar el guardado por separado si hace falta.

8.11 Versión organizada

Una versión más organizada del ejemplo inicial podría ser:

DESCUENTO_VIP = 0.15
DESCUENTO_REGULAR = 0.05
IMPUESTOS = {"AR": 0.21, "UY": 0.22}
IMPUESTO_PREDETERMINADO = 0.19
LIMITE_ENVIO_GRATIS = 10000
COSTO_ENVIO = 1500


def calcular_subtotal(productos):
    subtotal = 0
    for producto in productos:
        if producto["cantidad"] > 0:
            subtotal += producto["precio"] * producto["cantidad"]
    return subtotal


def obtener_descuento(cliente):
    if cliente == "vip":
        return DESCUENTO_VIP
    if cliente == "regular":
        return DESCUENTO_REGULAR
    return 0


def obtener_impuesto(pais):
    return IMPUESTOS.get(pais, IMPUESTO_PREDETERMINADO)


def aplicar_envio(total):
    if total < LIMITE_ENVIO_GRATIS:
        return total + COSTO_ENVIO
    return total


def calcular_total_venta(productos, cliente, pais):
    subtotal = calcular_subtotal(productos)
    descuento = obtener_descuento(cliente)
    impuesto = obtener_impuesto(pais)

    total = subtotal * (1 - descuento)
    total = total * (1 + impuesto)
    total = aplicar_envio(total)

    return round(total, 2)

8.12 Proteger el comportamiento con pruebas

Antes y después de dividir una función, ejecuta pruebas. En ventas_demo puedes usar:

python -m pytest

También puedes agregar pruebas específicas para las nuevas funciones pequeñas:

from ventas import aplicar_envio, calcular_subtotal, obtener_descuento


def test_calcular_subtotal_ignora_cantidades_no_positivas():
    productos = [
        {"precio": 1000, "cantidad": 2},
        {"precio": 5000, "cantidad": 0},
    ]

    assert calcular_subtotal(productos) == 2000


def test_obtener_descuento_para_cliente_vip():
    assert obtener_descuento("vip") == 0.15


def test_aplicar_envio_cuando_no_alcanza_limite():
    assert aplicar_envio(9000) == 10500

8.13 Señales para extraer una función

Extraer una función suele ser útil cuando encuentras:

  • Un bloque con una intención clara que puede tener nombre propio.
  • Un comentario que anuncia una etapa, como “calcular impuestos”.
  • Una parte de la lógica que quieres probar sola.
  • Un bloque repetido o muy parecido en más de un lugar.
  • Una condición compleja que podría convertirse en pregunta de dominio.

8.14 Cuándo no conviene extraer

No toda línea merece su propia función. Extraer demasiado puede fragmentar el código y obligar al lector a saltar entre muchas funciones pequeñas sin necesidad.

Evita extraer cuando:

  • La nueva función tendría un nombre igual de confuso que el bloque original.
  • La extracción no reduce complejidad ni duplicación.
  • La función resultante solo envuelve una operación trivial sin aportar intención.
  • El cambio hace más difícil seguir el flujo principal.

8.15 Funciones pequeñas no siempre significan buen diseño

Un archivo con muchas funciones pequeñas también puede ser difícil de mantener si los nombres son malos o si las responsabilidades siguen mezcladas. El objetivo no es crear muchas funciones, sino hacer visible la intención del código.

Una buena función pequeña tiene un propósito claro, un nombre expresivo y una relación evidente con el flujo principal.

8.16 Ejercicio guiado

Analiza esta función y separa al menos tres responsabilidades:

def registrar_usuario(datos):
    if datos["email"] == "":
        return "email obligatorio"
    if "@" not in datos["email"]:
        return "email inválido"
    if datos["edad"] < 18:
        return "edad inválida"

    usuario = {
        "email": datos["email"].lower(),
        "nombre": datos["nombre"].strip(),
        "activo": True,
    }

    print(f"Usuario registrado: {usuario['email']}")
    return usuario

Una primera separación podría crear funciones para validar datos, construir el usuario y mostrar el mensaje.

8.17 Una posible solución

EDAD_MINIMA = 18


def validar_datos_usuario(datos):
    if datos["email"] == "":
        return "email obligatorio"
    if "@" not in datos["email"]:
        return "email inválido"
    if datos["edad"] < EDAD_MINIMA:
        return "edad inválida"
    return None


def crear_usuario(datos):
    return {
        "email": datos["email"].lower(),
        "nombre": datos["nombre"].strip(),
        "activo": True,
    }


def mostrar_usuario_registrado(usuario):
    print(f"Usuario registrado: {usuario['email']}")


def registrar_usuario(datos):
    error = validar_datos_usuario(datos)
    if error:
        return error

    usuario = crear_usuario(datos)
    mostrar_usuario_registrado(usuario)
    return usuario

La función principal queda como una descripción del flujo, y cada función auxiliar tiene una responsabilidad identificable.

8.18 Ejercicio propuesto

En el proyecto ventas_demo, revisa calcular_total_venta y realiza estas tareas:

  • Identifica las responsabilidades internas.
  • Extrae al menos dos funciones pequeñas.
  • Agrega pruebas para una de las funciones extraídas.
  • Ejecuta Black, Ruff y las pruebas.
  • Comprueba que el resultado final no cambió.
python -m ruff check src tests
python -m black src tests
python -m pytest

8.19 Lista de verificación

Antes de continuar, verifica que puedes hacer lo siguiente:

  • Reconocer una función larga por responsabilidades, no solo por cantidad de líneas.
  • Separar cálculo, validación, entrada, salida y persistencia.
  • Extraer funciones con nombres que comuniquen intención.
  • Evitar extracciones que agregan ruido sin mejorar claridad.
  • Proteger el comportamiento con pruebas antes y después del cambio.
  • Ejecutar herramientas de calidad después de modificar el código.

8.20 Conclusión

En este tema vimos que una función larga suele ser síntoma de responsabilidades mezcladas. Aprendimos a detectar etapas internas, extraer funciones pequeñas y mantener una función principal más fácil de leer.

En el próximo tema trabajaremos con otro smell frecuente: parámetros excesivos, banderas booleanas y firmas difíciles de usar.