Към съдържанието
wannacode

record, switch и шаблони

Проблем

Мястото е заето, но HashMap не го намира

Резервации в кино: ключът е мястото (ред и номер), стойността е името на човека.

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), "Мария");
System.out.println(reservations.get(new Seat(5, 12)));
System.out.println(reservations.containsKey(new Seat(5, 12)));
Изход
null
false
Мария е на ред 5, място 12, но get връща null. Помниш защо от урока за хеширането: new Seat(5, 12) два пъти са два различни обекта, а Seat не казва кога са равни. Поправката е equals и hashCode, а за отпечатване и toString, плюс getter-и. Около 30 реда за клас, който само носи две числа.

Идеята

record: клас за данни

От Java 16 същото е на един ред:
record Seat(int row, int number) {}
Map<Seat, String> reservations = new HashMap<>();
reservations.put(new Seat(5, 12), "Мария");
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("Ред " + seat.row() + ", място " + seat.number());
Изход
Мария
true
Seat[row=5, number=12]
Ред 5, място 12

record е клас, чиято работа е да носи данни. В скобите са компонентите му, а Java пише вместо теб:

  • частно final поле за всеки компонент;
  • конструктор с всички компоненти;
  • метод за четене на всяко поле, със същото име: row(), а не getRow();
  • equals и hashCode, които сравняват полетата. Затова get вече намира Мария;
  • toString във вида Seat[row=5, number=12].

Setter-и няма. Обект от record не се променя след създаването си.

Map<Seat, String> reservations = new HashMap<>();
reservations.put(new Seat(5, 12), "Мария");
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("Ред " + seat.row() + ", място " + seat.number());
Стъпка по стъпка, с думи
  1. Празен речник: ключът е място в киното, стойността е име.
  2. put слага ключ Seat(5, 12) със стойност „Мария“.
  3. get получава нов обект Seat. Това е друг обект в паметта, не ключът от put.
  4. hashCode на record се смята от полетата, затова е еднакъв: търсенето отива на същото място.
  5. equals на record сравнява поле по поле: ред 5 и 5, място 12 и 12. Равни са, и get връща „Мария“.
  6. containsKey прави същото с още един нов Seat: true.
  7. toString и методите row() и number() record-ът също пише сам.
По-точно, за любопитните
  • record е final: не може да се наследи. Неявно наследява java.lang.Record и не може да наследи друг клас, но може да имплементира интерфейси.
  • Може да има свои методи, статични полета и статични методи. Полета на обекта извън компонентите не може да има:
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)
  • Неизменността е само на първо ниво. Ако компонентът е List, полето винаги сочи същия списък, но самият списък може да се променя.
  • В модул „Колекции“ правеше equals и hashCode на ръка. С record това вече го има. Ако ти трябва друго правило за равенство, пак можеш да ги презапишеш.

Идеята

Проверка и методи в record

Цената не може да е отрицателна. Проверката отива в компактен конструктор: без скоби и без параметри, той се изпълнява преди полетата да се запишат.
record Price(double amount, String currency) {
Price {
if (amount < 0) {
throw new IllegalArgumentException("Цената не може да е отрицателна: " + 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]
Цената не може да е отрицателна: -5.0
  • В компактния конструктор amount и currency са параметрите. Не пишеш this.amount = amount: това става само след проверката.
  • withDiscount не променя цената. Връща нова Price. Така се работи с обекти, които не се променят: coffee си остава 18,50 €.

Пробвай

Равни ли са?

Seat е record, както в картата преди тази.

Какво ще отпечата програмата?

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());
Отговор

Пробвай

Доставката излиза скъпа

Доставката до офис е 4,50 €, а програмата казва 6,90 €. На кой ред е грешката?

Избери реда с грешката.

Идеята

switch, който връща стойност

От Java 14 switch може да е израз: дава стойност, която записваш в променлива или връщаш.
String method = "офис";
double price = switch (method) {
case "офис" -> 4.50;
case "куриер", "до адрес" -> 6.90;
case "магазин" -> 0;
default -> throw new IllegalArgumentException("Непознат начин на доставка: " + method);
};
System.out.println("Доставка: " + price + " €");
Изход
Доставка: 4.5 €
  • След -> стои стойността на случая. Пропадане няма, break не се пише.
  • Един случай може да има няколко стойности: case "куриер", "до адрес".
  • В случая може да хвърлиш изключение, както в default.
  • Изразът трябва да даде стойност за всеки възможен вход. При текст входовете са безброй, затова default е задължителен. Без него:
double price = switch (method) {
case "офис" -> 4.50;
case "куриер" -> 6.90;
};
Грешка
Main.java:4: error: the switch expression does not cover all possible input values
double price = switch (method) {
^

Ако случаят има няколко реда, той е в { }, а стойността се дава с yield:

static double delivery(String method, double weightKg) {
return switch (method) {
case "офис" -> 4.50;
case "куриер" -> {
double base = 6.90;
yield weightKg > 10 ? base + 3 : base;
}
default -> 0;
};
}
System.out.println(delivery("куриер", 4));
System.out.println(delivery("куриер", 12));
Изход
6.9
9.9
По-точно, за любопитните
  • Стрелки може да пишеш и в switch, който не връща стойност, а само прави нещо. Тогава също няма пропадане.
  • yield е като return, но за switch, а не за метода.
  • Старият запис case "офис": ... break; още работи и ще го срещаш в стар код. Двата вида не се смесват в един switch.

Идеята

sealed: затворено семейство

Магазинът приема три начина на плащане: карта, банков превод и наложен платеж. Друг няма. Това може да се каже на компилатора:

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

sealed значи „запечатан“. След permits е списъкът с типовете, които имат право да имплементират Payment. Опит за четвърти не се компилира:

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

Наследниците тук са record, защото всеки начин на плащане е само данни: номер на карта, IBAN или нищо.

По-точно, за любопитните
  • Всеки наследник на sealed тип е final, sealed (и той с permits) или non-sealed (отворен отново). record е final по правило.
  • Ако всички наследници са в същия файл, permits може да се пропусне.
  • sealed може да е и абстрактен клас: sealed abstract class Shape permits Circle, Square.

Виж го

Шаблони в switch

Таксата зависи от начина на плащане. От Java 21 switch проверява типа на обекта:
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.0
0.5
2.0

case CardPayment card значи „ако обектът е CardPayment, наречи го card“. Това е instanceof с шаблон от модул „Наследяване“, но за много типове наведнъж.

default няма. Компилаторът знае трите наследника и проверява, че всеки е покрит. Забравиш ли един, кодът не се компилира:

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

Това е ползата от sealed. Ако утре добавиш четвърти начин на плащане, компилаторът ще ти покаже всеки switch, който трябва да научи за него.

Шаблонът за record вади полетата направо, а when добавя условие към случая:

static String describe(Payment payment) {
return switch (payment) {
case CardPayment(String number) -> "карта ****" + number.substring(number.length() - 4);
case BankTransfer(String iban) when iban.startsWith("BG") -> "превод от българска сметка";
case BankTransfer(String iban) -> "превод от чужбина";
case CashOnDelivery() -> "наложен платеж";
};
}
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()));
Изход
карта ****4444
превод от българска сметка
превод от чужбина
наложен платеж
По-точно, за любопитните
  • Шаблонът за record работи и с instanceof:
Payment payment = new CardPayment("4111222233334444");
if (payment instanceof CardPayment(String number)) {
System.out.println("Картата завършва на " + number.substring(12));
}
Изход
Картата завършва на 4444
  • Ако обектът е null, switch хвърля NullPointerException. Ако null е смислен вход, добави case null -> ....
  • Шаблоните се вгнездяват: case Order(Customer(String name), double total).

Пробвай

Кой случай печели?

Случаите се проверяват отгоре надолу. Печели първият, който пасва.

Какво ще отпечата програмата?

static String label(Payment payment, double amount) {
return switch (payment) {
case CashOnDelivery cash when amount > 500 -> "наложен платеж: не се приема над 500 €";
case CashOnDelivery cash -> "наложен платеж";
case CardPayment card when amount > 1000 -> "карта: нужно е потвърждение";
case CardPayment card -> "карта";
case BankTransfer transfer -> "превод";
};
}
System.out.println(label(new CashOnDelivery(), 650));
System.out.println(label(new CashOnDelivery(), 80));
System.out.println(label(new CardPayment("4111222233334444"), 650));
Отговор
По-точно, за любопитните

Случаят с when трябва да е преди общия за същия тип. Обратното не се компилира, защото общият случай хваща всичко и този с when никога не би се стигнал:

case CashOnDelivery cash -> "наложен платеж";
case CashOnDelivery cash when amount > 500 -> "наложен платеж: не се приема над 500 €";
Грешка
Main.java:11: error: this case label is dominated by a preceding case label
case CashOnDelivery cash when amount > 500 -> "наложен платеж: не се приема над 500 €";
^

Идеята

Текстови блокове и var

Текст на няколко реда, като касова бележка или JSON, с " и \n на всеки ред се чете трудно. От Java 15 има текстов блок между """ и """:

String receipt = """
Магазин „Ъгъла“
Кафе на зърна 18.50 €
Мед 12.00 €
Общо: 30.50 €
""";
System.out.print(receipt);
String json = """
{"customer": "%s", "total": %.2f}
""".formatted("Мария", 75.5);
System.out.print(json);
Изход
Магазин „Ъгъла“
Кафе на зърна 18.50 €
Мед 12.00 €
Общо: 30.50 €
{"customer": "Мария", "total": 75.50}
  • След отварящите """ текстът започва на нов ред.
  • Общият отстъп отляво се маха. Мярката е най-левият ред, включително затварящите """.
  • Кавичките вътре не се екранират.
  • formatted(...) попълва %s и %.2f, както String.format.

И едно напомняне от „Основи на езика“: var оставя компилатора да изведе типа. С дългите типове от колекциите той спестява най-много:

var prices = new LinkedHashMap<String, Double>();
prices.put("кафе", 18.50);
prices.put("мед", 12.00);
for (var entry : prices.entrySet()) {
System.out.println(entry.getKey() + ": " + entry.getValue() + " €");
}
Изход
кафе: 18.5 €
мед: 12.0 €

var е само за локални променливи със стойност. Типът е определен веднъж и не се сменя. Полета, параметри и var x = null; не може.

Упражнение

Модерна Java

Реши задачите в редактора си.

1. Фигури. sealed interface Shape с три record наследника: Circle(double radius), Rectangle(double width, double height) и Triangle(double base, double height). Напиши area(Shape) със switch и шаблони за record, без default. Напиши и name(Shape), който за правоъгълник с равни страни казва „квадрат“ (с when). Отпечатай кръг с радиус 1, правоъгълник 2 на 3, квадрат 4 на 4, триъгълник с основа 3 и височина 5 и общата площ.
Изход
кръг: 3.14
правоъгълник: 6.00
квадрат: 16.00
триъгълник: 7.50
Общо: 32.64
2. Цена на доставка. sealed interface Delivery с ToOffice(String office), ToAddress(String city, double weightKg) и Pickup(). Цената: до офис 4,50 €; до адрес 5,90 € в София и 6,90 € другаде, плюс 1 € за всеки килограм над 5; от магазина 0 €. Отпечатай всяка доставка (с toString на record) и цената ѝ.
Изход
ToOffice[office=Варна Център] -> 4.50 €
ToAddress[city=София, weightKg=2.0] -> 5.90 €
ToAddress[city=Варна, weightKg=8.0] -> 9.90 €
Pickup[] -> 0.00 €
3. Точка като ключ. Куриерът минава през точки по картата: (2, 3), (5, 1), (2, 3), (0, 0), (5, 1), (2, 3). Направи record Point(int x, int y) и преброй колко пъти е бил във всяка точка с LinkedHashMap<Point, Integer> и merge(p, 1, Integer::sum). Накрая потърси new Point(2, 3) в речника.
Изход
Point[x=2, y=3]: 3
Point[x=5, y=1]: 2
Point[x=0, y=0]: 1
Посещения в (2, 3): 3

Още задачи

  • Студент като record. record Student(String name, double grade) с компактен конструктор, който отказва оценка извън 2–6. Подреди списък от студенти с Comparator.comparing(Student::grade).
  • Ден от седмицата. Пренапиши задачата „ден от седмицата“ от урока „Условия“ със switch израз: case 1, 2, 3, 4, 5 -> "делник".
  • Писмо. С текстов блок и formatted направи писмо за потвърждение на резервация: име, дата, брой гости и цена в евро.

Тест

Провери се

1. Какво получаваш наготово с record Seat(int row, int number) {}?
2. Защо record работи като ключ в HashMap, а обикновен клас без нищо добавено не?
3. Какво е предимството на switch със стрелки пред стария switch?
4. sealed interface Payment permits CardPayment, BankTransfer, CashOnDelivery. Защо switch по Payment може да е без default?
5. Какво прави case BankTransfer(String iban) when iban.startsWith("BG") -> ...?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·