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

  • 24 марта, 17:22
  • 642
  • 0

Записи (records) у новій версії Java забезпечують компактний синтаксис для оголошення класів. Давайте вивчимо байт-код і порівняємо з реалізаціями аналогів в Kotlin і Scala.

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

Оголошення класу

Почнемо з простого прикладу:

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 з декількома фундаментальними властивостями:

  1. Записи оголошені як final, тому ми не можемо успадковуватися від них.
  2. Записи вже успадковуються від іншого класу під назвою java.lang.Record. Тому записи не можуть розширити будь-який інший клас, оскільки Java не допускає множинного спадкоємства.
  3. Записи можуть імплементити інші інтерфейси.
  4. Для кожного компонента існує свій метод доступу, наприклад, maxі min.
  5. Існують автоматично генеровані реалізації для toString, equals і hashCode та автоматично згенерований конструктор, який приймає всі компоненти в якості своїх аргументів.
  6. Крім того, 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-розробник). Що ми маємо:

  1. спочатку створюється новий екземпляр StringBuilder (індекс 0, 3, 4);
  2. додається літерал Range;
  3. додається фактичне значення min (індекс 12, 13, 16);
  4. додається літерал max= (індекс 19, 21);
  5. додається фактичне значення max (індекс 24, 25, 28);
  6. дужки закриваються і додається літерал (індекс 31, 33);
  7. створюється і повертається примірник 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, який використовується для підготовки фактичної логіки до виклику.

Потім метод bootstrap повертає екземпляр java.invoke.CallSite. CallSite містить посилання на фактичний метод, тобто MethodHandle. З цього моменту кожен раз, коли JVM знову бачить invokedynamicinstruction, він пропускає «повільний шлях» і застосовує прямий виклик, поки щось не зміниться.

Чому Indy?

На відміну від Reflection API, java.lang.invoke API більш ефективний, так як JVM може повністю бачити всі виклики. Тому JVM може застосовувати всілякі оптимізації, поки ми уникаємо «повільного шляху», наскільки це можливо.

Згенеруваний байт-код для записів Java не залежить від кількості властивостей. Таким чином, менше байт-коду - швидше час запуску.

Нарешті, припустимо, що нова версія Java включає в себе нову реалізацію методу bootstrap. З інструкціями invokedynamic наш додаток може скористатися перевагами цього поліпшення без перекомпіляції. Отже, у нас є свого роду пряма бінарна сумісність.

Методи об'єкта

Давайте розберемося в байт-коді invokedynamic:

invokedynamic #18,  0 // InvokeDynamic #0:toString:(LRange;)Ljava/lang/String;

Ось що в байт-коді:

BootstrapMethods:0:#41REF_invokeStaticjava/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. Метод може приймати такі аргументи:

  1. Примірник MethodHandles.Lookup, що описує контекст пошуку ( Ljava/lang/invoke/MethodHandles$Lookup).
  2. Ім'я методу: toString, equals, hashCode і т.д. Наприклад, якщо значення дорівнює toString, bootstrap поверне ConstantCallSite- покажчик на фактичну реалізацію toString для цього конкретного запису.
  3. TypeDescriptor для методу ( Ljava/lang/invoke/TypeDescriptor).
  4. Токен Class<?>, що описує тип класу запису. Тут це Class<Range>.
  5. Список всіх імен компонентів, розділених " ;", тобто min;max.
  6. Один 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 . Так що тепер є все, щоб спробувати записи в справі.

Джерело перекладу


0 комментариев
Сортировка:
Добавить комментарий