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 дозволяє успадкування, але не робить усі члени автоматично перевизначуваними. У 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 — нащадками.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ