Записи — это новая функция предварительного просмотра в Java 14 , обеспечивающая приятный компактный синтаксис для объявления классов, которые должны быть глупыми держателями данных. В этой статье мы рассмотрим, как выглядят записи под капотом. Так пристегнуться!
Представление класса
Давайте начнем с очень простого примера:
Джава
1
public record Range(int min, int max) {}
Как насчет компиляции этого кода с помощью javac:
Джава
xxxxxxxxxx
1
javac --enable-preview -source 14 Range.java
Затем можно взглянуть на сгенерированный байт-код, используя javap:
Джава
xxxxxxxxxx
1
javap Range
Это напечатает следующее:
Джава
xxxxxxxxxx
1
Compiled from "Range.java"
2
public final class Range extends java.lang.Record {
3
public Range(int, int);
4
public java.lang.String toString();
5
public final int hashCode();
6
public final boolean equals(java.lang.Object);
7
public int min();
8
public int max();
9
}
Интересно, что, подобно Enums , Records — это обычные Java-классы с несколькими фундаментальными свойствами:
- Они объявлены как
finalклассы, поэтому мы не можем наследовать от них. - Они уже наследуются от другого класса с именем
java.lang.Record. Поэтому Records не может расширять любой другой класс, поскольку Java не допускает множественное наследование. - Записи могут реализовывать другие интерфейсы.
- Для каждого компонента есть метод доступа, например,
maxиmin. - Есть автоматически генерируемые реализации
toString,equalsи наhashCodeоснове всех компонентов. - Наконец, есть автоматически сгенерированный конструктор, который принимает все компоненты в качестве аргументов.
Кроме того, java.lang.Recordэто просто абстрактный класс с защищенным конструктором без аргументов и несколькими другими основными абстрактными методами:
Джава
xxxxxxxxxx
1
public abstract class Record {
2
protected Record() {}
4
6
public abstract boolean equals(Object obj);
7
9
public abstract int hashCode();
10
12
public abstract String toString();
13
}
Ничего особенного в этом классе!
Вам также может понравиться: Представляем Java Record
Любопытный случай классов данных
Исходя из Kotlin или Scala, можно заметить некоторые сходства между записями в Java, классами данных в Kotlin и классами Case в Scala . На первый взгляд, все они имеют одну очень фундаментальную цель: облегчить написание держателей данных.
Несмотря на это фундаментальное сходство, на уровне байт-кода все сильно отличается.
Класс данных Котлина
Для сравнения давайте рассмотрим класс данных Kotlin, эквивалентный Range:
Котлин
xxxxxxxxxx
1
data class Range(val min: Int, val max: Int)
Подобно Records, Котлин компилятор генерирует аксессоры методы, по умолчанию toString, equalsи hashCodeреализации и несколько дополнительных функций , основанных на этом простом однострочнике.
Давайте посмотрим, как компилятор Kotlin генерирует код, скажем, для toString:
Котлин
xxxxxxxxxx
1
Compiled from "Range.kt"
2
public java.lang.String toString();
3
descriptor: ()Ljava/lang/String;
4
flags: (0x0001) ACC_PUBLIC
5
Code:
6
stack=2, locals=1, args_size=1
7
0: new #36 // class StringBuilder
8
3: dup
9
4: invokespecial #37 // Method StringBuilder."<init>":()V
10
7: ldc #39 // String Range(min=
11
9: invokevirtual #43 // Method StringBuilder.append:(LString;)LStringBuilder;
12
12: aload_0
13
13: getfield #10 // Field min:I
14
16: invokevirtual #46 // Method StringBuilder.append:(I)LStringBuilder;
15
19: ldc #48 // String , max=
16
21: invokevirtual #43 // Method StringBuilder.append:(LString;)LStringBuilder;
17
24: aload_0
18
25: getfield #16 // Field max:I
19
28: invokevirtual #46 // Method StringBuilder.append:(I)LStringBuilder;
20
31: ldc #50 // String )
21
33: invokevirtual #43 // Method StringBuilder.append:(LString;)LStringBuilder;
22
36: invokevirtual #52 // Method StringBuilder.toString:()LString;
23
39: areturn
Мы выпустили javap -c -v Rangeдля генерации этого вывода. Кроме того, здесь мы используем простые имена классов для краткости.
В любом случае, Kotlin использует StringBuilderдля генерации строковое представление вместо множественных конкатенаций строк (как любой хороший Java-разработчик!). Это:
- Сначала создается новый экземпляр
StringBuilder(индекс 0, 3, 4). - Затем он добавляет буквальную
Range(min=строку (индекс 7, 9). - Затем добавляется фактическое минимальное значение (индекс 12, 13, 16).
- Затем он добавляет литерал
, max=(индекс 19, 21). - Затем добавляется фактическое максимальное значение (индекс 24, 25, 28).
- Затем он закрывает скобки, добавляя
)литерал (индекс 31, 33). - Наконец, он создает
StringBuilderэкземпляр и возвращает его (индекс 36, 39).
По сути, чем больше у нас свойств в нашем классе данных, тем длиннее байт-код и, следовательно, тем больше время запуска.
Класс случая Скалы
Давайте напишем case classэквивалент в Scala:
Scala
xxxxxxxxxx
1
case class Range(min: Int, max: Int)
На первый взгляд кажется, что Scala генерирует гораздо более простую toStringреализацию:
Scala
xxxxxxxxxx
1
Compiled from "Range.scala"
2
public java.lang.String toString();
3
descriptor: ()Ljava/lang/String;
4
flags: (0x0001) ACC_PUBLIC
5
Code:
6
stack=2, locals=1, args_size=1
7
0: getstatic #89 // Field ScalaRunTime$.MODULE$:LScalaRunTime$;
8
3: aload_0
9
4: invokevirtual #111 // Method ScalaRunTime$._toString:(LProduct;)LString;
10
7: areturn
Однако toStringвызывает scala.runtime.ScalaRunTime._toString статический метод . Это, в свою очередь, вызывает productIteratorметод для перебора этого типа продукта. Этот итератор вызывает productElementметод, который выглядит следующим образом:
Scala
xxxxxxxxxx
1
public java.lang.Object productElement(int);
2
descriptor: (I)Ljava/lang/Object;
3
flags: (0x0001) ACC_PUBLIC
4
Code:
5
stack=3, locals=3, args_size=2
6
0: iload_1
7
1: istore_2
8
2: iload_2
9
3: tableswitch { // 0 to 1
10
0: 24
11
1: 34
12
default: 44
13
}
14
24: aload_0
15
25: invokevirtual #55 // Method min:()I
16
28: invokestatic #71 // Method BoxesRunTime.boxToInteger:(I)LInteger;
17
31: goto 59
18
34: aload_0
19
35: invokevirtual #58 // Method max:()I
20
38: invokestatic #71 // Method BoxesRunTime.boxToInteger:(I)LInteger;
21
41: goto 59
22
44: new #73 // class IndexOutOfBoundsException
23
47: dup
24
48: iload_1
25
49: invokestatic #71 // Method BoxesRunTime.boxToInteger:(I)LInteger;
26
52: invokevirtual #76 // Method Object.toString:()LString;
27
55: invokespecial #79 // Method IndexOutOfBoundsException."<init>":(LString;)V
28
58: athrow
29
59: areturn
Это в основном переключает все свойства case class. Например, если productIteratorсвойство хочет первое свойство, оно возвращает min. Также, когда productIteratorтребуется второй элемент, он вернет maxзначение. В противном случае он сгенерирует экземпляр IndexOutOfBoundsExceptionсигнала о выходе запроса за пределы.
Опять же, чем больше у нас свойств в a case class, тем больше будет этих переключателей. Следовательно, длина байт-кода пропорциональна количеству свойств. Другими словами, та же проблема, что и у Котлина data class.
Invoke Dynamic
Давайте еще ближе рассмотрим байт-код, сгенерированный для записей Java:
Джава
xxxxxxxxxx
1
Compiled from "Range.java"
2
public java.lang.String toString();
3
descriptor: ()Ljava/lang/String;
4
flags: (0x0001) ACC_PUBLIC
5
Code:
6
stack=1, locals=1, args_size=1
7
0: aload_0
8
1: invokedynamic #18, 0 // InvokeDynamic #0:toString:(LRange;)Ljava/lang/String;
9
6: areturn
Независимо от количества компонентов записи, это будет байт-код. Простое, полированное и элегантное решение. Но как это invokedynamicработает?
Представляем Indy
Invoke Dynamic (также известный как Indy) был частью JSR 292, намереваясь улучшить поддержку JVM для динамических языков. После первого выпуска в Java 7 invokedynamicкод операции и его java.lang.invokeбагаж довольно широко используются динамическими языками на основе JVM, такими как JRuby.
Хотя Indy был специально разработан для улучшения поддержки динамического языка, он предлагает гораздо больше. Фактически, это подходит для использования там, где разработчику языка нужна любая форма динамичности, от акробатики динамического типа до динамических стратегий! Например, лямбда-выражения Java 8 фактически реализованы с использованием языка invokedynamicJava, хотя это статически типизированный язык!
Определяемый пользователем байт-код
В течение достаточно долгого времени JVM поддерживала четыре типа invokestaticвызова методов : вызывать статические методы, invokeinterfaceвызывать методы интерфейса, invokespecialвызывать конструкторы super()или privateметоды и invokevirtualвызывать методы экземпляра.
Несмотря на различия, эти типы вызовов имеют одну общую черту: мы не можем обогатить их своей собственной логикой. Напротив, invokedynamicпозволяет нам загружать процесс вызова любым способом, каким мы захотим. Затем JVM позаботится о прямом вызове загрузочного метода.
Как работает инди?
В первый раз, когда JVM видит invokedynamicинструкцию, она вызывает специальный статический метод Bootstrap Method . Метод начальной загрузки - это фрагмент кода Java, который мы написали для подготовки фактической логики, которая будет вызвана :
Затем метод начальной загрузки возвращает экземпляр java.invoke.CallSite. Это CallSiteдержит ссылку на фактический метод, то есть MethodHandle. С этого момента каждый раз, когда JVM invokedynamicснова видит эту инструкцию, она пропускает медленный путь и напрямую вызывает основной исполняемый файл . JVM продолжает пропускать медленный путь, если что-то не меняется.
Почему Инди?
В отличие от API Reflection, java.lang.invokeAPI довольно эффективен, поскольку JVM может полностью видеть все вызовы. Поэтому JVM может применять все виды оптимизаций, пока мы максимально избегаем медленного пути!
В дополнение к аргументу эффективности, этот invokedynamicподход является более надежным и менее хрупким из-за своей простоты.
Кроме того, сгенерированный байт-код для записей Java не зависит от количества свойств. Таким образом, меньше байт-кода и более быстрое время запуска.
Наконец, давайте предположим, что новая версия Java включает новую и более эффективную реализацию метода начальной загрузки. С invokedynamic, наше приложение может воспользоваться этим усовершенствованием без перекомпиляции. Таким образом, у нас есть какая-то прямая двоичная совместимость . Кроме того, это динамическая стратегия, о которой мы говорили!
Методы объекта
Теперь, когда мы достаточно знакомы с Indy, давайте разберемся с invokedynamicбайт-кодом в записях:
Джава
xxxxxxxxxx
1
invokedynamic #18, 0 // InvokeDynamic #0:toString:(LRange;)Ljava/lang/String;
2
Посмотрите, что я нашел в таблице методов Bootstrap :
xxxxxxxxxx
1
BootstrapMethods:
2
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;
3
Method arguments:
4
#8 Range
5
#48 min;max
6
#50 REF_getField Range.min:I
7
#51 REF_getField Range.max:I
Таким образом, вызывается метод начальной загрузки для Records, bootstrapкоторый находится в java.lang.runtime.ObjectMethodsклассе. Как видите, этот метод начальной загрузки ожидает следующие параметры:
- Экземпляр
MethodHandles.Lookupпредставления контекста поиска (Ljava/lang/invoke/MethodHandles$Lookupчасть). - Имя методы (то есть
toString,equals,hashCodeи т.д.) начальная загрузка будет ссылка. Например, когда значение равноtoString, bootstrap вернетConstantCallSite(a,CallSiteкоторый никогда не изменяется), которое указывает на фактическуюtoStringреализацию для этой конкретной Записи. - Для
TypeDescriptorметода (Ljava/lang/invoke/TypeDescriptorчасть). - Маркер типа, т. Е.
Class<?>Представляющий тип класса Record. ЭтоClass<Range>в этом случае. - Пол-двоеточия списка всех компонентов имен, то есть
min;max. - Один
MethodHandleна компонент. Таким образом, метод начальной загрузки может создать наMethodHandleоснове компонентов для этой конкретной реализации метода.
invokedynamicИнструкция передает все эти аргументы метода начальной загрузки. Метод Bootstrap, в свою очередь, возвращает экземпляр ConstantCallSite. Это ConstantCallSiteсодержит ссылку на запрошенную реализацию метода, например toString.
Размышляя о записях
java.lang.ClassAPI был модернизирован для поддержки записи. Например, учитывая Class<?>, мы можем проверить, является ли это Запись или нет, используя новый isRecordметод:
Джава
xxxxxxxxxx
1
jshell> var r = new Range(0, 42)
2
r ==> Range[min=0, max=42]
3
jshell> r.getClass().isRecord()
5
$5 ==> true
Это, очевидно, возвращает falseдля не-типов записей:
Джава
xxxxxxxxxx
1
jshell> "Not a record".getClass().isRecord()
2
$6 ==> false
Существует также getRecordComponentsметод, который возвращает массив RecordComponentв том же порядке, который они определили в исходной записи. Каждый java.lang.reflect.RecordComponentпредставляет компонент записи или переменную текущего типа записи. Например, RecordComponent.getNameвозвращает имя компонента:
Джава
xxxxxxxxxx
1
jshell> public record User(long id, String username, String fullName) {}
2
| created record User
3
jshell> var me = new User(1L, "alidg", "Ali Dehghani")
5
me ==> User[id=1, username=alidg, fullName=Ali Dehghani]
6
jshell> Stream.of(me.getClass().getRecordComponents()).map(RecordComponent::getName).
8
...> forEach(System.out::println)
9
id
10
username
11
fullName
Точно так же getTypeметод возвращает токен типа для каждого компонента:
Джава
xxxxxxxxxx
1
jshell> Stream.of(me.getClass().getRecordComponents()).map(RecordComponent::getType).
2
...> forEach(System.out::println)
3
long
4
class java.lang.String
5
class java.lang.String
Можно даже получить дескриптор методов доступа через getAccessor:
Джава
xxxxxxxxxx
1
jshell> var nameComponent = me.getClass().getRecordComponents()[2].getAccessor()
2
nameComponent ==> public java.lang.String User.fullName()
3
jshell> nameComponent.setAccessible(true)
5
jshell> nameComponent.invoke(me)
7
$21 ==> "Ali Dehghani"
Аннотирующие записи
Java позволяет аннотировать записи, если аннотация применима к записи или ее элементам. Кроме того, будет новая аннотация ElementTypeназывается RECORD_COMPONENT. Аннотации с этой целью могут использоваться только для компонентов записи:
Джава
xxxxxxxxxx
1
(ElementType.RECORD_COMPONENT)
2
public @interface Param {}
Сериализация
Любая новая функция Java без неприятных отношений с сериализацией была бы неполной. На этот раз, однако, отношения звучат не так непривлекательно, как мы привыкли.
Хотя записи по умолчанию не сериализуемы, сделать это можно, просто внедрив java.io.Serializableинтерфейс маркера.
Сериализуемые записи сериализуются и десериализуются иначе, чем обычные сериализуемые объекты. Обновленный Javadoc для ObjectInputStreamутверждает, что:
- Сериализованная форма объекта записи представляет собой последовательность значений, полученных из компонентов записи .
- Процесс, с помощью которого объекты записей сериализуются или экспортируются, не может быть настроен ; любой класс-специфический
writeObject,readObject,readObjectNoData,writeExternal, иreadExternalметоды , определенные классы записей игнорируются во время сериализации и десериализации. - Класс
serialVersionUIDзаписи0Lне указан явно.
Заключение
Записи Java собираются предоставить новый способ инкапсуляции держателей данных. Хотя в настоящее время они ограничены с точки зрения функциональности (по сравнению с тем, что предлагают Kotlin или Scala), реализация является надежной.
Первый предварительный просмотр Records будет доступен в марте 2020 года. В этой статье мы использовали openjdk 14-ea 2020-03-17сборку, поскольку Java 14 еще не выпущена!
Дальнейшее чтение
Первый взгляд на записи в Java 14
Потенциальные ловушки в классах данных Kotlin