18 марта 2020

Java Records. Не музыкальный лейбл, а расширение возможностей языка

Библиотека программиста — ваш источник образовательного контента в IT-сфере. Мы публикуем обзоры книг, видеолекции и видеоуроки, дайджесты и образовательные статьи, которые помогут вам улучшить процесс познания в разработке.
Записи (records) в новой версии Java обеспечивают компактный синтаксис для объявления классов. Давайте изучим байт-код и сравним с реализациями аналогов в Kotlin и Scala.
Java Records. Не музыкальный лейбл, а расширение возможностей языка
☕ Подтянуть свои знания по Java вы можете на нашем телеграм-канале «Библиотека Java для собеса»

Объявление класса

Начнем с простого примера:

        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();
}
    
🧩☕ Интересные задачи по Java для практики можно найти на нашем телеграм-канале «Библиотека задач по Java»

Классы данных 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 поддерживал четыре типа вызова методов:

  1. invokestatic для вызова статических методов,
  2. invokeinterface для вызова интерфейсных методов,
  3. invokespecial для вызова конструкторов,
  4. super() или privatemethods и invokevirtual для вызова методов экземпляра.

Несмотря на их различия, эти типы вызовов имеют одну общую черту: мы не можем снабдить их собственной логикой. А еще, invokedynamic позволяет нам загружать процесс вызова любым желаемым способом.

Как работает Indy

Когда JVM видит вызываемую динамическую инструкцию, он вызывает специальный статический Bootstrap Method. Данный метод – это фрагмент кода Java, используемый для подготовки фактической логики к вызову.

Java Records. Не музыкальный лейбл, а расширение возможностей языка

Затем метод 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. Так что теперь есть всё, чтобы попробовать записи в деле.

Источники

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
Java Team Lead
Москва, по итогам собеседования
Разработчик С#
от 200000 RUB до 400000 RUB
Senior Java Developer
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ