Polimorfismo: programar contra contratos y elegir comportamiento en tiempo de ejecución
En POO práctica, polimorfismo significa dos ideas complementarias: (1) programar contra un contrato (una interfaz o un tipo abstracto) en lugar de contra una clase concreta, y (2) permitir la selección dinámica del comportamiento en tiempo de ejecución según el objeto real que se inyecta o se pasa como dependencia.
Esto habilita variación controlada: el sistema cambia de comportamiento sin reescribir el código consumidor. El consumidor solo conoce el contrato; las implementaciones pueden crecer sin modificarlo (o con cambios mínimos y localizados).
Ejemplo mental rápido
Si un servicio necesita “pagar”, no debería depender de TarjetaCredito o PayPal directamente. Debería depender de MetodoDePago. En tiempo de ejecución, se elige la implementación concreta (tarjeta, transferencia, etc.).
Interfaces como contratos estables para desacoplar módulos
Una interfaz define un conjunto de operaciones que un módulo ofrece o requiere. Su valor principal es el desacoplamiento: el código que usa la interfaz no necesita conocer detalles internos ni dependencias de la implementación.
- Estabilidad: el contrato debe cambiar poco; las implementaciones pueden cambiar más.
- Intercambiabilidad: múltiples implementaciones pueden coexistir.
- Testabilidad: puedes sustituir implementaciones reales por dobles (fakes/stubs) sin frameworks.
Interfaces en Java vs C#: similitudes y diferencias útiles
| Aspecto | Java | C# |
|---|---|---|
| Declaración | interface | interface |
| Implementación por clase | class X implements I | class X : I |
| Múltiples interfaces | Sí | Sí |
| Miembros por defecto | Desde Java 8: default methods | Desde C# 8: default interface members (con matices de runtime/versión) |
| Campos/estado | No estado de instancia; constantes public static final | No estado de instancia; constantes permitidas |
| Visibilidad | Métodos de interfaz son public (implícito) | Métodos de interfaz son public (implícito) |
Miembros por defecto: cuándo usarlos (y cuándo no)
Los métodos por defecto en interfaces permiten añadir comportamiento sin romper implementaciones existentes. Úsalos con moderación:
- Escuche el audio con la pantalla apagada.
- Obtenga un certificado al finalizar.
- ¡Más de 5000 cursos para que explores!
Descargar la aplicación
- Sí: para pequeñas utilidades coherentes con el contrato, o para evolución compatible.
- No: para meter lógica compleja o dependencias; eso suele indicar que necesitas una clase (o un servicio) separado.
¿Interfaz o clase abstracta? Criterios prácticos
Ambas expresan contratos, pero sirven a propósitos distintos.
Prefiere interfaz cuando…
- Quieres desacoplar consumidores de implementaciones.
- Necesitas múltiples implementaciones sin relación jerárquica.
- Quieres componer capacidades (una clase puede implementar varias interfaces).
- El contrato es pequeño y estable.
Prefiere clase abstracta cuando…
- Hay código compartido significativo y estable entre implementaciones.
- Necesitas estado protegido común o una plantilla de algoritmo (template method).
- Quieres imponer una estructura interna (no solo operaciones públicas).
Regla práctica: si lo que buscas es “puedo intercambiar implementaciones”, interfaz. Si lo que buscas es “comparto base y estructura”, clase abstracta.
Strategy: variación controlada con inyección de dependencias simple
El patrón Strategy encapsula una familia de algoritmos (o políticas) detrás de una interfaz. El consumidor recibe una estrategia y la usa sin conocer su implementación. Esto es polimorfismo aplicado.
Guía paso a paso (sin frameworks)
- Paso 1: Define una interfaz con una sola responsabilidad (la variación).
- Paso 2: Implementa estrategias concretas.
- Paso 3: Inyecta la estrategia en el consumidor (constructor o parámetro).
- Paso 4: Prueba el consumidor con un fake/stub de la interfaz.
Ejemplo 1: FormatoDeReporte (Java)
Paso 1: contrato
public interface FormatoDeReporte { String formatear(Reporte reporte);}Paso 2: estrategias concretas
public final class FormatoTextoPlano implements FormatoDeReporte { @Override public String formatear(Reporte reporte) { return "REPORTE: " + reporte.titulo() + " | Total: " + reporte.total(); }}public final class FormatoJson implements FormatoDeReporte { @Override public String formatear(Reporte reporte) { return "{\"titulo\":\"" + reporte.titulo() + "\",\"total\":" + reporte.total() + "}"; }}Modelo simple
public record Reporte(String titulo, double total) {}Paso 3: consumidor con inyección por constructor
public final class GeneradorDeReportes { private final FormatoDeReporte formato; public GeneradorDeReportes(FormatoDeReporte formato) { this.formato = formato; } public String generar(Reporte reporte) { return formato.formatear(reporte); }}Paso 4: prueba sin frameworks (fake)
final class FormatoFake implements FormatoDeReporte { String ultimoTitulo; @Override public String formatear(Reporte reporte) { ultimoTitulo = reporte.titulo(); return "OK"; }}public class GeneradorDeReportesTest { public static void main(String[] args) { FormatoFake fake = new FormatoFake(); GeneradorDeReportes gen = new GeneradorDeReportes(fake); String salida = gen.generar(new Reporte("Ventas", 120.5)); if (!"OK".equals(salida)) throw new AssertionError("Salida inesperada"); if (!"Ventas".equals(fake.ultimoTitulo)) throw new AssertionError("No se llamó al contrato"); }}Observa que el test valida el comportamiento del consumidor sin depender de JSON real ni de texto plano real. Eso es desacoplamiento + polimorfismo.
Ejemplo 2: MetodoDePago (C#)
Paso 1: contrato
public interface IMetodoDePago{ string Pagar(decimal monto);}Paso 2: estrategias concretas
public sealed class PagoConTarjeta : IMetodoDePago{ public string Pagar(decimal monto) => $"Tarjeta aprobada: {monto}";}public sealed class PagoConTransferencia : IMetodoDePago{ public string Pagar(decimal monto) => $"Transferencia iniciada: {monto}";}Paso 3: consumidor con inyección por parámetro (útil cuando cambia por operación)
public sealed class CheckoutService{ public string Cobrar(IMetodoDePago metodo, decimal monto) { return metodo.Pagar(monto); }}Paso 4: prueba sin frameworks (stub)
public sealed class MetodoDePagoStub : IMetodoDePago{ public decimal UltimoMonto { get; private set; } public string Pagar(decimal monto) { UltimoMonto = monto; return "OK"; }}public static class CheckoutServiceTest{ public static void Main() { var stub = new MetodoDePagoStub(); var service = new CheckoutService(); var r = service.Cobrar(stub, 99.90m); if (r != "OK") throw new Exception("Respuesta inesperada"); if (stub.UltimoMonto != 99.90m) throw new Exception("No se pasó el monto correctamente"); }}La inyección por parámetro es una forma simple de DI: el método recibe la dependencia. La inyección por constructor es mejor cuando la dependencia es fija para la vida del objeto.
Diseño de interfaces pequeñas y coherentes
Criterios concretos
- Una razón principal de cambio: la interfaz debe agrupar operaciones que cambian por el mismo motivo.
- Evita “comodines”: si una implementación deja métodos “vacíos” o lanza
UnsupportedOperationException/NotSupportedException, la interfaz está mal segmentada. - Nombres orientados a capacidad:
FormateadorDeReporte,MetodoDePago,CalculadoraDeImpuestos. Evita nombres genéricos tipoManageroHelper. - Parámetros mínimos: pasa objetos de dominio (p. ej.,
Reporte,Pago) en lugar de listas largas de primitivos. - Retornos claros: devuelve resultados que el consumidor pueda usar sin conocer detalles internos (p. ej.,
ResultadoPagoen vez de códigos mágicos).
Cómo evitar “interfaces paraguas” (god interfaces)
Una “interfaz paraguas” mezcla responsabilidades: por ejemplo, IProcesador con métodos para validar, persistir, notificar, auditar y exportar. Esto crea acoplamiento y obliga a implementaciones a depender de cosas que no usan.
Señales de alerta:
- La interfaz tiene muchos métodos con verbos distintos y dominios distintos.
- Las implementaciones típicamente usan solo un subconjunto.
- Los cambios en un área rompen implementaciones de otra.
Refactor típico: divide por capacidades y compón en el consumidor.
// En lugar de una interfaz enorme:interface ProcesadorTodo { void validar(); void guardar(); void notificar(); void exportar();}// Divide por capacidades:interface Validador { void validar(); }interface Repositorio { void guardar(); }interface Notificador { void notificar(); }interface Exportador { void exportar();}// El consumidor recibe lo que necesita:final class CasoDeUso { private final Validador validador; private final Repositorio repo; CasoDeUso(Validador v, Repositorio r) { this.validador = v; this.repo = r; } void ejecutar() { validador.validar(); repo.guardar(); }}Polimorfismo en la práctica: checklist de extensibilidad
- ¿El consumidor depende de una interfaz y no de una clase concreta?
- ¿La interfaz es pequeña y sus métodos se usan juntos?
- ¿Puedes añadir una nueva estrategia (nuevo formato, nuevo método de pago) sin tocar el consumidor?
- ¿Puedes probar el consumidor con un fake/stub sin infraestructura externa?
- ¿Los métodos por defecto (si existen) son mínimos y no esconden dependencias?