JavaRush /Курсы /Kotlin SELF /Геттеры и сеттеры в Kotlin: private set, computed propert...

Геттеры и сеттеры в Kotlin: private set, computed properties и field

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

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)
var x = ...
быстро читать риск «двух источников истины», если вы храните ещё и производные значения
Computed property
val y get() = ...
всегда актуально, не хранит лишнее вычисляется при каждом чтении (поэтому должно быть разумно дешёвым)

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 — отличное место, чтобы:

  1. нормализовать ввод (например, trim() для строк),
  2. отбрасывать некорректные значения через require(...),
  3. поддерживать ограничения диапазона (например, уровень громкости 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. Обычно проще и чище дать чтение как свойство, а запись закрыть.

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