JavaRush /Курси /Kotlin SELF /Порядок ініціалізації: властивості →

Порядок ініціалізації: властивості → init → тіло secondary constructor

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

1. Вступ

Порядок ініціалізації — це як черговість дій під час готування: якщо спершу поставити торт у духовку, а потім згадати, що тісто ще в пакеті, вийде… експериментальна кухня. У програмуванні так само: якщо в init ви використовуєте властивість, яку ще не ініціалізовано, або розраховуєте, що тіло secondary constructor «встигне підготувати дані», ви отримаєте дивні значення, зайві обчислення або винятки в найнесподіваніших місцях.

Ця лекція потрібна, щоб ви могли впевнено відповісти на запитання: чому якийсь println() виводиться раніше за інший; чому перевірка спрацьовує ще до вашого коду в secondary constructor; чому властивість уже має значення всередині init; і куди краще винести обчислення, щоб воно виконувалося рівно один раз — і в правильний момент.

2. Ментальна модель: з чого складається створення об’єкта

Коли ми пишемо val e = Expense(...), це виглядає як один рядок. Насправді ж створення об’єкта — це сценарій із кількох кроків, і Kotlin виконує їх у фіксованому порядку. Найзручніше уявляти це як невеликий конвеєр: спочатку готуються властивості (їхні ініціалізатори), потім виконуються init-блоки, і лише після цього (якщо ви створюєте об’єкт через secondary constructor) виконується його тіло.

Ідея така: init-блоки виконуються під час створення екземпляра й ідуть у тому самому порядку, що й ініціалізатори властивостей. А якщо secondary constructor делегує в primary (а за наявності primary він зобов’язаний це робити), то вся ініціалізація (властивості + init) гарантовано відпрацює до коду в тілі secondary constructor.

Зафіксуймо схему.

flowchart TD
    A["Виклик конструктора: ClassName(...)"] --> B["Делегування в primary constructor (за потреби)"]
    B --> C["Ініціалізатори властивостей (за порядком у класі)"]
    C --> D["init-блоки (за порядком у класі)"]
    D --> E{"Створення відбувалося через secondary constructor?"}
    E -->|так| F["Тіло secondary constructor { ... }"]
    E -->|ні| G["Об’єкт готовий"]
    F --> G

І ще коротка табличка, щоб було зручно «звірятися очима»:

Частина класу Де пишемо Коли виконується
Ініціалізатор властивості
val x = ...
До init, у порядку оголошення в класі
init { ... }
init { ... }
Після ініціалізаторів властивостей, теж у порядку згори вниз
Тіло secondary constructor
constructor(...) : this(...) { ... }
Після того, як відпрацювали властивості + init

Ще один корисний орієнтир: присвоєння властивостей із параметрів primary constructor концептуально відбувається до ініціалізаторів властивостей і init-блоків. Тому «рядок виглядає одним», а кроків там насправді чимало.

3. Ініціалізатори властивостей: раніше, ніж ви думаєте

Ініціалізатор властивості — це вираз праворуч від = під час оголошення властивості. На вигляд це просто «задали значення», але за змістом — фрагмент коду, який виконується під час створення об’єкта. Тому до ініціалізаторів варто ставитися як до частини процесу конструювання: вони мають бути простими, передбачуваними й не перетворювати створення об’єкта на серіал із 12 сезонів.

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

Властивість, яка залежить від параметра конструктора

Продовжимо навчальний консольний мініпроєкт «облік витрат» (умовний ExpenseTracker). Сьогодні зробимо фрагмент моделі так, щоб він наочно показував порядок ініціалізації.

class Expense(rawTitle: String, val amountCents: Int) {
    val title: String = rawTitle.trim()
    val titleLength: Int = title.length

    init {
        println("init: title='$title', length=$titleLength")
    }
}

fun main() {
    Expense("  Coffee  ", 350)
    // init: title='Coffee', length=6
}

Тут фішка в тому, що titleLength залежить від title, а title — від параметра rawTitle. Kotlin спокійно це дозволяє: параметри primary constructor доступні і в ініціалізаторах властивостей, і в init.

Під час створення об’єкта спочатку обчислиться title, потім titleLength, а далі виконається init. Тобто порядок іде згори вниз по тілу класу: як написано — так і виконується.

Прихована робота в ініціалізаторі властивості

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

Зробімо ініціалізацію спеціально «шумною», щоб побачити порядок:

class DebugExpense(val rawTitle: String) {

    val title: String = normalizeTitle()

    init {
        println("init: title='$title'")
    }

    private fun normalizeTitle(): String {
        println("ініціалізація властивості: normalizeTitle()")
        return rawTitle.trim()
    }
}

fun main() {
    DebugExpense("  Taxi  ")
    // ініціалізація властивості: normalizeTitle()
    // init: title='Taxi'
}

Зверніть увагу: спочатку друк із normalizeTitle() (це ініціалізатор властивості title), а потім друк із init. Ось і вся «механіка конвеєра».

Якщо ви звикли думати, що init — головне місце ініціалізації, то так: він важливий. Але ініціалізатори властивостей — не менш реальна частина процесу. Просто вони часто виглядають невинно.

4. init-блоки: зібрати й перевірити

init-блок — це код, який виконується під час створення об’єкта як частина ініціалізації. Зазвичай його використовують для двох речей: нормалізації/підготовки стану та перевірки коректності входу.

У Kotlin може бути кілька init-блоків, і вони виконуються в порядку появи в тілі класу — разом з ініціалізаторами властивостей. Звучить просто, але в реальному коді часто виникають запитання: «а якщо init-блоків кілька — який буде першим?» або «а якщо я оголосив властивість нижче — чи можна нею користуватися вище?». Розберімо це на зрозумілих «слідах» println().

Один init і друк сліду

Зробімо мінімальний «трекер порядку»:

class BuildOrder(val x: Int) {

    val y: Int = x + 1

    init {
        println("init: x=$x, y=$y")
    }
}

fun main() {
    BuildOrder(10)
    // init: x=10, y=11
}

Тут видно, що y уже обчислено на момент входу в init. Це очікувано: ініціалізатори властивостей ідуть раніше.

Кілька init-блоків: порядок згори вниз

Тепер зробимо два init-блоки й додамо властивість між ними. Це гарний прийом, щоб «відчути», що порядок тут буквально текстовий: згори вниз у файлі.

class ManySteps(val name: String) {

    init {
        println("init #1: name='$name'")
    }

    val upper: String = name.uppercase()

    init {
        println("init #2: upper='$upper'")
    }
}

fun main() {
    ManySteps("Ann")
    // init #1: name='Ann'
    // init #2: upper='ANN'
}

Практичний наслідок простий: якщо другий init спирається на якусь властивість, переконайтеся, що її оголошено вище за нього. Інакше ви або не зможете до неї звернутися, або отримаєте структуру, яку важко читати й супроводжувати.

5. Secondary constructor: чому його тіло завжди останнє

Secondary constructor часто сприймають як «альтернативний спосіб створення об’єкта», і це правда. Але ключовий нюанс — коли саме виконується його тіло.

Новачки інколи очікують, що можна «в тілі secondary constructor підготувати дані, а потім уже відпрацює init». У Kotlin інший контракт: secondary constructor за наявності primary constructor спочатку зобов’язаний делегувати в primary (this(...)), і лише після того, як відпрацювали ініціалізатори властивостей і init-блоки, виконується тіло secondary constructor.

Це зроблено для того, щоб у класу був єдиний шлях ініціалізації: об’єкт не повинен існувати «напівготовим».

Делегування в primary: живий приклад

Візьмемо Expense і додамо secondary constructor, який приймає суму рядком (ніби ми читаємо введення користувача).

class Expense(val title: String, val amountCents: Int) {

    init {
        println("init: title='$title', amount=$amountCents")
    }

    constructor(rawTitle: String, amountText: String) : this(
        title = rawTitle.trim(),
        amountCents = amountText.toIntOrNull() ?: 0
    ) {
        println("тіло secondary: amountText='$amountText'")
    }
}

fun main() {
    Expense("  Tea  ", "120")
    // init: title='Tea', amount=120
    // тіло secondary: amountText='120'
}

Повідомлення з init друкується раніше, ніж повідомлення з тіла secondary constructor. Це не випадковість: делегування гарантує, що основна ініціалізація виконається до логіки secondary constructor.

І це логічно: init — частина «ядра коректності об’єкта». Якби secondary міг виконуватися раніше, ви могли б тимчасово тримати об’єкт у некоректному стані.

Клас без primary: init усе одно раніше тіла secondary

Іноді primary constructor узагалі немає, а secondary є. І тут теж легко помилитися в очікуваннях: здається, що раз «primary немає», то init ніби має бути «після конструктора». Але ні: init-блок однаково виконується до тіла secondary constructor.

class NoPrimary {
    init {
        println("init виконується першим")
    }

    constructor(x: Int) {
        println("тіло secondary: x=$x")
    }
}

fun main() {
    NoPrimary(5)
    // init виконується першим
    // тіло secondary: x=5
}

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

6. Сюрпризи на практиці в мініпроєкті обліку витрат

Тепер прив’яжемо механіку до реального змісту. У нашому консольному ExpenseTracker ми хочемо, щоб Expense завжди був коректним: заголовок не порожній, сума не від’ємна, і нам зручно зберігати «коротке імʼя» для відображення в списку.

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

Помилка: сподіватися, що secondary constructor підготує дані для init

Спробуймо зробити так, щоб init перевіряв title, а secondary constructor хотів би «полагодити» заголовок пізніше. Не вийде (і це добре).

class BadExpense(val title: String) {

    init {
        require(title.isNotBlank()) { "title не повинен бути порожнім" }
        println("init: ok")
    }

    constructor(rawTitle: String, allowBlank: Boolean) : this(rawTitle) {
        // Запізно! init уже пройшов.
        println("тіло secondary: allowBlank=$allowBlank")
    }
}

Якщо ви створите BadExpense(" ", true), то require(...) впаде ще до того, як тіло secondary constructor узагалі почне виконуватися. Це не баг, це гарантія: init — частина спільного шляху.

Правильна думка тут така: якщо вам потрібно «лагодити» вхід, робіть це під час делегування : this(...) (тобто ще до потрапляння в init) або в ініціалізаторі властивості / init спільного шляху.

Правильний патерн: підготувати вхід до init

Зробімо «нормальний» варіант: secondary constructor приводить вхід до канонічного вигляду й передає його в primary.

class Expense(val title: String) {

    init {
        require(title.isNotBlank()) { "title не повинен бути порожнім" }
        println("init: title='$title'")
    }

    constructor(rawTitle: String, trim: Boolean) : this(
        title = if (trim) rawTitle.trim() else rawTitle
    )
}

fun main() {
    Expense("  Taxi  ", trim = true)
    // init: title='Taxi'
}

Тепер неважливо, яким шляхом створюємо об’єкт: init завжди бачить уже підготовлений title. Це відповідає ідеї «secondary — адаптер, primary+init — єдиний шлях».

Порядок як інструмент дизайну API

Коли ви розумієте порядок, стає простіше обирати, куди класти логіку.

Якщо обчислення має бути зроблене один раз і завжди однаково, його зручно тримати або в параметрах делегування (: this(...)), або в ініціалізаторі властивості, або в init. Усі три місця виконуються до тіла secondary constructor і складають «основу створення».

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

Як налагоджувати ініціалізацію

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

Найпростіший інструмент — тимчасові println() в ініціалізаторах властивостей, у init і в тілі secondary constructor. Намагайтеся підписувати повідомлення так, щоб вони відрізнялися. Інакше потім ви самі читатимете лог, ніби це шифр.

Хороша звичка — зібрати мінімальний приклад: один клас, одна властивість, один init, один secondary constructor і кілька друків. Щойно ви побачили порядок на простому прикладі, переносіть розуміння назад у «бойовий» клас. Kotlin тут передбачуваний: ініціалізатори й init ідуть у порядку оголошення, а тіло secondary — після них.

7. Типові помилки під час розуміння порядку ініціалізації

Помилка №1: очікувати, що init виконається раніше за ініціалізатори властивостей.
Часто здається логічним: «я ж написав init, значить там усе починається». Але val x = ... — це теж код ініціалізації, і він виконується до init. Через це люди дивуються, чому якісь обчислення/друк відбуваються «до конструктора». Правильна звичка — сприймати ініціалізатори властивостей як першу фазу створення об’єкта, яка передує init.

Помилка №2: думати, що тіло secondary constructor виконається до init і зможе підготувати дані.
Це особливо популярно, коли secondary constructor приймає рядок і хоче «поправити» поля, а init уже перевіряє коректність. Але Kotlin гарантує протилежне: secondary делегує в primary, виконуються властивості й init, і лише потім іде тіло secondary constructor. Якщо потрібно підготувати дані для init, робіть це в делегуванні : this(...).

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

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

Помилка №5: дублювати ініціалізацію й перевірки в кількох конструкторах.
Коли в класу кілька secondary constructors, новачок інколи копіює однакові require(...) або однакове trim() у кожне тіло конструктора. Підсумок: один конструктор забули оновити — і поведінка класу стала залежати від способу створення. Набагато надійніше тримати правила коректності у спільному шляху (в init і/або в делегуванні до primary), щоб усі шляхи створення вели до однієї й тієї самої логіки.

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

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