1. Зачем держать поведение рядом с данными
Когда начинаешь писать программу, очень хочется держать всю логику «снаружи»: вот данные, а вот отдельно функции, которые с ними что-то делают. И это нормально — так проще стартовать. Но по мере роста кода появляется неприятный эффект: правила работы с данными начинают повторяться в разных местах, а изменения становятся похожи на игру «найди все места, где я забыл проверку».
Представьте, что у нас есть трата: название и сумма. Пока вы печатаете её один раз — всё хорошо. Но как только вы начинаете менять название, менять сумму, проверять «крупная ли трата», форматировать вывод — логика размазывается по всему проекту.
Сначала это выглядит примерно так:
fun isLargeExpense(amount: Int): Boolean {
return amount >= 1000
}
fun main() {
val title = "Taxi"
val amount = 1200
println("$title: $amount") // Taxi: 1200
println(isLargeExpense(amount)) // true
}
Работает? Да. Масштабируется? Как дипломный проект на коленке — тоже да, но недолго.
И вот тут появляется идея: если «проверка крупности» — это смысловая часть траты, почему бы не сделать это методом траты?
2. Методы: объявление, вызов и доступ к свойствам
Метод класса: как объявить и как вызвать
Метод — это обычная функция, но живёт внутри класса и вызывается у объекта через точку. В Kotlin это читается очень естественно: expense.isLarge() звучит как фраза, а не как заклинание из древнего фреймворка.
Начнём с самого простого: добавим метод, который красиво печатает трату одной строкой.
class Expense(val title: String, val amount: Int) {
fun asLine(): String {
return "$title: $amount"
}
}
fun main() {
val e = Expense("Coffee", 300)
println(e.asLine()) // Coffee: 300
}
Обратите внимание на два момента. Во-первых, метод объявляется как fun внутри фигурных скобок класса. Во-вторых, вызывается как e.asLine(): объект слева, метод справа. Это важная привычка, потому что дальше почти всё ООП в Kotlin будет «через точку».
Почему методы «видят» свойства объекта бесплатно
Самая приятная часть методов для новичка — это то, что метод уже «держит в руках» объект. То есть ему не нужно передавать отдельно title и amount, потому что они уже внутри объекта. Это выглядит как магия, но на самом деле просто логика: метод вызывается на конкретном объекте, значит он работает с его состоянием.
Сравним два подхода — функция снаружи и метод внутри.
Функция снаружи:
class Expense(val title: String, val amount: Int)
fun asLine(e: Expense): String {
return "${e.title}: ${e.amount}"
}
fun main() {
val e = Expense("Lunch", 900)
println(asLine(e)) // Lunch: 900
}
То же самое, но «рядом с данными»:
class Expense(val title: String, val amount: Int) {
fun asLine(): String = "$title: $amount"
}
fun main() {
val e = Expense("Lunch", 900)
println(e.asLine()) // Lunch: 900
}
Второй вариант обычно читается легче: меньше шума в вызове и меньше риск «не тот объект передал». И ещё важнее — правила форматирования живут в одном месте, а не расползаются по проекту.
3. Состояние объекта: var и «объект во времени»
Состояние — это текущие значения var-свойств объекта. Если у объекта есть изменяемые поля, значит объект может меняться во времени: был такой, стал другой. Это полезно для счётчиков, кошельков, текущих настроек и вообще всего, что «живёт» и меняется.
Сделаем мини-пример: счётчик, который можно увеличивать.
class Counter(var value: Int = 0) {
fun inc() {
value += 1
}
}
fun main() {
val c = Counter()
c.inc()
c.inc()
println(c.value) // 2
}
И очень важная деталь, которая часто ломает мозг в начале: val c = Counter() запрещает переназначить ссылку (то есть нельзя сделать c = Counter()), но не запрещает менять состояние объекта, если внутри есть var.
То есть val не делает объект «замороженным», он делает переменную «непереназначаемой».
4. this: когда он не нужен и когда спасает
Слово this в Kotlin означает «текущий объект». В большинстве случаев Kotlin позволяет его не писать: если вы обращаетесь к title, компилятор понимает, что это this.title.
Но иногда this нужен, чтобы убрать двусмысленность — особенно когда у параметра метода такое же имя, как у свойства. Это частый случай при «переименовании», «обновлении суммы» и подобных операциях.
Вот пример, где this делает намерение однозначным:
class Person(var name: String) {
fun rename(name: String) {
this.name = name.trim()
}
}
fun main() {
val p = Person("Ann")
p.rename(" Anna ")
println(p.name) // Anna
}
Практическое правило простое: пока нет конфликтов имён — можно не писать this. Как только появляется ощущение «что-то тут подозрительно», this помогает явно показать, что вы меняете состояние именно объекта.
5. Инварианты и проверки: держим объект в корректном состоянии
Инвариант — это простое правило корректности состояния. Смысл бытовой: «что должно быть всегда правдой, чтобы объект имел смысл». Например, у траты сумма не должна быть отрицательной, а название не должно быть пустым.
Инварианты лучше проверять там, где происходит изменение, то есть в методах. Тогда у вас появляется «одна точка контроля», и вы не обязаны помнить про проверки в каждом месте программы.
Сделаем Expense изменяемым и дадим ему методы обновления с минимальными проверками:
class Expense(var title: String, var amount: Int) {
fun rename(newTitle: String): Boolean {
val t = newTitle.trim()
if (t.isBlank()) return false
title = t
return true
}
fun changeAmount(newAmount: Int): Boolean {
if (newAmount < 0) return false
amount = newAmount
return true
}
}
Здесь метод возвращает Boolean, и это очень «приземлённый» контракт: true — получилось, false — не получилось, и объект не сломан. Такой подход хорош, когда ошибка — ожидаемая часть жизни (пользователь ввёл ерунду, бывает).
Ранний return и require/check: разные сценарии
Иногда ошибка — это нормальная ситуация, а иногда — логическая катастрофа (в стиле «внутри кошелька отрицательный баланс, хотя мы обещали, что такого не бывает»). Kotlin даёт вам два популярных инструмента: мягкий стиль через return false и жёсткий стиль через require/check.
- require(...) обычно проверяет корректность входных аргументов и при нарушении бросает IllegalArgumentException.
- check(...) обычно проверяет состояние и при нарушении бросает IllegalStateException.
Сравним подходы на примере кошелька.
Мягкий вариант (ожидаем, что пользователь может ошибиться):
class Wallet(var balance: Int) {
fun withdraw(amount: Int): Boolean {
if (amount <= 0) return false
if (amount > balance) return false
balance -= amount
return true
}
}
fun main() {
val w = Wallet(100)
println(w.withdraw(30)) // true
println(w.balance) // 70
println(w.withdraw(200)) // false
}
Жёсткий вариант (считаем, что «сюда нельзя приходить с неправильными данными»):
class Wallet(var balance: Int) {
fun withdraw(amount: Int) {
require(amount > 0) { "Amount must be positive: $amount" }
check(balance >= 0) { "Balance is broken: $balance" }
require(amount <= balance) {
"Not enough money. Balance=$balance, amount=$amount"
}
balance -= amount
}
}
Разница не в синтаксисе, а в философии.
- Возврат false позволяет программе продолжить и спокойно попросить пользователя «ввести ещё раз».
- require/check — это сигнал: «программа в такой ситуации не должна продолжать, это нарушение контракта».
6. Применяем на практике: обновляем Expense через методы
Мы уже можем показать правильную идею на маленьком фрагменте: внешний код не должен знать все правила изменения траты. Внешний код должен попросить объект сделать действие, а объект сам решит, что допустимо.
Вот как может выглядеть обновление названия: мы не делаем expense.title = ... напрямую, а вызываем rename(...).
class Expense(var title: String, var amount: Int) {
fun rename(newTitle: String): Boolean {
val t = newTitle.trim()
if (t.isBlank()) return false
title = t
return true
}
}
fun main() {
val e = Expense("Coffee", 300)
val ok = e.rename(" ")
println(ok) // false
println(e.title) // Coffee
}
Обратите внимание, что в случае ошибки объект остаётся в прежнем корректном состоянии. Это и есть маленькая «победа» инварианта: мы не позволили создать странный объект, с которым потом непонятно что делать.
Если вы хотите обновлять и сумму, то внешний код снова не «знает» правил — он просто вызывает метод:
class Expense(var title: String, var amount: Int) {
fun changeAmount(newAmount: Int): Boolean {
if (newAmount < 0) return false
amount = newAmount
return true
}
}
fun main() {
val e = Expense("Taxi", 1200)
e.changeAmount(1500)
println(e.amount) // 1500
}
Это выглядит как мелочь, но на реальном проекте экономит много нервов: вы не ищете по всему коду «где мы могли присвоить отрицательную сумму», потому что присваивания «снаружи» почти не делаете.
7. Типичные ошибки
Ошибка №1: класс есть, но вся логика всё равно живёт снаружи.
Новичок заводит class Expense(...), но затем продолжает писать десяток функций вида formatExpense(e), isLargeExpense(e), renameExpense(e, ...), validateExpense(e) и т.д. В итоге класс превращается в «мешок полей», а правила остаются размазанными. Если действие естественно относится к конкретному объекту, лучше сделать его методом: так меньше шансов забыть правило в одном из мест.
Ошибка №2: изменяемость (var) добавлена «на всякий случай», а не по смыслу.
Если вы сделаете все свойства var, то любой кусок кода сможет поменять объект как угодно и когда угодно. Потом вы внезапно увидите amount = -100 и начнёте подозревать, что в проект проникла тёмная магия. Начинать лучше с val, а var добавлять только тогда, когда изменение действительно нужно по смыслу сценария.
Ошибка №3: изменения состояния делают напрямую, обходя методы.
Вы написали rename() с проверками, но где-то в коде всё равно осталось expense.title = readln(). В этот момент проверки теряют смысл: часть изменений идёт через «правильный вход», а часть — через «чёрный ход». Если вы решили защищать инварианты методами, старайтесь вызывать методы, а не менять поля напрямую.
Ошибка №4: метод меняет состояние, но не сообщает, получилось ли.
Если метод может «не сработать» (например, новое название пустое), но при этом возвращает Unit и молча ничего не делает, то внешний код не понимает, что произошло. Это порождает странные сценарии: пользователь ввёл пустую строку, программа продолжила как будто всё хорошо, а изменение не применилось. Для ожидаемых ошибок удобен контракт Boolean: вызывающий код сможет показать сообщение и повторить ввод.
Ошибка №5: путаница из-за одинаковых имён и отсутствие this.
Метод rename(name: String) почти наверняка встретится в вашей жизни, и почти наверняка вы один раз напишете name = name.trim() и будете минуту смотреть в монитор, ожидая просветления. В таких местах this — не «лишние буквы», а способ сделать намерение очевидным: this.name = name.trim().
Ошибка №6: require/check используются там, где сценарий должен продолжаться.
Если вы проверяете пользовательский ввод через require(...), то при ошибке ваша программа «упадёт» исключением, потому что require именно так и устроен. Это полезно для защиты контракта, но плохо для дружелюбного CLI, где пользователь просто мог ошибиться. Для ожидаемых ошибок лучше вернуть false/null и попросить ввести заново, а require/check оставлять для ситуаций «так быть не должно».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ