1. Вступ
Коли новачок уперше бачить override, реакція часто така: «Навіщо мені це писати? Компілятор і так бачить, що я хочу зробити такий самий метод». І ось тут Kotlin поводиться дуже по-дорослому: він змушує вас явно підтвердити свій намір. Бо в реальних проєктах «схожий метод» і «перевизначений метод» — це різні речі. А ціна помилки зазвичай вимірюється годинами зневадження.
У Kotlin перевизначення можливе лише тоді, коли в базовому класі член позначено як open, а в нащадку ви пишете override. Це правило просте й жорстке: компілятор не дає вам випадково «підмінити» метод — наприклад, через описку в сигнатурі або інший тип. Kotlin прямо вимагає явних модифікаторів і для членів, які можна перевизначати, і для самих перевизначень.
Невелика демонстрація (продовжимо умовний консольний застосунок для обліку грошей; назвімо його MoneyFlow):
open class Operation {
open fun title(): String = "Operation"
}
class ExpenseOperation : Operation() {
// fun title(): String = "Expense" // не компілюється без override
override fun title(): String = "Expense"
}
Тут важливо відчути ідею: Kotlin не «прискіпується» — він допомагає вам не помилитися. Якщо ви справді хочете перевизначити метод, напишіть override. Якщо ж ви випадково написали «просто схожий» метод, компілятор зупинить вас.
2. Перевизначення методів: open fun у базі та override fun у нащадку
Зазвичай мотивація для override дуже життєва: ви зробили базовий клас «загалом про операції», а потім зрозуміли, що витрати й доходи треба описувати по-різному. Гарний стиль — дати базовому класу контракт (метод, який «має існувати»), а нащадкам — можливість реалізувати його по-своєму.
Правило дуже просте: метод базового класу має бути open, інакше його не можна перевизначити. Kotlin окремо підкреслює: якщо метод не open, то оголосити в нащадку метод із такою самою сигнатурою не можна — ані з override, ані без нього.
Зробімо базовий клас Operation і нащадка. Поки без «складної архітектури» — просто щоб рука звикла:
open class Operation(
val amountCents: Int,
) {
open fun signedAmountCents(): Int = amountCents
open fun renderLine(): String =
"Сума: ${signedAmountCents()} центів"
}
class Expense(amountCents: Int) : Operation(amountCents) {
override fun signedAmountCents(): Int = -amountCents
override fun renderLine(): String =
"Витрата: ${signedAmountCents()} центів"
}
Зверніть увагу на корисну звичку: ми не перевизначаємо «все підряд», а лише те, що справді має відрізнятися. Успадкування — це інструмент, а не релігія.
Перевірмо, що все працює:
fun main() {
val op = Operation(amountCents = 500)
val exp = Expense(amountCents = 500)
println(op.renderLine()) // Сума: 500 центів
println(exp.renderLine()) // Витрата: -500 центів
}
Так, поки що ми навіть не обговорюємо «розумні колекції базового типу» — це буде окрема тема. Зараз важливо побачити базову механіку: нащадок справді змінює поведінку через override.
3. Перевизначення властивостей: open val, сумісність типів і трюк val → var
Методи — це «поведінка». Але в Kotlin можна перевизначати й властивості. Зазвичай це навіть практичніше, ніж здається: базовий клас може обіцяти «у кожної операції є kind», а конкретні операції уточнюють, що саме це за kind.
З погляду мови властивості перевизначаються майже так само, як методи: у базовому класі open val/var, у нащадку — override val/var. Механізм перевизначення властивостей описано в офіційних правилах успадкування: тип має бути сумісним, а перевизначення можна записати як властивість з ініціалізацією або як властивість із кастомним get().
Простий приклад: open val → override val
open class Operation(
val amountCents: Int,
) {
open val kind: String = "OPERATION"
}
class Income(amountCents: Int) : Operation(amountCents) {
override val kind: String = "INCOME"
}
class Expense(amountCents: Int) : Operation(amountCents) {
override val kind: String = "EXPENSE"
}
Перевірка:
fun main() {
val inc = Income(1200)
val exp = Expense(700)
println(inc.kind) // INCOME
println(exp.kind) // EXPENSE
}
Перевизначення через get(): коли значення обчислюється
Іноді «тип операції» хочеться скласти з кількох частин. Тоді зручно перевизначати властивість через ґетер:
open class Operation(val amountCents: Int) {
open val kind: String
get() = "OPERATION"
}
class Expense(amountCents: Int, private val category: String) : Operation(amountCents) {
override val kind: String
get() = "EXPENSE($category)"
}
Тест:
fun main() {
val exp = Expense(amountCents = 500, category = "Food")
println(exp.kind) // EXPENSE(Food)
}
Чому можна val перевизначити як var (і чому навпаки не можна)
Це місце часто викликає відчуття «магії», але пояснення доволі приземлене. val — це, по суті, «є ґетер». var — це «є ґетер і сетер». Якщо базовий клас пообіцяв лише ґетер (val), нащадок може дати більше можливостей і додати сетер (var). Зворотний напрям заборонено: якщо базовий клас пообіцяв сетер, нащадок не має права «урізати контракт» і лишити тільки ґетер.
Коротка ілюстрація:
open class Operation {
open val note: String = ""
}
class EditableOperation : Operation() {
override var note: String = "чернетка" // val -> var: можна
}
4. Корисні нюанси: Kotlin 2.x і final override
Kotlin 2.x: open-властивості з backing field ініціалізуються одразу
У Kotlin 2.0+ є правило, про яке приємно знати заздалегідь, щоб не впіймати «чому вчора працювало, а сьогодні — ні». Якщо властивість open і в неї є backing field (тобто це не суто обчислюваний get()), то вона має бути ініціалізована одразу — у місці оголошення, а не «потім у init». У Kotlin 2.0 це стало суворішим: тепер і open val з backing field не можна відкладати до init.
Поганий (або, точніше, уже не дозволений) стиль:
open class Base {
// open val a: Int // припустимо, хотіли так
// init { a = 1 } // Kotlin 2.x скаже: не можна
}
Хороший стиль — одразу задати значення:
open class Base {
open val a: Int = 1
}
Або зробити властивість обчислюваною (без backing field), якщо вона справді обчислюється:
open class Base {
open val a: Int
get() = 1
}
Для навчальних проєктів це правило особливо корисне: воно змушує писати ініціалізацію так, щоб поведінка не залежала від «тонких моментів» порядку створення обʼєкта.
final override: як «зачинити двері» після перевизначення
Є тонкість, яку дуже легко пропустити: у Kotlin член, позначений як override, сам стає відкритим для подальшого перевизначення (якщо ви явно не «закрили» його). Це логічно: ви продовжуєте ланцюжок успадкування.
Але інколи потрібно навпаки: «на цьому рівні ієрархії фіксуємо реалізацію й далі не даємо її змінювати». Для цього використовується final override. Якщо треба заборонити перевизначення надалі, використовуйте final.
Приклад із нашою «фінансовою» тематикою:
open class Operation {
open fun kindCode(): String = "OP"
}
open class ExpenseBase : Operation() {
final override fun kindCode(): String = "EXP" // далі перевизначати не можна
}
class FoodExpense : ExpenseBase() {
override fun kindCode(): String = "FOOD" // не компілюється
}
Це зручно, коли ви хочете стабілізувати поведінку на певному рівні. Наприклад, усі витрати мають мати код "EXP", а уточнення «їжа/транспорт» зберігатимуться в окремій властивості (а не ламатимуть спільний контракт).
5. protected: видно нащадкам, але не видно зовні
До цього моменту ми обговорювали «як змінювати поведінку». Але успадкування швидко підводить до іншої проблеми: нащадку інколи потрібно скористатися якимись внутрішніми деталями базового класу, а ви не хочете робити ці деталі public. Бо тоді ними почне користуватися будь-хто — і ви втратите контроль.
Для цього існує protected. Це модифікатор видимості з простим змістом: член доступний усередині класу й усередині нащадків, але недоступний для зовнішнього коду. Це один із головних інструментів, щоб успадкування не перетворювалося на «роздачу нутрощів усім охочим».
Щоб було легше тримати це в голові, ось невелика таблиця:
| Модифікатор | Доступ усередині класу | Доступ у нащадку | Доступ «ззовні» |
|---|---|---|---|
|
так | ні | ні |
|
так | так | ні |
|
так | так | так |
|
так | так | так (але лише всередині модуля) |
Тепер застосуймо це до нашого прикладу: нехай базовий клас уміє форматувати суму в «нормальний вигляд» і чистити нотатку. Ми хочемо, щоб нащадки могли цим користуватися, але зовнішній код — ні.
open class Operation(
val amountCents: Int,
note: String,
) {
// Зберігаємо "сиру" нотатку, але не віддаємо її назовні
protected var normalizedNote: String = note.trim()
protected fun formatMoney(): String {
val dollars = amountCents / 100
val cents = amountCents % 100
return "$dollars.${cents.toString().padStart(2, '0')}"
}
open fun renderLine(): String =
"Сума: ${formatMoney()} USD, note='$normalizedNote'"
}
class Expense(amountCents: Int, note: String) : Operation(amountCents, note) {
override fun renderLine(): String {
// Нащадок бачить protected-елементи й може ними користуватися
normalizedNote = "[EXP] $normalizedNote"
return "Витрата: -${formatMoney()} USD, note='$normalizedNote'"
}
}
Перевірмо:
fun main() {
val exp = Expense(amountCents = 2599, note = " lunch ")
println(exp.renderLine()) // Витрата: -25.99 USD, note='[EXP] lunch'
println(exp.normalizedNote) // не компілюється: protected
println(exp.formatMoney()) // не компілюється: protected
}
Ось це і є хороший компроміс: нащадки отримують «інструменти» й доступ до потрібної внутрішньої частини стану, а зовнішній код бачить лише публічний контракт.
Окремий цікавий момент: якщо ви пишете функцію-розширення поза класом, вона не може «проникнути» у private/protected члени цього класу — розширення працюють ззовні й підпорядковуються правилам видимості. Це корисно памʼятати, коли зʼявляється бажання «обійти» інкапсуляцію через розширення.
6. Практичний приклад: контракт + override + protected
Зберімо все в одну невелику «перевірочну» програму, щоб побачити: теми лекції поєднуються в реальному коді, а не існують у вакуумі.
open class Operation(val amountCents: Int) {
open val kind: String = "OP"
protected fun formatMoney(): String {
val dollars = amountCents / 100
val cents = amountCents % 100
return "$dollars.${cents.toString().padStart(2, '0')}"
}
open fun renderLine(): String = "$kind ${formatMoney()} USD"
}
class Income(amountCents: Int) : Operation(amountCents) {
override val kind: String = "IN"
override fun renderLine(): String = "$kind +${formatMoney()} USD"
}
class Expense(amountCents: Int) : Operation(amountCents) {
override val kind: String = "EX"
override fun renderLine(): String = "$kind -${formatMoney()} USD"
}
fun main() {
val a = Income(1250)
val b = Expense(399)
println(a.renderLine()) // IN +12.50 USD
println(b.renderLine()) // EX -3.99 USD
}
Тут видно одразу три ключові ідеї: базовий клас задає «скелет» API, нащадки уточнюють деталі через override, а спільна корисна логіка лишається в базовому класі й доступна нащадкам через protected, не стаючи публічною.
7. Типові помилки з override і protected
Помилка №1: спроба перевизначити те, що не open.
Це найпоширеніша історія: ви пишете в нащадку override fun …, а компілятор каже, що перевизначати нічого. Зазвичай причина проста: базовий метод (або властивість) забули позначити open. У Kotlin «закрито за замовчуванням» — це нормальний стан, і open треба додавати свідомо.
Помилка №2: «Я точно перевизначив», але сигнатура трохи інша.
Іноді змінюється тип параметра, порядок аргументів або тип результату — і раптом це вже не перевизначення, а зовсім інший метод. Kotlin частково рятує вас тим, що вимагає override: якщо сигнатура не збіглася, код просто не скомпілюється. Але початківці інколи прибирають override, «щоб хоч якось працювало», — і отримують два різні методи, один із яких ніхто не викликає. Найкращі ліки — не боротися з компілятором, а читати помилку й порівнювати сигнатури.
Помилка №3: очікування, що override «за замовчуванням final».
Новачки інколи думають: «Ну я ж уже перевизначив — значить, далі не можна». У Kotlin навпаки: перевизначений член зазвичай лишається відкритим для подальших перевизначень. Якщо ви хочете зафіксувати реалізацію, потрібен final override.
Помилка №4: відкладати ініціалізацію open-властивості на init (Kotlin 2.x).
Зі старого досвіду з інших мов хочеться оголосити open val x: Int, а значення присвоїти в init. У Kotlin 2.x це може зламатися: open-властивості з backing field мають ініціалізуватися одразу. Якщо потрібне відкладене обчислення, краще зробити обчислюваний get() або змінити дизайн (наприклад, прибрати open, якщо це справді не потрібно).
Помилка №5: робити поле public, бо «нащадку треба», і випадково відкрити нутрощі всьому світові.
Це архітектурна помилка, а не синтаксична. Якщо зовнішньому коду не потрібно чіпати внутрішню змінну або helper-метод, але нащадку потрібно — це прямий кейс для protected. Так ви зберігаєте можливість розширення, не перетворюючи клас на «вітрину внутрішньої кухні».
Помилка №6: намагатися дістатися до protected через функцію-розширення «ззовні».
Іноді здається, що розширення — це як «дописати метод прямо в клас», отже можна дістатися до protected. Але розширення живе поза класом і підпорядковується правилам видимості: ззовні protected недоступний. Якщо хочеться використати внутрішню деталь — отже, або робіть це всередині нащадка, або переглядайте API.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ