El polimorfismo permite tratar objetos de tipos distintos mediante un tipo común y conservar su comportamiento específico.
Ya sabemos heredar (extends) y definir clases abstractas. Ahora veremos cómo objetos distintos pueden responder de forma diferente a la misma operación.
La palabra Polimorfismo viene del griego “muchas formas”. En programación, es la capacidad de un objeto de comportarse de diferentes maneras dependiendo del contexto, o mejor dicho, la capacidad de una variable de referencia de apuntar a objetos de diferentes formas.
El principio de sustitución
Para entender esto, primero debemos grabar a fuego un concepto: El Principio de Sustitución (de Liskov).
Este principio dice, básicamente:
“Si
Bes hijo deA, entonces donde sea que uses un objeto de tipoA, deberías poder usar un objeto de tipoBsin que el programa falle.”
En Java, esto se traduce en que una variable de tipo PADRE puede guardar una referencia a un objeto HIJO.
// Lo normal: Referencia Coche -> Objeto Coche
Coche miCoche = new Coche();
// POLIMORFISMO (Upcasting): Referencia Vehiculo -> Objeto Coche
Vehiculo miVehiculo = new Coche();
Esto es legal porque un Coche es un Vehículo. Sin embargo, al revés no funciona. No todo vehículo es un coche.
Coche c = new Vehiculo(); // ¡ERROR! Un vehículo genérico no es un coche.
Tipo de referencia vs tipo de objeto
Aquí es donde la cabeza suele hacer “clic” (o explotar). Cuando hacemos:
Animal miMascota = new Perro();
Tenemos dos actores:
- La Variable (
miMascota): Es de tipoAnimal. Es el “mando a distancia”. Define QUÉ botones puedes pulsar (qué métodos puedes llamar). - El Objeto (
new Perro()): Es de tipoPerro. Es la “tele” real en el Heap. Define CÓMO reacciona cuando pulsas el botón.
miMascota.hacerSonido(); // ✅ Funciona (Animal tiene ese método)
// miMascota.traerLaPelota(); // ❌ ERROR DE COMPILACIÓNAunque el objeto real es un Perro y sabe traer la pelota, el mando a distancia es de Animal, y los animales genéricos no traen pelotas. El compilador solo mira el tipo de la variable (la izquierda del igual).
Sobrescritura de métodos (overriding)
Ya vimos que un hijo hereda los métodos del padre. Pero, ¿y si quiere cambiarlos? Eso es Sobrescribir.
Para sobrescribir un método, lo volvemos a declarar en la clase hija con la misma firma (mismo nombre, mismos parámetros).
public class Animal {
public void hacerSonido() {
System.out.println("Sonido genérico...");
}
}
public class Perro extends Animal {
@Override // <--- Buenas práctica
public void hacerSonido() {
System.out.println("¡Guau Guau!");
}
}
La anotación @Override
Aunque no es obligatoria, úsala SIEMPRE.
Le dice al compilador: “Oye, mi intención es sobrescribir un método del padre. Si me he equivocado en el nombre o los parámetros, avísame”.
Sin ella, podrías crear un método nuevo por error en lugar de sobrescribir, y volverte loco buscando el bug.
Vinculación dinámica (dynamic binding)
Veamos cómo se resuelve la llamada en el ejemplo polimórfico:
Animal a = new Perro();
a.hacerSonido();
¿Qué se imprime?
- ¿“Sonido genérico…” (Lo que dice la variable
Animal)? - ¿“¡Guau Guau!” (Lo que dice el objeto
Perro)?
Respuesta: ¡Guau Guau!
En Java, los métodos de instancia son polimórficos. Aunque el compilador comprueba la variable (para ver si existe el método), la JVM en tiempo de ejecución busca el método en el objeto real en memoria.
Si el objeto es un Perro, ejecutará la versión de Perro, aunque la variable sea Animal.
¿Para qué sirve todo esto?
Quizás pienses: “¿Para qué quiero guardar un Perro en una variable Animal? Si lo guardo en una variable Perro tengo acceso a todo.”
La potencia viene cuando trabajamos con Colecciones y métodos genéricos. Imagina un array de animales:
// Array polimórfico: Guarda Perros, Gatos y cualquier hijo de Animal
Animal[] zoologico = {
new Perro(),
new Gato(),
new Perro(),
new Leon()
};
for (Animal a : zoologico) {
// No necesitamos conocer el subtipo concreto de 'a'.
// Java ejecutará el método correcto para cada uno.
a.hacerSonido();
}
Salida:
¡Guau Guau!
¡Miau!
¡Guau Guau!
¡Grrrr!
Sin polimorfismo, tendrías que hacer un if gigante: if (a es Perro) ladrar() else if (a es Gato) maullar().... El polimorfismo elimina esos condicionales.
Casting de objetos (downcasting)
A veces, tienes un Animal y estás seguro de que dentro hay un Perro, y necesitas llamar a traerLaPelota().
Para recuperar la referencia completa, hacemos un Casting (igual que con (int) double).
Animal a = new Perro();
// a.traerLaPelota(); // Error
Perro p = (Perro) a; // Downcasting: "Confía en mí, esto es un perro"
p.traerLaPelota(); // Ahora sí
El peligro: ClassCastException
Si mientes al compilador, el programa explotará en ejecución.
Animal a = new Gato();
Perro p = (Perro) a; // ¡BOOM! ClassCastException
// No puedes convertir un Gato en Perro a la fuerza.
Para hacerlo de forma segura, usamos el operador instanceof:
if (a instanceof Perro) {
Perro p = (Perro) a;
p.traerLaPelota();
}
O la versión Pattern Matching, que nos ahorra el casting manual:
if (a instanceof Perro p) {
p.traerLaPelota(); // 'p' ya está creado y castedo automáticamente
}