Herencia, interfaces y sellado

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:

  • abstract en la clase: no se puede crear un Empleado a 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 hace PorHoras.
  • @Override no 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.

Y aquí está lo que hace que esto valga tanto. Añadí un 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.