🔒 Розділ 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.

Пов’язані питання