Исследуем записи в 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, записи и запечатанные типы (с большой вероятностью).

Подписывайтесь на канал в Telegram, чтобы не пропускать новости.

Все материалы на этом сайте выложены под лицензией CC BY-SA 4.0
© Евгений Козлов, 2017-2020