📋 Розділ 20 · Питання #5

Які методи автоматично генеруються для Record

Коли ви оголошуєте record, компілятор Java автоматично створює 5 типів методів:


🟢 Junior Level

Коли ви оголошуєте record, компілятор Java автоматично створює 5 типів методів:

  1. Канонічний конструктор: приймає всі компоненти в порядку їхнього оголошення в заголовку та присвоює їх відповідним private final полям.
  2. Публічні методи-аксесори (геттери): генеруються для кожного компонента з ім’ям, що точно збігається з ім’ям компонента (наприклад, user.name(), user.age() — строго без префікса get!).
  3. equals(Object o): повертає true, якщо об’єкт для порівняння належить до того самого типу запису і всі його компоненти є еквівалентними.
  4. hashCode(): обчислює хеш-код на основі значень усіх компонентів запису.
  5. toString(): повертає зрозуміле рядкове представлення вигляду RecordName[field1=val1, field2=val2].
public record User(String name, int age) {}

// Використання всіх автогенерованих методів:
User user1 = new User("Alice", 25); // 1. Канонічний конструктор
User user2 = new User("Alice", 25);

System.out.println(user1.name());   // 2. Аксесор: "Alice"
System.out.println(user1.age());    // 2. Аксесор: 25

System.out.println(user1.equals(user2)); // 3. true (порівняння за полями)
System.out.println(user1.hashCode() == user2.hashCode()); // 4. true (ідентичні хеші)
System.out.println(user1);          // 5. "User[name=Alice, age=25]"

🟡 Middle Level

1. Як працюють згенеровані методи порівняння

  • equals(Object o):
    • Спочатку перевіряє посилальну рівність (this == o).
    • Перевіряє точний тип: o != null && this.getClass() == o.getClass().
    • Порівнює примітивні поля через оператор == (для float/double викликаються Float.compare та Double.compare для уникнення аномалій із NaN та -0.0).
    • Порівнює посилальні поля через Objects.equals(this.field, other.field).
  • hashCode():
    • Комбінує хеш-коди всіх компонентів за алгоритмом множення на непарне просте число ($31$), гарантуючи коректний контракт з equals() для роботи в HashMap та HashSet.

2. Критична пастка: Record з масивами (byte[], int[])

[!CAUTION] Автогенеровані методи equals(), hashCode() та toString() не вміють працювати з масивами за їхнім вмістом! Для масивів метод Objects.equals(a, b) порівнює посилання на область пам’яті, а не самі елементи!

public record BinaryPayload(String id, byte[] data) {}

BinaryPayload p1 = new BinaryPayload("1", new byte[]{1, 2, 3});
BinaryPayload p2 = new BinaryPayload("1", new byte[]{1, 2, 3});

// ❌ Проблема: різні масиви в купі, посилання не рівні!
System.out.println(p1.equals(p2)); // FALSE!
System.out.println(p1);            // BinaryPayload[id=1, data=[B@6bdf28bb] (адреса масиву!)

Рішення: Явне перевизначення через Arrays

Якщо у вашому Record присутній масив, ви зобов’язані вручну перевизначити всі три методи:

public record BinaryPayload(String id, byte[] data) {
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof BinaryPayload that)) return false;
        return Objects.equals(id, that.id) && Arrays.equals(data, that.data);
    }

    @Override
    public int hashCode() {
        int result = Objects.hashCode(id);
        return 31 * result + Arrays.hashCode(data);
    }

    @Override
    public String toString() {
        return "BinaryPayload[id=" + id + ", data=" + Arrays.toString(data) + "]";
    }
}

🔴 Senior Level

Внутрішній устрій у байткоді: invokedynamic та ObjectMethods.bootstrap

Всупереч поширеній помилковій думці, компілятор javac не генерує довгі простирадла байткоду для кожного методу equals, hashCode та toString усередині кожного файлу .class.

Замість цього компілятор генерує виклик інструкції invokedynamic (indy), що вказує на системний бутстрап-метод:

// Декомпіляція через javap -v:
public final boolean equals(java.lang.Object);
  descriptor: (Ljava/lang/Object;)Z
  flags: (0x0011) ACC_PUBLIC, ACC_FINAL
  Code:
    0: aload_0
    1: aload_1
    2: invokedynamic #17, 0 // BootstrapMethods #0:equals:(LUser;Ljava/lang/Object;)Z
    7: ireturn

Бутстрап-метод: java.lang.runtime.ObjectMethods.bootstrap(...)

Під час першого виконання JVM викликає:

public static Object bootstrap(
    MethodHandles.Lookup lookup,
    String methodName,
    TypeDescriptor type,
    Class<?> recordClass,
    String names,
    MethodHandle... getters
)
  1. JVM передає список MethodHandle для кожного компонента запису.
  2. Метод ObjectMethods на льоту компонує оптимізований ланцюжок викликів MethodHandle під конкретний тип запису.
  3. JIT-компілятор (C2) бездоганно оптимізує та інлайнить сгенеровані дескриптори безпосередньо в машинний код процесора.

Переваги архітектури на invokedynamic:

  • Мінімальний розмір .class файлів (відсутні сотні байтів однотипних інструкцій).
  • Можливість оптимізації алгоритмів хешування та порівняння в майбутніх версіях JDK без необхідності перекомпіляції користувацького коду.

Чому Record не кешує свій hashCode?

Клас java.lang.String кешує свій хеш-код у приватному мутабельному полі private int hash. При першому виклику hashCode() він обчислюється, а під час наступних повертається миттєво за $O(1)$.

У Record такий прийом неможливий:

  • У тілі record заборонено оголошувати будь-які нестатичні поля екземпляра (instance fields).
  • Створити приховане мутабельне поле private int cachedHashCode = 0; компілятор не дозволить (JLS §8.10).
  • Кожного разу при виклику record.hashCode() обчислення виконується заново за всіма полями. Якщо в записі містяться важкі вкладені структури (наприклад, рядки на мільйон символів або глибокі колекції), частий виклик hashCode() у циклі може стати вузьким місцем (CPU bottleneck).

4 Tricky Questions

1. Як компілятор реалізує equals, hashCode та toString у байткоді: генерує класичний код чи використовує invokedynamic?

Відповідь: Компілятор використовує інструкцію invokedynamic із бутстрап-методом java.lang.runtime.ObjectMethods.bootstrap. У байткоді класу запису немає жорстко закодованих ланцюжків if-else чи обчислень хешу. Під час першого завантаження бутстрап-метод динамічно компонує ланцюжок дескрипторів MethodHandle для читання полів конкретного запису та пов’язує їх із CallSite. Це кардинально зменшує розмір байткоду і дозволяє віртуальній машині на льоту застосовувати глибокі оптимізації JIT-компілятора.


2. Що станеться з автогенерованими equals(), hashCode() та toString(), якщо одним із компонентів Record є масив (наприклад, byte[] payload)?

Відповідь: Виникне логічна помилка порівняння:

  • Автогенерований equals() використовує Objects.equals(), який для масивів порівнює їхні посилання в пам’яті (==), а не вміст. Два різних об’єкти Record з ідентичними даними в масивах повернуть false при виклику .equals().
  • hashCode() поверне identity-хешкод масиву (за його адресою в пам’яті), а не хеш вмісту елементів.
  • toString() надрукує тип і системний хеш масиву (наприклад, [B@4f023edb), а не зрозумілий масив. Висновок: Якщо компонент є масивом, розробник зобов’язаний вручну перевизначити всі три методи з використанням утилітного класу java.util.Arrays.

3. Чи можна реалізувати ліниве кешування hashCode всередині Record, як це зроблено в класі String?

Відповідь: Напряму в полях об’єкта — не можна. Специфікація мови Java (JLS §8.10) категорично забороняє оголошення будь-яких нестатичних полів усередині тіла record, окрім канонічних компонентів, оголошених у заголовку. Створити змінну private int hash; неможливо. Хеш-код обчислюється заново при кожному зверненні. Якщо кешування критичне для швидкодії, необхідно або використовувати зовнішній кеш (наприклад, ConcurrentHashMap), або перейти від Record до класичного final class із ручним кешуванням хешу.


4. Чи генерує Record метод clone() або спеціальні методи для Java Serialization (readObject / writeObject)?

Відповідь: Ні, не генерує.

  • Метод clone() не генерується і не підтримується. Оскільки Record є незмінним (immutable), клонування не має сенсу (копіювання посилання є достатнім, а для створення змінених копій застосовується патерн Wither).
  • Методи readObject та writeObject не генеруються, а якщо їх оголосити вручну, JVM їх проігнорує. Механізм серіалізації для записів жорстко зафіксований на рівні платформи: десеріалізація виконується виключно через виклик канонічного конструктора.

🎯 Шпаргалка для інтерв’ю

5 автоматично генерованих методів Record

Метод Сигнатура Особливості реалізації
Канонічний конструктор User(String name, int age) Ініціалізує private final поля в порядку оголошення.
Аксесори name(), age() Імена строго без префікса get. Повертають значення полів.
equals(Object o) boolean equals(Object o) Реалізований через invokedynamic (ObjectMethods.bootstrap).
hashCode() int hashCode() Хеш комбінації всіх компонентів ($31 \times \dots$). Не кешується!
toString() String toString() Повертає: RecordName[comp1=val1, comp2=val2].

Пам’ятка розробника

  1. Масиви в Record: Автогенерація порівнює посилання. Завжди перевизначайте equals, hashCode та toString через Arrays.*.
  2. Байткод під капотом: invokedynamic + ObjectMethods.bootstrap — індикатор глибокого розуміння JVM на співбесіді.
  3. Кешування: Полів для кешу додати не можна, розрахунок хешу відбувається при кожному виклику.

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