Polimorfismo en Java

📅 Actualizado en marzo 2026 ✍️ Ángel López ⏱️ 18 min de lectura ✓ Nivel intermedio ★ ★ ★ ★ ★ (5/5)

🧩 ¿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.

💡
Concepto clave: Gracias al polimorfismo, el código trabaja con abstracciones (superclases o interfaces), y los detalles concretos los aporta cada subclase. Esto hace que el código sea más flexible, extensible y fácil de mantener, ya que se pueden añadir nuevas subclases sin modificar el código existente.

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:

TipoMecanismoSe resuelve enEjemplo
Polimorfismo estático (en compilación)Sobrecarga de métodos (overloading)Tiempo de compilaciónVarios métodos calcular() con distintos parámetros
Polimorfismo dinámico (en ejecución)Sobrescritura de métodos (overriding)Tiempo de ejecuciónSubclase 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.

Java
// 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:

Java — Uso polimórfico
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.

Buena práctica: Usa siempre la anotación @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:

ReglaDetalle
Misma firmaNombre, parámetros (número, tipo y orden) idénticos al método de la superclase.
Tipo de retornoIgual o covariante (subtipo del retorno original).
VisibilidadIgual o más amplia. Si el original es protected, se puede sobrescribir como public, pero nunca como private.
ExcepcionesSolo puede lanzar las mismas excepciones checked o subtipos de estas.
Métodos finalNo se pueden sobrescribir. La palabra clave final bloquea la sobrescritura.
Métodos staticNo 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.

Java — Sobrecarga
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.

⚠️
Atención: Cambiar solo el tipo de retorno no constituye sobrecarga válida. Si dos métodos tienen los mismos parámetros pero distinto retorno, el compilador genera un error. La distinción debe estar siempre en los parámetros.

🔗 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.

Java — Polimorfismo con interfaces
// 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.

💡
En la práctica profesional: Los frameworks modernos como Spring utilizan intensivamente el polimorfismo con interfaces para la inyección de dependencias. Declaras que necesitas un 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:

Java — Sistema de nóminas con polimorfismo
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:

Java — Procesamiento polimórfico
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

Java
// ❌ 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

Java
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

Java
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

Java
// ❌ 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ácticaRazón
Programa contra interfaces, no contra implementacionesDeclara tipos como List, no como ArrayList. Así puedes cambiar la implementación sin afectar al resto del código.
Usa siempre @OverrideEl 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 herenciaLa herencia crea acoplamiento fuerte. Las interfaces y la delegación ofrecen polimorfismo sin las restricciones de una jerarquía rígida.
Evita cadenas de instanceofSi necesitas preguntar por el tipo concreto, probablemente falta un método polimórfico en la clase base.
Documenta el contrato de los métodos abstractosCon Javadoc, indica precondiciones, postcondiciones y valores de retorno esperados.
Consejo profesional: En proyectos reales se sigue el principio «programa para la interfaz, no para la implementación». Esto significa declarar las variables, parámetros y retornos con el tipo más abstracto posible: 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
Java
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
Java
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.

Java — ¿Viola LSP?
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:

Java
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.

La sobrecarga (overloading) ocurre dentro de la misma clase con distinto número o tipo de parámetros. La sobrescritura (overriding) ocurre entre superclase y subclase, redefiniendo un método con la misma firma. La sobrecarga se resuelve en tiempo de compilación y la sobrescritura en tiempo de ejecución.
Sí. Una interfaz define un contrato que múltiples clases pueden implementar, cada una a su manera, permitiendo tratar objetos de distintas clases de forma uniforme. De hecho, el polimorfismo con interfaces es el más utilizado en proyectos profesionales porque ofrece mayor desacoplamiento que la herencia de clases.
El dispatch dinámico tiene un coste mínimo. La JVM optimiza estos casos con inline caching y compilación JIT, por lo que en la práctica el impacto es negligible. En la gran mayoría de aplicaciones el beneficio en mantenibilidad y extensibilidad supera con creces cualquier diferencia de rendimiento.
El principio de sustitución de Liskov (LSP) establece que cualquier objeto de una subclase debe poder usarse en lugar de un objeto de la superclase sin alterar el comportamiento esperado del programa. Es la base teórica del polimorfismo: si una subclase viola este principio, el código polimórfico dejará de funcionar correctamente.
No. Los métodos estáticos pertenecen a la clase, no a la instancia, y se resuelven en tiempo de compilación según el tipo de referencia (no el tipo real del objeto). Esto se denomina method hiding, no sobrescritura. Por tanto, los métodos estáticos no participan en el polimorfismo dinámico.
Valora este artículo

💬 Foro de discusión

¿Tienes dudas sobre Polimorfismo en Java? Comparte tu pregunta con la comunidad.

¿Tienes cuenta? o comenta como invitado ↓

Todavía no hay mensajes. ¡Sé el primero en participar!