openjdk.ruOpenJDK на русском

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-значения вычисляется так:

  1. Вычисляется исходное выражение, тип которого во время компиляции называет record-класс R. Если вычисление завершается аварийно, то вычисление выражения производного создания record-значения завершается аварийно по той же причине.

  2. Если значение исходного выражения равно null, то вычисление выражения производного создания record-значения завершается аварийно с NullPointerException.

  3. Перед выполнением содержимого блока преобразования по порядку выполняется последовательность неявных операторов объявления локальных переменных, полученных из record-компонентов в заголовке record-класса R, так что:

    • Объявление локальной переменной имеет то же имя и объявленный тип, что и record-компонент.

    • У объявления локальной переменной есть инициализатор, задаваемый так, как если бы для значения исходного выражения вызывался соответствующий метод доступа к компоненту.

    Если вычисление любого из этих операторов объявления локальных переменных завершается аварийно, то вычисление выражения производного создания record-значения завершается аварийно по той же причине.

  4. Выполняется содержимое блока преобразования. Если выполнение завершается аварийно, вычисление выражения производного создания record-значения завершается аварийно по той же причине.

  5. Создаётся новый экземпляр 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-класс затем изменяют так, что у него появляется компонент с тем же именем, то эту локальную переменную может затенить локальная переменная, неявно объявленная в блоке преобразования. Мы предполагаем, что такой сценарий встречается редко и что компиляторы будут при необходимости выдавать о нём предупреждение.