🔒 Розділ 13 · Питання #15

Чи можна наслідуватися від незмінного класу

З погляду синтаксису компілятора Java, ви можете успадкуватися від класу, якщо розробник забув оголосити його як final. Проте з погляду архітектури та безпеки це груба помилка п...


🟢 Junior Level

30-секундна відповідь

З погляду синтаксису компілятора Java, ви можете успадкуватися від класу, якщо розробник забув оголосити його як final. Проте з погляду архітектури та безпеки це груба помилка проєктування та критичний антипатерн.

Якщо клас не захищений від успадкування:

  1. Клас-нащадок може додати власні змінні поля та сетери.
  2. Клас-нащадок може перевизначити гетери базового класу, повертаючи динамічно змінювані або підроблені значення.
  3. У результаті посилання базового «незмінного» типу насправді вказуватиме на мутабельний об’єкт, повністю руйнуючи потокобезпечність та передбачуваність програми.

Наочний приклад: Злам незмінності через успадкування

// ❌ ПОМИЛКА ДИЗАЙНУ: розробник забув ключове слово final
public class CurrencyAmount {
    private final long cents;

    public CurrencyAmount(long cents) {
        this.cents = cents;
    }

    public long getCents() {
        return cents;
    }
}

// 💥 Нащадок перетворює "незмінний" клас на бомбу сповільненої дії:
public class MutableCurrencyAmount extends CurrencyAmount {
    private long extraBonusCents = 0;

    public MutableCurrencyAmount(long cents) {
        super(cents);
    }

    public void setExtraBonusCents(long bonus) {
        this.extraBonusCents = bonus; // Додали сетер!
    }

    @Override
    public long getCents() {
        // Ламаємо інваріант детермінованості: значення змінюється на льоту!
        return super.getCents() + extraBonusCents;
    }
}

Наочна аналогія з життя

Уявіть державний паспорт:

  • Справжній паспорт — суворо незмінний документ.
  • Якщо дозволити вільне «успадкування» паспорта, будь-який громадянин зможе вклеїти в нього додаткові сторінки із саморобними печатками та пред’являти таку підробку в банку під виглядом офіційного документа.

🟡 Middle Level

Порушення принципу підстановки Лісков (LSP)

Принцип підстановки Барбари Лісков (Liskov Substitution Principle — LSP) вимагає, щоб будь-який підклас міг використовуватися замість свого суперкласу без порушення контракту та очікуваної поведінки програми.

Коли код приймає параметр типу CurrencyAmount, він розраховує на суворі інваріанти незмінності:

  1. Виклик getCents() є чистою ідемпотентною функцією і повертатиме одне й те саме число сьогодні, завтра і через мільйон викликів.
  2. Об’єкт можна безпечно передавати десяткам паралельних робочих потоків без синхронізації та блокувань пам’яті.
  3. Значення hashCode() та equals() стабільні в часі — об’єкт можна використовувати як ключ у HashMap.

Якщо в метод передати MutableCurrencyAmount:

  • Потоки отримують стан перегонів (Data Race).
  • Колекції HashMap безповоротно втрачають ключі через розсинхронізацію хеш-коду.
  • Контракт LSP грубо порушується.

2 законні альтернативи успадкуванню

1. Композиція замість успадкування (Effective Java, Стаття 18)

Замість розширення чужого класу включіть незмінний об’єкт як приватне фінальне поле:

public final class AuditedCurrencyAmount {
    private final CurrencyAmount amount; // Композиція
    private final String auditComment;

    public AuditedCurrencyAmount(CurrencyAmount amount, String auditComment) {
        this.amount = Objects.requireNonNull(amount);
        this.auditComment = Objects.requireNonNull(auditComment);
    }

    public CurrencyAmount getAmount() { return amount; }
    public String getAuditComment() { return auditComment; }
}

2. Приватний конструктор і статичні фабрики

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

public class SecureToken {
    private final String value;

    // Конструктор закритий: сторонній код не може викликати super()
    private SecureToken(String value) {
        this.value = value;
    }

    public static SecureToken of(String value) {
        return new SecureToken(value);
    }
}

🔴 Senior Level

Запечатані ієрархії (Sealed Classes, Java 17+, JEP 409)

Якщо бізнес-архітектура дійсно вимагає поліморфізму та дерева типів для незмінних структур (наприклад, алгебраїчні типи даних ADT), у сучасній Java використовують sealed інтерфейси та класи:

// Кореневий контракт жорстко обмежує коло дозволених реалізацій:
public sealed interface OrderEvent permits OrderCreated, OrderCancelled, OrderShipped {}

// Усі реалізації оголошуються як final або record (неявно final):
public record OrderCreated(String orderId, long timestamp) implements OrderEvent {}
public record OrderCancelled(String orderId, String reason) implements OrderEvent {}
public record OrderShipped(String orderId, String trackingNumber) implements OrderEvent {}

Переваги:

  • Захист від сторонніх нащадків: зовнішній модуль чи зловмисник не має права створити свій підклас MaliciousOrderEvent.
  • Exhaustive Pattern Matching (Java 21+): компілятор виконує перевірку вичерпності гілок switch без потреби в блоці default.

Оптимізації JIT C2: Class Hierarchy Analysis (CHA) та девіртуалізація

Оголошення класу як final безпосередньо впливає на машинний код, що генерується компілятором HotSpot C2:

  • Коли метод викликається у non-final класу, компілятор транслює його в інструкцію байткоду invokevirtual.
  • C2-компілятор використовує підсистему Class Hierarchy Analysis (CHA), щоб перевірити, скільки підкласів завантажено в JVM.
  • Якщо клас оголошений як final, компілятор знає на 100%, що поліморфізм виключено. Він виконує девіртуалізацію (Devirtualization): виклик перетворюється на прямий безумовний перехід (direct jump) або повний інлайнінг (inlining) тіла методу.
  • Якщо ж клас не final і в нього з’являються підкласи, JVM змушена використовувати таблиці віртуальних методів (vtable) або інлайн-кеші (Monomorphic/Polymorphic Inline Caches), що збільшує навантаження на конвеєр процесора та ймовірність помилок передбачення переходів (Branch Misprediction).

4 Tricky Questions

1. Як Class Hierarchy Analysis (CHA) в HotSpot C2 оптимізує виклики методів у final класів порівняно з нефінальними?

Відповідь: Для нефінальних класів компілятор C2 виконує оптимістичну мономорфну девіртуалізацію: якщо в цей момент у JVM завантажено рівно один клас і немає активних підкласів, компілятор інлайнить метод, але встановлює точку деоптимізації (uncommon trap). Якщо в рантаймі динамічно завантажиться новий клас (через ClassLoader.loadClass()), що успадковує цей метод, JVM буде змушена скинути машинний код (deoptimization), повернутися в режим інтерпретатора та перекомпілювати метод із поліморфним викликом через таблицю vtable. Якщо клас оголошений як final, можливість появи підкласів виключена на рівні специфікації мови. Компілятор C2 назавжди інлайнить метод без встановлення guard-перевірок деоптимізації, усуваючи накладні витрати на віртуальний виклик.


2. Чи може абстрактний клас (abstract class) брати участь у проєктуванні незмінних структур даних? Як при цьому захиститися від зовнішніх змінних підкласів?

Відповідь: Так, абстрактний клас може містити спільний незмінний стан та поведінку (наприклад, abstract class AbstractImmutableList<E>). Щоб захиститися від зовнішніх змінних підкласів, використовують патерн Package-Private конструктора:

public abstract class AbstractDataNode {
    private final String id;

    // Пакетно-приватний конструктор: доступний тільки всередині цього пакета!
    AbstractDataNode(String id) {
        this.id = id;
    }
    public String getId() { return id; }
}

Код із зовнішніх пакетів фізично не зможе викликати конструктор базового класу через super(), тому успадкування ззовні пакета блокується. У Java 17+ ще більш елегантним і типобезпечним рішенням є public sealed abstract class ... permits SubA, SubB.


3. Що станеться, якщо клас-нащадок перевизначить методи equals() та hashCode(), додавши в них змінні поля? Як це ламає HashSet?

Відповідь: Якщо нащадок додасть змінне поле state і перевизначить hashCode() = super.hashCode() + state.hashCode(), то при модифікації поля state хеш-код об’єкта зміниться. Якщо цей об’єкт уже був поміщений у HashSet або як ключ у HashMap:

  • Корзина (bucket), у яку його було покладено спочатку, розраховувалася за старим хеш-кодом.
  • При наступному виклику set.contains(obj) хеш-код обчислюється заново, і HashSet шукає об’єкт у зовсім іншій корзині.
  • Метод contains(obj) поверне false, а метод remove(obj) не зможе його видалити. Об’єкт назавжди зависне в пам’яті купи як прихований витік пам’яті.

4. Чому в Java дозволялося успадковуватися від java.util.Date, і до яких уразливостей безпеки в Java Security Manager це призводило?

Відповідь: Клас java.util.Date був спроєктований у Java 1.0 без модифікатора final і містить методи-мутатори (setTime). Історична вразливість у SecurityManager: якщо системний код приймав Date validUntil для перевірки терміну дії сертифіката чи цифрового підпису, зловмисник міг передати екземпляр шкідливого підкласу MaliciousDate extends Date:

  • Під час перевірки прав метод getTime() повертав коректну дату з минулого (Time-of-Check).
  • У момент фактичного виконання операції підклас повертав дату з майбутнього або змінював свій стан (Time-of-Use). Саме тому у всій сучасній бібліотеці java.time (JSR-310: LocalDate, Instant, ZonedDateTime) усі без винятку класи оголошені суворо як final.

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

Головне про успадкування від незмінних класів

  • Золоте правило: незмінний клас завжди зобов’язаний бути закритим від успадкування.
  • Як закрити клас:
    1. Модифікатор final у класу (стандартне рішення).
    2. private конструктори + статичні фабричні методи.
    3. package-private конструктор (дозволяє підкласи лише всередині свого пакета).
    4. sealed клас/інтерфейс (Java 17+, явний список нащадків через permits).
  • Чим небезпечне успадкування: підклас може додати сетери, перевизначити гетери, зламати контракт hashCode/equals і порушити принцип LSP.
  • Продуктивність: final клас дозволяє JIT C2 компілятору HotSpot агресивно інлайнити методи через девіртуалізацію (CHA).

Червоні прапорці на співбесіді (Чого говорити не можна)

  • ❌ «Успадковуватися від незмінного класу абсолютно нормально, якщо не створювати в підкласі сетери» — підклас може перевизначити гетери або додати мутабельні поля.
  • ❌ «Модифікатор final у класів — це поганий стиль, що обмежує можливості ООП» — для класів значень (Value Objects) та незмінних типів final є обов’язковою вимогою надійності.
  • ❌ «У Java 17 Record може успадковуватися від іншого класу» — ні, record неявно успадковується від java.lang.Record і завжди є строго final.

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