La herencia es la herramienta más usada de más en la programación orientada a objetos. Esta lección enseña cómo funciona, por qué conviene usarla poco, y la combinación —interfaces selladas, records y switch— con la que hoy se modelan en Java los conjuntos cerrados de casos.
Heredar
Una clase puede extender otra y quedarse con todo lo suyo. El caso en el que esto tiene sentido es cuando varias cosas comparten de verdad una naturaleza y solo se diferencian en un detalle:
abstract class Empleado {
protected final String nombre;
Empleado(String nombre) { this.nombre = nombre; }
abstract double sueldoMensual(); // sin cuerpo: cada hijo lo resuelve
String ficha() { return nombre + ": " + String.format("%.2f", sueldoMensual()) + " €"; }
}
class Asalariado extends Empleado {
private final double anual;
Asalariado(String nombre, double anual) { super(nombre); this.anual = anual; }
@Override double sueldoMensual() { return anual / 12; }
}
class PorHoras extends Empleado {
private final double tarifa; private final int horas;
PorHoras(String nombre, double tarifa, int horas) {
super(nombre); this.tarifa = tarifa; this.horas = horas;
}
@Override double sueldoMensual() { return tarifa * horas; }
@Override String ficha() { return super.ficha() + " (" + horas + " h)"; }
}
salidaAna: 3000.00 € Luis: 2700.00 € (120 h) el tipo real sigue ahí: Asalariado PorHoras
Cuatro palabras que conviene fijar:
abstracten la clase: no se puede crear unEmpleadoa secas, solo uno de sus hijos. En el método: no tiene cuerpo, y cada hijo está obligado a escribirlo.super(nombre)llama al constructor del padre, y tiene que ser lo primero del constructor.super.ficha()llama a la versión del padre y le añade algo. Es lo que hacePorHoras.@Overrideno es decorativo: le pide al compilador que compruebe que de verdad estás sustituyendo un método que existe. Si te equivocas al escribir el nombre, te lo dice en vez de crear en silencio un método nuevo que nadie llama.
Lo que hace que esto valga la pena es la última parte de la salida: recorriendo un array de Empleado, cada objeto ejecuta su versión de sueldoMensual. El tipo real no se pierde. A eso se le llama polimorfismo y es, en la práctica, de lo poco que hay que recordar de la herencia.
Interfaces
Una interfaz no dice qué es algo, sino qué sabe hacer. Una clase extiende una sola clase, pero puede implementar todas las interfaces que quiera.
interface Describible {
String descripcion(); // abstracto
default String resumen() { // con cuerpo: no rompe a quien ya la implementa
String d = descripcion();
return d.length() <= 20 ? d : d.substring(0, 17) + "...";
}
static Describible de(String t) { return () -> t; } // una interfaz de un solo método
}
salidadescripcion: una descripción bastante larga para el resumen resumen : una descripción b...
Los métodos default existen por un motivo muy concreto: permiten añadir un método a una interfaz sin romper las cien clases que ya la implementaban. Antes de que existieran, ampliar una interfaz pública era imposible en la práctica.
Y fíjate en ese () -> t: cuando una interfaz tiene un solo método abstracto, se puede implementar con una lambda, sin escribir ninguna clase. Es la base de todo lo de la lección 9.
Sellar: la combinación que cambia cómo se modela
La herencia clásica tiene un problema: cualquiera puede extender tu clase, así que nunca sabes cuántos hijos hay. Una interfaz sellada declara la lista completa y cierra la puerta:
sealed interface Figura permits Circulo, Rect, Triangulo {}
record Circulo(double r) implements Figura {}
record Rect(double an, double al) implements Figura {}
record Triangulo(double b, double h) implements Figura {}
double area(Figura f) {
return switch (f) { // sin default: el compilador sabe que están todas
case Circulo c -> Math.PI * c.r() * c.r();
case Rect r -> r.an() * r.al();
case Triangulo t -> t.b() * t.h() / 2;
};
}
salidaCirculo[r=1.0] -> 3.14 Rect[an=2.0, al=3.0] -> 6.00 Triangulo[b=4.0, h=5.0] -> 10.00
Ese switch no tiene default, y compila. El compilador sabe que solo hay tres figuras posibles y comprueba que las tres están cubiertas.
Rombo a la lista de permits y no toqué el switch. Esto es lo que dijo el compilador:
javacL6c.java:8: error: the switch expression does not cover all possible input values
return switch (f) {
^
1 error
Es decir: al añadir un caso nuevo al modelo, el programa deja de compilar hasta que lo trates en todos los sitios. Con un default en el switch, ese rombo habría pasado silenciosamente por la rama equivocada y el error aparecería meses después, en producción.
Y la puerta está cerrada de verdad:
javacL6d.java:3: error: class is not allowed to extend sealed class: Figura (as it is not listed in its 'permits' clause)
record Intrusa(double x) implements Figura {}
^
1 error
Cuándo heredar y cuándo no
La herencia es la herramienta que más se usa de más. El criterio clásico es preguntarse si la frase «un X es un Y» es cierta sin forzarla: un asalariado es un empleado, sí. Un Coche no es un Motor, aunque tenga uno; ahí lo que toca es un campo, no un extends.
El problema de fondo es que heredar es el acoplamiento más fuerte que existe: el hijo depende de los detalles internos del padre, y un cambio en el padre rompe a todos los hijos a la vez. Por eso el consejo que lleva décadas repitiéndose es componer antes que heredar, y por eso Go directamente no tiene herencia.
| Quieres… | Usa |
|---|---|
| Compartir implementación entre cosas que son lo mismo | una clase abstract |
| Decir qué sabe hacer algo | una interface |
| Un conjunto cerrado de alternativas | sealed interface + records + switch |
| Reutilizar funcionalidad sin parentesco | un campo, no extends |
| Que el compilador avise al añadir un caso | sellar y no poner default |
Todo el código de esta lección se compiló y se ejecutó con Java 25 LTS en un contenedor limpio antes de publicarla; las salidas y los errores del compilador están copiados de esa ejecución.