📋 Розділ 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»: компілятор забороняє будь-які поля екземпляра поза заголовком.

Пов’язані теми