1. Свойство в Kotlin — это маленький API
Если вы только начинаете, очень легко думать так: «var x — это просто переменная, которая живёт внутри объекта». И в бытовом смысле это почти так… пока не выясняется, что чтение и запись свойства — это операции, которые могут быть контролируемыми. Kotlin как раз и сделан так, чтобы вы проектировали объект не как «мешок с данными», а как набор безопасных действий.
Важно поймать идею: когда вы пишете obj.name, вы не «достали поле из памяти» напрямую (по крайней мере, концептуально). Вы вызвали чтение свойства, то есть его getter. Когда вы пишете obj.name = "...", вы вызвали запись свойства, то есть setter. Именно поэтому Kotlin позволяет переопределять поведение чтения/записи: вы пишете маленький «контракт» вокруг свойства.
В документации Kotlin это видно даже «по следам компилятора»: когда у свойства есть логика чтения/записи, по сути появляются функции get() и set(value) (это хорошо видно на примерах генерации кода для делегированных свойств, где явно показаны get()/set(value) как форма аксессоров).
Для ощущения механики — мини-схема:
flowchart LR
A["Код снаружи: obj.x"] --> B["get()"]
C["Код снаружи: obj.x = v"] --> D["set(v)"]
B --> E["возврат значения"]
D --> F["проверки / нормализация / запись"]
2. Управление доступом и представлением свойства
private set: читаем всем, меняем только внутри класса
Иногда вы хотите простую вещь: пусть свойство можно читать откуда угодно, но менять — только внутри объекта. Это супер-популярный паттерн, потому что он отлично поддерживает инкапсуляцию. Например, баланс счёта, количество попыток, внутренний счётчик ID — всё это обычно нельзя «присваивать» снаружи как попало.
private set — это как стеклянная витрина: смотреть можно всем, руками трогать — только персоналу.
Минимальный пример: «баланс можно только пополнять»:
class Wallet {
var balanceCents: Int = 0
private set
fun deposit(amountCents: Int) {
require(amountCents > 0) { "amountCents must be > 0" }
balanceCents += amountCents
}
}
fun main() {
val w = Wallet()
w.deposit(250)
println(w.balanceCents) // 250
}
Обратите внимание на важную штуку: мы не запрещаем чтение balanceCents, но запрещаем прямую запись w.balanceCents = 1_000_000. Теперь баланс меняется только через понятное действие deposit(...), а значит, у вас всегда есть точка контроля (проверки, логирование, ограничения — всё что угодно).
Чем отличается private set от private var:
private var — это «снаружи свойства не существует вообще».
var ... private set — это «снаружи свойство существует, но только для чтения».
Когда вы проектируете API класса, это очень разные ощущения для пользователя. И чаще всего «видеть можно, менять нельзя» — именно то, что нужно.
Computed properties: свойство-витрина над данными
Бывает, что хранить данные нужно в одном формате (например, в центах), а показывать пользователю — в другом (в долларах, с двумя знаками). Или вам нужен красивый текст «как представление», но вы не хотите хранить его отдельно, чтобы не завести второго источника истины (и не начать синхронизировать одно с другим вручную).
Тут и появляется computed property: свойство, которое не хранит значение, а вычисляет его в get().
Кстати, у Kotlin есть даже стилевое правило: свойство предпочтительнее функции, если вычисление дешёвое, не кидает исключения и возвращает один и тот же результат при неизменном состоянии объекта. Это прямо отражает философию computed properties.
Пример: расход в центах, отображение «доллары.центы». Представим, что мы развиваем консольное приложение учёта расходов (мы начали его раньше на коллекциях, а теперь перевели элементы в классы). Сделаем класс Expense, который хранит сумму в центах, а строку для вывода вычисляет.
class Expense(
val id: Int,
amountCents: Int,
note: String
) {
var amountCents: Int = amountCents
private set
var note: String = note
private set
val amountLabel: String
get() = "${amountCents / 100}.${(amountCents % 100).toString().padStart(2, '0')}"
}
Здесь amountLabel — computed property: оно каждый раз честно строится из amountCents. Никаких «а вдруг забыли обновить строку после изменения суммы».
Computed property vs «хранимое поле» — маленькая таблица:
| Подход | Что это | Плюсы | Минусы |
|---|---|---|---|
| Хранимое свойство (с backing field) | |
быстро читать | риск «двух источников истины», если вы храните ещё и производные значения |
| Computed property | |
всегда актуально, не хранит лишнее | вычисляется при каждом чтении (поэтому должно быть разумно дешёвым) |
3. Backing field и field: где лежит значение
Когда у свойства обычный вид var name: String = "...", Kotlin хранит значение «внутри» объекта. В терминах языка это называется backing field (внутреннее поле-хранилище). Когда вы пишете кастомный get() или set(value), вам иногда нужно обратиться именно к этому хранилищу.
Для этого в Kotlin существует специальное слово field.
Если запомнить одно правило из этой части лекции, то вот оно: внутри setter нельзя присваивать свойству его же именем — получите рекурсию. Нужно присваивать field.
Неправильный setter (рекурсия) — классическая ловушка новичка:
class User {
var name: String = "Unknown"
set(value) {
name = value.trim() // ❌ рекурсия: setter вызывает сам себя
}
}
Что произойдёт? Setter вызовет setter, который вызовет setter… и так далее, пока стек вызовов не закончится.
Правильный setter через field:
class User {
var name: String = "Unknown"
set(value) {
field = value.trim() // ✅ записываем во внутреннее хранилище
}
}
fun main() {
val u = User()
u.name = " Alice "
println(u.name) // Alice
}
Вот здесь field — это «реальное значение свойства», а name — это «доступ к свойству через его API».
Где field существует, а где нет:
field существует только если у свойства есть backing field, то есть Kotlin реально хранит значение. У computed property обычно нет хранилища: оно вычисляется «на лету», и поэтому field там просто не нужен.
4. Валидация и нормализация в set: поддерживаем инварианты
Когда мы говорим «инвариант», звучит слегка академично, но смысл простой: объект должен оставаться корректным всегда, а не только «если пользователь класса был достаточно аккуратен и не трогал ничего лишнего».
Setter — отличное место, чтобы:
- нормализовать ввод (например, trim() для строк),
- отбрасывать некорректные значения через require(...),
- поддерживать ограничения диапазона (например, уровень громкости 0..10).
Сеттер — это такой «охранник на входе»: если попытались занести в объект что-то странное — объект говорит «нет» сразу, а не делает вид, что всё хорошо.
Нормализация: заметка не хранит лишние пробелы.
В Expense мы хотим, чтобы заметка хранилась аккуратно. Но при этом мы не хотим писать .trim() по всему коду приложения. Значит, нормализацию надо централизовать.
class Expense(val id: Int, amountCents: Int, note: String) {
var note: String = note
set(value) {
field = value.trim()
}
}
Валидация: нельзя установить отрицательную сумму.
Теперь добавим правило: сумма расхода должна быть больше нуля (или хотя бы неотрицательной — решайте как продуктово правильно). Сеттер — хорошее место для такого правила. Но нам ещё нужно решить: хотим ли мы разрешать менять сумму снаружи вообще.
Сделаем так: снаружи менять нельзя, но внутри класса можно (например, через метод changeAmount(...)).
class Expense(val id: Int, amountCents: Int) {
var amountCents: Int = amountCents
private set(value) {
require(value >= 0) { "amountCents must be >= 0" }
field = value
}
fun changeAmount(newAmountCents: Int) {
amountCents = newAmountCents
}
}
Обратите внимание, как здесь красиво сочетаются идеи: private set закрывает прямую запись снаружи, а setter всё равно существует и защищает инвариант. То есть даже внутри класса вы случайно не сможете присвоить мусор — проверка стоит в одном месте.
5. Пример: учёт расходов с безопасными свойствами
Сейчас будет важный момент «как это применяется в реальном коде», потому что иначе геттеры/сеттеры кажутся магией ради магии. Представим, что наше консольное приложение хранит список расходов в памяти (мы уже умеем MutableList, ввод/валидацию и команды).
Мы хотим, чтобы:
- расход имел id, который нельзя поменять;
- сумма не могла стать отрицательной;
- заметка была нормализована (trim());
- у расхода было удобное computed-свойство для вывода суммы.
Класс Expense (минимально практичный):
class Expense(
val id: Int,
amountCents: Int,
note: String
) {
var amountCents: Int = amountCents
private set(value) {
require(value >= 0) { "amountCents must be >= 0" }
field = value
}
var note: String = note
set(value) {
field = value.trim()
}
val amountLabel: String
get() = "${amountCents / 100}.${(amountCents % 100).toString().padStart(2, '0')}"
fun changeAmount(newAmountCents: Int) {
amountCents = newAmountCents
}
}
Здесь всё коротко, но уже «по-взрослому»: внешний код не может сломать объект случайным присваиванием.
Класс-хранилище ExpenseBook: computed property для суммы всех расходов.
Теперь сделаем объект, который хранит расходы. И добавим computed property totalCents, чтобы не хранить отдельную переменную и не синхронизировать её вручную после каждого добавления/удаления.
class ExpenseBook {
private val expenses: MutableList<Expense> = mutableListOf()
val totalCents: Int
get() = expenses.sumOf { it.amountCents }
fun add(expense: Expense) {
expenses.add(expense)
}
}
totalCents здесь — computed property. Да, он пересчитывается при каждом чтении. Но для учебного приложения это нормально, а главное — логически надёжно: сумма всегда соответствует списку.
Мини-демо в main:
fun main() {
val book = ExpenseBook()
book.add(Expense(id = 1, amountCents = 199, note = " coffee "))
book.add(Expense(id = 2, amountCents = 2500, note = "groceries"))
println(book.totalCents) // 2699
}
6. Типичные ошибки при работе с геттерами и сеттерами
Ошибка №1: рекурсия в setter из-за присваивания «самому себе».
Самая частая история выглядит так: вы пишете кастомный set(value), и по привычке делаете prop = value. Но prop = value — это снова вызов setter. В итоге вы устраиваете вечный двигатель, который работает не на кофеине, а на StackOverflow. Лечится это одним словом: внутри setter используйте field = ...
Ошибка №2: computed property превращают в «дорогую функцию», которую читают сто раз.
Computed property хороша, когда вычисление дешёвое и логически похоже на «свойство-витрину». Если внутри get() вы начинаете делать тяжёлую обработку данных, длинные циклы или сложный парсинг, чтение свойства превращается в неожиданно дорогую операцию. Kotlin-стиль как раз подсказывает: свойство уместно, когда вычисление дешёвое и предсказуемое.
Ошибка №3: валидацию размазывают по коду вместо того, чтобы держать в одном месте.
Новичок часто делает так: в одном месте проверил, в другом забыл, в третьем «ну там точно норм». В итоге объект иногда корректный, иногда нет, а баги живут своей жизнью. Правильнее закреплять инварианты либо в init, либо в set, либо в методах класса, но так, чтобы любое изменение состояния проходило через один и тот же контроль.
Ошибка №4: делают public var, а потом пытаются «договориться» с пользователем класса.
Иногда кажется: «ну я же напишу в комментарии, что нельзя присваивать отрицательное». Это работает примерно как табличка «не нажимать красную кнопку» в фильмах — руку так и тянет. Если значение нельзя менять снаружи, закрывайте запись через private set и предоставляйте методы, которые делают изменение безопасно.
Ошибка №5: путают “скрыть свойство” и “закрыть запись”.
private var скрывает свойство целиком — снаружи его как будто не существует. var ... private set оставляет публичное чтение, но запрещает запись. Новички иногда выбирают private var, а потом удивляются, что им приходится писать отдельный getBalance() и вручную поддерживать API. Обычно проще и чище дать чтение как свойство, а запись закрыть.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ