Ir al contenido
wannacode

record, switch y patrones

En resumen: Un record es una clase de datos para la que Java escribe el constructor, equals, hashCode y toString, y un switch con patrones elige según el tipo y los campos del objeto.

Problema

El asiento está ocupado, pero HashMap no lo encuentra

Reservas en un cine: la clave es el asiento (fila y número), el valor es el nombre de la persona.

Seat
class Seat {
private int row;
private int number;
public Seat(int row, int number) {
this.row = row;
this.number = number;
}
}
Map<Seat, String> reservations = new HashMap<>();
reservations.put(new Seat(5, 12), "María");
System.out.println(reservations.get(new Seat(5, 12)));
System.out.println(reservations.containsKey(new Seat(5, 12)));
Salida
null
false
María está en la fila 5, asiento 12, pero get devuelve null. Ya sabes por qué desde la lección «Códigos hash y funciones hash»: new Seat(5, 12) escrito dos veces son dos objetos distintos, y Seat no dice cuándo dos de ellos son iguales. La solución es equals y hashCode, más toString para imprimir, más los getters. Unas 30 líneas para una clase que solo lleva dos números.

La idea

record: una clase para datos

Desde Java 16 lo mismo ocupa una línea:
record Seat(int row, int number) {}
Map<Seat, String> reservations = new HashMap<>();
reservations.put(new Seat(5, 12), "María");
System.out.println(reservations.get(new Seat(5, 12)));
System.out.println(reservations.containsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);
System.out.println(seat);
System.out.println("Fila " + seat.row() + ", asiento " + seat.number());
Salida
María
true
Seat[row=5, number=12]
Fila 5, asiento 12

Un record es una clase cuyo trabajo es llevar datos. Entre los paréntesis están sus componentes, y Java escribe por ti:

  • un campo privado final para cada componente;
  • un constructor con todos los componentes;
  • un método que lee cada campo, con el mismo nombre: row(), no getRow();
  • equals y hashCode que comparan los campos. Por eso get ahora encuentra a María;
  • toString con la forma Seat[row=5, number=12].

No hay setters. Un objeto de un record no cambia después de crearse.

Map<Seat, String> reservations = new HashMap<>();
reservations.put(new Seat(5, 12), "María");
System.out.println(reservations.get(new Seat(5, 12)));
System.out.println(reservations.containsKey(new Seat(5, 12)));
Seat seat = new Seat(5, 12);
System.out.println(seat);
System.out.println("Fila " + seat.row() + ", asiento " + seat.number());
Paso a paso, en palabras
  1. Un mapa vacío: la clave es un asiento del cine, el valor es un nombre.
  2. put añade la clave Seat(5, 12) con el valor «María».
  3. get recibe un objeto Seat nuevo. Es otro objeto en la memoria, no la clave de put.
  4. El hashCode de un record se calcula con los campos, así que es el mismo: la búsqueda va al mismo lugar.
  5. El equals de un record compara campo por campo: fila 5 y 5, asiento 12 y 12. Son iguales, y get devuelve «María».
  6. containsKey hace lo mismo con otro Seat nuevo más: true.
  7. El record también escribe solo toString y los métodos row() y number().
Más precisamente, para curiosos
  • Un record es final: no se puede extender. Extiende implícitamente java.lang.Record y no puede extender otra clase, pero sí puede implementar interfaces.
  • Puede tener sus propios métodos, campos estáticos y métodos estáticos. No puede tener campos de instancia fuera de los componentes:
record Cart(List<String> items) {
private int count;
}
Error
Main.java:4: error: field declaration must be static
private int count;
^
(consider replacing field with record component)
  • La inmutabilidad es solo de un nivel. Si un componente es una List, el campo siempre apunta a la misma lista, pero la propia lista puede cambiar.
  • En el módulo «Colecciones» escribiste equals y hashCode a mano. Con record ya los tienes. Si necesitas otra regla de igualdad, todavía puedes sobrescribirlos.

La idea

Comprobaciones y métodos en un record

Un precio no puede ser negativo. La comprobación va en un constructor compacto: sin paréntesis y sin parámetros, se ejecuta antes de que se asignen los campos.
record Price(double amount, String currency) {
Price {
if (amount < 0) {
throw new IllegalArgumentException("El precio no puede ser negativo: " + amount);
}
}
Price withDiscount(double percent) {
return new Price(amount * (1 - percent / 100), currency);
}
}
Price coffee = new Price(18.50, "EUR");
Price sale = coffee.withDiscount(20);
System.out.println(coffee);
System.out.println(sale);
try {
new Price(-5, "EUR");
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
Salida
Price[amount=18.5, currency=EUR]
Price[amount=14.8, currency=EUR]
El precio no puede ser negativo: -5.0
  • En el constructor compacto, amount y currency son los parámetros. No escribes this.amount = amount: eso ocurre solo después de la comprobación.
  • withDiscount no cambia el precio. Devuelve un Price nuevo. Así se trabaja con objetos que no cambian: coffee sigue en 18,50 €.

Pruébalo

¿Son iguales?

Seat es un record, como en la tarjeta anterior.

¿Qué imprimirá el programa?

Seat a = new Seat(5, 12);
Seat b = new Seat(5, 12);
System.out.println(a == b);
System.out.println(a.equals(b));
System.out.println(a.hashCode() == b.hashCode());
Respuesta

Pruébalo

El envío sale caro

El envío a una oficina cuesta 4,50 €, pero el programa dice 6,90 €. ¿En qué línea está el error?

Elige la línea con el error.

La idea

Un switch que devuelve un valor

Desde Java 14 un switch puede ser una expresión: da un valor que guardas en una variable o devuelves.
String method = "oficina";
double price = switch (method) {
case "oficina" -> 4.50;
case "mensajero", "domicilio" -> 6.90;
case "tienda" -> 0;
default -> throw new IllegalArgumentException("Método de envío desconocido: " + method);
};
System.out.println("Envío: " + price + " €");
Salida
Envío: 4.5 €
  • Después de -> va el valor del caso. No hay caída, y no escribes break.
  • Un caso puede tener varios valores: case "mensajero", "domicilio".
  • Un caso puede lanzar una excepción, como en default.
  • La expresión tiene que dar un valor para cada entrada posible. Con texto las entradas son infinitas, así que default es obligatorio. Sin él:
double price = switch (method) {
case "oficina" -> 4.50;
case "mensajero" -> 6.90;
};
Error
Main.java:4: error: the switch expression does not cover all possible input values
double price = switch (method) {
^

Si un caso tiene varias líneas, va entre { }, y el valor se da con yield:

static double delivery(String method, double weightKg) {
return switch (method) {
case "oficina" -> 4.50;
case "mensajero" -> {
double base = 6.90;
yield weightKg > 10 ? base + 3 : base;
}
default -> 0;
};
}
System.out.println(delivery("mensajero", 4));
System.out.println(delivery("mensajero", 12));
Salida
6.9
9.9
Más precisamente, para curiosos
  • También puedes escribir flechas en un switch que no devuelve un valor, sino que solo hace algo. Entonces tampoco hay caída.
  • yield es como return, pero para el switch, no para el método.
  • La forma antigua case "oficina": ... break; sigue funcionando, y la encontrarás en código antiguo. Las dos formas no se mezclan en un mismo switch.

La idea

sealed: una familia cerrada

La tienda acepta tres formas de pago: tarjeta, transferencia bancaria y contra reembolso. No hay otras. Puedes decírselo al compilador:

sealed interface Payment permits CardPayment, BankTransfer, CashOnDelivery {}
record CardPayment(String cardNumber) implements Payment {}
record BankTransfer(String iban) implements Payment {}
record CashOnDelivery() implements Payment {}

sealed significa «cerrado». Después de permits va la lista de tipos que pueden implementar Payment. Un intento de un cuarto no compila:

record Voucher(String code) implements Payment {}
Error
Main.java:6: error: class is not allowed to extend sealed class: Payment (as it is not listed in its 'permits' clause)
record Voucher(String code) implements Payment {}
^

Aquí los subtipos son records, porque cada forma de pago son solo datos: un número de tarjeta, un IBAN o nada.

Más precisamente, para curiosos
  • Todo subtipo de un tipo sealed es final, sealed (con su propio permits) o non-sealed (abierto de nuevo). Un record es final por definición.
  • Si todos los subtipos están en el mismo archivo, permits se puede omitir.
  • sealed también puede ser una clase abstracta: sealed abstract class Shape permits Circle, Square.

Míralo

Patrones en switch

La comisión depende de la forma de pago. Desde Java 21, un switch comprueba el tipo del objeto:
static double fee(Payment payment, double amount) {
return switch (payment) {
case CardPayment card -> amount * 0.015;
case BankTransfer transfer -> 0.50;
case CashOnDelivery cash -> 2.00;
};
}
System.out.println(fee(new CardPayment("4111222233334444"), 200));
System.out.println(fee(new BankTransfer("ES9121000418450200051332"), 200));
System.out.println(fee(new CashOnDelivery(), 200));
Salida
3.0
0.5
2.0

case CardPayment card significa «si el objeto es un CardPayment, llámalo card». Es instanceof con patrón del módulo «Herencia y polimorfismo», pero para muchos tipos a la vez.

No hay default. El compilador conoce los tres subtipos y comprueba que cada uno está cubierto. Si olvidas uno, el código no compila:

static double fee(Payment payment, double amount) {
return switch (payment) {
case CardPayment card -> amount * 0.015;
case BankTransfer transfer -> 0.50;
};
}
Error
Main.java:9: error: the switch expression does not cover all possible input values
return switch (payment) {
^

Esa es la ventaja de sealed. Si mañana añades una cuarta forma de pago, el compilador te mostrará cada switch que tiene que conocerla.

Un patrón de record saca los campos directamente, y when añade una condición al caso:

static String describe(Payment payment) {
return switch (payment) {
case CardPayment(String number) -> "tarjeta ****" + number.substring(number.length() - 4);
case BankTransfer(String iban) when iban.startsWith("ES") -> "transferencia desde una cuenta española";
case BankTransfer(String iban) -> "transferencia desde el extranjero";
case CashOnDelivery() -> "contra reembolso";
};
}
System.out.println(describe(new CardPayment("4111222233334444")));
System.out.println(describe(new BankTransfer("ES9121000418450200051332")));
System.out.println(describe(new BankTransfer("DE89370400440532013000")));
System.out.println(describe(new CashOnDelivery()));
Salida
tarjeta ****4444
transferencia desde una cuenta española
transferencia desde el extranjero
contra reembolso
Más precisamente, para curiosos
  • Un patrón de record también funciona con instanceof:
Payment payment = new CardPayment("4111222233334444");
if (payment instanceof CardPayment(String number)) {
System.out.println("La tarjeta termina en " + number.substring(12));
}
Salida
La tarjeta termina en 4444
  • Si el objeto es null, el switch lanza una NullPointerException. Si null es una entrada con sentido, añade case null -> ....
  • Los patrones se pueden anidar: case Order(Customer(String name), double total).

Pruébalo

¿Qué caso gana?

Los casos se comprueban de arriba abajo. Gana el primero que encaja.

¿Qué imprimirá el programa?

static String label(Payment payment, double amount) {
return switch (payment) {
case CashOnDelivery cash when amount > 500 -> "contra reembolso: no se acepta por encima de 500 €";
case CashOnDelivery cash -> "contra reembolso";
case CardPayment card when amount > 1000 -> "tarjeta: hace falta confirmación";
case CardPayment card -> "tarjeta";
case BankTransfer transfer -> "transferencia";
};
}
System.out.println(label(new CashOnDelivery(), 650));
System.out.println(label(new CashOnDelivery(), 80));
System.out.println(label(new CardPayment("4111222233334444"), 650));
Respuesta
Más precisamente, para curiosos

El caso con when tiene que ir antes del general para el mismo tipo. Al revés no compila, porque el caso general lo atrapa todo y nunca se podría llegar al de when:

case CashOnDelivery cash -> "contra reembolso";
case CashOnDelivery cash when amount > 500 -> "contra reembolso: no se acepta por encima de 500 €";
Error
Main.java:11: error: this case label is dominated by a preceding case label
case CashOnDelivery cash when amount > 500 -> "contra reembolso: no se acepta por encima de 500 €";
^

La idea

Bloques de texto y var

Un texto de varias líneas, como un recibo o un JSON, se lee mal con " y \n en cada línea. Desde Java 15 existe el bloque de texto entre """ y """:

String receipt = """
La tienda de la esquina
Café en grano 18.50 €
Miel 12.00 €
Total: 30.50 €
""";
System.out.print(receipt);
String json = """
{"customer": "%s", "total": %.2f}
""".formatted("María", 75.5);
System.out.print(json);
Salida
La tienda de la esquina
Café en grano 18.50 €
Miel 12.00 €
Total: 30.50 €
{"customer": "María", "total": 75.50}
  • Después de los """ de apertura el texto empieza en una línea nueva.
  • Se quita la sangría común de la izquierda. La medida es la línea que está más a la izquierda, incluidos los """ de cierre.
  • Las comillas de dentro no se escapan.
  • formatted(...) rellena %s y %.2f, como String.format.

Y un recordatorio de «Fundamentos del lenguaje»: var deja que el compilador deduzca el tipo. Lo que más ahorra es con los tipos largos de las colecciones:

var prices = new LinkedHashMap<String, Double>();
prices.put("café", 18.50);
prices.put("miel", 12.00);
for (var entry : prices.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue() + " €");
}
Salida
café: 18.5 €
miel: 12.0 €

var es solo para variables locales con un valor. El tipo se fija una vez y no cambia. Los campos, los parámetros y var x = null; no están permitidos.

Ejercicio

Java moderno

Resuelve las tareas en tu editor.

1. Figuras. Una sealed interface Shape con tres subtipos record: Circle(double radius), Rectangle(double width, double height) y Triangle(double base, double height). Escribe area(Shape) con un switch y patrones de record, sin default. Escribe también name(Shape), que dice «cuadrado» para un rectángulo con los lados iguales (con when). Imprime un círculo de radio 1, un rectángulo de 2 por 3, un cuadrado de 4 por 4, un triángulo de base 3 y altura 5, y el área total.
Salida
círculo: 3.14
rectángulo: 6.00
cuadrado: 16.00
triángulo: 7.50
Total: 32.64
2. Precio del envío. Una sealed interface Delivery con ToOffice(String office), ToAddress(String city, double weightKg) y Pickup(). El precio: a una oficina 4,50 €; a domicilio 5,90 € en Madrid y 6,90 € en otros lugares, más 1 € por cada kilogramo por encima de 5; recogida en la tienda 0 €. Imprime cada envío (con el toString del record) y su precio.
Salida
ToOffice[office=Valencia Centro] -> 4.50 €
ToAddress[city=Madrid, weightKg=2.0] -> 5.90 €
ToAddress[city=Valencia, weightKg=8.0] -> 9.90 €
Pickup[] -> 0.00 €
3. Un punto como clave. El mensajero pasa por puntos del mapa: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Haz record Point(int x, int y) y cuenta cuántas veces estuvo en cada punto con un LinkedHashMap<Point, Integer> y merge(p, 1, Integer::sum). Al final, busca new Point(2, 3) en el mapa.
Salida
Point[x=2, y=3]: 3
Point[x=5, y=1]: 2
Point[x=0, y=0]: 1
Visitas a (2, 3): 3

Más tareas

  • Un estudiante como record. record Student(String name, double grade) con un constructor compacto que rechace una nota fuera del rango de 2 a 6. Ordena una lista de estudiantes con Comparator.comparing(Student::grade).
  • Día de la semana. Reescribe la tarea «día de la semana» de la lección «Condiciones» con una expresión switch: case 1, 2, 3, 4, 5 -> "laborable".
  • Una carta. Con un bloque de texto y formatted, haz una carta que confirme una reserva: nombre, fecha, número de invitados y precio en euros.

Test

Ponte a prueba

1. ¿Qué obtienes ya hecho con record Seat(int row, int number) {}?
2. ¿Por qué un record sirve como clave en un HashMap y una clase normal sin nada añadido no?
3. ¿Qué ventaja tiene un switch con flechas sobre el switch antiguo?
4. sealed interface Payment permits CardPayment, BankTransfer, CashOnDelivery. ¿Por qué un switch sobre Payment puede no tener default?
5. ¿Qué hace case BankTransfer(String iban) when iban.startsWith("ES") -> ...?

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·