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

Чи можна додавати додаткові методи в Record

У Record дозволено додавати: 4. Wither-методи (методи створення копії зі зміненим полем).


🟢 Junior Level

Так, можна. Record у Java — це повноцінний тип даних, і ви можете оголошувати в ньому будь-які допоміжні методи.

У Record дозволено додавати:

  1. Методи екземпляра (Instance methods) для обчислення похідних значень на основі полів.
  2. Статичні методи (Static methods), наприклад, фабричні методи of(...) або empty().
  3. Приватні методи для декомпозиції внутрішньої логіки.
  4. Wither-методи (методи створення копії зі зміненим полем).
public record User(String firstName, String lastName, int age) {
    // 1. Метод екземпляра (похідна властивість)
    public String fullName() {
        return firstName + " " + lastName;
    }

    // 2. Бізнес-перевірка
    public boolean isAdult() {
        return age >= 18;
    }

    // 3. Статичний фабричний метод
    public static User anonymous() {
        return new User("John", "Doe", 0);
    }
}

// Використання:
User user = new User("Alice", "Smith", 25);
System.out.println(user.fullName()); // "Alice Smith"
System.out.println(user.isAdult());  // true

Головне обмеження

У Record можна додавати поведінку (методи), але категорично заборонено додавати стан (нові нестатичні поля). Увесь стан запису зобов’язаний бути оголошений у його заголовку.


🟡 Middle Level

1. Патерн «Wither» (Незмінне оновлення стану)

Оскільки поля запису незмінні (final), у Record не можна написати сетер setAge(int age). Замість цього використовується патерн Wither: метод повертає новий екземпляр запису з оновленим значенням:

public record Order(UUID id, BigDecimal total, OrderStatus status) {
    // Wither-метод: повертає копію замовлення з новим статусом
    public Order withStatus(OrderStatus newStatus) {
        return new Order(this.id, this.total, newStatus);
    }

    // Wither-метод: повертає копію з перерахованою сумою
    public Order withTotal(BigDecimal newTotal) {
        return new Order(this.id, newTotal, this.status);
    }
}

// Приклад використання ланцюжка:
Order pending = new Order(UUID.randomUUID(), BigDecimal.valueOf(100), OrderStatus.PENDING);
Order paid = pending.withStatus(OrderStatus.PAID);

2. Перевизначення канонічних методів

Ви можете явно перевизначити стандартні методи:

  • toString(): для маскування чутливих даних (номерів карток, паролів).
  • equals() та hashCode(): для нестандартного порівняння (наприклад, без урахування регістру рядків або порівняння вмісту масивів через Arrays.equals).
  • Методи-аксесори (наприклад, public String name()): для захисної нормалізації (наприклад, return name.trim()).
public record CreditCard(String cardNumber, String holder, String cvv) {
    // Перевизначаємо toString, щоб не скомпрометувати CVV та номер картки в логах:
    @Override
    public String toString() {
        return "CreditCard[holder=" + holder + ", cardNumber=****" + cardNumber.substring(cardNumber.length() - 4) + "]";
    }
}

🔴 Senior Level

Обмеження специфікації мови (JLS §8.10.2)

Специфікація мови Java накладає на методи Record низку суворих правил:

  1. Заборона native методів:
    public record NativeHolder(int value) {
        // public native void doLowLevelWork(); // ❌ Помилка компіляції: native method not allowed in record
    }
    

    Чому? Записи спроєктовані як математично прозорі та платформо-незалежні структури. Нативні методи приховують реалізацію за межами JVM, що суперечить семантиці Record.

  2. Конфлікти сигнатур із компонентами: Не можна оголосити метод, ім’я якого збігається з компонентом, але тип повернення відрізняється:
    public record Point(int x, int y) {
        // public String x() { return "x=" + x; } // ❌ Помилка: x() in Point cannot override x() in Point; attempting to use incompatible return type
    }
    
  3. Небезпека розсинхронізації при перевизначенні аксесора: Якщо ви перевизначаєте метод читання поля:
    public record User(String name) {
        public String name() {
            return name.toUpperCase(); // Повертає у верхньому регістрі
        }
    }
    

    [!CAUTION] Автогенерований метод equals() порівнює поле this.name напряму через байткод, а не через виклик методу name(). Якщо u1 = new User("alice") та u2 = new User("ALICE"), то u1.name().equals(u2.name()) поверне true, проте u1.equals(u2) поверне false! Завжди нормалізуйте дані в компактному конструкторі, а не в аксесорах.

Архітектурний баланс: Data Carrier vs Anemic Model

Додавання бізнес-логіки в Record — дискусійна тема в Software Design:

  • Рекомендується додавати: логіку обчислення похідних атрибутів (Derived attributes: fullName(), area()), предикати валідності (isExpired()), фабрики та Withers. Це робить Record багатим Value Object у термінології DDD (Domain-Driven Design).
  • Категорично не рекомендується: впроваджувати залежності (Spring Services, Repositories), виконувати I/O-операції, виклики зовнішніх API або мутувати зовнішні структури даних. Record має залишатися чистою функцією від своїх аргументів.

4 Tricky Questions

1. Чи можна оголосити в Record нативний метод (public native void doSomething())?

Відповідь: Ні, не можна.

Специфікація Java (JLS §8.10.2) прямо забороняє модифікатор native для методів в оголошеннях record: «It is a compile-time error if a record declaration contains a native method».

Записи спроєктовані як серіалізовні, прозорі та оптимізовні контейнери даних. Нативні методи пов’язують клас із машинно-залежним кодом через JNI, що порушує концепцію чистого носія даних.

2. Як у Record правильно реалізувати модифікацію одного поля (патерн «Wither»)?

Відповідь: Оскільки поля запису є незмінними (final), класичні сетери неможливі. Застосовується патерн Wither: оголошується метод із префіксом with..., який приймає нове значення для одного компонента та викликає канонічний конструктор, копіюючи всі інші поля:

public record Account(UUID id, BigDecimal balance, boolean active) {
    public Account withBalance(BigDecimal newBalance) {
        return new Account(this.id, newBalance, this.active);
    }
}

Цей підхід зберігає потокобезпечність та незмінність екземпляра. У межах Project Amber у майбутніх версіях Java планується синтаксис with-виразів (наприклад, account with { balance = newBalance; }).

3. Що станеться, якщо перевизначити аксесор компонента так, щоб він повертав модифіковане значення (наприклад, return name.toLowerCase())?

Відповідь: Код скомпільується (починаючи з Java 16 перевизначення аксесорів з ідентичною сигнатурою дозволено). Проте це створить критичну приховану помилку узгодженості:

Автозгенерований компілятором метод equals() звертається до приватного поля this.name напряму через байткод getfield, оминаючи виклик методу name(). Якщо ви створите User("ALICE") та User("alice"), метод-аксесор .name() для обох поверне "alice", створюючи видимість ідентичності, проте u1.equals(u2) поверне false!

Саме тому будь-яка трансформація значень має здійснюватися в компактному конструкторі на етапі створення об’єкта, зберігаючи в полі вже нормалізоване значення.

4. Чи можна в Record перевантажити метод-аксесор (наприклад, оголосити public String name(String prefix))?

Відповідь: Так, можна.

У Java механізм перевантаження методів (Method Overloading) спирається на повну сигнатуру методу (ім’я + типи та кількість параметрів). Метод public String name(String prefix) має інший список параметрів, ніж канонічний аксесор public String name(). Компілятор розглядає його як звичайний користувацький метод екземпляра.


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

Головне про методи в Record

  • Що можна:
    • Будь-які методи екземпляра (public, protected, package-private, private).
    • Статичні методи та константи (public static of(...)).
    • Wither-методи (with...) для незмінної модифікації.
    • Перевизначення equals(), hashCode(), toString() та аксесорів.
  • Чого не можна:
    • Оголошувати нестатичні (instance) поля.
    • Оголошувати native методи.
    • Змінювати тип повернення канонічних аксесорів.
  • Правило чистоти: Record має залишатися Value Object. Жодних мережевих викликів, звернень до БД або мутацій усередині його методів.

Антипатерни

  • ❌ Модифікація даних усередині гетера (return name.trim()): ламає контракт equals(). Нормалізацію робіть у конструкторі!
  • ❌ Спроба зімітувати сетери (public void setX(int x)): поля final, мутація неможлива.
  • ❌ Впровадження Spring-бінів усередину Record: Record — це дані, а не сервісний компонент.

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