1. Fail-fast и разница между require и check
Зачем вообще нужно «падать раньше»: идея fail-fast
Если вы только начинаете, может казаться, что хорошая программа должна быть вежливой и терпеливой: «ну пользователь ввёл ерунду — давайте тихо подставим 0, пустую строку и как-нибудь дальше». На практике такая «доброта» часто превращается в накопление ошибок: программа продолжает работать, но данные становятся мусорными, и в итоге ломается всё… только гораздо позже и гораздо непонятнее.
Подход fail-fast (буквально «упади быстро») говорит: если дальше логика не имеет смысла, лучше остановиться сразу и объяснить, что именно было не так. Kotlin прямо поддерживает этот стиль стандартными функциями-предусловиями: они автоматически выбрасывают исключения с понятным типом.
Чтобы картинка сложилась, держите маленькую схему (это именно «про смысл», не про синтаксис):
flowchart TD
A[Получили данные] --> B{Данные пригодны?}
B -- Нет --> C[Fail-fast: require/check -> исключение]
B -- Да --> D[Продолжаем логику]
D --> E{Состояние корректно?}
E -- Нет --> F[Fail-fast: check -> исключение]
E -- Да --> G[Результат предсказуем]
Самый главный бонус fail-fast в учебном проекте: вы перестаёте гадать «почему оно странно считает», и начинаете получать ответы уровня «Age must be >= 0, got -5». Удивительно, насколько это снижает желание швырнуть ноутбук в окно (я проверял, окно ни в чём не виновато).
require vs check: одно похоже, но смысл разный
Снаружи require(condition) и check(condition) выглядят почти одинаково: вы передаёте условие и (обычно) сообщение. Но смысл у них разный — и это важно, потому что смысл превращается в тип исключения, а тип исключения — это подсказка для вас и для того, кто будет читать код после вас.
require() используют для проверки корректности входных аргументов, а check() — для проверки корректности состояния объекта или переменной. При нарушении require() вылетает IllegalArgumentException, а при нарушении check() — IllegalStateException.
Сведём в таблицу (её реально стоит держать в голове):
| Вопрос, который вы задаёте | Что использовать | Типичная мысль | Исключение |
|---|---|---|---|
| «Мне передали плохие данные, я не могу продолжать» | |
«Аргумент неправильный» | |
| «Данные-то нормальные, но объект сейчас в невозможном/запрещённом состоянии» | |
«Состояние неправильное» | |
Очень грубая, но запоминающаяся аналогия: require — это фейс-контроль на входе в клуб («в кедах нельзя»), а check — охрана внутри («в бассейн нельзя с тостером»). И если вам кажется, что «и там и там нельзя», вы правы. Но причины «нельзя» разные.
2. require: защищаем вход
Когда вы создаёте объект, вы хотите, чтобы он появлялся сразу корректным. Это прям ключевая идея ООП на уровне новичка: если объект существует, ему можно доверять. В Kotlin типичный способ сделать это — поставить проверки в init { ... } или прямо в конструкторной логике.
Минимальный пример require в init
Начнём с чего-то максимально простого, чтобы рука привыкла:
class Age(val value: Int) {
init {
require(value >= 0) { "Age must be >= 0, got $value" }
}
}
fun main() {
println(Age(10).value) // 10
}
Здесь мы зафиксировали правило: возраст не может быть отрицательным. Важно, что мы не «чинить» пытаемся, а запрещаем создание некорректного объекта. И это нормально: если вам прилетело -10, это не «почти возраст», это ошибка входа.
Пример из учебного проекта: трекер расходов
Продолжим практический пример: консольный трекер расходов. Раньше мы могли хранить расход как набор полей, а теперь хотим, чтобы объект Expense не мог существовать в мусорном виде.
Сделаем небольшой класс:
class Expense(
val amountCents: Int,
val category: String,
val note: String
) {
init {
require(amountCents > 0) { "amountCents must be > 0, got $amountCents" }
require(category.isNotBlank()) { "category must not be blank" }
require(note.length <= 100) { "note must be <= 100 chars, got ${note.length}" }
}
}
Обратите внимание на стиль: сообщение должно объяснять, что ожидали и что получили. Это не эстетика — это скорость отладки.
Теперь создание:
fun main() {
val e = Expense(amountCents = 1999, category = "Food", note = "Pizza")
println("${e.category}: ${e.amountCents} cents") // Food: 1999 cents
}
Если кто-то попытается создать Expense(amountCents = 0, ...), объект не создастся — и это правильно: нулевой расход чаще всего означает, что мы где-то не распарсили число или перепутали валюту.
require подходит и для обычных методов
Предусловия нужны не только при создании объекта. Если у вас есть метод, который принимает аргументы извне, вы тоже можете защищать вход require-ом: это как раз про аргументы, без которых функция не может работать.
Представим, что у нас есть сервисный метод форматирования суммы:
fun formatCents(amountCents: Int): String {
require(amountCents >= 0) { "amountCents must be >= 0, got $amountCents" }
val dollars = amountCents / 100
val cents = amountCents % 100
return "$dollars.${cents.toString().padStart(2, '0')}"
}
fun main() {
println(formatCents(1999)) // 19.99
}
Да, можно было бы «молча» превратить -5 в 0. Но тогда вы потеряете сигнал о том, что где-то у вас ошибка.
3. check: защищаем состояние объекта
Теперь перейдём к check. Он нужен тогда, когда входные данные нормальные, но сам объект сейчас не должен выполнять операцию. Это часто встречается в объектах с «режимом работы»: сессия может быть закрыта, файл — уже сохранён и «sealed», отчёт — уже финализирован.
check() — это про проверку корректности состояния объекта или переменной, и при провале он выбрасывает IllegalStateException.
Пример: «закрытый» трекер расходов
Добавим в наш проект класс, который хранит список расходов. Мы ещё не обсуждали коллекции объектов глубоко, поэтому сделаем максимально просто: список плюс флаг closed.
class ExpenseTracker {
private val expenses: MutableList<Expense> = mutableListOf()
private var closed: Boolean = false
fun close() {
check(!closed) { "Tracker already closed" }
closed = true
}
fun add(expense: Expense) {
check(!closed) { "Can't add expense: tracker is closed" }
expenses.add(expense)
}
fun totalCents(): Int {
var sum = 0
for (e in expenses) sum += e.amountCents
return sum
}
}
Смотрите, что мы сделали: вход в add(expense) как таковой корректный (объект Expense уже гарантированно валиден). Но добавлять его нельзя, если трекер закрыт. Это и есть проверка состояния.
Как это выглядит при использовании
fun main() {
val tracker = ExpenseTracker()
tracker.add(Expense(500, "Coffee", "Latte"))
tracker.close()
println(tracker.totalCents()) // 500
// tracker.add(Expense(100, "Snack", "Bar"))
// Если раскомментировать: IllegalStateException из check(...)
}
Почему это хорошо: ошибка сообщает вам, что проблема не в расходе и не в «вводе», а в жизненном цикле объекта. Такой баг обычно лечится не «подставь другое число», а «не вызывай метод в этом состоянии».
4. Сообщения и обработка исключений
Сообщения в require/check: как сделать исключение полезным
Когда начинающие ставят require(x > 0) без сообщения, они рассчитывают, что «и так понятно». Но через две недели (или через два часа, если день был тяжёлый) вы увидите в консоли что-то вроде «Failed requirement.» и будете вспоминать, какой именно requirement и где именно он failed.
Kotlin позволяет писать сообщение как строку или как лямбду { ... }. Лямбда удобна тем, что вычисляется только если проверка провалилась (то есть в нормальном случае не тратит ресурсы).
Хорошее сообщение — это почти всегда формат: ожидали → получили.
fun parseAmountCents(text: String): Int {
val trimmed = text.trim()
val value = trimmed.toIntOrNull()
require(value != null) { "Amount must be an integer, got '$text'" }
require(value > 0) { "Amount must be > 0, got $value" }
return value
}
fun main() {
println(parseAmountCents(" 150 ")) // 150
}
Тут две разные ошибки: не число и число, но плохое. И сообщения разные — это экономит вам нервы при отладке.
А для check сообщение должно объяснять «почему состояние запрещено»:
class Session {
private var started: Boolean = false
fun start() {
check(!started) { "Session already started" }
started = true
}
}
Это почти идеальный минимальный пример: аргументов нет, а операция запрещена из-за состояния.
Как аккуратно подружить require/check с консольным main
Fail-fast хорош внутри вашей модели, но консольное приложение не обязано падать «красивым стектрейсом» от первой ошибки пользователя. Поэтому часто делают так: внутри классов — require/check, а на уровне main — аккуратный перехват и понятное сообщение человеку.
Это не противоречие. Это разделение обязанностей: модель говорит «так нельзя», а CLI решает «как объяснить это пользователю».
Соберём маленький пример: пользователь вводит сумму, категорию, заметку, а мы добавляем расход.
fun main() {
val tracker = ExpenseTracker()
try {
print("Amount (cents): ")
val amount = parseAmountCents(readln())
print("Category: ")
val category = readln()
print("Note: ")
val note = readln()
tracker.add(Expense(amount, category, note))
println("Added!") // Added!
println("Total = ${tracker.totalCents()} cents") // Total = ... cents
} catch (e: IllegalArgumentException) {
println("Input error: ${e.message}")
} catch (e: IllegalStateException) {
println("App state error: ${e.message}")
}
}
Здесь видно пользу разных исключений: IllegalArgumentException мы трактуем как проблему ввода, а IllegalStateException — как проблему порядка действий. Это ровно то смысловое разделение, ради которого require и check вообще существуют.
5. Типичные ошибки при использовании require/check
Ошибка №1: использовать check для пользовательского ввода.
Такое часто происходит по привычке: «ну я же проверяю условие — значит check». Но смысл у check другой: он про ситуацию «мы сами довели объект до невозможного состояния». Если пользователь ввёл -5, это не «состояние объекта плохое», это «аргумент плохой», то есть require. Kotlin как раз разделяет эти случаи и даже выбрасывает разные исключения: require даёт IllegalArgumentException, а check — IllegalStateException.
Ошибка №2: делать сообщения бесполезными («bad input», «error», «oops»).
Когда вы пишете сообщение в стиле «wrong value», вы выигрываете ровно ноль секунд. Сообщение должно быть диагностикой: что ожидалось и что пришло. Идеальный формат: «X must be …, got …». Особенно полезно добавлять фактическое значение и длину строки, если проверяете текст.
Ошибка №3: ставить проверки слишком поздно, после изменения состояния.
Например, метод успел добавить расход в список, потом проверил, что трекер закрыт, и упал. В итоге упал, но данные уже частично изменены. Поэтому проверки require/check почти всегда ставят в начале метода — до любых изменений. Это делает код предсказуемым: либо операция полностью выполнена, либо не начата.
Ошибка №4: пытаться «починить всё» нормализацией, где нужна жёсткая валидация.
Иногда хочется заменить require(amount > 0) на «если меньше нуля — сделаем ноль». Но это превращает ошибки в тихие баги. Нормализация полезна для вещей вроде trim() и приведения регистра, но для ключевых чисел и идентификаторов чаще безопаснее fail-fast: не создавать объект, если он по смыслу не может существовать.
Ошибка №5: хранить в объекте некорректное состояние и надеяться «потом поправим».
Если ваш Expense может существовать с пустой категорией, то рано или поздно вы забудете его поправить, и он доживёт до отчёта, сохранения или сортировки. Правило «создали — значит корректен» сильно упрощает мышление о коде. Поэтому проверки в init — не занудство, а инвестиция в спокойный сон.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ