Llegas al cierre del módulo con dos deudas pendientes que arrastras desde el módulo 3. La primera: clasificarGravedad devuelve las cadenas "LEVE" y "GRAVE", que todo el proyecto compara con equals. Nada impide escribir "grave" en minúsculas, "GRAVISIMO" o "LEBE", y el compilador no dirá una palabra; el fallo aparecerá en producción, en forma de una multa mal clasificada. La segunda: has escrito a mano equals, hashCode y toString en Material, en Prestamo.Incidencia y en cada clase de datos del proyecto —las mismas veinte líneas, tres veces, con el riesgo de equivocarte en cualquiera de ellas.

Java tiene una respuesta exacta para cada deuda. Las enumeraciones (enum) convierten un conjunto de valores fijos en un tipo propio: Gravedad.GRAVE no es una cadena, es un valor de un conjunto cerrado que el compilador conoce, con el que un error de escritura es imposible y sobre el que un switch puede comprobar que has cubierto todos los casos. Los registros (record, Java 16) generan automáticamente el constructor, los accesores, equals, hashCode y toString de una clase inmutable de datos, reduciendo cincuenta líneas a una. Al terminar esta lección, BiblioTech estará completo como proyecto de POO avanzada, y verás con claridad qué le sigue faltando para ser un sistema de verdad.

Contenido

  1. El problema: constantes como String o int
  2. Qué es un enum
  3. Métodos implícitos: values, valueOf, name, ordinal
  4. Comparación de enums: == frente a equals
  5. enum con campos, constructor y métodos
  6. enum con cuerpo por constante
  7. enum en switch y exhaustividad
  8. El singleton con enum
  9. Registros: qué es un record
  10. Qué genera un record automáticamente
  11. Constructor compacto y validación
  12. Constructores adicionales y métodos propios
  13. Qué NO puede hacer un record y cuándo no usarlo
  14. Records en BiblioTech: DTO y objetos de valor
  15. Tabla comparativa: clase, record y enum
  16. Cierre del módulo: estado de BiblioTech
  17. Errores Comunes y Consejos
  18. Ejercicios

  1. El problema: constantes como String o int

Este es el método que llevas arrastrando desde 03-07:

public final String clasificarGravedad(int diasTranscurridos) {
    int retraso = calcularDiasRetraso(diasTranscurridos);
    if (retraso == 0)           { return "SIN RETRASO"; }
    if (retraso <= UMBRAL_LEVE) { return "LEVE"; }
    return "GRAVE";
}

Y así se usa por todo el proyecto:

if (prestamo.clasificarGravedad().equals("GRAVE")) {
    escalarIncidencia(prestamo);
}

Enumeremos los problemas, porque son cinco y todos serios:

Problema Consecuencia
Sin seguridad de tipos equals("grave"), equals("GRABE") y equals("URGENTE") compilan sin protesta y devuelven false siempre
Sin exhaustividad Si añades un tercer nivel de gravedad, nada te avisa de los diez if que hay que actualizar
Sin autocompletado El IDE no puede sugerir los valores válidos: son cadenas cualesquiera
Sin comportamiento La cadena "GRAVE" no sabe cuál es su canal de aviso ni su umbral
Coste de comparación Comparar cadenas recorre caracteres; comparar referencias es una instrucción

La alternativa tradicional era peor: constantes int.

public static final int GRAVEDAD_SIN_RETRASO = 0;
public static final int GRAVEDAD_LEVE        = 1;
public static final int GRAVEDAD_GRAVE       = 2;

// ...y nada impide esto:
procesarGravedad(47);           // compila perfectamente
procesarGravedad(diasRetraso);  // se cuela un valor de otro dominio

Este antipatrón tiene nombre en la literatura: int enum pattern. Java 5 lo resolvió de una vez.

  1. Qué es un enum

Un enum es un tipo cuyos valores posibles son un conjunto fijo y conocido en tiempo de compilación. En su forma más simple:

package com.nexussoftware.bibliotech.dominio;

/** Grado de gravedad del retraso de un prestamo. */
public enum Gravedad {
    SIN_RETRASO,
    LEVE,
    GRAVE
}

Con esa declaración, Gravedad es un tipo tan real como String o Material:

Gravedad g = Gravedad.GRAVE;

// Gravedad g2 = "GRAVE";        // NO COMPILA: String no es Gravedad
// Gravedad g3 = Gravedad.GRAVE2;// NO COMPILA: no existe esa constante

Bajo el capó, un enum es una clase que hereda de java.lang.Enum y cuyas constantes son instancias public static final de esa clase, creadas por la JVM al cargar el tipo. Tres consecuencias:

  • No puedes instanciar un enum con new. Su constructor es implícitamente private.
  • Existe exactamente una instancia de cada constante en toda la JVM. Son singletons por construcción.
  • Es una clase de pleno derecho: puede tener campos, métodos, constructores e implementar interfaces (apartado 5).

Y clasificarGravedad mejora de inmediato:

public final Gravedad clasificarGravedad(int diasTranscurridos) {
    int retraso = calcularDiasRetraso(diasTranscurridos);
    if (retraso == 0)           { return Gravedad.SIN_RETRASO; }
    if (retraso <= UMBRAL_LEVE) { return Gravedad.LEVE; }
    return Gravedad.GRAVE;
}
if (prestamo.clasificarGravedad() == Gravedad.GRAVE) {   // == en vez de equals
    escalarIncidencia(prestamo);
}
// if (prestamo.clasificarGravedad() == Gravedad.URGENTE)  // NO COMPILA

El error de escritura ha pasado de ser un fallo silencioso en producción a un error de compilación.

  1. Métodos implícitos: values, valueOf, name, ordinal

Todo enum recibe gratis un conjunto de métodos, algunos heredados de java.lang.Enum y otros generados por el compilador.

Método Tipo Qué devuelve
values() static Un array con todas las constantes, en orden de declaración
valueOf(String) static La constante con ese nombre exacto (o IllegalArgumentException)
name() instancia El nombre exacto de la constante, como texto
ordinal() instancia Su posición (base 0) en la declaración
compareTo(E) instancia Comparación por ordinal
toString() instancia Por defecto igual que name(); sí se puede sobrescribir
// Recorrer todas las constantes
for (Gravedad g : Gravedad.values()) {
    System.out.printf("%d -> %s%n", g.ordinal(), g.name());
}
0 -> SIN_RETRASO
1 -> LEVE
2 -> GRAVE
// De texto a enum: util al leer configuracion o entrada del usuario
Gravedad leida = Gravedad.valueOf("GRAVE");
System.out.println(leida == Gravedad.GRAVE);     // true

// Gravedad mala = Gravedad.valueOf("grave");    // IllegalArgumentException en ejecucion

valueOf distingue mayúsculas y lanza excepción si no encuentra la constante. Como las excepciones son el módulo 6, mientras tanto conviene un método de conversión tolerante escrito a mano:

/** Convierte texto a Gravedad sin lanzar excepciones. Devuelve SIN_RETRASO si no encaja. */
public static Gravedad desdeTexto(String texto) {
    if (texto == null) { return SIN_RETRASO; }
    String limpio = texto.trim().toUpperCase().replace(' ', '_');
    for (Gravedad g : values()) {
        if (g.name().equals(limpio)) { return g; }
    }
    return SIN_RETRASO;
}

Y ahora la advertencia importante: no dependas de ordinal().

// MAL: la logica depende de la POSICION en la declaracion
if (gravedad.ordinal() >= 2) {
    escalarIncidencia();
}

Ese código funciona hoy. Pero si mañana alguien añade MUY_LEVE entre SIN_RETRASO y LEVE, todos los ordinales se desplazan y el >= 2 pasa a significar otra cosa sin que nada falle ni avise. Lo mismo vale para guardar el ordinal() en un fichero o una base de datos: reordenar las constantes corrompe los datos guardados.

La alternativa correcta es un campo explícito:

public enum Gravedad {
    SIN_RETRASO(0),
    LEVE(1),
    GRAVE(2);

    private final int nivel;                    // valor explicito y estable
    Gravedad(int nivel) { this.nivel = nivel; }
    public int getNivel() { return nivel; }
}
if (gravedad.getNivel() >= 2) { escalarIncidencia(); }   // estable ante reordenaciones

El ordinal() existe porque las colecciones especializadas del JDK (EnumMap, EnumSet) lo usan internamente. Tu código no debería tocarlo nunca.

  1. Comparación de enums: == frente a equals

Con los enums, == es la forma correcta y preferida. Es la única situación del curso en que esto es cierto para objetos.

Gravedad a = Gravedad.GRAVE;
Gravedad b = prestamo.clasificarGravedad();

if (a == b)        { }    // CORRECTO y preferido
if (a.equals(b))   { }    // funciona, pero innecesario

Cuatro razones, que recuperan lo que aprendiste en 01-05 y 03-09:

  1. Solo hay una instancia de cada constante. La igualdad de referencia y la igualdad lógica coinciden por construcción. Enum.equals es final y está implementado exactamente como this == other.
  2. == es seguro frente a null. a == null da false; a.equals(b) con a nulo lanza NullPointerException.
  3. == da seguridad de tipos. Comparar dos enums de tipos distintos con == no compila; con equals compila y devuelve false siempre, escondiendo el error.
  4. Es más rápido: una comparación de referencias frente a una llamada a método.
Gravedad g = Gravedad.LEVE;
Periodicidad p = Periodicidad.MENSUAL;

// if (g == p)            // NO COMPILA: tipos incomparables. Perfecto.
if (g.equals(p)) { }      // compila y siempre es false: bug silencioso

  1. enum con campos, constructor y métodos

Aquí es donde los enums de Java superan con creces a los de otros lenguajes. Una constante de enum puede llevar datos asociados.

Aplicado al proyecto: Libro, Revista y Dvd existen como clases porque cada una tiene su plazo y su tarifa. Pero si esa fuera toda la diferencia —solo datos, sin comportamiento propio ni campos exclusivos— la jerarquía entera sobraría.

package com.nexussoftware.bibliotech.dominio;

/**
 * Tipos de material del catalogo, con sus parametros de prestamo.
 *
 * <p>Cada constante lleva sus datos asociados: sustituye a una jerarquia
 * entera cuando lo unico que varia son valores, no comportamiento.</p>
 */
public enum TipoMaterial {

    // Cada constante invoca al constructor con SUS valores
    LIBRO  ("Libro",   15, 0.25, "correo"),
    REVISTA("Revista",  7, 0.10, "chat"),
    DVD    ("DVD",      3, 0.50, "telefono"),
    AUDIO  ("Audiolibro", 10, 0.15, "correo");   // el punto y coma es OBLIGATORIO

    private final String etiqueta;
    private final int    diasPrestamo;
    private final double tarifaDiaria;
    private final String canalAviso;

    /** El constructor de un enum es implicitamente private. */
    TipoMaterial(String etiqueta, int diasPrestamo,
                 double tarifaDiaria, String canalAviso) {
        this.etiqueta     = etiqueta;
        this.diasPrestamo = diasPrestamo;
        this.tarifaDiaria = tarifaDiaria;
        this.canalAviso   = canalAviso;
    }

    public String getEtiqueta()     { return etiqueta; }
    public int    getDiasPrestamo() { return diasPrestamo; }
    public double getTarifaDiaria() { return tarifaDiaria; }
    public String getCanalAviso()   { return canalAviso; }

    /** Metodo de negocio propio del tipo. */
    public double multaPara(int diasRetraso) {
        return Math.min(Math.max(0, diasRetraso) * tarifaDiaria, 20.0);
    }

    public boolean esPlazoCorto() { return diasPrestamo <= 7; }

    @Override
    public String toString() { return etiqueta; }
}

Puntos de sintaxis que hay que fijar:

  • Las constantes van primero, antes que cualquier campo o método.
  • El punto y coma tras la última constante es obligatorio si hay algo más en el cuerpo. Es el error de compilación más común con enums.
  • El constructor es implícitamente private y no puede ser otra cosa. Se invoca una sola vez por constante, al cargar la clase.
  • Los campos deben ser final. Técnicamente se permiten mutables, pero un enum mutable es un error de diseño: la constante es única y compartida por todo el programa.

Y lo que ahora se puede hacer con él:

System.out.printf("%-12s %-6s %-8s %-10s %s%n",
                  "TIPO", "PLAZO", "TARIFA", "CANAL", "MULTA@17d");

for (TipoMaterial t : TipoMaterial.values()) {
    System.out.printf("%-12s %-6d %-8.2f %-10s %.2f EUR%n",
                      t.getEtiqueta(), t.getDiasPrestamo(), t.getTarifaDiaria(),
                      t.getCanalAviso(), t.multaPara(17));
}
TIPO         PLAZO  TARIFA   CANAL      MULTA@17d
Libro        15     0,25     correo     4,25 EUR
Revista      7      0,10     chat       1,70 EUR
DVD          3      0,50     telefono   8,50 EUR
Audiolibro   10     0,15     correo     2,55 EUR

¿Sustituye esto a la jerarquía Material? No del todo, y esa es la lección de diseño:

Situación Elige
Los tipos solo difieren en valores (plazo, tarifa, canal) enum: cuatro líneas frente a cuatro clases
Los tipos tienen campos propios distintos Jerarquía de clases
Los tipos tienen comportamiento estructuralmente distinto Jerarquía de clases
Ambos Jerarquía con un enum como campo

En BiblioTech se cumple lo tercero: Libro tiene autor y anioPublicacion, Revista tiene numero y periodicidad, Dvd tiene duracionMinutos. La jerarquía se queda. Pero TipoMaterial sigue siendo útil como campo que centraliza los parámetros comunes, y lo aplicarás en el apartado 16.

  1. enum con cuerpo por constante

Una vuelta de tuerca más: cada constante puede tener su propia implementación de un método. La sintaxis es un cuerpo { ... } tras los argumentos del constructor.

package com.nexussoftware.bibliotech.dominio;

/** Gravedad del retraso, con la accion que corresponde a cada nivel. */
public enum Gravedad {

    SIN_RETRASO(0) {
        @Override
        public String accionRecomendada() {
            return "Ninguna. El prestamo esta en plazo.";
        }
        @Override
        public boolean requiereAviso() { return false; }
    },

    LEVE(1) {
        @Override
        public String accionRecomendada() {
            return "Enviar recordatorio por el canal habitual.";
        }
        @Override
        public boolean requiereAviso() { return true; }
    },

    GRAVE(2) {
        @Override
        public String accionRecomendada() {
            return "Escalar al responsable y bloquear nuevos prestamos.";
        }
        @Override
        public boolean requiereAviso() { return true; }
    };

    private final int nivel;

    Gravedad(int nivel) { this.nivel = nivel; }

    public int getNivel() { return nivel; }

    /** Cada constante DEBE implementarlo: es abstracto. */
    public abstract String accionRecomendada();

    /** Cada constante lo implementa; podria tener implementacion por defecto. */
    public abstract boolean requiereAviso();

    /** Metodo comun a todas las constantes. */
    public boolean esMasGraveQue(Gravedad otra) {
        return this.nivel > otra.nivel;
    }
}
for (Gravedad g : Gravedad.values()) {
    System.out.printf("%-12s nivel %d  aviso=%-5b  %s%n",
                      g, g.getNivel(), g.requiereAviso(), g.accionRecomendada());
}
System.out.println("GRAVE mas grave que LEVE: " + Gravedad.GRAVE.esMasGraveQue(Gravedad.LEVE));
SIN_RETRASO  nivel 0  aviso=false  Ninguna. El prestamo esta en plazo.
LEVE         nivel 1  aviso=true   Enviar recordatorio por el canal habitual.
GRAVE        nivel 2  aviso=true   Escalar al responsable y bloquear nuevos prestamos.
GRAVE mas grave que LEVE: true

¿Qué está pasando aquí realmente? Cada constante con cuerpo es una subclase anónima del enum (04-04). Puedes comprobarlo:

System.out.println(Gravedad.LEVE.getClass().getName());   // ...Gravedad$2
System.out.println(Gravedad.class.getName());             // ...Gravedad

La consecuencia práctica es enorme: el compilador obliga a implementar el método abstracto en cada constante. Si mañana añades MUY_GRAVE sin accionRecomendada(), el código no compila. Compara eso con un switch sobre cadenas, donde el caso nuevo simplemente cae en el default y nadie se entera.

¿Cuerpo por constante o switch? Usa cuerpo por constante cuando la lógica pertenece al valor y varía en cada uno. Usa campos y métodos comunes cuando la lógica es la misma con datos distintos. Y si el cuerpo por constante empieza a tener veinte líneas, el enum probablemente esté asumiendo responsabilidades ajenas.

  1. enum en switch y exhaustividad

Los enums y el switch están hechos el uno para el otro. Y hay un detalle de sintaxis que sorprende:

public String mensajePara(Gravedad gravedad) {
    return switch (gravedad) {
        case SIN_RETRASO -> "Todo en orden.";              // sin 'Gravedad.'
        case LEVE        -> "Recordatorio enviado.";
        case GRAVE       -> "Incidencia escalada.";
    };
}

Dentro del case no se cualifica la constante: se escribe LEVE, no Gravedad.LEVE. El compilador ya sabe de qué tipo es la expresión del switch, y cualificarla es de hecho un error de compilación.

Y ahora lo verdaderamente valioso, retomando el switch de expresión de 02-03: la exhaustividad.

// switch de EXPRESION sobre un enum: no hace falta 'default'
return switch (gravedad) {
    case SIN_RETRASO -> "Todo en orden.";
    case LEVE        -> "Recordatorio enviado.";
    case GRAVE       -> "Incidencia escalada.";
};

Como un switch de expresión debe producir siempre un valor, el compilador verifica que has cubierto todas las constantes. Si mañana añades MUY_GRAVE al enum:

error: the switch expression does not cover all possible input values

El compilador te lleva de la mano a cada lugar del proyecto que hay que actualizar. Eso es imposible con cadenas y es la razón número uno para usar enums en el dominio.

Una advertencia sobre el default:

// Con default: compila hoy y compilara manana... haciendo algo mal
return switch (gravedad) {
    case SIN_RETRASO -> "Todo en orden.";
    case LEVE        -> "Recordatorio enviado.";
    default          -> "Incidencia escalada.";   // MUY_GRAVE caeria aqui en silencio
};

Regla: en un switch de expresión sobre un enum, omite el default siempre que puedas. Es lo que te da la comprobación de exhaustividad, que es justamente lo que has venido a buscar.

En un switch clásico de sentencia (con case ... :) la exhaustividad no se comprueba, porque no hay obligación de producir un valor. Otro motivo para preferir la forma de flecha.

  1. El singleton con enum

Una aplicación breve pero conocida: como la JVM garantiza una única instancia de cada constante, un enum de un solo valor es la forma más simple y segura de implementar el patrón Singleton (formalizado en 12-02), inmune a la reflexión y a la serialización.

public enum RegistroBiblioTech {
    INSTANCIA;

    private int operaciones;

    public void registrar(String operacion) {
        operaciones++;
        System.out.println("[" + operaciones + "] " + operacion);
    }
}

// Uso:
RegistroBiblioTech.INSTANCIA.registrar("Prestamo PR-0001 creado");

  1. Registros: qué es un record

Cambiamos de herramienta. Mira esta clase, que solo existe para transportar tres datos:

public final class Ficha {

    private final String titulo;
    private final String autor;
    private final int    anio;

    public Ficha(String titulo, String autor, int anio) {
        this.titulo = titulo;
        this.autor  = autor;
        this.anio   = anio;
    }

    public String getTitulo() { return titulo; }
    public String getAutor()  { return autor;  }
    public int    getAnio()   { return anio;   }

    @Override
    public boolean equals(Object o) {
        if (this == o) { return true; }
        if (o == null || getClass() != o.getClass()) { return false; }
        Ficha otra = (Ficha) o;
        return anio == otra.anio
            && Objects.equals(titulo, otra.titulo)
            && Objects.equals(autor, otra.autor);
    }

    @Override
    public int hashCode() { return Objects.hash(titulo, autor, anio); }

    @Override
    public String toString() {
        return "Ficha[titulo=" + titulo + ", autor=" + autor + ", anio=" + anio + "]";
    }
}

Casi cuarenta líneas, y la única información real son tres nombres y tres tipos. Todo lo demás es mecánico, y por serlo es propenso a errores: olvidar un campo en equals, no actualizar hashCode al añadir uno, dejar un toString desfasado.

Un record (Java 16) expresa exactamente lo mismo:

public record Ficha(String titulo, String autor, int anio) { }

Una línea. Y es completamente equivalente, con los tres métodos correctamente implementados.

La declaración se lee así:

record NombreDelTipo(Tipo componente1, Tipo componente2, ...) { cuerpo opcional }

A los parámetros de la cabecera se les llama componentes. Un record es, por diseño, un portador transparente de datos inmutables: transparente porque sus componentes son exactamente su estado y se pueden leer todos.

  1. Qué genera un record automáticamente

De esa única línea, el compilador genera:

Elemento generado Detalle
Un campo private final por componente No accesible directamente ni desde el propio record salvo por su nombre
Constructor canónico Recibe todos los componentes en orden y los asigna
Un accesor por componente Se llama como el componente: titulo(), no getTitulo()
equals(Object) Compara todos los componentes; cumple el contrato de 03-09
hashCode() Derivado de todos los componentes; consistente con equals
toString() Ficha[titulo=Java Efectivo, autor=Joshua Bloch, anio=2018]
Ficha f1 = new Ficha("Java Efectivo", "Joshua Bloch", 2018);
Ficha f2 = new Ficha("Java Efectivo", "Joshua Bloch", 2018);

System.out.println(f1.titulo());            // Java Efectivo  (sin 'get')
System.out.println(f1);                     // Ficha[titulo=Java Efectivo, ...]
System.out.println(f1.equals(f2));          // true
System.out.println(f1.hashCode() == f2.hashCode());   // true
System.out.println(f1 == f2);               // false: son objetos distintos

Fíjate en el detalle de los accesores: se llaman titulo(), no getTitulo(). No es un capricho: es una señal deliberada del diseño del lenguaje de que un record no es un JavaBean, sino un valor. Si necesitas la convención getXxx porque un framework la exige (Hibernate, por ejemplo, módulo 11), puedes añadirla a mano, pero suele ser síntoma de que ahí querías una clase normal.

Y tres propiedades estructurales importantes:

  • Un record es implícitamente final. No se puede extender.
  • Sus componentes son inmutables. No hay setters ni forma de reasignarlos.
  • Hereda de java.lang.Record, no de Object directamente. Por eso un record no puede extender ninguna otra clase (apartado 13).

  1. Constructor compacto y validación

Un record sin validación aceptaría un título nulo o un año imposible. La solución es el constructor compacto, una forma abreviada exclusiva de los records:

public record Ficha(String titulo, String autor, int anio) {

    /**
     * Constructor COMPACTO: sin lista de parametros ni asignaciones.
     * Se ejecuta antes de asignar los campos, sobre los parametros.
     */
    public Ficha {
        if (titulo == null || titulo.isBlank()) {
            titulo = "Sin titulo";                 // se reasigna el PARAMETRO
        }
        if (autor == null || autor.isBlank()) {
            autor = "Desconocido";
        }
        if (anio < 1450 || anio > 2100) {
            anio = 0;
        }
        titulo = titulo.trim();
        autor  = autor.trim();
        // NO se escribe this.titulo = titulo; el compilador lo hace al final
    }
}
System.out.println(new Ficha("  Java Efectivo  ", null, 3000));
Ficha[titulo=Java Efectivo, autor=Desconocido, anio=0]

Tres reglas del constructor compacto:

  1. No lleva lista de parámetros: se escribe public Ficha {, no public Ficha(String titulo, ...) {.
  2. Dentro trabajas con los parámetros, no con los campos. titulo = "Sin titulo" cambia el parámetro; el compilador asigna los campos al final, automáticamente.
  3. No escribas this.titulo = titulo: es innecesario y confuso.

Esto es exactamente el saneamiento de datos que hacías a mano en los constructores del módulo 3, ahora en su sitio natural. En cuanto tengas excepciones (módulo 6), lo habitual será lanzar IllegalArgumentException en lugar de sustituir valores; el mecanismo del constructor compacto es el mismo.

  1. Constructores adicionales y métodos propios

El cuerpo de un record admite mucho más que el constructor compacto.

Constructores adicionales, que deben delegar en el canónico con this(...) (03-04):

public record Ficha(String titulo, String autor, int anio) {

    public Ficha {
        if (titulo == null || titulo.isBlank()) { titulo = "Sin titulo"; }
    }

    /** Ficha sin año conocido. */
    public Ficha(String titulo, String autor) {
        this(titulo, autor, 0);          // OBLIGATORIO delegar en el canonico
    }

    /** Ficha construida desde un material del catalogo. */
    public static Ficha desde(Libro libro) {
        return new Ficha(libro.getTitulo(), libro.getAutor(), libro.getAnioPublicacion());
    }
}

Métodos de instancia propios, que operan sobre los componentes:

public record Ficha(String titulo, String autor, int anio) {

    public boolean esClasico() {
        return anio > 0 && anio < 2000;
    }

    public String etiquetaCorta() {
        String corto = titulo.length() > 20 ? titulo.substring(0, 17) + "..." : titulo;
        return String.format("%-20s (%s)", corto, anio > 0 ? String.valueOf(anio) : "s.f.");
    }

    /** "Modificador" al estilo inmutable: devuelve una copia nueva. */
    public Ficha conAnio(int nuevoAnio) {
        return new Ficha(titulo, autor, nuevoAnio);
    }
}

Ese conAnio es el patrón estándar con tipos inmutables (03-07): no se modifica, se crea una copia con el cambio. Es lo que hace String con toUpperCase() y lo que harás con LocalDate en 10-05.

Constantes, métodos static y tipos anidados, todo permitido:

public record Ficha(String titulo, String autor, int anio) {
    public static final Ficha VACIA = new Ficha("Sin titulo", "Desconocido", 0);
    public static int comparar(Ficha a, Ficha b) { return a.titulo.compareTo(b.titulo); }
}

Implementar interfaces, sí; extender clases, no:

public record Ficha(String titulo, String autor, int anio)
        implements Comparable<Ficha> {

    @Override
    public int compareTo(Ficha otra) {
        return this.titulo.compareToIgnoreCase(otra.titulo);
    }
}

Y también se puede sobrescribir un accesor o un método generado, aunque conviene hacerlo con moderación:

public record Ficha(String titulo, String autor, int anio) {
    @Override
    public String toString() {
        return titulo + " - " + autor + (anio > 0 ? " (" + anio + ")" : "");
    }
}

  1. Qué NO puede hacer un record y cuándo no usarlo

No puede Por qué
Extender una clase Ya extiende java.lang.Record
Ser extendido Es implícitamente final
Declarar campos de instancia propios Su estado son exactamente sus componentes; es la garantía de transparencia
Tener componentes mutables Son final por construcción
Ser abstract No tiene sentido en un portador de datos

Ese tercer punto sorprende a mucha gente:

public record Ficha(String titulo, String autor, int anio) {
    // private int contadorAccesos;     // NO COMPILA: campo de instancia
    private static int fichasCreadas;   // SI COMPILA: los static si se permiten
}

La razón es el principio de diseño: el estado de un record es su lista de componentes, sin excepciones. Solo así equals, hashCode y toString generados pueden ser correctos por construcción.

Cuándo NO usar un record:

  • Cuando el objeto tiene identidad, no valor. Un Empleado con EMP-001 sigue siendo el mismo empleado aunque cambie de nombre; dos Ficha con los mismos datos son la misma ficha. La pregunta clave: ¿dos objetos con los mismos datos son el mismo objeto? Si sí, record.
  • Cuando el estado debe cambiar. Un Prestamo cambia de disponibilidad, acumula incidencias y registra devoluciones. No es un record.
  • Cuando quieres ocultar la representación interna. Un record expone todos sus componentes. Si necesitas encapsular la representación (03-07), usa una clase.
  • Cuando necesitas heredar. Un record no puede extender ni ser extendido.
  • Cuando la API pública debe seguir la convención getXxx por exigencia de un framework antiguo.
flowchart TD
    A["Necesito un tipo de datos"] --> B{"Su estado cambia con el tiempo?"}
    B -- "Si" --> C["Clase normal"]
    B -- "No" --> D{"Dos objetos con los mismos datos son el mismo?"}
    D -- "No, tiene identidad propia" --> C
    D -- "Si, es un valor" --> E{"Necesito heredar o ocultar campos?"}
    E -- "Si" --> C
    E -- "No" --> F["record"]

  1. Records en BiblioTech: DTO y objetos de valor

Dos usos concretos en el proyecto.

Objeto de valor: Ficha. Una ficha bibliográfica de catálogo, sin identidad propia.

package com.nexussoftware.bibliotech.dominio;

/** Ficha bibliografica de catalogo. Objeto de valor inmutable. */
public record Ficha(String titulo, String autor, int anio) implements Comparable<Ficha> {

    public Ficha {
        titulo = (titulo == null || titulo.isBlank()) ? "Sin titulo" : titulo.trim();
        autor  = (autor  == null || autor.isBlank())  ? "Desconocido" : autor.trim();
        anio   = (anio < 1450 || anio > 2100) ? 0 : anio;
    }

    public Ficha(String titulo, String autor) { this(titulo, autor, 0); }

    /** Fabrica desde un libro del catalogo. */
    public static Ficha desde(Libro libro) {
        return new Ficha(libro.getTitulo(), libro.getAutor(), libro.getAnioPublicacion());
    }

    public boolean esClasico() { return anio > 0 && anio < 2000; }

    @Override
    public int compareTo(Ficha otra) {
        return this.titulo.compareToIgnoreCase(otra.titulo);
    }
}

DTO de salida: ResumenSesion. Un objeto que transporta el resultado de una sesión de devoluciones desde el servicio hasta la presentación.

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Gravedad;

/**
 * Resultado de una sesion de devoluciones.
 *
 * <p>Objeto de transferencia (DTO): agrupa datos calculados para entregarlos
 * a la capa de presentacion sin exponer el dominio.</p>
 */
public record ResumenSesion(int devoluciones,
                            double totalMultas,
                            int incidenciasGraves,
                            Gravedad gravedadMaxima) {

    public ResumenSesion {
        devoluciones      = Math.max(0, devoluciones);
        totalMultas       = Math.max(0.0, totalMultas);
        incidenciasGraves = Math.max(0, incidenciasGraves);
        gravedadMaxima    = (gravedadMaxima == null) ? Gravedad.SIN_RETRASO : gravedadMaxima;
    }

    /** Sesion vacia: punto de partida de la acumulacion. */
    public static ResumenSesion vacio() {
        return new ResumenSesion(0, 0.0, 0, Gravedad.SIN_RETRASO);
    }

    /** Devuelve un resumen NUEVO con una devolucion mas. Estilo inmutable. */
    public ResumenSesion mas(double multa, Gravedad gravedad) {
        return new ResumenSesion(
                devoluciones + 1,
                totalMultas + multa,
                incidenciasGraves + (gravedad == Gravedad.GRAVE ? 1 : 0),
                gravedad.esMasGraveQue(gravedadMaxima) ? gravedad : gravedadMaxima);
    }

    public double multaMedia() {
        return devoluciones == 0 ? 0.0 : totalMultas / devoluciones;
    }
}

Y el uso conjunto, con el enum y el record trabajando juntos:

Prestamo[] devueltos = { p1, p2, p3, p4 };
int[]      dias      = { 20, 40, 16, 12 };

ResumenSesion resumen = ResumenSesion.vacio();

for (int i = 0; i < devueltos.length; i++) {
    double   multa    = devueltos[i].registrarDevolucion(dias[i]);
    Gravedad gravedad = devueltos[i].clasificarGravedad();
    resumen = resumen.mas(multa, gravedad);      // se reasigna: cada 'mas' crea uno nuevo
}

System.out.println(resumen);
System.out.printf("Multa media: %.2f EUR%n", resumen.multaMedia());
System.out.println("Accion: " + resumen.gravedadMaxima().accionRecomendada());
ResumenSesion[devoluciones=4, totalMultas=15.30, incidenciasGraves=2, gravedadMaxima=GRAVE]
Multa media: 3,83 EUR
Accion: Escalar al responsable y bloquear nuevos prestamos.

Fíjate en el toString() que no has escrito, en el equals que sería correcto si lo necesitaras, y en la última línea: gravedadMaxima() devuelve un Gravedad, y sobre él se llama directamente a accionRecomendada(). Un enum que lleva su comportamiento y un record que lleva sus datos.

  1. Tabla comparativa: clase, record y enum

Dimensión Clase record enum
Número de instancias Ilimitado Ilimitado Fijo: una por constante
Estado mutable No No debería
Constructor Escrito a mano Canónico generado + compacto Implícitamente private
equals/hashCode/toString A mano Generados Heredados de Enum, equals es final
Accesores getXxx() a mano xxx() generados A mano
Puede extender clases No No
Puede ser extendido Sí (si no es final) No No (salvo cuerpo por constante)
Puede implementar interfaces
Campos de instancia propios No (solo componentes)
Comparación equals equals ==
Exhaustividad en switch No No
Úsalo para Entidades con identidad y estado Valores inmutables, DTO Conjuntos cerrados de valores
En BiblioTech Material, Prestamo, Empleado Ficha, ResumenSesion Gravedad, TipoMaterial

  1. Cierre del módulo: estado de BiblioTech

Aplica los enums al proyecto y el módulo queda cerrado. Prestamo.Incidencia, que en 04-03 guardaba la gravedad como String, pasa a usar el tipo:

public static class Incidencia {

    private final int      dia;
    private final String   motivo;
    private final Gravedad gravedad;          // antes: String

    public Incidencia(int dia, String motivo, Gravedad gravedad) {
        this.dia      = Math.max(0, dia);
        this.motivo   = (motivo == null || motivo.isBlank())
                        ? "Sin especificar" : motivo.trim();
        this.gravedad = (gravedad == null) ? Gravedad.LEVE : gravedad;
    }

    public int      getDia()      { return dia; }
    public String   getMotivo()   { return motivo; }
    public Gravedad getGravedad() { return gravedad; }
    public boolean  esGrave()     { return gravedad == Gravedad.GRAVE; }

    @Override
    public String toString() {
        return String.format("dia %d - %s [%s]", dia, motivo, gravedad);
    }
}

Y este es el estado completo del proyecto al terminar el módulo 4:

classDiagram
    class Prestable {
        <<interface>>
        +prestar() boolean
        +devolver() boolean
        +estaDisponible() boolean
        +getDiasPrestamo() int
        +diasRestantes(int) int
    }
    class Notificable {
        <<interface>>
        +getCanalAviso() String
        +generarAviso(int) String
    }
    class Material {
        <<abstract>>
        +calcularMulta(int) double
        +clasificarGravedad(int) Gravedad
        +getTipo()* String
    }
    class Gravedad {
        <<enumeration>>
        SIN_RETRASO
        LEVE
        GRAVE
        +accionRecomendada() String
    }
    class TipoMaterial {
        <<enumeration>>
        LIBRO
        REVISTA
        DVD
    }
    class Ficha {
        <<record>>
        +titulo() String
        +autor() String
    }
    class ResumenSesion {
        <<record>>
        +totalMultas() double
    }
    Prestable <|.. Material
    Notificable <|.. Material
    Prestable <|.. SalaReuniones
    Material <|-- Libro
    Material <|-- Revista
    Material <|-- Dvd
    Prestamo --> Material
    Prestamo --> Empleado
    Prestamo *-- Incidencia
    Incidencia --> Gravedad
    Material --> Gravedad

Inventario del proyecto tras el módulo 4:

Elemento Tipo Miembros públicos destacados
Prestable interfaz prestar, devolver, estaDisponible, getDiasPrestamo; default diasRestantes, estaVencido; static plazoValido, contarDisponibles; PLAZO_MAXIMO_DIAS
Notificable interfaz getCanalAviso, generarAviso; default avisoUrgente, avisoRutinario; private cabecera
Material clase abstracta abstract getTipo/getDiasPrestamo/getTarifaDiaria; final calcularDiasRetraso/calcularMulta/clasificarGravedad; prestar, devolver, describir, equals, hashCode, toString
Libro, Revista, Dvd clases concretas Sus tres métodos obligatorios más sus campos propios
SalaReuniones clase Prestable sin ser Material
Empleado clase puedeTomarPrestado, registrarPrestamo, registrarDevolucion, getIniciales, getHistorial
Empleado.Historial anidada estática Contadores acumulados
Prestamo clase registrarDevolucion, calcularMulta, clasificarGravedad, anotarIncidencia, getIncidencias
Prestamo.Incidencia anidada estática getDia, getMotivo, getGravedad, esGrave
Gravedad enum SIN_RETRASO, LEVE, GRAVE; getNivel, accionRecomendada, requiereAviso, esMasGraveQue
TipoMaterial enum LIBRO, REVISTA, DVD, AUDIO; getEtiqueta, getDiasPrestamo, getTarifaDiaria, multaPara
Ficha record titulo(), autor(), anio(), esClasico, desde(Libro)
ResumenSesion record devoluciones(), totalMultas(), gravedadMaxima(), mas, multaMedia, vacio()
ReglaTarifa, FiltroMaterial interfaces funcionales Un método abstracto cada una
Catalogo, GestorPrestamos servicios Reciben Predicate, Function, Consumer, Comparator
ReciboConsola, InformeMaterial presentación Template Method en InformeMaterial

Errores Comunes y Consejos

Olvidar el punto y coma tras la última constante. Si el enum tiene campos o métodos, la lista de constantes termina en ;. Es el error de compilación número uno con enums.

Depender de ordinal(). Reordenar las constantes cambia todos los ordinales en silencio. Usa un campo explícito.

Guardar ordinal() en un fichero o base de datos. Peor todavía: corrompe datos ya guardados. Guarda name(), que es estable, o un código propio.

Poner default en un switch de expresión sobre un enum. Anula la comprobación de exhaustividad, que es la principal ventaja. Omítelo.

Cualificar la constante dentro del case. Se escribe case LEVE ->, no case Gravedad.LEVE ->. La segunda forma no compila.

Usar equals con enums. Funciona, pero == es más seguro (frente a null), más rápido y detecta comparaciones entre tipos distintos en compilación.

Escribir getTitulo() en un record. El accesor generado se llama titulo(). Si escribes getTitulo(), estás añadiendo un método, no sobrescribiendo nada.

Poner la lista de parámetros en el constructor compacto. public Ficha(String titulo, ...) { es el constructor canónico explícito, no el compacto; entonces sí tienes que asignar los campos a mano.

Intentar declarar un campo de instancia en un record. No compila. Su estado son exactamente sus componentes. Los static sí se permiten.

Usar un record para una entidad con identidad. Un Empleado no es un valor: dos empleados con el mismo nombre no son el mismo empleado. Su igualdad va por identificador, no por todos los campos.

Consejo: enum para todo conjunto cerrado. Estados, tipos, canales, niveles, roles, monedas, días de la semana. Si los valores posibles son fijos y conocidos, es un enum. Convertirlos después cuesta mucho más que empezar bien.

Consejo: record para todo lo que sea un valor. Coordenadas, importes con divisa, rangos, resultados de cálculo, DTO entre capas, claves compuestas. Ahorras código y eliminas una fuente de bugs, porque equals y hashCode generados son correctos por construcción.

Consejo: combínalos. Un record cuyo componente es un enum es una pareja extraordinariamente expresiva, como ResumenSesion(..., Gravedad gravedadMaxima).

Ejercicios

Los ejercicios 2 y 3 se apoyan en los tipos del ejercicio 1, así que conviene hacerlos en orden.

Ejercicio 1: EstadoPrestamo

Crea un enum EstadoPrestamo con las constantes ACTIVO, VENCIDO, DEVUELTO y PERDIDO. Cada una debe llevar una etiqueta legible y un booleano que indique si cuenta para el límite de préstamos del empleado. Añade un método abstract String descripcion() implementado por cada constante y un switch de expresión que devuelva la acción a realizar, sin default. Comprueba qué ocurre al añadir una quinta constante.

Ejercicio 2: record LineaCatalogo

Crea un record LineaCatalogo(String referencia, String titulo, TipoMaterial tipo, boolean disponible) con un constructor compacto que valide, un método estático desde(Material), un método etiqueta() que devuelva una línea formateada y la implementación de Comparable<LineaCatalogo> por título. Genera un array de líneas a partir del catálogo y ordénalo.

Ejercicio 3: informe con enum y record

Escribe un método que recorra un array de préstamos y devuelva un record InformeGravedad(int sinRetraso, int leves, int graves, double totalMultas). Usa un switch de expresión sobre Gravedad sin default y el estilo inmutable (un método mas(...) que devuelva una copia nueva). Imprime el informe y la acción recomendada del nivel más alto detectado.

Soluciones

Solución 1

package com.nexussoftware.bibliotech.dominio;

/** Estado en que se encuentra un prestamo. */
public enum EstadoPrestamo {

    ACTIVO("Activo", true) {
        @Override public String descripcion() {
            return "El material esta en poder del empleado y dentro de plazo.";
        }
    },

    VENCIDO("Vencido", true) {
        @Override public String descripcion() {
            return "El plazo se ha superado y se acumula multa diaria.";
        }
    },

    DEVUELTO("Devuelto", false) {
        @Override public String descripcion() {
            return "El material ha vuelto al catalogo y esta disponible.";
        }
    },

    PERDIDO("Perdido", false) {
        @Override public String descripcion() {
            return "El material se da por perdido; se aplica la multa maxima.";
        }
    };      // <-- punto y coma OBLIGATORIO

    private final String  etiqueta;
    private final boolean cuentaParaLimite;

    EstadoPrestamo(String etiqueta, boolean cuentaParaLimite) {
        this.etiqueta         = etiqueta;
        this.cuentaParaLimite = cuentaParaLimite;
    }

    public String  getEtiqueta()         { return etiqueta; }
    public boolean cuentaParaLimite()    { return cuentaParaLimite; }

    /** Cada constante DEBE implementarlo. */
    public abstract String descripcion();

    @Override public String toString() { return etiqueta; }

    /** Accion a realizar. switch de EXPRESION sin default: exhaustividad garantizada. */
    public String accion() {
        return switch (this) {
            case ACTIVO   -> "Ninguna. Revisar al vencimiento.";
            case VENCIDO  -> "Enviar aviso y calcular multa.";
            case DEVUELTO -> "Archivar el prestamo y liberar el material.";
            case PERDIDO  -> "Aplicar multa maxima y dar de baja el material.";
        };
    }

    public static void main(String[] args) {
        System.out.printf("%-10s %-8s %s%n", "ESTADO", "LIMITE", "ACCION");
        for (EstadoPrestamo e : EstadoPrestamo.values()) {
            System.out.printf("%-10s %-8b %s%n", e, e.cuentaParaLimite(), e.accion());
        }
        System.out.println();
        System.out.println("Detalle de VENCIDO: " + EstadoPrestamo.VENCIDO.descripcion());
        System.out.println("Comparacion con ==: "
                           + (EstadoPrestamo.valueOf("VENCIDO") == EstadoPrestamo.VENCIDO));
    }
}
ESTADO     LIMITE   ACCION
Activo     true     Ninguna. Revisar al vencimiento.
Vencido    true     Enviar aviso y calcular multa.
Devuelto   false    Archivar el prestamo y liberar el material.
Perdido    false    Aplicar multa maxima y dar de baja el material.

Detalle de VENCIDO: El plazo se ha superado y se acumula multa diaria.
Comparacion con ==: true

Qué ocurre al añadir una quinta constante. Si añades RENOVADO("Renovado", true), el compilador produce dos errores, y ambos son exactamente lo que quieres:

error: RENOVADO is not abstract and does not override abstract method descripcion()
error: the switch expression does not cover all possible input values

El primero te obliga a describir el estado nuevo; el segundo, a decidir qué acción le corresponde. Ninguno de los dos existiría con cadenas: "RENOVADO" habría caído en un default silencioso y el sistema habría hecho algo incorrecto sin avisar. Esta es la razón número uno para usar enums en el dominio.

Solución 2

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.*;
import java.util.Arrays;
import java.util.Comparator;

/** Linea de catalogo lista para mostrar. Objeto de valor inmutable. */
public record LineaCatalogo(String referencia,
                            String titulo,
                            TipoMaterial tipo,
                            boolean disponible) implements Comparable<LineaCatalogo> {

    /** Constructor compacto: valida y normaliza ANTES de asignar los campos. */
    public LineaCatalogo {
        referencia = (referencia == null || referencia.isBlank())
                     ? "SIN-REF" : referencia.trim();
        titulo     = (titulo == null || titulo.isBlank())
                     ? "Sin titulo" : titulo.trim();
        tipo       = (tipo == null) ? TipoMaterial.LIBRO : tipo;
        // 'disponible' es boolean: no necesita validacion
    }

    /** Fabrica desde el dominio: traduce la clase concreta a la constante del enum. */
    public static LineaCatalogo desde(Material m) {
        TipoMaterial t = switch (m.getTipo()) {
            case "Libro"      -> TipoMaterial.LIBRO;
            case "Revista"    -> TipoMaterial.REVISTA;
            case "DVD"        -> TipoMaterial.DVD;
            case "Audiolibro" -> TipoMaterial.AUDIO;
            default           -> TipoMaterial.LIBRO;
        };
        return new LineaCatalogo(m.getReferencia(), m.getTitulo(), t, m.estaDisponible());
    }

    public String etiqueta() {
        return String.format("%-16s %-24s %-11s %s",
                             referencia, titulo, tipo.getEtiqueta(),
                             disponible ? "LIBRE" : "PRESTADO");
    }

    @Override
    public int compareTo(LineaCatalogo otra) {
        return this.titulo.compareToIgnoreCase(otra.titulo);
    }

    public static void main(String[] args) {

        Material[] catalogo = {
            new Dvd("Refactorizacion en vivo", "DVD-0007", 95),
            new Libro("Java Efectivo",      "Joshua Bloch",  "978-0000000001", 2018),
            new Revista("Java Magazine", "REV-2024-03", 42, "Mensual"),
            new Libro("Patrones de Diseno", "Erich Gamma",   "978-0000000002", 1994)
        };
        catalogo[1].prestar();

        // Transformar el dominio en lineas de presentacion
        LineaCatalogo[] lineas = new LineaCatalogo[catalogo.length];
        for (int i = 0; i < catalogo.length; i++) {
            lineas[i] = LineaCatalogo.desde(catalogo[i]);
        }

        // Orden natural (Comparable): por titulo
        Arrays.sort(lineas);
        System.out.println("--- Orden natural (por titulo) ---");
        for (LineaCatalogo l : lineas) { System.out.println("  " + l.etiqueta()); }

        // Orden alternativo con Comparator y referencias a metodo (04-06)
        Arrays.sort(lineas, Comparator.comparing(LineaCatalogo::tipo)
                                      .thenComparing(LineaCatalogo::titulo));
        System.out.println("--- Por tipo y titulo ---");
        for (LineaCatalogo l : lineas) { System.out.println("  " + l.etiqueta()); }

        // equals y hashCode generados: funcionan sin escribir nada
        LineaCatalogo a = new LineaCatalogo("DVD-0007", "Refactorizacion en vivo",
                                            TipoMaterial.DVD, true);
        LineaCatalogo b = LineaCatalogo.desde(catalogo[0]);
        System.out.println("--- Igualdad por valor ---");
        System.out.println("  a.equals(b) = " + a.equals(b));
        System.out.println("  toString    = " + a);
    }
}
--- Orden natural (por titulo) ---
  978-0000000001   Java Efectivo            Libro       PRESTADO
  REV-2024-03      Java Magazine            Revista     LIBRE
  978-0000000002   Patrones de Diseno       Libro       LIBRE
  DVD-0007         Refactorizacion en vivo  DVD         LIBRE
--- Por tipo y titulo ---
  978-0000000001   Java Efectivo            Libro       PRESTADO
  978-0000000002   Patrones de Diseno       Libro       LIBRE
  REV-2024-03      Java Magazine            Revista     LIBRE
  DVD-0007         Refactorizacion en vivo  DVD         LIBRE
--- Igualdad por valor ---
  a.equals(b) = true
  toString    = LineaCatalogo[referencia=DVD-0007, titulo=Refactorizacion en vivo, tipo=DVD, disponible=true]

Tres observaciones. Primera: el orden "por tipo y título" coloca los libros antes que la revista y el DVD porque Comparator.comparing sobre un enum usa su orden natural, que es el de declaración (LIBRO, REVISTA, DVD, AUDIO); es el único uso legítimo del ordinal, y lo hace el JDK por ti. Segunda: a.equals(b) es true sin haber escrito una línea de equals, porque el record compara todos sus componentes. Tercera: LineaCatalogo::tipo y LineaCatalogo::titulo son referencias a los accesores generados, sin get.

Solución 3

package com.nexussoftware.bibliotech.servicio;

import com.nexussoftware.bibliotech.dominio.Gravedad;
import com.nexussoftware.bibliotech.dominio.Prestamo;

public class AnalizadorGravedad {

    /** Recuento de prestamos por gravedad. Objeto de valor inmutable. */
    public record InformeGravedad(int sinRetraso, int leves, int graves, double totalMultas) {

        public InformeGravedad {
            sinRetraso  = Math.max(0, sinRetraso);
            leves       = Math.max(0, leves);
            graves      = Math.max(0, graves);
            totalMultas = Math.max(0.0, totalMultas);
        }

        public static InformeGravedad vacio() {
            return new InformeGravedad(0, 0, 0, 0.0);
        }

        /** Devuelve un informe NUEVO con un prestamo mas. Estilo inmutable. */
        public InformeGravedad mas(Gravedad g, double multa) {
            // switch de EXPRESION sin default: si se anade una constante, no compila
            return switch (g) {
                case SIN_RETRASO -> new InformeGravedad(sinRetraso + 1, leves, graves,
                                                        totalMultas + multa);
                case LEVE        -> new InformeGravedad(sinRetraso, leves + 1, graves,
                                                        totalMultas + multa);
                case GRAVE       -> new InformeGravedad(sinRetraso, leves, graves + 1,
                                                        totalMultas + multa);
            };
        }

        public int total() { return sinRetraso + leves + graves; }

        /** Nivel maximo alcanzado, deducido del recuento. */
        public Gravedad gravedadMaxima() {
            if (graves > 0) { return Gravedad.GRAVE; }
            if (leves  > 0) { return Gravedad.LEVE;  }
            return Gravedad.SIN_RETRASO;
        }

        public String informeTexto() {
            return String.format(
                    "Prestamos analizados: %d%n"
                  + "  Sin retraso: %d%n"
                  + "  Leves:       %d%n"
                  + "  Graves:      %d%n"
                  + "  Multas:      %.2f EUR (media %.2f EUR)%n"
                  + "  Nivel maximo: %s -> %s",
                    total(), sinRetraso, leves, graves, totalMultas,
                    total() == 0 ? 0.0 : totalMultas / total(),
                    gravedadMaxima(), gravedadMaxima().accionRecomendada());
        }
    }

    /** Analiza los prestamos indicados a los dias transcurridos correspondientes. */
    public static InformeGravedad analizar(Prestamo[] prestamos, int[] dias) {
        InformeGravedad informe = InformeGravedad.vacio();
        for (int i = 0; i < prestamos.length; i++) {
            double   multa    = prestamos[i].getMaterial().calcularMulta(dias[i]);
            Gravedad gravedad = prestamos[i].getMaterial().clasificarGravedad(dias[i]);
            informe = informe.mas(gravedad, multa);      // cada 'mas' crea uno nuevo
        }
        return informe;
    }
}
Empleado marta = new Empleado("Marta Ruiz",   "EMP-001");
Empleado diego = new Empleado("Diego Alonso", "EMP-002");

Prestamo[] prestamos = {
    new Prestamo(new Libro("Java Efectivo", "Joshua Bloch", "978-0000000001", 2018), marta, 100),
    new Prestamo(new Dvd("Refactorizacion en vivo", "DVD-0007", 95), diego, 100),
    new Prestamo(new Revista("Java Magazine", "REV-2024-03", 42, "Mensual"), marta, 100),
    new Prestamo(new Libro("Patrones de Diseno", "Erich Gamma", "978-0000000002", 1994), diego, 100)
};
int[] dias = { 20, 25, 9, 10 };

System.out.println(AnalizadorGravedad.analizar(prestamos, dias).informeTexto());
Prestamos analizados: 4
  Sin retraso: 1
  Leves:       2
  Graves:      1
  Multas:      12,45 EUR (media 3,11 EUR)
  Nivel maximo: GRAVE -> Escalar al responsable y bloquear nuevos prestamos.

Tres puntos de diseño. Primero, el switch no lleva default: si mañana añades MUY_GRAVE a Gravedad, este método no compilará y el compilador te llevará al punto exacto que hay que revisar. Segundo, mas(...) no modifica el informe, devuelve uno nuevo; por eso el bucle escribe informe = informe.mas(...). Es el estilo inmutable de 03-07, natural con records. Tercero, gravedadMaxima().accionRecomendada() encadena el enum con su comportamiento por constante: el informe no tiene ni un solo if sobre el nivel de gravedad.

Conclusión

Has cerrado el módulo con las dos herramientas que convierten datos sueltos en tipos con significado. Sabes que un enum es un tipo cuyos valores posibles forman un conjunto cerrado y conocido en compilación, y qué problemas resuelve frente a las constantes String o int: seguridad de tipos —Gravedad.GRAVISIMO no compila, "GRAVISIMO" sí—, autocompletado, comportamiento asociado y, sobre todo, exhaustividad: un switch de expresión sin default sobre un enum obliga al compilador a llevarte de la mano a cada punto del proyecto que hay que actualizar cuando añades una constante. Conoces sus métodos implícitos —values(), valueOf(), name(), ordinal()— y por qué ordinal() no debe aparecer nunca en tu lógica ni en tus datos guardados: reordenar las constantes lo cambia todo en silencio. Sabes que con enums == es la comparación correcta, más segura frente a null y capaz de detectar en compilación una comparación entre tipos distintos que equals escondería.

Dominas el enum con campos, constructor y métodos, que sustituye una jerarquía entera cuando lo único que varía son valores —y sabes cuándo no debe sustituirla, porque en BiblioTech cada soporte tiene campos propios—; y el enum con cuerpo por constante, donde cada valor implementa a su manera un método abstracto y el compilador exige que ninguno se quede sin implementar.

Sabes que un record declara en una línea lo que costaba cuarenta: genera el constructor canónico, un accesor por componente con el nombre del componente —titulo(), no getTitulo()—, y los tres métodos de Object que tanto trabajo dieron en 03-09, correctos por construcción. Usas el constructor compacto para validar sin repetir la lista de parámetros ni asignar campos a mano, añades constructores adicionales que delegan en el canónico, métodos propios, constantes e interfaces. Y conoces sus límites y su criterio de uso: un record no extiende ni es extendido, su estado son exactamente sus componentes, y la pregunta que decide es siempre la misma: ¿dos objetos con los mismos datos son el mismo objeto? Si sí, es un valor y es un record; si tiene identidad propia o cambia con el tiempo —Empleado, Prestamo—, es una clase.

BiblioTech, tras el módulo 4, es un proyecto de orientación a objetos completo. Material es una clase abstracta que no se puede instanciar, obliga a cada soporte a declarar su tipo, su plazo y su tarifa, y aplica un calcularMulta final en forma de Template Method. Firma dos interfaces, Prestable y Notificable, que una SalaReuniones puede firmar sin entrar en la familia. Las incidencias son una clase anidada estática inmutable, y la gravedad ya no es una cadena sino el enum Gravedad, que además lleva la acción recomendada de cada nivel. El catálogo se ordena y se filtra con comparadores, predicados y funciones que llegan como parámetros, de modo que criterios nuevos no tocan una línea del código existente. Y Ficha y ResumenSesion transportan datos entre capas como valores inmutables, sin una sola línea de código repetitivo.

Y sin embargo, mira de cerca cualquiera de las clases que has escrito y verás el mismo remiendo repetido: Material[] catalogo = new Material[10], Incidencia[] ampliado = Arrays.copyOf(incidencias, incidencias.length + 1), un Prestamo[] de tamaño fijo, una búsqueda de grupos en bucle anidado O(n²) porque no hay forma de buscar por clave. Cada vez que BiblioTech necesita guardar muchas cosas, recurre a un array de tamaño fijo que hay que copiar entero para añadir un elemento, recorrer completo para encontrar uno y recortar a mano para devolver un resultado. Lo has ido marcando como provisional lección tras lección, y ya es lo más urgente que le falta al proyecto.

En el módulo 5, Estructuras de Datos y Colecciones, eso desaparece. Empezarás por dominar los arrays de verdad —incluidos los multidimensionales y la clase Arrays— y entrarás en el Framework de Colecciones de Java: ArrayList para listas que crecen solas, LinkedList para inserciones rápidas, HashMap para buscar por clave en tiempo constante (y ahí verás por fin por qué equals y hashCode tenían que ir siempre juntos), HashSet para conjuntos sin duplicados, colas, pilas y Deque, y los algoritmos de ordenación y búsqueda que aplicarán todos los comparadores que has escrito en este módulo. Los arrays provisionales de BiblioTech se convertirán en colecciones, y aquel informePorTipo de veinte líneas con bucles anidados se quedará en tres.

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