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