1. Навіщо потрібен init і що він робить
Коли ви вперше пишете клас, часто здається, що конструктор — це просто «параметри в дужках», а все інше відбувається магічно. На практиці магія там справді є — але керована. Kotlin дає місце, куди можна покласти код, що виконається в момент створення обʼєкта. Це місце й називається init‑блоком — невеличка «кухня» всередині класу, де ви приводите вхідні дані до ладу й готуєте стан.
init { ... } — це блок ініціалізації, який виконується під час створення екземпляра класу, тобто під час виконання primary constructor. У документації Kotlin це прямо описано як initializer blocks, які запускаються під час виконання primary constructor.
Найкорисніше в init для новачка — те, що він допомагає зробити так, щоб обʼєкт не зберігав «брудне» введення (пробіли, порожні рядки, неправильний формат). Натомість усередині все буде охайно й передбачувано.
Мініприклад «на пальцях»:
class User(val name: String) {
init {
println("Створили користувача: $name")
}
}
fun main() {
User("Ann") // Створили користувача: Ann
}
Так, println у init — не стиль для продакшну. Але для навчання це чудовий спосіб побачити, що блок справді виконується під час створення обʼєкта.
2. Нормалізація: навіщо «лагодити» введення під час створення обʼєкта
Нормалізація — це коли ви берете вхідні дані й приводите їх до «канонічної» форми. У програмуванні це майже як мити руки перед готуванням: можна, звісно, не мити… але потім ви здивуєтеся, чому все почало дивно пахнути.
Якщо ви робите консольний застосунок (а ми якраз робимо), то введення до вас часто приходить із readln(). А readln() читає світ таким, яким він є: із пробілами, зайвими переносами, несподіваними « » замість імені та класичним «я точно ввів число» (ні).
Продовжуємо наш навчальний застосунок
Уявімо, що ми робимо маленький консольний трекер витрат (умовний BudgetBuddy). У нас є сутність Expense — витрата: категорія, сума й нотатка. Поки без data class (до неї ще дійдемо) — звичайний клас.
Зробімо так, щоб Expense сам приводив дані до ладу:
class Expense(
categoryInput: String,
amountInput: Int,
noteInput: String?
) {
val category: String
val amount: Int
val note: String?
init {
category = categoryInput.trim().lowercase()
amount = amountInput
val n = noteInput?.trim()
note = if (n == null || n.isBlank()) null else n
}
}
Тут важливий момент: параметри categoryInput, amountInput, noteInput — це просто введення. А властивості category, amount, note — це те, що реально зберігатиметься в обʼєкті. І саме в init ми перетворюємо введення на стан.
Перевірмо на маленькому main:
fun main() {
val e = Expense(categoryInput = " Food ", amountInput = 250, noteInput = " coffee ")
println(e.category) // food
println(e.note) // coffee
}
Тепер будь-яка частина програми може розраховувати, що category уже без пробілів і в одному регістрі. Це зменшує кількість перевірок «про всяк випадок» в інших місцях.
3. Підготовка стану: не лише «почистити», а й «дозібрати»
init корисний не лише для trim(). Часто обʼєкту потрібно підготувати ще якісь «внутрішні» значення. Наприклад, рядок для друку, короткий ідентифікатор, нормалізовану версію для пошуку або акуратно зібраний заголовок.
Важливо не перетворювати init на «серіал на 12 сезонів». Він має відповідати за створення коректного стану, а не за бізнес-сценарій на кшталт «сходити в інтернет, запитати курс долара й ухвалити доленосне рішення».
Додамо в Expense підготовлений текст для відображення (умовно summary). Він обчислюється один раз під час створення й зберігається як val:
class Expense(
categoryInput: String,
amountInput: Int,
noteInput: String?
) {
val category: String
val amount: Int
val note: String?
val summary: String
init {
category = categoryInput.trim().lowercase()
amount = amountInput
val n = noteInput?.trim()
note = if (n == null || n.isBlank()) null else n
summary = if (note == null) {
"$category: $amount"
} else {
"$category: $amount ($note)"
}
}
}
fun main() {
val e = Expense(" Transport ", 120, " metro ")
println(e.summary) // transport: 120 (metro)
}
Зверніть увагу: ми не робимо тут нічого складного, але виграємо в читабельності іншого коду. Пізніше, коли виводитимемо список витрат, зможемо друкувати summary, не збираючи рядок заново в десятку місць.
4. Дві стратегії нормалізації: «виправити» або «заборонити»
У реальних проєктах ви майже завжди обираєте одну з двох філософій.
Перша філософія — «спробуємо акуратно виправити введення». Наприклад, trim() для рядка — це мʼяке виправлення: користувач увів « Food », а ви перетворили це на «food», і всім добре.
Друга філософія — «якщо введення погане, обʼєкт не створюємо». Це підхід fail-fast: «краще впасти одразу, ніж жити зі сміттям усередині».
Kotlin прямо рекомендує init як місце для валідації й показує приклад із require(...).
Зробімо мʼякий варіант (прибирати пробіли) і строгий варіант (забороняти порожню категорію).
class Expense(categoryInput: String, val amount: Int) {
val category: String
init {
val fixed = categoryInput.trim().lowercase()
require(fixed.isNotBlank()) { "Категорія не може бути порожньою" }
category = fixed
}
}
fun main() {
val e = Expense(" Food ", 100)
println(e.category) // food
}
Функція require() — це «передумова»: якщо умова хибна, вона викидає IllegalArgumentException. Ми детально розберемо require/check в окремій лекції цього дня. А поки важливо запамʼятати ідею: валідація введення логічно живе там само, де створюється обʼєкт. Інакше ви постійно шукатимете, хто саме забув перевірити аргументи.
Невеличка таблиця, щоб відчути межі:
| Ситуація | Зазвичай робимо в init | Чому це зручно |
|---|---|---|
| Пробіли навколо рядка | |
Прибираємо «шум» один раз, а не в кожному місці використання |
| Різний регістр | / |
Пошук і порівняння стають стабільнішими |
| Порожній рядок там, де не можна | |
Краще не створювати обʼєкт, ніж зберігати зламаний стан |
| Порожня нотатка, яка не обовʼязкова | перетворюємо на null | «Немає нотатки» та «нотатка = пробіли» перестають бути різними світами |
5. Кілька init‑блоків і порядок виконання
Іноді хочеться розділити логіку ініціалізації на частини: окремо нормалізація, окремо обчислення похідних полів, окремо перевірки. Kotlin це дозволяє: init‑блоків може бути кілька, і виконуються вони в порядку оголошення (згори донизу).
Це схоже на те, як ви готуєте вечерю: спочатку дістаєте продукти, потім миєте, далі ріжете — і вже тоді готуєте. Можна зробити все в одному гігантському «мега-кроці», але мозку простіше, коли кроки розділені.
Покажемо порядок на прикладі:
class Demo(categoryInput: String) {
val category: String = categoryInput.trim()
init {
println("init #1: category='$category'")
}
init {
println("init #2: length=${category.length}")
}
}
fun main() {
Demo(" Food ")
// init #1: category='Food'
// init #2: length=4
}
Ідея, яку важливо забрати із собою: init — не «одна чарівна штука», а послідовність блоків. Це допомагає, коли ви читаєте чужий код (або свій код через три тижні — а це майже те саме).
Щоб уявити процес створення обʼєкта, зручно тримати в голові таку схему:
flowchart TD
A["Викликали конструктор
Demo(' Food ')"] --> B["Ініціалізатори властивостей
val category = ..."]
B --> C["init #1"]
C --> D["init #2"]
D --> E["Обʼєкт готовий до використання"]
Про тонші нюанси порядку ініціалізації ми поговоримо в окремій лекції цього дня. А зараз достатньо впевнено розуміти, що init виконується «під час створення» — і завжди в оголошеному порядку.
6. Дизайн: нормалізація всередині класу та інваріанти
Нормалізація має жити всередині класу
Дуже частий «біль новачка» виглядає так: ви десь прочитали рядок, десь зробили trim(), а десь забули — і потім ловите баг «чому категорія food не дорівнює категорії food ».
Якщо нормалізація живе зовні, ви зобовʼязані памʼятати про неї в кожному місці, де створюєте обʼєкт. А таких місць із часом стає багато: команда add, імпорт із файлу, тестовий код, генератор демоданих. І щоразу хтось забуватиме.
Тому хороший стиль звучить так: клас сам відповідає за те, щоб усередині нього було чисто.
Поганий варіант (нормалізація зовні):
fun main() {
val rawCategory = " Food "
val e = Expense(categoryInput = rawCategory.trim(), amountInput = 100, noteInput = null)
println(e.category)
}
Проблема тут не в тому, що це «не працює». Проблема в іншому: за тиждень хтось створить Expense(rawCategory, ...) без trim() — і буде щиро здивований.
Хороший варіант (нормалізація всередині):
fun main() {
val rawCategory = " Food "
val e = Expense(categoryInput = rawCategory, amountInput = 100, noteInput = null)
println(e.category) // food
}
Тепер обʼєкт гарантує, що category у нормі. Зовнішній код може бути простим, а простота — це недооцінена суперсила.
init як «місце збирання інваріантів»
Коли ви проєктуєте клас, корисно домовитися із самим собою про невеликі правила, які завжди мають бути правдою для обʼєкта. Наприклад: «категорія не порожня», «сума не відʼємна», «нотатка або null, або непорожній рядок без пробілів по краях».
Такі правила часто називають інваріантами стану. Не обовʼязково запамʼятовувати термін — достатньо зрозуміти зміст: обʼєкт не повинен існувати в безглуздому вигляді.
init — чудове місце, щоб ці правила «закріпити». Kotlin навіть підказує це як типовий сценарій: init + require(...) для перевірки коректності даних.
У нашому Expense зафіксуємо дві прості ідеї: категорія не порожня, сума не відʼємна (поки просто заборонимо відʼємні значення).
class Expense(categoryInput: String, amountInput: Int) {
val category: String
val amount: Int
init {
val fixedCategory = categoryInput.trim().lowercase()
require(fixedCategory.isNotBlank()) { "Категорія не може бути порожньою" }
require(amountInput >= 0) { "Сума має бути >= 0, отримано $amountInput" }
category = fixedCategory
amount = amountInput
}
}
Якщо хтось спробує створити Expense(" ", -10), обʼєкт просто не зʼявиться. Це набагато краще, ніж створити «зламаний» обʼєкт і потім намагатися рахувати за ним звіти.
7. Типові помилки під час роботи з init
Помилка №1: нормалізація живе поза класом і в результаті дублюється.
Зазвичай це починається невинно: ви зробили trim() в одному місці — і все працює. Потім зʼявляється друге місце створення обʼєкта, там trim() забули, і пошук та порівняння починають поводитися так, «ніби Kotlin жартує». Краще один раз нормалізувати в init, ніж десять раз сподіватися на дисципліну людини.
Помилка №2: init перетворюють на «головний сценарій застосунку».
Іноді в init хочеться і багато друкувати, і читати readln(), і рахувати щось велике, і зробити «половину програми». Це поганий стиль: конструювання має бути швидким і передбачуваним. init — про коректність стану, а не про те, щоб «зробити все одразу».
Помилка №3: змішують стратегії «лагодити введення» та «забороняти введення» без явного рішення.
Якщо частину полів ви тихо виправляєте (наприклад, trim()), а інша частина раптом падає з require, користувач класу отримує дивний досвід. На рівні дизайну важливо хоча б для себе обрати: «ми мʼяко нормалізуємо» або «ми строго забороняємо», — і бути послідовним.
Помилка №4: роблять властивості var лише для того, щоб змінити їх у init.
Технічно це працює, але часто веде до зайвої змінюваності: раз властивість var, то її можна змінювати й після створення — навіть якщо це не частина задуму. Нерідко краще зберігати результат нормалізації в окремих val і не давати обʼєкту «дрейфувати».
Помилка №5: пишуть перевірки, але з неінформативними повідомленнями.
require(x > 0) { "bad" } — це майже як записка «зламалося» на зламаному мосту. Хороше повідомлення має пояснювати очікування та фактичне значення. Kotlin і документація щодо винятків прямо підкреслюють, що require кидає IllegalArgumentException, і від якості повідомлення залежить, наскільки швидко ви зрозумієте проблему.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ