В скором времени в грядущей Java 14 появится новая синтаксическая фича — записи (records). После изучения превью, в котором вкратце описано, как выглядят записи и с “чем их едят”, я осмелился адаптировать документ на русский для хабра. Кому интересно — добро пожаловать под кат.
Резюме
Записи позволяют расширить возможности Java. Они обеспечивают лаконичный синтаксис для объявления тех классов, которые являются простыми носителями постоянных, неизменяемых наборов данных.
Причины и цели
Жалобы на то, что “Java слишком многословен” и что с ним нужно много “церемониться” достаточно распространены. Причина тому классы, которые предназначены лишь для хранения некого набора данных. Чтобы правильно написать такой класс, нужно прописать много формального, повторяющегося и подверженного ошибкам кода: конструкторы, геттеры и сеттеры, equals (), hashCode (), toString () и т.д. Разработчики иногда халтурят и не переопределяют equals () и hashCode (), что в свою очередь может привести к нестандартному поведению или проблемам с отладкой. Либо, когда разработчики не хотят объявлять еще один класс, они прописывают альтернативный, но не совсем подходящий, только потому, что он имеет “правильную форму”.
Среды разработки помогут прописать большую часть кода в классе, но не помогут разработчику, читающему этот код, быстро сориентироваться среди десятков строк шаблонного кода и понять, что этот класс является обычным носителем данных. Java-код, моделирующий наборы стандартных данных, должен быть простым для написания, восприятия и проверки.
На первый взгляд может показаться, что записи предназначены для сокращения шаблонного кода. Мы же вкладываем в них семантическую цель: “моделирование данных как данных” (modeling data as data). Если семантика верна, то шаблонный код сделает все сам без участия разработчика. Ведь объявление постоянных наборов данных должно быть легким, ясным и лаконичным.
Цели, которых не было
Мы не ставили перед собой цель “объявить войну” шаблонному коду. В частности, мы не намеревались решить проблему изменяемых классов c использованием соглашения о наименованиях JavaBean-компонентов. Несмотря на то что свойства, метапрограммирование и генерация кода на основе аннотаций часто предлагаются в качестве «решений» этой проблемы, добавлять эти фичи также не было нашей целью.
Описание
Записи — это новый вид объявления типа в Java. Так же, как и enum, записи представляет собой ограниченный по функциональности класс. Он объявляет свое представление и предоставляет API, который основывается на этом представлении. Записи не отделяют API от представления и, в свою очередь, являются краткими.
Запись содержит имя и описание состояния. Описание состояния объявляет компоненты этой записи. Опционально, запись может иметь тело. Например:
record Point(int x, int y) { }
Поскольку семантически записи являются простыми носителями данных, они автоматически получают стандартные элементы:
- Приватное финальное поле для каждого компонента состояния;
- Публичный метод чтения для каждого компонента состояния с тем же именем и типом, что и у компонента;
- Публичный конструктор, совпадающий с сигнатурой записи; он инициализирует каждое поле из соответствующего аргумента;
- Реализации equals() и hashCode(), которые говорят, что две записи равны, если они одного типа и содержат одинаковое состояние;
- Реализация toString(), которая включает строковое представление всех компонентов записи с их именами.
Другими словами, представление записи полностью основано на описании состояния. Также на основе состояния записи происходит формирование equals(), hashCode() и toString().
Ограничения
Записи не могут наследовать какой-либо другой класс и не могут объявлять поля объекта, кроме приватных финальных полей, которые соответствуют компонентам состояния. Любые другие объявленные поля должны быть статическими. Эти ограничения гарантируют, что описание состояния само по себе определяет представление.
Записи являются финальными и не могут быть абстрактными. Эти ограничения указывают на то, что API записи определяется только описанием состояния и его нельзя будет расширить позже при помощи другого класса или записи.
Компоненты записи являются финальными. Это ограничение реализует принцип “неизменный по умолчанию”, который широко применяется для наборов данных.
Помимо упомянутых выше ограничений, записи ведут себя как обычные классы: они могут быть объявлены как верхнеуровневые или вложенные, могут быть дженериками, могут реализовывать интерфейсы. Записи создаются с помощью вызова оператора new. Тело записи может объявлять статические методы, статические поля, статические блоки инициализации, конструкторы, методы экземпляров, блоки инициализации экземпляра и вложенные типы. Запись и отдельные компоненты состояния могут быть помечены аннотациями. Если запись является вложенной, значит она статична; это исключает ситуацию с вложенными экземплярами, которые могли бы автоматически добавлять состояние к записи.
Явно объявляемые элементы записи
Хотя стандартная реализация геттеров, а также методов equals(), hashCode() и toString() является вполне приемлемой для большинства вариантов использования записей, у разработчика есть возможность переопределить стандартную реализацию. Однако, следует быть особенно осторожным при переопределении методов equals / hashCode.
Особое внимание уделяется явному объявлению канонического конструктора, сигнатура которого совпадает с описанием состояния записи. Конструктор может быть объявлен без формального списка параметров: в этом случае предполагается, что он совпадает с описанием состояния, а любые поля записи неявно инициализируются при стандартном закрытии тела конструктора из соответствующих формальных параметров (this. х = х) на выходе. Это позволяет каноническому конструктору выполнять только проверку и корректировку своих параметров, а также пропускать явную инициализацию полей. Например:
record Range(int lo, int hi) {
public Range {
if (lo > hi) /* referring here to the implicit constructor parameters */
throw new IllegalArgumentException(String.format("(%d,%d)", lo, hi));
}
}
Грамматика
RecordDeclaration:
{ClassModifier} record TypeIdentifier [TypeParameters]
(RecordComponents) [SuperInterfaces] [RecordBody]
RecordComponents:
{RecordComponent {, RecordComponent}}
RecordComponent:
{Annotation} UnannType Identifier
RecordBody:
{ {RecordBodyDeclaration} }
RecordBodyDeclaration:
ClassBodyDeclaration
RecordConstructorDeclaration
RecordConstructorDeclaration:
{Annotation} {ConstructorModifier} [TypeParameters] SimpleTypeName
[Throws] ConstructorBody
Аннотации для компонентов записи
Для компонентов записи можно применять аннотации объявлений, если они распространяются на компоненты, параметры, поля или методы. Аннотации объявлений, применимые к любому из этих компонентов, распространяются на неявные объявления любых обязательных элементов.
Аннотации типов, которые изменяют типы компонентов записи, распространяются на типы в неявных объявлениях обязательных элементов (например, параметров конструктора, объявлений полей и методов). Явные объявления обязательных элементов должны точно совпадать с типом соответствующего компонента записи, не включая аннотации типов.
Рефлексия (Reflection API)
В java.lang.Class будут добавлены следующие публичные методы:
- RecordComponent[] getRecordComponents()
- boolean isRecord()
Метод getRecordComponents() возвращает массив java.lang.reflect.RecordComponent, где java.lang.reflect.RecordComponent — это новый класс.
Элементы этого массива соответствуют компонентам записи и идут в том же порядке, в котором объявлены в записи. Дополнительную информацию можно извлечь из каждого RecordComponent в массиве, включая имя, тип, дженерик, а также получить его значение.
Метод isRecord() возвращает true в случае, если данный класс объявлен в качестве записи. (Аналогично методу isEnum()).
Альтернативы
Записи можно определить как условную форму кортежей. Вместо записей, мы можем использовать структурные кортежи. Несмотря на то, что кортежи предлагают более легковесные способы выражения некоторых наборов данных, результат часто является менее информативным:
- Главный принцип Java-философии состоит в том, что имена имеют значение. Классы и их элементы носят имена, релевантные их содержанию, в то время как кортежи и их компоненты — нет. То есть, класс Person со свойствами firstName и lastName более понятен и надежен, чем анонимный кортеж из String и String.
- Классы поддерживают валидацию состояния через свои конструкторы, кортежи — нет. Некоторые наборы данных, например числовые диапазоны, имеют инварианты, на которые впоследствии можно ссылаться, если они применяются конструктором;
- Классы могут обладать поведением, основанном на их состоянии; совмещение состояния и поведения делает само поведение более явным и доступным. Кортежи, представляя собой просто набор данных, не предлагают такой возможности.
Зависимости
Записи хорошо сочетаются с изолированными типами (JEP 360); вместе с изолированнами типами, записи образуют конструкцию, часто называемую алгебраическими типами данных. Кроме того, записи сами по себе допускают pattern matching. Поскольку записи связывают свой API с описанием состояния, мы в конечном итоге можем также получать паттерны деконструкции (deconstruction patterns) для записей и использовать информацию изолированных классов в операторе switch.
trolley813
То есть, что-то типа case-классов в Scala?
snuk182
Больше на дата-классы из Kotlin.
virex
Еще больше на record из Pascal.
LordDarklight
на record из Pascal совсем не похоже, покрайней мере потому, что record из Pascal — это структура, передаваемая и присваиваемая по значению (кстати в статье про это ни слова, даже когда сравнивали с кортежами). А ещё record из Pascal не генерит никаких доп. членов и вообще поддерживает только поля (хотя вот тут могу ошибаться — может что-то сейчас изменилось в актуальных компиляторах, но классический и всякий турбо/борланд паскали это не поддерживали). А вот Data-классы Kotlin на явовский рекорд очень похожи. А case-классы из Scala так вообще, наверное прародители данной концепции! А ещё в C# 8-ом такие record-классы обещали — но так и не завезли пока — хотят вот тут сходство с Java будет максимальным!