Исследуем записи в 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.

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

Оставить комментарий

Интересное