JavaRush /Курсы /Kotlin SELF /Делегаты свойств: Delegates.observable как реакция на при...

Делегаты свойств: Delegates.observable как реакция на присваивание

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

1. Введение

Когда вы впервые видите Delegates.observable, возникает логичный вопрос: «А зачем мне какая-то магия с by, если я могу написать set(value) и там всё проверить/залогировать?» Вопрос честный. Ответ тоже честный: observable полезен, когда вам нужно централизованно реагировать на присваивания и не размазывать одинаковый код по нескольким методам или сеттерам — например, для логирования, подсчёта изменений, выставления флага «объект изменён».

Важно сразу зафиксировать рамки: это обзорная лекция. Мы не будем писать свои делегаты и не будем углубляться в продвинутые темы: как именно устроены getValue/setValue, рефлексия и тонкости многопоточности. Мы научимся уверенно пользоваться Delegates.observable как готовым инструментом стандартной библиотеки.

Что означает by в свойствах

Синтаксис делегированного свойства выглядит так: val/var имя: Тип by <выражение>. Читается это примерно как: «чтение и (если var) запись этого свойства обслуживает другой объект — делегат». В документации Kotlin это объясняется так: аксессоры свойства «переадресуются» методам делегата (getValue() и setValue()).

Если вспоминать предыдущую лекцию, by lazy { ... } — это тоже делегат (просто самый популярный, поэтому кажется «особенным»). Delegates.observable — делегат другого типа: он хранит значение и вызывает обработчик после каждого присваивания.

Чтобы не было ощущения, что мы «внезапно» прыгаем в новый синтаксис, вот маленький ориентир:

Механизм Что решает Когда выполняется код Есть ли кеш
set(value)
Контроль записи (валидация/нормализация) Каждый раз при присваивании Зависит от вас
computed property (
get() = ...
)
Вычисление представления Каждый раз при чтении Нет
by lazy { ... }
Ленивое вычисление Первый доступ к чтению Да (встроенный)
Delegates.observable(...)
Реакция на изменения Каждый раз при присваивании (после записи) Да (делегат хранит текущее значение)

2. Как работает Delegates.observable

Базовый контракт и когда срабатывает обработчик

Delegates.observable принимает стартовое значение и обработчик изменений. Обработчик вызывается каждый раз, когда вы присваиваете значение свойству, причём важный нюанс: он вызывается после того, как присваивание уже произошло.

У обработчика три параметра: «описание свойства», старое значение и новое значение. В коде это обычно выглядит так:

import kotlin.properties.Delegates

class User {
    var name: String by Delegates.observable("<no name>") { _, old, new ->
        println("$old -> $new") // <no name> -> Alice, затем Alice -> Bob
    }
}

fun main() {
    val u = User()
    u.name = "Alice"
    u.name = "Bob"
}

Заметьте: первый параметр обработчика мы заменили на _. Это нормальная практика — если параметр не нужен, не мешаем себе читать код.

Два важных следствия из «после присваивания».

Во-первых, внутри обработчика вы можете читать само свойство и получите уже новое значение (оно уже записано). Это удобно, но иногда сбивает с толку, если вы ожидали «сначала уведомление, потом запись».

Во-вторых, observable — это не место, где вы «останавливаете» неправильное значение. Если нужно блокировать присваивание, это уже другой инструмент (и мы его сегодня не разбираем, чтобы не расползаться по темам).

Первый параметр обработчика и зачем он иногда нужен

Первый параметр обработчика — это объект, который описывает свойство (по сути: «метаданные о property»). Документация показывает, что этот параметр приходит в обработчик вместе с old/new.

Мы пока не изучали рефлексию глубоко (и не надо), но одну практичную вещь можно взять: у свойства есть имя. Иногда это удобно для отладочного лога, особенно если у вас несколько observable-свойств и вы хотите общий стиль сообщений.

Сделаем кошелёк чуть разговорчивее:

import kotlin.properties.Delegates

class Wallet {
    var balanceCents: Int by Delegates.observable(0) { prop, old, new ->
        println("${prop.name}: $old -> $new") // balanceCents: 0 -> 100
    }
        private set

    fun deposit(cents: Int) {
        require(cents > 0) { "deposit must be > 0" }
        balanceCents += cents
    }
}

Да, это выглядит как «чуть-чуть магии». Но на практике вы воспринимаете prop.name как аккуратный бонус: хотите — используете, не хотите — ставите _.

Небольшой взгляд под капот: почему есть prop$delegate

Мини‑обзор для понимания, без погружения в написание своих делегатов. Kotlin при делегировании обычно генерирует скрытое поле, которое хранит делегат, и аксессоры свойства просто вызывают методы делегата. В документации это показывают на примере, где появляется поле вида prop$delegate.

Если очень упрощённо, можно представить так:

obj.prop = value
   ↓
делегат.setValue(obj, описание_свойства, value)
   ↓
делегат записывает новое значение
   ↓
делегат вызывает обработчик observable (old/new)

Именно поэтому observable выглядит «как будто встроено в язык»: на самом деле это просто готовый делегат стандартной библиотеки, а синтаксис by — языковая поддержка делегирования.

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

3. Практика: где observable помогает, а где мешает

Мини‑трекер бюджета: логируем изменение баланса

Чтобы примеры были не «в вакууме», продолжим условное приложение: простой трекер бюджета (кошелёк), где есть баланс и операции пополнения/списания. Мы уже умеем инкапсулировать состояние: баланс нельзя менять напрямую, только через методы, которые держат инварианты.

Сделаем класс Wallet и добавим реакцию на изменение баланса: будем печатать в консоль лог изменения. Это типичный сценарий observable: наблюдение за присваиванием, без попытки заменить бизнес-логику.

import kotlin.properties.Delegates

class Wallet {
    var balanceCents: Int by Delegates.observable(0) { _, old, new ->
        println("balance: $old -> $new") // например: balance: 0 -> 500
    }
        private set

    fun deposit(cents: Int) {
        require(cents > 0) { "deposit must be > 0" }
        balanceCents += cents
    }
}

И проверим в main:

fun main() {
    val w = Wallet()
    w.deposit(500) // balance: 0 -> 500
    w.deposit(250) // balance: 500 -> 750
}

Здесь происходят сразу две полезные вещи. Во-первых, мы сохранили правильный дизайн: снаружи баланс менять нельзя (private set). Во-вторых, логика логирования «приклеилась» к самому свойству и не дублируется в каждом методе.

observable и инкапсуляция: где должны жить правила и проверки

Самая частая ошибка новичка — увидеть observable, обрадоваться и начать пихать туда всю бизнес-логику: «если новое значение плохое, откатим», «если баланс ушёл в минус — исправим» и так далее. Проблема в том, что observable — это реакция после присваивания. То есть вы уже записали значение, и только потом начали «что-то исправлять».

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

Например, сделаем методы deposit и withdraw и запретим уходить в минус. А в observable будем только отмечать, что состояние менялось.

import kotlin.properties.Delegates

class Wallet {
    var isDirty: Boolean = false
        private set

    var balanceCents: Int by Delegates.observable(0) { _, old, new ->
        if (old != new) isDirty = true
    }
        private set

    fun withdraw(cents: Int) {
        require(cents > 0) { "withdraw must be > 0" }
        require(balanceCents >= cents) { "not enough money" }
        balanceCents -= cents
    }
}

И небольшой запуск:

fun main() {
    val w = Wallet()
    println(w.isDirty) // false
    w.withdraw(0)      // IllegalArgumentException (withdraw must be > 0)
}

Здесь важная идея: мы не «лечим последствия», мы их предотвращаем. observable просто фиксирует факт изменения, а не «рулит» корректностью.

observable vs обычный сеттер: что выбрать

observable — не замена сеттерам. Это другой инструмент.

Если ваша задача — нормализовать строку (trim, приведение регистра), проверять диапазон, поддерживать инвариант, то сеттер (или метод) обычно понятнее и прямолинейнее. Если ваша задача — реагировать на изменения (логировать, считать изменения, уведомлять), observable часто делает код компактнее.

Представим, что в нашем приложении есть «уровень громкости уведомлений», и мы хотим: хранить значение от 0 до 10, а при изменении печатать лог. Сами правила пусть живут в методе, а лог — в observable.

import kotlin.properties.Delegates

class NotificationSettings {
    var volume: Int by Delegates.observable(5) { _, old, new ->
        println("volume: $old -> $new")
    }
        private set

    fun setVolume(x: Int) {
        require(x in 0..10) { "volume must be in 0..10" }
        volume = x
    }
}

Теперь «контракт класса» читается приятно: setVolume — единственная дверь для изменения, volume можно только читать, а логирование централизовано.

observable в реальной жизни: счётчик изменений

В крупных проектах observable часто ассоциируют с UI: «когда изменилось поле — обнови экран». Но даже в консольном приложении он может быть удобен.

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

С observable можно привязать счётчик к факту изменения свойства:

import kotlin.properties.Delegates

class Wallet {
    var changeCount: Int = 0
        private set

    var balanceCents: Int by Delegates.observable(0) { _, old, new ->
        if (old != new) changeCount++
    }
        private set

    fun deposit(cents: Int) {
        require(cents > 0) { "deposit must be > 0" }
        balanceCents += cents
    }
}

Проверка:

fun main() {
    val w = Wallet()
    w.deposit(100)
    w.deposit(100)
    println(w.changeCount) // 2
}

Да, это всё ещё побочный эффект. Но он локализован и понятен: «каждое изменение баланса увеличивает счётчик».

4. Типичные ошибки при работе с Delegates.observable

Ошибка №1: размещать бизнес-логику и правила корректности в обработчике observable.
Очень легко начать делать в обработчике всё подряд: проверять диапазоны, откатывать значения, «чинить» объект. Проблема в том, что обработчик вызывается после присваивания, а значит вы работаете уже с изменённым состоянием. Гораздо надёжнее держать инварианты в методах (deposit/withdraw) или в сеттере с require(...), а в observable оставить логирование и простые реакции.

Ошибка №2: случайно устроить рекурсию — присваивать наблюдаемое свойство внутри обработчика.
Если в обработчике вы сделаете что-то вроде balanceCents = ..., вы снова вызовете присваивание, снова обработчик — и так до StackOverflowError или до того момента, когда вы начнёте сомневаться в реальности происходящего. Если нужно «исправлять» значение, чаще всего это знак, что вам нужен другой дизайн (валидировать до присваивания), а не попытка чинить уже записанное.

Ошибка №3: забыть, что обработчик вызывается даже при присваивании того же значения.
observable реагирует на факт присваивания. Если вы сделали x = x, обработчик тоже может вызваться. Поэтому, если вы считаете изменения или ставите флаг isDirty, почти всегда полезно сравнивать old и new и реагировать только при реальном изменении: if (old != new) ...

Ошибка №4: превращать observable в скрытый логгер, который шумит всегда.
Отладочные println в обработчике — это удобно в учебных примерах, но в реальном проекте такой лог быстро превращается в шум. Даже в учебном приложении лучше постепенно приучаться: логирование либо делать выключаемым (хотя бы через флаг), либо печатать только важные вещи, либо ограничивать вывод.

Ошибка №5: ожидать, что observable заменяет инкапсуляцию.
Иногда кажется: «ну раз я наблюдаю изменения, то можно сделать public var и пусть кто угодно меняет, а я в обработчике проконтролирую». Это скользкая дорожка. Наблюдение не заменяет ограничение доступа. Если свойство нельзя менять снаружи — делайте private set и меняйте через методы. А observable пусть будет вашим аккуратным «датчиком», а не единственным охранником на входе.

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