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 must not be blank" }
category = fixed
}
}
fun main() {
val e = Expense(" Food ", 100)
println(e.category) // food
}
Функция require() — это “предусловие”: если условие ложно, она выбрасывает IllegalArgumentException. Мы подробно разберём require/check в отдельной лекции этого дня, но на уровне init важно запомнить идею: валидация входа логично живёт там же, где объект создаётся, иначе вы вечно будете искать, кто именно забыл проверить аргументы.
Небольшая табличка для ощущения границ:
| Ситуация | Обычно делаем в init | Почему это удобно |
|---|---|---|
| Пробелы вокруг строки | |
Убираем “шум” один раз, а не в каждом месте использования |
| Разный регистр | / |
Поиск/сравнение становятся стабильнее |
| Пустая строка там, где нельзя | |
Лучше не создавать объект, чем хранить поломанное состояние |
| Пустая заметка, которая не обязательна | превращаем в null | “Нет заметки” и “заметка = пробелы” перестают быть разными мирами |
5. Несколько init‑блоков и порядок выполнения
Иногда хочется разделить логику инициализации на части: отдельно нормализация, отдельно вычисление derived‑полей, отдельно проверки. 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()) { "Category must not be blank" }
require(amountInput >= 0) { "Amount must be >= 0, got $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, и от качества сообщения зависит, насколько быстро вы поймёте проблему.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ