JavaRush /Курси /Kotlin SELF /Успадкування в Kotlin: fi...

Успадкування в Kotlin: final за замовчуванням і синтаксис : Base (...)

Kotlin SELF
Рівень 35 , Лекція 0
Відкрита

1. Вступ

Успадкування — це коли ми створюємо новий клас на основі наявного. Новий клас (нащадок) отримує властивості й поведінку базового класу та може додавати власні деталі. У підручниках це зазвичай описують фразою «щоб не копіювати код», але на практиці думка трохи глибша: успадкування допомагає виразити звʼязок «є різновидом».

Уявіть, що в нашому консольному застосунку для обліку грошей (витрати/доходи) ми хочемо зберігати різні записи: витрату й дохід. У них є спільний мінімум: сума, коментар, ідентифікатор. Але у витрати є категорія, а в доходу — джерело. Хочеться описати спільний фундамент один раз, а далі лише розширювати. Саме тут і зʼявляється успадкування.

Але Kotlin ставиться до успадкування обережно. І це не занудство мови, а прагматичний підхід: успадкування — потужний інструмент, який легко перетворити на «макаронну ієрархію», де один клас тягне за собою десяток інших. У підсумку вже ніхто не розуміє, де чий код.

2. final за замовчуванням і ключове слово open

Почнімо з найважливішого правила: класи в Kotlin за замовчуванням final. Це означає, що від них не можна успадковуватися. Така поведінка — норма мови: якщо автор класу не сказав «можна успадковуватися», то значить — не можна.

Ідея така: успадкування — це не просто «фіча», а обіцянка. Якщо ви дозволили успадковуватися від вашого класу, то як автор припускаєте, що хтось розширюватиме його поведінку. А отже, вам потрібно думати про стабільність контрактів, про захищені деталі, про те, які методи можна перевизначати, тощо. Kotlin змушує ухвалити це рішення явно.

На практиці це виглядає так: ви пишете звичайний клас, а потім намагаєтеся зробити нащадка — і компілятор каже «не можна». Приклад:

class Box(val value: Int)

// class IntBox(value: Int) : Box(value) // не скомпілюється: Box final за замовчуванням

Як дозволити успадкування через open

Щоб від класу можна було успадковуватися, його потрібно позначити ключовим словом open.

open class Message(val text: String)

class ErrorMessage(text: String) : Message(text)

Тут Message відкритий для успадкування, тому ErrorMessage може від нього успадковуватися. Важливо не переплутати: open стосується саме можливості бути базовим класом. Якщо open не написано — клас закритий.

Іноді новачки сприймають open як «нехай буде про всяк випадок». Я б так не робив. У Kotlin гарний тон — відкривати успадкування лише тоді, коли ви справді плануєте ієрархію та хочете дозволити розширення.

3. Синтаксис : Base(...) і виклик конструктора базового класу

Тепер перейдемо до найбільш «механічної» частини: як виглядає успадкування в коді.

У Kotlin успадкування записують через двокрапку після заголовка класу:

open class Base(p: Int)

class Derived(p: Int) : Base(p)

Після : вказується базовий тип, а якщо це клас, то далі йде виклик його конструктора.

Чому ми викликаємо конструктор базового класу

Тому що базовий клас — це частина обʼєкта. Коли ви створюєте нащадка, усередині нього спочатку має коректно створитися «шматок базового класу». А отже, базовий клас має отримати параметри, які йому потрібні.

Якщо в базового класу є параметри в первинному конструкторі, нащадок зобовʼязаний їх передати.

Чи потрібні дужки після Base

Якщо базовий тип — клас, то так: дужки потрібні, навіть якщо конструктор без параметрів. Це часта дрібна «спотикалка» в новачків: візуально хочеться написати просто : Base, але Kotlin очікує : Base().

Приклад із «порожнім» конструктором:

open class Parent

class Child : Parent()

У Parent є неявний конструктор без параметрів, і ми викликаємо його через Parent().

4. Нащадок може додавати свої властивості й методи

У цій лекції ми не перевизначаємо поведінку через override (це буде наступна тема). Сьогодні важливо зрозуміти інше: навіть без override успадкування вже корисне, тому що нащадок може розширити модель даними й новими методами.

Скажімо, у нас є базовий клас «Користувач», а нащадок — «Співробітник», у якого є посада:

open class Person(val name: String)

class Employee(name: String, val role: String) : Person(name)

Зверніть увагу на дві речі.

По-перше, Employee отримує name, але не зберігає його повторно: name уже оголошено в Person. Тобто ми уникаємо дублювання даних.

По-друге, у Employee зʼявляється нова властивість role. Це класичний сценарій: «додаємо деталі».

5. Що саме включає open

Щоб не виникло хибного очікування «я написав open, і тепер у нащадку можна змінювати що завгодно», корисно зафіксувати різницю.

Що ми робимо Що потрібно написати в базовому класі Що ми отримуємо
Дозволяємо успадковуватися від класу
open class ...
Можна написати
class Child : Base(...)
Дозволяємо перевизначати метод/властивість
open fun ...
/
open val ...
Можна написати
override ...

Ключова думка: open class дозволяє успадкування, але не робить усі члени автоматично перевизначуваними. У Kotlin і класи, і їхні члени за замовчуванням final, а «небезпечні можливості» потрібно вмикати явно.

6. Приклад: спільний базовий клас для записів бюджету

Тепер давайте акуратно привʼяжемо тему до нашого «практичного» консольного застосунку для обліку грошей. До цього моменту в нас могла бути одна модель, наприклад Expense (витрата). Але щойно ми захочемо додати доходи, швидко зʼясується, що витрата й дохід мають спільні поля.

Зробімо базовий клас LedgerEntry (запис у журналі), який зберігатиме спільний мінімум. Поки що не будемо ускладнювати логіку й обмежимося даними.

Базовий клас LedgerEntry

open class LedgerEntry(
    val id: Int,
    val amount: Int,
    val note: String,
)

Тут усе просто: open, бо ми плануємо нащадків.

Нащадок: витрата

Витраті додамо категорію. Категорії в нас уже могли бути зроблені через enum class у попередніх лекціях.

enum class Category { FOOD, TRANSPORT, OTHER }

data class ExpenseEntry(
    val category: Category,
    val merchant: String,
    val entryId: Int,
    val entryAmount: Int,
    val entryNote: String,
) : LedgerEntry(entryId, entryAmount, entryNote)

Так, тут є трохи «механіки» в параметрах: щоб приклад залишався коротким, ми явно назвали параметри entryId/entryAmount/entryNote, а потім передали їх у базовий клас.

Найважливіше в цьому фрагменті коду — фінальна частина:

) : LedgerEntry(entryId, entryAmount, entryNote)

Це рівно той синтаксис : Base(...), який ми сьогодні вивчаємо.

Нащадок: дохід

Доходу додамо джерело:

data class IncomeEntry(
    val source: String,
    val entryId: Int,
    val entryAmount: Int,
    val entryNote: String,
) : LedgerEntry(entryId, entryAmount, entryNote)

Поки що ми не робимо жодних перевизначень: просто створюємо спільний «каркас» і кілька конкретних варіантів.

Невеликий тест у main

Щоб побачити, що це взагалі працює, зробімо невелике виведення:

fun main() {
    val e = ExpenseEntry(Category.FOOD, "Кавʼярня", 1, 450, "Лате")
    val i = IncomeEntry("Зарплата", 2, 120_000, "Я молодець")

    println(e.note) // Лате
    println(i.note) // Я молодець
}

Ми звертаємося до note, хоча note оголошено в LedgerEntry, а обʼєкт — нащадок. Це і є базова практична цінність успадкування: спільні властивості живуть в одному місці.

7. Обмеження успадкування в Kotlin

data class і успадкування

Корисно акуратно проговорити момент, який часто дивує.

data class може успадковуватися від звичайного класу. Це нормально й часто використовується, якщо базовий клас дає спільну структуру або контракт. Водночас важливо памʼятати, що data class не може бути open.

Тобто так можна:

open class Base(val id: Int)

data class Item(val title: String, val itemId: Int) : Base(itemId)

А так не можна:

open data class Item(...) // так не буває: data class не може бути open

Чому це важливо? Тому що в новачків часто зʼявляється бажання зробити «універсальну data-модель», а потім від неї успадковуватися. Kotlin цьому опирається: data class задумано як «конкретна модель даних», а не як «точка розширення».

Один базовий клас

Ще одне правило Kotlin: клас може мати лише один базовий клас. Тобто ви можете написати class A : B(), але не можете написати class A : B(), C(), якщо C теж клас.

Якщо ви колись бачили в інших мовах множинне успадкування класів, то могли подумати: «А чому Kotlin так не вміє?» Простими словами — тому що це майже завжди призводить до конфліктів: які поля брати, які методи викликати, що робити, якщо два базові класи визначають одне й те саме.

Схема ієрархії

classDiagram
    LedgerEntry <|-- ExpenseEntry
    LedgerEntry <|-- IncomeEntry

    class LedgerEntry {
        +id: Int
        +amount: Int
        +note: String
    }

    class ExpenseEntry {
        +category: Category
        +merchant: String
    }

    class IncomeEntry {
        +source: String
    }

Сенс діаграми простий: ExpenseEntry і IncomeEntry — різні класи, але в них спільний фундамент LedgerEntry.

8. Типові помилки

Помилка № 1: спроба успадкуватися від класу без open.
Це найчастіший стартовий промах: пишемо class Child : Parent() і щиро дивуємося, чому компілятор незадоволений. У Kotlin за замовчуванням усе final, і успадкування потрібно вмикати явно. Якщо клас ваш — додавайте open. Якщо клас чужий (наприклад, з бібліотеки), то автор бібліотеки свідомо заборонив успадкування — і це краще поважати.

Помилка № 2: забути, що базовий клас потрібно ініціалізувати.
Новачкам іноді здається, що успадкування — це просто «двокрапка й імʼя», а параметри якось «протечуть». Але базовий клас — це частина обʼєкта, і він має бути створений коректно. Тому, якщо в базового конструктора є параметри, нащадок зобовʼязаний викликати Base(...) з відповідними аргументами.

Помилка № 3: написати : Base замість : Base() і потім довго дивитися на помилку.
Якщо базовий тип — клас, у нього викликається конструктор, отже потрібні дужки. Навіть якщо параметрів немає, пишемо Base().

Помилка № 4: очікувати, що open class автоматично дозволяє перевизначати все підряд.
open class означає «дозволити успадкування», а не «дозволити змінювати будь-які методи». У Kotlin члени теж final за замовчуванням, і щоб перевизначати метод, він має бути open.

Помилка № 5: спроба зробити data class базовим класом ієрархії.
Інтуїтивно хочеться: «нехай базовий буде data class, щоб був гарний toString()». Але Kotlin не дає зробити data class відкритим для успадкування, і це обмеження варто сприймати як підказку з дизайну: базовий клас — це частіше каркас, а data class — конкретний запис. Якщо потрібно і те, і інше — робіть open class базою, а data class — нащадками.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ