Herencia, Polimorfismo y Sobrecarga de Métodos
Hasta ahora cada clase que escribiste vivía sola. Pero en cualquier sistema real vas a encontrarte con clases que comparten buena parte de su estado y su comportamiento: un Auto, una Moto y un Camión tienen marca, arrancan y frenan.
Copiar y pegar esos atributos en las tres clases funciona hasta el día en que cambia una regla. Ahí tenés que acordarte de los tres lugares. Ya sabés cómo termina eso.
La herencia resuelve ese problema, y el polimorfismo —que es su consecuencia, no un tema aparte— es lo que hace que valga la pena. Esta lección trata las dos cosas juntas, porque separadas no se entienden.
1. Herencia: extends y la prueba del “es un”
Una clase puede extender a otra y recibir automáticamente todos sus atributos y métodos:
public class Vehiculo {
protected String marca;
public Vehiculo(String marca) {
this.marca = marca;
}
public void arrancar() {
System.out.println("El vehículo arranca.");
}
public void frenar() {
System.out.println("El vehículo frena.");
}
}
public class Auto extends Vehiculo {
public Auto(String marca) {
super(marca);
}
@Override
public void arrancar() {
System.out.println("Auto " + marca + " arrancando con botón.");
}
public void abrirBaul() {
System.out.println("Baúl abierto.");
}
}
Auto no declara marca ni frenar(), pero los tiene. Los heredó.
La prueba antes de escribir extends
Antes de heredar, hacete esta pregunta en voz alta: “¿un X es un Y?”
- Un
Autoes unVehiculo. ✔ Herencia correcta. - Un
Autoes unMotor. ✘ Un auto tiene un motor. Eso es composición, no herencia.
Si la frase suena rara, la herencia está mal. Y una herencia mal puesta no se nota el primer día: se nota seis meses después, cuando la subclase hereda cinco métodos que no tienen ningún sentido en ella.
Java tiene herencia simple: una clase extiende a una sola clase. No existe
extends A, B. Para combinar comportamientos de varios orígenes están las interfaces, que vas a ver en la lección siguiente.
2. super: la cadena de constructores
Acá está la parte que más confunde al principio. Cuando instanciás una subclase, no se ejecuta un constructor: se ejecuta toda la cadena, desde el ancestro más lejano hasta la clase concreta.
Tres reglas que el compilador aplica sin excepción:
super(...)tiene que ser la primera sentencia del constructor. Igual quethis(...), y por la misma razón: nada puede correr antes de que la parte heredada esté lista.- Si no escribís
super(...), Java insertasuper()sin argumentos automáticamente. - Si la superclase no tiene constructor sin argumentos, esa inserción automática falla y el compilador te obliga a llamar explícitamente a uno que sí exista.
Este es el error más frecuente de toda la lección:
public class Vehiculo {
protected String marca;
public Vehiculo(String marca) { this.marca = marca; }
// Al escribir este constructor, Vehiculo dejó de tener uno sin argumentos
}
public class Auto extends Vehiculo {
public Auto() {
// ERROR: Java intenta insertar super() y Vehiculo no tiene ese constructor
}
}
super también sirve para llamar a la versión del padre de un método que estás sobrescribiendo, algo muy común cuando querés extender el comportamiento en lugar de reemplazarlo:
@Override
public void arrancar() {
super.arrancar(); // primero hace lo que hace todo vehículo
System.out.println("...y activa el arranque sin llave.");
}
3. Sobrescritura: @Override y el contrato que no podés romper
Sobrescribir es redefinir en la subclase un método que ya existe en la superclase, con la misma firma: mismo nombre, mismos tipos de parámetros, en el mismo orden.
La anotación @Override no es obligatoria, pero escribila siempre. No cambia nada en tiempo de ejecución; lo que hace es pedirle al compilador que verifique que realmente estás sobrescribiendo algo:
public class Auto extends Vehiculo {
@Override
public void arrancar(int velocidad) { // ← ERROR de compilación, y eso es bueno
...
}
}
Sin @Override, ese método compilaría perfecto. Java lo trataría como un método nuevo de Auto llamado arrancar que recibe un int, y el arrancar() original seguiría heredado sin cambios. Tu código correría, no haría lo que esperabas, y no habría ningún error para guiarte. @Override convierte un bug silencioso en un error de compilación.
Qué puede y qué no puede cambiar la subclase
| Elemento | Regla al sobrescribir |
|---|---|
| Nombre y parámetros | Idénticos. Si cambian, ya no es sobrescritura sino un método nuevo. |
| Tipo de retorno | Igual, o un subtipo del original (retorno covariante). |
| Visibilidad | Igual o más abierta. Un método public no puede volverse protected. |
| Excepciones checked | Las mismas, menos, o subtipos. Nunca una más amplia. |
Métodos private, static o final | No se pueden sobrescribir. |
La regla de la visibilidad tiene una lógica muy concreta: si alguien puede tratar a un Auto como un Vehiculo, y Vehiculo.arrancar() es público, entonces arrancar() tiene que seguir siendo invocable en el Auto. Cerrarlo rompería esa promesa.
4. Sobrecarga y sobrescritura: se parecen los nombres, no los conceptos
Estas dos palabras se confunden todo el tiempo, y la diferencia de fondo es cuándo se decide qué método se ejecuta.
public class Consola {
public void imprimir(String texto) { ... }
public void imprimir(int numero) { ... }
public void imprimir(String texto, int veces) { ... }
}
Nada de esto es herencia. Es simplemente comodidad: tres formas de llamar a algo que conceptualmente es lo mismo.
El tipo de retorno no cuenta para la sobrecarga.
int calcular()ydouble calcular()en la misma clase no compilan: el compilador no tiene forma de decidir cuál querés cuando escribíscalcular();a secas.
5. Polimorfismo y despacho dinámico
Acá se junta todo lo anterior. Una variable declarada como Vehiculo puede apuntar a cualquier objeto que sea un Vehiculo, incluidos los de sus subclases:
Vehiculo v = new Auto("Toyota");
v.arrancar(); // Imprime: "Auto Toyota arrancando con botón."
Fijate lo que pasó: la variable dice Vehiculo, pero se ejecutó el código de Auto. Eso es polimorfismo, y el mecanismo se llama despacho dinámico.
Para qué sirve esto de verdad
El valor del polimorfismo no está en una llamada suelta, está en poder escribir código que no sabe con qué subclase está trabajando y no le importa:
public class Taller {
// Este método no conoce Auto, Moto ni Camión. Y no necesita conocerlos.
public void revisar(Vehiculo[] flota) {
for (Vehiculo v : flota) {
v.arrancar(); // cada objeto ejecuta SU propia versión
v.frenar();
}
}
}
Vehiculo[] flota = {
new Auto("Toyota"),
new Moto("Honda"),
new Camion("Scania")
};
new Taller().revisar(flota);
Mañana agregás una clase Bicicleta extends Vehiculo y Taller la maneja sin que toques una sola línea suya. Ese es el premio: código nuevo que se integra sin modificar el que ya funcionaba.
La alternativa sin polimorfismo es esta cadena, que crece para siempre y hay que tocar cada vez:
// El código que el polimorfismo te ahorra escribir
if (v instanceof Auto) {
((Auto) v).arrancarAuto();
} else if (v instanceof Moto) {
((Moto) v).arrancarMoto();
} else if (v instanceof Camion) {
...
}
Casteo y instanceof
Con una referencia de tipo Vehiculo solo podés llamar a lo que Vehiculo declara. Si necesitás algo específico de la subclase, tenés que hacer un casteo, y hacerlo con red:
Vehiculo v = new Auto("Toyota");
// v.abrirBaul(); // ERROR: Vehiculo no declara abrirBaul()
if (v instanceof Auto auto) { // pattern matching, desde Java 16
auto.abrirBaul(); // 'auto' ya viene casteado y listo
}
Sin la comprobación, un casteo a un tipo equivocado revienta en ejecución con ClassCastException. Y si te encontrás casteando seguido, tomalo como una señal: probablemente el método que necesitás debería estar declarado en la superclase.
6. Cuándo NO heredar
La herencia es la relación más fuerte que existe entre dos clases: la subclase queda atada a los detalles internos del padre para siempre. Cada cambio en la superclase puede romper subclases que nadie tocó.
Por eso la regla de la industria es preferir composición antes que herencia:
// Herencia forzada: ¿un Auto ES UN Motor? No.
public class Auto extends Motor { ... }
// Composición: un Auto TIENE UN motor. Esto sí.
public class Auto {
private final Motor motor;
public Auto(Motor motor) {
this.motor = motor;
}
public void arrancar() {
motor.encender(); // delega en el motor
}
}
La composición te deja cambiar el motor sin tocar el auto, y probar el auto con un motor de mentira. La herencia no te deja ninguna de las dos cosas.
Cuando una clase no debe ser extendida, decilo con final y que el compilador lo haga cumplir:
public final class Coordenada { ... } // nadie puede heredar de acá
public class Cuenta {
public final void acreditar(double m) { ... } // este método no se sobrescribe
}
7. Todo hereda de Object
Aunque no escribas extends, toda clase en Java hereda de Object. De ahí vienen métodos que ya usaste sin darte cuenta:
public class Auto extends Vehiculo {
@Override
public String toString() {
return "Auto{marca='" + marca + "'}";
}
}
Auto a = new Auto("Toyota");
System.out.println(a); // Java llama a toString() solo
Sin sobrescribir toString(), System.out.println(a) imprime algo como Auto@1b6d3586: el nombre de la clase y un código hash. Inútil para depurar. Sobrescribirlo cuesta dos líneas y te devuelve horas.
Object también trae equals() y hashCode(), que tienen reglas propias y bastante trampa. Los vas a ver en profundidad en la lección de iteradores y ordenamiento.
8. Errores frecuentes
| Error | Qué pasa | Cómo se arregla |
|---|---|---|
| Sobrescribir cambiando los parámetros | Java lo toma como un método nuevo. El original sigue heredado y tu código no hace nada de lo que esperás. | Poner @Override siempre: convierte el bug en error de compilación. |
Constructor de subclase sin super(...) cuando el padre no tiene constructor vacío | Error de compilación poco claro sobre un constructor que nunca escribiste. | Llamar explícitamente a super(argumentos) en la primera línea. |
| Llamar a un método sobrescribible desde el constructor del padre | Corre la versión de la subclase antes de que sus campos estén inicializados: valores en null o 0 sin explicación. | Que los constructores llamen solo a métodos private o final. |
Castear sin comprobar con instanceof | ClassCastException en ejecución. | Usar if (v instanceof Auto auto), o repensar por qué necesitás el casteo. |
| Heredar para reutilizar código, sin que exista un “es un” | Jerarquías rígidas donde la subclase hereda métodos sin sentido. | Componer: tener el objeto como atributo y delegarle. |
| Confundir sobrecarga con sobrescritura | Se espera polimorfismo y se obtiene una selección estática hecha por el compilador. | Sobrecarga: misma clase, firmas distintas. Sobrescritura: subclase, misma firma. |
9. Ejercicio práctico guiado
Desafío: jerarquía de empleados
- Creá una superclase
EmpleadoconnombreysueldoBase(ambos protegidos o privados con getters), un constructor que valide que el sueldo no sea negativo, y un métodocalcularSalario()que devuelva el sueldo base. - Creá
Gerente extends Empleado, que agregue unbonoy sobrescribacalcularSalario()para sumarlo. - Creá
Vendedor extends Empleado, conventasDelMesy una comisión del 8 %. - Sobrescribí
toString()en las tres. - En el
main, armá unEmpleado[]con objetos de los tres tipos, recorrelo una sola vez y mostrá el salario de cada uno. El bucle no debe usarinstanceofni castear.
Ver solución sugerida
public class Empleado {
private final String nombre;
private final double sueldoBase;
public Empleado(String nombre, double sueldoBase) {
if (nombre == null || nombre.isBlank()) {
System.out.println("Nombre inválido, se usó \"Sin nombre\" por defecto.");
nombre = "Sin nombre";
}
if (sueldoBase < 0) {
System.out.println("Sueldo base inválido, se usó 0 por defecto.");
sueldoBase = 0;
}
this.nombre = nombre;
this.sueldoBase = sueldoBase;
}
public String getNombre() { return nombre; }
public double getSueldoBase() { return sueldoBase; }
public double calcularSalario() {
return sueldoBase;
}
@Override
public String toString() {
return getClass().getSimpleName() + " " + nombre;
}
}
public class Gerente extends Empleado {
private final double bono;
public Gerente(String nombre, double sueldoBase, double bono) {
super(nombre, sueldoBase); // primera sentencia, obligatorio
if (bono < 0) {
System.out.println("Bono inválido, se usó 0 por defecto.");
bono = 0;
}
this.bono = bono;
}
@Override
public double calcularSalario() {
return super.calcularSalario() + bono; // extiende, no reemplaza
}
}
public class Vendedor extends Empleado {
private static final double COMISION = 0.08;
private final double ventasDelMes;
public Vendedor(String nombre, double sueldoBase, double ventasDelMes) {
super(nombre, sueldoBase);
if (ventasDelMes < 0) {
System.out.println("Ventas inválidas, se usó 0 por defecto.");
ventasDelMes = 0;
}
this.ventasDelMes = ventasDelMes;
}
@Override
public double calcularSalario() {
return super.calcularSalario() + ventasDelMes * COMISION;
}
}
public class MainNomina {
public static void main(String[] args) {
Empleado[] nomina = {
new Empleado("Ana Torres", 800000),
new Gerente("Luis Paz", 1500000, 400000),
new Vendedor("Sofía Ríos", 700000, 2500000)
};
double total = 0;
// Un solo bucle, sin instanceof y sin casteos:
for (Empleado e : nomina) {
double salario = e.calcularSalario();
total += salario;
System.out.printf("%-22s $ %,.2f%n", e, salario);
}
System.out.printf("%-22s $ %,.2f%n", "TOTAL", total);
}
}
Lo que hay que mirar acá es el bucle. No pregunta de qué tipo es cada empleado, y sin embargo cada uno calcula su salario a su manera. Si mañana agregás Pasante extends Empleado, ese bucle sigue funcionando sin una sola modificación. Eso es polimorfismo haciendo su trabajo.
Fijate también en super.calcularSalario(): Gerente y Vendedor no repiten la lógica del sueldo base, la reutilizan y le suman lo suyo.
Para llevarte
extendssolo se justifica si la frase “un X es un Y” es verdadera. Si no, componé.- La cadena de constructores sube con
super(...)y se ejecuta bajando: el padre siempre queda inicializado antes que el hijo. - Escribí
@Overridesiempre: transforma un método fantasma silencioso en un error de compilación. - Sobrecarga = misma clase, firmas distintas, la decide el compilador. Sobrescritura = subclase, misma firma, la decide la JVM.
- La variable define qué podés llamar; el objeto define qué se ejecuta. Ahí está todo el polimorfismo.
- El beneficio real es escribir código que funciona con subclases que todavía no existen.
- Castear seguido es un síntoma de que el diseño de la jerarquía está pidiendo un método en la superclase.
- Preferí composición antes que herencia, y marcá con
finallo que no debe extenderse.