📋 Раздел 20 · Вопрос #2

В чём основные отличия Record от обычного класса

Обычный класс (class) — это универсальный механизм объектно-ориентированного программирования, предназначенный для инкапсуляции изменяемого состояния, полиморфного поведения и б...


🟢 Junior Level

Record — это специализированная форма класса в Java, созданная исключительно для роли неизменяемого носителя данных (Data Carrier / Value Object).

Обычный класс (class) — это универсальный механизм объектно-ориентированного программирования, предназначенный для инкапсуляции изменяемого состояния, полиморфного поведения и бизнес-логики.

Сводная таблица ключевых отличий

Характеристика record Обычный class
Назначение Неизменяемый контейнер данных Универсальная сущность (данные + поведение)
Суперкласс Всегда java.lang.Record По умолчанию java.lang.Object (или любой класс)
Наследование Запрещено (неявно final) Разрешено (если явно не помечен как final)
Поля экземпляра Только private final, строго из заголовка Любые (public, protected, private, mutable, transient)
Аксессоры (геттеры) Генерируются автоматически: user.name() Пишутся вручную: user.getName()
equals(), hashCode(), toString() Автоматически по всем полям из коробки Требуют ручной реализации или аннотаций Lombok
Объявление 1 строка кода 30–50 строк кода (конструктор, геттеры, методы)
// Record: лаконично, безопасно, immutable
public record Point(int x, int y) {}

// Обычный класс с эквивалентной семантикой требует ручного написания всего boilerplate:
public final class PointClass {
    private final int x;
    private final int y;

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

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

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

🟡 Middle Level

1. Семантический контракт: «State is the Header»

В обычном классе связь между полями, параметрами конструктора и геттерами — это чисто условное соглашение (convention), поддерживаемое разработчиком. Поле может называться x, параметр конструктора — initialX, а геттер может возвращать this.x * 2.

В Record действует строгий фундаментальный инвариант языка: состояние объекта равно его заголовку.

  • Нельзя добавить скрытое нестатическое состояние:
    public record User(String name) {
        // private int age; // ❌ Ошибка компиляции: non-static field not allowed
        public static int counter; // ✅ Статические поля разрешены
    }
    
  • Список компонентов в заголовке (String name, int age) однозначно определяет:
    1. Набор и порядок private final полей.
    2. Сигнатуру канонического конструктора.
    3. Имена и типы методов-аксессоров (name(), age()).
    4. Формулу вычисления equals() и hashCode().
    5. Формат toString().

2. Конструкторы: канонический, компактный и кастомный

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

  1. Канонический конструктор: создается автоматически. Если вы переопределяете его явно, его параметры обязаны точно совпадать с заголовком записи по типам и именам.
  2. Компактный конструктор (Compact Constructor): синтаксис, доступный только в record, созданный для валидации и нормализации параметров без лишнего повторения this.x = x:
    public record Range(int start, int end) {
        public Range {
            if (start > end) {
                throw new IllegalArgumentException("start > end");
            }
        }
    }
    
  3. Кастомные (вторичные) конструкторы: обязаны первой же строкой вызывать канонический конструктор через this(...):
    public record User(String name, int age) {
        public User(String name) {
            this(name, 18); // Обязательная делегация
        }
    }
    

3. Record и JPA / Hibernate

Частый вопрос: можно ли заменить обычные классы сущностей на Records в JPA?

[!IMPORTANT] Records НЕЛЬЗЯ использовать в качестве @Entity в JPA/Hibernate. Спецификация Jakarta Persistence требует:

  1. Наличие конструктора без аргументов (no-arg constructor), чего у записей нет.
  2. Мутабельность полей или сеттеры для механизмов отслеживания изменений (dirty-checking).
  3. Возможность создания runtime-прокси (CGLIB / ByteBuddy), для чего класс и методы не должны быть final (а record неявно final).

Где Records идеально подходят в связке с JPA:

  • DTO-проекции в JPQL: SELECT new com.example.UserDto(u.id, u.name) FROM User u.
  • Проекции Spring Data JPA (UserRepository.findDtoByName(...)).
  • Составные первичные ключи (@Embeddable / @EmbeddedId в Hibernate 6+).

🔴 Senior Level

Байткод, метаданные JVM и Pattern Matching

На уровне платформы Java Record — это не просто трюк компилятора javac (в отличие от Kotlin data class или аннотаций Lombok). Это полноценная языковая концепция, закрепленная в спецификации JVM (JVMS):

  1. Атрибут Record в структуре .class: Компилятор записывает в байткод массив RecordComponent. Это дает сторонним фреймворкам гарантированную информацию о структуре объекта без необходимости гадать по именам геттеров или читать приватные поля.
  2. Безусловная деконструкция (Record Patterns в Java 21+, JEP 440): Благодаря прозрачности состояния Record язык поддерживает нативное распаковывание в операторе switch и instanceof:
    // Деконструкция компонентов прямо в условии:
    if (obj instanceof Point(int x, int y)) {
        System.out.println("Coordinates: " + x + ", " + y);
    }
    
    // Сопоставление с образцом в switch:
    String describe(Object shape) {
        return switch (shape) {
            case Circle(double r) -> "Circle with radius " + r;
            case Rectangle(double w, double h) when w == h -> "Square: " + w;
            case Rectangle(double w, double h) -> "Rectangle " + w + "x" + h;
            default -> "Unknown";
        };
    }
    

    Обычные классы не поддерживают деконструкцию «из коробки», так как JVM не может гарантировать взаимно-однозначное соответствие между внутренним состоянием и публичными методами.

Сериализационный иммунитет

В обычных классах Java Serialization порождает архитектурную дыру: ObjectInputStream создает объект с помощью недокументированного native-вызова в обход конструкторов.

В Record спецификация сериализации была переписана с нуля:

  • Десериализация всегда вызывает канонический конструктор.
  • Все инварианты, объявленные в компактном конструкторе (валидация на null, диапазон значений), гарантированно выполняются при десериализации.
  • Атаки через подделку сериализованных байткодов на Records не работают.

Защита от Reflection

В обычных классах модификатор final у полей можно обойти:

// Для обычного класса (до усиления инкапсуляции модулей):
Field f = NormalClass.class.getDeclaredField("value");
f.setAccessible(true);
f.set(instance, "new value"); // Успешно изменяло final поле!

В случае с Record метод Field.set() на уровне реализации ядра JDK содержит проверку:

if (declaringClass.isRecord()) {
    throw new IllegalAccessException("Can not set final field of a record class");
}

Поля Record защищены от изменения через Reflection на уровне JVM.


4 Tricky Questions

1. Почему в Record запрещено объявлять нестатические (instance) поля в теле класса, хотя статические поля разрешены?

Ответ: Потому что фундаментальный архитектурный инвариант Record гласит: «Состояние записи описывается исключительно ее заголовком».

Если бы разработчикам разрешили объявлять дополнительные поля экземпляра внутри тела { ... }, это привело бы к разрыву контракта:

  1. Автогенерированные equals(), hashCode() и toString() перестали бы учитывать полное состояние объекта.
  2. Механизм Pattern Matching (деконструкция в switch и instanceof) потерял бы возможность полностью восстанавливать состояние объекта из заголовка.
  3. Сериализация не смогла бы безопасно восстанавливать объект через канонический конструктор. Статические поля разрешены, так как они принадлежат самому классу в метаспейсе, а не конкретному экземпляру, и не влияют на его состояние.

2. Можно ли использовать Record в связке с JPA / Hibernate, и если да, то в каких сценариях?

Ответ: В качестве сущности (@Entity) использовать Record нельзя. Спецификация JPA требует наличия конструктора по умолчанию (no-arg), мутабельности состояния (или методов-сеттеров) для отслеживания изменений (dirty-checking), а также отсутствия модификатора final на классе и методах для создания ленивых прокси-объектов (CGLIB/ByteBuddy).

Однако Record активно и эффективно применяется в JPA в следующих сценариях:

  1. DTO-проекции: использование в JPQL конструкторных выражениях (SELECT new com.app.UserSummary(u.id, u.name) FROM User u).
  2. Spring Data Projections: возврат Records из методов репозиториев (Optional<UserDto> findUserById(Long id)).
  3. Составные ключи (@Embeddable / @EmbeddedId): начиная с Hibernate 6, Records разрешены в качестве компонентов составного первичного ключа, так как для них естественно соблюдается требование неизменяемости, а также корректной реализации equals() и hashCode().

3. Почему методы чтения компонентов Record названы field() вместо привычного для JavaBeans стандарта getField()?

Ответ: Создатели Java сознательно дистанцировались от соглашения JavaBeans (1996 года).

Стандарт JavaBeans (getX(), setX(), isX()) проектировался для компонентных визуальных моделей с изменяемым состоянием и генерацией событий изменения свойств. Record — это не JavaBean, а математический именованный кортеж (nominal tuple). В нем нет понятия «property with side-effects», есть лишь «component value».

Отсутствие префикса get:

  1. Подчеркивает функциональную природу объекта (метод — это проекция значения, аналогично математическим функциям).
  2. Унифицирует Java с современными языками (Kotlin, Scala, C#, где также используется прямое имя поля).
  3. Избавляет от спорных соглашений вроде getIsActive() vs isActive().

4. Как Record взаимодействует с деконструкцией в Pattern Matching (Java 21 Record Patterns)?

Ответ: В Java 21 (JEP 440) для Records появилась возможность глубокой деконструкции. Поскольку спецификация гарантирует соответствие между компонентами заголовка и полями объекта, компилятор позволяет извлекать компоненты прямо в сигнатуре проверки типов:

record Point(int x, int y) {}
record Line(Point start, Point end) {}

// Глубокая вложенная деконструкция:
if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
    int dx = x2 - x1;
    int dy = y2 - y1;
}

Обычные классы такой синтаксис не поддерживают, так как для них компилятор не может гарантировать, что вызов произвольных методов вернет именно те значения, из которых был собран объект.


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

Главные различия в одном взгляде

  • Семантика: Record — неизменяемый кортеж («данные как они есть»); Обычный класс — инкапсулированный объект («поведение + состояние»).
  • Иерархия: Record неявно final, всегда наследуется от java.lang.Record, прямое наследование классов запрещено. Интерфейсы разрешены.
  • Поля: Только private final из заголовка. Никаких дополнительных полей экземпляра.
  • Геттеры: Именуются без get (user.email()).
  • Специфика конструкторов: Есть компактный конструктор public User { ... } без круглых скобок для валидации.
  • Безопасность: Защищен от Reflection-модификаций и обхода конструктора при десериализации.
  • Java 21: Поддерживает Record Patterns (деконструкция в switch и instanceof).

Красные флаги (чего НЕ говорить на собеседовании)

  • ❌ «Record — это просто синтаксический сахар для Lombok @Value»: Record — это концепция платформы JVM с атрибутами в байткоде, защищенной десериализацией и pattern matching, а не просто генератор кода.
  • ❌ «Record можно использовать как JPA Entity»: JPA требует no-arg конструктор, открытый (non-final) класс для прокси и мутабельность.
  • ❌ «В Record можно объявить изменяемое поле, если пометить его non-final»: компилятор запрещает любые поля экземпляра вне заголовка.

Связанные темы