Saltar al contenido
polimorfismo.java · devschool

Polimorfismo en Java

Lección 18 de 26 · 10 min de lectura · Actualizado el

En esta lección
  1. Las clases de partida
  2. Una referencia del padre apuntando a un hijo
  3. Enlace dinámico
  4. Arrays y listas de tipo padre
  5. Casting hacia arriba y hacia abajo
  6. instanceof y pattern matching
  7. Sobrecarga frente a sobrescritura
  8. Polimorfismo con interfaces
  9. Errores frecuentes
  10. Resumen

Polimorfismo significa “muchas formas”. En Java es la capacidad de tratar objetos de clases distintas a través de un mismo tipo y que cada uno responda a su manera. Es lo que permite escribir un método que calcula el área total de una lista de figuras sin saber si son círculos, rectángulos o triángulos, y que siga funcionando cuando mañana añadas pentágonos.

Para entender esta lección necesitas herencia y clases abstractas e interfaces. El ejemplo que construiremos es una aplicación de geometría con figuras que calculan su área.

Las clases de partida

Empezamos con una clase abstracta Figura y tres subclases:

public abstract class Figura {
    private String nombre;

    public Figura(String nombre) { this.nombre = nombre; }

    public String getNombre() { return nombre; }

    public abstract double area();

    public String describir() {
        return getNombre() + " con área " + Math.round(area() * 100) / 100.0;
    }
}
public class Circulo extends Figura {
    private double radio;

    public Circulo(double radio) {
        super("Círculo");
        this.radio = radio;
    }

    public double getRadio() { return radio; }

    @Override
    public double area() { return Math.PI * radio * radio; }
}

public class Rectangulo extends Figura {
    protected double base, altura;

    public Rectangulo(double base, double altura) {
        super("Rectángulo");
        this.base = base;
        this.altura = altura;
    }

    @Override
    public double area() { return base * altura; }
}

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 area() { return base * altura / 2; }
}

Math.round(x * 100) / 100.0 redondea a dos decimales. Lo verás en Math y clases envoltorio.

Una referencia del padre apuntando a un hijo

Hasta ahora escribías Circulo c = new Circulo(2);. Pero como un círculo es una figura, también puedes guardarlo en una variable de tipo Figura:

Figura f = new Circulo(2);
System.out.println(f.area());      // 12.566370614359172
System.out.println(f.describir()); // Círculo con área 12.57

f = new Rectangulo(3, 4);
System.out.println(f.describir()); // Rectángulo con área 12.0

Aquí hay que distinguir dos cosas:

  • El tipo de la variable (Figura): lo que ve el compilador. Decide qué métodos puedes llamar.
  • El tipo del objeto (Circulo, Rectangulo): lo que existe de verdad en memoria. Decide qué versión del método se ejecuta.

Por eso f.area() compila (toda Figura tiene area()), pero esto no:

Figura f = new Circulo(2);
f.getRadio();
// error: cannot find symbol  symbol: method getRadio()  location: variable f of type Figura

El compilador solo sabe que f es una Figura, y no todas las figuras tienen radio.

Enlace dinámico

Cuando llamas a f.area(), Java no decide al compilar qué código ejecutar. Lo decide mientras el programa se ejecuta, mirando qué objeto hay realmente en la variable. Esto se llama enlace dinámico (dynamic binding).

Fíjate en describir(): está escrito una sola vez en Figura, y llama a getNombre() y a area(). Cada vez se ejecuta la versión del objeto concreto. Lo comprobamos con una nueva subclase que sobrescribe getNombre():

public class Cuadrado extends Rectangulo {
    public Cuadrado(double lado) { super(lado, lado); }

    @Override
    public String getNombre() { return "Cuadrado"; }

    public double getLado() { return base; }
}
Figura g = new Cuadrado(3);
System.out.println(g.describir()); // Cuadrado con área 9.0

Cuadrado no toca describir() ni area(), pero el resultado es correcto: describir() (de Figura) llama a getNombre() (de Cuadrado) y a area() (de Rectangulo). Java siempre busca el método empezando por la clase real del objeto y subiendo por la jerarquía.

Cuidado: el enlace dinámico solo se aplica a los métodos de instancia sobrescritos. Los atributos y los métodos static se eligen según el tipo de la variable, no del objeto.

Arrays y listas de tipo padre

Aquí es donde el polimorfismo muestra su verdadera utilidad. Puedes guardar objetos distintos en un mismo array o lista y tratarlos a todos igual:

Figura[] figuras = {
    new Circulo(1),
    new Rectangulo(2, 5),
    new Triangulo(6, 3),
    new Cuadrado(3)
};

for (Figura fig : figuras) {
    System.out.println(fig.describir());
}
// Círculo con área 3.14
// Rectángulo con área 10.0
// Triángulo con área 9.0
// Cuadrado con área 9.0

Y un método que recibe el tipo padre funciona con cualquier figura, presente o futura:

public static double areaTotal(List<Figura> figuras) {
    double total = 0;
    for (Figura f : figuras) {
        total += f.area();
    }
    return total;
}
List<Figura> lista = new ArrayList<>(List.of(figuras));
System.out.println(Math.round(areaTotal(lista) * 100) / 100.0); // 31.14

Si mañana creas Pentagono extends Figura, areaTotal funcionará con él sin cambiar una sola línea. Sin polimorfismo tendrías un if por cada tipo de figura, y cada figura nueva te obligaría a tocarlos todos.

Casting hacia arriba y hacia abajo

Convertir una referencia de un tipo a otro de su jerarquía se llama casting.

Upcasting (hacia arriba)

De hijo a padre. Es automático y siempre seguro, porque un cuadrado siempre es una figura:

Cuadrado c = new Cuadrado(4);
Figura f = c;             // upcasting implícito

Downcasting (hacia abajo)

De padre a hijo. Hay que pedirlo explícitamente con el tipo entre paréntesis, porque no siempre es posible:

Figura g = new Cuadrado(4);
Cuadrado cu = (Cuadrado) g;      // downcasting explícito
System.out.println(cu.getLado()); // 4.0

Sin el (Cuadrado) el compilador da el error incompatible types: Figura cannot be converted to Cuadrado. Con él, le prometes que el objeto es un cuadrado. Si mientes, el programa falla al ejecutarse:

ClassCastException

Figura h = new Circulo(1);
Rectangulo r = (Rectangulo) h;
// Exception in thread "main" java.lang.ClassCastException:
// class Circulo cannot be cast to class Rectangulo

El compilador lo permite (una Figura podría ser un Rectangulo), pero el objeto real es un círculo. Las excepciones las verás en detalle en excepciones. La forma de evitar este error es comprobar el tipo antes con instanceof.

instanceof y pattern matching

El operador instanceof comprueba si un objeto es de un tipo (o de una subclase suya):

System.out.println(new Circulo(1) instanceof Figura); // true

Antes de Java 16 se usaba así, con comprobación y casting por separado:

if (f instanceof Circulo) {
    Circulo c = (Circulo) f;
    System.out.println("Radio: " + c.getRadio());
}

Desde Java 16, con pattern matching, la comprobación y la conversión van en uno: si f es un Circulo, se crea directamente la variable c:

public static void mostrarDetalle(Figura f) {
    if (f instanceof Circulo c) {
        System.out.println("Radio: " + c.getRadio());
    } else if (f instanceof Cuadrado cu) {
        System.out.println("Lado: " + cu.getLado());
    } else {
        System.out.println("Sin detalles para " + f.getNombre());
    }
}
for (Figura fig : figuras) {
    mostrarDetalle(fig);
}
// Radio: 1.0
// Sin detalles para Rectángulo
// Sin detalles para Triángulo
// Lado: 3.0

Pattern matching en switch

En Java 21 también puedes usar patrones en un switch, e incluso añadir condiciones con when:

public static String tipo(Figura f) {
    return switch (f) {
        case Circulo c when c.getRadio() > 10 -> "círculo grande";
        case Circulo c -> "círculo";
        case Rectangulo r -> "rectángulo o cuadrado";
        default -> "otra figura";
    };
}

System.out.println(tipo(new Circulo(20)));     // círculo grande
System.out.println(tipo(new Cuadrado(2)));     // rectángulo o cuadrado
System.out.println(tipo(new Triangulo(1, 1))); // otra figura

El orden importa: se comprueban de arriba abajo, así que el caso más concreto va primero.

Consejo: si te encuentras escribiendo muchos instanceof para decidir qué hacer con cada tipo, probablemente ese comportamiento debería ser un método sobrescrito en cada clase. instanceof es para casos puntuales, no para sustituir al polimorfismo.

Sobrecarga frente a sobrescritura

Son dos conceptos con nombres parecidos que se confunden a menudo. La sobrecarga consiste en tener varios métodos con el mismo nombre y distintos parámetros:

static int sumar(int a, int b) { return a + b; }
static double sumar(double a, double b) { return a + b; }
static int sumar(int a, int b, int c) { return a + b + c; }

System.out.println(sumar(2, 3));    // 5
System.out.println(sumar(2.5, 3));  // 5.5
System.out.println(sumar(1, 2, 3)); // 6

La sobrescritura es lo que has visto en esta lección: una subclase redefine un método del padre.

Sobrecarga (overloading)Sobrescritura (overriding)
DóndeEn la misma clase (o en una subclase)En una subclase
NombreIgualIgual
ParámetrosDistintos (número o tipo)Iguales
Tipo de retornoPuede cambiarEl mismo (o un subtipo)
Quién decide cuál se ejecutaEl compilador, por los argumentosLa JVM al ejecutar, por el objeto real
@OverrideNoSí
Se le llamaPolimorfismo estáticoPolimorfismo dinámico

Polimorfismo con interfaces

Todo lo anterior funciona igual con interfaces, y además entre clases sin ninguna relación de herencia:

public interface Dibujable {
    void dibujar();
}

public class Texto implements Dibujable {
    private String contenido;
    public Texto(String contenido) { this.contenido = contenido; }
    @Override
    public void dibujar() { System.out.println("Texto: " + contenido); }
}

public class Icono implements Dibujable {
    @Override
    public void dibujar() { System.out.println("[*]"); }
}
List<Dibujable> lienzo = List.of(new Texto("Hola"), new Icono());
for (Dibujable d : lienzo) {
    d.dibujar();
}
// Texto: Hola
// [*]

Texto e Icono no comparten superclase (salvo Object), pero los dos saben dibujarse. Es la forma más flexible de polimorfismo y la que más verás en código profesional: se programa contra la interfaz, no contra la clase concreta. Por ejemplo, se escribe List<Figura> lista = new ArrayList<>(); y no ArrayList<Figura> lista, como verás en colecciones.

Errores frecuentes

  • Llamar a un método que el tipo de la variable no tiene. f.getRadio() con f de tipo Figura no compila, aunque el objeto sea un círculo.
  • Hacer downcasting sin comprobar. Provoca ClassCastException al ejecutar. Usa instanceof con pattern matching.
  • Llenar el código de instanceof en lugar de sobrescribir métodos. Pierdes la ventaja del polimorfismo.
  • Confundir sobrecarga con sobrescritura. Si cambias los parámetros al “sobrescribir”, en realidad estás sobrecargando, y el método del padre sigue ejecutándose. @Override te avisa.
  • Poner un caso general antes que uno concreto en un switch con patrones. El compilador lo marca como error porque el segundo nunca se alcanzaría.

Resumen

ConceptoIdea clave
PolimorfismoTratar objetos distintos a través de un mismo tipo
Tipo de la variableDecide qué métodos se pueden llamar (compilación)
Tipo del objetoDecide qué versión se ejecuta (ejecución)
Enlace dinámicoLa JVM elige el método sobrescrito según el objeto real
UpcastingHijo a padre, automático y seguro
DowncastingPadre a hijo, explícito; puede lanzar ClassCastException
instanceof Tipo tComprueba y convierte a la vez (Java 16+)
switch con patronesElige según el tipo del objeto, con when opcional (Java 21)
Sobrecarga / sobrescrituraDistintos parámetros / mismos parámetros en subclase

Ya dominas los cuatro pilares de la orientación a objetos: encapsulación, herencia, abstracción y polimorfismo. En la siguiente lección verás un tipo de clase muy especial: los enums.

Pon a prueba lo que has aprendido

[Java] ¿Qué imprime este código?
class Animal {
    String sonido() { return "..."; }
}
class Perro extends Animal {
    @Override
    String sonido() { return "Guau"; }
}

Animal a = new Perro();
System.out.println(a.sonido());

[Java] Circulo y Rectangulo heredan de Figura. ¿Qué ocurre con este código?
Figura f = new Circulo();
Rectangulo r = (Rectangulo) f;
System.out.println("ok");

[Java] ¿En qué caso decide la JVM, mientras el programa se ejecuta, qué método se llama?

[Java] ¿Qué imprime este código?
Object o = "Java";
if (o instanceof String s && s.length() > 3) {
    System.out.println(s.toUpperCase());
} else {
    System.out.println("no");
}

¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.