by lazy или lateinit: что быстрее в Kotlin?

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: вечная дилемма Kotlin-разработчика

Если вы когда-нибудь писали на Kotlin и сталкивались с необходимостью отложить инициализацию свойства, то наверняка задавались вопросом: «А что же выбрать — by lazy или lateinit?» Этот вопрос — настоящий хит на Stack Overflow (тысячи просмотров, десятки обсуждений). И неспроста: от правильного выбора зависит не только читаемость кода, но и производительность приложения.

В этой статье мы не просто перескажем документацию. Мы проведём настоящее расследование: разберём внутреннее устройство обоих механизмов, сравним их скорость, расход памяти и дадим чёткие рекомендации. А в конце — подведём итог с конкретными action items. Поехали!

Что такое lateinit и by lazy? Краткий ликбез

Для начала освежим базовые понятия. Оба подхода решают одну задачу: отложить инициализацию свойства до момента его первого использования. Но делают это по-разному.

lateinit var — обещание компилятору

Ключевое слово lateinit говорит компилятору: «Я обещаю, что инициализирую это свойство до первого обращения, а пока просто выдели под него место». Используется только с var и только для mutable (изменяемых) свойств. Идеально подходит для dependency injection (Dagger, Hilt, Koin) или для свойств, которые инициализируются в onCreate у Activity/Fragment.

class MyActivity : AppCompatActivity() {
    lateinit var repository: UserRepository

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        repository = UserRepository() // инициализация
    }

    fun loadUser() {
        // Если repository не инициализирован — будет UninitializedPropertyAccessException
        repository.getUser()
    }
}

by lazy — ленивый делегат

Делегат lazy — это более гибкий механизм. Он создаёт объект-обёртку, которая при первом обращении вызывает лямбду и запоминает результат. Все последующие обращения просто возвращают сохранённое значение. По умолчанию потокобезопасен (синхронизирован), но можно настроить режим.

class MyViewModel : ViewModel() {
    val heavyData: ExpensiveData by lazy {
        // Этот блок выполнится только один раз, при первом обращении
        ExpensiveData()
    }
}

Оба подхода решают проблему «ленивой инициализации», но делают это с разными компромиссами. Давайте разберём эти компромиссы под микроскопом.

Производительность: кто быстрее?

Здесь мы подходим к самому интересному — к цифрам. Производительность — это не только скорость доступа к свойству, но и накладные расходы на инициализацию, память, потокобезопасность.

Скорость первого доступа

lateinit — абсолютный рекордсмен. При первом обращении компилятор просто проверяет, что поле не null (если оно nullable — выбрасывает исключение). Никаких лямбд, никаких синхронизаций. Это практически бесплатно.

by lazy — медленнее. При первом доступе вызывается синхронизированный блок (если режим по умолчанию LazyThreadSafetyMode.SYNCHRONIZED), выполняется лямбда, результат кэшируется. Это занимает микросекунды, но в высоконагруженных циклах или в критическом коде может быть заметно.

Скорость повторных обращений

После инициализации оба подхода работают одинаково быстро. lateinit просто читает поле (как обычный var). by lazy после первой инициализации также читает кэшированное значение. Разница в скорости на этом этапе стремится к нулю.

Расход памяти

Здесь lateinit выигрывает всухую. Он не создаёт дополнительных объектов. by lazy же создаёт экземпляр класса Lazy (или его разновидность), который хранит ссылку на лямбду и результат. Плюс — внутренняя синхронизация. Для большинства приложений это копейки, но если у вас тысячи ленивых свойств — разница может стать ощутимой.

Потокобезопасность

lateinit — не потокобезопасен. Если два потока одновременно попытаются инициализировать свойство — будет гонка данных (data race). Вы должны гарантировать, что инициализация происходит в одном потоке до того, как другие потоки начнут читать.

by lazy — по умолчанию потокобезопасен. Режим SYNCHRONIZED гарантирует, что лямбда выполнится только один раз, даже при одновременном доступе из нескольких потоков. Можно переключить на PUBLICATION (быстрее, но может выполниться несколько раз в редких случаях) или NONE (максимальная скорость, но без гарантий).

Когда что выбирать? Практические сценарии

Теперь, когда мы знаем теорию, давайте применим её к реальным задачам. Вот несколько типичных случаев из практики Kotlin-разработчиков.

Сценарий 1: Dependency Injection (Dagger / Hilt / Koin)

Здесь однозначный выбор — lateinit. Фреймворки сами инициализируют поля после создания объекта. Использовать by lazy неудобно, так как делегат требует лямбду, а DI-контейнеры работают через рефлексию или кодогенерацию.

// Правильно
@Inject lateinit var apiService: ApiService

// Неправильно (не скомпилируется или будет костыль)
@Inject val apiService: ApiService by lazy { /* ... */ }

Сценарий 2: Тяжёлые вычисления или загрузка данных

Если свойство инициализируется сложным вычислением, чтением файла или сетевым запросом — используйте by lazy. Вы получите ленивую инициализацию с автоматической потокобезопасностью.

val config: AppConfig by lazy {
    // Чтение конфигурационного файла — дорогая операция
    loadConfigFromFile("config.json")
}

Сценарий 3: Свойства, которые инициализируются в жизненном цикле Android

Для Activity, Fragment или ViewModel, где свойство должно быть готово к определённому моменту (например, в onCreate), лучше подходит lateinit. Он не требует дополнительной обёртки и даёт максимальную производительность.

class ProfileFragment : Fragment() {
    private lateinit var adapter: UserAdapter

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedState: Bundle?): View {
        adapter = UserAdapter()
        // ...
    }
}

Блоги

Рекомендаций по книгам