18. Reemplazar condicionales de tipo por polimorfismo simple en Python

18.1 Objetivo del tema

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.

Objetivo práctico: convertir condicionales por tipo en objetos con comportamiento propio, manteniendo pruebas durante la migración.

18.2 Qué es polimorfismo

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.

18.3 Código inicial con condicionales de tipo

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.

18.4 Pruebas antes de cambiar

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

18.5 Extraer clases por variante

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.

18.6 Crear una fábrica simple

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.

18.7 Usar la estrategia en la función original

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.

18.8 Código refactorizado completo

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.

18.9 Probar estrategias por separado

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.

18.10 Usar diccionario en la fábrica

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.

18.11 Agregar type hints con Protocol

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.

18.12 Cuándo usar polimorfismo

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:

  • El mismo condicional por tipo aparece en varias funciones.
  • Cada tipo tiene varias reglas propias.
  • Agregar un nuevo tipo obliga a tocar muchos lugares.
  • Queremos probar cada variante de forma aislada.

18.13 Cuándo no usar polimorfismo

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.

18.14 Mantener compatibilidad

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.

18.15 Ejercicio propuesto

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:

  • Escribe pruebas para cliente regular, vip, empleado y mayorista.
  • Crea una clase de estrategia por tipo de cliente.
  • Crea una fábrica que devuelva la estrategia correcta.
  • Mantén la función calcular_descuento como interfaz compatible.
  • Ejecuta python -m pytest después de cada paso.

18.16 Una posible solución

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.

18.17 Lista de verificación

Antes de continuar, verifica que puedes explicar estos puntos:

  • Qué problema producen los condicionales repetidos por tipo.
  • Qué significa polimorfismo en Python.
  • Cómo crear clases con una interfaz común.
  • Qué papel cumple una fábrica simple.
  • Cuándo un diccionario puede reemplazar condicionales en una fábrica.
  • Cuándo el polimorfismo sería una abstracción prematura.

18.18 Conclusión

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.