Записи — это новая функция предварительного просмотра в 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 фактически реализованы с использованием языка invokedynamic
Java, хотя это статически типизированный язык!
Определяемый пользователем байт-код
В течение достаточно долгого времени 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.invoke
API довольно эффективен, поскольку 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.Class
API был модернизирован для поддержки записи. Например, учитывая 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