JavaRush /Курси /Kotlin SELF /super і порядок ініціалізації: розширюємо базову логіку

super і порядок ініціалізації: розширюємо базову логіку

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

1. Вступ

У попередній лекції ми навчилися перевизначати методи та властивості за допомогою override, відкривати потрібні точки розширення через open, а також познайомилися з protected як із «доступно нащадкам, але не зовнішньому світу». Сьогодні додамо до цього важливу практичну навичку: як не зламати базову логіку, коли ви перевизначаєте поведінку, і чому інколи super — це не «я здався», а «я зробив правильно». Також розберемо порядок ініціалізації — саме через нього новачкам часто здається, ніби «Kotlin мене підколює».

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

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

Найважливіша думка: super потрібен не завжди. Але якщо базовий клас виконує обовʼязкові кроки, super — це спосіб розширити, а не зламати.

2. super у перевизначеному методі

Зазвичай життя підкидає нам не задачу «повністю замінити метод», а радше «зробити те саме, але ще трошки». Наприклад, базовий клас уже вміє друкувати повідомлення, а нащадок хоче додати префікс. Або базовий клас валідовує рядок, а нащадок додає ще одну перевірку — і все одно хоче скористатися базовою.

Спочатку подивімося мінімальний приклад, щоб «намацати» відчуття:

open class Logger {
    open fun log(message: String) {
        println("[LOG] $message")
    }
}

class TaggedLogger : Logger() {
    override fun log(message: String) {
        super.log("TAG: $message")
    }
}

fun main() {
    val logger = TaggedLogger()
    logger.log("Hello")
    // [LOG] TAG: Hello
}

Тут нащадок не перевинаходить друк, а користується вже готовою логікою «як саме виводити лог», додаючи своє.

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

open class ReportLine {
    open fun render(): String = "- (empty)"
}

class TextLine(private val text: String) : ReportLine() {
    override fun render(): String = super.render() + " $text"
}

fun main() {
    val line = TextLine("hello")
    println(line.render())
    // - (empty) hello
}

Приклад навмисно дещо спрощений, але він добре показує механіку: super.render() — це базова частина формату, далі ми додаємо своє. У реальному житті базовий render() робитиме щось корисніше, ніж писати "empty". Саме тому вигідно розширювати через super, а не копіювати формат у всіх нащадках.

Трохи більш життєвий варіант: базовий клас гарантує, що рядок звіту ніколи не буде порожнім (наприклад, повертає "(no data)"), а нащадок додає деталі, не порушуючи цього контракту.

3. super і властивості: доступ до геттера

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

У документації про успадкування це зазвичай показують так: нащадок може звернутися до властивості суперкласу через super.property і використати її як частину своєї логіки.

Приклад:

open class Document {
    open val title: String = "Untitled"
}

class NamedDocument(private val name: String) : Document() {
    override val title: String
        get() = super.title + ": " + name
}

fun main() {
    val doc = NamedDocument("Budget report")
    println(doc.title)
    // Untitled: Budget report
}

Зверніть увагу на деталь: ми перевизначили val title не через «збережене значення», а через get(). Це зручно, коли «значення» потрібно обчислювати, а не зберігати. І так, super.title тут викликає базовий геттер.

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

4. Порядок ініціалізації в успадкуванні

Поки ми не дійшли до підводних каменів, розберімося, у якому порядку створюється обʼєкт, якщо є базовий клас і нащадок. Багато багів в успадкуванні виглядають як «чому моє поле ще 0/null, я ж йому присвоїв значення!». І майже завжди відповідь одна: порядок ініціалізації.

Інтуїтивне правило таке: під час створення обʼєкта нащадка спочатку створюється «базова частина» обʼєкта, і лише потім — «частина нащадка». Звучить логічно, але є важливі нюанси. Перед ініціалізацією базового класу ще обчислюються аргументи для виклику його конструктора. У Kotlin це описано прямо: базова ініціалізація виконується першою (окрім обчислення аргументів для конструктора суперкласу). Це означає, що перевизначені в нащадку властивості ще не проініціалізовані, коли виконується код базового конструктора або init.

Подивімося на демонстраційний приклад із виведенням кроків. Тут спеціально використовується also { println(...) }, щоб показати, «коли саме й що обчислилося»:

open class Base(name: String) {
    init {
        println("Base.init, name=$name")
    }

    open val size: Int = name.length.also {
        println("Base.size initialized: $it")
    }
}

class Derived(name: String, private val lastName: String) : Base(
    name.uppercase().also { println("Argument for Base: $it") }
) {
    init {
        println("Derived.init, lastName=$lastName")
    }

    override val size: Int = (super.size + lastName.length).also {
        println("Derived.size initialized: $it")
    }
}

fun main() {
    println("Constructing...")
    Derived("hello", "world")
}

Очікуваний вивід (приблизно такий; порядок важливіший за конкретний текст):

Constructing...
Argument for Base: HELLO
Base.init, name=HELLO
Base.size initialized: 5
Derived.init, lastName=world
Derived.size initialized: 10

Якщо винести з цього «кістяк» того, що відбувається, вийде така схема:

flowchart TD
    A["Створюємо Derived(...)"] --> B["Обчислюємо аргументи для Base(...)"]
    B --> C["Ініціалізація Base: властивості + init-блоки"]
    C --> D["Ініціалізація Derived: властивості + init-блоки"]

Саме тому базовий клас має бути обережним: під час його ініціалізації нащадок ще «не зібраний».

До речі, якщо вам хочеться швидко перевірити порядок у своєму коді, println у init { } — цілком чесний інструмент налагодження. Kotlin за це не «сваритиме». А от ваш майбутній керівник команди може трохи посварити — але це вже інша історія.

5. Чому open‑виклики в init небезпечні

Зараз ми підходимо до місця, де успадкування перестає бути милим котиком і перетворюється на котика, який скидає кружку зі столу просто тому, що «а що ви мені зробите». Проблема ось у чому: якщо базовий клас у init викликає open-метод або звертається до open-властивості, Kotlin може викликати перевизначення в нащадку, хоча нащадок ще не встиг ініціалізувати свої поля.

Це не «особливість компілятора», а логічний наслідок поліморфізму: виклик має піти в найконкретнішу реалізацію. Документація прямо попереджає: під час проєктування базового класу варто уникати використання open-членів у конструкторах, ініціалізаторах властивостей і init-блоках.

Подивімося на приклад, який виглядає невинно, але може «впасти»:

open class BaseGreeter {
    init {
        // Погана ідея: open-метод викличеться в момент,
        // коли нащадок може бути ще не готовий.
        println(buildGreeting())
    }

    open fun buildGreeting(): String = "Hello from Base"
}

class FancyGreeter : BaseGreeter() {
    private val prefix: String = "[Fancy]"

    override fun buildGreeting(): String = "$prefix Hello from Fancy"
}

fun main() {
    FancyGreeter()
}

На перший погляд усе нормально: prefix же оголошений. Але порядок такий, що BaseGreeter.init виконується до ініціалізації FancyGreeter (його властивостей і init). У деяких випадках це призведе до того, що перевизначення спробує використати стан, який ще не готовий.

Щоб зробити проблему максимально наочною й гарантовано отримати помилку, використаємо lateinit. Це цілком життєва ситуація: ви відкладаєте ініціалізацію, але за надто раннього виклику отримаєте виняток.

open class BaseFormatter {
    init {
        println(format()) // ризик
    }

    open fun format(): String = "(base)"
}

class UserFormatter : BaseFormatter() {
    private lateinit var userName: String

    init {
        userName = "Alice"
    }

    override fun format(): String = "User=$userName"
}

fun main() {
    UserFormatter()
    // UninitializedPropertyAccessException: lateinit property userName has not been initialized
}

Це і є той самий момент: «чому воно взагалі полізло в нащадка». А сталося це тому, що метод open, а реальний обʼєкт — UserFormatter.

Як це виправляти? Ідея проста: базовий клас не повинен вимагати перевизначення для власної ініціалізації. Один із безпечних прийомів — зробити в базовому класі final-метод, який викликається в init, а розширення винести в окрему точку. Її ви викликаєте вже після того, як обʼєкт точно зібраний.

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

open class SafeBaseFormatter {
    init {
        println(baseFormat()) // базова частина, не open
    }

    fun baseFormat(): String = "(base safe)"
}

class SafeUserFormatter : SafeBaseFormatter() {
    private val userName: String = "Alice"

    fun fullFormat(): String = "User=$userName"
}

fun main() {
    val f = SafeUserFormatter()
    println(f.fullFormat()) // User=Alice
}

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

6. Міні‑крок у проєкті: форматування рядків звіту

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

Зробімо базовий клас ReportLine, який знає, як сформувати «шапку рядка», а нащадки додаватимуть вміст:

open class ReportLine {
    protected fun bullet(): String = "•"

    open fun render(): String = "${bullet()} "
}

class TextReportLine(private val text: String) : ReportLine() {
    override fun render(): String = super.render() + text
}

fun main() {
    println(TextReportLine("Expenses").render())
    // • Expenses
}

Тут уже видно дві ключові ідеї: super.render() додає спільну частину, а protected залишає «службовий» bullet() доступним нащадкам, але не зовнішньому світу.

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

class TotalReportLine(private val total: Int) : ReportLine() {
    override fun render(): String = super.render() + "TOTAL = $total"
}

fun main() {
    println(TotalReportLine(1200).render())
    // • TOTAL = 1200
}

А тепер покажімо приклад, де super використовується у властивостях, бо «заголовок» теж зручно зробити властивістю:

open class TitledLine {
    open val title: String = "line"

    open fun render(): String = "[$title]"
}

class CategoryLine(private val category: String) : TitledLine() {
    override val title: String
        get() = super.title + ":" + category
}

fun main() {
    println(CategoryLine("Food").render())
    // [line:Food]
}

Тут super.title звертається до базового геттера. Навіть якщо базовий title згодом стане обчислюваним, код нащадка не доведеться переписувати.

І маленька практична мораль: super допомагає зробити так, щоб спільний стиль звіту жив в одному місці. Коли ви вирішите змінити маркер "•" на "-", вам не доведеться шукати 15 копій форматування по всьому проєкту.

Коли викликати super, а коли ні

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

Зручно тримати в голові таку табличку:

Ситуація Що зазвичай означає Що частіше робити
Базовий метод валідовує дані, веде журнал (логує), оновлює лічильник Важлива інфраструктура: без неї клас може бути «зламаним» Викликати super і додавати своє до/після
Базовий метод повертає «заглушку» або значення за замовчуванням База не несе сенсу: це просто «щоб було» Можна повністю замінити без super
Перевизначення змінює формат, але спільний каркас один Спільна частина потрібна всім нащадкам super + додавання свого
Базовий клас у init викликає open-метод Ризик раннього виклику нащадка Переробити дизайн: super тут не врятує

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

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

Помилка №1: «Я перевизначив метод — отже базова логіка більше не потрібна».
Такий підхід часто ламає інваріанти базового класу: наприклад, він нормалізує вхідні дані, веде лічильник, заповнює службові поля або друкує обовʼязкову частину звіту. Якщо ви не викликаєте super, ви не просто «змінюєте поведінку» — ви можете випадково прибрати важливу частину контракту.

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

Помилка №3: звернення до super.someProperty і очікування, що це просто поле, а не обчислення.
У Kotlin властивість — це, по суті, методи get()/set(). super.title може бути обчислюваною, може звертатися до інших полів і навіть мати побічні ефекти (хоча так робити не варто). Тому super.property варто сприймати як виклик логіки, а не як «читання памʼяті».

Помилка №4: виклик open-методів/властивостей із init базового класу.
Це один із найнеприємніших «професійних» багів: код компілюється, інколи навіть працює, але щойно нащадок ускладнюється — починає падати через те, що поля «ще не готові». Kotlin окремо попереджає про цей патерн: під час проєктування базового класу краще уникати open-викликів у конструкторі та init.

Помилка №5: спроба «лікувати» проблему порядку ініціалізації «ще одним lateinit».
lateinit — корисний інструмент, але він не робить порядок ініціалізації безпечнішим. Навпаки: якщо open-виклик станеться надто рано, ви отримаєте виняток. Правильне «лікування» тут не в тому, щоб «відкласти ініціалізацію ще сильніше», а в тому, щоб базовий клас не вимагав перевизначення для власного створення, зважаючи на порядок ініціалізації в ієрархії.

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