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

JEP 384: Records (Second Preview)

Records (записи), вторая версия Preview (предварительная версия)

АвторBrian Goetz
ОтветственныйVicente Arturo Romero Zaldivar
ТипFeature
ОбластьSE
СтатусClosed / Delivered
Выпуск15
Компонентspecification / language
Обсуждениеamber dash dev at openjdk dot java dot net
ТрудоёмкостьM
ДлительностьM
Связан сJEP 359: Records (Preview)
JEP 395: Records
РецензентыAlex Buckley
Создан2020/04/07 20:05
Обновлён2022/03/11 20:15
Задача8242303

Аннотация

Расширить язык программирования Java возможностью Records — классами, которые служат прозрачными носителями неизменяемых данных. Records можно рассматривать как номинальные кортежи. Это Preview-возможность языка в JDK 15.

История

Records были предложены в JEP 359 в середине 2019 года и вошли в JDK 14 в начале 2020 года как Preview-возможность. Этот JEP предлагает повторно выпустить эту возможность в статусе Preview в JDK 15, чтобы учесть доработки по итогам отзывов и поддержать дополнительные формы локальных классов и интерфейсов в языке Java.

Цели

  • Создать объектно-ориентированную конструкцию, которая выражает простую совокупность значений.
  • Помочь программистам сосредоточиться на моделировании неизменяемых данных, а не расширяемого поведения.
  • Автоматически реализовывать методы, определяемые данными, такие как equals и методы доступа.
  • Сохранить давние принципы Java, такие как номинальная типизация и совместимость при миграции.

Что не является целью

  • Цель не в том, чтобы объявить «войну шаблонному коду». В частности, цель не в том, чтобы решить проблемы изменяемых классов, которые следуют соглашениям об именовании JavaBeans.

  • Цель не в том, чтобы добавить такие возможности, как свойства или генерация кода на основе аннотаций, которые часто предлагают для упрощения объявления классов «Plain Old Java Objects» (простых старых Java-объектов).

Мотивация

Часто жалуются, что «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 и z». Код на Java, который моделирует несколько значений, должно быть проще писать, читать и проверять на корректность.

Хотя на первый взгляд соблазнительно считать, что Records в первую очередь нужны для сокращения шаблонного кода, мы выбираем более семантическую цель: моделировать данные как данные. (Если семантика правильная, шаблонный код исчезнет сам собой.) Объявлять классы-носители данных, которые по умолчанию делают свои данные неизменяемыми и предоставляют идиоматичные реализации методов, создающих и использующих эти данные, должно быть просто и лаконично.

Описание

Records — новый вид классов в языке Java. Назначение record-класса — объявить, что небольшая группа переменных должна рассматриваться как новый вид сущности. Record-класс объявляет своё состояние — группу переменных — и обязуется предоставлять API, соответствующий этому состоянию. Это значит, что record-классы отказываются от свободы, которой обычно обладают классы, — возможности отделить API класса от его внутреннего представления, — но взамен становятся значительно лаконичнее.

Объявление record-класса задаёт имя, заголовок и тело. В заголовке перечислены компоненты record-класса — переменные, из которых состоит его состояние. (Список компонентов иногда называют описанием состояния.) Например:

record Point(int x, int y) { }

Поскольку record-классы семантически заявляют, что они прозрачные носители своих данных, record-класс автоматически получает многие стандартные члены:

  • Для каждого компонента в заголовке — два члена: public метод доступа с тем же именем и типом возвращаемого значения, что и у компонента, и private final поле того же типа, что и компонент;

  • Канонический конструктор, сигнатура которого совпадает с заголовком и который присваивает каждому полю private соответствующий аргумент из выражения new, создающего экземпляр record-класса;

  • Методы equals и hashCode, согласно которым два record-объекта равны, если они одного типа и содержат равные значения компонентов; и

  • Метод toString, который возвращает строковое представление всех компонентов record-класса вместе с их именами.

Иными словами, заголовок record-класса описывает его состояние (типы и имена его компонентов), а API механически и полностью выводится из этого описания состояния. API включает протоколы создания, доступа к членам, проверки равенства и отображения. (Мы ожидаем, что в будущей версии появится поддержка шаблонов деконструкции, которые сделают возможным мощный Pattern Matching (сопоставление с образцом).)

Правила для Records

Любой из членов, автоматически выводимых из заголовка, кроме полей private, выводимых из компонентов record-класса, можно объявить явно. Любая явная реализация методов доступа или equals/hashCode должна аккуратно сохранять семантические инварианты 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-класса наложено множество ограничений:

  • У record-класса нет предложения extends. Суперкласс record-класса — всегда java.lang.Record, подобно тому как суперкласс перечисления — всегда java.lang.Enum. Хотя обычный класс может явно расширять свой неявный суперкласс Object, record-класс не может явно расширять никакой класс, даже свой неявный суперкласс Record.

  • Record-класс неявно является final и не может быть abstract. Эти ограничения подчёркивают, что API record-класса определяется только описанием его состояния и не может быть позднее расширен другим классом или record-классом.

  • Record-класс не может явно объявлять поля экземпляра и не может содержать инициализаторы экземпляра. Эти ограничения гарантируют, что состояние значения record-класса определяется только заголовком record-класса.

  • Неявно объявленные поля, соответствующие компонентам record-класса, являются final и, более того, не могут быть изменены через рефлексию (такая попытка приведёт к исключению IllegalAccessException). Эти ограничения воплощают политику неизменяемости по умолчанию, которая широко применима к классам-носителям данных.

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

  • Record-класс не может объявлять методы native. Если бы record-класс мог объявить метод native, то поведение record-класса по определению зависело бы от внешнего состояния, а не от явного состояния record-класса. Ни один класс с методами native не является подходящим кандидатом для перевода в record-класс.

Помимо перечисленных выше ограничений, record-класс ведёт себя как обычный класс:

  • Экземпляр record-класса создаётся с помощью ключевого слова new.

  • Record-класс может быть объявлен на верхнем уровне или как вложенный, а также может быть обобщённым.

  • Record-класс может объявлять статические методы, статические поля и статические инициализаторы.

  • Record-класс может объявлять методы экземпляра. В частности, record-класс может явно объявлять методы доступа public, соответствующие компонентам, а также может объявлять другие методы экземпляра.

  • Record-класс может реализовывать интерфейсы. Хотя record-класс не может указать суперкласс (это означало бы унаследованное состояние помимо описанного в заголовке), он может свободно указывать суперинтерфейсы и объявлять методы экземпляра, помогающие их реализовать. Как и в случае классов, интерфейс может с пользой характеризовать поведение многих record-классов; это поведение может не зависеть от предметной области (например, Comparable) или быть специфичным для неё — в этом случае record-классы могут быть частью sealed-иерархии, описывающей предметную область (см. ниже).

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

  • Record-класс и компоненты в описании его состояния можно аннотировать. Аннотации переносятся на автоматически выводимые поля, методы и параметры конструктора. Аннотации типов на типах компонентов record-класса также переносятся на типы автоматически выводимых членов.

Records и sealed types

Records хорошо сочетаются с sealed types (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 {...}

Сочетание Records и sealed types иногда называют алгебраическими типами данных. Records позволяют нам выражать типы-произведения, а sealed types — типы-суммы.

Локальные record-классы

Программа, которая создаёт и использует record-классы, скорее всего, будет иметь дело со множеством промежуточных значений, которые сами по себе являются простыми группами переменных. Часто будет удобно объявлять record-классы для моделирования этих промежуточных значений. Один вариант — объявить «вспомогательные» record-классы, которые являются static и вложенными, так же как многие программы сегодня объявляют вспомогательные классы. Более удобным вариантом было бы объявить record-класс внутри метода, рядом с кодом, который работает с этими переменными. Поэтому данный JEP предлагает локальные 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-классы неявно статические, отличает их от локальных классов, которые неявно статическими не являются. Более того, локальные классы никогда не бывают статическими — ни неявно, ни явно — и всегда могут обращаться к переменным объемлющего метода.

Раз локальные record-классы полезны, было бы полезно иметь также локальные перечисления и локальные интерфейсы. Традиционно в Java они были запрещены из-за опасений относительно их семантики. В частности, вложенные перечисления и вложенные интерфейсы неявно статические, поэтому локальные перечисления и локальные интерфейсы тоже должны быть неявно статическими; однако локальные объявления в языке Java (локальные переменные, локальные классы) никогда не бывают статическими. Тем не менее появление локальных record-классов в JEP 359 сняло это семантическое затруднение: локальное объявление теперь может быть статическим, и это открыло путь локальным перечислениям и локальным интерфейсам.

Аннотации на 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-класса применима к объявлению поля, то аннотация появляется на соответствующем поле private.

  • Если аннотация на компоненте record-класса применима к объявлению метода, то аннотация появляется на соответствующем методе доступа.

  • Если аннотация на компоненте record-класса применима к формальному параметру, то аннотация появляется на соответствующем формальном параметре канонического конструктора, если он не объявлен явно, или на соответствующем формальном параметре компактного конструктора, если он объявлен явно.

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

Если открытый метод доступа или (некомпактный) канонический конструктор объявлен явно, то у него есть только те аннотации, которые указаны на нём непосредственно; с соответствующего компонента record-класса на эти члены ничего не распространяется.

Также можно объявить, что аннотация пришла от аннотации, определённой на компоненте record-класса, с помощью нового объявления аннотации @Target(RECORD_COMPONENT). Такие аннотации можно получить через рефлексию, как подробно описано ниже в разделе Reflection API.

Грамматика 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:
 {Annotation} {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()
  • boolean isRecord()

Метод getRecordComponents() возвращает массив объектов java.lang.reflect.RecordComponent. Элементы этого массива соответствуют компонентам record-класса в том же порядке, в каком они указаны в объявлении record-класса. Из каждого элемента массива можно извлечь дополнительную информацию, включая его имя, аннотации и метод доступа.

Метод isRecord возвращает true, если данный класс был объявлен как record-класс. (Сравните с isEnum.)

Альтернативы

Record-классы можно считать номинальной формой кортежей. Вместо record-классов мы могли бы реализовать структурные кортежи. Однако, хотя кортежи, возможно, дали бы более легковесный способ выражать некоторые агрегаты, в результате часто получаются худшие агрегаты:

  • Одна из центральных идей философии проектирования Java состоит в том, что имена важны. У классов и их членов есть осмысленные имена, а у кортежей и компонентов кортежей их нет. То есть класс Person со свойствами firstName и lastName понятнее и безопаснее, чем анонимный кортеж из String и String.

  • Классы поддерживают проверку состояния через свои конструкторы; кортежи — нет. У некоторых агрегатов данных (например, числовых диапазонов) есть инварианты, на которые, если их обеспечивает конструктор, можно в дальнейшем полагаться; кортежи такой возможности не дают.

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

Зависимости

Помимо упомянутого выше сочетания record-классов и sealed-типов, record-классы естественным образом подходят для Pattern Matching. Поскольку record-классы связывают свой API с описанием своего состояния, со временем мы сможем выводить для record-классов и деконструирующие шаблоны, а также использовать информацию о sealed-типах, чтобы определять полноту в выражениях switch с шаблонами типов или деконструирующими шаблонами.