Módulo 14: Enumeraciones, records y clases selladas

Objetivos del módulo

  • Entender por qué las constantes int son una mala forma de representar un conjunto cerrado de valores
  • Declarar y usar enumeraciones (enum) y sus métodos values(), valueOf(), ordinal() y name()
  • Usar enumeraciones en switch clásicos y en expresiones switch
  • Añadir atributos, constructores y métodos a un enum, incluidos métodos abstractos por constante
  • Conocer EnumSet y EnumMap
  • Crear records (Java 16): componentes, métodos generados, constructor canónico y compacto con validación
  • Decidir cuándo usar un record y cuándo una clase normal
  • Diseñar jerarquías cerradas con clases e interfaces selladas (Java 17): sealed, permits, final y non-sealed
  • Aplicar pattern matching: instanceof con patrón, switch con patrones de tipo (Java 21), guardas when, record patterns y variables sin nombre _ (Java 22)
  • Aprovechar la exhaustividad del compilador con jerarquías selladas

1. El problema: representar un conjunto cerrado de valores

Muchos datos de un programa solo pueden tomar unos pocos valores conocidos de antemano: el día de la semana, el estado de un pedido (pendiente, pagado, enviado…), el palo de una baraja o el color de un semáforo.

Con lo que sabes hasta ahora, la solución más inmediata es usar constantes enteras (static final, módulo 11):

public class ConstantesInt {
    // La forma "antigua": constantes enteras
    public static final int LUNES = 0, MARTES = 1, MIERCOLES = 2, JUEVES = 3,
                            VIERNES = 4, SABADO = 5, DOMINGO = 6;

    public static final int ROJO = 0;   // otra constante con el mismo valor

    static boolean esFinDeSemana(int dia) {
        return dia == SABADO || dia == DOMINGO;
    }

    public static void main(String[] args) {
        System.out.println(esFinDeSemana(SABADO));  // true, correcto
        System.out.println(esFinDeSemana(ROJO));    // compila: ¿un color es un día?
        System.out.println(esFinDeSemana(42));      // compila: ¿el día 42?
        System.out.println("Hoy es " + MARTES);     // imprime un número, no un nombre
    }
}

Salida:

true
false
false
Hoy es 1

El programa compila y se ejecuta, pero tiene cuatro problemas graves:

  1. No hay seguridad de tipos. El parámetro es un int cualquiera: el compilador acepta ROJO o 42 como si fueran días.
  2. Los valores no se explican solos. Al imprimir obtienes 1, no MARTES. En una traza o en un fichero, un 1 no dice nada.
  3. No hay forma de recorrerlos. Si quieres listar todos los días, tienes que escribirlos a mano.
  4. No se les puede añadir comportamiento. ¿Dónde pondrías el número de horas laborables de cada día o su nombre en español?

📘 Concepto: Una enumeración (enum) es un tipo de dato cuyo conjunto de valores posibles está cerrado y fijado en el código. Cada valor es una constante con nombre y el compilador no deja usar nada que no sea uno de esos valores.


2. Enumeraciones básicas

2.1. Declarar y usar un enum

enum Dia {
    LUNES, MARTES, MIERCOLES, JUEVES, VIERNES, SABADO, DOMINGO
}
  • La palabra reservada es enum (en lugar de class).
  • Las constantes se escriben en MAYÚSCULAS separadas por comas (convención de constantes).
  • Un enum es un tipo, igual que una clase: puedes declarar variables, parámetros y atributos de tipo Dia.
  • Normalmente va en su propio fichero Dia.java (como public enum Dia). En los ejemplos de este módulo lo ponemos en el mismo fichero que el programa para que puedas ejecutarlo con un solo java Fichero.java.
enum Dia {
    LUNES, MARTES, MIERCOLES, JUEVES, VIERNES, SABADO, DOMINGO
}

public class DiasSemana {

    static boolean esFinDeSemana(Dia dia) {
        return dia == Dia.SABADO || dia == Dia.DOMINGO;
    }

    public static void main(String[] args) {
        Dia hoy = Dia.MIERCOLES;

        System.out.println("Hoy es " + hoy);                 // toString() -> nombre
        System.out.println("name(): " + hoy.name());
        System.out.println("ordinal(): " + hoy.ordinal());   // posición, empieza en 0
        System.out.println("¿Fin de semana? " + esFinDeSemana(hoy));

        // values(): array con todas las constantes, en orden de declaración
        System.out.print("Todos los días:");
        for (Dia d : Dia.values()) {
            System.out.print(" " + d);
        }
        System.out.println();

        // valueOf(): de texto a constante (distingue mayúsculas)
        Dia otro = Dia.valueOf("VIERNES");
        System.out.println("valueOf(\"VIERNES\"): " + otro);

        // Comparación: con == (cada constante es un objeto único)
        System.out.println("¿otro == Dia.VIERNES? " + (otro == Dia.VIERNES));
    }
}

Salida:

Hoy es MIERCOLES
name(): MIERCOLES
ordinal(): 2
¿Fin de semana? false
Todos los días: LUNES MARTES MIERCOLES JUEVES VIERNES SABADO DOMINGO
valueOf("VIERNES"): VIERNES
¿otro == Dia.VIERNES? true

Ahora esFinDeSemana(42) o esFinDeSemana(ROJO) no compilan: el parámetro es de tipo Dia y solo admite las siete constantes (o null).

2.2. Qué es realmente una constante de un enum

📘 Concepto: Un enum es una clase especial. Cada constante es un objeto de esa clase que Java crea una sola vez, al cargar la clase. No puedes crear más con new: el conjunto de objetos está cerrado.

Las siete constantes del enum Dia son siete objetos únicos; las variables hoy y otro apuntan a MIERCOLES y VIERNES Figura 1. Cada constante de un enum es un objeto único; las variables solo guardan referencias a esos objetos.

Como cada constante es un objeto único, comparar con == es correcto y recomendable (a diferencia de los String, módulo 09). Además, == nunca lanza NullPointerException, mientras que hoy.equals(...) sí lo haría si hoy fuese null.

Todos los enum heredan automáticamente de la clase java.lang.Enum, que les aporta estos métodos:

Método Qué devuelve Ejemplo con Dia.MIERCOLES
name() El nombre exacto de la constante (String) "MIERCOLES"
toString() Por defecto, lo mismo que name() (se puede sobrescribir) "MIERCOLES"
ordinal() Posición en la declaración, empezando en 0 2
compareTo(otro) Negativo, 0 o positivo según el orden de declaración MIERCOLES.compareTo(VIERNES) → -2
Dia.values() Array nuevo con todas las constantes (método estático) [LUNES, ..., DOMINGO]
Dia.valueOf(texto) La constante con ese nombre exacto (método estático) Dia.valueOf("LUNES") → LUNES

2.3. Cuidado con valueOf() y ordinal()

valueOf() exige el nombre exacto, con las mismas mayúsculas. Si no existe, lanza una excepción IllegalArgumentException (las excepciones se estudian a fondo en el módulo 15):

enum Dia {
    LUNES, MARTES, MIERCOLES, JUEVES, VIERNES, SABADO, DOMINGO
}

public class ValueOfError {
    public static void main(String[] args) {
        System.out.println(Dia.valueOf("lunes".toUpperCase()));  // LUNES
        System.out.println(Dia.valueOf("lunes"));                 // excepción
    }
}

Salida:

LUNES
Exception in thread "main" java.lang.IllegalArgumentException: No enum constant Dia.lunes
	at java.base/java.lang.Enum.valueOf(Enum.java:293)
	at Dia.valueOf(ValueOfError.java:1)
	at ValueOfError.main(ValueOfError.java:8)

💡 Consejo: Cuando el texto venga del usuario, normalízalo antes con strip().toUpperCase() y prepárate para el caso de que no exista (lo harás con try/catch en el módulo 15, o recorriendo values() y comparando).

⚠️ Importante: No uses ordinal() para guardar datos (en un fichero o una base de datos) ni para hacer cálculos de negocio. Si mañana alguien añade una constante en medio de la lista, todos los ordinales cambian y los datos guardados dejan de significar lo mismo. Si necesitas un número asociado a cada constante, añádelo como atributo (sección 3).

2.4. Enumeraciones en switch

Los enum encajan de maravilla con switch. Dentro de los case se escribe el nombre de la constante sin el prefijo Dia.:

enum Dia {
    LUNES, MARTES, MIERCOLES, JUEVES, VIERNES, SABADO, DOMINGO
}

public class SwitchEnum {

    // switch clásico (con dos puntos; aquí return hace de break)
    static String plan(Dia dia) {
        switch (dia) {
            case SABADO:
            case DOMINGO:
                return "descansar";
            default:
                return "trabajar";
        }
    }

    // switch como expresión (Java 14): sin break y sin default
    static int horasLaborables(Dia dia) {
        return switch (dia) {
            case LUNES, MARTES, MIERCOLES, JUEVES -> 8;
            case VIERNES -> 6;
            case SABADO, DOMINGO -> 0;
        };   // el compilador comprueba que están TODOS los casos
    }

    public static void main(String[] args) {
        int total = 0;
        for (Dia d : Dia.values()) {
            System.out.printf("%-10s %-10s %d h%n", d, plan(d), horasLaborables(d));
            total += horasLaborables(d);
        }
        System.out.println("Total semanal: " + total + " h");
    }
}

Salida:

LUNES      trabajar   8 h
MARTES     trabajar   8 h
MIERCOLES  trabajar   8 h
JUEVES     trabajar   8 h
VIERNES    trabajar   6 h
SABADO     descansar  0 h
DOMINGO    descansar  0 h
Total semanal: 38 h

Fíjate en que la expresión switch de horasLaborables no tiene default. No le hace falta: el compilador sabe que Dia solo tiene siete valores y comprueba que todos están cubiertos. Esto se llama exhaustividad, y es una gran ayuda: si se te olvida un caso, el programa no compila.

Por ejemplo, si borras la línea case SABADO, DOMINGO -> 0;:

SwitchIncompleto.java:7: error: the switch expression does not cover all possible input values
        return switch (dia) {
               ^
1 error
error: compilation failed

💡 Consejo: En las expresiones switch sobre un enum, evita poner default si cubres todas las constantes. Así, cuando alguien añada una constante nueva al enum, el compilador te avisará de todos los switch que tienes que actualizar. Con un default el fallo pasaría desapercibido.

⚠️ Importante: Si la variable del switch vale null, se lanza NullPointerException. Lo verás con más detalle en la sección 7.


3. Enumeraciones con atributos, constructor y métodos

Un enum es una clase, así que puede tener atributos, constructores y métodos. Esto lo convierte en una herramienta mucho más potente que una lista de constantes.

3.1. Atributos y constructor

enum Planeta {
    // Cada constante llama al constructor con sus argumentos
    MERCURIO("Mercurio", 3.70),
    VENUS("Venus", 8.87),
    TIERRA("Tierra", 9.81),
    MARTE("Marte", 3.71),
    JUPITER("Júpiter", 24.79);

    // Atributos: final, porque una constante no debe cambiar
    private final String nombre;
    private final double gravedad;   // m/s²

    // El constructor de un enum es siempre privado
    Planeta(String nombre, double gravedad) {
        this.nombre = nombre;
        this.gravedad = gravedad;
    }

    public String getNombre() {
        return nombre;
    }

    // Peso (en newtons) de una masa en este planeta
    public double peso(double masaKg) {
        return masaKg * gravedad;
    }

    // Peso "de báscula" equivalente en kg (lo que marcaría una báscula terrestre)
    public double kilosEquivalentes(double masaKg) {
        return masaKg * gravedad / TIERRA.gravedad;
    }
}

public class Planetas {
    public static void main(String[] args) {
        double masa = 70;
        System.out.println("Una persona de " + masa + " kg:");
        for (Planeta p : Planeta.values()) {
            System.out.printf("  En %-9s pesa %7.1f N (como %5.1f kg en la Tierra)%n",
                    p.getNombre(), p.peso(masa), p.kilosEquivalentes(masa));
        }
    }
}

Salida:

Una persona de 70.0 kg:
  En Mercurio  pesa   259.0 N (como  26.4 kg en la Tierra)
  En Venus     pesa   620.9 N (como  63.3 kg en la Tierra)
  En Tierra    pesa   686.7 N (como  70.0 kg en la Tierra)
  En Marte     pesa   259.7 N (como  26.5 kg en la Tierra)
  En Júpiter   pesa  1735.3 N (como 176.9 kg en la Tierra)

Reglas que debes recordar:

  • La lista de constantes va primero y termina en ; si después hay más código.
  • El constructor es siempre privado (aunque no lo escribas): nadie puede hacer new Planeta(...).
  • Los atributos deberían ser final: una constante que cambia es una trampa.
  • Un enum no puede heredar (ya hereda de java.lang.Enum), pero sí puede implementar interfaces.

💡 Consejo: Sobrescribe toString() en el enum si quieres un texto “bonito” para el usuario (por ejemplo, "Júpiter" con tilde), pero no cambies name(), que es el que usa valueOf().

3.2. Métodos abstractos por constante

A veces cada constante necesita un comportamiento distinto. Podrías usar un switch (this) dentro de un método, pero hay otra opción elegante: declarar un método abstracto en el enum y que cada constante lo implemente en su propio cuerpo { ... }.

import java.util.EnumMap;
import java.util.EnumSet;

enum Operacion {
    SUMA("+") {
        public double aplicar(double a, double b) { return a + b; }
    },
    RESTA("-") {
        public double aplicar(double a, double b) { return a - b; }
    },
    MULTIPLICACION("*") {
        public double aplicar(double a, double b) { return a * b; }
    },
    DIVISION("/") {
        public double aplicar(double a, double b) { return a / b; }
    };

    private final String simbolo;

    Operacion(String simbolo) {
        this.simbolo = simbolo;
    }

    public String getSimbolo() {
        return simbolo;
    }

    // Método abstracto: cada constante DEBE implementarlo
    public abstract double aplicar(double a, double b);

    // Búsqueda por símbolo (valueOf solo busca por nombre)
    public static Operacion desdeSimbolo(String s) {
        for (Operacion op : values()) {
            if (op.simbolo.equals(s)) {
                return op;
            }
        }
        throw new IllegalArgumentException("Operador desconocido: " + s);
    }
}

public class OperacionesEnum {
    public static void main(String[] args) {
        double a = 12, b = 4;
        for (Operacion op : Operacion.values()) {
            System.out.printf("%.1f %s %.1f = %.1f%n", a, op.getSimbolo(), b, op.aplicar(a, b));
        }
        System.out.println("desdeSimbolo(\"*\") -> " + Operacion.desdeSimbolo("*"));

        // EnumSet: conjunto de constantes muy eficiente
        EnumSet<Operacion> conmutativas = EnumSet.of(Operacion.SUMA, Operacion.MULTIPLICACION);
        System.out.println("Conmutativas: " + conmutativas);
        System.out.println("No conmutativas: " + EnumSet.complementOf(conmutativas));

        // EnumMap: mapa cuyas claves son constantes de un enum
        EnumMap<Operacion, Integer> usos = new EnumMap<>(Operacion.class);
        usos.put(Operacion.DIVISION, 3);
        usos.put(Operacion.SUMA, 10);
        System.out.println("Usos: " + usos);   // ordenado según el enum
    }
}

Salida:

12.0 + 4.0 = 16.0
12.0 - 4.0 = 8.0
12.0 * 4.0 = 48.0
12.0 / 4.0 = 3.0
desdeSimbolo("*") -> MULTIPLICACION
Conmutativas: [SUMA, MULTIPLICACION]
No conmutativas: [RESTA, DIVISION]
Usos: {SUMA=10, DIVISION=3}

Es polimorfismo (módulo 13) aplicado a constantes: op.aplicar(a, b) ejecuta un código distinto según la constante. Si añades una operación nueva y olvidas implementar aplicar, el programa no compila.

💡 Consejo: Si solo cambian los datos de cada constante, usa atributos (como en Planeta). Si la lógica es corta, un switch (this) dentro de un método se lee de un vistazo. Reserva los métodos abstractos por constante para cuando cada una tenga un algoritmo propio algo más largo.

3.3. EnumSet y EnumMap

El ejemplo anterior usa dos colecciones especializadas del paquete java.util:

  • EnumSet<E>: un conjunto de constantes de un mismo enum (por ejemplo, los días en que abre una tienda). Se crea con EnumSet.of(...), EnumSet.allOf(Dia.class), EnumSet.noneOf(Dia.class), EnumSet.range(Dia.LUNES, Dia.VIERNES) o EnumSet.complementOf(otro).
  • EnumMap<K, V>: un mapa cuyas claves son constantes de un enum (por ejemplo, horario por día). Mantiene las claves en el orden de declaración.

Internamente son muy rápidos (un EnumSet se guarda como una secuencia de bits). Los conjuntos y los mapas se estudian a fondo en el módulo 16; de momento basta con que sepas que existen y que son la opción recomendada cuando las claves o elementos son de un enum.


4. Records (Java 16)

4.1. El problema: clases que solo guardan datos

En los módulos 10 a 12 escribiste muchas clases cuyo único propósito era agrupar datos: un punto, una fecha, un producto, un alumno con su nota. Para que una clase así esté “bien hecha” necesitas atributos private final, constructor, getters, equals(), hashCode() y toString():

final class Punto {
    private final int x;
    private final int y;
    public Punto(int x, int y) { this.x = x; this.y = y; }
    public int getX() { return x; }
    public int getY() { return y; }
    @Override public boolean equals(Object o) { /* comparar x e y */ }
    @Override public int hashCode() { return Objects.hash(x, y); }
    @Override public String toString() { return "Punto[x=" + x + ", y=" + y + "]"; }
}

Escrita con el formato habitual (un método por bloque, como en el módulo 12) son más de treinta líneas para decir “un punto tiene una x y una y”. Y es código repetitivo y propenso a errores: si añades un atributo z y olvidas actualizar equals(), tendrás un error difícil de encontrar.

4.2. Declarar un record

📘 Concepto: Un record (Java 16) es una clase especial pensada para ser un portador inmutable de datos. Declaras sus componentes en la cabecera y el compilador genera automáticamente los atributos, el constructor, los accesores, equals(), hashCode() y toString().

// La misma clase, en una línea
record Punto(int x, int y) {

    // Un record puede tener métodos propios
    double distanciaAlOrigen() {
        return Math.sqrt(x * x + y * y);
    }

    // "Modificar" un record = crear otro con el cambio
    Punto conX(int nuevaX) {
        return new Punto(nuevaX, y);
    }
}

public class PuntoRecord {
    public static void main(String[] args) {
        Punto p1 = new Punto(3, 4);
        Punto p2 = new Punto(3, 4);
        Punto p3 = p1.conX(10);

        System.out.println("p1 = " + p1);                  // toString() generado
        System.out.println("p1.x() = " + p1.x());          // accesor (sin "get")
        System.out.println("p1.y() = " + p1.y());
        System.out.println("p1 == p2: " + (p1 == p2));     // objetos distintos
        System.out.println("p1.equals(p2): " + p1.equals(p2));   // mismo contenido
        System.out.println("Distancia: " + p1.distanciaAlOrigen());
        System.out.println("p3 = " + p3 + ", p1 sigue siendo " + p1);
    }
}

Salida:

p1 = Punto[x=3, y=4]
p1.x() = 3
p1.y() = 4
p1 == p2: false
p1.equals(p2): true
Distancia: 5.0
p3 = Punto[x=10, y=4], p1 sigue siendo Punto[x=3, y=4]

Comparación entre la clase Punto escrita a mano y el record Punto con todo lo que genera el compilador Figura 2. Todo lo que escribes a mano en una clase de datos lo genera el compilador en un record.

4.3. Qué genera el compilador

A partir de record Punto(int x, int y), Java genera lo que ves en la figura 2: un atributo private final por componente, el constructor canónico (recibe todos los componentes en orden), un accesor por componente que se llama igual que él (x(), no getX()), y equals(), hashCode() y toString() basados en todos los componentes.

Además, un record es implícitamente final (nadie puede heredar de él) y hereda de java.lang.Record, por lo que no puede usar extends. Sí puede implementar interfaces, tener métodos de instancia y estáticos, constantes estáticas y constructores adicionales. Lo que no puede tener son atributos de instancia extra: todo su estado está en los componentes.

⚠️ Importante: Los accesores no empiezan por get. Es p.x(), no p.getX(). Es un error muy frecuente al pasar de clases a records.

4.4. Inmutabilidad

Los atributos de un record son final: una vez creado, no puedes cambiar sus valores. No hay setters. Para “modificar” un record se crea otro con el valor cambiado, como hace el método conX() del ejemplo. Es el mismo enfoque que String (módulo 09) y que las clases inmutables del módulo 11.

¿Por qué es bueno? Un objeto inmutable se puede compartir entre varias partes del programa sin miedo a que alguien lo cambie “por detrás”, y es seguro usarlo como clave en un mapa (módulo 16).

Pero ojo: la inmutabilidad de un record es superficial. Si un componente es un objeto mutable (un array, una lista), el record guarda la referencia y el contenido sí se puede modificar:

record Notas(String alumno, int[] valores) { }

int[] array = {7, 8, 9};
Notas n = new Notas("Ana", array);
array[0] = 0;                    // modificamos el array original...
n.valores()[1] = 0;              // ...y el que devuelve el accesor
System.out.println(n.alumno() + " " + Arrays.toString(n.valores()));   // Ana [0, 0, 9]

La solución es hacer copias defensivas:

record NotasSeguras(String alumno, int[] valores) {
    NotasSeguras {
        valores = valores.clone();      // copia defensiva al construir
    }

    @Override
    public int[] valores() {
        return valores.clone();         // copia defensiva al devolver
    }
}
// Con el mismo código de antes, NotasSeguras sigue mostrando [7, 8, 9]

💡 Consejo: Si un record tiene componentes mutables, haz copias defensivas en el constructor compacto (y, si hace falta, en el accesor). Con listas, en el módulo 16 verás List.copyOf(lista), que crea una copia inmutable en una sola llamada. Recuerda también que equals() en arrays compara referencias, no contenido, así que dos Notas con arrays iguales pero distintos no serán equals.

4.5. Constructor compacto: validar y normalizar

Un record con datos absurdos (un precio negativo, un nombre vacío) es tan peligroso como una clase sin validar. Para validar, los records ofrecen el constructor compacto: se escribe sin lista de parámetros y se ejecuta antes de que Java asigne los atributos.

record Producto(String nombre, double precio, int stock) {

    // Constante estática: permitida
    static final double IVA = 0.21;

    // Constructor COMPACTO: sin lista de parámetros.
    // Se ejecuta antes de asignar los campos; sirve para validar y normalizar.
    Producto {
        if (nombre == null || nombre.isBlank()) {
            throw new IllegalArgumentException("El nombre es obligatorio");
        }
        if (precio < 0) {
            throw new IllegalArgumentException("Precio negativo: " + precio);
        }
        nombre = nombre.strip();   // normaliza: reasigna el PARÁMETRO
    }   // aquí Java hace this.nombre = nombre; this.precio = precio; ...

    // Constructor adicional: debe delegar en el canónico con this(...)
    Producto(String nombre, double precio) {
        this(nombre, precio, 0);
    }

    double precioConIva() {
        return precio * (1 + IVA);
    }
}

public class RecordValidacion {
    public static void main(String[] args) {
        Producto p = new Producto("  Teclado  ", 25.0, 10);
        System.out.println(p);
        System.out.printf("Con IVA: %.2f €%n", p.precioConIva());
        System.out.println(new Producto("Ratón", 12.5));

        try {
            new Producto("Monitor", -100, 3);
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Salida:

Producto[nombre=Teclado, precio=25.0, stock=10]
Con IVA: 30.25 €
Producto[nombre=Ratón, precio=12.5, stock=0]
Error: Precio negativo: -100.0

(El bloque try/catch captura la excepción para que el programa continúe; lo estudiarás en el módulo 15.)

Hay tres formas de escribir constructores en un record:

Forma Sintaxis Uso
Implícito No escribes nada Lo genera el compilador; basta si no hay que validar
Compacto Producto { ... } Validar y normalizar. No se escribe this.x = x: lo hace Java al final
Canónico explícito Producto(String nombre, double precio, int stock) { this.nombre = nombre; ... } Igual que el compacto pero asignando tú cada atributo. Más largo; casi nunca hace falta
Adicional Producto(String nombre, double precio) { this(nombre, precio, 0); } Valores por defecto. Debe empezar llamando a otro constructor con this(...)

⚠️ Importante: En el constructor compacto se trabaja con los parámetros (nombre = nombre.strip();), no con los atributos. Escribir this.nombre = ... dentro de un constructor compacto es un error de compilación.

4.6. ¿Record o clase?

Usa un record cuando… Usa una clase cuando…
El objeto es, ante todo, un conjunto de datos (punto, fecha, coordenada, línea de pedido, resultado de una operación) El objeto tiene estado que cambia con el tiempo (una cuenta bancaria con saldo, un carrito de la compra)
Dos objetos con los mismos datos deben ser iguales La identidad importa más que el contenido (dos clientes distintos con el mismo nombre no son el mismo)
Quieres inmutabilidad sin esfuerzo Necesitas heredar de otra clase
Vas a usarlo como clave de un mapa o elemento de un conjunto Necesitas ocultar la representación interna (los componentes de un record son públicos a través de sus accesores)
Vas a modelar una jerarquía sellada de casos (sección 5) Es una entidad con ciclo de vida (se crea, se modifica, se da de baja)

💡 Consejo: Los records también pueden declararse dentro de un método (records locales). Son útiles para agrupar temporalmente dos o tres valores que solo usa ese método, por ejemplo para devolver un nombre y una nota juntos.

✏️ Ejercicio: Crea un record Rectangulo(double ancho, double alto) que rechace medidas no positivas, con los métodos area(), perimetro() y esCuadrado(). Comprueba que new Rectangulo(2, 3).equals(new Rectangulo(2, 3)) es true.


5. Clases e interfaces selladas (Java 17)

5.1. El problema: jerarquías demasiado abiertas

En el módulo 13 viste que cualquier clase puede heredar de una clase no final o implementar una interfaz. Casi siempre es lo que quieres, pero a veces el modelo del problema tiene un número fijo de variantes: una figura de tu aplicación es un círculo, un rectángulo o un triángulo, y nada más; un método de pago es tarjeta, Bizum o efectivo.

Con herencia “abierta”, el compilador no puede saber cuántos subtipos hay, así que te obliga a escribir default en todos los switch y no te avisa cuando aparece un subtipo nuevo. Con final cierras del todo y no puedes tener subclases. Faltaba un punto intermedio.

📘 Concepto: Una clase o interfaz sellada (sealed, Java 17) declara explícitamente qué clases pueden heredar de ella con la cláusula permits. Ninguna otra clase puede extenderla o implementarla. El resultado es una jerarquía cerrada que el compilador conoce por completo.

5.2. Sintaxis: sealed, permits, final y non-sealed

// Interfaz sellada: solo Coche, Moto y Camion pueden implementarla
sealed interface Vehiculo permits Coche, Moto, Camion { }

// final: nadie puede heredar de Coche
final class Coche implements Vehiculo {
    int plazas = 5;
}

// non-sealed: Moto vuelve a quedar abierta; cualquiera puede heredar de ella
non-sealed class Moto implements Vehiculo { }

class MotoElectrica extends Moto { }   // permitido, Moto es non-sealed

// sealed: Camion restringe a su vez sus subclases.
// abstract: no se pueden crear camiones "genéricos", solo Trailer o Furgoneta
abstract sealed class Camion implements Vehiculo permits Trailer, Furgoneta { }

final class Trailer extends Camion { }
final class Furgoneta extends Camion { }

public class Vehiculos {
    static int ruedas(Vehiculo v) {
        return switch (v) {
            case Coche c -> 4;
            case Moto m -> 2;
            case Trailer t -> 18;
            case Furgoneta f -> 4;
        };   // sin default: el compilador sabe que no hay más casos
    }

    public static void main(String[] args) {
        Vehiculo[] flota = { new Coche(), new MotoElectrica(), new Trailer(), new Furgoneta() };
        for (Vehiculo v : flota) {
            System.out.println(v.getClass().getSimpleName() + ": " + ruedas(v) + " ruedas");
        }
    }
}

Salida:

Coche: 4 ruedas
MotoElectrica: 2 ruedas
Trailer: 18 ruedas
Furgoneta: 4 ruedas

Jerarquía sellada: Vehiculo permite Coche (final), Moto (non-sealed) y Camion (abstract sealed, que permite Trailer y Furgoneta); Bicicleta no está permitida Figura 3. Una jerarquía sellada. Cada subclase permitida elige si cierra (final), restringe (sealed) o reabre (non-sealed) su rama.

Cada subclase permitida debe declarar uno de los tres modificadores de la figura: final (la rama termina), sealed ... permits ... (solo las subclases que enumere) o non-sealed (rama reabierta a cualquiera).

Los records son implícitamente final y los enum también cumplen la regla, así que pueden ser subtipos permitidos sin escribir nada más. Por eso la combinación “interfaz sellada + records” es tan habitual (sección 8).

Si te saltas una regla, el compilador te avisa:

sealed interface Vehiculo permits Coche, Moto { }
final class Coche implements Vehiculo { }
final class Moto implements Vehiculo { }

// Bicicleta no está en la lista permits
final class Bicicleta implements Vehiculo { }
SelladaError.java:6: error: class is not allowed to extend sealed class: Vehiculo (as it is not listed in its 'permits' clause)
final class Bicicleta implements Vehiculo { }
      ^
1 error
error: compilation failed

Y si una clase permitida no declara ninguno de los tres modificadores (class Coche implements Vehiculo { }), el error es sealed, non-sealed or final modifiers expected.

⚠️ Importante: Fíjate en el abstract de Camion. Si Camion no fuese abstracta, se podría crear un new Camion() “a secas”, que no es ni Trailer ni Furgoneta, y el switch de ruedas() dejaría de ser exhaustivo (el compilador diría the switch expression does not cover all possible input values). Cuando una clase sellada intermedia solo sirve para agrupar, hazla abstract.

5.3. Reglas prácticas

  • Las clases permitidas deben estar en el mismo paquete que la clase sellada (o en el mismo módulo, si usas módulos de Java, que verás en el Curso Avanzado).
  • Si la clase sellada y todas sus subclases están en el mismo fichero, puedes omitir permits: el compilador deduce la lista.
  • Sellar una jerarquía no cambia cómo se usa: sigue habiendo polimorfismo, upcasting, métodos abstractos, etc. Lo que ganas es que el compilador conoce todos los casos, y eso lo aprovecha el switch con patrones.

6. Pattern matching con instanceof (Java 16)

En el módulo 13 hiciste downcasting así: comprobar el tipo con instanceof, convertir con un cast y guardar el resultado en una variable. Son tres pasos para una sola idea.

📘 Concepto: El pattern matching (coincidencia de patrones) permite comprobar la forma de un objeto y extraer sus partes en un solo paso. Un patrón de tipo como String s significa: “si el objeto es un String, conviértelo y llámalo s”.

public class InstanceofPatron {

    static void describir(Object obj) {
        // Antes de Java 16 eran tres pasos:
        //   if (obj instanceof String) { String s = (String) obj; ... }
        // Java 16: instanceof con patrón (comprueba, convierte y declara a la vez)
        if (obj instanceof String s) {
            System.out.println("Texto de " + s.length() + " caracteres");
        } else if (obj instanceof Integer n && n > 0) {   // n ya se puede usar tras &&
            System.out.println("Entero positivo: " + n);
        } else if (obj instanceof Integer n) {
            System.out.println("Entero no positivo: " + n);
        } else {
            System.out.println("Otra cosa: " + obj);
        }
    }

    public static void main(String[] args) {
        describir("programación");
        describir(42);
        describir(-7);
        describir(3.14);
        describir(null);   // instanceof con null siempre es false
    }
}

Salida:

Texto de 12 caracteres
Entero positivo: 42
Entero no positivo: -7
Otra cosa: 3.14
Otra cosa: null

Detalles importantes:

  • La variable de patrón (s, n) solo existe donde el compilador está seguro de que el patrón ha coincidido: dentro del if, o a la derecha de un &&.
  • Con || no se puede usar: en obj instanceof String s || s.isEmpty() no se sabe si s existe.
  • instanceof con null siempre es false, así que el patrón nunca te da una variable null.
  • Funciona también “en negativo”: tras if (!(obj instanceof String s)) return; puedes usar s en el resto del método.

💡 Consejo: El caso típico es equals(). Lo que en el módulo 12 escribías como if (!(o instanceof Punto)) return false; Punto otro = (Punto) o; ahora es return o instanceof Punto otro && x == otro.x && y == otro.y;.


7. switch con patrones (Java 21)

7.1. Patrones de tipo en case

Desde Java 21, un switch puede trabajar con cualquier tipo de objeto (no solo enteros, String o enum) y cada case puede ser un patrón de tipo:

public class SwitchPatrones {

    static String describir(Object obj) {
        return switch (obj) {
            case null -> "es null";
            case Integer n when n < 0 -> "entero negativo (" + n + ")";
            case Integer n -> "entero " + n;
            case String s when s.isBlank() -> "texto vacío";
            case String s -> "texto \"" + s + "\" en mayúsculas: " + s.toUpperCase();
            case int[] array -> "array de " + array.length + " enteros";
            default -> "objeto de tipo " + obj.getClass().getSimpleName();
        };
    }

    public static void main(String[] args) {
        Object[] cosas = { 5, -3, "Java", "  ", new int[] {1, 2, 3}, 2.5, null };
        for (Object c : cosas) {
            System.out.println(describir(c));
        }
    }
}

Salida:

entero 5
entero negativo (-3)
texto "Java" en mayúsculas: JAVA
texto vacío
array de 3 enteros
objeto de tipo Double
es null

7.2. Guardas con when

Una guarda añade una condición extra a un patrón: case Integer n when n < 0 solo coincide si el objeto es un Integer y además es negativo. Dentro de la guarda ya puedes usar la variable del patrón.

Los case se prueban de arriba abajo, así que el orden importa: los casos más específicos (con guarda) deben ir antes que los generales. Si lo haces al revés, el compilador lo detecta, porque el segundo caso nunca se podría alcanzar (se dice que está dominado):

DominanciaError.java:5: error: this case label is dominated by a preceding case label
            case Integer n when n < 0 -> "negativo";   // nunca se alcanzaría
                 ^
1 error
error: compilation failed

7.3. null en el switch

Si la variable vale null y ningún case lo contempla, el switch lanza NullPointerException, aunque tenga default (el default no recoge el null). Si null es un valor posible, trátalo explícitamente con case null -> ... (como en SwitchPatrones) o combínalo con el caso por defecto: case null, default -> "otro";.

7.4. Exhaustividad con jerarquías selladas

Un switch con patrones debe ser exhaustivo: tiene que cubrir todos los valores posibles. Con Object eso obliga a poner default. Pero con un tipo sellado, el compilador conoce todos los subtipos y no necesitas default. Fíjate en lo que pasa si se te olvida un caso con la interfaz sellada Figura (permits Circulo, Rectangulo, Triangulo), que verás completa en la sección 8.2:

// Con las figuras de la sección 8.2 (Circulo, Rectangulo y Triangulo):
static double area(Figura f) {
    return switch (f) {
        case Circulo c -> Math.PI * c.radio() * c.radio();
        case Rectangulo r -> r.base() * r.altura();
    };
}
ExhaustividadError.java:8: error: the switch expression does not cover all possible input values
        return switch (f) {
               ^
1 error
error: compilation failed

Falta Triangulo y el compilador lo sabe. Este es el gran beneficio de combinar clases selladas y patrones: si mañana añades Hexagono a la lista permits, todos los switch que no lo traten dejarán de compilar, y el compilador te llevará de la mano a cada sitio que tienes que actualizar.

💡 Consejo: ¿Polimorfismo (un método abstracto area() en cada clase, módulo 13) o switch con patrones? Si las operaciones son pocas y los tipos cambian a menudo, el polimorfismo es más natural. Si los tipos son fijos (jerarquía sellada) y vas a añadir muchas operaciones distintas sobre ellos (área, perímetro, dibujar, exportar…), el switch con patrones evita llenar cada clase de métodos. Ambos enfoques son correctos.


8. Record patterns y desestructuración (Java 21)

8.1. Desestructurar un record

Un record pattern (patrón de record) no solo comprueba el tipo: además extrae los componentes en variables. A esto se le llama desestructurar.

// Con patrón de tipo: hay que llamar a los accesores
case Rectangulo r -> r.base() * r.altura();

// Con record pattern: los componentes salen ya en variables
case Rectangulo(double b, double h) -> b * h;

Puedes usar var en lugar del tipo de cada componente: case Rectangulo(var b, var h).

8.2. Ejemplo completo: figuras selladas

sealed interface Figura permits Circulo, Rectangulo, Triangulo { }

record Circulo(double radio) implements Figura { }
record Rectangulo(double base, double altura) implements Figura { }
record Triangulo(double base, double altura) implements Figura { }

public class FigurasSelladas {

    // Versión 1: patrones de tipo
    static double area(Figura f) {
        return switch (f) {
            case Circulo c -> Math.PI * c.radio() * c.radio();
            case Rectangulo r -> r.base() * r.altura();
            case Triangulo t -> t.base() * t.altura() / 2;
        };
    }

    // Versión 2: record patterns (desestructuración) + guardas
    static String describir(Figura f) {
        return switch (f) {
            case Circulo(double r) when r == 0 -> "un punto";
            case Circulo(double r) -> "círculo de radio " + r;
            case Rectangulo(double b, double h) when b == h -> "cuadrado de lado " + b;
            case Rectangulo(double b, double h) -> "rectángulo de " + b + " x " + h;
            case Triangulo(var b, var h) -> "triángulo de base " + b + " y altura " + h;
        };
    }

    public static void main(String[] args) {
        Figura[] figuras = {
            new Circulo(1), new Circulo(0), new Rectangulo(2, 3),
            new Rectangulo(4, 4), new Triangulo(6, 2)
        };
        double total = 0;
        for (Figura f : figuras) {
            double a = area(f);
            total += a;
            System.out.printf("%-36s área = %6.2f%n", describir(f), a);
        }
        System.out.printf("Área total: %.2f%n", total);
    }
}

Salida:

círculo de radio 1.0                 área =   3.14
un punto                             área =   0.00
rectángulo de 2.0 x 3.0              área =   6.00
cuadrado de lado 4.0                 área =  16.00
triángulo de base 6.0 y altura 2.0   área =   6.00
Área total: 31.14

Ni area() ni describir() tienen default, y no les hace falta: la interfaz sellada garantiza que no hay más figuras.

8.3. Patrones anidados y variables sin nombre _ (Java 22)

Los record patterns se pueden anidar: si un componente es a su vez un record, puedes desestructurarlo en el mismo patrón. Y cuando un componente no te interesa, escribe _ (variable sin nombre, Java 22) en lugar de inventarte un nombre que no vas a usar.

record Punto(int x, int y) { }
record Linea(Punto inicio, Punto fin) { }

public class RecordPatterns {

    static String analizar(Object obj) {
        // record pattern en instanceof: desestructura y comprueba a la vez
        if (obj instanceof Punto(int x, int y) && x == y && x != 0) {
            return "punto en la diagonal (" + x + ")";
        }
        return switch (obj) {
            case Punto(int x, int y) when x == 0 && y == 0 -> "el origen";
            case Punto(int x, int y) -> "punto (" + x + ", " + y + ")";
            // patrones ANIDADOS: un record dentro de otro
            case Linea(Punto(var x1, var y1), Punto(var x2, var y2)) when x1 == x2 ->
                    "línea vertical en x=" + x1;
            // _ (Java 22): no nos interesa ese componente
            case Linea(Punto(_, var y1), Punto(_, var y2)) when y1 == y2 ->
                    "línea horizontal en y=" + y1;
            case Linea(Punto inicio, _) -> "línea que empieza en " + inicio;
            default -> "desconocido";
        };
    }

    public static void main(String[] args) {
        System.out.println(analizar(new Punto(0, 0)));
        System.out.println(analizar(new Punto(5, 5)));
        System.out.println(analizar(new Punto(2, 7)));
        System.out.println(analizar(new Linea(new Punto(3, 0), new Punto(3, 9))));
        System.out.println(analizar(new Linea(new Punto(0, 4), new Punto(8, 4))));
        System.out.println(analizar(new Linea(new Punto(1, 2), new Punto(5, 6))));
        System.out.println(analizar("hola"));
    }
}

Salida:

el origen
punto en la diagonal (5)
punto (2, 7)
línea vertical en x=3
línea horizontal en y=4
línea que empieza en Punto[x=1, y=2]
desconocido

La variable sin nombre _ también sirve fuera de los patrones, en cualquier sitio donde Java te obliga a declarar una variable que no vas a usar:

public class SinNombre {
    public static void main(String[] args) {
        // Variable de bucle que no usamos: solo queremos repetir 3 veces
        for (int _ : new int[3]) {
            System.out.print("¡Hola! ");
        }
        System.out.println();

        // Excepción que no necesitamos nombrar
        String texto = "12a";
        try {
            System.out.println(Integer.parseInt(texto));
        } catch (NumberFormatException _) {
            System.out.println("\"" + texto + "\" no es un número");
        }
    }
}

Salida:

¡Hola! ¡Hola! ¡Hola! 
"12a" no es un número

8.4. Resumen de la evolución del pattern matching

Versión Novedad Ejemplo
Java 14 Expresiones switch con -> case LUNES, MARTES -> 8;
Java 16 instanceof con patrón de tipo y records if (obj instanceof String s)
Java 17 Clases e interfaces selladas sealed interface Figura permits ...
Java 21 switch con patrones, guardas when, case null, record patterns case Integer n when n < 0 ->
Java 22 Variables y patrones sin nombre _ case Tarjeta(_, boolean ext)
Java 25 Patrones con tipos primitivos (case int i) Todavía en fase preview: no lo uses en código de producción

Este curso usa Java 25 LTS. Aunque ya existe el JDK 27, no es LTS; todo lo de este módulo funciona igual en ambas versiones.


9. Ejemplos completos

9.1. Expresiones aritméticas como árbol de records

Una expresión como (2 + 3) * 4 se puede representar como un árbol: la multiplicación tiene como hijos una suma y un número; la suma tiene como hijos dos números. Con una interfaz sellada y records, modelarlo es casi inmediato, y las operaciones (evaluar, mostrar) son switch con patrones recursivos (módulo 07).

Árbol de la expresión (2 + 3) * 4: Mult con hijos Suma(Num 2, Num 3) y Num 4 Figura 4. La expresión (2 + 3) * 4 representada como un árbol de records.

// Una expresión aritmética representada como un ÁRBOL de records
sealed interface Expr permits Num, Suma, Mult, Neg { }

record Num(double valor) implements Expr { }
record Suma(Expr izq, Expr der) implements Expr { }
record Mult(Expr izq, Expr der) implements Expr { }
record Neg(Expr expr) implements Expr { }

public class Expresiones {

    // Evalúa recursivamente el árbol
    static double evaluar(Expr e) {
        return switch (e) {
            case Num(double v) -> v;
            case Suma(Expr a, Expr b) -> evaluar(a) + evaluar(b);
            case Mult(Expr a, Expr b) -> evaluar(a) * evaluar(b);
            case Neg(Expr a) -> -evaluar(a);
        };
    }

    // Convierte el árbol en texto con paréntesis
    static String mostrar(Expr e) {
        return switch (e) {
            case Num(double v) when v == Math.floor(v) -> String.valueOf((long) v);
            case Num(double v) -> String.valueOf(v);
            case Suma(Expr a, Expr b) -> "(" + mostrar(a) + " + " + mostrar(b) + ")";
            case Mult(Expr a, Expr b) -> mostrar(a) + " * " + mostrar(b);
            case Neg(Expr a) -> "-" + mostrar(a);
        };
    }

    public static void main(String[] args) {
        // (2 + 3) * 4
        Expr e1 = new Mult(new Suma(new Num(2), new Num(3)), new Num(4));
        System.out.println(mostrar(e1) + " = " + evaluar(e1));

        // -(1.5 + 2) * 2
        Expr e2 = new Mult(new Neg(new Suma(new Num(1.5), new Num(2))), new Num(2));
        System.out.println(mostrar(e2) + " = " + evaluar(e2));

        System.out.println(e1);   // toString() generado: el árbol completo
    }
}

Salida:

(2 + 3) * 4 = 20.0
-(1.5 + 2) * 2 = -7.0
Mult[izq=Suma[izq=Num[valor=2.0], der=Num[valor=3.0]], der=Num[valor=4.0]]

Observa varias cosas:

  • La desestructuración Suma(Expr a, Expr b) saca los dos hijos y la recursión hace el resto: cada función tiene una línea por tipo de nodo.
  • La guarda de mostrar (when v == Math.floor(v)) imprime 2 en lugar de 2.0 cuando el número es entero.
  • El toString() generado de los records muestra el árbol completo, muy útil para depurar.
  • Ningún switch tiene default: si añades record Div(Expr izq, Expr der) a la jerarquía, el compilador te señalará las dos funciones.

9.2. Estados de un pedido con enum

Una máquina de estados describe por qué estados pasa un objeto y qué transiciones están permitidas. Un enum con métodos es la forma natural de modelarla. Un pedido empieza PENDIENTE; puede pasar a PAGADO o CANCELADO; de PAGADO a ENVIADO o CANCELADO; de ENVIADO a ENTREGADO; y ENTREGADO y CANCELADO son estados finales:

enum EstadoPedido {
    PENDIENTE, PAGADO, ENVIADO, ENTREGADO, CANCELADO;

    // Reglas de transición: desde cada estado, a cuáles se puede pasar
    public boolean puedePasarA(EstadoPedido nuevo) {
        return switch (this) {
            case PENDIENTE -> nuevo == PAGADO || nuevo == CANCELADO;
            case PAGADO -> nuevo == ENVIADO || nuevo == CANCELADO;
            case ENVIADO -> nuevo == ENTREGADO;
            case ENTREGADO, CANCELADO -> false;   // estados finales
        };
    }

    public boolean esFinal() {
        return this == ENTREGADO || this == CANCELADO;
    }
}

class Pedido {
    private final int numero;
    private EstadoPedido estado = EstadoPedido.PENDIENTE;

    Pedido(int numero) {
        this.numero = numero;
    }

    public void cambiarEstado(EstadoPedido nuevo) {
        if (!estado.puedePasarA(nuevo)) {
            System.out.println("  ✗ Pedido " + numero + ": no se puede pasar de "
                    + estado + " a " + nuevo);
            return;
        }
        System.out.println("  ✓ Pedido " + numero + ": " + estado + " -> " + nuevo);
        estado = nuevo;
    }

    public EstadoPedido getEstado() {
        return estado;
    }
}

public class Pedidos {
    public static void main(String[] args) {
        Pedido p1 = new Pedido(1001);
        p1.cambiarEstado(EstadoPedido.PAGADO);
        p1.cambiarEstado(EstadoPedido.ENVIADO);
        p1.cambiarEstado(EstadoPedido.CANCELADO);   // no permitido
        p1.cambiarEstado(EstadoPedido.ENTREGADO);
        System.out.println("Estado: " + p1.getEstado() + " (¿final? " + p1.getEstado().esFinal() + ")");

        Pedido p2 = new Pedido(1002);
        p2.cambiarEstado(EstadoPedido.ENVIADO);     // no permitido: no está pagado
        p2.cambiarEstado(EstadoPedido.CANCELADO);
        p2.cambiarEstado(EstadoPedido.PAGADO);      // no permitido: ya es final
    }
}

Salida:

  ✓ Pedido 1001: PENDIENTE -> PAGADO
  ✓ Pedido 1001: PAGADO -> ENVIADO
  ✗ Pedido 1001: no se puede pasar de ENVIADO a CANCELADO
  ✓ Pedido 1001: ENVIADO -> ENTREGADO
Estado: ENTREGADO (¿final? true)
  ✗ Pedido 1002: no se puede pasar de PENDIENTE a ENVIADO
  ✓ Pedido 1002: PENDIENTE -> CANCELADO
  ✗ Pedido 1002: no se puede pasar de CANCELADO a PAGADO

Las reglas de negocio están en un solo sitio (el enum), la clase Pedido no necesita saber cuáles son, y el switch (this) es exhaustivo: si añades el estado DEVUELTO, el compilador te obligará a decidir sus transiciones. En el módulo 15 cambiarás el mensaje ✗ por una excepción propia.


10. Errores comunes

Error Síntoma Solución
Comparar constantes de enum con equals() sobre una variable null NullPointerException Usa ==: estado == EstadoPedido.PAGADO
Dia.valueOf("lunes") IllegalArgumentException: No enum constant Normaliza el texto (toUpperCase()) y controla el caso de que no exista
Guardar ordinal() en un fichero Los datos cambian de significado al reordenar constantes Guarda name() o un atributo propio (código)
Llamar p.getX() en un record cannot find symbol Los accesores se llaman como el componente: p.x()
Asignar this.nombre = ... en el constructor compacto Error de compilación Reasigna el parámetro: nombre = nombre.strip();
Pensar que un record con un array es totalmente inmutable El contenido del array cambia Copias defensivas (clone(), List.copyOf)
Subclase permitida sin final/sealed/non-sealed sealed, non-sealed or final modifiers expected Añade uno de los tres modificadores
Clase sellada intermedia no abstracta El switch deja de ser exhaustivo Hazla abstract si solo agrupa subtipos
Caso general antes que el caso con guarda this case label is dominated by a preceding case label Ordena de lo más específico a lo más general
switch con patrones que recibe null NullPointerException Añade case null -> o case null, default ->

11. Ejercicios

Ejercicio 1: Semáforo

Crea un enum Semaforo con las constantes VERDE (30 s), AMBAR (5 s) y ROJO (40 s). Cada constante guarda su duración y tiene un método siguiente() que devuelve la luz que viene después (rojo → verde → ámbar → rojo). En main, simula 6 cambios empezando en ROJO, mostrando el instante en que empieza cada luz.

Ver solución
enum Semaforo {
    VERDE(30), AMBAR(5), ROJO(40);

    private final int segundos;

    Semaforo(int segundos) {
        this.segundos = segundos;
    }

    public int getSegundos() {
        return segundos;
    }

    public Semaforo siguiente() {
        return switch (this) {
            case VERDE -> AMBAR;
            case AMBAR -> ROJO;
            case ROJO -> VERDE;
        };
    }
}

public class E1Semaforo {
    public static void main(String[] args) {
        Semaforo s = Semaforo.ROJO;
        int tiempo = 0;
        for (int i = 0; i < 6; i++) {
            System.out.printf("t=%3d s  %-5s (%d s)%n", tiempo, s, s.getSegundos());
            tiempo += s.getSegundos();
            s = s.siguiente();
        }
    }
}

Salida:

t=  0 s  ROJO  (40 s)
t= 40 s  VERDE (30 s)
t= 70 s  AMBAR (5 s)
t= 75 s  ROJO  (40 s)
t=115 s  VERDE (30 s)
t=145 s  AMBAR (5 s)

El método siguiente() usa un switch (this) exhaustivo, sin default. Una alternativa es values()[(ordinal() + 1) % values().length], pero depende del orden de declaración (aquí VERDE, AMBAR, ROJO), y es más frágil: si alguien reordena las constantes, el semáforo cambia de comportamiento sin que nadie lo note.

Ejercicio 2: Meses y estaciones

Crea un enum Mes con los doce meses y sus días (en un año no bisiesto) y un método estacion() que devuelva un enum Estacion { INVIERNO, PRIMAVERA, VERANO, OTONO } (estaciones meteorológicas: diciembre, enero y febrero son invierno, etc.). Usa un switch exhaustivo, sin default. En main, calcula cuántos días tiene cada estación usando un array de enteros indexado por ordinal().

Ver solución
enum Estacion { INVIERNO, PRIMAVERA, VERANO, OTONO }

enum Mes {
    ENERO(31), FEBRERO(28), MARZO(31), ABRIL(30), MAYO(31), JUNIO(30),
    JULIO(31), AGOSTO(31), SEPTIEMBRE(30), OCTUBRE(31), NOVIEMBRE(30), DICIEMBRE(31);

    private final int dias;   // en un año no bisiesto

    Mes(int dias) {
        this.dias = dias;
    }

    public int getDias() {
        return dias;
    }

    // Estación "meteorológica" en el hemisferio norte
    public Estacion estacion() {
        return switch (this) {
            case DICIEMBRE, ENERO, FEBRERO -> Estacion.INVIERNO;
            case MARZO, ABRIL, MAYO -> Estacion.PRIMAVERA;
            case JUNIO, JULIO, AGOSTO -> Estacion.VERANO;
            case SEPTIEMBRE, OCTUBRE, NOVIEMBRE -> Estacion.OTONO;
        };
    }
}

public class E2Meses {
    public static void main(String[] args) {
        int[] diasPorEstacion = new int[Estacion.values().length];
        for (Mes m : Mes.values()) {
            diasPorEstacion[m.estacion().ordinal()] += m.getDias();
        }
        for (Estacion e : Estacion.values()) {
            System.out.println(e + ": " + diasPorEstacion[e.ordinal()] + " días");
        }
    }
}

Salida:

INVIERNO: 90 días
PRIMAVERA: 92 días
VERANO: 92 días
OTONO: 91 días

Aquí usar ordinal() como índice del array es aceptable: es un uso interno y momentáneo, no se guarda en ningún sitio. (En el módulo 16 verás que un EnumMap<Estacion, Integer> lo haría todavía más limpio.) Por convención, los nombres de las constantes se escriben sin tildes ni Ñ (Java las admite, pero se evitan), por eso OTONO.

Ejercicio 3: Fracciones con record

Crea un record Fraccion(int num, int den) que, en su constructor compacto:

  • Rechace el denominador 0 con IllegalArgumentException.
  • Ponga el signo siempre en el numerador (1/-4 se guarda como -1/4).
  • Simplifique la fracción dividiendo por el máximo común divisor.

Añade un método sumar y un toString() que muestre 3/4 o 2 si el denominador es 1. Comprueba que 2/4 es equals a 1/2.

Ver solución
record Fraccion(int num, int den) {

    Fraccion {
        if (den == 0) {
            throw new IllegalArgumentException("El denominador no puede ser 0");
        }
        if (den < 0) {          // el signo siempre en el numerador
            num = -num;
            den = -den;
        }
        int m = mcd(Math.abs(num), den);
        num = num / m;
        den = den / m;
    }

    private static int mcd(int a, int b) {
        return b == 0 ? (a == 0 ? 1 : a) : mcd(b, a % b);
    }

    Fraccion sumar(Fraccion o) {
        return new Fraccion(num * o.den + o.num * den, den * o.den);
    }

    @Override
    public String toString() {
        return den == 1 ? String.valueOf(num) : num + "/" + den;
    }
}

public class E3Fraccion {
    public static void main(String[] args) {
        Fraccion a = new Fraccion(6, 8);
        Fraccion b = new Fraccion(1, -4);
        System.out.println("a = " + a + ", b = " + b);
        System.out.println("a + b = " + a.sumar(b));
        System.out.println("2/4 equals 1/2: " + new Fraccion(2, 4).equals(new Fraccion(1, 2)));
        try {
            new Fraccion(3, 0);
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }
    }
}

Salida:

a = 3/4, b = -1/4
a + b = 1/2
2/4 equals 1/2: true
Error: El denominador no puede ser 0

Como el constructor compacto normaliza siempre, dos fracciones equivalentes acaban con los mismos componentes y el equals() generado funciona sin escribirlo. El MCD recursivo es el algoritmo de Euclides del módulo 07; el caso a == 0 evita dividir entre 0 cuando el numerador es 0 (0/5 queda como 0/1).

Ejercicio 4: Baraja española

Crea un enum Palo (oros, copas, espadas, bastos), un enum Valor (as, dos, tres, cuatro, cinco, seis, siete, sota, caballo, rey) y un record Carta(Valor valor, Palo palo) con toString() “SOTA de COPAS”. Genera la baraja de 40 cartas en un array, muestra la primera y la última y comprueba con equals() que la carta de la posición 17 es la sota de copas.

Ver solución
enum Palo { OROS, COPAS, ESPADAS, BASTOS }

enum Valor { AS, DOS, TRES, CUATRO, CINCO, SEIS, SIETE, SOTA, CABALLO, REY }

record Carta(Valor valor, Palo palo) {
    @Override
    public String toString() {
        return valor + " de " + palo;
    }
}

public class E4Baraja {
    public static void main(String[] args) {
        Carta[] baraja = new Carta[Palo.values().length * Valor.values().length];
        int i = 0;
        for (Palo p : Palo.values()) {
            for (Valor v : Valor.values()) {
                baraja[i++] = new Carta(v, p);
            }
        }
        System.out.println("Cartas: " + baraja.length);
        System.out.println("Primera: " + baraja[0] + ", última: " + baraja[baraja.length - 1]);
        System.out.println("¿baraja[17] es la sota de copas? "
                + baraja[17].equals(new Carta(Valor.SOTA, Palo.COPAS)));
    }
}

Salida:

Cartas: 40
Primera: AS de OROS, última: REY de BASTOS
¿baraja[17] es la sota de copas? true

El tamaño del array se calcula con values().length en lugar de escribir 40: si un día usas una baraja de 48 cartas (añadiendo OCHO y NUEVE), el código sigue funcionando. Como ampliación, añade a Valor un atributo con los puntos de la brisca (as 11, tres 10, rey 4, caballo 3, sota 2, el resto 0) y suma los de toda la baraja (deben salir 120). La comparación con equals() funciona porque el record compara sus componentes, y los componentes son constantes de enum (objetos únicos).

Ejercicio 5: Métodos de pago sellados

Modela los métodos de pago de una tienda con una interfaz sellada MetodoPago y tres records: Tarjeta(String numero, boolean extranjera), Bizum(String telefono) y Efectivo(). Escribe con un switch con record patterns, sin default, el método comision(metodo, importe): tarjeta nacional 1 %, tarjeta extranjera 3 %, Bizum gratis pero no admitido por encima de 1000 € (devuelve -1), efectivo gratis.

Ver solución
sealed interface MetodoPago permits Tarjeta, Bizum, Efectivo { }

record Tarjeta(String numero, boolean extranjera) implements MetodoPago { }
record Bizum(String telefono) implements MetodoPago { }
record Efectivo() implements MetodoPago { }

public class E5Pagos {

    static double comision(MetodoPago metodo, double importe) {
        return switch (metodo) {
            case Tarjeta(_, boolean extranjera) when extranjera -> importe * 0.03;
            case Tarjeta t -> importe * 0.01;
            case Bizum b when importe > 1000 -> -1;   // no se admite
            case Bizum b -> 0;
            case Efectivo() -> 0;
        };
    }

    public static void main(String[] args) {
        MetodoPago[] metodos = {
            new Tarjeta("4111222233334444", false),
            new Tarjeta("5500111122223333", true),
            new Bizum("600123456"),
            new Efectivo()
        };
        double[] importes = {100, 1500};
        for (double imp : importes) {
            System.out.println("Importe: " + imp + " €");
            for (MetodoPago m : metodos) {
                double c = comision(m, imp);
                String texto = c < 0 ? "no admitido" : String.format("comisión %.2f €", c);
                System.out.printf("  %-10s %s%n", m.getClass().getSimpleName(), texto);
            }
        }
    }
}

Salida:

Importe: 100.0 €
  Tarjeta    comisión 1.00 €
  Tarjeta    comisión 3.00 €
  Bizum      comisión 0.00 €
  Efectivo   comisión 0.00 €
Importe: 1500.0 €
  Tarjeta    comisión 15.00 €
  Tarjeta    comisión 45.00 €
  Bizum      no admitido
  Efectivo   comisión 0.00 €

Efectivo() es un record sin componentes: es válido y su patrón es Efectivo(). En comision el caso de la tarjeta extranjera va primero porque tiene guarda; case Tarjeta t recoge el resto. Devolver -1 como “no admitido” es una solución provisional: en el módulo 15 aprenderás a lanzar una excepción, que es la forma correcta de indicar un error.

Ejercicio 6: Clasificar cualquier valor

Escribe static String clasificar(Object o) con un único switch con patrones y guardas que distinga: null; enteros pares e impares; decimales Double (redondeados); cadenas vacías, cadenas formadas solo por dígitos y el resto de cadenas; caracteres; booleanos; y cualquier otro tipo (mostrando su nombre de clase).

Ver solución
public class E6Describir {

    static String clasificar(Object o) {
        return switch (o) {
            case null -> "nada";
            case Integer i when i % 2 == 0 -> "entero par";
            case Integer i -> "entero impar";
            case Double d -> String.format("decimal redondeado: %d", Math.round(d));
            case String s when s.isEmpty() -> "cadena vacía";
            case String s when s.matches("\\d+") -> "cadena numérica";   // regex, módulo 09
            case String s -> "cadena de " + s.length() + " letras";
            case Character c -> "carácter '" + c + "'";
            case Boolean b -> b ? "verdadero" : "falso";
            default -> "tipo no contemplado: " + o.getClass().getSimpleName();
        };
    }

    public static void main(String[] args) {
        Object[] valores = { 4, 7, 2.6, "", "2026", "hola", 'x', true, null, 10L };
        for (Object v : valores) {
            System.out.printf("%-6s -> %s%n", v, clasificar(v));
        }
    }
}

Salida:

4      -> entero par
7      -> entero impar
2.6    -> decimal redondeado: 3
       -> cadena vacía
2026   -> cadena numérica
hola   -> cadena de 4 letras
x      -> carácter 'x'
true   -> verdadero
null   -> nada
10     -> tipo no contemplado: Long

Los literales 4, 2.6, 'x' y true se convierten automáticamente a Integer, Double, Character y Boolean al guardarse en un Object[] (autoboxing, módulo 11). 10L es un Long, que no tiene case propio, así que cae en default. Aquí el default es obligatorio porque Object no es sellado. El orden de los tres casos de String es importante: si case String s fuese el primero, dominaría a los otros dos y no compilaría.

Ejercicios propuestos

✏️ Ejercicio 7: Tallas de ropa. Crea un enum Talla (XS a XXL) con el contorno de pecho mínimo y máximo de cada talla. Añade static Talla paraPecho(int cm) y un toString() que muestre “M (96-103 cm)”. Escribe un programa que pida al usuario su contorno con Scanner y le recomiende una talla.

✏️ Ejercicio 8: Calculadora con enum. Amplía el enum Operacion de la sección 3.2 con POTENCIA("^") y MODULO("%"). Escribe un programa que lea expresiones como 7 ^ 2 (tres partes separadas por espacios), busque la operación con desdeSimbolo() y muestre el resultado. Repite hasta que el usuario escriba fin.

✏️ Ejercicio 9: Intervalos. Crea un record Intervalo(int inicio, int fin) que obligue a inicio <= fin (si vienen al revés, intercámbialos en el constructor compacto). Añade longitud(), contiene(int x), seSolapa(Intervalo otro) y union(Intervalo otro) (solo si se solapan; si no, lanza IllegalArgumentException).

✏️ Ejercicio 10: Impuesto de circulación. Crea una jerarquía sellada Vehiculo con los records Turismo(int caballosFiscales), Motocicleta(int cc), Camion(double toneladas) y Bicicleta(). Calcula el impuesto anual con un switch con record patterns y guardas (por ejemplo: turismos de menos de 8 CV pagan 25 €, de 8 a 12 CV pagan 68 €, más de 12 CV pagan 145 €; las motos de más de 125 cc pagan 20 €, el resto 8 €; camiones 90 € por tonelada; bicicletas 0 €).

✏️ Ejercicio 11: Más expresiones. Amplía el ejemplo de la sección 9.1 con record Resta(Expr izq, Expr der), record Div(Expr izq, Expr der) y record Var(String nombre). evaluar recibirá además el valor de la variable x (evaluar(Expr e, double x)). Comprueba que el compilador te señala los dos switch que hay que completar. Añade también un método simplificar(Expr e) con patrones anidados que aplique x * 1 = x, x + 0 = x y --x = x (cuidado con el orden de los case).

✏️ Ejercicio 12: Reproductor de música. Modela los estados de un reproductor (DETENIDO, REPRODUCIENDO, PAUSADO) y las acciones (PLAY, PAUSA, STOP) como dos enum. Añade a Estado un método Estado aplicar(Accion a) que devuelva el estado siguiente (por ejemplo, PAUSA estando DETENIDO no hace nada). Lee una secuencia de acciones del teclado y muestra el estado tras cada una.


Resumen

Concepto Sintaxis / ejemplo Para qué sirve
Enumeración enum Dia { LUNES, MARTES } Conjunto cerrado de constantes con seguridad de tipos
Métodos de enum values(), valueOf("X"), name(), ordinal() Recorrer, convertir desde texto, nombre y posición
Comparar constantes dia == Dia.LUNES Cada constante es un objeto único
enum en switch case LUNES, MARTES -> 8; Exhaustivo en expresiones switch, sin default
enum con datos TIERRA("Tierra", 9.81); + constructor privado Asociar datos y comportamiento a cada constante
Método abstracto por constante SUMA("+") { double aplicar(...) {...} } Comportamiento distinto en cada constante
EnumSet / EnumMap EnumSet.of(A, B), new EnumMap<>(Dia.class) Conjuntos y mapas eficientes de constantes
Record (Java 16) record Punto(int x, int y) { }, accesor p.x() Portador inmutable de datos con equals, hashCode, toString
Constructor compacto Punto { if (x < 0) throw ...; } Validar y normalizar
Clase/interfaz sellada (Java 17) sealed interface F permits A, B { } Jerarquía cerrada y conocida por el compilador
Subclases permitidas final, sealed, non-sealed Cerrar, restringir o reabrir cada rama
instanceof con patrón (Java 16) if (o instanceof String s) Comprobar y convertir en un paso
switch con patrones (Java 21) case Circulo c -> ... Elegir según el tipo del objeto
Guarda y null case Integer n when n < 0 ->, case null -> Condición adicional en un case; tratar null
Record pattern (Java 21) case Rectangulo(double b, double h) -> Desestructurar un record
Variable sin nombre (Java 22) case Linea(Punto p, _), catch (Exception _) Ignorar componentes o variables que no se usan
Exhaustividad switch sobre tipo sellado sin default El compilador avisa si falta un caso