Programación en Java

Herencia, polimorfismo e interfaces en Java

Principiante8 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

El polimorfismo permite que un mismo punto de llamada funcione con muchos tipos concretos, y Java ofrece tres herramientas para expresarlo: la herencia de clases, las clases abstractas y las interfaces. Esta guía explica cómo resuelve realmente una llamada el despacho dinámico, cómo diseñar interfaces con métodos default y static, cómo las clases sealed y el pattern matching cierran una jerarquía de forma segura, y por qué la composición suele ser la mejor forma de reutilizar código.

Lo que aprenderás

  • Explicar qué ocurre en tiempo de ejecución cuando se llama a un método sobrescrito a través de una referencia de un supertipo
  • Decidir entre una clase abstracta y una interfaz para un diseño concreto
  • Usar métodos default, static y private en interfaces sin romper a quienes las implementan
  • Modelar un conjunto cerrado de variantes con interfaces sealed y un switch exhaustivo
  • Reconocer cuándo es preferible la composición a la herencia

La herencia es la característica más asociada a la programación orientada a objetos, y también la que más se usa mal. Su verdadero beneficio no es reutilizar código —eso lo hace mejor la composición—, sino el polimorfismo: escribir código contra un tipo general y dejar que cada objeto concreto aporte su propio comportamiento. Esta guía cubre los mecanismos que Java ofrece para ello (extensión de clases, clases abstractas, interfaces), las incorporaciones modernas que hacen más seguras las jerarquías (tipos sealed y pattern matching) y los criterios para decidir cuándo usar cada uno. Los ejemplos están pensados para Java 21.

Extender una clase

class Manager extends Employee convierte a Manager en una subclase que hereda todos los campos y métodos accesibles de Employee y puede añadir o sustituir comportamiento. Un Manager es un Employee, así que cualquier código que espere un Employee acepta un Manager. Java solo permite una superclase; toda clase sin una cláusula extends explícita extiende java.lang.Object.

public class Employee {
    private final String name;
    private final long monthlySalaryCents;

    public Employee(String name, long monthlySalaryCents) {
        this.name = name;
        this.monthlySalaryCents = monthlySalaryCents;
    }

    public long annualPayCents() {
        return monthlySalaryCents * 12;
    }

    public String name() { return name; }
}

public class Manager extends Employee {
    private final long bonusCents;

    public Manager(String name, long monthlySalaryCents, long bonusCents) {
        super(name, monthlySalaryCents);       // must run the superclass constructor first
        this.bonusCents = bonusCents;
    }

    @Override
    public long annualPayCents() {
        return super.annualPayCents() + bonusCents;   // reuse, then extend
    }
}

Hay tres detalles de este código que importan. El constructor de la subclase llama a super(...) como primera sentencia; si lo omites, el compilador inserta super(), que no compila cuando la superclase no tiene un constructor sin argumentos. La anotación @Override es opcional, pero debería ser obligatoria en tu código: convierte en error de compilación una errata en el nombre del método que, sin ella, crearía en silencio un método nuevo. Y los campos private se heredan en el sentido de que existen en el objeto de la subclase, pero no son accesibles desde su código: Manager llega al salario a través del método público, no del campo.

Qué significan aquí final y protected

Marcar una clase como final impide heredar de ella; marcar un método como final impide sobrescribirlo. Usa final en las clases que no estén diseñadas para extenderse —que son la mayoría—, porque una clase extensible tiene que garantizar que cada uno de sus métodos funciona cuando se sobrescribe cualquier otro. Un buen diseño o bien documenta exactamente cómo extender una clase, o bien lo prohíbe.

Los miembros protected son visibles para las subclases y para el mismo paquete. Es tentador hacer protected los campos para que las subclases los usen directamente, pero eso acopla la subclase a la representación interna de la superclase, de modo que un cambio posterior en el campo rompe todas las subclases. Si las subclases necesitan de verdad esos datos, prefiere campos private con métodos de acceso protected.

Despacho dinámico: cómo se resuelve la llamada

La regla esencial: el compilador comprueba la llamada según el tipo estático de la referencia; en tiempo de ejecución se elige la implementación según la clase real del objeto.

Employee e = new Manager("Ada", 800_000, 2_000_000);
long pay = e.annualPayCents();    // compiles because Employee has the method
                                  // runs Manager's override: 11,600,000

El tipo estático Employee decide qué métodos puedes llamar. El tipo dinámico Manager decide qué cuerpo se ejecuta. Esto es lo que permite que una List<Employee> contenga empleados, managers y contratistas y que la nómina total se calcule con un único bucle que nunca menciona ninguna subclase.

Hay dos cosas que no se despachan dinámicamente: los campos y los métodos static. Un acceso a campo como e.someField se resuelve según el tipo estático, y un método static llamado a través de una referencia también se resuelve según el tipo estático (el compilador incluso avisa cuando llamas a un método static a través de una instancia). Si te descubres queriendo «sobrescribir» un método static, lo que necesitas es un método de instancia.

La sobrescritura tiene reglas que el compilador hace cumplir. El método sobrescrito debe tener el mismo nombre y los mismos tipos de parámetros; su tipo de retorno puede ser un subtipo del original (retorno covariante); no puede ser menos accesible que el original, y no puede declarar excepciones comprobadas más amplias. Estas reglas existen para que cualquier llamador escrito contra la superclase siga funcionando cuando recibe una subclase.

Clases abstractas

Una clase abstracta no se puede instanciar y puede declarar métodos abstractos: firmas sin cuerpo que las subclases concretas deben implementar. Es la herramienta adecuada cuando varias implementaciones comparten estado y parte de un algoritmo, pero difieren en pasos concretos:

public abstract class Report {
    private final String title;

    protected Report(String title) { this.title = title; }

    public final String render() {                // template: fixed skeleton
        return header() + "\n" + body() + "\n" + footer();
    }

    protected String header() { return "== " + title + " =="; }
    protected String footer() { return "-- end --"; }
    protected abstract String body();             // each subclass supplies this
}

public class SalesReport extends Report {
    private final long totalCents;

    public SalesReport(long totalCents) {
        super("Sales");
        this.totalCents = totalCents;
    }

    @Override
    protected String body() { return "Total: " + totalCents / 100.0; }
}

Este es el patrón Template Method: la clase abstracta fija la secuencia y las subclases rellenan los huecos. Fíjate en el final de render(): el esqueleto en sí no se renegocia.

Interfaces

Una interfaz declara un contrato —un conjunto de firmas de métodos— sin estado de instancia. Una clase implementa tantas interfaces como quiera, y esa es la respuesta de Java a la herencia múltiple: puedes heredar tipos de muchas fuentes, pero implementación de una sola superclase.

public interface Shape {
    double area();

    default String describe() {                   // default method: has a body
        return getClass().getSimpleName() + " with area " + area();
    }

    static Shape unitSquare() {                   // static method on the interface
        return new Rectangle(1, 1);
    }
}

public record Rectangle(double width, double height) implements Shape {
    @Override public double area() { return width * height; }
}

public record Circle(double radius) implements Shape {
    @Override public double area() { return Math.PI * radius * radius; }
}

Los métodos de una interfaz son implícitamente public abstract; sus campos son implícitamente public static final, así que una interfaz no es lugar para guardar estado. Desde Java 8, una interfaz también puede incluir:

  • Métodos default: implementaciones que heredan todas las clases que la implementan, salvo que las sobrescriban. Existen para que una interfaz muy implementada pueda ganar un método sin romper las clases existentes; Collection.stream() e Iterable.forEach() se añadieron así.
  • Métodos static: métodos utilitarios o de factoría asociados a la interfaz.
  • Métodos private (Java 9): auxiliares compartidos entre métodos default, invisibles para quienes implementan la interfaz.

Si una clase hereda dos métodos default con la misma firma de dos interfaces, no compila hasta que la propia clase sobrescribe el método; dentro, puede elegir uno de ellos con InterfaceName.super.method(). Un método heredado de una superclase siempre prevalece sobre un método default de una interfaz, lo que mantiene estables las jerarquías de clases existentes cuando se añaden métodos default.

¿Interfaz o clase abstracta?

InterfazClase abstracta
Campos de instanciaNoSí
ConstructoresNoSí
Herencia múltipleSí, sin límiteNo, una sola superclase
Cuerpos de métodosSolo default, static y privateCualquiera
Niveles de accesoPublic (o auxiliares private)Cualquiera
ExpresaUna capacidad o un rolUna implementación parcial con estado compartido

Empieza con una interfaz. Si resulta que varias implementaciones comparten campos y código, añade una clase abstracta que implemente la interfaz y haz que esas implementaciones la extiendan; las demás pueden seguir implementando la interfaz directamente. Así se relacionan List y AbstractList en el JDK.

Una interfaz marcadora no tiene ningún método y solo sirve para etiquetar un tipo; Serializable es el ejemplo más conocido. Las anotaciones han sustituido la mayoría de los usos nuevos de esta idea, pero te la encontrarás en código existente.

Jerarquías sealed y pattern matching

Una interfaz tradicional es abierta: cualquiera puede implementarla, así que nunca se puede comprobar que el código que hace un switch sobre «todos los tipos de figura» los cubra todos. Java 17 finalizó las clases e interfaces sealed, que enumeran sus subtipos permitidos:

public sealed interface PaymentResult permits Approved, Declined, Pending {}
record Approved(String authorisationCode) implements PaymentResult {}
record Declined(String reason) implements PaymentResult {}
record Pending(java.time.Instant retryAfter) implements PaymentResult {}

Cada subtipo permitido tiene que ser a su vez final, sealed o explícitamente non-sealed, y los records son implícitamente final, así que esta jerarquía está cerrada. El pattern matching para switch de Java 21 (JEP 441) permite entonces al compilador comprobar la exhaustividad:

static String message(PaymentResult result) {
    return switch (result) {
        case Approved a -> "Approved, code " + a.authorisationCode();
        case Declined d -> "Declined: " + d.reason();
        case Pending p  -> "Pending until " + p.retryAfter();
        // no default needed: the compiler knows these are all the cases
    };
}

Añade un cuarto record a permits y todos esos switch dejarán de compilar hasta que traten el nuevo caso. Esa es precisamente la seguridad que una rama default habría echado por tierra. Los record patterns (case Approved(String code) ->) desestructuran directamente los componentes, y las guardas (case Declined d when d.reason().startsWith("fraud") ->) refinan un caso.

Los tipos sealed alejan algunos diseños del polimorfismo clásico. Si el comportamiento varía según el subtipo y encaja de forma natural en él, usa un método sobrescrito. Si el comportamiento pertenece al consumidor —serialización, renderizado, reglas de precios que cambian con más frecuencia que los tipos—, un switch sobre una jerarquía sealed mantiene esa lógica junta sin obligar a meter en el tipo las preocupaciones de cada módulo.

Las antiguas comprobaciones con instanceof seguidas de un cast también se han simplificado: if (obj instanceof Circle c) { ... c.radius() ... } vincula la variable de patrón en un solo paso (Java 16).

Composición antes que herencia

La herencia acopla una subclase a la implementación de su superclase, no solo a su interfaz. Un cambio en un método de la superclase puede romper una subclase que dependía de cómo ese método llamaba a otros: es el problema de la «clase base frágil» (fragile base class). La forma más limpia de reutilizar comportamiento es la composición: mantener una referencia al objeto que tiene ese comportamiento y delegar en él.

// Inheritance: brittle. If HashSet.addAll is later changed to call add,
// or stops calling it, this count silently becomes wrong.
class CountingSet<E> extends HashSet<E> {
    private int added;
    @Override public boolean add(E e) { added++; return super.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { added += c.size(); return super.addAll(c); }
}

// Composition: robust. Behaviour depends only on Set's public contract.
class CountingSet<E> implements Set<E> {
    private final Set<E> inner;
    private int added;
    CountingSet(Set<E> inner) { this.inner = inner; }
    @Override public boolean add(E e) { added++; return inner.add(e); }
    @Override public boolean addAll(Collection<? extends E> c) { added += c.size(); return inner.addAll(c); }
    // ... every other Set method forwards to inner
}

La versión compuesta requiere escribir más (una clase abstracta de reenvío elimina casi todo ese trabajo), pero los detalles internos de HashSet no pueden romperla, funciona con cualquier implementación de Set y se puede probar con un fake. Usa la herencia cuando exista una relación es-un genuina y la superclase esté diseñada para ello; usa la composición para las relaciones tiene-un y para reutilizar comportamiento.

Errores comunes

Llamar a métodos sobrescribibles desde un constructor. La versión sobrescrita de la subclase se ejecuta antes de que se inicialicen los campos de la subclase, así que lee valores por defecto (null, 0). Los constructores solo deberían llamar a métodos private o final.

Romper el contrato de la superclase. Una sobrescritura que lanza excepciones donde el padre no lo hacía, ignora un argumento que el padre respetaba o debilita una postcondición viola el principio de sustitución de Liskov: los llamadores escritos contra el padre dejan de funcionar. Si la subclase no puede cumplir el contrato, no es un subtipo.

Jerarquías profundas. Cuatro niveles de extends para compartir unos pocos métodos auxiliares son una trampa de mantenimiento. Aplánalas con composición e interfaces.

Olvidar @Override. Un nombre de método mal escrito (equal en lugar de equals) compila como un método completamente nuevo, y tu sobrescritura nunca se ejecuta.

Ramas default en switch sobre tipos sealed. Compilan, pero desactivan la comprobación de exhaustividad, que era el motivo de sellar la jerarquía.

Exponer campos protected. Congelan la representación de la superclase para todas las subclases que existan.

Confundir cadenas de instanceof con diseño. Un método que comprueba instanceof para cinco subtipos y ramifica suele delatar un método polimórfico que falta o, si los tipos son tuyos y forman un conjunto cerrado, un switch sobre una jerarquía sealed.

Puntos clave

  • Polimorfismo significa que el código escrito contra un supertipo funciona con cualquier subtipo; en tiempo de ejecución se elige la sobrescritura según la clase real del objeto, mientras que el compilador comprueba la llamada según el tipo declarado.
  • Los campos y los métodos static no se despachan dinámicamente; solo los métodos de instancia.
  • Las interfaces describen capacidades y admiten herencia múltiple de tipo; las clases abstractas comparten estado e implementaciones parciales. Empieza con una interfaz.
  • Los métodos default permiten que las interfaces evolucionen; una clase debe resolver explícitamente los conflictos entre métodos default.
  • Las interfaces sealed junto con el switch con pattern matching ofrecen un tratamiento comprobado por el compilador de un conjunto cerrado de variantes: prescinde del default.
  • Reutiliza comportamiento mediante composición y delegación; reserva la herencia para relaciones es-un genuinas con una superclase diseñada para extenderse.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Debo usar una clase abstracta o una interfaz?
Usa una interfaz para describir una capacidad que pueden compartir clases sin relación entre sí; una clase puede implementar muchas. Usa una clase abstracta cuando las implementaciones comparten estado o una implementación parcial que necesita campos, constructores o miembros no públicos. Si dudas, empieza por una interfaz: siempre puedes añadir más adelante una clase esqueleto abstracta a su lado.
¿Por qué Java no admite herencia múltiple de clases?
Dos superclases podrían aportar campos e implementaciones en conflicto sin una regla clara sobre cuál prevalece. En su lugar, Java permite que una clase implemente cualquier número de interfaces y, desde Java 8, las interfaces pueden incluir métodos default; los conflictos entre métodos default debe resolverlos explícitamente la clase que los implementa.
¿Qué diferencia hay entre sobrescritura (overriding) y sobrecarga (overloading)?
La sobrescritura sustituye un método de la superclase por una versión de la subclase con la misma firma, que se elige en tiempo de ejecución según la clase real del objeto. La sobrecarga declara en una misma clase varios métodos con el mismo nombre pero distintos parámetros, y es el compilador quien elige entre ellos, en tiempo de compilación, según los tipos de los argumentos.
¿Cuándo conviene usar una interfaz sealed?
Cuando el conjunto de implementaciones está fijado por diseño y otro código necesita tratar todos los casos: estados de un pedido, nodos de una expresión, tipos de resultado. Sellarla permite al compilador comprobar que un switch cubre todos los subtipos permitidos, así que añadir una variante más adelante produce errores de compilación justo donde falta tratarla.