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

Чи можна наслідуватися від Record або наслідувати Record від іншого класу

Хоча успадкування класів заборонено, Record може реалізовувати будь-яку кількість інтерфейсів:


🟢 Junior Level

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

  1. Не можна успадковуватися від Record: Будь-який record неявно є final. Спроба оголосити клас або інший record, що розширює запис, викличе помилку компіляції.
  2. Record не може розширювати інші класи: Усі записи неявно успадковуються безпосередньо від абстрактного класу java.lang.Record. Оскільки множинне успадкування класів у Java заборонено, ключове слово extends для record є неприпустимим.
public record Point(int x, int y) {}

// ❌ Помилка компіляції: Cannot inherit from final 'Point'
public class ColoredPoint extends Point {} 

// ❌ Помилка компіляції: Records cannot extend other classes
public record MyRecord(int a) extends SomeClass {} 

// ❌ Помилка компіляції: Records cannot extend java.lang.Record explicitly
public record MyRecord(int a) extends Record {} 

Що дозволено: Реалізація інтерфейсів

Хоча успадкування класів заборонено, Record може реалізовувати будь-яку кількість інтерфейсів:

public record User(String name, String email) implements Serializable, Comparable<User> {
    @Override
    public int compareTo(User o) {
        return this.name.compareTo(o.name);
    }
}

🟡 Middle Level

Чому в Java введені такі жорсткі обмеження

Заборона на успадкування в Record — це свідоме архітектурне рішення творців мови (Браян Гетц і команда OpenJDK):

  1. Принцип вичерпного опису стану: Стан запису визначається виключно його заголовком. Якби успадкування класів було дозволено, підклас міг би оголосити додаткові поля екземпляра, порушивши контракт «заголовок дорівнює стану».
  2. Гарантія незмінності (Immutability): Підклас міг би додати мутабельні поля або перевизначити методи-аксесори, повертаючи різні значення при послідовних викликах.
  3. Порушення контракту equals() та hashCode(): Класична проблема еквівалентності в ООП (описана Джошуа Блохом в Effective Java): неможливо зберегти симетричність і транзитивність equals(), якщо підклас додає нові поля. Для Record рівність визначена суворо і математично: два рекорди рівні тоді й лише тоді, коли рівні всі їхні компоненти.
  4. Свобода оптимізації компілятора (JIT / Project Valhalla): Знаючи, що клас final і його структура незмінна, JVM може інлайнити аксесори, розгортати об’єкти на стеку (Escape Analysis) і в майбутньому перетворювати їх на пласкі Value Types.

Чим замінити успадкування: Композиція та Делегування

Замість спроб побудувати ієрархію класів використовується класичний принцип «Composition over Inheritance»:

// ❌ Спроба зробити базову сутність через успадкування:
// record AuditedEntity(...) extends BaseAuditInfo {} // Помилка!

// ✅ Ідіоматичний підхід: Композиція компонентів
public record AuditMetadata(Instant createdAt, String createdBy) {}

public record Order(
    UUID orderId,
    BigDecimal amount,
    AuditMetadata audit // Вкладений незмінний компонент
) {}

🔴 Senior Level

Алгебраїчні типи даних (ADT): Sealed Interfaces + Records

У сучасній Java (Java 17+) класичне ієрархічне успадкування для моделей даних замінено на алгебраїчні типи даних (Algebraic Data Types / ADT):

  • Product Type (Тип-добуток) — моделюється через record ($A \times B \times C$: замовлення складається з ID, ціни ТА дати).
  • Sum Type (Тип-сума) — моделюється через sealed interface ($A + B + C$: платіж може бути АБО карткою, АБО готівкою, АБО криптовалютою).
// Запечатаний інтерфейс суворо обмежує можливі реалізації:
public sealed interface PaymentMethod permits CardPayment, CashPayment, CryptoPayment {}

public record CardPayment(String cardNumber, String cvv) implements PaymentMethod {}
public record CashPayment(BigDecimal tenderedAmount) implements PaymentMethod {}
public record CryptoPayment(String walletAddress, String txHash) implements PaymentMethod {}

Вичерпний Pattern Matching у Java 21+

Компілятор знає всі дозволені гілки sealed-інтерфейсу. Завдяки цьому в операторі switch не потрібна гілка default, а компілятор видає помилку, якщо розробник забув обробити одну з гілок:

public String processPayment(PaymentMethod method) {
    return switch (method) {
        case CardPayment(var num, var cvv) -> "Processing card: " + mask(num);
        case CashPayment(var amount)       -> "Cash received: " + amount;
        case CryptoPayment(var addr, var tx) -> "Crypto transfer: " + tx;
        // default НЕ ПОТРІБЕН: компілятор гарантує вичерпний аналіз (Exhaustiveness Check)
    };
}

Обмеження на рівні специфікації JVM (JVMS)

Заборона на успадкування жорстко закріплена не лише в компіляторі javac, але й у віртуальній машині Java (JVM):

  1. Прапорці доступу: Байткод класу запису зобов’язаний містити модифікатори ACC_FINAL та ACC_RECORD.
  2. Перевірка суперкласу: Згідно з JVMS §4.1, суперкласом будь-якого record повинен бути суворо java/lang/Record.
  3. Заборона для звичайних класів: JLS §8.1.4 оголошує помилкою компіляції спробу звичайного класу розширити java.lang.Record:
    // Помилка компіляції: The type java.lang.Record cannot be the superclass of RegularClass;
    // only records can extend java.lang.Record
    public class RegularClass extends java.lang.Record {}
    
  4. ClassLoader Verification: Якщо завантажувач класів спробує завантажити згенерований в обхід компілятора байткод звичайного класу, успадкованого від java.lang.Record, JVM викине ClassFormatError або VerifyError.

4 Tricky Questions

1. Чи може звичайний клас явно успадкуватися від java.lang.Record (public class MyClass extends java.lang.Record)?

Відповідь: Ні, це категорично заборонено специфікацією мови (JLS §8.1.4).

Спроба скомпілювати такий клас завершиться помилкою: «The type java.lang.Record cannot be the superclass of MyClass; only records can extend java.lang.Record».

Більше того, це обмеження перевіряється верифікатором байткоду самої JVM. Якщо спробувати підробити байткод через ASM або ByteBuddy та завантажити звичайний клас (без прапорця ACC_RECORD), суперкласом якого вказано java/lang/Record, JVM під час лінкування класу викине фатальну помилку ClassFormatError.

2. Чи може Record бути оголошений з модифікаторами abstract, sealed або non-sealed?

Відповідь:

  • abstract: Заборонено. Будь-який record неявно є final і містить вичерпну реалізацію всіх методів. Поєднання final abstract неможливе в Java.
  • sealed / non-sealed: Заборонено. Оскільки record не може мати нащадків (він final), модифікатори управління успадкуванням до нього незастосовні.

Проте record може виступати в якості дозволеного листка (permitted leaf) в оголошенні іншого sealed-інтерфейсу:

public sealed interface Result<T> permits Success, Failure {}
public record Success<T>(T data) implements Result<T> {}
public record Failure<T>(Throwable error) implements Result<T> {}

3. Як концепція Algebraic Data Types (ADT) в Java замінює класичне успадкування класів за допомогою Records та Sealed Interfaces?

Відповідь: Класичне успадкування класів будується на відкритому поліморфізмі: базовий клас відкритий для додавання нових підкласів у будь-яких модулях, а поведінка розподіляється по віртуальних методах підкласів.

ADT пропонують зворотний підхід — закриті типи даних:

  • sealed interface фіксує вичерпний перелік можливих варіантів стану системи (Sum Type).
  • record фіксує суворо незмінну структуру даних для кожного конкретного варіанта (Product Type).
  • Нові операції та логіка додаються не через модифікацію інтерфейсів, а ззовні — через чисті функції та зіставлення зі зразком (Pattern Matching / Visitor Pattern без boilerplate). При додаванні нового варіанта компілятор змусить обробити його в усіх switch-виразах проєкту.

4. Якщо два Record мають абсолютно однакові компоненти (record PointA(int x, int y) і record PointB(int x, int y)), чи можна використовувати їх взаємозамінно без спільного інтерфейсу?

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

У Java реалізована номінативна типізація (Nominal Typing), а не структурна типізація (Structural/Duck Typing). Навіть якщо два рекорди мають побайтово ідентичні поля, сигнатури та типи, для системи типів JVM PointA та PointB — це два абсолютно різні несумісні типи. Присвоїти екземпляр PointA змінній типу PointB без явного створення нового об’єкта неможливо. Поліморфна взаємозамінність можлива лише через спільний предок-інтерфейс:

public interface Coordinates { int x(); int y(); }
public record PointA(int x, int y) implements Coordinates {}
public record PointB(int x, int y) implements Coordinates {}

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

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

  • Record $\rightarrow$ Class: Record не може розширювати жоден клас (він уже неявно extends java.lang.Record).
  • Class $\rightarrow$ Record: Жоден клас або рекорд не може успадковуватися від Record (він неявно final).
  • Class $\rightarrow$ java.lang.Record: Звичайному класу заборонено успадковуватися від java.lang.Record.
  • Інтерфейси: Record може реалізовувати будь-які інтерфейси (implements InterfaceA, InterfaceB).
  • Модифікатори: Record не може бути abstract, sealed або non-sealed.
  • Заміна успадкуванню: Композиція (has-a) та Алгебраїчні типи даних (sealed interface + record).

Червоні прапорці на співбесіді

  • ❌ «Можна успадкуватися від абстрактного рекорда»: абстрактних рекордів у Java не існує.
  • ❌ «Record успадковується від Object»: прямий суперклас Record — це java.lang.Record, який у свою чергу успадковує Object.
  • ❌ «Ми використовуємо успадкування рекордів для додавання полів до DTO»: це не компілюється в Java.

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