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
И ещё короткая табличка, чтобы было удобно «сверяться глазами»:
| Часть класса | Где пишем | Когда выполняется |
|---|---|---|
| Инициализатор свойства | |
До init, в порядке объявления в классе |
| init { ... } | |
После инициализаторов свойств, тоже по порядку сверху вниз |
| Тело secondary constructor | |
После того, как отработали свойства + 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("property init: normalizeTitle()")
return rawTitle.trim()
}
}
fun main() {
DebugExpense(" Taxi ")
// property init: 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 (this(...)) при наличии primary constructor, и только после того, как отработали инициализаторы свойств и 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 body: amountText='$amountText'")
}
}
fun main() {
Expense(" Tea ", "120")
// init: title='Tea', amount=120
// secondary body: 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 runs first")
}
constructor(x: Int) {
println("secondary body: x=$x")
}
}
fun main() {
NoPrimary(5)
// init runs first
// secondary body: x=5
}
Если вы это запомните, то перестанете «надеяться на удачу» и начнёте проектировать инициализацию осознанно: всё, что нужно для корректности, кладём в общий путь, а вторичный конструктор оставляем как адаптер входа и, максимум, как место для второстепенных действий (например, логирования), но не для формирования базового состояния.
6. Сюрпризы на практике в мини‑приложении расходов
Теперь привяжем механику к реальному смыслу. В нашем консольном ExpenseTracker мы хотим, чтобы Expense всегда был корректным: заголовок не пустой, сумма не отрицательная, и нам удобно хранить “короткое имя” для отображения в списке.
Если неправильно разложить вычисления по местам, можно получить дублирование, разные значения в зависимости от конструктора или просто нечитабельную кашу. Дальше будут примеры, которые выглядят немного «учебно», но это намеренно: они показывают не бизнес‑логику, а порядок событий.
Ошибка: надеяться, что secondary constructor подготовит данные для init
Попробуем сделать так, чтобы init проверял title, а secondary constructor хотел бы «починить» заголовок позже. Не получится (и это хорошо).
class BadExpense(val title: String) {
init {
require(title.isNotBlank()) { "title must not be blank" }
println("init: ok")
}
constructor(rawTitle: String, allowBlank: Boolean) : this(rawTitle) {
// Поздно! init уже прошёл.
println("secondary body: 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 must not be blank" }
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 объект должен быть готов к использованию, а не «почти готов, осталось сделать ещё три шага». Если вам нужно много внешних действий, чаще всего это знак, что вы смешали «создание модели» и «сценарий приложения» в одном месте.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ