En muchos proyectos Python, los datos empiezan como diccionarios y las reglas se escriben como funciones sueltas. Eso puede ser suficiente al principio, pero cuando varias funciones reciben el mismo diccionario y conocen sus claves internas, aparece una oportunidad para extraer una clase.
En este tema transformaremos datos y comportamiento mezclados en una clase con responsabilidad clara. Lo haremos de manera gradual, manteniendo pruebas y una función adaptadora para no romper llamadas existentes.
Extraer una clase puede ser útil cuando:
No hace falta crear clases para todo. Una clase debe mejorar la cohesión, no solo envolver funciones sin aportar claridad.
Crea el archivo src/pedidos_clase.py:
def calcular_subtotal(pedido):
subtotal = 0
for item in pedido["items"]:
subtotal = subtotal + item["precio"] * item["cantidad"]
return subtotal
def calcular_descuento(pedido):
if pedido["cliente"]["tipo"] == "vip":
return calcular_subtotal(pedido) * 0.10
return 0
def calcular_envio(pedido):
if pedido["retiro_en_local"]:
return 0
if calcular_subtotal(pedido) >= 10000:
return 0
return 1200
def calcular_total(pedido):
subtotal = calcular_subtotal(pedido)
descuento = calcular_descuento(pedido)
envio = calcular_envio(pedido)
return round(subtotal - descuento + envio, 2)
Las funciones comparten el mismo diccionario pedido y conocen sus claves internas.
Crea tests/test_pedidos_clase.py:
from pedidos_clase import calcular_total
def test_calcula_total_pedido_vip_con_envio():
pedido = {
"cliente": {"nombre": "Ana", "tipo": "vip"},
"retiro_en_local": False,
"items": [
{"precio": 3000, "cantidad": 2},
],
}
assert calcular_total(pedido) == 6600.0
def test_calcula_total_pedido_regular_con_envio_gratis():
pedido = {
"cliente": {"nombre": "Luis", "tipo": "regular"},
"retiro_en_local": False,
"items": [
{"precio": 5000, "cantidad": 2},
],
}
assert calcular_total(pedido) == 10000
Ejecuta:
python -m pytest tests/test_pedidos_clase.py
El primer paso es crear una clase que reciba los mismos datos, sin mover toda la lógica todavía:
class Pedido:
def __init__(self, datos):
self.datos = datos
Esto por sí solo no mejora mucho. La clase empieza a tener sentido cuando movemos comportamiento relacionado con esos datos.
El subtotal usa solo los items del pedido. Es un buen primer método:
class Pedido:
def __init__(self, datos):
self.datos = datos
def calcular_subtotal(self):
subtotal = 0
for item in self.datos["items"]:
subtotal = subtotal + item["precio"] * item["cantidad"]
return subtotal
Podemos actualizar la función externa para delegar:
def calcular_subtotal(pedido):
return Pedido(pedido).calcular_subtotal()
Ejecuta las pruebas después de este cambio.
Ahora movemos reglas que también pertenecen al pedido:
class Pedido:
def __init__(self, datos):
self.datos = datos
def calcular_subtotal(self):
subtotal = 0
for item in self.datos["items"]:
subtotal = subtotal + item["precio"] * item["cantidad"]
return subtotal
def calcular_descuento(self):
if self.datos["cliente"]["tipo"] == "vip":
return self.calcular_subtotal() * 0.10
return 0
def calcular_envio(self):
if self.datos["retiro_en_local"]:
return 0
if self.calcular_subtotal() >= 10000:
return 0
return 1200
El comportamiento se acerca al dato que usa.
La clase puede calcular su propio total:
class Pedido:
def __init__(self, datos):
self.datos = datos
def calcular_subtotal(self):
subtotal = 0
for item in self.datos["items"]:
subtotal = subtotal + item["precio"] * item["cantidad"]
return subtotal
def calcular_descuento(self):
if self.datos["cliente"]["tipo"] == "vip":
return self.calcular_subtotal() * 0.10
return 0
def calcular_envio(self):
if self.datos["retiro_en_local"]:
return 0
if self.calcular_subtotal() >= 10000:
return 0
return 1200
def calcular_total(self):
subtotal = self.calcular_subtotal()
descuento = self.calcular_descuento()
envio = self.calcular_envio()
return round(subtotal - descuento + envio, 2)
La función externa puede mantenerse como adaptadora:
def calcular_total(pedido):
return Pedido(pedido).calcular_total()
Además de conservar las pruebas anteriores, podemos probar la clase:
from pedidos_clase import Pedido, calcular_total
def test_pedido_calcula_subtotal():
pedido = Pedido(
{
"cliente": {"nombre": "Ana", "tipo": "vip"},
"retiro_en_local": False,
"items": [
{"precio": 3000, "cantidad": 2},
],
}
)
assert pedido.calcular_subtotal() == 6000
Esto permite migrar código nuevo hacia la clase sin eliminar todavía la interfaz anterior.
Guardar el diccionario completo dentro de la clase mantiene cierta dependencia de claves. Podemos avanzar un paso más:
class Pedido:
def __init__(self, cliente, items, retiro_en_local=False):
self.cliente = cliente
self.items = items
self.retiro_en_local = retiro_en_local
La función adaptadora convierte el diccionario viejo a la nueva forma:
def crear_pedido_desde_dict(datos):
return Pedido(
cliente=datos["cliente"],
items=datos["items"],
retiro_en_local=datos["retiro_en_local"],
)
La clase queda más clara:
class Pedido:
def __init__(self, cliente, items, retiro_en_local=False):
self.cliente = cliente
self.items = items
self.retiro_en_local = retiro_en_local
def calcular_subtotal(self):
subtotal = 0
for item in self.items:
subtotal = subtotal + item["precio"] * item["cantidad"]
return subtotal
def calcular_descuento(self):
if self.cliente["tipo"] == "vip":
return self.calcular_subtotal() * 0.10
return 0
def calcular_envio(self):
if self.retiro_en_local:
return 0
if self.calcular_subtotal() >= 10000:
return 0
return 1200
def calcular_total(self):
subtotal = self.calcular_subtotal()
descuento = self.calcular_descuento()
envio = self.calcular_envio()
return round(subtotal - descuento + envio, 2)
def crear_pedido_desde_dict(datos):
return Pedido(
cliente=datos["cliente"],
items=datos["items"],
retiro_en_local=datos["retiro_en_local"],
)
def calcular_total(pedido):
return crear_pedido_desde_dict(pedido).calcular_total()
El código nuevo puede construir Pedido directamente; el código viejo puede seguir usando diccionarios por ahora.
Si la clase principalmente guarda datos y tiene algunos métodos, dataclass puede reducir código repetitivo:
from dataclasses import dataclass
@dataclass
class Pedido:
cliente: dict
items: list
retiro_en_local: bool = False
Luego podemos agregar los métodos de cálculo dentro de la misma clase. En temas posteriores profundizaremos en dataclass y type hints.
Una clase que solo envuelve una función sin aportar cohesión no mejora el diseño:
class Calculador:
def calcular(self, pedido):
return calcular_total(pedido)
Esta clase no tiene estado propio ni reglas agrupadas. Extraer una clase tiene sentido cuando datos y comportamiento quedan mejor juntos.
En proyectos reales, rara vez podemos cambiar todas las llamadas de golpe. Una estrategia segura es:
Crea el archivo src/cursos_clase.py:
def calcular_precio_final(inscripcion):
precio = inscripcion["curso"]["precio"]
if inscripcion["alumno"]["tipo"] == "becado":
precio = precio * 0.5
if inscripcion["modalidad"] == "presencial":
precio = precio + 3000
return round(precio, 2)
def generar_resumen(inscripcion):
total = calcular_precio_final(inscripcion)
return {
"alumno": inscripcion["alumno"]["nombre"],
"curso": inscripcion["curso"]["nombre"],
"total": total,
}
Realiza estas tareas:
calcular_precio_final y generar_resumen.Inscripcion.python -m pytest después de cada paso.Una solución posible es:
class Inscripcion:
def __init__(self, alumno, curso, modalidad):
self.alumno = alumno
self.curso = curso
self.modalidad = modalidad
def calcular_precio_final(self):
precio = self.curso["precio"]
if self.alumno["tipo"] == "becado":
precio = precio * 0.5
if self.modalidad == "presencial":
precio = precio + 3000
return round(precio, 2)
def generar_resumen(self):
return {
"alumno": self.alumno["nombre"],
"curso": self.curso["nombre"],
"total": self.calcular_precio_final(),
}
def crear_inscripcion_desde_dict(datos):
return Inscripcion(
alumno=datos["alumno"],
curso=datos["curso"],
modalidad=datos["modalidad"],
)
def calcular_precio_final(inscripcion):
return crear_inscripcion_desde_dict(inscripcion).calcular_precio_final()
def generar_resumen(inscripcion):
return crear_inscripcion_desde_dict(inscripcion).generar_resumen()
La clase concentra los datos y reglas de una inscripción, mientras las funciones antiguas siguen disponibles como puente temporal.
Antes de continuar, verifica que puedes explicar estos puntos:
En este tema extrajimos una clase desde datos y comportamiento mezclados. Pasamos de funciones que conocían las claves internas de un diccionario a un objeto que concentra reglas relacionadas y ofrece una interfaz más clara.
En el próximo tema refactorizaremos clases grandes hacia responsabilidades pequeñas.