Al cerrar la lección anterior quedó una grieta abierta en BiblioTech: Material sigue siendo una clase instanciable. Nada impide escribir new Material("Algo", "REF-1", true) y obtener un objeto que no es un libro, ni una revista, ni un DVD; un objeto sin autor, sin número y sin duración, que responde "Material" cuando le preguntas su tipo. Es un objeto que no debería existir, y su existencia es un accidente del diseño, no una decisión.

La clase abstracta cierra esa puerta. Es una clase que declara explícitamente que está incompleta: sirve como base común —con campos, constructor y código compartido— pero no se puede instanciar, y puede obligar a sus subclases a implementar los métodos que solo ellas pueden decidir. Donde la interfaz dice "esto es lo que hay que saber hacer", la clase abstracta dice "esto es lo que ya está hecho, y esto es lo que te toca a ti". Al terminar esta lección, Material será abstracta, calcularMulta estará escrito una sola vez para todo el sistema mediante el patrón Template Method, y sabrás elegir con criterio entre interfaz, clase abstracta o —lo más frecuente en el diseño profesional— las dos a la vez.

Contenido

  1. abstract en clases: qué significa y qué impide
  2. Métodos abstractos: la obligación que se hereda
  3. Una clase abstracta CON estado: campos, constructor y métodos concretos
  4. ¿Para qué sirve el constructor de una clase que no se instancia?
  5. La subclase incompleta también debe ser abstracta
  6. Interfaz frente a clase abstracta: la tabla completa
  7. Regla práctica de decisión
  8. El patrón Template Method
  9. Combinar ambas: AbstractXxx
  10. BiblioTech: Material se vuelve abstracta
  11. Refactor de Libro, Revista y Dvd
  12. Errores Comunes y Consejos
  13. Ejercicios

  1. abstract en clases: qué significa y qué impide

El modificador abstract aplicado a una clase declara que esa clase es conceptualmente incompleta: representa una idea general de la que no existen ejemplares concretos.

public abstract class Material {
    // ... exactamente lo mismo que antes ...
}

Con esa sola palabra, esto deja de compilar:

Material m = new Material("Algo", "REF-1", true);
error: Material is abstract; cannot be instantiated

Es importante entender qué se prohíbe y qué no:

Operación ¿Permitida en una clase abstracta?
new Material(...) No. Error de compilación
Material m = new Libro(...) Sí. Es un tipo válido para declarar
Material[] catalogo = new Material[10] Sí. El array guarda referencias, no instancias
Tener campos, incluso private
Tener constructor , y es fundamental (apartado 4)
Tener métodos concretos con cuerpo
Tener métodos abstract sin cuerpo Sí, y es lo característico
Tener métodos static y final
Extender otra clase
Implementar interfaces Sí, y es muy habitual (apartado 9)
No tener ningún método abstracto Sí, es legal (aunque poco común)

Ese último punto sorprende: una clase puede ser abstract sin tener un solo método abstracto. Es perfectamente válido y a veces útil: la marcas como abstracta simplemente para declarar que instanciarla no tiene sentido, aunque técnicamente esté completa.

La ganancia inmediata es de modelado. "Material" es una abstracción: en la biblioteca de Nexus Software hay libros, revistas y DVDs, pero no hay "materiales" a secas. El código pasa a decir la verdad sobre el dominio, y el compilador la hace cumplir.

  1. Métodos abstractos: la obligación que se hereda

Un método abstracto es un método declarado sin cuerpo, terminado en punto y coma, que la clase promete que existirá pero se niega a implementar:

public abstract class Material {

    /** Cada soporte declara su etiqueta legible. */
    public abstract String getTipo();

    /** Cada soporte declara su plazo de prestamo en dias. */
    public abstract int getDiasPrestamo();

    /** Cada soporte declara su tarifa diaria de multa. */
    public abstract double getTarifaDiaria();
}

Las reglas son estrictas y merece la pena tenerlas todas juntas:

  • Un método abstracto solo puede existir dentro de una clase abstracta (o de una interfaz). Si pones un método abstracto en una clase normal, el error es missing method body, or declare abstract.
  • Toda subclase concreta debe implementarlos todos. Si falta uno, no compila.
  • abstract es incompatible con private, static y final. Con private porque la subclase no lo vería; con static porque los métodos estáticos no son polimórficos (03-06); con final porque final prohíbe exactamente lo que abstract exige.

La diferencia con el diseño anterior es de garantías. Compara las dos versiones de getDiasPrestamo:

// ANTES (modulo 3): implementacion de relleno
public int getDiasPrestamo() { return DIAS_PRESTAMO; }   // 15 dias "por si acaso"

// AHORA: obligacion
public abstract int getDiasPrestamo();

Con la primera, si mañana añades AudioLibro extends Material y olvidas su plazo, el programa compila y aplica silenciosamente 15 días. El error no se detecta hasta que un empleado se queja de una multa mal calculada. Con la segunda, no compila: el error llega en el segundo cero, con el nombre del método y el número de línea.

Esta es la diferencia entre un valor por defecto y un contrato. El valor por defecto oculta el olvido; el contrato lo denuncia.

  1. Una clase abstracta CON estado: campos, constructor y métodos concretos

Aquí está la diferencia esencial con una interfaz: una clase abstracta puede guardar datos. Y por tanto puede escribir código compartido de verdad, no solo código que se apoya en llamadas al contrato.

public abstract class Material {

    // 1. Constantes compartidas
    public static final double MULTA_MAXIMA = 20.0;
    public static final int    UMBRAL_LEVE  = 7;

    // 2. Estado de instancia: IMPOSIBLE en una interfaz
    private final String titulo;
    private final String referencia;
    private boolean      disponible;

    // 3. Constructor: IMPOSIBLE en una interfaz
    protected Material(String titulo, String referencia, boolean disponible) {
        this.titulo     = (titulo == null || titulo.isBlank()) ? "Sin titulo" : titulo.trim();
        this.referencia = (referencia == null || referencia.isBlank())
                          ? "000-0000000000" : referencia.trim();
        this.disponible = disponible;
    }

    // 4. Metodos concretos que operan sobre ese estado
    public String  getTitulo()      { return titulo; }
    public String  getReferencia()  { return referencia; }
    public boolean estaDisponible() { return disponible; }

    public boolean prestar() {
        if (!disponible) { return false; }
        disponible = false;
        return true;
    }

    // 5. Metodos abstractos que las subclases deben rellenar
    public abstract String getTipo();
    public abstract int    getDiasPrestamo();
    public abstract double getTarifaDiaria();
}

Las cinco secciones numeradas resumen el reparto: la clase abstracta aporta lo que es común (2, 3, 4), y delega lo que varía (5). Ninguna interfaz puede ofrecer los puntos 2 y 3.

Es la respuesta directa a una pregunta que quizá te hiciste en 04-01: si un método default puede tener cuerpo, ¿para qué necesito una clase abstracta? Porque prestar() necesita el campo disponible. Un default no puede tenerlo: tendría que llamar a estaDisponible() y a un hipotético setDisponible(), ampliando el contrato con un mutador que rompe el encapsulamiento que tanto cuidaste en 03-07. La clase abstracta guarda el campo, lo mantiene private y expone solo las operaciones de negocio.

  1. ¿Para qué sirve el constructor de una clase abstracta?

Es la duda clásica: si new Material(...) está prohibido, ¿qué sentido tiene ese constructor?

La respuesta está en la cadena de inicialización que estudiaste en 03-04: el constructor de una subclase siempre invoca primero al de su superclase, explícitamente con super(...) o implícitamente. Ese constructor sí se ejecuta, simplemente nunca por sí solo.

public class Libro extends Material {
    public Libro(String titulo, String autor, String isbn, int anio) {
        super(titulo, isbn, true);        // ejecuta el constructor de Material
        this.autor = autor;
        this.anioPublicacion = anio;
    }
}
flowchart TD
    A["new Libro(...)"] --> B["se reserva memoria para el objeto completo"]
    B --> C["constructor de Libro: super(titulo, isbn, true)"]
    C --> D["constructor de Object"]
    D --> E["cuerpo del constructor de Material: valida e inicializa titulo, referencia, disponible"]
    E --> F["cuerpo del constructor de Libro: inicializa autor y anioPublicacion"]
    F --> G["objeto Libro completamente construido"]

Así que el constructor de una clase abstracta tiene tres funciones muy concretas:

  1. Inicializar el estado común que la subclase no debe tocar (los campos son private).
  2. Centralizar la validación. La comprobación de título y referencia se escribe una vez y la aplican Libro, Revista y Dvd, sin posibilidad de saltársela.
  3. Garantizar las invariantes de la parte común desde el primer instante de vida del objeto.

Y hay una decisión de diseño en la firma: declararlo protected en lugar de public. En una clase abstracta, public en el constructor es engañoso —sugiere que alguien puede llamarlo desde fuera, y no puede—, mientras que protected dice exactamente la verdad: este constructor existe para las subclases. Es el convenio que sigue el propio JDK.

  1. La subclase incompleta también debe ser abstracta

¿Qué pasa si una subclase implementa solo una parte de los métodos abstractos? Que sigue siendo incompleta, y Java te obliga a decirlo:

/** Base comun de los soportes que se consultan en sala y no salen del edificio. */
public abstract class MaterialDeConsulta extends Material {

    protected MaterialDeConsulta(String titulo, String referencia) {
        super(titulo, referencia, true);
    }

    /** Todos los materiales de consulta comparten plazo y tarifa... */
    @Override public int    getDiasPrestamo() { return 1; }
    @Override public double getTarifaDiaria() { return 1.0; }

    // ... pero getTipo() sigue sin implementar: la clase sigue siendo abstracta
}

Si le quitaras el abstract a MaterialDeConsulta, el compilador diría:

error: MaterialDeConsulta is not abstract and does not override
       abstract method getTipo() in Material

La obligación se hereda hacia abajo hasta que alguien la cumple. Esto permite jerarquías de varios niveles donde cada nivel resuelve lo que sabe y deja lo demás pendiente:

classDiagram
    class Material {
        <<abstract>>
        +getTipo()* String
        +getDiasPrestamo()* int
        +getTarifaDiaria()* double
    }
    class MaterialDeConsulta {
        <<abstract>>
        +getDiasPrestamo() int
        +getTarifaDiaria() double
    }
    class Atlas {
        +getTipo() String
    }
    Material <|-- MaterialDeConsulta
    MaterialDeConsulta <|-- Atlas

Atlas es la primera clase concreta de la rama: implementa lo único que quedaba pendiente y ya se puede instanciar.

  1. Interfaz frente a clase abstracta: la tabla completa

Esta es la tabla que se anunció en 04-01. Estúdiala entera: cubre las seis dimensiones que realmente deciden.

Dimensión Interfaz Clase abstracta
Estado de instancia Imposible. Solo public static final Campos de cualquier tipo y visibilidad
Constructor No tiene Sí, y se ejecuta vía super(...)
Herencia múltiple Una clase implementa varias Una clase extiende una sola
Visibilidad de miembros Todo public (salvo private de Java 9, invisible fuera) public, protected, package, private
Métodos con cuerpo Sí: default, static, private Sí, sin restricciones
Bloques de inicialización No Sí, estáticos y de instancia
Miembros protected No Sí: el mecanismo natural para las subclases
Evolución de la API Añadir un abstracto rompe; añadir un default no Añadir un método concreto no rompe; uno abstracto sí
Relación que expresa "puede hacer" (capacidad) "es un" (identidad)
Acoplamiento que impone Mínimo: no consume la herencia Alto: consume la única herencia disponible
Ejemplo del JDK Comparable, Runnable, List AbstractList, InputStream, Number

Tres filas merecen comentario adicional:

Evolución de la API. Es asimétrica y suele confundirse. En una interfaz, añadir un método abstracto rompe a todos los implementadores, pero un default no rompe a nadie. En una clase abstracta, añadir un método concreto es seguro (todas las subclases lo heredan), pero añadir uno abstracto rompe a todas las subclases concretas. Cada una tiene su vía segura de crecer.

Acoplamiento. Es el argumento decisivo y a menudo el olvidado. Si obligas a tus usuarios a extender tu clase abstracta, les gastas su única herencia y no podrán extender nada más. Una interfaz no cuesta nada en ese sentido. Por eso la recomendación profesional es: el tipo público es la interfaz; la clase abstracta es una ayuda opcional.

Miembros protected. Una clase abstracta puede ofrecer a sus subclases herramientas que el resto del mundo no ve. Una interfaz no: todo lo que declara es público. Cuando necesitas ese "canal privado" hacia las subclases, la clase abstracta es la única opción.

  1. Regla práctica de decisión

flowchart TD
    A["Necesito un tipo comun"] --> B{"Hace falta estado compartido o un constructor?"}
    B -- "No" --> C{"Lo van a firmar clases sin parentesco?"}
    B -- "Si" --> D["Clase abstracta"]
    C -- "Si" --> E["Interfaz"]
    C -- "No" --> F{"Necesito codigo compartido?"}
    F -- "No" --> E
    F -- "Si" --> G["Interfaz mas clase abstracta base"]
    D --> H{"Tambien quiero que lo firmen otros?"}
    H -- "Si" --> G
    H -- "No" --> D

Y en tres frases memorizables:

  • Interfaz cuando defines qué se puede hacer y quieres que lo firmen clases que no comparten familia. Es la opción por defecto: empieza siempre por aquí.
  • Clase abstracta cuando compartes estado, constructor y código real entre clases que sí forman una familia genuina.
  • Ambas cuando quieres las dos cosas: la interfaz como tipo público y la clase abstracta como implementación base opcional. Es lo que hace el JDK y lo que harás en BiblioTech.

  1. El patrón Template Method

Ahora llega uno de los usos más potentes de las clases abstractas. Observa este método, que ya existe en tu Material desde el módulo 3:

public double calcularMulta(int diasTranscurridos) {
    return Math.min(calcularDiasRetraso(diasTranscurridos) * getTarifaDiaria(),
                    MULTA_MAXIMA);
}

Lo interesante es su estructura: define el esqueleto invariable del algoritmo —calcular el retraso, multiplicarlo por la tarifa, aplicar el tope— pero delega en getTarifaDiaria(), que cada subclase implementa a su manera. El esqueleto se escribe una vez; los pasos variables, tantas veces como soportes haya.

Eso es exactamente el patrón Template Method: un método concreto de la clase base define la secuencia de pasos, y los pasos que varían son métodos abstractos que las subclases rellenan.

Y hay un detalle clave que hace el patrón sólido: el método plantilla se declara final.

/**
 * PLANTILLA: la secuencia del calculo es politica de la empresa
 * y ninguna subclase puede alterarla.
 */
public final double calcularMulta(int diasTranscurridos) {
    int retraso = calcularDiasRetraso(diasTranscurridos);   // paso fijo
    double bruta = retraso * getTarifaDiaria();             // PASO VARIABLE
    return Math.min(bruta, MULTA_MAXIMA);                   // paso fijo (tope)
}

¿Por qué final? Porque el tope de 20 € y la fórmula son política de Nexus Software, no una decisión de cada soporte. Sin final, un Dvd podría sobrescribir calcularMulta y saltarse el tope. Con final, el compilador lo impide: las subclases solo pueden influir a través de los huecos que la plantilla les ofrece.

flowchart TD
    A["calcularMulta(dias) FINAL en Material"] --> B["paso fijo: calcularDiasRetraso(dias)"]
    B --> C["PASO VARIABLE: getTarifaDiaria()"]
    C --> D["paso fijo: aplicar MULTA_MAXIMA"]
    D --> E["resultado"]
    C -.-> F["Libro devuelve 0.25"]
    C -.-> G["Revista devuelve 0.10"]
    C -.-> H["Dvd devuelve 0.50"]

Repartir así las responsabilidades tiene un nombre en la literatura: el principio de Hollywood — "no nos llames, nosotros te llamamos". La subclase no controla el flujo; se limita a rellenar los huecos que la clase base le reserva.

Este patrón se llama así formalmente porque es uno de los patrones de diseño clásicos. Aquí lo has descubierto de forma natural, escribiendo la clase que hacía falta; en la lección 12-02 lo verás formalizado junto a Strategy, Factory, Observer y los demás, con su ficha completa y sus alternativas.

  1. Combinar ambas: AbstractXxx

Casi nunca eliges entre interfaz y clase abstracta: usas las dos en capas.

  • La interfaz es el tipo público. Lo que declaran las variables, los parámetros y los retornos.
  • La clase abstracta implementa la interfaz y ofrece una base parcial con el trabajo repetitivo ya resuelto.
  • Las clases concretas extienden la base y solo escriben lo suyo.

Y quien no quiera la base, implementa la interfaz directamente. La ayuda es opcional, y ahí está toda la gracia.

El JDK está construido así, y el convenio de nombres lo delata:

Interfaz (tipo público) Base parcial (ayuda opcional) Clase concreta
List AbstractList ArrayList, LinkedList
Map AbstractMap HashMap, TreeMap
Set AbstractSet HashSet

ArrayList es un AbstractList y es una List. Pero puedes escribir tu propia List sin tocar AbstractList. Todas estas clases llegan en el módulo 5; lo que importa hoy es reconocer el patrón estructural.

En BiblioTech el reparto queda exactamente igual:

classDiagram
    class Prestable {
        <<interface>>
        +prestar() boolean
        +devolver() boolean
        +estaDisponible() boolean
        +getDiasPrestamo() int
    }
    class Notificable {
        <<interface>>
        +getCanalAviso() String
        +generarAviso(int) String
    }
    class Material {
        <<abstract>>
        -titulo String
        -referencia String
        -disponible boolean
        +calcularMulta(int)$ double
        +getTipo()* String
    }
    class SalaReuniones
    Prestable <|.. Material
    Notificable <|.. Material
    Prestable <|.. SalaReuniones
    Material <|-- Libro
    Material <|-- Revista
    Material <|-- Dvd

SalaReuniones implementa Prestable sin pasar por Material: no quiere la base, y no la necesita. Es la prueba de que la ayuda es opcional.

  1. BiblioTech: Material se vuelve abstracta

Este es el estado final de la clase tras aplicar todo lo aprendido.

package com.nexussoftware.bibliotech.dominio;

/**
 * Base abstracta de todo material del catalogo de Nexus Software.
 *
 * <p>Define el estado comun (titulo, referencia, disponibilidad), las reglas
 * de negocio compartidas y la plantilla del calculo de multas. Cada soporte
 * concreto aporta su tipo, su plazo y su tarifa.</p>
 */
public abstract class Material implements Prestable, Notificable {

    public static final double MULTA_MAXIMA = 20.0;
    public static final int    UMBRAL_LEVE  = 7;

    private static int materialesCreados = 0;

    private final String titulo;
    private final String referencia;
    private boolean      disponible;

    /** Constructor protected: existe para las subclases, no para el exterior. */
    protected Material(String titulo, String referencia, boolean disponible) {
        this.titulo     = (titulo == null || titulo.isBlank()) ? "Sin titulo" : titulo.trim();
        this.referencia = (referencia == null || referencia.isBlank())
                          ? "000-0000000000" : referencia.trim();
        this.disponible = disponible;
        materialesCreados++;
    }

    // ---------- Lo que CADA soporte debe declarar ----------

    /** @return etiqueta legible del soporte: "Libro", "Revista", "DVD". */
    public abstract String getTipo();

    /** @return dias de plazo de prestamo de este soporte. */
    @Override public abstract int getDiasPrestamo();

    /** @return euros de multa por dia de retraso de este soporte. */
    public abstract double getTarifaDiaria();

    // ---------- Estado comun (implementado una sola vez) ----------

    public String  getTitulo()      { return titulo; }
    public String  getReferencia()  { return referencia; }
    @Override public boolean estaDisponible() { return disponible; }

    public static int getMaterialesCreados() { return materialesCreados; }

    // ---------- Reglas de negocio: PLANTILLAS final ----------

    /** Dias de retraso sobre el plazo del soporte. Nunca negativo. */
    public final int calcularDiasRetraso(int diasTranscurridos) {
        return Math.max(0, diasTranscurridos - getDiasPrestamo());
    }

    /**
     * TEMPLATE METHOD. El esqueleto del calculo es politica de empresa
     * y por eso es final; el paso variable es getTarifaDiaria().
     */
    public final double calcularMulta(int diasTranscurridos) {
        int    retraso = calcularDiasRetraso(diasTranscurridos);
        double bruta   = retraso * getTarifaDiaria();
        return Math.min(bruta, MULTA_MAXIMA);
    }

    /** Clasificacion del retraso. En 04-07 dejara de devolver String. */
    public final String clasificarGravedad(int diasTranscurridos) {
        int retraso = calcularDiasRetraso(diasTranscurridos);
        if (retraso == 0)           { return "SIN RETRASO"; }
        if (retraso <= UMBRAL_LEVE) { return "LEVE"; }
        return "GRAVE";
    }

    // ---------- Operaciones de negocio (contrato Prestable) ----------

    @Override
    public boolean prestar() {
        if (!disponible) {
            System.out.println("AVISO: '" + titulo + "' ya estaba prestado.");
            return false;
        }
        disponible = false;
        return true;
    }

    @Override
    public boolean devolver() {
        if (disponible) {
            System.out.println("AVISO: '" + titulo + "' ya estaba disponible.");
            return false;
        }
        disponible = true;
        return true;
    }

    // ---------- Contrato Notificable ----------

    @Override public String getCanalAviso() { return "correo"; }

    public int getDiasPreaviso() { return 2; }

    @Override
    public String generarAviso(int diasTranscurridos) {
        return String.format("Aviso por %s con %d dias de preaviso: '%s' acumula %.2f EUR.",
                             getCanalAviso(), getDiasPreaviso(), titulo,
                             calcularMulta(diasTranscurridos));
    }

    // ---------- Representacion e identidad ----------

    public String describir() {
        return getTipo() + " \"" + titulo + "\" (ref. " + referencia + ")";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) { return true; }
        if (o == null || getClass() != o.getClass()) { return false; }
        Material otro = (Material) o;
        return referencia.equals(otro.referencia);
    }

    @Override
    public int hashCode() { return referencia.hashCode(); }

    @Override
    public String toString() {
        return getTipo() + "[ref=" + referencia + ", titulo=" + titulo
             + ", disponible=" + disponible + "]";
    }
}

Cuatro cambios que merecen atención:

  1. abstract class: new Material(...) ya no compila.
  2. Constructor protected: dice la verdad sobre quién puede llamarlo.
  3. getTipo(), getDiasPrestamo() y getTarifaDiaria() son abstractos: desaparecen las constantes de relleno DIAS_PRESTAMO = 15 y TARIFA_DIARIA = 0.25 de Material. Cada soporte declara las suyas y nadie puede olvidarlas.
  4. calcularDiasRetraso, calcularMulta y clasificarGravedad son final: son la política de la empresa, no negociable por subclase.

Y observa describir(): ahora llama a getTipo(), un método abstracto. Un método concreto de la clase base invocando a un método que aún no existe. Funciona gracias al despacho dinámico de 03-06: en tiempo de ejecución, this es siempre un objeto concreto, y su getTipo() está implementado.

  1. Refactor de Libro, Revista y Dvd

Ahora cada soporte declara sus tres decisiones, y ninguna se puede omitir.

package com.nexussoftware.bibliotech.dominio;

/** Libro tecnico del catalogo. Plazo largo y tarifa estandar. */
public class Libro extends Material {

    public static final int    DIAS_PRESTAMO_LIBRO = 15;
    public static final double TARIFA_DIARIA_LIBRO = 0.25;

    private final String autor;
    private final int    anioPublicacion;

    public Libro(String titulo, String autor, String isbn,
                 int anioPublicacion, boolean disponible) {
        super(titulo, isbn, disponible);
        this.autor = (autor == null || autor.isBlank()) ? "Desconocido" : autor.trim();
        this.anioPublicacion = (anioPublicacion < 1450 || anioPublicacion > 2100)
                               ? 0 : anioPublicacion;
    }

    public Libro(String titulo, String autor, String isbn, int anioPublicacion) {
        this(titulo, autor, isbn, anioPublicacion, true);
    }

    public String getAutor()           { return autor; }
    public int    getAnioPublicacion() { return anioPublicacion; }
    public String getIsbn()            { return getReferencia(); }

    // --- Las tres decisiones OBLIGATORIAS ---
    @Override public String getTipo()         { return "Libro"; }
    @Override public int    getDiasPrestamo() { return DIAS_PRESTAMO_LIBRO; }
    @Override public double getTarifaDiaria() { return TARIFA_DIARIA_LIBRO; }

    @Override
    public String describir() {
        return super.describir() + " - " + autor + ", " + anioPublicacion;
    }
}
package com.nexussoftware.bibliotech.dominio;

/** Revista tecnica. Circula mucho: plazo corto y tarifa baja. */
public class Revista extends Material {

    public static final int    DIAS_PRESTAMO_REVISTA = 7;
    public static final double TARIFA_DIARIA_REVISTA = 0.10;

    private final int    numero;
    private final String periodicidad;

    public Revista(String titulo, String referencia, int numero, String periodicidad) {
        super(titulo, referencia, true);
        this.numero       = Math.max(0, numero);
        this.periodicidad = (periodicidad == null || periodicidad.isBlank())
                            ? "Desconocida" : periodicidad.trim();
    }

    public int    getNumero()       { return numero; }
    public String getPeriodicidad() { return periodicidad; }

    @Override public String getTipo()         { return "Revista"; }
    @Override public int    getDiasPrestamo() { return DIAS_PRESTAMO_REVISTA; }
    @Override public double getTarifaDiaria() { return TARIFA_DIARIA_REVISTA; }

    @Override public String getCanalAviso()   { return "chat"; }
    @Override public int    getDiasPreaviso() { return 1; }

    @Override
    public String describir() {
        return super.describir() + " - n." + numero + " (" + periodicidad + ")";
    }
}
package com.nexussoftware.bibliotech.dominio;

/** DVD de formacion. Pocas copias: plazo minimo y tarifa doble. */
public class Dvd extends Material {

    public static final int    DIAS_PRESTAMO_DVD = 3;
    public static final double TARIFA_DIARIA_DVD = 0.50;

    private final int duracionMinutos;

    public Dvd(String titulo, String referencia, int duracionMinutos) {
        super(titulo, referencia, true);
        this.duracionMinutos = Math.max(0, duracionMinutos);
    }

    public int getDuracionMinutos() { return duracionMinutos; }

    @Override public String getTipo()         { return "DVD"; }
    @Override public int    getDiasPrestamo() { return DIAS_PRESTAMO_DVD; }
    @Override public double getTarifaDiaria() { return TARIFA_DIARIA_DVD; }

    @Override public String getCanalAviso()   { return "telefono"; }
    @Override public int    getDiasPreaviso() { return 1; }

    @Override
    public String describir() {
        return super.describir() + " - " + duracionMinutos + " min";
    }
}

Prueba del resultado:

Material[] catalogo = {
    new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018),
    new Revista("Java Magazine", "REV-2024-03", 42, "Mensual"),
    new Dvd("Refactorizacion en vivo", "DVD-0007", 95)
};

System.out.printf("%-10s %-6s %-8s %-10s %s%n",
                  "TIPO", "PLAZO", "TARIFA", "MULTA@20d", "GRAVEDAD");
for (Material m : catalogo) {
    System.out.printf("%-10s %-6d %-8.2f %-10.2f %s%n",
                      m.getTipo(), m.getDiasPrestamo(), m.getTarifaDiaria(),
                      m.calcularMulta(20), m.clasificarGravedad(20));
}

// Material generico = new Material("X", "REF", true);   // NO COMPILA
TIPO       PLAZO  TARIFA   MULTA@20d  GRAVEDAD
Libro      15     0,25     1,25       LEVE
Revista    7      0,10     1,30       GRAVE
DVD        3      0,50     8,50       GRAVE

Un único calcularMulta, escrito una sola vez, produciendo tres resultados distintos y correctos. Y la línea comentada es la mejora principal: el objeto sin sentido ya no se puede crear.

Errores Comunes y Consejos

Poner un método abstracto en una clase no abstracta. El error es missing method body, or declare abstract. Si un método no puede implementarse en la clase base, la clase base es abstracta. No hay término medio.

Intentar new sobre la clase abstracta. Material m = new Material(...) no compila. Lo que sí es legal, y confunde a mucha gente, es Material m = new Libro(...): la clase abstracta es un tipo perfectamente válido para declarar.

Declarar el constructor public en una clase abstracta. Compila, pero miente: sugiere un acceso que no existe. Usa protected.

Combinar abstract con final, static o private. Las tres combinaciones son contradictorias y el compilador las rechaza. abstract significa "hay que sobrescribirlo"; final significa "no se puede", static significa "no es polimórfico" y private significa "no se ve".

Olvidar que la subclase parcial debe seguir siendo abstracta. Si implementas dos de tres métodos abstractos, la clase resultante sigue incompleta.

Elegir clase abstracta por costumbre. Es la trampa de diseño más cara del módulo. Al obligar a extends, gastas la única herencia de tus usuarios. Empieza siempre por la interfaz y añade la base abstracta solo si hay estado o código real que compartir.

Llamar a un método abstracto (o sobrescribible) desde el constructor de la base. Es un error sutil y grave: cuando se ejecuta el constructor de Material, los campos de Libro aún no están inicializados. Si Material llamara en su constructor a un describir() que usa autor, obtendrías null. Regla: desde un constructor, invoca solo métodos private, static o final.

Consejo: final en el método plantilla. Es lo que convierte una jerarquía en un Template Method de verdad. Si el esqueleto no es final, cualquier subclase puede saltarse la política.

Consejo: documenta el contrato de cada método abstracto. El Javadoc de un método abstracto es el único sitio donde puedes decir a quien lo implemente qué se espera de él: qué debe devolver, qué rangos son válidos, si puede devolver null. Es diseño por contrato (03-08) aplicado al punto exacto donde más falta hace.

Ejercicios

Ejercicio 1: AudioLibro

Añade a BiblioTech un soporte AudioLibro extends Material con un campo propio duracionHoras (double) y un método getNarrador(). Plazo: 10 días. Tarifa: 0,15 €/día. Canal de aviso: "correo" (el heredado). Comprueba que el compilador te obliga a implementar los tres métodos abstractos e integra el nuevo soporte en el array catalogo sin tocar el bucle que lo recorre.

Ejercicio 2: Template Method para el recibo

Crea una clase abstracta InformeMaterial en el paquete presentacion con:

  • Un método public final String generar(Material m, int diasTranscurridos) que devuelva cabecera() + cuerpo(m, dias) + pie().
  • cabecera() y pie() como métodos concretos con una implementación por defecto.
  • cuerpo(Material, int) como método abstracto.

Crea dos subclases: InformeBreve (una línea con título y multa) e InformeDetallado (título, tipo, plazo, retraso, multa y gravedad, una por línea). Comprueba que el esqueleto se escribe una sola vez.

Ejercicio 3: elegir interfaz o clase abstracta

Para cada caso, decide si usarías interfaz, clase abstracta o ambas, y justifica en una frase:

  1. Todo objeto de BiblioTech que se pueda exportar a un fichero de texto.
  2. La base común de tres tipos de empleado (Interno, Externo, Becario) que comparten nombre, identificador y el límite de préstamos, pero con distinto máximo.
  3. La capacidad de comparar dos materiales para ordenarlos.
  4. Un catálogo de préstamos con distintas implementaciones futuras: en memoria, en fichero y en base de datos.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.dominio;

/** Audiolibro del catalogo. Plazo intermedio y tarifa reducida. */
public class AudioLibro extends Material {

    public static final int    DIAS_PRESTAMO_AUDIO = 10;
    public static final double TARIFA_DIARIA_AUDIO = 0.15;

    private final String narrador;
    private final double duracionHoras;

    public AudioLibro(String titulo, String referencia,
                      String narrador, double duracionHoras) {
        super(titulo, referencia, true);          // validacion centralizada en Material
        this.narrador      = (narrador == null || narrador.isBlank())
                             ? "Desconocido" : narrador.trim();
        this.duracionHoras = Math.max(0.0, duracionHoras);
    }

    public String getNarrador()      { return narrador; }
    public double getDuracionHoras() { return duracionHoras; }

    // Los tres metodos que el compilador EXIGE:
    @Override public String getTipo()         { return "Audiolibro"; }
    @Override public int    getDiasPrestamo() { return DIAS_PRESTAMO_AUDIO; }
    @Override public double getTarifaDiaria() { return TARIFA_DIARIA_AUDIO; }

    @Override
    public String describir() {
        return super.describir() + " - narrado por " + narrador
             + ", " + duracionHoras + " h";
    }
}

Si omites, por ejemplo, getTarifaDiaria():

error: AudioLibro is not abstract and does not override
       abstract method getTarifaDiaria() in Material

Y la integración:

Material[] catalogo = {
    new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018),
    new Revista("Java Magazine", "REV-2024-03", 42, "Mensual"),
    new Dvd("Refactorizacion en vivo", "DVD-0007", 95),
    new AudioLibro("Patrones de Diseno", "AUD-0002", "Nuria Vidal", 12.5)   // unica linea nueva
};
TIPO        PLAZO  TARIFA   MULTA@20d  GRAVEDAD
Libro       15     0,25     1,25       LEVE
Revista     7      0,10     1,30       GRAVE
DVD         3      0,50     8,50       GRAVE
Audiolibro  10     0,15     1,50       GRAVE

El bucle no se ha tocado. Un soporte nuevo cuesta una clase y una línea de datos: cero modificaciones en la lógica existente. Y lo nuevo respecto al módulo 3 es que el compilador ha garantizado que declaraste plazo, tarifa y tipo. Con la versión antigua de Material, olvidar la tarifa habría cobrado silenciosamente 0,25 €/día.

Solución 2

package com.nexussoftware.bibliotech.presentacion;

import com.nexussoftware.bibliotech.dominio.Material;

/** Base de todos los informes de material. Define el esqueleto; no el contenido. */
public abstract class InformeMaterial {

    private static final String LINEA = "----------------------------------------";

    /**
     * TEMPLATE METHOD: la estructura del informe es fija (final)
     * y el unico paso variable es el cuerpo.
     */
    public final String generar(Material m, int diasTranscurridos) {
        return cabecera() + cuerpo(m, diasTranscurridos) + pie();
    }

    /** Paso concreto: valido por defecto, sobrescribible si hace falta. */
    protected String cabecera() {
        return LINEA + System.lineSeparator()
             + "   BIBLIOTECH - NEXUS SOFTWARE" + System.lineSeparator()
             + LINEA + System.lineSeparator();
    }

    /** Paso concreto. */
    protected String pie() {
        return LINEA + System.lineSeparator();
    }

    /** PASO VARIABLE: cada informe decide que muestra. */
    protected abstract String cuerpo(Material m, int diasTranscurridos);
}
package com.nexussoftware.bibliotech.presentacion;

import com.nexussoftware.bibliotech.dominio.Material;

/** Una sola linea: para listados largos. */
public class InformeBreve extends InformeMaterial {

    @Override
    protected String cuerpo(Material m, int diasTranscurridos) {
        return String.format("  %-28s %6.2f EUR%n",
                             m.getTitulo(), m.calcularMulta(diasTranscurridos));
    }
}
package com.nexussoftware.bibliotech.presentacion;

import com.nexussoftware.bibliotech.dominio.Material;

/** Informe completo: para incidencias y reclamaciones. */
public class InformeDetallado extends InformeMaterial {

    @Override
    protected String cuerpo(Material m, int diasTranscurridos) {
        StringBuilder sb = new StringBuilder();
        sb.append(String.format("  %-14s %s%n",       "Titulo:",   m.getTitulo()));
        sb.append(String.format("  %-14s %s%n",       "Tipo:",     m.getTipo()));
        sb.append(String.format("  %-14s %d dias%n",  "Plazo:",    m.getDiasPrestamo()));
        sb.append(String.format("  %-14s %d dias%n",  "Retraso:",  m.calcularDiasRetraso(diasTranscurridos)));
        sb.append(String.format("  %-14s %.2f EUR%n", "Multa:",    m.calcularMulta(diasTranscurridos)));
        sb.append(String.format("  %-14s %s%n",       "Gravedad:", m.clasificarGravedad(diasTranscurridos)));
        return sb.toString();
    }
}
Material dvd = new Dvd("Refactorizacion en vivo", "DVD-0007", 95);

InformeMaterial breve     = new InformeBreve();
InformeMaterial detallado = new InformeDetallado();

System.out.print(breve.generar(dvd, 20));
System.out.print(detallado.generar(dvd, 20));
----------------------------------------
   BIBLIOTECH - NEXUS SOFTWARE
----------------------------------------
  Refactorizacion en vivo        8,50 EUR
----------------------------------------
----------------------------------------
   BIBLIOTECH - NEXUS SOFTWARE
----------------------------------------
  Titulo:        Refactorizacion en vivo
  Tipo:          DVD
  Plazo:         3 dias
  Retraso:       17 dias
  Multa:         8,50 EUR
  Gravedad:      GRAVE
----------------------------------------

Cuatro detalles de diseño: generar es final (la estructura no se negocia); cuerpo es protected porque es un punto de extensión para las subclases y no parte de la API pública; cabecera y pie son concretos y protected, así que una futura subclase puede sobrescribirlos sin verse obligada; y la variable se declara InformeMaterial, no InformeBreve, así que cambiar de informe cuesta una palabra.

Solución 3

Caso Decisión Justificación
1. Exportable a texto Interfaz (Exportable con String aLinea()) Es una capacidad que deben firmar clases sin parentesco (Material, Empleado, Prestamo, SalaReuniones), y no requiere estado compartido
2. Base de tres tipos de empleado Clase abstracta (Empleado con getMaxPrestamos() abstracto) Comparten campos (nombre, identificador, prestamosAcumulados), constructor con validación y lógica real; forman una familia genuina y solo varía un valor
3. Comparar materiales Interfaz, y además una del JDK: Comparable<Material> Es puro comportamiento sin estado, ya existe en la biblioteca estándar y no debe consumir la herencia. Se aplica en 04-04 y 05-09
4. Catálogo con varias implementaciones Ambas: interfaz CatalogoPrestamos más base AbstractCatalogoPrestamos La interfaz es el tipo público del que dependen las reglas de negocio (inversión de dependencias, 04-01); la base abstracta ahorra a las tres implementaciones el código repetido de validación y formateo, sin obligar a nadie a usarla

El caso 4 es el arquetipo profesional y el que verás una y otra vez a partir del módulo 5: la interfaz define el tipo, la clase abstracta ofrece ayuda opcional, y las clases concretas eligen.

Conclusión

Has completado el par que sostiene el diseño en Java. Sabes que abstract en una clase declara que está conceptualmente incompleta y que impide new, sin impedir que la uses como tipo de variables, parámetros y arrays. Sabes que un método abstracto es una obligación que se transmite hacia abajo hasta que una subclase concreta la cumple, y —lo más importante— por qué esa obligación vale más que cualquier implementación de relleno: convierte un olvido silencioso, que aparece semanas después como una multa mal calculada, en un error de compilación instantáneo.

Entiendes lo que una clase abstracta ofrece y una interfaz jamás podrá ofrecer: campos de instancia, constructor y miembros protected. Sabes para qué sirve el constructor de una clase que no se instancia —se ejecuta siempre vía super(...), centraliza la validación común y garantiza las invariantes de la parte compartida— y por qué se declara protected. Tienes la tabla comparativa completa entre interfaz y clase abstracta, incluida la fila que más pesa en la práctica: la clase abstracta consume la única herencia de quien la usa, y por eso el camino profesional empieza siempre por la interfaz.

Y has descubierto el Template Method escribiéndolo, no leyéndolo: un método final que fija el esqueleto del algoritmo y delega los pasos variables en métodos abstractos. Es lo que permite que la fórmula de la multa —retraso por tarifa, con tope de 20 €— exista una sola vez en todo BiblioTech y produzca resultados distintos y correctos para un libro, una revista, un DVD y cualquier soporte que añadas mañana. Ese patrón se formaliza en 12-02. Por último, sabes reconocer el patrón AbstractXxx del JDK, donde interfaz y clase abstracta no compiten, sino que se reparten el trabajo: la primera es el tipo público, la segunda una ayuda opcional.

BiblioTech queda con una jerarquía honesta: Material es abstracta y firma Prestable y Notificable; Libro, Revista, Dvd declaran obligatoriamente su tipo, su plazo y su tarifa; y SalaReuniones demuestra que se puede firmar el contrato sin entrar en la familia.

Pero fíjate en una pieza que arrastras desde 03-07 y que sigue siendo poco satisfactoria: las incidencias de un Prestamo son un String[] de textos sueltos. Una incidencia real tiene un día, un motivo y una gravedad, y merece ser un tipo propio. Ahora bien, ¿una clase de primer nivel, en su propio fichero, para algo que solo tiene sentido dentro de un préstamo? En la lección 04-03, Clases Internas, verás los cuatro tipos de clase anidada que ofrece Java, aprenderás cuándo cada uno es la elección correcta —incluida la que causa fugas de memoria reales en producción— y convertirás esas cadenas sueltas en una Prestamo.Incidencia con nombre, estructura y encapsulamiento propios.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados