La duplicación de código aumenta el costo de mantenimiento porque una misma regla debe cambiarse en varios lugares. Pero eliminar toda similitud de inmediato también puede producir abstracciones confusas, llenas de parámetros y difíciles de entender.
En este tema aprenderemos a distinguir duplicación real de similitud superficial. Practicaremos cómo extraer comportamiento común en Python sin forzar abstracciones prematuras y verificando cada paso con pruebas.
Dos fragmentos pueden parecer iguales, pero representar decisiones distintas. Antes de extraer una abstracción, conviene preguntar si realmente cambian por la misma razón.
Eliminar duplicación real suele mejorar el diseño. Unificar similitudes accidentales puede acoplar partes que deberían evolucionar por separado.
Crea el archivo src/facturacion.py:
def calcular_factura_minorista(items, cliente):
subtotal = 0
for item in items:
subtotal = subtotal + item["precio"] * item["cantidad"]
if cliente["tipo"] == "vip":
subtotal = subtotal * 0.9
impuesto = subtotal * 0.21
total = subtotal + impuesto
return round(total, 2)
def calcular_factura_mayorista(items, cliente):
subtotal = 0
for item in items:
subtotal = subtotal + item["precio"] * item["cantidad"]
if cliente["tipo"] == "distribuidor":
subtotal = subtotal * 0.85
impuesto = subtotal * 0.21
total = subtotal + impuesto
return round(total, 2)
Hay duplicación clara en el cálculo del subtotal, el impuesto y el redondeo. Pero los descuentos responden a reglas distintas.
Crea tests/test_facturacion.py:
from facturacion import calcular_factura_mayorista, calcular_factura_minorista
def test_calcula_factura_minorista_vip():
items = [
{"precio": 1000, "cantidad": 2},
{"precio": 500, "cantidad": 1},
]
cliente = {"tipo": "vip"}
assert calcular_factura_minorista(items, cliente) == 2722.5
def test_calcula_factura_mayorista_distribuidor():
items = [
{"precio": 1000, "cantidad": 2},
{"precio": 500, "cantidad": 1},
]
cliente = {"tipo": "distribuidor"}
assert calcular_factura_mayorista(items, cliente) == 2571.25
Ejecuta:
python -m pytest tests/test_facturacion.py
El cálculo del subtotal está repetido exactamente y tiene el mismo significado en ambos casos. Es un buen candidato para extraer.
def calcular_subtotal(items):
subtotal = 0
for item in items:
subtotal = subtotal + item["precio"] * item["cantidad"]
return subtotal
Luego lo usamos en ambas funciones:
def calcular_factura_minorista(items, cliente):
subtotal = calcular_subtotal(items)
if cliente["tipo"] == "vip":
subtotal = subtotal * 0.9
impuesto = subtotal * 0.21
total = subtotal + impuesto
return round(total, 2)
Después del cambio ejecuta python -m pytest tests/test_facturacion.py.
El cálculo del impuesto también está duplicado y representa la misma regla.
IVA = 0.21
def calcular_impuesto(subtotal):
return subtotal * IVA
Ahora ambas funciones pueden usar la misma regla:
impuesto = calcular_impuesto(subtotal)
total = subtotal + impuesto
Este cambio reduce duplicación sin mezclar reglas de descuento diferentes.
Podemos ir un paso más y extraer el cálculo del total final:
def calcular_total_con_impuesto(subtotal):
return round(subtotal + calcular_impuesto(subtotal), 2)
Las funciones principales quedan así:
def calcular_factura_minorista(items, cliente):
subtotal = calcular_subtotal(items)
if cliente["tipo"] == "vip":
subtotal = subtotal * 0.9
return calcular_total_con_impuesto(subtotal)
def calcular_factura_mayorista(items, cliente):
subtotal = calcular_subtotal(items)
if cliente["tipo"] == "distribuidor":
subtotal = subtotal * 0.85
return calcular_total_con_impuesto(subtotal)
Ejecuta las pruebas antes de seguir.
Podríamos intentar crear una función genérica para descuentos:
def aplicar_descuento(subtotal, cliente, tipo, porcentaje):
if cliente["tipo"] == tipo:
return subtotal * (1 - porcentaje)
return subtotal
Funciona, pero puede ser una abstracción prematura. El descuento minorista y el mayorista podrían cambiar por razones distintas. Si todavía no sabemos si esas reglas evolucionarán juntas, conviene mantenerlas separadas o nombrarlas de forma específica.
Una alternativa más clara es extraer funciones específicas:
def aplicar_descuento_minorista(subtotal, cliente):
if cliente["tipo"] == "vip":
return subtotal * 0.9
return subtotal
def aplicar_descuento_mayorista(subtotal, cliente):
if cliente["tipo"] == "distribuidor":
return subtotal * 0.85
return subtotal
Hay cierta similitud, pero cada función representa una regla del dominio. Si en el futuro ambas reglas convergen, podremos unificarlas con más información.
Una versión equilibrada puede quedar así:
IVA = 0.21
def calcular_subtotal(items):
subtotal = 0
for item in items:
subtotal = subtotal + item["precio"] * item["cantidad"]
return subtotal
def calcular_impuesto(subtotal):
return subtotal * IVA
def calcular_total_con_impuesto(subtotal):
return round(subtotal + calcular_impuesto(subtotal), 2)
def aplicar_descuento_minorista(subtotal, cliente):
if cliente["tipo"] == "vip":
return subtotal * 0.9
return subtotal
def aplicar_descuento_mayorista(subtotal, cliente):
if cliente["tipo"] == "distribuidor":
return subtotal * 0.85
return subtotal
def calcular_factura_minorista(items, cliente):
subtotal = calcular_subtotal(items)
subtotal = aplicar_descuento_minorista(subtotal, cliente)
return calcular_total_con_impuesto(subtotal)
def calcular_factura_mayorista(items, cliente):
subtotal = calcular_subtotal(items)
subtotal = aplicar_descuento_mayorista(subtotal, cliente)
return calcular_total_con_impuesto(subtotal)
La duplicación técnica se redujo, pero las diferencias de negocio siguen visibles.
No toda duplicación se ve como líneas iguales. A veces el problema es repetir el mismo conocimiento con formas distintas.
if total >= 10000:
envio = 0
# En otro archivo:
if compra["importe"] >= 10000:
costo_envio = 0
Si 10000 representa el mismo límite de envío gratis, la regla está duplicada aunque el código no sea idéntico. Una constante con nombre ayuda:
LIMITE_ENVIO_GRATIS = 10000
Las pruebas también pueden tener duplicación útil o problemática. Repetir datos explícitos a veces mejora la lectura. Pero si el armado de datos es largo y se repite mucho, conviene crear una función auxiliar.
def crear_item(precio=1000, cantidad=1):
return {"precio": precio, "cantidad": cantidad}
La regla es la misma: extrae cuando mejora claridad, no solo para reducir líneas.
Una señal de abstracción prematura es una función que intenta cubrir demasiados casos:
def calcular_factura(items, cliente, tipo_descuento, porcentaje, aplica_iva, redondear):
# demasiadas decisiones mezcladas
...
Si una abstracción necesita muchos parámetros de control, tal vez está unificando comportamientos que todavía deberían permanecer separados.
Una guía útil es no extraer una abstracción grande ante la primera similitud. Cuando aparece una tercera repetición, suele haber más información para entender qué es realmente común y qué cambia.
Esto no significa tolerar duplicación peligrosa. Si una regla crítica aparece dos veces y sabemos que debe cambiar junta, conviene extraerla. La clave es razonar sobre el costo y el riesgo, no aplicar una regla mecánica.
Crea el archivo src/notificaciones.py:
def crear_email_bienvenida(usuario):
asunto = "Bienvenido " + usuario["nombre"]
cuerpo = "Hola " + usuario["nombre"] + ", gracias por registrarte."
cuerpo = cuerpo + " Tu email registrado es " + usuario["email"] + "."
return {"canal": "email", "asunto": asunto, "cuerpo": cuerpo}
def crear_email_recuperacion(usuario):
asunto = "Recuperar contraseña"
cuerpo = "Hola " + usuario["nombre"] + ", solicitaste recuperar tu contraseña."
cuerpo = cuerpo + " Tu email registrado es " + usuario["email"] + "."
return {"canal": "email", "asunto": asunto, "cuerpo": cuerpo}
Realiza estas tareas:
python -m pytest después de cada cambio.Una solución simple puede extraer solo la parte repetida del email registrado:
def texto_email_registrado(usuario):
return " Tu email registrado es " + usuario["email"] + "."
def crear_email_bienvenida(usuario):
asunto = "Bienvenido " + usuario["nombre"]
cuerpo = "Hola " + usuario["nombre"] + ", gracias por registrarte."
cuerpo = cuerpo + texto_email_registrado(usuario)
return {"canal": "email", "asunto": asunto, "cuerpo": cuerpo}
def crear_email_recuperacion(usuario):
asunto = "Recuperar contraseña"
cuerpo = "Hola " + usuario["nombre"] + ", solicitaste recuperar tu contraseña."
cuerpo = cuerpo + texto_email_registrado(usuario)
return {"canal": "email", "asunto": asunto, "cuerpo": cuerpo}
No intentamos unificar todos los emails en una sola función genérica. Conservamos la intención de bienvenida y recuperación.
Antes de continuar, verifica que puedes explicar estos puntos:
En este tema eliminamos duplicación con cuidado. Extraer código común puede mejorar mucho el mantenimiento, pero solo cuando la abstracción representa una idea real. Si unificamos diferencias por apresurarnos, el diseño puede volverse más rígido y menos claro.
En el próximo tema refactorizaremos parámetros: objetos de datos, valores por defecto y firmas más simples.