Java Records. Не музыкальный лейбл, а расширение возможностей языка
Записи (records) в новой версии Java обеспечивают компактный синтаксис для объявления классов. Давайте изучим байт-код и сравним с реализациями аналогов в Kotlin и Scala.
Объявление класса
Начнем с простого примера:
public record Range(int min, int max) {}
Скомпилируем посредством javac:
javac --enable-preview -source 14 Range.java
Посмотрим на
сгенерированный байт-код с помощью javap:
javap Range
Выведется следующее:
Compiled from "Range.java"
public final class Range extends java.lang.Record {
public Range(int, int);
public java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public int min();
public int max();
}
Интересно, что записи, как и Enum-ы, являются обычными классами Java с несколькими фундаментальными свойствами:
- Записи объявлены как
final, поэтому мы не можем наследоваться от них. - Записи уже наследуются от другого класса под названием
java.lang.Record. Поэтому записи не могут расширить какой-либо другой класс, поскольку Java не допускает множественного наследования. - Записи могут имплементить другие интерфейсы.
- Для каждого компонента существует свой метод доступа, например,
maxиmin. - Существуют автоматически генерируемые реализации для
toString,equalsиhashCodeи автоматически сгенерированный конструктор, который принимает все компоненты в качестве своих аргументов. - Кроме того,
java.lang.Record– это абстрактный класс с защищенным конструктором no-arg и несколькими базовыми абстрактными методами:
public abstract class Record {
protected Record() {}
@Override
public abstract boolean equals(Object obj);
@Override
public abstract int hashCode();
@Override
public abstract String toString();
}
Классы данных Kotlin
Давайте рассмотрим класс данных Kotlin, эквивалентный описанному выше Range:
data class Range(val min: Int, val max: Int)
Подобно записям,
компилятор Kotlin генерирует методы доступа, стандартные реализации toString,
equals и hashCode и еще несколько функций, основанных на этой однострочной
схеме.
Посмотрим, как
компилятор Kotlin генерирует код, например, для toString:
Compiled from "Range.kt"
public java.lang.String toString();
descriptor: ()Ljava/lang/String;
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: new #36 // class StringBuilder
3: dup
4: invokespecial #37 // Method StringBuilder."<init>":()V
7: ldc #39 // String Range(min=
9: invokevirtual #43 // Method StringBuilder.append:(LString;)LStringBuilder;
12: aload_0
13: getfield #10 // Field min:I
16: invokevirtual #46 // Method StringBuilder.append:(I)LStringBuilder;
19: ldc #48 // String , max=
21: invokevirtual #43 // Method StringBuilder.append:(LString;)LStringBuilder;
24: aload_0
25: getfield #16 // Field max:I
28: invokevirtual #46 // Method StringBuilder.append:(I)LStringBuilder;
31: ldc #50 // String )
33: invokevirtual #43 // Method StringBuilder.append:(LString;)LStringBuilder;
36: invokevirtual #52 // Method StringBuilder.toString:()LString;
39: areturn
Для генерации вывода мы применили 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:
case class Range(min: Int, max: Int)
Кажется, что Scala
генерирует более простую реализацию toString:
Compiled from "Range.scala"
public java.lang.String toString();
descriptor: ()Ljava/lang/String;
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #89 // Field ScalaRunTime$.MODULE$:LScalaRunTime$;
3: aload_0
4: invokevirtual #111 // Method ScalaRunTime$._toString:(LProduct;)LString;
7: areturn
toString вызывает статический метод
scala.runtime.ScalaRunTime._toString,
а он, в свою очередь, метод productIterator для итерации по продуктам. Итератор
вызывает метод productElement, выглядящий примерно так:
public java.lang.Object productElement(int);
descriptor: (I)Ljava/lang/Object;
flags: (0x0001) ACC_PUBLIC
Code:
stack=3, locals=3, args_size=2
0: iload_1
1: istore_2
2: iload_2
3: tableswitch { // 0 to 1
0: 24
1: 34
default: 44
}
24: aload_0
25: invokevirtual #55 // Method min:()I
28: invokestatic #71 // Method BoxesRunTime.boxToInteger:(I)LInteger;
31: goto 59
34: aload_0
35: invokevirtual #58 // Method max:()I
38: invokestatic #71 // Method BoxesRunTime.boxToInteger:(I)LInteger;
41: goto 59
44: new #73 // class IndexOutOfBoundsException
47: dup
48: iload_1
49: invokestatic #71 // Method BoxesRunTime.boxToInteger:(I)LInteger;
52: invokevirtual #76 // Method Object.toString:()LString;
55: invokespecial #79 // Method IndexOutOfBoundsException."<init>":(LString;)V
58: athrow
59: areturn
Это перебор свойств Case-класса: если
productIterator хочет получить первое свойство, он получает значение min. Если хочет второй элемент – значение max. Иначе возникнет исключение IndexOutOfBoundsException.
Все, как и в Data-классах Kotlin: чем больше свойств в case-классе, тем объемнее будет перебор, а длина байт-кода пропорциональна количеству свойств.
Знакомство с Indy
Вернемся и внимательнее рассмотрим байт-код, сгенерированный для записей Java:
Compiled from "Range.java"
public java.lang.String toString();
descriptor: ()Ljava/lang/String;
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokedynamic #18, 0 // InvokeDynamic #0:toString:(LRange;)Ljava/lang/String;
6: areturn
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:
invokedynamic #18, 0 // InvokeDynamic #0:toString:(LRange;)Ljava/lang/String;
Вот что в байт-коде:
BootstrapMethods:
0: #41 REF_invokeStatic java/lang/runtime/ObjectMethods.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/TypeDescriptor;Ljava/lang/Class;Ljava/lang/String;[Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object;
Method arguments:
#8 Range
#48 min;max
#50 REF_getField Range.min:I
#51 REF_getField Range.max:I
Таким образом, метод 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:
jshell> var r = new Range(0, 42) r ==> Range[min=0, max=42] jshell> r.getClass().isRecord() $5 ==> true
Очевидно, что он
возвращает false для типов без записей:
jshell> "Not a record".getClass().isRecord() $6 ==> false
Существует также метод
getRecordComponents, возвращающий массив RecordComponent в том же порядке, в котором
они определены в исходной записи. Каждый java.lang.reflect.RecordComponent представляет
собой компонент записи или переменную текущего типа записи. Например, RecordComponent.getName
возвращает имя компонента:
jshell> public record User(long id, String username, String fullName) {}
| created record User
jshell> var me = new User(1L, "johndo", "John Dou")
me ==> User[id=1, username=johndo, fullName=John Dou]
jshell> Stream.of(me.getClass().getRecordComponents()).map(RecordComponent::getName).
...> forEach(System.out::println)
id
username
fullName
Похожим образом метод
getType возвращает type-token для каждого компонента:
jshell> Stream.of(me.getClass().getRecordComponents()).map(RecordComponent::getType). ...> forEach(System.out::println) long class java.lang.String class java.lang.String
Можно даже получить
дескриптор для методов доступа через getAccessor:
jshell> var nameComponent = me.getClass().getRecordComponents()[2].getAccessor() nameComponent ==> public java.lang.String User.fullName() jshell> nameComponent.setAccessible(true) jshell> nameComponent.invoke(me) $21 ==> "John Dou"
Аннотации записей
Java позволяет добавлять аннотации к записи, если они применимы к записи или ее членам. Аннотации могут использоваться только в компонентах записи:
@Target(ElementType.RECORD_COMPONENT)
2
public @interface Param {}
Сериализация
Любая новая функция
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. Так что теперь есть всё, чтобы попробовать записи в деле.