Polimorfismo en Java
En esta lección
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
staticse 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
instanceofpara decidir qué hacer con cada tipo, probablemente ese comportamiento debería ser un método sobrescrito en cada clase.instanceofes 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ónde | En la misma clase (o en una subclase) | En una subclase |
| Nombre | Igual | Igual |
| Parámetros | Distintos (número o tipo) | Iguales |
| Tipo de retorno | Puede cambiar | El mismo (o un subtipo) |
| Quién decide cuál se ejecuta | El compilador, por los argumentos | La JVM al ejecutar, por el objeto real |
@Override | No | Sí |
| Se le llama | Polimorfismo estático | Polimorfismo 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()confde tipoFigurano compila, aunque el objeto sea un círculo. - Hacer downcasting sin comprobar. Provoca
ClassCastExceptional ejecutar. Usainstanceofcon pattern matching. - Llenar el código de
instanceofen 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.
@Overridete avisa. - Poner un caso general antes que uno concreto en un
switchcon patrones. El compilador lo marca como error porque el segundo nunca se alcanzaría.
Resumen
| Concepto | Idea clave |
|---|---|
| Polimorfismo | Tratar objetos distintos a través de un mismo tipo |
| Tipo de la variable | Decide qué métodos se pueden llamar (compilación) |
| Tipo del objeto | Decide qué versión se ejecuta (ejecución) |
| Enlace dinámico | La JVM elige el método sobrescrito según el objeto real |
| Upcasting | Hijo a padre, automático y seguro |
| Downcasting | Padre a hijo, explícito; puede lanzar ClassCastException |
instanceof Tipo t | Comprueba y convierte a la vez (Java 16+) |
switch con patrones | Elige según el tipo del objeto, con when opcional (Java 21) |
| Sobrecarga / sobrescritura | Distintos 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
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.