🔒 Раздел 13 · Вопрос #20

Что такое Record и как он помогает создавать иммутабельные классы

Объявление record в одну строку полностью заменяет 30+ строк шаблонного кода (boilerplate):


🟢 Junior Level

30-секундный ответ

Запись (Record) — это специализированный вид класса, появившийся как превью в Java 14 и ставший стабильным стандартом в Java 16 (JEP 395). Он спроектирован специально как «прозрачный переносчик неизменяемых данных» (Transparent Carrier for Immutable Data).

Объявление record в одну строку полностью заменяет 30+ строк шаблонного кода (boilerplate):

// Одна строка вместо целого класса!
public record GeoPoint(double latitude, double longitude) {}

Компилятор javac автоматически генерирует в байткоде:

  1. Класс со спецификатором final, неявно наследующий java.lang.Record.
  2. Поля компонентов со спецификаторами private final.
  3. Канонический конструктор со всеми параметрами.
  4. Публичные методы чтения без префикса get (в примере: latitude() и longitude()).
  5. Корректные реализации equals(), hashCode() и toString(), учитывающие все компоненты записи.

Сравнение: Обычный класс vs Record

// ❌ ДО Java 16: Многословный boilerplate
public final class PointOld {
    private final int x;
    private final int y;

    public PointOld(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() { return x; }
    public int getY() { return y; }

    @Override
    public boolean equals(Object o) { ... }
    @Override
    public int hashCode() { ... }
    @Override
    public String toString() { ... }
}

// ✅ В Java 16+: Идеальная лаконичность
public record Point(int x, int y) {}

🟡 Middle Level

Главная ловушка: Поверхностная иммутабельность (Shallow Immutability)

record гарантирует, что ссылки на поля нельзя перезаписать. Однако если компонент записи является изменяемым объектом (коллекция, массив, Date), его содержимое можно свободно модифицировать извне:

public record UserGroup(String groupName, List<String> members) {}

// ОПАСНОСТЬ:
List<String> mutableList = new ArrayList<>();
mutableList.add("Alice");

UserGroup group = new UserGroup("Admins", mutableList);
// Модификация исходного списка ломает состояние Record!
mutableList.add("Hacker"); 

// Модификация через геттер:
group.members().clear(); // Содержимое группы очищено!

Решение: Компактный конструктор (Compact Constructor)

В record появился специальный синтаксис — компактный конструктор. В нём отсутствуют круглые скобки со списком параметров, а присвоение полям this.x = x выполняется компилятором автоматически в самом конце:

public record UserGroup(String groupName, List<String> members) {
    // Компактный конструктор для валидации и защитного копирования:
    public UserGroup {
        Objects.requireNonNull(groupName, "Group name cannot be null");
        // Защитная копия: превращаем в неизменяемый список!
        members = (members == null) ? List.of() : List.copyOf(members);
    }
}

Виды конструкторов в Record

  1. Канонический (Canonical): конструктор, сигнатура которого совпадает с заголовком записи. Генерируется неявно.
  2. Компактный (Compact): модифицирует параметры или валидирует их до неявного присвоения полям.
  3. Кастомный (Пользовательский): обязан явно делегировать вызов каноническому конструктору через this(...):
    public record Point(int x, int y) {
        // Дополнительный конструктор:
        public Point(int value) {
            this(value, value); // Обязательный вызов this(...)
        }
    }
    

🔴 Senior Level

Безопасность десериализации (Safe Deserialization)

Классическая сериализация Java (implements Serializable) исторически являлась главным источником уязвимостей (RCE-эксплойты, обход валидаций в конструкторах):

  • При десериализации обычного класса JVM использует «магический» рефлексивный механизм, который полностью обходит конструкторы класса, напрямую восстанавливая байты в поля.
  • Records кардинально меняют архитектуру сериализации (JEP 395): При десериализации record JVM обязана вызвать канонический конструктор! Все проверки бизнес-логики, ограничения и защитные копии, написанные в компактном конструкторе, гарантированно выполнятся даже при десериализации. Создать невалидный Record через манипуляцию байтами сериализации невозможно.

Pattern Matching и деконструкция Record (Java 21, JEP 440)

В Java 21 записи стали основой для паттерн-матчинга с деконструкцией (Record Patterns):

public sealed interface Shape permits Circle, Rectangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}

// Деконструкция прямо в блоке switch без явных вызовов геттеров:
public static double calculateArea(Shape shape) {
    return switch (shape) {
        case Circle(double r) -> Math.PI * r * r;
        case Rectangle(double w, double h) -> w * h;
    };
}

Архитектурные ограничения Record

  1. Запрет наследования классов: record уже неявно наследуется от java.lang.Record и является final. Он не может наследоваться от другого класса и не может служить суперклассом.
  2. Интерфейсы разрешены: record может реализовывать любое количество интерфейсов (implements Serializable, Comparable<T>).
  3. Запрет дополнительных полей экземпляра: внутри тела record нельзя объявлять нестатические поля:
    public record User(String name) {
        // private int age; // ❌ ОШИБКА КОМПИЛЯЦИИ!
        public static final String DEFAULT_ROLE = "GUEST"; // ✅ Статические поля разрешены
    }
    

4 Tricky Questions

1. Почему десериализация Record в Java считается намного безопаснее десериализации обычных классов?

Ответ: Для стандартных классов механизм десериализации Java восстанавливает поля напрямую из входного потока байтов в память в обход конструкторов (используя нативный бэкдор ReflectionFactory.newConstructorForSerialization). В результате вредоносный байт-код может создать объект с невалидными инвариантами или внедрить эксплойт. Для record спецификация JVM требует обязательного вызова канонического конструктора при десериализации:

  1. Поток байтов читает значения компонентов записи.
  2. Аргументы передаются в канонический конструктор.
  3. Компактный конструктор выполняет валидацию (Objects.requireNonNull, диапазоны чисел) и защитное копирование коллекций. Если данные подделаны, конструктор выбросит исключение, предотвратив создание скомпрометированного объекта.

2. Можно ли переопределить метод доступа (геттер) в record? Не сломает ли это контракт записи?

Ответ: Да, метод доступа можно явно переопределить в теле записи:

public record User(String name) {
    @Override
    public String name() {
        return name == null ? "Anonymous" : name.trim();
    }
}

Однако спецификация Java настойчиво рекомендует: геттер должен оставаться чистой проекцией состояния компонента. Если переопределить геттер так, чтобы он возвращал совершенно другое значение, методы equals(), hashCode() и toString() (которые по умолчанию читают приватные поля напрямую, а не через геттеры) разойдутся в поведении с вызовом метода доступа, что нарушит принцип прозрачности данных.


3. Может ли record быть объявлен локально внутри метода? Имеет ли он доступ к локальным переменным метода?

Ответ: Да, начиная с Java 16, записи можно объявлять локально внутри методов (Local Records). Однако важный нюанс: локальный Record является неявно статическим (static). Он не имеет права захватывать нестатические переменные обрамляющего метода (в отличие от локальных анонимных классов). Все данные, с которыми работает локальный Record, должны передаваться ему явно через аргументы конструктора. Это исключает скрытые утечки памяти стека.


4. В чём разница между Record и аннотацией Lombok @Value?

Ответ:

  1. Уровень реализации: record — это фундаментальная языковая конструкция и фича JVM. Она поддерживается байткодом, Reflection API (Class.isRecord(), Class.getRecordComponents()), десериализацией и Record Patterns в Java 21. Lombok @Value — это хак компилятора через Annotation Processor, просто генерирующий обычный Java-класс.
  2. Семантика данных: record обязан быть финальным, не допускает скрытых полей состояния и гарантирует вызов конструктора при десериализации. Lombok @Value позволяет создавать изменяемые сущности или наследовать структуры, но не имеет нативной поддержки со стороны виртуальной машины.

🎯 Шпаргалка для интервью

Главное о Java Record

  • Введён: Java 14 (preview), Java 16 (LTS / стандарт).
  • Назначение: неизменяемый контейнер данных с минимальным синтаксисом.
  • Генерирует: final class, private final поля, конструктор, геттеры без префикса get (x()), equals(), hashCode(), toString().
  • Иммутабельность: поверхностная (Shallow)! Мутабельные коллекции требуют обязательного защитного копирования (List.copyOf) в компактном конструкторе.
  • Ограничения: запрещено наследование классов, запрещены дополнительные поля экземпляра.

Сравнение синтаксиса геттеров

| Обычный класс / JavaBeans | Java Record | | :— | :— | | user.getName() | user.name() | | user.isActive() | user.active() |

Красные флаги на собеседовании (Чего говорить нельзя)

  • ❌ «Record полностью защищает коллекции от изменений» — Record создаёт shallow-иммутабельность; list.add() всё ещё сработает, если не сделать List.copyOf().
  • ❌ «Геттеры в Record называются getName() и getAge()» — префикса get нет: методы называются name() и age().
  • ❌ «Record может наследоваться от абстрактного класса» — запрещено, все рекорды уже неявно наследуют java.lang.Record.
  • ❌ «Record — это просто синтаксический сахар для Lombok @Data» — @Data генерирует мутабельный класс с сеттерами, а Record — иммутабельный тип с поддержкой в JVM и pattern matching.

Связанные вопросы