JavaRush /Курсы /Kotlin SELF /by lazy — ленивое вычисление и кеширование результата

by lazy — ленивое вычисление и кеширование результата

Kotlin SELF
32 уровень , 3 лекция
Открыта

1. Введение

Когда вы создаёте объект, Kotlin честно выполняет всё, что нужно для инициализации: конструктор, init, значения свойств. И иногда это прекрасно. Но бывают ситуации, когда значение может вообще не понадобиться — или понадобится только в одном из сценариев, и вы не хотите тратить время на вычисление «на всякий случай». Тут появляется идея ленивой инициализации: «выполним вычисление только при первом обращении».

Самая бытовая аналогия: вы не жарите картошку каждый день «на будущее», если не уверены, что сегодня вообще будете дома. Вы начинаете готовить, когда точно голодны. lazy — это примерно так же, только без запаха на всю квартиру.

В Kotlin для этого есть стандартный механизм делегированных свойств: val x by lazy { ... }. Он вычислит значение один раз, при первом чтении, а затем будет возвращать уже готовый результат из кеша. Именно это поведение и описано в документации: блок выполняется при первом доступе и результат запоминается, последующие чтения возвращают запомненное значение.

2. Синтаксис val x by lazy {...} и смысл by

На первый взгляд синтаксис выглядит слегка магически: почему нельзя просто написать val x = ...? Секрет в том, что lazy — это делегат свойства. То есть чтение свойства «передаётся» специальному объекту, который знает, как вычислить значение и как потом его хранить.

Синтаксис такой:


val name: Type by lazy {
    // вычисление
}

И важные практические правила здесь очень приземлённые.

lazy применяется к val, потому что смысл именно в том, что значение вычислится один раз и дальше «зафиксируется». В документации это описывается как «вычисляется при первом обращении и запоминается».

Если вам хочется «var и тоже lazy», это уже другой дизайн: там нужно либо вычисляемое свойство (get()), либо явный кеш с инвалидированием (это уже более взрослые темы про архитектуру и ответственность).

Блок внутри lazy { ... } — это лямбда, то есть кусочек кода, который можно выполнить позже. Мы уже встречали лямбды раньше, так что сейчас воспринимайте это как «отложенную инструкцию».

Минимальный пример:

class AppInfo {
    val banner: String by lazy { "ExpenseTracker v0.1" }
}

fun main() {
    val info = AppInfo()
    println(info.banner) // ExpenseTracker v0.1
}

Смешно то, что этот пример вообще не показывает смысла lazy (строка и так дешёвая). Но он помогает привыкнуть к синтаксису, прежде чем мы начнём лениво делать что-то реально полезное.

3. Когда выполняется блок lazy и что кешируется

Самый частый вопрос новичка звучит так: «Окей, оно ленивое… но когда именно вычислится?» Ответ очень конкретный: когда вы впервые прочитаете свойство. Не при создании объекта, не при компиляции и не «когда Kotlin решит». А ровно при первом обращении вида obj.x.

Вот короткий демонстрационный пример, где мы специально добавим println, чтобы увидеть момент вычисления:

class TokenProvider {
    val token: String by lazy {
        println("computing token...")
        "T-123"
    }
}

fun main() {
    val p = TokenProvider()

    println("before first read")
    println(p.token) // computing token... T-123
    println(p.token) // T-123
}

Обратите внимание на поведение: сообщение "computing token..." появится только один раз, а второй println(p.token) уже не запускает блок, потому что значение закешировано. Это как «один раз сварили кофе — потом пьём из кружки», только кофе не заканчивается (что уже немного фантастика).

Формально это и есть контракт lazy: первый вызов выполняет лямбду и запоминает результат, последующие возвращают запомненное.

4. lazy, computed property и lateinit: похожи, но решают разное

Очень легко перепутать три подхода, потому что внешне они все про «значение не как обычное поле». Но смысл у них разный, и правильный выбор экономит вам часы отладки.

Сравним в одной таблице:

Подход Как выглядит Когда появляется значение Меняется ли со временем Типичная цель
Обычное хранимое свойство
val x = ... / var x = ...
сразу при создании объекта
val
— нет,
var
— да
простые данные
Computed property
val x get() = ...
каждый раз при чтении зависит от используемых данных «вычисляемое представление» без хранения
lateinit var
lateinit var x: String
позже, через присваивание да, это
var
non-null, но инициализация после создания
val x by lazy { ... }
val x by lazy { ... }
при первом чтении нет (один раз и навсегда) отложить вычисление и закешировать

Теперь покажем короткими примерами, чтобы это стало «ощутимо руками».

Computed property: пересчитывается каждый раз

Здесь мы не кешируем — это не «ленивость», а «динамическое вычисление»:

class CounterView(private var x: Int) {
    val doubled: Int
        get() = x * 2

    fun inc() { x++ }
}

fun main() {
    val v = CounterView(10)
    println(v.doubled) // 20
    v.inc()
    println(v.doubled) // 22
}

lateinit: значение присвоят позже, и это ваша ответственность

class Config {
    lateinit var apiKey: String
}

fun main() {
    val c = Config()
    c.apiKey = "SECRET"
    println(c.apiKey) // SECRET
}

Если вы забудете присвоить — получите ошибку времени выполнения. И это нормально: lateinit — контракт «сначала инициализируй, потом используй».

lazy: вычислится один раз и запомнится

class Greeting(private var name: String) {
    val hello: String by lazy { "Hello, $name" }
}

fun main() {
    val g = Greeting("Ann")
    println(g.hello) // Hello, Ann
}

И вот тут начинается тонкость: если name потом поменять (у нас сейчас name приватный, но представим), hello уже не обновится, потому что lazy кеширует.

5. lazy в приложении ExpenseTracker

Чтобы lazy не остался «фокусом ради фокуса», продолжим развивать единое учебное приложение. Пусть это будет маленький консольный ExpenseTracker: мы храним расходы, умеем добавлять их и печатать простые отчёты.

Начнём с простого класса расхода, где мы защищаем инварианты через require:

class Expense(
    val title: String,
    val cents: Int,
    val category: String
) {
    init {
        require(title.isNotBlank()) { "title must not be blank" }
        require(cents > 0) { "cents must be > 0" }
        require(category.isNotBlank()) { "category must not be blank" }
    }
}

Теперь сделаем класс, который хранит список расходов. Здесь мы специально будем хранить список внутри и давать наружу только безопасные методы (в духе инкапсуляции).

class ExpenseBook {
    private val items: MutableList<Expense> = mutableListOf()

    fun add(expense: Expense) {
        items.add(expense)
    }

    fun size(): Int = items.size
}

Пока скучно, но сейчас появится место для lazy.

Ленивый help-текст для CLI

В консольных приложениях обычно есть команда help, которая печатает «как пользоваться». Это текст, который чаще всего вообще никто не читает… но когда читают — он должен быть аккуратным. И вот это хороший кандидат для lazy: не собираем многострочный текст заранее, если пользователь его не запросил.

class ConsoleHelp {
    val text: String by lazy {
        """
        Команды:
          add <title> <cents> <category>  — добавить расход
          list                           — показать расходы
          help                           — помощь
          exit                           — выход
        """.trimIndent()
    }
}

fun main() {
    val help = ConsoleHelp()
    println("App started")     // App started
    println(help.text)         // (помощь печатается здесь)
}

Если пользователь ни разу не вводил help, вы вообще не «платите» за сборку строки. Да, это микрооптимизация, но она отлично демонстрирует идею: lazy управляет моментом вычисления.

Ленивый форматтер денег

Чуть более «взрослый» пример: форматирование денег. Мы храним суммы в центах (cents), а показывать хотим красиво: "12.05".

Сделаем отдельный класс форматирования:

class MoneyFormatter {
    fun format(cents: Int): String {
        val dollars = cents / 100
        val rest = (cents % 100).toString().padStart(2, '0')
        return "$dollars.$rest"
    }
}

Теперь допустим, что форматтер создаётся «не всегда нужен». Например, вы хотите печатать отчёт только в команде list. Тогда можно держать его как lazy внутри класса приложения:

class ExpenseApp {
    private val book = ExpenseBook()

    private val money: MoneyFormatter by lazy {
        println("MoneyFormatter created")
        MoneyFormatter()
    }

    fun addTestData() {
        book.add(Expense("Coffee", 350, "food"))
    }

    fun printCount() {
        println("items: ${book.size()}") // items: 1
    }

    fun printPriceExample() {
        println(money.format(350))       // MoneyFormatter created 3.50
    }
}

Здесь вы увидите, что MoneyFormatter создаётся только тогда, когда вы впервые вызвали printPriceExample() и реально обратились к money.

6. Главная ловушка lazy: кеш устаревает при изменениях

Вот где lazy перестаёт быть «милой фичей» и превращается в штуку, которой нужно пользоваться осознанно.

Если ленивое значение зависит от изменяемого состояния, вы рискуете получить «красиво закешированную неправду». И она будет стабильной, быстрой и уверенной в себе — как человек, который выучил неправильный ответ и теперь не сомневается.

Плохой пример: мы хотим «итого по расходам», но список расходов пополняется. Если мы закешируем сумму через lazy, она станет верной только для того момента, когда к ней впервые обратились.

class ExpenseBookBad {
    private val items: MutableList<Expense> = mutableListOf()

    val totalCents: Int by lazy {
        items.sumOf { it.cents }
    }

    fun add(expense: Expense) {
        items.add(expense)
    }
}

Сценарий проблемы:

fun main() {
    val b = ExpenseBookBad()
    b.add(Expense("Coffee", 350, "food"))

    println(b.totalCents) // 350

    b.add(Expense("Sandwich", 650, "food"))
    println(b.totalCents) // всё ещё 350 (и это уже неправда)
}

lazy честно делает то, что обещал: «вычислю один раз и запомню». Просто вы выбрали не тот инструмент.

Что делать правильно? Если значение должно отражать текущее состояние, лучше сделать computed property:

class ExpenseBook {
    private val items: MutableList<Expense> = mutableListOf()

    val totalCents: Int
        get() = items.sumOf { it.cents }

    fun add(expense: Expense) {
        items.add(expense)
    }
}

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

Практическое правило звучит так: lazy хорошо подходит для значений, которые либо не зависят от меняющегося состояния, либо зависят от того, что после инициализации уже не меняется (конфигурация, шаблоны текста, подготовленные «тяжёлые» объекты, которые не нужно пересоздавать).

7. Нюансы lazy: побочные эффекты и потокобезопасность

Чтение свойства (в идеальном мире) воспринимается как «безопасная операция», которая не меняет состояние и не делает сюрпризов. lazy же прячет вычисление внутрь чтения свойства — и это удобно, но требует дисциплины.

Побочные эффекты: почему «тихий геттер» — хорошая привычка

Если внутри lazy вы делаете println, сетевые запросы, запись в файл или «а давайте создадим 10000 объектов» — чтение свойства превращается в мини-квест. Иногда это допустимо (например, отладочный println в учебном коде), но в реальном проекте такие вещи лучше держать под контролем.

Посмотрите на этот пример:

class ReportBuilder {
    val report: String by lazy {
        println("Building report...") // побочный эффект
        "Report v1"
    }
}

Если кто-то просто логирует объект и случайно обращается к report, у вас внезапно начнёт строиться отчёт «сам по себе». Поэтому хорошая привычка такая: блок lazy должен быть максимально «чистым» — вычислить и вернуть значение, без сюрпризов. А если там всё-таки есть побочные эффекты, то хотя бы чтобы это было ожидаемо по смыслу (например, инициализация тяжёлого кэша).

Потокобезопасность: что важно знать без погружения в детали

Иногда спрашивают: «А lazy потокобезопасен?» Если очень коротко: по умолчанию lazy устроен так, что вычисление будет выполнено один раз в многопоточной среде (то есть предусмотрена синхронизация). В документации это сформулировано как поведение по умолчанию и описаны режимы, которые можно выбрать параметром LazyThreadSafetyMode.

Но важный практический момент для нас сейчас такой: в типичном учебном консольном приложении, которое работает в одном потоке, вам почти всегда достаточно «обычного lazy по умолчанию». Главное — понимать контракт «один раз при первом чтении».

Ещё один нюанс: если блок lazy выбросит исключение, то значение не будет успешно закешировано, и при следующей попытке чтения свойство снова попробует вычислиться (потому что «успешного результата» не было). Это полезно помнить, если вы внутри lazy делаете require(...) или что-то, что может падать: падение произойдёт не при создании объекта, а при первом чтении свойства, то есть позже, и иногда это неожиданно.

8. Типичные ошибки при работе с by lazy

Ошибка №1: пытаться сделать var с lazy.
Иногда хочется написать var x by lazy {...}, потому что «ну я же хочу лениво». Но смысл lazy именно в том, что значение вычисляется один раз и фиксируется. Если вам нужно изменяемое значение, это уже не задача lazy: чаще всего вам подойдёт computed property (get() = ...) или обычное var с явным управлением обновлением.

Ошибка №2: кешировать через lazy то, что зависит от меняющихся данных.
Классическая ловушка — закешировать сумму, статус или отчёт, которые должны меняться при добавлении элементов в список. lazy выполнится один раз, а дальше будет возвращать старый результат. Если значение должно быть актуальным всегда, используйте computed property или пересчитывайте в методах изменения состояния.

Ошибка №3: прятать тяжёлую и/или «шумную» логику внутрь чтения свойства.
Когда lazy внутри делает много работы, чтение свойства внезапно становится «операцией с побочными эффектами». Это усложняет понимание кода: разработчик читает obj.x и не ожидает, что там полсекунды вычислений. Лучше держать блок lazy небольшим, а сложную часть выносить в функцию, которую вы вызываете внутри lazy (так вы хотя бы видите имя операции).

Ошибка №4: ожидать, что lazy «ускоряет программу всегда».
lazy не является универсальной оптимизацией. Он помогает отложить вычисление до момента, когда оно реально нужно, и не повторять его. Но если значение нужно всегда и сразу, lazy может даже добавить небольшой накладной расход. Используйте его как инструмент дизайна и контроля момента вычисления, а не как «волшебную кнопку производительности».

Ошибка №5: незаметно завязать lazy на внешнюю мутабельность.
Если lazy использует переменные, которые могут измениться до первого обращения, результат будет зависеть от «когда именно впервые прочитали». Это иногда нормальная логика, но чаще — источник странных багов. Хороший стиль: чтобы lazy зависел либо от неизменяемой конфигурации, либо от данных, которые вы считаете стабильными к моменту первого использования.

1
Задача
Kotlin SELF, 32 уровень, 3 лекция
Недоступна
Ленивое приветствие
Ленивое приветствие
1
Задача
Kotlin SELF, 32 уровень, 3 лекция
Недоступна
Команда помощи
Команда помощи
1
Задача
Kotlin SELF, 32 уровень, 3 лекция
Недоступна
Идентификатор сессии
Идентификатор сессии
1
Задача
Kotlin SELF, 32 уровень, 3 лекция
Недоступна
Актуальная корзина
Актуальная корзина
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ