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("обчислюю токен...")
        "T-123"
    }
}

fun main() {
    val p = TokenProvider()

    println("перед першим читанням")
    println(p.token) // обчислюю токен... T-123
    println(p.token) // T-123
}

Зверніть увагу на поведінку: повідомлення "обчислюю токен..." зʼявиться лише один раз, а другий 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 { "Привіт, $name" }
}

fun main() {
    val g = Greeting("Ann")
    println(g.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 не має бути порожнім" }
        require(cents > 0) { "cents має бути > 0" }
        require(category.isNotBlank()) { "category не має бути порожньою" }
    }
}

Тепер зробимо клас, який зберігає список витрат. Тут ми спеціально триматимемо список усередині, а назовні віддаватимемо лише безпечні методи (у дусі інкапсуляції).

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("Застосунок запущено")     // Застосунок запущено
    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 створено")
        MoneyFormatter()
    }

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

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

    fun printPriceExample() {
        println(money.format(350))       // MoneyFormatter створено 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("Формую звіт...") // побічний ефект
        "Звіт 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 залежав або від незмінної конфігурації, або від даних, які ви вважаєте стабільними на момент першого використання.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ