override і protected у Kotlin

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

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 valoverride 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. Це модифікатор видимості з простим змістом: член доступний усередині класу й усередині нащадків, але недоступний для зовнішнього коду. Це один із головних інструментів, щоб успадкування не перетворювалося на «роздачу нутрощів усім охочим».

Щоб було легше тримати це в голові, ось невелика таблиця:

Модифікатор Доступ усередині класу Доступ у нащадку Доступ «ззовні»
private
так ні ні
protected
так так ні
public
так так так
internal
так так так (але лише всередині модуля)

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

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.

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