record, switch and patterns
Problem
The seat is taken, but HashMap cannot find it
Movie theater reservations: the key is the seat (row and number), the value is the person's name.
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), "Maria");
System.out.println(reservations.get(new Seat(5, 12)));System.out.println(reservations.containsKey(new Seat(5, 12)));nullfalseget returns null. You remember why from the lesson on hashing: new Seat(5, 12) written twice is two different objects, and Seat does not say when two of them are equal. The fix is equals and hashCode, plus toString for printing, plus getters. About 30 lines for a class that just carries two numbers.The idea
record: a class for data
record Seat(int row, int number) {}Map<Seat, String> reservations = new HashMap<>();reservations.put(new Seat(5, 12), "Maria");
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("Row " + seat.row() + ", seat " + seat.number());MariatrueSeat[row=5, number=12]Row 5, seat 12A record is a class whose job is to carry data. In the parentheses are its components, and Java writes for you:
- a private
finalfield for each component; - a constructor with all the components;
- a method that reads each field, with the same name:
row(), notgetRow(); equalsandhashCodethat compare the fields. That is whygetnow finds Maria;toStringin the formSeat[row=5, number=12].
There are no setters. An object of a record does not change after it is created.
Map<Seat, String> reservations = new HashMap<>();reservations.put(new Seat(5, 12), "Maria");
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("Row " + seat.row() + ", seat " + seat.number());Step by step, in words
- An empty map: the key is a theater seat, the value is a name.
- put adds the key Seat(5, 12) with the value "Maria".
- get receives a new Seat object. It is a different object in memory, not the key from put.
- The hashCode of a record is computed from the fields, so it is the same: the lookup goes to the same place.
- The equals of a record compares field by field: row 5 and 5, seat 12 and 12. They are equal, and get returns "Maria".
- containsKey does the same with yet another new Seat: true.
- The record also writes toString and the methods row() and number() by itself.
More precisely, if you are curious
- A
recordisfinal: it cannot be extended. It implicitly extendsjava.lang.Recordand cannot extend another class, but it can implement interfaces. - It can have its own methods, static fields and static methods. It cannot have instance fields outside the components:
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)- The immutability is only one level deep. If a component is a
List, the field always points to the same list, but the list itself can change. - In the "Collections" module you wrote
equalsandhashCodeby hand. Withrecordyou already have them. If you need a different rule for equality, you can still override them.
The idea
Checks and methods in a record
record Price(double amount, String currency) { Price { if (amount < 0) { throw new IllegalArgumentException("The price cannot be negative: " + 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]The price cannot be negative: -5.0- In the compact constructor,
amountandcurrencyare the parameters. You do not writethis.amount = amount: that happens only after the check. withDiscountdoes not change the price. It returns a newPrice. That is how you work with objects that do not change:coffeestays €18.50.
Try it
Are they equal?
Seat is a record, as in the card before this one.What will the program print?
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 and b are two objects, so == is false: it compares the arrows. The equals of a record compares the fields, and they are the same. hashCode is also computed from the fields, so it is the same too. That is exactly what HashMap wants.Try it
The delivery comes out expensive
Delivery to an office is €4.50, but the program says €6.90. Which line is the bug on?
Pick the line with the bug.
price = 4.50; there is no break. The old switch does not stop at the end of the case but falls through into the next one: it also runs price = 6.90;, and only then gets to break. The fix is a break after line 5. Even better is a switch with no fall-through at all. That is the next card.The idea
A switch that returns a value
switch can be an expression: it gives a value that you store in a variable or return.String method = "office";double price = switch (method) { case "office" -> 4.50; case "courier", "address" -> 6.90; case "store" -> 0; default -> throw new IllegalArgumentException("Unknown delivery method: " + method);};System.out.println("Delivery: €" + price);Delivery: €4.5- After
->comes the value of the case. There is no fall-through, and you do not writebreak. - One case can have several values:
case "courier", "address". - A case can throw an exception, as in
default. - The expression has to give a value for every possible input. With text the inputs are endless, so
defaultis required. Without it:
double price = switch (method) { case "office" -> 4.50; case "courier" -> 6.90;};Main.java:4: error: the switch expression does not cover all possible input values double price = switch (method) { ^If a case has several lines, it goes in { }, and the value is given with yield:
static double delivery(String method, double weightKg) { return switch (method) { case "office" -> 4.50; case "courier" -> { double base = 6.90; yield weightKg > 10 ? base + 3 : base; } default -> 0; };}System.out.println(delivery("courier", 4));System.out.println(delivery("courier", 12));6.99.9More precisely, if you are curious
- You can also write arrows in a
switchthat does not return a value but only does something. Then there is no fall-through either. yieldis likereturn, but for theswitch, not for the method.- The old form
case "office": ... break;still works, and you will run into it in old code. The two forms are not mixed in oneswitch.
The idea
sealed: a closed family
The shop accepts three payment methods: card, bank transfer and cash on delivery. There are no others. You can tell the compiler that:
sealed interface Payment permits CardPayment, BankTransfer, CashOnDelivery {}
record CardPayment(String cardNumber) implements Payment {}record BankTransfer(String iban) implements Payment {}record CashOnDelivery() implements Payment {}sealed means "closed off". After permits comes the list of types that are allowed to implement Payment. An attempt at a fourth one does not compile:
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 {}^The subtypes here are records, because each payment method is just data: a card number, an IBAN or nothing.
More precisely, if you are curious
- Every subtype of a
sealedtype isfinal,sealed(with its ownpermits) ornon-sealed(open again). Arecordisfinalby definition. - If all the subtypes are in the same file,
permitscan be left out. sealedcan also be an abstract class:sealed abstract class Shape permits Circle, Square.
See it
Patterns in switch
switch checks the type of the object: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("BG80BNBG96611020345678"), 200));System.out.println(fee(new CashOnDelivery(), 200));3.00.52.0case CardPayment card means "if the object is a CardPayment, call it card". This is instanceof with a pattern from the "Inheritance" module, but for many types at once.
There is no default. The compiler knows the three subtypes and checks that each one is covered. If you forget one, the code does not compile:
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) { ^That is the benefit of sealed. If tomorrow you add a fourth payment method, the compiler will show you every switch that needs to learn about it.
A record pattern takes the fields out directly, and when adds a condition to the case:
static String describe(Payment payment) { return switch (payment) { case CardPayment(String number) -> "card ****" + number.substring(number.length() - 4); case BankTransfer(String iban) when iban.startsWith("BG") -> "transfer from a Bulgarian account"; case BankTransfer(String iban) -> "transfer from abroad"; case CashOnDelivery() -> "cash on delivery"; };}System.out.println(describe(new CardPayment("4111222233334444")));System.out.println(describe(new BankTransfer("BG80BNBG96611020345678")));System.out.println(describe(new BankTransfer("DE89370400440532013000")));System.out.println(describe(new CashOnDelivery()));card ****4444transfer from a Bulgarian accounttransfer from abroadcash on deliveryMore precisely, if you are curious
- A record pattern also works with
instanceof:
Payment payment = new CardPayment("4111222233334444");if (payment instanceof CardPayment(String number)) { System.out.println("The card ends in " + number.substring(12));}The card ends in 4444- If the object is
null, theswitchthrows aNullPointerException. Ifnullis a meaningful input, addcase null -> .... - Patterns can be nested:
case Order(Customer(String name), double total).
Try it
Which case wins?
What will the program print?
static String label(Payment payment, double amount) { return switch (payment) { case CashOnDelivery cash when amount > 500 -> "cash on delivery: not accepted over €500"; case CashOnDelivery cash -> "cash on delivery"; case CardPayment card when amount > 1000 -> "card: confirmation needed"; case CardPayment card -> "card"; case BankTransfer transfer -> "transfer"; };}
System.out.println(label(new CashOnDelivery(), 650));System.out.println(label(new CashOnDelivery(), 80));System.out.println(label(new CardPayment("4111222233334444"), 650));when and gets to the second one. The card is for €650, and confirmation is needed over €1000, so the case with no condition wins: "card".More precisely, if you are curious
The case with when has to come before the general one for the same type. The other way around does not compile, because the general case catches everything and the one with when could never be reached:
case CashOnDelivery cash -> "cash on delivery";case CashOnDelivery cash when amount > 500 -> "cash on delivery: not accepted over €500";Main.java:11: error: this case label is dominated by a preceding case label case CashOnDelivery cash when amount > 500 -> "cash on delivery: not accepted over €500"; ^The idea
Text blocks and var
Text on several lines, like a receipt or JSON, is hard to read with " and \n on every line. Since Java 15 there is the text block between """ and """:
String receipt = """ The Corner Shop Coffee beans €18.50 Honey €12.00 Total: €30.50 """;System.out.print(receipt);
String json = """ {"customer": "%s", "total": %.2f} """.formatted("Maria", 75.5);System.out.print(json);The Corner ShopCoffee beans €18.50Honey €12.00Total: €30.50{"customer": "Maria", "total": 75.50}- After the opening
"""the text starts on a new line. - The common indentation on the left is removed. The measure is the leftmost line, including the closing
""". - Quotes inside are not escaped.
formatted(...)fills in%sand%.2f, likeString.format.
And a reminder from "Language basics": var lets the compiler infer the type. It saves the most with the long types from collections:
var prices = new LinkedHashMap<String, Double>();prices.put("coffee", 18.50);prices.put("honey", 12.00);for (var entry : prices.entrySet()) { System.out.println(entry.getKey() + ": €" + entry.getValue());}coffee: €18.5honey: €12.0var is only for local variables with a value. The type is set once and does not change. Fields, parameters and var x = null; are not allowed.
Exercise
Modern Java
Solve the tasks in your editor.
1. Shapes. Asealed interface Shape with three record subtypes: Circle(double radius), Rectangle(double width, double height) and Triangle(double base, double height). Write area(Shape) with a switch and record patterns, with no default. Also write name(Shape), which says "square" for a rectangle with equal sides (with when). Print a circle with radius 1, a 2 by 3 rectangle, a 4 by 4 square, a triangle with base 3 and height 5, and the total area.circle: 3.14rectangle: 6.00square: 16.00triangle: 7.50Total: 32.64sealed interface Delivery with ToOffice(String office), ToAddress(String city, double weightKg) and Pickup(). The price: to an office €4.50; to an address €5.90 in Sofia and €6.90 elsewhere, plus €1 for every kilogram over 5; pickup from the store €0. Print each delivery (with the record's toString) and its price.ToOffice[office=Varna Center] -> €4.50ToAddress[city=Sofia, weightKg=2.0] -> €5.90ToAddress[city=Varna, weightKg=8.0] -> €9.90Pickup[] -> €0.00record Point(int x, int y) and count how many times they were at each point with a LinkedHashMap<Point, Integer> and merge(p, 1, Integer::sum). At the end, look up new Point(2, 3) in the map.Point[x=2, y=3]: 3Point[x=5, y=1]: 2Point[x=0, y=0]: 1Visits to (2, 3): 3More tasks
- A student as a record.
record Student(String name, double grade)with a compact constructor that rejects a grade outside 2 to 6. Sort a list of students withComparator.comparing(Student::grade). - Day of the week. Rewrite the "day of the week" task from the lesson "Conditions" with a
switchexpression:case 1, 2, 3, 4, 5 -> "weekday". - A letter. With a text block and
formatted, make a letter confirming a reservation: name, date, number of guests and price in euros.
Test