1. Введение
Когда вы впервые видите Delegates.observable, возникает логичный вопрос: «А зачем мне какая-то магия с by, если я могу написать set(value) и там всё проверить/залогировать?» Вопрос честный. Ответ тоже честный: observable полезен, когда вам нужно централизованно реагировать на присваивания и не размазывать одинаковый код по нескольким методам или сеттерам — например, для логирования, подсчёта изменений, выставления флага «объект изменён».
Важно сразу зафиксировать рамки: это обзорная лекция. Мы не будем писать свои делегаты и не будем углубляться в продвинутые темы: как именно устроены getValue/setValue, рефлексия и тонкости многопоточности. Мы научимся уверенно пользоваться Delegates.observable как готовым инструментом стандартной библиотеки.
Что означает by в свойствах
Синтаксис делегированного свойства выглядит так: val/var имя: Тип by <выражение>. Читается это примерно как: «чтение и (если var) запись этого свойства обслуживает другой объект — делегат». В документации Kotlin это объясняется так: аксессоры свойства «переадресуются» методам делегата (getValue() и setValue()).
Если вспоминать предыдущую лекцию, by lazy { ... } — это тоже делегат (просто самый популярный, поэтому кажется «особенным»). Delegates.observable — делегат другого типа: он хранит значение и вызывает обработчик после каждого присваивания.
Чтобы не было ощущения, что мы «внезапно» прыгаем в новый синтаксис, вот маленький ориентир:
| Механизм | Что решает | Когда выполняется код | Есть ли кеш |
|---|---|---|---|
|
Контроль записи (валидация/нормализация) | Каждый раз при присваивании | Зависит от вас |
computed property () |
Вычисление представления | Каждый раз при чтении | Нет |
|
Ленивое вычисление | Первый доступ к чтению | Да (встроенный) |
|
Реакция на изменения | Каждый раз при присваивании (после записи) | Да (делегат хранит текущее значение) |
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 пусть будет вашим аккуратным «датчиком», а не единственным охранником на входе.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ