🧩 ¿Qué es el polimorfismo?
El polimorfismo es uno de los cuatro pilares fundamentales de la programación orientada a objetos, junto con la encapsulación, la herencia y la abstracción. La palabra proviene del griego polys (muchos) y morphé (forma): un mismo mensaje puede provocar comportamientos diferentes según el objeto que lo reciba.
En términos prácticos, el polimorfismo permite que una variable de tipo general —una superclase o una interfaz— apunte a objetos de distintas subclases, y que al invocar un método sobre esa variable, se ejecute la versión específica de la subclase correspondiente. Esta decisión se toma en tiempo de ejecución, no en tiempo de compilación, y es el mecanismo que da a Java su enorme flexibilidad.
Imagina un director de orquesta que señala a la sección de viento y pide «toca». Cada músico interpreta la orden de forma diferente: el flautista sopla la flauta, el trompetista sopla la trompeta. El director no necesita saber los detalles de cada instrumento; simplemente emite un mensaje genérico y cada ejecutante responde con su propia implementación. Eso es exactamente lo que hace el polimorfismo en Java.
El polimorfismo es esencial para aplicar principios SOLID como el principio abierto/cerrado (Open/Closed Principle): las clases están abiertas a la extensión pero cerradas a la modificación. Sin polimorfismo, cada vez que se añade un nuevo tipo se necesitaría modificar todo el código que lo usa, generando cadenas interminables de sentencias if-else o switch.
📋 Tipos de polimorfismo en Java
Java soporta dos formas principales de polimorfismo, cada una con características y momentos de resolución distintos:
| Tipo | Mecanismo | Se resuelve en | Ejemplo |
|---|---|---|---|
| Polimorfismo estático (en compilación) | Sobrecarga de métodos (overloading) | Tiempo de compilación | Varios métodos calcular() con distintos parámetros |
| Polimorfismo dinámico (en ejecución) | Sobrescritura de métodos (overriding) | Tiempo de ejecución | Subclase redefine toString() de Object |
El polimorfismo dinámico es el más representativo de la orientación a objetos y el que exploraremos en mayor profundidad, ya que es la base de la flexibilidad de la POO en Java. El compilador determina si la llamada es válida (verificación de tipo estática), pero la JVM decide qué versión del método ejecutar en tiempo de ejecución, según el tipo real del objeto.
Este mecanismo se conoce como dynamic dispatch o enlace tardío (late binding). La JVM consulta la tabla de métodos virtuales (vtable) del objeto para localizar la implementación correcta, lo que permite una resolución eficiente sin sacrificar la flexibilidad.
🔄 Sobrescritura de métodos (override)
La sobrescritura permite a una subclase proporcionar una implementación diferente de un método definido en su superclase. El método sobrescrito debe tener exactamente la misma firma: mismo nombre, mismo tipo de retorno (o uno covariante) y los mismos parámetros.
// Superclase public class Animal { public void hacerSonido() { System.out.println("El animal hace un sonido genérico"); } public void moverse() { System.out.println("El animal se mueve"); } } // Subclases que sobrescriben los métodos public class Perro extends Animal { @Override public void hacerSonido() { System.out.println("Guau guau"); } @Override public void moverse() { System.out.println("El perro corre a cuatro patas"); } } public class Gato extends Animal { @Override public void hacerSonido() { System.out.println("Miau"); } @Override public void moverse() { System.out.println("El gato camina sigilosamente"); } }
En este ejemplo, tanto Perro como Gato heredan de Animal, pero cada uno redefine el comportamiento de hacerSonido() y moverse(). Veamos cómo funciona el polimorfismo con estas clases:
public class Main { public static void main(String[] args) { // Variable de tipo Animal apunta a un Perro Animal miMascota = new Perro(); miMascota.hacerSonido(); // → Guau guau miMascota.moverse(); // → El perro corre a cuatro patas // Reasignamos a un Gato miMascota = new Gato(); miMascota.hacerSonido(); // → Miau miMascota.moverse(); // → El gato camina sigilosamente } }
Aunque la variable miMascota es de tipo Animal, la JVM ejecuta en cada caso la versión del método que corresponde al tipo real del objeto (Perro o Gato). El compilador solo verifica que el método existe en Animal; la JVM decide cuál ejecutar.
@Override al sobrescribir un método. Si cometes un error en la firma (por ejemplo, escribes hacersonido() en minúscula), el compilador te avisará de que no estás sobrescribiendo nada. Sin la anotación, crearías un método nuevo sin darte cuenta.Reglas de la sobrescritura en Java
Para que la sobrescritura sea válida, deben cumplirse estas condiciones:
| Regla | Detalle |
|---|---|
| Misma firma | Nombre, parámetros (número, tipo y orden) idénticos al método de la superclase. |
| Tipo de retorno | Igual o covariante (subtipo del retorno original). |
| Visibilidad | Igual o más amplia. Si el original es protected, se puede sobrescribir como public, pero nunca como private. |
| Excepciones | Solo puede lanzar las mismas excepciones checked o subtipos de estas. |
Métodos final | No se pueden sobrescribir. La palabra clave final bloquea la sobrescritura. |
Métodos static | No participan en sobrescritura (es method hiding, no overriding). |
⚡ Sobrecarga de métodos (overloading)
La sobrecarga es la otra cara del polimorfismo en Java. Permite definir varios métodos con el mismo nombre dentro de la misma clase, siempre que difieran en el número o tipo de parámetros. Se resuelve en tiempo de compilación.
public class Calculadora { public int sumar(int a, int b) { return a + b; } public int sumar(int a, int b, int c) { return a + b + c; } public double sumar(double a, double b) { return a + b; } public String sumar(String a, String b) { return a + b; } } // Uso Calculadora calc = new Calculadora(); System.out.println(calc.sumar(3, 5)); // → 8 (int, int) System.out.println(calc.sumar(3, 5, 7)); // → 15 (int, int, int) System.out.println(calc.sumar(2.5, 3.7)); // → 6.2 (double, double) System.out.println(calc.sumar("Hola", " Java")); // → Hola Java (String, String)
Las cuatro versiones de sumar() coexisten en la misma clase. El compilador selecciona la versión adecuada en función de los argumentos. A diferencia de la sobrescritura, aquí no interviene la herencia ni la JVM en tiempo de ejecución.
🔗 Polimorfismo con interfaces
Las interfaces son el mecanismo de polimorfismo más potente y flexible de Java. Una interfaz define un contrato — un conjunto de métodos que las clases implementadoras deben proporcionar — sin dictar cómo deben hacerlo.
// Interfaz: contrato que deben cumplir las clases public interface Exportable { String exportar(); String getFormato(); } public class ExportadorCSV implements Exportable { @Override public String exportar() { return "nombre,edad,email\nAna,30,ana@mail.com"; } @Override public String getFormato() { return "CSV"; } } public class ExportadorJSON implements Exportable { @Override public String exportar() { return "{\"nombre\":\"Ana\",\"edad\":30}"; } @Override public String getFormato() { return "JSON"; } } public class ExportadorXML implements Exportable { @Override public String exportar() { return "<persona><nombre>Ana</nombre></persona>"; } @Override public String getFormato() { return "XML"; } } // Uso polimórfico: la función no sabe qué tipo concreto recibe public static void generarExportacion(Exportable exportador) { System.out.println("Formato: " + exportador.getFormato()); System.out.println(exportador.exportar()); } generarExportacion(new ExportadorCSV()); // → CSV generarExportacion(new ExportadorJSON()); // → JSON generarExportacion(new ExportadorXML()); // → XML
La función generarExportacion() solo conoce la interfaz Exportable. No sabe si recibe un CSV, un JSON o un XML. Se puede añadir un nuevo formato sin tocar una sola línea de la función existente. Basta con crear una nueva clase que implemente Exportable.
Esta es la ventaja clave de las interfaces frente a la herencia de clases: una clase puede implementar múltiples interfaces, mientras que solo puede heredar de una clase. Esto permite composiciones mucho más flexibles y un código más desacoplado. En una arquitectura bien diseñada, las interfaces actúan como fronteras entre los módulos del sistema, permitiendo que cada módulo evolucione de forma independiente sin afectar a los demás.
Desde Java 8, las interfaces pueden incluir métodos con implementación por defecto (default methods), lo que añade una capa adicional de flexibilidad. Un método default proporciona una implementación base que las clases pueden sobrescribir si necesitan un comportamiento diferente, pero que funciona «de serie» para los casos comunes. Esto permite extender interfaces existentes sin romper las implementaciones previas.
Exportable y el framework te proporciona la implementación concreta configurada, sin que tu código dependa de una clase específica.🏗️ Ejemplo integrador completo
Veamos un ejemplo que combina clases abstractas, herencia y polimorfismo dinámico. Diseñaremos un sistema de cálculo de nóminas donde diferentes tipos de empleados tienen formas distintas de calcular su salario:
public abstract class Empleado { protected String nombre; protected String departamento; public Empleado(String nombre, String departamento) { this.nombre = nombre; this.departamento = departamento; } public abstract double calcularSalario(); public void mostrarNomina() { System.out.printf("%-20s | %-15s | $%,.2f%n", nombre, departamento, calcularSalario()); } } public class EmpleadoFijo extends Empleado { private double salarioMensual; public EmpleadoFijo(String nombre, String dept, double salarioMensual) { super(nombre, dept); this.salarioMensual = salarioMensual; } @Override public double calcularSalario() { return salarioMensual; } } public class EmpleadoPorHoras extends Empleado { private double precioHora; private int horasTrabajadas; public EmpleadoPorHoras(String nombre, String dept, double precioHora, int horas) { super(nombre, dept); this.precioHora = precioHora; this.horasTrabajadas = horas; } @Override public double calcularSalario() { if (horasTrabajadas <= 40) { return precioHora * horasTrabajadas; } double normal = precioHora * 40; double extra = precioHora * 1.5 * (horasTrabajadas - 40); return normal + extra; } } public class EmpleadoComision extends Empleado { private double salarioBase; private double ventas; private double porcentajeComision; public EmpleadoComision(String nombre, String dept, double base, double ventas, double comision) { super(nombre, dept); this.salarioBase = base; this.ventas = ventas; this.porcentajeComision = comision; } @Override public double calcularSalario() { return salarioBase + (ventas * porcentajeComision); } }
Ahora, el código principal utiliza polimorfismo para procesar la nómina de todos los empleados sin importar su tipo:
public class SistemaNominas { public static void main(String[] args) { Empleado[] plantilla = { new EmpleadoFijo("Ana García", "Ingeniería", 3200.00), new EmpleadoPorHoras("Carlos López", "Soporte", 18.50, 45), new EmpleadoComision("Marta Ruiz", "Ventas", 1500, 25000, 0.08), new EmpleadoFijo("Luis Fernández", "RRHH", 2800.00), new EmpleadoPorHoras("Elena Torres", "QA", 22.00, 38) }; System.out.println("═══════════════════════════════════════════════════"); System.out.printf("%-20s | %-15s | %s%n", "EMPLEADO", "DEPARTAMENTO", "SALARIO"); System.out.println("═══════════════════════════════════════════════════"); double totalNomina = 0; for (Empleado emp : plantilla) { emp.mostrarNomina(); totalNomina += emp.calcularSalario(); } System.out.println("═══════════════════════════════════════════════════"); System.out.printf("TOTAL NÓMINA MENSUAL: $%,.2f%n", totalNomina); } } /* Salida: ═══════════════════════════════════════════════════ EMPLEADO | DEPARTAMENTO | SALARIO ═══════════════════════════════════════════════════ Ana García | Ingeniería | $3,200.00 Carlos López | Soporte | $878.75 Marta Ruiz | Ventas | $3,500.00 Luis Fernández | RRHH | $2,800.00 Elena Torres | QA | $836.00 ═══════════════════════════════════════════════════ TOTAL NÓMINA MENSUAL: $11,214.75 */
El método main no contiene ni un solo if ni instanceof. Cada objeto «sabe» cómo calcular su propio salario. Si mañana se añade un nuevo tipo (EmpleadoBecario), basta con crear la clase y añadirla al array; el bucle no necesita ningún cambio.
🚫 Errores frecuentes
Error 1: Intentar instanciar una clase abstracta
// ❌ ERROR: no se puede instanciar una clase abstracta Empleado emp = new Empleado("Test", "IT"); // ✅ CORRECTO: usar una subclase concreta Empleado emp = new EmpleadoFijo("Test", "IT", 2500);
Las clases abstractas definen el contrato pero no pueden existir por sí mismas. Siempre se usan como tipo de referencia, nunca para crear instancias.
Error 2: Confundir sobrecarga con sobrescritura
public class Padre { public void saludar(String nombre) { System.out.println("Hola, " + nombre); } } public class Hijo extends Padre { // ❌ NO es sobrescritura, es sobrecarga (distintos parámetros) public void saludar() { System.out.println("Hola a todos"); } } Padre p = new Hijo(); p.saludar("Ana"); // → "Hola, Ana" (versión del Padre)
Como saludar() en Hijo no tiene los mismos parámetros, no hay sobrescritura sino sobrecarga. El polimorfismo dinámico no actúa.
Error 3: Métodos estáticos no son polimórficos
public class Base { public static void info() { System.out.println("Soy Base"); } } public class Derivada extends Base { public static void info() { System.out.println("Soy Derivada"); } } Base obj = new Derivada(); obj.info(); // → "Soy Base" (se resuelve por tipo de referencia)
Los métodos estáticos se resuelven según el tipo de la referencia, no según el tipo real del objeto. Esto se llama method hiding y es una fuente habitual de errores difíciles de depurar, especialmente en equipos donde varios desarrolladores trabajan sobre la misma jerarquía de clases.
Error 4: Usar instanceof en exceso
// ❌ Anti-patrón: cadena de instanceof public void procesar(Animal a) { if (a instanceof Perro) { System.out.println("Guau"); } else if (a instanceof Gato) { System.out.println("Miau"); } } // ✅ Correcto: aprovechar el polimorfismo public void procesar(Animal a) { a.hacerSonido(); // Cada animal sabe cómo hacerlo }
Si necesitas una cadena de instanceof, casi siempre significa que puedes resolver el problema de forma más elegante con polimorfismo.
📌 Buenas prácticas profesionales
| Práctica | Razón |
|---|---|
| Programa contra interfaces, no contra implementaciones | Declara tipos como List, no como ArrayList. Así puedes cambiar la implementación sin afectar al resto del código. |
Usa siempre @Override | El compilador verifica la firma. Sin la anotación, un error tipográfico crea un método nuevo en silencio. |
| Respeta el principio de Liskov (LSP) | Una subclase debe poder sustituir a su superclase sin romper el programa. |
| Prefiere composición sobre herencia | La herencia crea acoplamiento fuerte. Las interfaces y la delegación ofrecen polimorfismo sin las restricciones de una jerarquía rígida. |
Evita cadenas de instanceof | Si necesitas preguntar por el tipo concreto, probablemente falta un método polimórfico en la clase base. |
| Documenta el contrato de los métodos abstractos | Con Javadoc, indica precondiciones, postcondiciones y valores de retorno esperados. |
List<String> en lugar de ArrayList<String>, Map<K,V> en lugar de HashMap<K,V>.✏️ Ejercicios resueltos
Ejercicio 1: Sistema de figuras geométricas
Crea una clase abstracta Figura con un método abstracto calcularArea(). Implementa las clases Circulo, Rectangulo y Triangulo. En el main, crea un array polimórfico y muestra el área de cada figura.
🔍 Ver solución
public abstract class Figura { protected String nombre; public Figura(String nombre) { this.nombre = nombre; } public abstract double calcularArea(); public void mostrarInfo() { System.out.printf("%s → Área: %.2f%n", nombre, calcularArea()); } } public class Circulo extends Figura { private double radio; public Circulo(double radio) { super("Círculo"); this.radio = radio; } @Override public double calcularArea() { return Math.PI * radio * radio; } } public class Rectangulo extends Figura { private double ancho, alto; public Rectangulo(double ancho, double alto) { super("Rectángulo"); this.ancho = ancho; this.alto = alto; } @Override public double calcularArea() { return ancho * alto; } } public class Triangulo extends Figura { private double base, altura; public Triangulo(double base, double altura) { super("Triángulo"); this.base = base; this.altura = altura; } @Override public double calcularArea() { return (base * altura) / 2; } } // Main Figura[] figuras = { new Circulo(5), new Rectangulo(4, 6), new Triangulo(3, 8) }; for (Figura f : figuras) { f.mostrarInfo(); } /* Salida: Círculo → Área: 78.54 Rectángulo → Área: 24.00 Triángulo → Área: 12.00 */
Ejercicio 2: Sistema de notificaciones con interfaces
Define una interfaz Notificable con un método enviar(String mensaje). Implementa NotificacionEmail, NotificacionSMS y NotificacionPush. Usa un array polimórfico para enviar un mismo mensaje por todos los canales.
🔍 Ver solución
public interface Notificable { void enviar(String mensaje); String getCanal(); } public class NotificacionEmail implements Notificable { private String destinatario; public NotificacionEmail(String dest) { this.destinatario = dest; } @Override public void enviar(String msg) { System.out.println("📧 Email a " + destinatario + ": " + msg); } @Override public String getCanal() { return "Email"; } } public class NotificacionSMS implements Notificable { private String telefono; public NotificacionSMS(String tel) { this.telefono = tel; } @Override public void enviar(String msg) { System.out.println("📱 SMS a " + telefono + ": " + msg); } @Override public String getCanal() { return "SMS"; } } public class NotificacionPush implements Notificable { private String dispositivo; public NotificacionPush(String disp) { this.dispositivo = disp; } @Override public void enviar(String msg) { System.out.println("🔔 Push a " + dispositivo + ": " + msg); } @Override public String getCanal() { return "Push"; } } // Main Notificable[] canales = { new NotificacionEmail("ana@correo.com"), new NotificacionSMS("+34 612 345 678"), new NotificacionPush("iPhone-Ana") }; for (Notificable canal : canales) { canal.enviar("Tu pedido ha sido enviado"); } /* Salida: 📧 Email a ana@correo.com: Tu pedido ha sido enviado 📱 SMS a +34 612 345 678: Tu pedido ha sido enviado 🔔 Push a iPhone-Ana: Tu pedido ha sido enviado */
Ejercicio 3: Principio de Liskov — Detección de violaciones
Analiza el siguiente código. ¿Viola el principio de sustitución de Liskov? ¿Por qué? Propón una solución.
public class Rectangulo { protected int ancho, alto; public void setAncho(int ancho) { this.ancho = ancho; } public void setAlto(int alto) { this.alto = alto; } public int getArea() { return ancho * alto; } } public class Cuadrado extends Rectangulo { @Override public void setAncho(int ancho) { this.ancho = ancho; this.alto = ancho; // Fuerza lados iguales } @Override public void setAlto(int alto) { this.alto = alto; this.ancho = alto; // Fuerza lados iguales } }
🔍 Ver solución
Sí, viola el principio de Liskov. Un código que trabaje con Rectangulo espera poder establecer ancho y alto de forma independiente:
Rectangulo r = new Cuadrado(); r.setAncho(5); r.setAlto(3); System.out.println(r.getArea()); // Esperado: 15, Real: 9
Al sustituir un Rectangulo por un Cuadrado, el comportamiento cambia de forma inesperada. La solución es no hacer que Cuadrado herede de Rectangulo. Ambos pueden implementar una interfaz Figura con el método getArea(), eliminando la relación de herencia problemática.
❓ Preguntas frecuentes sobre Polimorfismo en Java
Las dudas más comunes respondidas de forma clara y directa.
💬 Foro de discusión
¿Tienes dudas sobre Polimorfismo en Java? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!