Исследуем записи в Java 14

от автора

В прошлый раз мы тестировали улучшенный оператор instanceof, который появится в грядущей, 14-й версии Java (выйдет в марте 2020). Сегодня я хотел бы исследовать в деталях вторую синтаксическую возможность, которая также появится в Java 14: записи (records).

У записей есть свой JEP, однако он не сильно блещет подробностями, поэтому многое придётся пробовать и проверять самим. Да, можно конечно, открыть спецификацию Java SE, но, мне кажется, гораздо интереснее самим начать писать код и смотреть на поведение компилятора в тех или иных ситуациях. Так что заваривайте чаёк и располагайтесь поудобнее. Поехали.

В отличие от прошлого раза, когда мне пришлось собирать специальную ветку JDK для тестирования instanceof, сейчас всё это уже присутствует в главной ветке и доступно в ранней сборке JDK 14, которую я и скачал.

Для начала реализуем классический пример с Point и скомпилируем его:

record Point(float x, float y) { }

> javac --enable-preview --release 14 Point.java Note: Point.java uses preview language features. Note: Recompile with -Xlint:preview for details.

javac успешно скомпилировал файл Point.class. Давайте его дизассемблируем и посмотрим, что нам там нагенерировал компилятор:

> javap -private Point.class Compiled from "Point.java" final class Point extends java.lang.Record {   private final float x;   private final float y;   public Point(float, float);   public java.lang.String toString();   public final int hashCode();   public final boolean equals(java.lang.Object);   public float x();   public float y(); }

Ага, компилятор создал следующее:

  • Финальный класс, отнаследованный от java.lang.Record (по аналогии с enum, которые наследуются от java.lang.Enum).
  • Приватные финальные поля x и y.
  • Публичный конструктор, совпадающий с сигнатурой самой записи. Такой конструктор называется каноническим.
  • Реализации toString(), hashCode() и equals(). Интересно, что hashCode() и equals() являются final, а toString() — нет. Это вряд ли на что-то может повлиять, так как сам класс final, но кто-нибудь знает, зачем так сделали? (Я нет)
  • Методы чтения полей.

С конструктором и методами чтения всё понятно, но интересно, как именно реализованы toString(), hashCode() и equals()? Давайте посмотрим. Для этого запустим javap с флагом -verbose:

Длинный вывод дизассемблера

> javap -private -verbose Point.class Classfile Point.class   Last modified 29 дек. 2019 г.; size 1157 bytes   SHA-256 checksum 24fe5489a6a01a7232f45bd7739a961c30d7f6e24400a3e3df2ec026cc94c0eb   Compiled from "Point.java" final class Point extends java.lang.Record   minor version: 65535   major version: 58   flags: (0x0030) ACC_FINAL, ACC_SUPER   this_class: #8                          // Point   super_class: #2                         // java/lang/Record   interfaces: 0, fields: 2, methods: 6, attributes: 4 Constant pool:    #1 = Methodref          #2.#3          // java/lang/Record."<init>":()V    #2 = Class              #4             // java/lang/Record    #3 = NameAndType        #5:#6          // "<init>":()V    #4 = Utf8               java/lang/Record    #5 = Utf8               <init>    #6 = Utf8               ()V    #7 = Fieldref           #8.#9          // Point.x:F    #8 = Class              #10            // Point    #9 = NameAndType        #11:#12        // x:F   #10 = Utf8               Point   #11 = Utf8               x   #12 = Utf8               F   #13 = Fieldref           #8.#14         // Point.y:F   #14 = NameAndType        #15:#12        // y:F   #15 = Utf8               y   #16 = Fieldref           #8.#9          // Point.x:F   #17 = Fieldref           #8.#14         // Point.y:F   #18 = InvokeDynamic      #0:#19         // #0:toString:(LPoint;)Ljava/lang/String;   #19 = NameAndType        #20:#21        // toString:(LPoint;)Ljava/lang/String;   #20 = Utf8               toString   #21 = Utf8               (LPoint;)Ljava/lang/String;   #22 = InvokeDynamic      #0:#23         // #0:hashCode:(LPoint;)I   #23 = NameAndType        #24:#25        // hashCode:(LPoint;)I   #24 = Utf8               hashCode   #25 = Utf8               (LPoint;)I   #26 = InvokeDynamic      #0:#27         // #0:equals:(LPoint;Ljava/lang/Object;)Z   #27 = NameAndType        #28:#29        // equals:(LPoint;Ljava/lang/Object;)Z   #28 = Utf8               equals   #29 = Utf8               (LPoint;Ljava/lang/Object;)Z   #30 = Utf8               (FF)V   #31 = Utf8               Code   #32 = Utf8               LineNumberTable   #33 = Utf8               MethodParameters   #34 = Utf8               ()Ljava/lang/String;   #35 = Utf8               ()I   #36 = Utf8               (Ljava/lang/Object;)Z   #37 = Utf8               ()F   #38 = Utf8               SourceFile   #39 = Utf8               Point.java   #40 = Utf8               Record   #41 = Utf8               BootstrapMethods   #42 = MethodHandle       6:#43          // 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;   #43 = Methodref          #44.#45        // 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;   #44 = Class              #46            // java/lang/runtime/ObjectMethods   #45 = NameAndType        #47:#48        // 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;   #46 = Utf8               java/lang/runtime/ObjectMethods   #47 = Utf8               bootstrap   #48 = Utf8               (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;   #49 = String             #50            // x;y   #50 = Utf8               x;y   #51 = MethodHandle       1:#7           // REF_getField Point.x:F   #52 = MethodHandle       1:#13          // REF_getField Point.y:F   #53 = Utf8               InnerClasses   #54 = Class              #55            // java/lang/invoke/MethodHandles$Lookup   #55 = Utf8               java/lang/invoke/MethodHandles$Lookup   #56 = Class              #57            // java/lang/invoke/MethodHandles   #57 = Utf8               java/lang/invoke/MethodHandles   #58 = Utf8               Lookup {   private final float x;     descriptor: F     flags: (0x0012) ACC_PRIVATE, ACC_FINAL    private final float y;     descriptor: F     flags: (0x0012) ACC_PRIVATE, ACC_FINAL    public Point(float, float);     descriptor: (FF)V     flags: (0x0001) ACC_PUBLIC     Code:       stack=2, locals=3, args_size=3          0: aload_0          1: invokespecial #1                  // Method java/lang/Record."<init>":()V          4: aload_0          5: fload_1          6: putfield      #7                  // Field x:F          9: aload_0         10: fload_2         11: putfield      #13                 // Field y:F         14: return       LineNumberTable:         line 1: 0     MethodParameters:       Name                           Flags       x       y    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:(LPoint;)Ljava/lang/String;          6: areturn       LineNumberTable:         line 1: 0    public final int hashCode();     descriptor: ()I     flags: (0x0011) ACC_PUBLIC, ACC_FINAL     Code:       stack=1, locals=1, args_size=1          0: aload_0          1: invokedynamic #22,  0             // InvokeDynamic #0:hashCode:(LPoint;)I          6: ireturn       LineNumberTable:         line 1: 0    public final boolean equals(java.lang.Object);     descriptor: (Ljava/lang/Object;)Z     flags: (0x0011) ACC_PUBLIC, ACC_FINAL     Code:       stack=2, locals=2, args_size=2          0: aload_0          1: aload_1          2: invokedynamic #26,  0             // InvokeDynamic #0:equals:(LPoint;Ljava/lang/Object;)Z          7: ireturn       LineNumberTable:         line 1: 0    public float x();     descriptor: ()F     flags: (0x0001) ACC_PUBLIC     Code:       stack=1, locals=1, args_size=1          0: aload_0          1: getfield      #16                 // Field x:F          4: freturn       LineNumberTable:         line 1: 0    public float y();     descriptor: ()F     flags: (0x0001) ACC_PUBLIC     Code:       stack=1, locals=1, args_size=1          0: aload_0          1: getfield      #17                 // Field y:F          4: freturn       LineNumberTable:         line 1: 0 } SourceFile: "Point.java" Record:   float x;     descriptor: F    float y;     descriptor: F  BootstrapMethods:   0: #42 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 Point       #49 x;y       #51 REF_getField Point.x:F       #52 REF_getField Point.y:F InnerClasses:   public static final #58= #54 of #56;    // Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles

В реализации toString(), hashCode() и equals() мы видим invokedynamic. Значит, логика этих методов будет генерироваться лениво самой виртуальной машиной. Я не большой специалист по рантайму, но думаю, что это сделано для лучшей эффективности. Например, если в будущем придумают какой-нибудь более быстрый хеш, то в таком подходе старый скомпилированный код получит все преимущества новой версии. Также это уменьшает размер class-файлов.

Но что-то мы слишком сильно углубились. Вернёмся к нашим баранам записям. Давайте попробуем создать экземпляр Point и посмотрим, как работают методы. С этого момента я больше не буду использовать javac и просто буду запускать java-файл напрямую:

… public class Main {     public static void main(String[] args) {         var point = new Point(1, 2);         System.out.println(point);         System.out.println("hashCode = " + point.hashCode());         System.out.println("hashCode2 = " + Objects.hash(point.x(), point.y()));          var point2 = new Point(1, 2);         System.out.println(point.equals(point2));     } }  record Point(float x, float y) { }

> java --enable-preview --source 14 Main.java Note: Main.java uses preview language features. Note: Recompile with -Xlint:preview for details. Point[x=1.0, y=2.0] hashCode = -260046848 hashCode2 = -260045887 true

Таким образом, toString() и equals() работают как я и ожидал (ну разве что toString() использует квадратные скобки, а я хотел бы фигурные). А вот hashCode() работает иначе. Я почему-то полагал, что он будет совместимым с Objects.hash(). Но ничто нам не мешает создать свою реализацию hashCode(). Давайте так и сделаем, а заодно перенесём метод main() внутрь:

… public record Point(float x, float y) {     @Override     public int hashCode() {         return Objects.hash(x, y);     }      public static void main(String[] args) {         System.out.println(new Point(1, 2).hashCode());     } }

> java --enable-preview --source 14 Point.java Note: Point.java uses preview language features. Note: Recompile with -Xlint:preview for details. -260045887

ОК. А теперь давайте проверим компилятор на стойкость. Сделаем что-нибудь некорректное, например, добавим поле:

public record Point(float x, float y) {     private float z; }

Point.java:2: error: field declaration must be static     private float z;                   ^   (consider replacing field with record component)

Значит, можно добавлять только статические поля.

Интересно, что будет, если сделать компоненты final? Станут ещё финальнее?

public record Point(final float x, final float y) { }

Point.java:1: error: record components cannot have modifiers public record Point(final float x, final float y) {                     ^ Point.java:1: error: record components cannot have modifiers public record Point(final float x, final float y) {                                    ^

Пожалуй, это логичный запрет. Чтобы не было иллюзии того, будто бы компоненты станут изменяемыми, если убрать final. Да и аналогичное правило есть у enum, так что ничего нового:

enum A {     final X; // No modifiers allowed for enum constants }

Что если переопределить тип метода доступа?

public record Point(float x, float y) {     public double x() {         return x;     } }

Point.java:2: error: invalid accessor method in record Point     public double x() {                   ^   (return type of accessor method x() is not compatible with type of record component x)

Это абсолютно логично.

А если изменить видимость?

public record Point(float x, float y) {     private float x() {         return x;     } }

Point.java:2: error: invalid accessor method in record Point     private float x() {                   ^   (accessor method must be public)

Тоже нельзя.

Наследоваться от классов запрещено, даже от Object:

public record Point(float x, float y) extends Object { }

Point.java:1: error: '{' expected public record Point(float x, float y) extends Object {                                      ^

А вот реализовывать интерфейсы можно:

public record Point(float x, float y) implements PointLike {     public static void main(String[] args) {         PointLike point = new Point(1, 2);         System.out.println(point.x());         System.out.println(point.y());     } }  public interface PointLike {     float x();     float y(); }

> java --enable-preview --source 14 Point.java Note: Point.java uses preview language features. Note: Recompile with -Xlint:preview for details. 1.0 2.0

Интересно немного поиграться с каноническим конструктором. Во-первых, давайте напишем компактный канонический конструктор, т.е. канонический конструктор без аргументов, и добавим туда валидацию входных параметров:

public record Point(float x, float y) {     public Point {         if (Float.isNaN(x) || Float.isNaN(y)) {             throw new IllegalArgumentException("NaN");         }     }      public static void main(String[] args) {         System.out.println(new Point(Float.NaN, 2));     } }

… Exception in thread "main" java.lang.IllegalArgumentException: NaN         at Point.<init>(Point.java:4)         at Point.main(Point.java:9)

Заработало. А вот интересно, заработает ли, если написать тот же самый код, но через return:

public record Point(float x, float y) {     public Point {         if (!Float.isNaN(x) && !Float.isNaN(y)) {             return;         }         throw new IllegalArgumentException("NaN");     } }

Point.java:2: error: invalid compact constructor in record Point(float,float)     public Point {            ^   (compact constructor must not have return statements)

Интересная деталь. Вряд ли мне это сильно помешает в жизни, так как я не любитель писать return, но всяким разработчикам IDE это нужно иметь в виду.

Давайте попробуем явный канонический конструктор. Интересно, можно ли переименовать параметры?

public record Point(float x, float y) {     public Point(float _x, float _y) {         if (Float.isNaN(_x) || Float.isNaN(_y)) {             throw new IllegalArgumentException("NaN");         }         this.x = _x;         this.y = _y;     } }

Point.java:2: error: invalid canonical constructor in record Point     public Point(float _x, float _y) {            ^   (invalid parameter names in canonical constructor)

Оказывается, нельзя переименовать. Но я не вижу ничего плохого в таком ограничении. Код чище будет.

А что там с порядком инициализации?

public record Point(float x, float y) {     public Point {         System.out.println(this);     }      public static void main(String[] args) {         System.out.println(new Point(-1, 2));     } }

… Point[x=0.0, y=0.0] Point[x=-1.0, y=2.0]

Сначала напечатался Point с нулями, значит присваивание полей произошло в самом конце конструктора, после System.out.println(this).

Хорошо. Как насчёт добавления неканонического конструктора? Например, конструктора без аргументов:

public record Point(float x, float y) {     public Point() {     } }

Point.java:2: error: constructor is not canonical, so its first statement must invoke another constructor     public Point() {            ^

Ага, забыли написать this(0, 0). Но не будем исправлять и проверять это.

Что насчёт дженериков?

public record Point<A extends Number>(A x, A y) {     public static void main(String[] args) {         System.out.println(new Point<>(-1, 2));     } }

> java --enable-preview --source 14 Point.java Note: Point.java uses preview language features. Note: Recompile with -Xlint:preview for details. Point[x=-1, y=2]

Ничего сверхъестественного. Ну разве что надо помнить, что параметры типа нужно ставить раньше параметров записи.

Можно ли создать запись без компонент?

public record None() {     public static void main(String[] args) {         System.out.println(new None());     } }

> java --enable-preview --source 14 None.java Note: None.java uses preview language features. Note: Recompile with -Xlint:preview for details. None[]

Почему нет.

Какие вещи мы ещё не попробовали? Что там со вложенными записями?

record Point(int x, int y) {     record Nested(int z) {         void print() {             System.out.println(x);         }     } }

Point.java:4: error: non-static record component x cannot be referenced from a static context             System.out.println(x);                                ^

Значит, вложенные записи всегдя являются статическими (как и enum). Если это так, то что если объявить локальную запись? По идее, тогда она не должна захватывать внешний нестатический контекст:

public class Main {     public static void main(String[] args) {         record Point(int x, int y) {             void print() {                 System.out.println(Arrays.toString(args));             }         }          new Point(1, 2).print();     } }

> java --enable-preview --source 14 Main.java Note: Main.java uses preview language features. Note: Recompile with -Xlint:preview for details. []

Хм, сработало. Думаю, это баг. Или просто недоделка: такое поведение унаследовалось от обычных локальных классов, которые умеют захватывать внешние effectively final переменные, а для записей поправить забыли.

Один больной вопрос, который меня интересует: можно ли создать несколько публичных записей в одном файле?

public record Point(float x, float y) { }  public record Point2(float x, float y) { }

> javac --enable-preview --release 14 Point.java Point.java:4: error: class Point2 is public, should be declared in a file named Point2.java public record Point2(float x, float y) {        ^

Нельзя. Интересно, будет ли это проблемой в реальных проектах? Наверняка многие захотят писать очень много записей, чтобы моделировать свои сущности. Тогда придётся всех их раскладывать по собственным файлам, либо использовать вложенные записи.

Напоследок я ещё хотел бы поиграться с рефлексией. Как во время выполнения узнать информацию о компонентах, которые содержит запись? Для это можно использовать метод Class.getRecordComponents():

import java.lang.reflect.RecordComponent;  public record Point(float x, float y) {     public static void main(String[] args) {         var point = new Point(1, 2);         for (RecordComponent component : point.getClass().getRecordComponents()) {             System.out.println(component);         }     } }

> java --enable-preview --source 14 Point.java Note: Point.java uses preview language features. Note: Recompile with -Xlint:preview for details. float x float y

Также я заметил, что в Java 14 появился новый тип аннотации специально для компонентов записей: ElementType.RECORD_COMPONENT. А что будет, если использовать старые типы FIELD и PARAMETER? Ведь компоненты вроде бы как и не поля, и не параметры:

public record Point(         @FieldAnnotation @ComponentAnnotation float x,         @ParamAnnotation @ComponentAnnotation float y) { }  @Target(ElementType.FIELD) @interface FieldAnnotation { }  @Target(ElementType.PARAMETER) @interface ParamAnnotation { }  @Target(ElementType.RECORD_COMPONENT) @interface ComponentAnnotation { }

Ага, код компилируется, значит работают все три. Ну это логично. Интересно, а будут ли они «протаскиваться» на поля?

public record Point(         @FieldAnnotation @ComponentAnnotation float x,         @ParamAnnotation @ComponentAnnotation float y) {     public static void main(String[] args) {         var point = new Point(1, 2);         Field[] fields = point.getClass().getDeclaredFields();         for (Field field : fields) {             for (Annotation annotation : field.getAnnotations()) {                 System.out.println(field + ": " + annotation);             }         }     } }  @Target(ElementType.FIELD) @Retention(RetentionPolicy.RUNTIME) @interface FieldAnnotation { }  @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) @interface ParamAnnotation { }  @Target(ElementType.RECORD_COMPONENT) @Retention(RetentionPolicy.RUNTIME) @interface ComponentAnnotation { }

> java --enable-preview --source 14 Point.java Note: Point.java uses preview language features. Note: Recompile with -Xlint:preview for details. private final float Point.x: @FieldAnnotation() 

Значит, «протаскиваются» только аннотации FIELD, но не RECORD_COMPONENT и PARAMETER.

На этом, пожалуй, я закончу, потому что статья и так уже вышла довольно громоздкой. Можно было бы «копать» ещё долго и глубоко, тестируя всякие разные краевые случаи, но думаю, текущего уровня глубины более чем достаточно.

Выводы

Записи — это несомненно крутая и очень ожидаемая сообществом вещь, которая в будущем будет экономить нам время и избавит нас от огромного количества шаблонного кода. Сейчас записи уже практически готовы, и осталось только подождать, когда починят некоторые шероховатости и выпустят общедоступный релиз Java 14. Правда, потом ещё нужно будет подождать 1-2 релиза, когда записи станут стабильными, но при большом желании их можно использовать в preview-режиме.

А те, кто не спешат переходить с Java 8, думаю, надо дождаться сентября 2021 года, и сразу перейти на Java 17, где уже будут стабильные выражения switch, блоки текста, улучшенный instanceof, записи и запечатанные типы (с большой вероятностью).

P.S. Если вы не хотите пропускать мои новости и статьи о Java, то рекомендую вам подписаться на мой канал в Telegram.

Всех с наступающим!


ссылка на оригинал статьи https://habr.com/ru/post/482300/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *