JEP 395: Records
Records (записи)
| Ответственный | Gavin Bierman |
| Тип | Feature |
| Область | SE |
| Статус | Closed / Delivered |
| Выпуск | 16 |
| Компонент | specification / language |
| Обсуждение | amber dash dev at openjdk dot java dot net |
| Связан с | JEP 359: Records (Preview) |
| JEP 384: Records (Second Preview) | |
| Рецензенты | Alex Buckley, Brian Goetz |
| Одобрен | Brian Goetz |
| Создан | 2020/06/08 16:07 |
| Обновлён | 2026/09/03 21:20 |
| Задача | 8246771 |
Аннотация
Дополнить язык программирования Java возможностью Records: это классы, которые служат прозрачными носителями неизменяемых данных. Records можно рассматривать как номинальные кортежи.
История
Records были предложены в JEP 359 и вошли в JDK 14 как возможность в статусе Preview (предварительная версия).
По итогам отзывов дизайн был доработан в JEP 384 и вошёл в JDK 15 повторно как Preview-возможность. Во второй версии Preview были внесены следующие доработки:
-
В первой версии Preview канонические конструкторы должны были быть
public. Во второй версии Preview, если канонический конструктор объявлен неявно, его модификатор доступа совпадает с модификатором доступа record-класса; если канонический конструктор объявлен явно, его модификатор доступа должен предоставлять как минимум такой же доступ, как у record-класса. -
Смысл аннотации
@Overrideбыл расширен: теперь он охватывает и случай, когда аннотированный метод является явно объявленным методом доступа к компоненту record-класса. -
Чтобы обеспечить использование компактных конструкторов по назначению, присваивание любому из полей экземпляра в теле конструктора стало ошибкой компиляции.
-
Появилась возможность объявлять локальные record-классы, локальные enum-классы и локальные интерфейсы.
Этот JEP предлагает сделать возможность окончательной в JDK 16 со следующей доработкой:
- Ослабить давнее ограничение, согласно которому внутренний класс не может объявлять член, который явно или неявно является статическим. Это станет допустимым и, в частности, позволит внутреннему классу объявлять член, который является record-классом.
С учётом дальнейших отзывов могут быть внесены дополнительные доработки.
Цели
-
Разработать объектно-ориентированную конструкцию, выражающую простую совокупность значений.
-
Помочь разработчикам сосредоточиться на моделировании неизменяемых данных, а не расширяемого поведения.
-
Автоматически реализовывать методы, определяемые данными, такие как
equalsи методы доступа. -
Сохранить давние принципы Java, такие как номинальная типизация и совместимость при миграции.
Что не является целью
-
Хотя Records действительно делают объявление классов-носителей данных более лаконичным, объявлять «войну шаблонному коду» не является целью. В частности, целью не является решение проблем изменяемых классов, использующих соглашения об именовании JavaBeans.
-
Целью не является добавление таких возможностей, как свойства или генерация кода на основе аннотаций, которые часто предлагаются для упрощения объявления классов для «Plain Old Java Objects».
Мотивация
Часто жалуются, что «Java слишком многословна» или в ней «слишком много церемоний». Одни из худших примеров — классы, которые представляют собой не более чем неизменяемые носители данных для нескольких значений. Чтобы правильно написать такой класс-носитель данных, нужно много малоценного, повторяющегося и подверженного ошибкам кода: конструкторы, методы доступа, equals, hashCode, toString и т. д. Например, класс для хранения координат x и y неизбежно получается таким:
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) {
if (!(o instanceof Point)) return false;
Point other = (Point) o;
return other.x == x && other.y == y;
}
public int hashCode() {
return Objects.hash(x, y);
}
public String toString() {
return String.format("Point[x=%d, y=%d]", x, y);
}
}
У разработчиков иногда возникает соблазн срезать углы: опустить такие методы, как equals, что приводит к неожиданному поведению или затрудняет отладку, или приспособить для дела другой, не вполне подходящий класс, потому что он имеет «нужную форму», а объявлять ещё один класс не хочется.
IDE помогают нам написать большую часть кода класса-носителя данных, но никак не помогают читателю извлечь из десятков строк шаблонного кода замысел «я — носитель данных для x и y». Код на Java, моделирующий несколько значений, должно быть проще писать, читать и проверять на корректность.
Хотя на первый взгляд заманчиво считать, что Records прежде всего нужны для сокращения шаблонного кода, мы выбираем более семантическую цель: моделировать данные как данные. (Если семантика правильная, шаблонный код исчезнет сам собой.) Объявлять классы-носители данных, которые по умолчанию делают свои данные неизменяемыми и предоставляют идиоматичные реализации методов, создающих и потребляющих эти данные, должно быть просто и лаконично.
Описание
Record-классы — новый вид классов в языке Java. Record-классы помогают моделировать простые совокупности данных с меньшими церемониями, чем обычные классы.
Объявление record-класса в основном состоит из объявления его состояния; record-класс затем обязуется предоставлять API, соответствующий этому состоянию. Это означает, что record-классы отказываются от свободы, которой обычно обладают классы, — возможности отделить API класса от его внутреннего представления, — но взамен объявления record-классов становятся значительно лаконичнее.
Точнее, объявление record-класса состоит из имени, необязательных параметров типа, заголовка и тела. В заголовке перечисляются компоненты record-класса — переменные, составляющие его состояние. (Этот список компонентов иногда называют описанием состояния.) Например:
record Point(int x, int y) { }
Поскольку record-классы семантически заявляют, что являются прозрачными носителями своих данных, record-класс автоматически получает многие стандартные члены:
-
для каждого компонента в заголовке — два члена: метод доступа
publicс тем же именем и типом возвращаемого значения, что и у компонента, и полеprivatefinalтого же типа, что и компонент; -
канонический конструктор, сигнатура которого совпадает с заголовком и который присваивает каждому private-полю соответствующий аргумент из выражения
new, создающего экземпляр record-класса; -
методы
equalsиhashCode, которые гарантируют, что два record-значения равны, если они одного типа и содержат равные значения компонентов; и -
метод
toString, возвращающий строковое представление всех компонентов record-класса вместе с их именами.
Иными словами, заголовок record-класса описывает его состояние, т. е. типы и имена его компонентов, а API выводится механически и полностью из этого описания состояния. API включает протоколы создания, доступа к членам, проверки на равенство и отображения. (Мы ожидаем, что в будущей версии появится поддержка деконструирующих шаблонов, которые позволят использовать мощный Pattern Matching (сопоставление с образцом).)
Конструкторы record-классов
Правила для конструкторов в record-классе отличаются от правил в обычном классе. Обычный класс без объявлений конструкторов автоматически получает конструктор по умолчанию. Record-класс без объявлений конструкторов, напротив, автоматически получает канонический конструктор, который присваивает всем private-полям соответствующие аргументы выражения new, создавшего экземпляр record-класса. Например, объявленный ранее record-класс record Point(int x, int y) { } компилируется так, как если бы он был таким:
record Point(int x, int y) {
// Implicitly declared fields
private final int x;
private final int y;
// Other implicit declarations elided ...
// Implicitly declared canonical constructor
Point(int x, int y) {
this.x = x;
this.y = y;
}
}
Канонический конструктор можно объявить явно со списком формальных параметров, соответствующим заголовку record-класса, как показано выше. Его также можно объявить компактнее, опустив список формальных параметров. В таком компактном каноническом конструкторе параметры объявляются неявно, а private-полям, соответствующим компонентам record-класса, нельзя присваивать значения в теле: им автоматически присваиваются соответствующие формальные параметры (this.x = x;) в конце конструктора. Компактная форма помогает разработчикам сосредоточиться на проверке и нормализации параметров без утомительной работы по присваиванию параметров полям.
Например, вот компактный канонический конструктор, который проверяет свои неявные формальные параметры:
record Range(int lo, int hi) {
Range {
if (lo > hi) // referring here to the implicit constructor parameters
throw new IllegalArgumentException(String.format("(%d,%d)", lo, hi));
}
}
Вот компактный канонический конструктор, который нормализует свои формальные параметры:
record Rational(int num, int denom) {
Rational {
int gcd = gcd(num, denom);
num /= gcd;
denom /= gcd;
}
}
Это объявление эквивалентно обычной форме конструктора:
record Rational(int num, int denom) {
Rational(int num, int denom) {
// Normalization
int gcd = gcd(num, denom);
num /= gcd;
denom /= gcd;
// Initialization
this.num = num;
this.denom = denom;
}
}
Record-классы с неявно объявленными конструкторами и методами обладают важными и интуитивно понятными семантическими свойствами. Например, рассмотрим record-класс R, объявленный следующим образом:
record R(T1 c1, ..., Tn cn){ }
Если экземпляр r1 класса R копируется следующим образом:
R r2 = new R(r1.c1(), r1.c2(), ..., r1.cn());
то, при условии что r1 не является нулевой ссылкой, выражение r1.equals(r2) всегда будет давать true. Явно объявленные методы доступа и методы equals должны соблюдать этот инвариант. Однако в общем случае компилятор не может проверить, что явно объявленные методы соблюдают этот инвариант.
Например, следующее объявление record-класса следует считать плохим стилем, поскольку его методы доступа «незаметно» изменяют состояние экземпляра record-класса и приведённый выше инвариант не выполняется:
record SmallPoint(int x, int y) {
public int x() { return this.x < 100 ? this.x : 100; }
public int y() { return this.y < 100 ? this.y : 100; }
}
Кроме того, для всех record-классов неявно объявленный метод equals реализован так, что он рефлексивен и ведёт себя согласованно с hashCode для record-классов с компонентами с плавающей точкой. Явно объявленные методы equals и hashCode также должны вести себя аналогично.
Правила для record-классов
По сравнению с обычным классом на объявление record-класса накладывается множество ограничений:
-
В объявлении record-класса нет предложения
extends. Суперклассом record-класса всегда являетсяjava.lang.Record, подобно тому как суперклассом enum-класса всегда являетсяjava.lang.Enum. Хотя обычный класс может явно расширять свой неявный суперклассObject, record-класс не может явно расширять никакой класс, даже свой неявный суперклассRecord. -
Record-класс неявно является
finalи не может бытьabstract. Эти ограничения подчёркивают, что API record-класса определяется исключительно его описанием состояния и не может быть позже расширен другим классом. -
Поля, выведенные из компонентов record-класса, являются
final. Это ограничение воплощает политику неизменяемости по умолчанию, которая широко применима к классам-носителям данных. -
Record-класс не может явно объявлять поля экземпляра и не может содержать инициализаторы экземпляра. Эти ограничения гарантируют, что состояние record-значения определяется только заголовком record-класса.
-
Любое явное объявление члена, который иначе был бы выведен автоматически, должно в точности совпадать по типу с автоматически выведенным членом, без учёта аннотаций в явном объявлении. Любая явная реализация методов доступа или методов
equalsиhashCodeдолжна тщательно сохранять семантические инварианты record-класса. -
Record-класс не может объявлять методы
native. Если бы record-класс мог объявить методnative, то поведение record-класса по определению зависело бы от внешнего состояния, а не от явного состояния record-класса. Никакой класс с native-методами вряд ли будет хорошим кандидатом для миграции в record-класс.
Помимо перечисленных выше ограничений, record-класс ведёт себя как обычный класс:
-
Экземпляры record-классов создаются с помощью выражения
new. -
Record-класс может быть объявлен на верхнем уровне или как вложенный, а также может быть обобщённым.
-
Record-класс может объявлять
static-методы, поля и инициализаторы. -
Record-класс может объявлять методы экземпляра.
-
Record-класс может реализовывать интерфейсы. Record-класс не может указывать суперкласс, поскольку это означало бы унаследованное состояние сверх состояния, описанного в заголовке. Однако record-класс может свободно указывать суперинтерфейсы и объявлять методы экземпляра для их реализации. Как и в случае классов, интерфейс может с пользой описывать поведение многих record-классов. Поведение может быть независимым от предметной области (например,
Comparable) или специфичным для неё — в этом случае record-классы могут входить в sealed-иерархию, отражающую предметную область (см. ниже). -
Record-класс может объявлять вложенные типы, в том числе вложенные record-классы. Если record-класс сам является вложенным, то он неявно статический; так исключается непосредственно объемлющий экземпляр, который незаметно добавлял бы состояние record-классу.
-
Record-класс и компоненты в его заголовке могут быть снабжены аннотациями. Все аннотации на компонентах record-класса распространяются на автоматически выводимые поля, методы и параметры конструктора в соответствии с набором допустимых целей аннотации. Аннотации типов на типах компонентов record-класса также распространяются на соответствующие использования типов в автоматически выводимых членах.
-
Экземпляры record-классов можно сериализовать и десериализовать. Однако этот процесс нельзя настроить, предоставив методы
writeObject,readObject,readObjectNoData,writeExternalилиreadExternal. Сериализацией управляют компоненты record-класса, а десериализацией — канонический конструктор record-класса.
Локальные record-классы
Программа, которая создаёт и потребляет экземпляры record-класса, скорее всего, имеет дело со множеством промежуточных значений, которые сами являются простыми группами переменных. Часто будет удобно объявлять record-классы для моделирования этих промежуточных значений. Один вариант — объявить статические вложенные «вспомогательные» record-классы, подобно тому как многие программы сегодня объявляют вспомогательные классы. Более удобным вариантом было бы объявить record-класс внутри метода, рядом с кодом, который работает с переменными. Поэтому мы вводим локальные record-классы, аналогичные существующей конструкции локальных классов.
В следующем примере совокупность продавца и суммы продаж за месяц моделируется локальным record-классом MerchantSales. Использование этого record-класса делает последующие потоковые операции более читаемыми:
List<Merchant> findTopMerchants(List<Merchant> merchants, int month) {
// Local record
record MerchantSales(Merchant merchant, double sales) {}
return merchants.stream()
.map(merchant -> new MerchantSales(merchant, computeSales(merchant, month)))
.sorted((m1, m2) -> Double.compare(m2.sales(), m1.sales()))
.map(MerchantSales::merchant)
.collect(toList());
}
Локальные record-классы — частный случай вложенных record-классов. Как и вложенные record-классы, локальные record-классы неявно статические. Это означает, что их собственные методы не могут обращаться к переменным объемлющего метода; в свою очередь, так исключается захват непосредственно объемлющего экземпляра, который незаметно добавлял бы состояние record-классу. То, что локальные record-классы неявно статические, отличает их от локальных классов, которые неявно статическими не являются. Более того, локальные классы никогда не бывают статическими — ни неявно, ни явно — и всегда могут обращаться к переменным объемлющего метода.
Локальные enum-классы и локальные интерфейсы
Добавление локальных record-классов даёт возможность добавить и другие виды неявно статических локальных объявлений.
Вложенные enum-классы и вложенные интерфейсы уже неявно статические, поэтому для единообразия мы вводим локальные enum-классы и локальные интерфейсы, которые также неявно статические.
Статические члены внутренних классов
Сейчас спецификация определяет как ошибку компиляции ситуацию, когда внутренний класс объявляет член, явно или неявно статический, если только этот член не является константной переменной. Это означает, например, что внутренний класс не может объявить член, являющийся record-классом, поскольку вложенные record-классы неявно статические.
Мы ослабляем это ограничение, чтобы внутренний класс мог объявлять члены, явно или неявно статические. В частности, так внутренний класс может объявить статический член, являющийся record-классом.
Аннотации на компонентах record-класса
Компоненты record-класса играют в объявлениях record-классов несколько ролей. Компонент record-класса — понятие первого класса, но каждый компонент также соответствует полю с тем же именем и типом, методу доступа с тем же именем и возвращаемым типом и формальному параметру канонического конструктора с тем же именем и типом.
Отсюда возникает вопрос: что именно аннотируется, когда аннотирован компонент? Ответ: «все элементы, к которым применима данная аннотация». Так классы, использующие аннотации на своих полях, параметрах конструктора или методах доступа, можно перевести на record-классы без избыточного объявления этих членов. Например, класс вроде следующего
public final class Card {
private final @MyAnno Rank rank;
private final @MyAnno Suit suit;
@MyAnno Rank rank() { return this.rank; }
@MyAnno Suit suit() { return this.suit; }
...
}
можно перевести в эквивалентное и гораздо более читаемое объявление record-класса:
public record Card(@MyAnno Rank rank, @MyAnno Suit suit) { ... }
Применимость аннотации объявляется с помощью мета-аннотации @Target. Рассмотрим следующее:
@Target(ElementType.FIELD)
public @interface I1 {...}
Здесь аннотация @I1 объявляется применимой к объявлениям полей. Можно объявить, что аннотация применима более чем к одному виду объявлений, например:
@Target({ElementType.FIELD, ElementType.METHOD})
public @interface I2 {...}
Здесь аннотация @I2 объявляется применимой и к объявлениям полей, и к объявлениям методов.
Вернёмся к аннотациям на компоненте record-класса: эти аннотации появляются в соответствующих местах программы, где они применимы. Иными словами, распространением управляет разработчик с помощью мета-аннотации @Target. Правила распространения систематичны и интуитивно понятны, и выполняются все применимые из них:
-
Если аннотация на компоненте record-класса применима к объявлению поля, она появляется на соответствующем закрытом поле.
-
Если аннотация на компоненте record-класса применима к объявлению метода, она появляется на соответствующем методе доступа.
-
Если аннотация на компоненте record-класса применима к формальному параметру, она появляется на соответствующем формальном параметре канонического конструктора, если он не объявлен явно, или же на соответствующем формальном параметре компактного конструктора, если он объявлен явно.
-
Если аннотация на компоненте record-класса применима к типу, она будет распространена на всё следующее:
- тип соответствующего поля
- возвращаемый тип соответствующего метода доступа
- тип соответствующего формального параметра канонического конструктора
- тип компонента record-класса (доступный во время выполнения через рефлексию)
Если открытый метод доступа или (не компактный) канонический конструктор объявлен явно, у него есть только те аннотации, которые указаны на нём непосредственно; с соответствующего компонента record-класса на эти члены ничего не распространяется.
Аннотация объявления на компоненте record-класса не окажется среди аннотаций, связанных с компонентом record-класса во время выполнения через Reflection API, если только аннотация не помечена мета-аннотацией @Target(RECORD_COMPONENT).
Совместимость и миграция
Абстрактный класс java.lang.Record — общий суперкласс всех record-классов. Каждый исходный файл Java неявно импортирует класс java.lang.Record, а также все остальные типы пакета java.lang, независимо от того, включены или отключены Preview-возможности. Однако если ваше приложение импортирует другой класс с именем Record из другого пакета, может возникнуть ошибка компиляции.
Рассмотрим следующее объявление класса com.myapp.Record:
package com.myapp;
public class Record {
public String greeting;
public Record(String greeting) {
this.greeting = greeting;
}
}
Следующий пример, org.example.MyappPackageExample, импортирует com.myapp.Record с подстановочным знаком, но не компилируется:
package org.example;
import com.myapp.*;
public class MyappPackageExample {
public static void main(String[] args) {
Record r = new Record("Hello world!");
}
}
Компилятор выдаёт сообщение об ошибке, похожее на следующее:
./org/example/MyappPackageExample.java:6: error: reference to Record is ambiguous
Record r = new Record("Hello world!");
^
both class com.myapp.Record in com.myapp and class java.lang.Record in java.lang match
./org/example/MyappPackageExample.java:6: error: reference to Record is ambiguous
Record r = new Record("Hello world!");
^
both class com.myapp.Record in com.myapp and class java.lang.Record in java.lang match
И Record из пакета com.myapp, и Record из пакета java.lang импортированы с подстановочным знаком. Поэтому ни один из классов не имеет приоритета, и компилятор выдаёт сообщение об ошибке, встретив использование простого имени Record.
Чтобы этот пример компилировался, инструкцию import можно изменить так, чтобы она импортировала полное имя Record:
import com.myapp.Record;
Классы в пакет java.lang добавляются редко, но иногда это необходимо. Прежние примеры — Enum в Java 5, Module в Java 9 и Record в Java 14.
Грамматика Java
RecordDeclaration:
{ClassModifier} `record` TypeIdentifier [TypeParameters]
RecordHeader [SuperInterfaces] RecordBody
RecordHeader:
`(` [RecordComponentList] `)`
RecordComponentList:
RecordComponent { `,` RecordComponent}
RecordComponent:
{Annotation} UnannType Identifier
VariableArityRecordComponent
VariableArityRecordComponent:
{Annotation} UnannType {Annotation} `...` Identifier
RecordBody:
`{` {RecordBodyDeclaration} `}`
RecordBodyDeclaration:
ClassBodyDeclaration
CompactConstructorDeclaration
CompactConstructorDeclaration:
{ConstructorModifier} SimpleTypeName ConstructorBody
Представление в class-файле
Файл class record-класса использует атрибут Record для хранения информации о компонентах record-класса:
Record_attribute {
u2 attribute_name_index;
u4 attribute_length;
u2 components_count;
record_component_info components[components_count];
}
record_component_info {
u2 name_index;
u2 descriptor_index;
u2 attributes_count;
attribute_info attributes[attributes_count];
}
Если у компонента record-класса есть обобщённая сигнатура, отличающаяся от стёртого дескриптора, то в структуре record_component_info должен быть атрибут Signature.
Reflection API
Мы добавляем в java.lang.Class два открытых метода:
-
RecordComponent[] getRecordComponents()— возвращает массив объектовjava.lang.reflect.RecordComponent. Элементы этого массива соответствуют компонентам record-класса в том же порядке, в каком они указаны в объявлении record-класса. Из каждого элемента массива можно извлечь дополнительную информацию, в том числе имя, аннотации и метод доступа. -
boolean isRecord()— возвращает true, если данный класс был объявлен как record. (Сравните сisEnum.)
Альтернативы
Record-классы можно считать номинальной формой кортежей. Вместо record-классов мы могли бы реализовать структурные кортежи. Однако, хотя кортежи могут предложить облегчённый способ выражения некоторых агрегатов, в результате часто получаются агрегаты худшего качества:
-
Центральный аспект философии дизайна Java состоит в том, что имена имеют значение. У классов и их членов есть осмысленные имена, а у кортежей и компонентов кортежей — нет. То есть record-класс
Personс компонентамиfirstNameиlastNameпонятнее и безопаснее, чем анонимный кортеж из двух строк. -
Классы позволяют проверять корректность состояния в конструкторах; кортежи, как правило, нет. У некоторых агрегатов данных (например, числовых диапазонов) есть инварианты, на которые, если их обеспечивает конструктор, можно затем полагаться. Кортежи такой возможности не дают.
-
У классов может быть поведение, основанное на их состоянии; размещение состояния и поведения вместе делает поведение более заметным и доступным. Кортежи, будучи сырыми данными, такой возможности не предоставляют.
Зависимости
Record-классы хорошо сочетаются с другой возможностью, которая сейчас находится в статусе Preview, а именно с Sealed Classes (запечатанные классы) (JEP 360). Например, можно явно объявить, что семейство record-классов реализует один и тот же sealed-интерфейс:
package com.example.expression;
public sealed interface Expr
permits ConstantExpr, PlusExpr, TimesExpr, NegExpr {...}
public record ConstantExpr(int i) implements Expr {...}
public record PlusExpr(Expr a, Expr b) implements Expr {...}
public record TimesExpr(Expr a, Expr b) implements Expr {...}
public record NegExpr(Expr e) implements Expr {...}
Сочетание record-классов и sealed-классов иногда называют алгебраическими типами данных. Record-классы позволяют выражать произведения, а sealed-классы — суммы.
Помимо сочетания record-классов и sealed-классов, record-классы естественным образом подходят для Pattern Matching. Поскольку record-классы связывают свой API с описанием своего состояния, со временем мы сможем выводить и шаблоны деконструкции для record-классов и использовать информацию о sealed-типах для определения исчерпывающего характера выражений switch с шаблонами типов или шаблонами деконструкции.