Java Records: углублённое изучение
Углублённое изучение Java records — канонические и компактные конструкторы, валидация и практические случаи применения.
Record — это способ объявить в Java класс, единственная задача которого — хранить данные. Впервые появившись в режиме предварительного просмотра в Java 14 и окончательно стабилизировавшись в Java 16, record сворачивает стандартный шаблонный код — приватные финальные поля, конструктор, методы доступа, equals, hashCode и toString — в одну строку заголовка. В предыдущей главе о records рассмотрен базовый синтаксис; здесь мы погружаемся глубже: как records работают на самом деле, каковы их канонические и компактные конструкторы, как они обеспечивают соблюдение инвариантов, какие гарантии неизменяемости вы получаете и где их применять (а где — нет).
Что генерирует компилятор
Когда вы пишете record Point(int x, int y) {}, компилятор создаёт final-класс с двумя полями private final, публичный конструктор, принимающий оба параметра, публичные методы доступа с именами, точно совпадающими с именами компонентов (x(), y() — без префикса get), а также основанные на значениях реализации equals, hashCode и toString.
record Point(int x, int y) {}
// Equivalent to (roughly) hand-writing:
// final class Point {
// private final int x;
// private final int y;
// Point(int x, int y) { this.x = x; this.y = y; }
// int x() { return x; }
// int y() { return y; }
// public boolean equals(Object o) { ... compares x and y ... }
// public int hashCode() { ... derived from x and y ... }
// public String toString() { return "Point[x=" + x + ", y=" + y + "]"; }
// }x и y в заголовке — это компоненты record. Все члены, генерируемые компилятором, полностью выводятся из них в порядке объявления.
Канонические и компактные конструкторы
У каждого record есть канонический конструктор, параметры которого соответствуют компонентам. Его редко пишут в полном виде — вместо этого используют компактный конструктор, который опускает список параметров и конечные присваивания this.field = field. Компилятор сначала выполняет ваш код, а затем присваивает (возможно, изменённые) параметры полям. Это естественное место для валидации и нормализации.
record Range(int low, int high) {
Range { // compact constructor — no (int low, int high)
if (low > high) {
throw new IllegalArgumentException("low must be <= high");
}
low = Math.max(low, 0); // reassigning the parameter normalizes the field
}
}Если вам когда-либо понадобится явный канонический конструктор (например, чтобы сделать защитную копию изменяемого компонента), напишите полную сигнатуру и выполните присваивания самостоятельно:
record Tags(String name, List<String> values) {
Tags(String name, List<String> values) { // explicit canonical constructor
this.name = name;
this.values = List.copyOf(values); // defensive, unmodifiable copy
}
}Неизменяемость и то, чем records не являются
Поля record являются final, поэтому ссылка, которую хранит каждый компонент, никогда не меняется после конструирования. Это делает records неизменяемыми на поверхностном уровне. Однако неизменяемость ограничивается ссылкой: если компонент указывает на изменяемый объект (например, ArrayList), вызывающий код, разделяющий этот объект, по-прежнему может изменять его содержимое. Защитные копии в каноническом конструкторе закрывают эту брешь.
| Свойство | Records | Обычные классы |
|---|---|---|
| Поля | всегда private final | на ваш выбор |
| Класс | неявно final | расширяемый, если не final |
| Суперкласс | всегда java.lang.Record | любой (по умолчанию Object) |
| Методы доступа | генерируются автоматически, без префикса get | создаются вручную |
equals/hashCode | основаны на значениях, генерируются | по умолчанию идентичность |
| Сеттеры | нет — неизменяемы | допускаются |
Поскольку record всегда расширяет java.lang.Record, он не может расширять другой класс. Тем не менее он может реализовывать интерфейсы, объявлять статические члены и добавлять методы экземпляра.
Добавление поведения, статических членов и фабричных методов
Record по-прежнему является классом. Вы можете добавить в него дополнительные методы, статические фабричные методы, статические поля и даже вложенные типы. Компоненты определяют состояние; всё остальное — обычный Java.
record Money(String currency, long cents) {
static Money of(String currency, long cents) { // static factory
return new Money(currency, cents);
}
Money plus(Money other) { // derived behavior
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException("currency mismatch");
}
return new Money(currency, cents + other.cents); // returns a new value
}
}Records также хорошо сочетаются с запечатанными типами и сопоставлением с образцом, моделируя замкнутые наборы форм данных — основу алгебраического стиля проектирования данных в современном Java. Запечатанный интерфейс фиксирует набор допустимых реализаций-records, а switch по этим records позволяет деструктурировать каждый из них по его компонентам в одном выражении.
Практический пример: records от начала до конца
Эта программа демонстрирует работу сгенерированных членов record, проверяет гарантии неизменяемости и свойства класса через рефлексию, обеспечивает соблюдение инварианта в компактном конструкторе, выводит компоненты record в порядке объявления и показывает работу records с коллекциями и добавленным поведением.
Что следует вынести из запуска:
Point, для которого не было написано никакого тела, всё равно вывелPoint[x=3, y=4], ответил наa.x()и сообщилequals by value: trueс совпадающими хэш-кодами — компилятор сгенерировал основанные на значенияхtoString, методы доступа,equalsиhashCodeисключительно из двух компонентов.- Рефлексия подтвердила контракт, гарантированный языком:
is final class : true(records нельзя создавать подклассы) иis a record : true(каждый record расширяетjava.lang.Record), что объясняет отсутствие сеттеров и неизменяемость полей. - Вызов
Range(9, 2)был отклонён с сообщениемlow must be <= high. Компактный конструктор выполняется до присвоения полей, поэтому record никогда не создаётся в недопустимом состоянии — валидация принадлежит именно туда, а не в отдельную проверку фабрики. getRecordComponents()вернул компоненты в порядке объявления:low:int high:int, демонстрируя, что структура record доступна через рефлексию — именно это является основой для библиотек сериализации и фреймворков, автоматически отображающих records.Money.of("USD", 500).plus(Money.of("USD", 250))вернулUSD 750, аdistinct()свёл два одинаковых значенияPoint(0,0)к2— records ведут себя как полноценные значения везде, включая потоки и множества, именно потому, что ихequals/hashCodeсравнивают содержимое.
Когда использовать record (и когда не стоит)
Выбирайте record, когда тип определяется своими данными и эти данные не меняются после создания объекта:
- DTO и полезные нагрузки запросов/ответов API.
- Ключи Map и элементы Set (основанные на значениях
equals/hashCodeдостаются бесплатно). - Возвращаемые типы, объединяющие несколько значений, заменяя одноразовые кортежи или out-параметры.
- «Листья» запечатанной иерархии, которые вы деструктурируете с помощью сопоставления с образцом.
Предпочтите обычный класс, когда:
- Объект имеет изменяемое состояние или жизненный цикл (сущности, строители, сервисы).
- Нужно расширить другой класс — records могут только реализовывать интерфейсы.
- Идентичность объекта важнее его содержимого (нужно ссылочное равенство).
Распространённая ловушка: метод доступа record возвращает хранимую ссылку как есть. Если компонент является изменяемым типом (List, массив, Date), сделайте защитную копию в каноническом конструкторе — как в примере Tags выше с List.copyOf — иначе вызывающий код сможет изменить «неизменяемое» состояние record через переданную ссылку.