Skip to content
wannacode

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.

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), "Maria");
System.out.println(reservations.get(new Seat(5, 12)));
System.out.println(reservations.containsKey(new Seat(5, 12)));
Output
null
false
Maria is in row 5, seat 12, but get 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

Since Java 16 the same thing takes one line:
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());
Output
Maria
true
Seat[row=5, number=12]
Row 5, seat 12

A record is a class whose job is to carry data. In the parentheses are its components, and Java writes for you:

  • a private final field for each component;
  • a constructor with all the components;
  • a method that reads each field, with the same name: row(), not getRow();
  • equals and hashCode that compare the fields. That is why get now finds Maria;
  • toString in the form Seat[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
  1. An empty map: the key is a theater seat, the value is a name.
  2. put adds the key Seat(5, 12) with the value "Maria".
  3. get receives a new Seat object. It is a different object in memory, not the key from put.
  4. The hashCode of a record is computed from the fields, so it is the same: the lookup goes to the same place.
  5. The equals of a record compares field by field: row 5 and 5, seat 12 and 12. They are equal, and get returns "Maria".
  6. containsKey does the same with yet another new Seat: true.
  7. The record also writes toString and the methods row() and number() by itself.
More precisely, if you are curious
  • A record is final: it cannot be extended. It implicitly extends java.lang.Record and 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;
}
Error
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 equals and hashCode by hand. With record you already have them. If you need a different rule for equality, you can still override them.

The idea

Checks and methods in a record

A price cannot be negative. The check goes in a compact constructor: with no parentheses and no parameters, it runs before the fields are set.
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());
}
Output
Price[amount=18.5, currency=EUR]
Price[amount=14.8, currency=EUR]
The price cannot be negative: -5.0
  • In the compact constructor, amount and currency are the parameters. You do not write this.amount = amount: that happens only after the check.
  • withDiscount does not change the price. It returns a new Price. That is how you work with objects that do not change: coffee stays €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());
Answer

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.

The idea

A switch that returns a value

Since Java 14 a 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);
Output
Delivery: €4.5
  • After -> comes the value of the case. There is no fall-through, and you do not write break.
  • 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 default is required. Without it:
double price = switch (method) {
case "office" -> 4.50;
case "courier" -> 6.90;
};
Error
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));
Output
6.9
9.9
More precisely, if you are curious
  • You can also write arrows in a switch that does not return a value but only does something. Then there is no fall-through either.
  • yield is like return, but for the switch, 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 one switch.

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 {}
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 {}
^

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 sealed type is final, sealed (with its own permits) or non-sealed (open again). A record is final by definition.
  • If all the subtypes are in the same file, permits can be left out.
  • sealed can also be an abstract class: sealed abstract class Shape permits Circle, Square.

See it

Patterns in switch

The fee depends on the payment method. Since Java 21, a 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));
Output
3.0
0.5
2.0

case 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;
};
}
Error
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()));
Output
card ****4444
transfer from a Bulgarian account
transfer from abroad
cash on delivery
More 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));
}
Output
The card ends in 4444
  • If the object is null, the switch throws a NullPointerException. If null is a meaningful input, add case null -> ....
  • Patterns can be nested: case Order(Customer(String name), double total).

Try it

Which case wins?

The cases are checked from top to bottom. The first one that matches 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));
Answer
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";
Error
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);
Output
The Corner Shop
Coffee beans €18.50
Honey €12.00
Total: €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 %s and %.2f, like String.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());
}
Output
coffee: €18.5
honey: €12.0

var 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. A sealed 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.
Output
circle: 3.14
rectangle: 6.00
square: 16.00
triangle: 7.50
Total: 32.64
2. Delivery price. A sealed 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.
Output
ToOffice[office=Varna Center] -> €4.50
ToAddress[city=Sofia, weightKg=2.0] -> €5.90
ToAddress[city=Varna, weightKg=8.0] -> €9.90
Pickup[] -> €0.00
3. A point as a key. The courier passes through points on the map: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Make record 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.
Output
Point[x=2, y=3]: 3
Point[x=5, y=1]: 2
Point[x=0, y=0]: 1
Visits to (2, 3): 3

More 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 with Comparator.comparing(Student::grade).
  • Day of the week. Rewrite the "day of the week" task from the lesson "Conditions" with a switch expression: 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

Check yourself

1. What do you get ready-made with record Seat(int row, int number) {}?
2. Why does a record work as a key in a HashMap, while a plain class with nothing added does not?
3. What is the advantage of a switch with arrows over the old switch?
4. sealed interface Payment permits CardPayment, BankTransfer, CashOnDelivery. Why can a switch on Payment have no default?
5. What does case BankTransfer(String iban) when iban.startsWith("BG") -> ... do?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·