Источник: abhinavpandey.dev В этом руководстве мы рассмотрим основы использования записей (Records) в Java. Записи появились в Java 14 как способ удалить шаблонный код вокруг создания объектов-значений (Value objects), используя преимущества неизменяемых объектов. Кофе-брейк #128. Руководство по использованию Java Records - 1

1. Основные понятия

Прежде чем перейти непосредственно к записям, давайте рассмотрим проблему, которую они решают. Для этого нам придется вспомнить, как объекты-значения создавались до Java 14.

1.1. Объекты-значения (Value objects)

Объекты-значения (Value objects) являются неотъемлемой частью приложений Java. В них хранятся данные, которые необходимо передавать между уровнями приложения. Объект-значение содержит поля, конструкторы и методы для доступа к этим полям. Ниже приведен пример объекта-значения:

public class Contact {
    private final String name;
    private final String email;

    public Contact(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }
}

1.2. Равенство между Value objects

Объекты-значения также могут предоставлять способ их сравнения на предмет равенства. По умолчанию Java сравнивает равенство объектов путем сравнения их адреса памяти. Однако в некоторых случаях объекты, содержащие одинаковые данные, могут считаться равными. Чтобы реализовать это, мы можем переопределить методы equals и .hashCode. Давайте реализуем их для класса Contact:

public class Contact {

    // ...

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Contact contact = (Contact) o;
        return Object.equals(email, contact.email) &&
                Objects.equals(name, contact.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name, email);
    }
}

1.3. Неизменяемость Value objects

Объекты-значения должны быть неизменяемыми. Это означает, что мы должны ограничить способы изменения полей объекта. Это целесообразно по следующим причинам:
  • Чтобы избежать риска случайного изменения значения поля.
  • Чтобы убедиться, что равные объекты остаются одинаковыми на протяжении всей их жизни.
Поскольку класс Contact уже неизменяем, теперь мы:
  1. сделали поля private и final.
  2. предоставили только getter для каждого поля (без setters).

1.4. Регистрация объектов Value objects

Часто нам требуется регистрировать значения, содержащиеся в объектах. Это делается путем предоставления метода toString. Всякий раз, когда объект регистрируется или печатается, вызывается метод toString. Здесь самый простой способ — распечатать значение каждого поля. Вот пример:

public class Contact {
    // ...
    @Override
    public String toString() {
        return "Contact[" +
                "name='" + name + '\'' +
                ", email=" + email +
                ']';
    }
}

2. Сокращение шаблонов с помощью Records

Поскольку большинство объектов-значений имеют одинаковые потребности и функциональные возможности, было бы неплохо упростить процесс их создания. Давайте посмотрим, как записи помогают этого достичь.

2.1. Преобразование класса Person в Record

Давайте создадим запись класса Contact, которая имеет ту же функциональность, что и класс Contact, определенный выше.

public record Contact(String name, String email) {}
Ключевое слово record используется для создания класса Record. Записи могут обрабатываться вызывающей стороной точно так же, как класс. Например, чтобы создать новый экземпляр записи, мы можем использовать ключевое слово new.

Contact contact = new Contact("John Doe", "johnrocks@gmail.com");

2.2. Поведение по умолчанию

Мы сократили код до одной строки. Перечислим, что в него входит:
  1. Поля name и email являются private и final по умолчанию.

  2. Код определяет “канонический конструктор”, который принимает поля в качестве параметров.

  3. Поля доступны через геттер-подобные методы — name() и email(). Для полей нет установщика, поэтому данные в объекте становятся неизменяемыми.

  4. Реализован метод toString для печати полей так же, как мы делали это для класса Contact.

  5. Реализованы методы equals и .hashCode. Они включают все поля, как и класс Contact.

2.3 Канонический конструктор

Конструктор, определенный по умолчанию, принимает все поля в качестве входных параметров и устанавливает их в поля. Например, ниже показан канонический конструктор (Canonical Constructor), определенный по умолчанию:

public Contact(String name, String email) {
    this.name = name;
    this.email = email;
}
Если мы определим конструктор с такой же сигнатурой в классе записи, он будет использоваться вместо канонического конструктора.

3. Работа с записями

Мы можем изменить поведение записи несколькими способами. Давайте рассмотрим некоторые варианты использования и способы их достижения.

3.1. Переопределение реализаций по умолчанию

Любую реализацию по умолчанию можно изменить, переопределив ее. Например, если мы хотим изменить поведение метода toString, то мы можем переопределить его между фигурными скобками {}.

public record Contact(String name, String email) {
    @Override
    public String toString() {
        return "Contact[" +
                "name is '" + name + '\'' +
                ", email is" + email +
                ']';
    }
}
Точно так же мы можем переопределить методы equals и hashCode.

3.2. Компактные конструкторы

Иногда мы хотим, чтобы конструкторы делали больше, чем просто инициализировали поля. Для этого мы можем добавить необходимые операции в нашу запись в компактном конструкторе (Compact Constructor). Он называется компактным, потому что ему не нужно определять инициализацию полей или список параметров.

public record Contact(String name, String email) {
    public Contact {
        if(!email.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
    }
}
Обратите внимание, что список параметров отсутствует, а инициализация name и email происходит в фоновом режиме перед выполнением проверки.

3.3. Добавление конструкторов

В запись можно добавить несколько конструкторов. Давайте рассмотрим пару примеров и ограничений. Для начала добавим новые допустимые конструкторы:

public record Contact(String name, String email) {
    public Contact(String email) {
        this("John Doe", email);
    }

    // replaces the default constructor
    public Contact(String name, String email) {
        this.name = name;
        this.email = email;
    }
}
В первом случае доступ к конструктору по умолчанию осуществляется с помощью ключевого слова this. Второй конструктор переопределяет конструктор по умолчанию, поскольку он имеет тот же список параметров. В этом случае запись сама по себе не создаст конструктор по умолчанию. Существует несколько ограничений на конструкторы.

1. Конструктор по умолчанию всегда должен вызываться из любого другого конструктора.

Например, приведенный ниже код не будет компилироваться:

public record Contact(String name, String email) {
    public Contact(String name) {
        this.name = "John Doe";
        this.email = null;
    }
}
Это правило гарантирует, что поля всегда инициализируются. Также гарантируется, что операции, определенные в компактном конструкторе, всегда выполняются.

2. Невозможно переопределить конструктор по умолчанию, если определен компактный конструктор.

Когда компактный конструктор определен, автоматически создается конструктор по умолчанию с логикой инициализации и компактного конструктора. В этом случае компилятор не позволит нам определить конструктор с теми же аргументами, что и конструктор по умолчанию. Например, в этом коде компиляция не произойдет:

public record Contact(String name, String email) {
    public Contact {
        if(!email.contains("@")) {
            throw new IllegalArgumentException("Invalid email");
        }
    }
    public Contact(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

3.4. Реализация интерфейсов

Как и в любом классе, в записях мы можем реализовать интерфейсы.

public record Contact(String name, String email) implements Comparable<Contact> {
    @Override
    public int compareTo(Contact o) {
        return name.compareTo(o.name);
    }
}
Важное примечание. Для обеспечения полной неизменности записи не могут участвовать в наследовании. Записи являются окончательными (final) и не могут быть расширены. Они также не могут расширять другие классы.

3.5. Добавление методов

В дополнение к конструкторам, которые переопределяют методы и реализации интерфейсов, мы также можем добавлять любые методы по своему желанию. Например:

public record Contact(String name, String email) {
    String printName() {
        return "My name is:" + this.name;
    }
}
Также мы можем добавить статические методы. Например, если мы хотим иметь статический метод, который возвращает регулярное выражение, по которому можно проверять электронную почту, то мы можем определить его, как показано ниже:

public record Contact(String name, String email) {
    static Pattern emailRegex() {
        return Pattern.compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE);
    }
}

3.6. Добавление полей

Мы не можем добавить поля экземпляра в запись. Однако мы можем добавить статические поля.

public record Contact(String name, String email) {
    private static final Pattern EMAIL_REGEX_PATTERN = Pattern
            .compile("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", Pattern.CASE_INSENSITIVE);

    static Pattern emailRegex() {
        return EMAIL_REGEX_PATTERN;
    }
}
Обратите внимание, что в статических полях нет неявных ограничений. При необходимости они могут быть общедоступными и не окончательными.

Заключение

Записи — отличный способ определить классы данных. Они намного удобнее и мощнее, чем подход JavaBeans/POJO. Из-за простоты реализации им следует отдавать предпочтение перед другими способами создания объектов-значений.