Урок 4 из 11 базовый около 30 мин
Records: неизменяемые данные без шаблонного кода
Объявляем запись одной строкой, получаем конструктор, equals и toString бесплатно и добавляем проверки в компактном конструкторе.
Зачем нужны записи
Большая часть классов в прикладных программах — это просто контейнеры данных: точка, адрес, строка отчёта, ответ сервера. Для каждого такого класса в старой Java приходилось писать поля, конструктор, геттеры, equals, hashCode и toString, то есть сорок строк ради двух полей. Запись (record, стандарт с Java 16) описывает то же самое одной строкой и гарантирует, что данные не изменятся после создания.
record Point(int x, int y) {}
var p = new Point(3, 4);
System.out.println(p.x()); // 3
System.out.println(p); // Point[x=3, y=4]
System.out.println(p.equals(new Point(3, 4))); // true
Компилятор сам создаёт канонический конструктор с параметрами в порядке объявления, методы доступа с именами компонентов без приставки get, а также equals, hashCode и toString, основанные на всех компонентах.
Что запись гарантирует и чего не гарантирует
- Все компоненты —
final-поля: у записи нет сеттеров, и она не может наследоваться от другого класса. - Сама запись
final: расширить её тоже нельзя, но она может реализовывать интерфейсы. - Неизменяемость поверхностная: если компонент — изменяемый список, его содержимое по-прежнему можно поменять снаружи. Об этом ниже.
- Две записи равны, когда равны все компоненты. Ссылочное сравнение
==по-прежнему сравнивает адреса.
Компактный конструктор: проверки и нормализация
Проверять входные данные в записи можно, не переписывая весь конструктор. Компактный конструктор объявляется без списка параметров, а присваивание полей компилятор дописывает сам в конце:
record Money(long kopecks, String currency) {
Money {
if (kopecks < 0) {
throw new IllegalArgumentException("Сумма не может быть отрицательной");
}
currency = currency.strip().toUpperCase(); // нормализация до присваивания
}
static Money rub(long rubles) {
return new Money(rubles * 100, "RUB");
}
Money plus(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("Разные валюты");
}
return new Money(kopecks + other.kopecks, currency);
}
}
var total = Money.rub(150).plus(Money.rub(49));
System.out.println(total); // Money[kopecks=19900, currency=RUB]
Этот пример не проверить в JShell
Оболочка JShell из JDK 21 не умеет объявлять записи с компактным конструктором: она отвечает invalid canonical constructor in record Money (attempting to assign stronger access privileges; was public). Это ограничение самой оболочки, а не ошибка в коде — сохраните пример в файл и запустите командой java Money.java. Записи без компактного конструктора JShell обрабатывает нормально.
Запись может содержать статические фабричные методы и обычные методы экземпляра. Изменить состояние они не могут, поэтому «модифицирующие» операции возвращают новую запись, как plus в примере.
Защита изменяемых компонентов
Если компонент — коллекция, сделайте в компактном конструкторе защитную копию. List.copyOf создаёт неизменяемый список и отвергает null-элементы:
record Group(String name, java.util.List<String> members) {
Group {
members = java.util.List.copyOf(members);
}
}
var source = new java.util.ArrayList<>(java.util.List.of("Анна", "Борис"));
var group = new Group("Первая", source);
source.add("Вера");
System.out.println(group.members().size()); // 2 — копия не изменилась
Записи как ключи и элементы множеств
Благодаря корректным equals и hashCode записи безопасно использовать в HashMap и HashSet. Это избавляет от классической ошибки, когда самодельный класс без hashCode «теряется» в множестве:
record Cell(int row, int column) {}
var visited = new java.util.HashSet<Cell>();
visited.add(new Cell(1, 1));
System.out.println(visited.contains(new Cell(1, 1))); // true
Локальные и вложенные записи
Запись можно объявить прямо внутри метода, чтобы временно связать несколько значений. Это удобнее, чем массив или пара из библиотеки:
static String longestWord(java.util.List<String> words) {
record Scored(String word, int length) {}
return words.stream()
.map(w -> new Scored(w, w.length()))
.max(java.util.Comparator.comparingInt(Scored::length))
.map(Scored::word)
.orElse("");
}
Локальные записи неявно статические: они не видят переменные окружающего метода и не захватывают this.
Когда запись не подходит
Если объекту нужно менять состояние (счётчик, кэш, сущность с изменяемыми полями), наследование от другого класса или скрытые поля, не входящие в равенство, — используйте обычный класс. Запись — это прозрачный носитель данных, а не универсальная замена классам.
Записи и сериализация в JSON
Современные библиотеки (Jackson, Gson последних версий, встроенные средства фреймворков) создают записи из JSON и обратно без дополнительных аннотаций: имена компонентов совпадают с ключами. Это ещё одна причина описывать структуры запросов и ответов именно записями.
Попробуйте сами
- Объявите запись
Temperature(double celsius)с компактным конструктором, который запрещает значения ниже абсолютного нуля (−273,15 °C), и методомfahrenheit(). - Создайте запись
Student(String name, java.util.List<Integer> grades)с защитной копией списка и методомaverage(). Проверьте, что изменение исходного списка не влияет на запись. - Положите десять записей
Cellс повторяющимися координатами вHashSetи объясните, почему размер множества меньше десяти.