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.
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:
procesar o manejar.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.
Antes de extraer funciones, identifiquemos responsabilidades:
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.
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.
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.
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.
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.
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.
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)
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
Extraer una función suele ser útil cuando encuentras:
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:
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.
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.
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.
En el proyecto ventas_demo, revisa calcular_total_venta y realiza estas tareas:
python -m ruff check src tests
python -m black src tests
python -m pytest
Antes de continuar, verifica que puedes hacer lo siguiente:
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.