Files
obsidian/Kotlin/ООП/Инициализация.md
T

15 KiB
Raw Blame History

title, description, tags
title description tags
Инициализация Порядок выполнения init и инициализаторов, инициализация при наследовании и ловушка open-метода
kotlin
oop

Инициализаторы свойств и блоки init выполняются в порядке их объявления в теле класса - как единая последовательность.

class Order4 {
    val a = println("1: свойство a").let { "a" }
    init { println("2: первый init") }
    val b = println("3: свойство b").let { "b" }
    init { println("4: второй init") }

    constructor() { println("5: тело конструктора") }
}

fun main() { Order4() }

Вывод: 1, 2, 3, 4, 5. Тело вторичного конструктора идёт последним, после всех инициализаторов.

Блоков init может быть несколько; их используют, когда инициализация не сводится к одному выражению.

Параметры конструктора доступны сразу

class User3(name: String, age: Int) {
    val displayName = name.uppercase()
    val category: String

    init {
        category = when {
            age < 18 -> "несовершеннолетний"
            age < 65 -> "взрослый"
            else -> "пенсионер"
        }
    }
}

fun main() {
    val u = User3("иван", 20)
    println("${u.displayName}: ${u.category}")    // ИВАН: взрослый
}

Порядок при наследовании

Базовый класс инициализируется полностью до начала инициализации подкласса:

open class Base4 {
    val baseField = println("1: свойство Base").let { "base" }
    init { println("2: init Base") }
}

class Derived4 : Base4() {
    val derivedField = println("3: свойство Derived").let { "derived" }
    init { println("4: init Derived") }
}

fun main() { Derived4() }

Вывод: 1, 2, 3, 4. Внутри каждого класса - строго порядок объявления; базовый класс целиком раньше подкласса. Переставьте init выше свойства - и номера пойдут как 2, 1, 4, 3.

Аргументы для конструктора базового класса вычисляются до всего остального:

open class Base5(val value: String) {
    init { println("2: init Base, value=$value") }
}

class Derived5(input: String) : Base5(input.uppercase().also { println("1: аргумент=$it") }) {
    init { println("3: init Derived") }
}

fun main() { Derived5("иван") }

Ловушка: open-метод в конструкторе

Это главная ошибка инициализации. Вызов open-метода из конструктора базового класса попадёт в переопределённую версию - до того, как подкласс инициализирован.

open class Base6 {
    init { show() }                      // опасно
    open fun show() = println("Base")
}

class Derived6 : Base6() {
    val name: String = "Иван"
    override fun show() = println("Derived, name='$name'")
}

fun main() { Derived6() }

Вывод: Derived, name='null'. Свойство name объявлено non-null, но в этот момент ещё не присвоено - и мы наблюдаем null в типе String.

Если свойство подкласса имеет примитивный тип, вместо null будет 0 - и это ещё труднее заметить, потому что исключения не возникает:

open class Base7 {
    init { println("размер при инициализации: ${size()}") }
    open fun size() = 0
}

class Derived7 : Base7() {
    private val count: Int = 3
    override fun size() = count
}

fun main() {
    val d = Derived7()
    println("размер после: ${d.size()}")
}

Вывод: 0, затем 3. Ошибки нет, просто неверное значение.

А вот если переопределённый метод обращается к объекту, результат - NullPointerException в коде, где ни одного nullable-типа не видно:

open class Base7b {
    init { println("размер: ${size()}") }
    open fun size() = 0
}

class Derived7b : Base7b() {
    private val items = listOf(1, 2, 3)
    override fun size() = items.size       // items ещё null
}

fun main() {
    try {
        Derived7b()
    } catch (e: NullPointerException) {
        println("NPE: ${e.message}")
        // NPE: Cannot invoke "java.util.List.size()" because "this.items" is null
    }
}

Та же проблема существует в Java; разница в том, что Kotlin позволяет её избежать, закрывая классы по умолчанию.

Как не попадать:

  • не вызывать open-члены из конструкторов и init
  • делать такие методы private или final
  • если поведение подкласса нужно при создании - передавать его как параметр конструктора
open class Base8(private val label: String) {
    init { println("метка: $label") }     // параметр уже готов
}

class Derived8 : Base8("Derived") {
    val name = "Иван"
}

fun main() { Derived8() }      // метка: Derived

Порядок объявления имеет значение

Свойство нельзя использовать до его объявления:

class Bad3 {
    // val b = a + 1      // ошибка: variable 'a' must be initialized
    val a = 1
    val b = a + 1
}

fun main() { println(Bad3().b) }   // 2

Компилятор ловит это для прямых обращений. Но через метод проверка обходится:

class Sneaky {
    val b = compute()         // вызывается до инициализации a
    val a = 10
    private fun compute() = a * 2

    override fun toString() = "a=$a b=$b"
}

fun main() { println(Sneaky()) }   // a=10 b=0

b равно 0, потому что a на момент вызова ещё не присвоено. Ошибки компиляции нет - компилятор не отслеживает порядок через вызовы методов.

Ленивая инициализация как решение

Если порядок становится проблемой, by lazy снимает её: значение вычисляется при первом обращении, когда объект уже собран.

class Fixed {
    val b: Int by lazy { a * 2 }      // вычислится после создания
    val a = 10

    override fun toString() = "a=$a b=$b"
}

fun main() { println(Fixed()) }       // a=10 b=20

Подробнее: Свойства

Исключение в инициализации

class Validated(val age: Int) {
    init {
        require(age >= 0) { "возраст отрицательный: $age" }
        println("объект создан")
    }
}

fun main() {
    try {
        Validated(-1)
    } catch (e: IllegalArgumentException) {
        println("не создан: ${e.message}")   // не создан: возраст отрицательный: -1
    }
    println(Validated(20).age)                // объект создан, 20
}

Ссылка на объект не вернётся, но побочные эффекты, выполненные до исключения, останутся. Поэтому в init держат только проверки и присваивания - без регистрации в реестрах, открытия файлов и запуска потоков.

Инициализация object и companion object

object Registry2 {
    init { println("инициализация Registry") }
    val items = mutableListOf<String>()
}

class Service9 {
    companion object {
        init { println("инициализация companion") }
        val created = 0
    }
}

fun main() {
    println("старт")
    println(Registry2.items.size)       // инициализация Registry, затем 0
    println(Service9.created)           // инициализация companion, затем 0
}

Оба инициализируются лениво - при первом обращении, потокобезопасно средствами JVM. companion object инициализируется при первом обращении к нему или при первом создании экземпляра класса.

Подробнее: Object и companion object

[!info] Отличия от Java

Java Kotlin
блок { } в теле класса init { }
static { } init в companion object
присваивание в конструкторе инициализатор свойства
super(args) первой строкой : Base(args) в заголовке
нет проверки порядка ошибка при прямом обращении вперёд

Порядок инициализации одинаков: базовый класс, потом подкласс; внутри класса - по порядку объявления. Проблема вызова переопределяемого метода из конструктора тоже одинакова.

Kotlin частично защищает от неинициализированных свойств. Прямое обращение к свойству до его объявления - ошибка компиляции, тогда как Java молча даёт значение по умолчанию:

class Sneaky {
    int b = a + 1;   // компилируется, b = 1, потому что a == 0
    int a = 10;
}

Через вызов метода обход возможен в обоих языках.

Главная защита - закрытые классы. Ловушка с open-методом в конструкторе требует, чтобы класс был наследуемым. Поскольку в Kotlin класс закрыт по умолчанию, большинство классов в эту ловушку попасть не может в принципе.

Сравнить: Блоки инициализации в Java, Конструкторы

[!warning] Подводные камни Вызов open-метода из init видит неинициализированный подкласс - значение будет null даже у non-null свойства:

open class Base9 {
    init { check() }
    open fun check() {}
}

class Derived9 : Base9() {
    private val name: String = "Иван"
    override fun check() {
        @Suppress("SENSELESS_COMPARISON")
        println("name инициализирован: " + (name != null))
    }
}

fun main() { Derived9() }      // name инициализирован: false

Это единственный штатный способ увидеть null в переменной типа String.

Порядок объявления свойств меняет поведение, если между ними есть зависимость через методы. Перестановка двух строк может дать 0 вместо правильного значения - без ошибки компиляции.

Тело вторичного конструктора выполняется последним, уже после всех init. Код, который ожидает обратного порядка, получит неожиданный результат.

lateinit не инициализируется автоматически - обращение до присваивания даёт UninitializedPropertyAccessException.

Круговая зависимость между object даёт null или 0 без ошибки:

object First { val value: String = Second.value + "-first" }
object Second { val value: String = "second" }

fun main() { println(First.value) }    // second-first

Если поменять зависимость местами, получится null-second: один из объектов будет читать поле другого во время его инициализации.

Исключение в init оставляет побочные эффекты. Частая ошибка - регистрация this в глобальном реестре до валидации: объект создан не будет, а ссылка на него останется.

Утечка this из конструктора. Передача this куда-либо в init открывает доступ к недосозданному объекту:

class Leaky {
    val items = mutableListOf<Leaky>()
    init { register(this) }           // объект ещё не готов
    val name: String = "готов"

    companion object {
        val all = mutableListOf<Leaky>()
        fun register(l: Leaky) = all.add(l)
    }
}

fun main() {
    Leaky()
    println("в реестре: ${Leaky.all.size}")    // в реестре: 1
}

Другой поток, получивший такую ссылку, увидит name как null.

Дополнительно: Классы и конструкторы Далее: Модификаторы видимости