6. Extraer funciones a partir de bloques largos o repetidos

6.1 Objetivo del tema

Extraer función consiste en tomar un bloque de código, moverlo a una función nueva y reemplazar el bloque original por una llamada con un nombre claro. Es una técnica central del refactoring porque permite separar responsabilidades, reducir duplicación y hacer visible la intención.

En este tema practicaremos cómo elegir qué bloque extraer, qué parámetros pasar, qué valor devolver y cómo verificar que el comportamiento se mantiene. Trabajaremos con ejemplos en Python y ejecutaremos pruebas después de cada paso importante.

Objetivo práctico: transformar una función larga en varias funciones pequeñas y expresivas sin cambiar sus resultados.

6.2 Cuándo conviene extraer una función

Extraer una función es útil cuando un bloque tiene una intención reconocible. No se trata de cortar código por cantidad de líneas, sino de darle nombre a una idea del dominio o a una operación que se repite.

Algunas señales frecuentes son:

  • Un bloque necesita un comentario para explicar qué hace.
  • Varias líneas calculan un valor con significado propio.
  • La misma lógica aparece repetida en más de un lugar.
  • Una función mezcla cálculo, validación, formato y salida.
  • Una parte del código sería más fácil de probar por separado.

6.3 Código inicial

Crea el archivo src/reportes.py con este código:

def generar_resumen_ventas(ventas):
    total = 0
    cantidad = 0
    ventas_validas = []

    for venta in ventas:
        if venta["estado"] == "confirmada":
            importe = venta["precio"] * venta["cantidad"]
            if venta["cliente"] == "vip":
                importe = importe * 0.9
            total = total + importe
            cantidad = cantidad + 1
            ventas_validas.append(venta)

    if cantidad == 0:
        promedio = 0
    else:
        promedio = total / cantidad

    if total >= 100000:
        categoria = "alta"
    elif total >= 50000:
        categoria = "media"
    else:
        categoria = "baja"

    texto = "Ventas confirmadas: " + str(cantidad)
    texto = texto + " | Total: " + str(round(total, 2))
    texto = texto + " | Promedio: " + str(round(promedio, 2))
    texto = texto + " | Categoría: " + categoria

    return {
        "cantidad": cantidad,
        "total": round(total, 2),
        "promedio": round(promedio, 2),
        "categoria": categoria,
        "texto": texto,
    }

La función calcula importes, filtra ventas, aplica descuento, calcula promedio, clasifica el total y arma texto. Hay varias responsabilidades en un solo lugar.

6.4 Pruebas de seguridad

Antes de extraer funciones, escribimos pruebas. Crea tests/test_reportes.py:

from reportes import generar_resumen_ventas


def test_genera_resumen_con_ventas_confirmadas_y_descuento_vip():
    ventas = [
        {"estado": "confirmada", "precio": 30000, "cantidad": 2, "cliente": "vip"},
        {"estado": "pendiente", "precio": 10000, "cantidad": 1, "cliente": "regular"},
        {"estado": "confirmada", "precio": 20000, "cantidad": 1, "cliente": "regular"},
    ]

    assert generar_resumen_ventas(ventas) == {
        "cantidad": 2,
        "total": 74000.0,
        "promedio": 37000.0,
        "categoria": "media",
        "texto": "Ventas confirmadas: 2 | Total: 74000.0 | Promedio: 37000.0 | Categoría: media",
    }


def test_genera_resumen_sin_ventas_confirmadas():
    ventas = [
        {"estado": "pendiente", "precio": 10000, "cantidad": 1, "cliente": "regular"},
    ]

    assert generar_resumen_ventas(ventas) == {
        "cantidad": 0,
        "total": 0,
        "promedio": 0,
        "categoria": "baja",
        "texto": "Ventas confirmadas: 0 | Total: 0 | Promedio: 0 | Categoría: baja",
    }

Ejecuta:

python -m pytest tests/test_reportes.py

6.5 Primer bloque candidato

El cálculo del importe de una venta tiene intención propia. Hoy está mezclado dentro del bucle:

importe = venta["precio"] * venta["cantidad"]
if venta["cliente"] == "vip":
    importe = importe * 0.9

Podemos extraerlo a una función llamada calcular_importe_venta.

6.6 Extraer calcular_importe_venta

Agregamos la función nueva y reemplazamos el bloque original por una llamada:

def calcular_importe_venta(venta):
    importe = venta["precio"] * venta["cantidad"]
    if venta["cliente"] == "vip":
        importe = importe * 0.9
    return importe


def generar_resumen_ventas(ventas):
    total = 0
    cantidad = 0
    ventas_validas = []

    for venta in ventas:
        if venta["estado"] == "confirmada":
            importe = calcular_importe_venta(venta)
            total = total + importe
            cantidad = cantidad + 1
            ventas_validas.append(venta)

    if cantidad == 0:
        promedio = 0
    else:
        promedio = total / cantidad

    if total >= 100000:
        categoria = "alta"
    elif total >= 50000:
        categoria = "media"
    else:
        categoria = "baja"

    texto = "Ventas confirmadas: " + str(cantidad)
    texto = texto + " | Total: " + str(round(total, 2))
    texto = texto + " | Promedio: " + str(round(promedio, 2))
    texto = texto + " | Categoría: " + categoria

    return {
        "cantidad": cantidad,
        "total": round(total, 2),
        "promedio": round(promedio, 2),
        "categoria": categoria,
        "texto": texto,
    }

Después de este paso ejecuta python -m pytest tests/test_reportes.py.

6.7 Elegir buenos parámetros

La función extraída recibe venta completa. En este caso es aceptable porque el cálculo depende de varios campos de esa venta. Otra opción sería pasar precio, cantidad y cliente, pero la llamada quedaría más larga.

La decisión depende de la claridad. Si una función recibe demasiados datos, tal vez el bloque extraído todavía no tiene límites claros. Si recibe un objeto completo pero solo usa un campo, quizá conviene pasar ese campo directamente.

6.8 Extraer el promedio

El cálculo del promedio tiene una regla especial cuando la cantidad es cero. Podemos darle nombre:

def calcular_promedio(total, cantidad):
    if cantidad == 0:
        return 0
    return total / cantidad

Y reemplazar el bloque original:

promedio = calcular_promedio(total, cantidad)

Ejecuta las pruebas. Este cambio es pequeño y debería conservar los mismos resultados.

6.9 Extraer la categoría

La clasificación del total también es una regla separada:

def obtener_categoria_ventas(total):
    if total >= 100000:
        return "alta"
    if total >= 50000:
        return "media"
    return "baja"

La función principal queda con una línea:

categoria = obtener_categoria_ventas(total)

Además de reducir líneas, el nombre explica qué representa la decisión.

6.10 Extraer el texto del resumen

El armado de texto no debería mezclarse con el cálculo del resumen. Podemos extraerlo:

def crear_texto_resumen(cantidad, total, promedio, categoria):
    texto = "Ventas confirmadas: " + str(cantidad)
    texto = texto + " | Total: " + str(round(total, 2))
    texto = texto + " | Promedio: " + str(round(promedio, 2))
    texto = texto + " | Categoría: " + categoria
    return texto

Y reemplazarlo por:

texto = crear_texto_resumen(cantidad, total, promedio, categoria)

Luego ejecuta python -m pytest.

6.11 Resultado después de varias extracciones

Después de aplicar los pasos anteriores, el archivo puede quedar así:

def calcular_importe_venta(venta):
    importe = venta["precio"] * venta["cantidad"]
    if venta["cliente"] == "vip":
        importe = importe * 0.9
    return importe


def calcular_promedio(total, cantidad):
    if cantidad == 0:
        return 0
    return total / cantidad


def obtener_categoria_ventas(total):
    if total >= 100000:
        return "alta"
    if total >= 50000:
        return "media"
    return "baja"


def crear_texto_resumen(cantidad, total, promedio, categoria):
    texto = "Ventas confirmadas: " + str(cantidad)
    texto = texto + " | Total: " + str(round(total, 2))
    texto = texto + " | Promedio: " + str(round(promedio, 2))
    texto = texto + " | Categoría: " + categoria
    return texto


def generar_resumen_ventas(ventas):
    total = 0
    cantidad = 0

    for venta in ventas:
        if venta["estado"] == "confirmada":
            total = total + calcular_importe_venta(venta)
            cantidad = cantidad + 1

    promedio = calcular_promedio(total, cantidad)
    categoria = obtener_categoria_ventas(total)
    texto = crear_texto_resumen(cantidad, total, promedio, categoria)

    return {
        "cantidad": cantidad,
        "total": round(total, 2),
        "promedio": round(promedio, 2),
        "categoria": categoria,
        "texto": texto,
    }

La función principal ahora muestra el flujo general y las funciones auxiliares explican las reglas específicas.

6.12 Detectar variables innecesarias después de extraer

En el código inicial existía ventas_validas, pero no se usaba para construir el resultado. Al extraer funciones y ejecutar pruebas podemos detectar este tipo de variables sobrantes.

Eliminar una variable no usada también es refactoring si no cambia el comportamiento. Herramientas como ruff suelen ayudar a encontrar estos casos.

ruff check .

6.13 Extraer código repetido

La extracción también sirve cuando hay duplicación. Observa este ejemplo:

def calcular_total_web(items):
    total = 0
    for item in items:
        total = total + item["precio"] * item["cantidad"]
    return total * 1.21


def calcular_total_sucursal(items):
    total = 0
    for item in items:
        total = total + item["precio"] * item["cantidad"]
    return total

El cálculo del subtotal está repetido. Podemos extraerlo:

def calcular_subtotal(items):
    total = 0
    for item in items:
        total = total + item["precio"] * item["cantidad"]
    return total


def calcular_total_web(items):
    return calcular_subtotal(items) * 1.21


def calcular_total_sucursal(items):
    return calcular_subtotal(items)

6.14 Cuándo no extraer

No todo bloque merece una función nueva. Extraer por extraer puede producir código fragmentado, donde hay que saltar entre demasiadas funciones para entender una idea simple.

Evita extraer cuando:

  • El nombre de la nueva función no agrega información.
  • La función tendría más parámetros que líneas útiles.
  • El bloque solo se usa una vez y ya es claro en su contexto.
  • La extracción obliga a compartir estado mutable innecesario.

6.15 Ejercicio propuesto

Crea el archivo src/inscripciones.py:

def generar_confirmacion(inscripcion):
    total = inscripcion["curso"]["precio"]
    if inscripcion["alumno"]["tipo"] == "becado":
        total = total * 0.5
    if inscripcion["alumno"]["tipo"] == "egresado":
        total = total * 0.8
    if inscripcion["curso"]["modalidad"] == "presencial":
        total = total + 3000

    if total == 0:
        estado = "sin cargo"
    elif inscripcion["pagado"] >= total:
        estado = "pagado"
    else:
        estado = "pendiente"

    mensaje = "Alumno: " + inscripcion["alumno"]["nombre"]
    mensaje = mensaje + " | Curso: " + inscripcion["curso"]["nombre"]
    mensaje = mensaje + " | Total: " + str(round(total, 2))
    mensaje = mensaje + " | Estado: " + estado

    return {"total": round(total, 2), "estado": estado, "mensaje": mensaje}

Realiza estas tareas:

  • Escribe al menos dos pruebas antes de modificar el código.
  • Extrae una función para calcular el total.
  • Extrae una función para determinar el estado.
  • Extrae una función para crear el mensaje.
  • Ejecuta python -m pytest después de cada extracción.

6.16 Una posible solución

Una versión refactorizada podría ser:

def calcular_total_inscripcion(inscripcion):
    total = inscripcion["curso"]["precio"]
    tipo_alumno = inscripcion["alumno"]["tipo"]

    if tipo_alumno == "becado":
        total = total * 0.5
    if tipo_alumno == "egresado":
        total = total * 0.8
    if inscripcion["curso"]["modalidad"] == "presencial":
        total = total + 3000

    return total


def determinar_estado(total, pagado):
    if total == 0:
        return "sin cargo"
    if pagado >= total:
        return "pagado"
    return "pendiente"


def crear_mensaje_confirmacion(inscripcion, total, estado):
    mensaje = "Alumno: " + inscripcion["alumno"]["nombre"]
    mensaje = mensaje + " | Curso: " + inscripcion["curso"]["nombre"]
    mensaje = mensaje + " | Total: " + str(round(total, 2))
    mensaje = mensaje + " | Estado: " + estado
    return mensaje


def generar_confirmacion(inscripcion):
    total = calcular_total_inscripcion(inscripcion)
    estado = determinar_estado(total, inscripcion["pagado"])
    mensaje = crear_mensaje_confirmacion(inscripcion, total, estado)

    return {"total": round(total, 2), "estado": estado, "mensaje": mensaje}

La función principal queda más breve, pero sigue mostrando el flujo completo de la operación.

6.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué señales indican que un bloque puede extraerse a una función.
  • Cómo elegir parámetros y valor de retorno para la función extraída.
  • Por qué ejecutar pruebas después de cada extracción reduce el riesgo.
  • Cómo la extracción ayuda a eliminar duplicación.
  • Cuándo una extracción puede empeorar la lectura.
  • Cómo detectar variables sobrantes después de refactorizar.

6.18 Conclusión

En este tema practicamos la extracción de funciones a partir de bloques largos o repetidos. Vimos que el objetivo no es crear muchas funciones, sino nombrar ideas importantes y separar responsabilidades sin modificar el comportamiento.

En el próximo tema veremos el caso inverso: aplicar inline de variables y funciones cuando una abstracción sobra.