Java Records. Не музыкальный лейбл, а расширение возможностей языка
Записи (records) в новой версии Java обеспечивают компактный синтаксис для объявления классов. Давайте изучим байт-код и сравним с реализациями аналогов в Kotlin и Scala.
Объявление класса
Начнем с простого примера:
Скомпилируем посредством javac
:
Посмотрим на
сгенерированный байт-код с помощью javap
:
Выведется следующее:
Интересно, что записи, как и Enum-ы, являются обычными классами Java с несколькими фундаментальными свойствами:
- Записи объявлены как
final
, поэтому мы не можем наследоваться от них. - Записи уже наследуются от другого класса под названием
java.lang.Record
. Поэтому записи не могут расширить какой-либо другой класс, поскольку Java не допускает множественного наследования. - Записи могут имплементить другие интерфейсы.
- Для каждого компонента существует свой метод доступа, например,
max
иmin
. - Существуют автоматически генерируемые реализации для
toString
,equals
иhashCode
и автоматически сгенерированный конструктор, который принимает все компоненты в качестве своих аргументов. - Кроме того,
java.lang.Record
– это абстрактный класс с защищенным конструктором no-arg и несколькими базовыми абстрактными методами:
Классы данных Kotlin
Давайте рассмотрим класс данных Kotlin, эквивалентный описанному выше Range:
Подобно записям,
компилятор Kotlin генерирует методы доступа, стандартные реализации toString
,
equals
и hashCode
и еще несколько функций, основанных на этой однострочной
схеме.
Посмотрим, как
компилятор Kotlin генерирует код, например, для toString
:
Для генерации вывода мы применили javap
-c -v Range
. Kotlin использует StringBuilder
для
генерации строкового представления вместо нескольких конкатенаций строк (как и
любой приличный Java-разработчик). Что мы имеем:
- сначала создается новый экземпляр
StringBuilder
(индекс 0, 3, 4); - добавляется литерал
Range
; - добавляется фактическое значение
min
(индекс 12, 13, 16); - добавляется литерал
max=
(индекс 19, 21); - добавляется фактическое значение
max
(индекс 24, 25, 28); - скобки закрываются и добавляется литерал) (индекс 31, 33);
- создается и возвращается экземпляр
StringBuilder
(индекс 36, 39).
Чем больше свойств в нашем классе данных, тем длиннее байт-код и больше время запуска.
Класс образец Scala
Напишем эквивалент записи в Scala:
Кажется, что Scala
генерирует более простую реализацию toString
:
toString
вызывает статический метод
scala.runtime.ScalaRunTime._toString
,
а он, в свою очередь, метод productIterator
для итерации по продуктам. Итератор
вызывает метод productElement
, выглядящий примерно так:
Это перебор свойств Case-класса: если
productIterator
хочет получить первое свойство, он получает значение min
. Если хочет второй элемент – значение max
. Иначе возникнет исключение IndexOutOfBoundsException
.
Все, как и в Data-классах Kotlin: чем больше свойств в case-классе, тем объемнее будет перебор, а длина байт-кода пропорциональна количеству свойств.
Знакомство с Indy
Вернемся и внимательнее рассмотрим байт-код, сгенерированный для записей Java:
Invoke Dynamic (также
известный как Indy) был частью JSR 292 и предназначался для улучшения поддержки
JVM в динамических языках. После первого релиза в Java 7, invokedynamic
opcode вместе с java.lang.invoke
широко используется в динамических JVM-based
языках, например, JRuby.
Хотя Indy был
специально разработан для улучшения языковой поддержки, он предлагает гораздо
больший функционал и подходит для использования везде, где требуется динамичность. Например, лямбда-выражения Java 8 реализуются с помощью
invokedynamic
, хотя Java – это статически типизированный язык.
Пользовательский байт-код
В течение длительного времени JVM поддерживал четыре типа вызова методов:
invokestatic
для вызова статических методов,invokeinterface
для вызова интерфейсных методов,invokespecial
для вызова конструкторов,super()
илиprivatemethods
иinvokevirtual
для вызова методов экземпляра.
Несмотря на их
различия, эти типы вызовов имеют одну общую черту: мы не можем снабдить их
собственной логикой. А еще, invokedynamic
позволяет нам загружать процесс
вызова любым желаемым способом.
Как работает Indy
Когда JVM видит
вызываемую динамическую инструкцию, он вызывает специальный статический Bootstrap
Method
. Данный метод – это фрагмент кода Java, используемый для подготовки
фактической логики к вызову.
Затем метод bootstrap
возвращает экземпляр java.invoke.CallSite
. CallSite
содержит ссылку на
фактический метод, т. е. MethodHandle
. С этого момента каждый раз, когда JVM
снова видит invokedynamicinstruction
, он пропускает «медленный путь» и применяет
прямой вызов, пока что-то не изменится.
Почему Indy?
В отличие от Reflection
API
, java.lang.invoke API
более эффективен, так как JVM может полностью видеть
все вызовы. Поэтому JVM может применять всевозможные оптимизации, пока мы
избегаем «медленного пути», насколько это возможно.
Cгенерированный байт-код для записей Java не зависит от количества свойств. Таким образом, меньше байт-кода – быстрее время запуска.
Наконец, предположим,
что новая версия Java включает в себя новую реализацию метода bootstrap
. С
инструкциями invokedynamic
наше приложение может воспользоваться
преимуществами этого улучшения без перекомпиляции. Итак, у нас есть своего рода
прямая бинарная совместимость.
Методы объекта
Давайте
разберемся в байт-коде invokedynamic
:
Вот что в байт-коде:
Таким образом, метод bootstrap
для записей находится в классе java.lang.runtime.ObjectMethods
. Метод может
принимать следующие аргументы:
- Экземпляр
MethodHandles.Lookup
, описывающий контекст поиска (Ljava/lang/invoke/MethodHandles$Lookup
). - Имя метода:
toString
,equals
,hashCode
и т. д. Например, если значение равноtoString
,bootstrap
вернетConstantCallSite
– указатель на фактическую реализациюtoString
для этой конкретной записи. TypeDescriptor
для метода (Ljava/lang/invoke/TypeDescriptor
).- Токен
Class<?>
, описывающий тип класса записи. Здесь этоClass<Range>
. - Список всех имен компонентов, разделенных “
;
”, т. е.min;max
. - Один
MethodHandle
для каждого компонента.
Инструкция invokedynamic
передает аргументы в метод bootstrap
, а он, в свою очередь, возвращает
экземпляр ConstantCallSite
, содержащий ссылку на запрошенную реализацию метода.
Рефлексия в записях
Для поддержки записей java.lang.Class API
был переписан, а чтобы проверить,
например, является ли Class<?>
записью, можно использовать метод
isRecord
:
Очевидно, что он
возвращает false
для типов без записей:
Существует также метод
getRecordComponents
, возвращающий массив RecordComponent
в том же порядке, в котором
они определены в исходной записи. Каждый java.lang.reflect.RecordComponent
представляет
собой компонент записи или переменную текущего типа записи. Например, RecordComponent.getName
возвращает имя компонента:
Похожим образом метод
getType
возвращает type-token
для каждого компонента:
Можно даже получить
дескриптор для методов доступа через getAccessor
:
Аннотации записей
Java позволяет добавлять аннотации к записи, если они применимы к записи или ее членам. Аннотации могут использоваться только в компонентах записи:
Сериализация
Любая новая функция
Java без сериализации была бы неполной. Хотя записи по умолчанию не являются
сериализуемыми, их можно сделать таковыми, просто реализовав интерфейс java.io.Serializable.
Сериализуемые записи сериализуются и десериализуются иначе, нежели обычные сериализуемые объекты. Вот, что говорит на эту тему javadoc:
- Сериализованная запись – это последовательность значений, полученных из компонентов записи.
- Процесс, с помощью которого сериализуются объекты записи, не может быть кастомизирован. Любые методы
writeObject
,readObject
,readObjectNoData
,writeExternal
иreadExternal
, определенные классами записей, игнорируются во время сериализации и десериализации. - Если
SerialVersionUID
класса не объявлен явно, он равен0L
.
Заключение
Записи Java предоставят новый способ инкапсуляции данных. Несмотря на то, что в настоящее время они ограничены в плане функциональности (по сравнению с тем, что предлагают Kotlin или Scala), реализация является надежной.
В данной статье использовалась сборка openjdk 14-ea 2020-03-17, когда Java 14 еще не была выпущена. В день, когда мы опубликовали эту статью, вышел официальный релиз Java SE 14. Так что теперь есть всё, чтобы попробовать записи в деле.