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.
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)));nullfalseget 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
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());MaríatrueSeat[row=5, number=12]Fila 5, asiento 12Un 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
finalpara cada componente; - un constructor con todos los componentes;
- un método que lee cada campo, con el mismo nombre:
row(), nogetRow(); equalsyhashCodeque comparan los campos. Por esogetahora encuentra a María;toStringcon la formaSeat[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
- Un mapa vacío: la clave es un asiento del cine, el valor es un nombre.
- put añade la clave Seat(5, 12) con el valor «María».
- get recibe un objeto Seat nuevo. Es otro objeto en la memoria, no la clave de put.
- El hashCode de un record se calcula con los campos, así que es el mismo: la búsqueda va al mismo lugar.
- El equals de un record compara campo por campo: fila 5 y 5, asiento 12 y 12. Son iguales, y get devuelve «María».
- containsKey hace lo mismo con otro Seat nuevo más: true.
- El record también escribe solo toString y los métodos row() y number().
Más precisamente, para curiosos
- Un
recordesfinal: no se puede extender. Extiende implícitamentejava.lang.Recordy 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;}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
equalsyhashCodea mano. Conrecordya los tienes. Si necesitas otra regla de igualdad, todavía puedes sobrescribirlos.
La idea
Comprobaciones y métodos en un record
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());}Price[amount=18.5, currency=EUR]Price[amount=14.8, currency=EUR]El precio no puede ser negativo: -5.0- En el constructor compacto,
amountycurrencyson los parámetros. No escribesthis.amount = amount: eso ocurre solo después de la comprobación. withDiscountno cambia el precio. Devuelve unPricenuevo. Así se trabaja con objetos que no cambian:coffeesigue 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());a y b son dos objetos, así que == es false: compara las flechas. El equals de un record compara los campos, y son los mismos. hashCode también se calcula con los campos, así que también es el mismo. Eso es justo lo que quiere HashMap.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.
price = 4.50; no hay break. El switch antiguo no se detiene al final del caso, sino que cae al siguiente: también ejecuta price = 6.90;, y solo entonces llega al break. La solución es un break después de la línea 5. Mejor todavía es un switch sin ninguna caída. Eso es la siguiente tarjeta.La idea
Un switch que devuelve un valor
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 + " €");Envío: 4.5 €- Después de
->va el valor del caso. No hay caída, y no escribesbreak. - 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
defaultes obligatorio. Sin él:
double price = switch (method) { case "oficina" -> 4.50; case "mensajero" -> 6.90;};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));6.99.9Más precisamente, para curiosos
- También puedes escribir flechas en un
switchque no devuelve un valor, sino que solo hace algo. Entonces tampoco hay caída. yieldes comoreturn, pero para elswitch, 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 mismoswitch.
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 {}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
sealedesfinal,sealed(con su propiopermits) onon-sealed(abierto de nuevo). Unrecordesfinalpor definición. - Si todos los subtipos están en el mismo archivo,
permitsse puede omitir. sealedtambién puede ser una clase abstracta:sealed abstract class Shape permits Circle, Square.
Míralo
Patrones en switch
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));3.00.52.0case 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; };}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()));tarjeta ****4444transferencia desde una cuenta españolatransferencia desde el extranjerocontra reembolsoMá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));}La tarjeta termina en 4444- Si el objeto es
null, elswitchlanza unaNullPointerException. Sinulles una entrada con sentido, añadecase null -> .... - Los patrones se pueden anidar:
case Order(Customer(String name), double total).
Pruébalo
¿Qué caso gana?
¿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));when y llega al segundo. La tarjeta es por 650 €, y la confirmación hace falta por encima de 1000 €, así que gana el caso sin condición: «tarjeta».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 €";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);La tienda de la esquinaCafé 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%sy%.2f, comoString.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() + " €");}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. Unasealed 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.círculo: 3.14rectángulo: 6.00cuadrado: 16.00triángulo: 7.50Total: 32.64sealed 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.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 €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.Point[x=2, y=3]: 3Point[x=5, y=1]: 2Point[x=0, y=0]: 1Visitas a (2, 3): 3Má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 conComparator.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
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: HashMap y HashSet · Códigos hash y funciones hash · Lambdas · Streams
- Continúa en: Patrones de diseño · Bases de datos · Observer · Factory Method y Simple Factory
- Ejercicios sobre el tema: Java moderno