JEP 468: Derived Record Creation (Preview)
Создание производных Records (записи), версия Preview (предварительная версия)
| Автор | Gavin Bierman & Brian Goetz |
| Ответственный | Gavin Bierman |
| Тип | Feature |
| Область | SE |
| Статус | Candidate |
| Компонент | specification / language |
| Обсуждение | amber dash dev at openjdk dot org |
| Рецензенты | Alex Buckley, Brian Goetz |
| Одобрен | Brian Goetz |
| Создан | 2023/11/30 17:45 |
| Обновлён | 2024/04/23 13:37 |
| Задача | 8321133 |
Аннотация
Добавить в язык Java создание производных значений для Records. Records — неизменяемые объекты, поэтому разработчики часто создают новые record-значения из старых, чтобы моделировать новые данные. Производное создание упрощает код: новое record-значение выводится из существующего, и указываются только отличающиеся компоненты. Это возможность языка в статусе Preview.
Цели
-
Дать лаконичный способ создавать новые record-значения, производные от существующих record-значений.
-
Упростить объявление record-классов: избавить от необходимости явно писать wither-методы, неизменяемый аналог методов-сеттеров.
Что не является целью
-
Целью не является конструкция
withв стиле Pascal, упрощающая доступ к произвольным сложным выражениям. -
Целью не является поддержка особой категории wither-методов.
-
Целью не является поддержка выражений производного создания для обычных значений, не являющихся record-значениями; это может стать темой будущего JEP.
Мотивация
Неизменяемость — мощный приём для написания безопасного и надёжного кода, о котором легко рассуждать. Писать неизменяемые классы на языке Java традиционно было утомительным занятием с большим количеством шаблонного кода, но record-классы (JEP 395), появившиеся в Java 16, позволяют легко объявлять (поверхностно) неизменяемые классы, ориентированные на данные.
Неизменяемость record-классов обеспечивает и безопасность, и предсказуемость. Она также делает возможным ряд особенностей, благодаря которым ими легко пользоваться, в том числе канонические конструкторы, методы доступа и чётко определённые методы Object. Но у систем, которые нам нужно моделировать, всё равно есть состояние, и поэтому нам нужно моделировать естественное развитие этого состояния. К сожалению, развивать состояние, смоделированное record-классами, бывает довольно громоздко. Рассмотрим, например, record-класс, моделирующий состояние, которое представляет собой точку в трёхмерном пространстве:
record Point(int x, int y, int z) { }
Предположим, мы хотим развить состояние, удвоив координату x у Point oldLoc, и получить Point newLoc:
Point newLoc = new Point(oldLoc.x() * 2, oldLoc.y(), oldLoc.z());
Этот код, хоть и прямолинеен, трудоёмок. Чтобы получить newLoc из oldLoc, нужно извлечь каждый компонент oldLoc, меняется он или нет, и передать значение для каждого компонента newLoc, даже если оно не отличается от oldLoc.
Если бы разработчикам приходилось раз за разом деконструировать record-значение, извлекая все его компоненты, чтобы создать новое record-значение почти с теми же компонентами, это постоянно снижало бы продуктивность. Поэтому авторы record-классов часто скрывают деконструкцию и создание экземпляра внутри таких классов, объявляя так называемые wither-методы:
record Point(int x, int y, int z) {
Point withX(int newX) { return new Point(newX, y, z); }
Point withY(int newY) { return new Point(x, newY, z); }
Point withZ(int newZ) { return new Point(x, y, newZ); }
}
Теперь можно лаконично получить Point newLoc из oldLoc, а также выстраивать цепочки вызовов методов, чтобы изменить больше одного компонента:
Point newLoc = oldLoc.withX(oldLoc.x()*2);
// Double newLoc's y and z components
Point nextLoc = newLoc.withY(newLoc.y()*2)
.withZ(newLoc.z()*2);
Однако у wither-методов есть две проблемы:
-
Они добавляют в record-класс шаблонный код, и это досадно, ведь record-классы призваны избавить от шаблонного кода, например от методов-геттеров и методов-сеттеров JavaBean.
-
У некоторых record-классов есть семантические ограничения, затрагивающие больше одного компонента и проверяемые в каноническом конструкторе. Это значит, что при написании wither-методов нужна особая осторожность. Например, если у record-класса два компонента
List, которые должны иметь одинаковую длину, не должно быть wither-методов, обновляющих только один компонентListза раз; нужно аккуратно объявить wither-метод, который вызывает канонический конструктор так, чтобы оба компонента обновлялись за один раз. Иначе говоря, wither-методы — не всегда простой шаблонный код: чтобы точно определить, какой шаблонный код нужен, приходится учитывать семантические ограничения.
Лучший способ получать новые record-значения из старых — дать разработчикам сосредоточиться на преобразовании компонентов, а деконструкцию и создание record-значений поручить компилятору Java, который будет выполнять их автоматически.
Описание
Выражение производного создания record-значения имеет вид
e with { ... }
Например, чтобы создать новое значение Point, у которого компонент x равен 0, а значения y и z берутся из существующего значения Point oldLoc, можно использовать выражение производного создания record-значения так:
Point nextLoc = oldLoc with {
x = 0;
};
Выражение слева от ключевого слова with, а именно oldLoc, — это исходное record-значение. Блок справа от ключевого слова with — это преобразование состояния исходного record-значения. Состояние состоит из трёх локальных переменных, x, y и z, которые соответствуют компонентам исходного record-значения и автоматически инициализируются из этих компонентов; например, x имеет значение oldLoc.x(). При вычислении выражения производного создания record-значения выполняется код блока, преобразующий некоторые или все из x, y и z, а затем создаётся новое record-значение передачей этих переменных каноническому конструктору, то есть new Point(x, y, z).
Состояние исходного record-значения представлено изменяемыми локальными переменными, чтобы внутри блока, который может обновить некоторые или все компоненты, состояние можно было и читать, и обновлять. Например, чтобы масштабировать точку nextLoc в два раза:
Point finalLoc = nextLoc with {
x *= 2;
y *= 2;
z *= 2;
};
Новое состояние проверяется автоматическим вызовом канонического конструктора в конце блока. Так гарантируется, что код в блоке соблюдает любые семантические ограничения, затрагивающие несколько компонентов. В примере с record-классом, у которого два компонента List одинаковой длины, канонический конструктор обнаружит, что блок дописал данные в одну локальную переменную List, но не в другую.
Это возможность языка в статусе Preview, по умолчанию отключённая
Чтобы опробовать примеры в JDK 23, нужно включить Preview-возможности:
-
Скомпилируйте программу с
javac --release 23 --enable-preview Main.javaи запускайте её сjava --enable-preview Main; или -
При использовании средства запуска исходного кода запускайте программу с
java --enable-preview Main.java; или -
При использовании
jshellзапускайте его сjshell --enable-preview.
Использование выражений производного создания record-значений
Выражения производного создания record-значений — важная часть модели программирования для record-классов. Использование with для получения новых record-значений улучшает читаемость, поскольку внимание сосредоточено только на изменённом состоянии. Это также помогает корректности, поскольку канонический конструктор задействуется автоматически.
В блоке справа от ключевого слова with нужно упоминать только преобразуемые компоненты; поэтому частая ситуация, когда новое record-значение получается небольшими изменениями существующего, записывается кратко.
Если блок справа от ключевого слова with пуст, результатом выражения производного создания record-значения будет новая копия исходного record-значения.
Выражения производного создания record-значений можно выстраивать в цепочки. Так большое преобразование можно разбить на ряд более мелких шагов, что улучшает читаемость:
Point finalLoc = nextLoc
with { x *= 2; }
with { y *= 2; }
with { z *= 2; };
Record-значения могут быть вложенными, с компонентами, которые сами являются record-значениями. Выражения производного создания record-значений тоже можно вкладывать друг в друга, чтобы преобразовывать вложенные record-значения:
record Marker(Point loc, String label, Icon icon) { }
Marker m = new Marker(new Point(...), ..., ...);
Marker scaled = m with { loc = loc with { x *= 2; y *= 2; z *= 2; }};
Выражения производного создания record-значений можно использовать и внутри record-классов, чтобы упростить реализацию базовых операций:
record Complex(double re, double im) {
Complex conjugate() { return this with { im = -im; }; }
Complex realOnly() { return this with { im = 0; }; }
Complex imOnly() { return this with { re = 0; }; }
}
Синтаксис и семантика
Грамматика выражения производного создания record-значения:
DerivedRecordCreationExpression:
Expression with Block
Выражение в левой части — это исходное выражение. Тип исходного выражения должен быть типом record-класса, и он же становится типом всего выражения производного создания record-значения.
Блок в правой части — это блок преобразования. Это обычный блок, содержащий произвольные операторы блока, но у него есть два ограничения на поток управления:
- Он не может содержать оператор
return, и - Он не может содержать оператор
yield,breakилиcontinue, цель которого содержит выражение производного создания record-значения.
Иными словами, управление может покинуть блок преобразования только при его нормальном завершении или при аварийном завершении с выбросом исключения.
Для каждого record-компонента (если они есть) в заголовке record-класса, названного типом исходного выражения, непосредственно перед содержимым блока преобразования неявно объявляется локальная переменная с тем же именем и типом, инициализированная значением компонента. Эти локальные переменные компонентов могут затенять любые локальные переменные, объявления которых находятся в области видимости.
На любые операторы присваивания внутри блока преобразования накладывается следующее ограничение: если левая часть присваивания — неквалифицированное имя, то это имя должно быть либо именем локальной переменной компонента, либо именем локальной переменной, явно объявленной в блоке преобразования.
Выражение производного создания record-значения не является выражением-оператором; иными словами, его нельзя использовать как оператор, поставив после него точку с запятой:
finalLoc with { sideEffectingMethod(); }; // Error
Вычисление в подробностях
Выражение производного создания record-значения вычисляется так:
-
Вычисляется исходное выражение, тип которого во время компиляции называет record-класс R. Если вычисление завершается аварийно, то вычисление выражения производного создания record-значения завершается аварийно по той же причине.
-
Если значение исходного выражения равно
null, то вычисление выражения производного создания record-значения завершается аварийно сNullPointerException. -
Перед выполнением содержимого блока преобразования по порядку выполняется последовательность неявных операторов объявления локальных переменных, полученных из record-компонентов в заголовке record-класса R, так что:
-
Объявление локальной переменной имеет то же имя и объявленный тип, что и record-компонент.
-
У объявления локальной переменной есть инициализатор, задаваемый так, как если бы для значения исходного выражения вызывался соответствующий метод доступа к компоненту.
Если вычисление любого из этих операторов объявления локальных переменных завершается аварийно, то вычисление выражения производного создания record-значения завершается аварийно по той же причине.
-
-
Выполняется содержимое блока преобразования. Если выполнение завершается аварийно, вычисление выражения производного создания record-значения завершается аварийно по той же причине.
-
Создаётся новый экземпляр record-класса R, как если бы вычислялось выражение создания экземпляра класса (
new) с типом исходного выражения во время компиляции и списком аргументов, содержащим локальные переменные компонентов (если они есть) в том порядке, в котором они указаны в заголовке record-класса R. (Так гарантируется, что для создания экземпляра record-класса вызывается канонический конструктор.) Полученный экземпляр record-класса R становится значением всего выражения производного создания record-значения. Если вычисление выражения создания экземпляра класса завершается аварийно, то вычисление выражения производного создания record-значения завершается аварийно по той же причине.
Аналогия с Pattern Matching (сопоставление с образцом)
Выражение производного создания record-значения, например
Point finalLoc = nextLoc with {
x *= 2;
y *= 2;
z *= 2;
};
можно представить как выражение switch
Point finalLoc = switch (nextLoc) {
case Point(var x, var y, var z) -> {
x *= 2;
y *= 2;
z *= 2;
yield new Point(x, y, z);
}
};
Отсюда видно, что выражение производного создания record-значения по сути выполняет Pattern Matching значения nextLoc с record-шаблоном — тем самым деконструируя его и инициализируя переменные шаблона x, y и z, — затем выполняет операторы блока преобразования, а затем создаёт новый экземпляр Point, вызывая канонический конструктор с итоговыми значениями переменных шаблона в качестве аргументов.
Соблюдение ограничений
Если канонический конструктор record-класса проверяет ограничения — как и должен, — то выражение производного создания record-значения неявно проверяет их при создании нового record-значения. Например:
record Rational(int num, int denom) {
Rational {
if (denom == 0)
throw new IllegalArgumentException("denom must not be zero");
}
}
Rational r = new Rational(3, 1); // OK
Rational s = r with { denom = 0; }; // throws IllegalArgumentException
При вычислении этого выражения производного создания record-значения локальные переменные num и denom инициализируются значениями 3 и 1, затем denom присваивается 0, а затем через new Rational(num, denom) вызывается канонический конструктор. Он выбрасывает исключение точно так же, как если бы мы попытались явно вычислить new Rational(3, 0).
(Блоки преобразования в выражениях производного создания record-значения похожи на тело компактного конструктора в record-классе в том смысле, что на них действуют одинаковые ограничения потока управления (они должны завершаться нормально или выбрасывать исключение), и в их области видимости есть набор неявно объявленных переменных, которым блок, как предполагается, присваивает значения.)
Альтернативы
-
Вместо того чтобы поддерживать форму выражения для создания новых record-значений в месте использования, мы могли бы поддержать её в месте объявления с помощью той или иной особой поддержки wither-методов. Мы предпочитаем гибкость создания в месте использования, потому что объявление wither-методов раздуло бы объявления record-классов, которые сейчас отличаются высокой лаконичностью.
-
Вместо поддержки блоков преобразования мы могли бы поддерживать в правой части выражения производного создания record-значения только список деклараторов переменных, например
e with { x1 = e1; ..., xn = en; }. Это препятствовало бы написанию кода с побочными эффектами, но, скорее всего, было бы громоздким и излишне ограничительным.
Риски и допущения
- Существует очень небольшой риск того, что взаимодействие компонентов record-класса и локальных переменных внутри блока преобразования приведёт к неожиданному поведению. Например, если блок преобразования обращается к локальной переменной, а соответствующий record-класс затем изменяют так, что у него появляется компонент с тем же именем, то эту локальную переменную может затенить локальная переменная, неявно объявленная в блоке преобразования. Мы предполагаем, что такой сценарий встречается редко и что компиляторы будут при необходимости выдавать о нём предупреждение.