Cuando una función tiene muchos if o elif que preguntan por un tipo, una categoría o un modo de operación, probablemente haya varias reglas mezcladas en el mismo lugar. Cada nuevo tipo obliga a modificar esa función central.
En este tema reemplazaremos condicionales de tipo por polimorfismo simple. Crearemos clases pequeñas con una interfaz común para que cada variante conozca su propia regla.
Polimorfismo significa que distintos objetos pueden responder al mismo método de formas diferentes. El código que los usa no necesita preguntar qué tipo exacto son; simplemente llama al método común.
costo = estrategia_envio.calcular_costo(pedido)
La estrategia puede ser normal, express o internacional. Cada una implementa calcular_costo según su regla.
Crea el archivo src/envios_polimorfismo.py:
def calcular_costo_envio(pedido):
peso = pedido["peso"]
tipo = pedido["tipo_envio"]
if tipo == "normal":
return 1000 + peso * 100
if tipo == "express":
return 2000 + peso * 180
if tipo == "internacional":
return 5000 + peso * 450
return 0
La función central conoce todas las variantes. Si aparece un nuevo tipo de envío, tendremos que modificarla.
Crea tests/test_envios_polimorfismo.py:
from envios_polimorfismo import calcular_costo_envio
def test_calcula_costo_envio_normal():
pedido = {"tipo_envio": "normal", "peso": 3}
assert calcular_costo_envio(pedido) == 1300
def test_calcula_costo_envio_express():
pedido = {"tipo_envio": "express", "peso": 3}
assert calcular_costo_envio(pedido) == 2540
def test_calcula_costo_envio_internacional():
pedido = {"tipo_envio": "internacional", "peso": 3}
assert calcular_costo_envio(pedido) == 6350
Ejecuta:
python -m pytest tests/test_envios_polimorfismo.py
Creamos una clase para cada tipo de envío. Todas tendrán el método calcular_costo.
class EnvioNormal:
def calcular_costo(self, pedido):
return 1000 + pedido["peso"] * 100
class EnvioExpress:
def calcular_costo(self, pedido):
return 2000 + pedido["peso"] * 180
class EnvioInternacional:
def calcular_costo(self, pedido):
return 5000 + pedido["peso"] * 450
Las reglas siguen siendo las mismas, pero ahora están separadas por variante.
Para convertir el string de entrada en una estrategia, usamos una función fábrica:
def crear_estrategia_envio(tipo_envio):
if tipo_envio == "normal":
return EnvioNormal()
if tipo_envio == "express":
return EnvioExpress()
if tipo_envio == "internacional":
return EnvioInternacional()
return None
Esta función todavía tiene condicionales, pero quedan concentrados en el punto de creación. El cálculo ya no necesita conocer todas las reglas.
Podemos mantener la función pública y hacerla delegar:
def calcular_costo_envio(pedido):
estrategia = crear_estrategia_envio(pedido["tipo_envio"])
if estrategia is None:
return 0
return estrategia.calcular_costo(pedido)
Después de este cambio ejecuta python -m pytest tests/test_envios_polimorfismo.py.
El módulo puede quedar así:
class EnvioNormal:
def calcular_costo(self, pedido):
return 1000 + pedido["peso"] * 100
class EnvioExpress:
def calcular_costo(self, pedido):
return 2000 + pedido["peso"] * 180
class EnvioInternacional:
def calcular_costo(self, pedido):
return 5000 + pedido["peso"] * 450
def crear_estrategia_envio(tipo_envio):
if tipo_envio == "normal":
return EnvioNormal()
if tipo_envio == "express":
return EnvioExpress()
if tipo_envio == "internacional":
return EnvioInternacional()
return None
def calcular_costo_envio(pedido):
estrategia = crear_estrategia_envio(pedido["tipo_envio"])
if estrategia is None:
return 0
return estrategia.calcular_costo(pedido)
El condicional principal ya no contiene fórmulas de cada tipo de envío.
Ahora podemos probar una variante directamente:
from envios_polimorfismo import EnvioExpress, calcular_costo_envio
def test_envio_express_calcula_su_costo():
pedido = {"tipo_envio": "express", "peso": 3}
assert EnvioExpress().calcular_costo(pedido) == 2540
Esto ayuda a localizar errores cuando una variante cambia.
Si la fábrica empieza a crecer, podemos reemplazar condicionales por un diccionario:
ESTRATEGIAS_ENVIO = {
"normal": EnvioNormal,
"express": EnvioExpress,
"internacional": EnvioInternacional,
}
def crear_estrategia_envio(tipo_envio):
clase_estrategia = ESTRATEGIAS_ENVIO.get(tipo_envio)
if clase_estrategia is None:
return None
return clase_estrategia()
Agregar una variante nueva implica crear una clase y registrarla en el diccionario.
Podemos expresar la interfaz común con Protocol:
from typing import Protocol
class EstrategiaEnvio(Protocol):
def calcular_costo(self, pedido: dict) -> float:
...
Esto documenta que cualquier estrategia debe tener un método calcular_costo. No obliga a heredar de una clase base.
El polimorfismo es útil cuando las variantes tienen comportamiento diferente y tienden a crecer. Si solo hay dos condiciones simples y estables, un if puede ser suficiente.
Conviene considerarlo cuando:
No conviene crear una jerarquía de clases para una decisión trivial. Este código puede estar bien como condicional:
if cliente["tipo"] == "vip":
descuento = 0.10
else:
descuento = 0
La refactorización debe reducir complejidad. Si agrega más archivos, clases y navegación sin un beneficio claro, probablemente sea prematura.
La función calcular_costo_envio sigue existiendo. Eso permite que el resto del sistema continúe enviando diccionarios con "tipo_envio" mientras internamente usamos estrategias.
Más adelante podríamos cambiar el código nuevo para construir directamente una estrategia y pasarla donde corresponda, pero no hace falta romper la interfaz de golpe.
Crea el archivo src/descuentos_polimorfismo.py:
def calcular_descuento(cliente, total):
if cliente["tipo"] == "regular":
return 0
if cliente["tipo"] == "vip":
return total * 0.10
if cliente["tipo"] == "empleado":
return total * 0.25
if cliente["tipo"] == "mayorista":
return total * 0.15
return 0
Realiza estas tareas:
calcular_descuento como interfaz compatible.python -m pytest después de cada paso.Una versión refactorizada puede ser:
class DescuentoRegular:
def calcular(self, total):
return 0
class DescuentoVip:
def calcular(self, total):
return total * 0.10
class DescuentoEmpleado:
def calcular(self, total):
return total * 0.25
class DescuentoMayorista:
def calcular(self, total):
return total * 0.15
ESTRATEGIAS_DESCUENTO = {
"regular": DescuentoRegular,
"vip": DescuentoVip,
"empleado": DescuentoEmpleado,
"mayorista": DescuentoMayorista,
}
def crear_estrategia_descuento(tipo_cliente):
clase_estrategia = ESTRATEGIAS_DESCUENTO.get(tipo_cliente, DescuentoRegular)
return clase_estrategia()
def calcular_descuento(cliente, total):
estrategia = crear_estrategia_descuento(cliente["tipo"])
return estrategia.calcular(total)
Las reglas quedan distribuidas en clases pequeñas. La función pública sigue existiendo y delega en la estrategia adecuada.
Antes de continuar, verifica que puedes explicar estos puntos:
En este tema reemplazamos condicionales de tipo por polimorfismo simple. Cada variante pasó a tener su propia clase y el código central dejó de conocer todas las fórmulas.
En el próximo tema reduciremos acoplamiento mediante inyección de dependencias.