Привет, Хабр!

Сегодня разберёмся, почему попытка «избавиться от ? любой ценой» приводит к проблемам, и как жить с этим вообще жить.

Зачем вообще lateinit, и почему он кусает?

Kotlin дал нам нулевую безопасность, но иногда объект попросту нельзя инициализировать сразу — например, его втыкает DI‑контейнер или он зависит от жинджера Андроид‑жизненного цикла. В Java мы бы держали @Nullable, в Kotlin ставят ?, но IDE начинает требовать safe calls, Elvis‑операторы и прочее. И обычно разраб достаёт последнюю карту:

@Inject lateinit var repository: UserRepository   // ну вот и без «?»

Ноль ?, ноль NPE… и ровно до первого обращения раньше времени.
lateinit — это обещание; JVM поверила, IDE поверила, а вы забыли. Получили UninitializedPropertyAccessException.

5 классических ошибок

Ошибка 1. Доступ к полю в init{} до того, как DI-фреймворк сделал инъекцию

class UserPresenter @Inject constructor() {

    @Inject lateinit var repo: UserRepository

    init {
        // Хочется бодро дернуть репозиторий
        repo.warmUp()             // UninitializedPropertyAccessException
    }
}

Hilt/Dagger делает field‑injection после вызова конструктора, а init{} ― это часть конструктора. В результате промах по времени.

Как чинится:

  • Переходим на constructor injection — передаем всё нужное прямо в параметрах. Тогда объект гарантированно готов.

  • Если поле действительно должно быть var, то перекладываем раннюю логику в @PostConstruct‑подобный метод или в onCreate()/onStart() (при Android).

  • Крайняя страховка — lateinit::repo.isInitialized (но это только для логирования: лучше не плодить if вокруг каждой переменной).

Ошибка 2. lateinit var во ViewModel + recreation Activity

class DashboardViewModel : ViewModel() {
    lateinit var analytics: AnalyticsService

    fun trackScreen() = analytics.hit("dashboard")
}

При повороте экрана система пересоздаёт активити, но ViewModel остаётся. Если сервис инжектится снаружи активити, новый экран получит старую ViewModel, в которой analytics ещё не проставлен.

Как фиксить:

  1. Constructor + Hilt @HiltViewModel — пусть сервис придёт через конструктор.

  2. Или by lazy { AnalyticsService(context) } — создаём при первом вызове, потокобезопасно.

  3. Для редких случаев — SavedStateHandle + восстановление в init{}: не самый красивый, но рабочий обход.

Ошибка 3. Тесты: поле не сетится в @Before

class CsvParserTest {

    private lateinit var parser: CsvParser

    @Before
    fun setup() {
        // забыли parser = CsvParser(...)
    }

    @Test
    fun `should parse three rows`() {
        val rows = parser.parse("sample.csv")      // ?
        assertEquals(3, rows.size)
    }
}

JUnit вызывает метод теста, видит незаполненный parser — ошибка.

Что делать:

  • Ставим @BeforeEach/@Before и инициализирую там.

  • Если объект дорогой — lazy:

    private val parser by lazy { CsvParser(File("sample.csv")) }
  • Либо Delegates.notNull<T>() — полезно тем, что компилятор заставит присвоить хотя бы один раз.

Ошибка 4. lateinit у ViewBinding во Фрагменте → утечка памяти

class ProfileFragment : Fragment() {

    private lateinit var binding: FragmentProfileBinding    // кажется, удобно…

    override fun onCreateView(...) = FragmentProfileBinding.inflate(inflater)
        .also { binding = it }

    // …

    override fun onDestroyView() {
        // binding = null  ← *нельзя*, lateinit!
    }
}

Фрагмент держит ссылку на уже уничтоженное View‑дерево; ликает всё, от Bitmap‑ов до Context‑а.

Как можно сделать:

private var _binding: FragmentProfileBinding? = null
private val binding get() = requireNotNull(_binding)

override fun onCreateView(...) = FragmentProfileBinding.inflate(inflater)
    .also { _binding = it }

override fun onDestroyView() { _binding = null }

Или ставим делегат viewBinding().

Ошибка 5. Гонки корутин: чтение раньше записи

lateinit var session: SessionManager

suspend fun initSession() = withContext(Dispatchers.IO) {
    session = SessionManager(api.login())          // поток 1
}

suspend fun doRequest() = withContext(Dispatchers.Default) {
    session.fetchStuff()                           // поток 2: может
}                                                  // опередить и упасть

Один поток ещё пишет, другой уже читает — null safety бессильна.

Лекарства:

  • Mutex или AtomicRef — жёсткая синхронизация.

  • Или объявить как val session by lazy(LazyThreadSafetyMode.PUBLICATION) { blockingLogin() } — лениво, но потокобезопасно.

  • В Android часто достаточно запустить initSession() в Application.onCreate() синхронно, чтобы гарантировать порядок.

Как жить без ?, но и без lateinit

by lazy { … }

Простой и безопасный. Работает для val, потокобезопасен по дефолту.

val logger by lazy { LoggerFactory.getLogger(javaClass) }

Минус — нельзя «разинициализировать» (некритично в большинстве сервисов).

Null-Object Pattern

Часто сервис имеет «пустую» реализацию.

interface Analytics { fun hit(event: String) }

object NoOpAnalytics : Analytics {
    override fun hit(event: String) { /* no-op */ }
}

// В проде:
val analytics: Analytics = if (BuildConfig.DEBUG) NoOpAnalytics else RealAnalytics()

Плюс — ни ?, ни lateinit, минус — надо писать заглушку, но зато тесты упрощаются.

Делегаты

  • Delegates.notNull<T>() — заставляет присвоить до первого использования, но без полуинициализированных состояний.

    var token by Delegates.notNull<String>()
  • Свой делегат‑очиститель для фрагмента:

    class AutoCleared<T : Any>(val fragment: Fragment) : ReadWriteProperty<Fragment, T> {
        private var value: T? = null
        override fun getValue(thisRef: Fragment, property: KProperty<*>): T =
            value ?: error("Value destroyed")
        override fun setValue(thisRef: Fragment, property: KProperty<*>, value: T) {
            this.value = value
            fragment.viewLifecycleOwnerLiveData.observe(fragment) { owner ->
                owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
                    override fun onDestroy(owner: LifecycleOwner) { this@AutoCleared.value = null }
                })
            }
        }
    }
    
    class ProfileFragment : Fragment() {
        private var binding by AutoCleared<FragmentProfileBinding>(this)
        // …
    }

    После onDestroyView() делегат сам обнуляет ссылку.


Итоги

lateinit в Kotlin — это не костыль, но и не волшебная палочка. Он решает конкретную задачу: отложенную инициализацию без ?, но требует чёткого понимания жизненного цикла, многопоточности и поведения DI‑фреймворков. Безопасная альтернатива почти всегда есть — lazy, делегаты, Null Object, constructor injection.

Используете lateinit в проде? Натыкались на ошибки или нашли устойчивые паттерны? Делитесь опытом в комментариях.

Если вы работаете с Android и хотите углубиться в современные подходы разработки, обратите внимание на серию открытых уроков курса Android Developer. Professional.

21 июля в 20:00 — поговорим о подходах к созданию анимаций, которые делают интерфейс живым и отзывчивым без ущерба для производительности.
Участвовать

5 августа в 20:00 — соберём с нуля небольшое приложение и разберём, как выстраивать его UI и логику с учётом актуальных требований.
Участвовать

12 августа в 20:00 — рассмотрим, как внедрить собственные элементы в реактивную архитектуру приложения.
Участвовать

Также доступно вступительное тестирование, которое поможет вам оценить актуальный уровень владения Android‑инструментами и определить, насколько программа курса вам подходит.

Комментарии (0)