1. Почему иногда лучше упасть сразу, чем притвориться, что всё нормально
Если вы когда-нибудь видели баг, который проявляется «раз в три недели по четвергам, но только если Луна в Козероге», то вы уже знакомы с главной проблемой: программа где-то давно пошла не туда, но продолжила жить, делая вид, что всё нормально. Fail-fast — это привычка останавливать выполнение сразу, когда стало ясно: дальше логика не имеет смысла. Это как пожарная сигнализация: она мешает спать, зато хорошо объясняет, что поспать уже не получится.
В наших утилитах ввода/парсинга/валидации мы уже выбрали мягкую стратегию: на ожидаемые ошибки данных возвращаем null. Но есть и другой класс проблем: ошибка использования функции. Например, вы написали валидатор диапазона, а кто-то (часто вы же) случайно передал min=100, max=10. Это не «пользователь ввёл не то», это «программист перепутал границы». И тут null уже плохо помогает: он делает ошибку тихой.
Давайте зафиксируем разницу:
| Ситуация | Пример | Нормальная реакция в нашем дне |
|---|---|---|
| Ожидаемая проблема данных (пользовательский ввод) | Ввели "qwerty" вместо числа | Вернуть null, сценарий объяснит и решит, что делать |
| Нарушение предусловий (ошибка программиста) | Передали диапазон min > max | Fail-fast через require/check |
Именно для второго случая в Kotlin есть «предусловия‑функции» require() и check() — они автоматически бросают исключение и останавливают выполнение, когда условие не выполнено.
2. Предусловия: что это такое
Предусловие — это правило, которое должно быть истинным до начала основной логики функции, иначе функция не обязана работать корректно. Это не про «проверить всё на свете», а про защиту от некорректного вызова. Причём важно: предусловия — это не «валидация пользовательского ввода», потому что пользователь не вызывает ваши функции напрямую; он вводит текст, а вы уже решаете, что с ним делать.
Предусловие удобно формулировать так, будто вы пишете короткую записку будущему себе: «Эта функция работает только если …». И если условие не соблюдено — пусть программа упадёт сразу, громко и с понятным сообщением. Это неприятно, но очень честно: «я не могу продолжать, потому что входные условия неправильные».
Два типа предусловий: аргументы и состояние
Часто предусловия делятся на два вида, и Kotlin специально даёт под них две разные функции. Эта граница не математически идеальна, но в реальной жизни очень помогает держать стиль в голове и не превращать проверки в кашу.
Предусловия по аргументам — это когда функция получила параметры, и вы хотите убедиться, что они корректны: например, min <= max, «скидка от 0 до 100», «размер > 0». Для этого обычно используется require(...), и при нарушении он бросает IllegalArgumentException.
Предусловия по состоянию — это когда аргументы вроде нормальные, но внутри программы есть некое состояние, и функция предполагает, что оно уже подготовлено: «сессия начата», «настройки загружены», «сначала вызови init». Для этого обычно используется check(...), и при нарушении он бросает IllegalStateException.
3. require: проверяем аргументы и падаем с IllegalArgumentException
require(condition) { message } — это стандартный способ сказать: «Вы вызываете функцию неправильно, потому что аргументы не подходят». Kotlin в таком случае бросает IllegalArgumentException. Это именно та ситуация, где ошибка должна быть максимально заметной: если аргументы неверны, значит, проблема в коде, а не в пользователе.
Важно: require — не инструмент для «пользователь ввёл буквы вместо цифр». Для этого у нас уже есть …OrNull и обработка на уровне сценария. require — это инструмент «программист передал ерунду».
Валидатор диапазона с защитой от min > max
Мы уже писали валидаторы вроде «проверить, что число в диапазоне». Теперь усилим их: добавим предусловие, что сам диапазон задан корректно.
fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }
if (x < min) return null
if (x > max) return null
return x
}
Обратите внимание на тонкую, но важную философию: если x «не попал» — это нормальная ситуация данных, поэтому null. А если диапазон задан абсурдно — это не «данные», это «кто-то (возможно, я) ошибся в коде», поэтому fail-fast.
Утилита чтения числа в диапазоне
Давайте накинем ещё одну функцию уровня «сценарий», которая комбинирует чтение и валидацию. Здесь require особенно полезен, потому что мы передаём границы диапазона не пользователем, а программистом.
fun readIntOrNull(prompt: String): Int? {
print(prompt)
val s = readln()
return s.toIntOrNull()
}
fun readIntInRangeOrNull(prompt: String, min: Int, max: Int): Int? {
require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }
val x = readIntOrNull(prompt) ?: return null
return validateIntInRangeOrNull(x, min, max)
}
Если кто-то вызовет readIntInRangeOrNull("Возраст: ", 150, 0), программа упадёт сразу — и это хорошо. Потому что иначе вы получите «странные» сценарии, где всё время возвращается null, и вы начинаете подозревать пользователя, клавиатуру и ретроградный Меркурий.
4. check: проверяем состояние и падаем с IllegalStateException
check(condition) { message } — это способ сказать: «Я ожидаю, что внутри программы сейчас корректное состояние, иначе это логическая ошибка». Kotlin в этом случае бросает IllegalStateException. Если require чаще ловит «плохой вызов снаружи», то check ловит «мы сами себя загнали в угол».
В консольных программах новичкового уровня состояние часто выглядит как одна-две переменные: «настройки заданы», «имя пользователя уже введено», «режим выбран». Это уже достаточно, чтобы увидеть пользу check.
Пример: сессия должна быть начата
Сделаем очень учебный, но показательный пример. Представим, что у нас есть мини‑программа, где нужно сначала «начать сессию», а потом задавать вопросы.
var sessionStarted: Boolean = false
fun startSession() {
sessionStarted = true
}
fun askUserName(): String {
check(sessionStarted) { "Сессия не начата. Сначала вызовите startSession()." }
print("Ваше имя: ")
return readln()
}
Здесь пользователь не виноват вообще. Он даже не знает, что такое startSession(). Это наш внутренний протокол. Если мы нарушили порядок вызовов — это логическая ошибка, и лучше увидеть её немедленно.
Почему тут не require
Можно было бы написать require(sessionStarted), и технически оно тоже упадёт. Но смысл будет хуже: IllegalArgumentException намекает «ты дал плохие аргументы», а проблема не в аргументах, а в том, что «программа находится в неправильном состоянии». check выражает это точнее, а точность — это половина хорошей диагностики.
Сообщения в require/check: пишем так, чтобы себе же было не стыдно
Сообщение в require/check — это не «украшение», а половина пользы. Если вы напишете просто require(min <= max), то при падении вы получите исключение, но без контекста: что именно было передано? В маленьких программах это ещё можно догадаться, а в реальных — вы будете благодарны себе за подробности.
Хорошее сообщение обычно содержит две части: «что ожидали» и «что получили». Это звучит как занудство, но это занудство, которое экономит часы.
fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }
if (x < min) return null
if (x > max) return null
return x
}
Если оно упадёт — вы сразу увидите конкретные числа. Это особенно важно, когда диапазоны собираются не «в лоб», а вычисляются где-то выше (пусть даже из других переменных).
5. Guard clauses: как перестать писать пирамиду из if
Представьте типичный код новичка (и не только новичка, если честно): проверка условия, внутри ещё проверка, внутри ещё… Получается «пирамида», которая визуально уезжает вправо и вызывает головную боль. Guard clauses (охранные проверки) — это стиль, где вы делаете проверки в начале и выходите из функции сразу, если что-то не так. Код становится линейным: читается сверху вниз как список препятствий на пути к основной логике.
Важно: guard clauses — это не отдельная конструкция языка. Это просто привычка писать if (...) return ... вместо вложенных блоков.
Было: вложенность и лесенка вправо
Допустим, мы хотим прочитать ширину и высоту прямоугольника (1..100) и посчитать площадь. Вот версия, от которой глаза начинают искать кнопку «Undo»:
fun rectangleAreaScenario() {
val w = readIntOrNull("Ширина: ")
if (w != null) {
val h = readIntOrNull("Высота: ")
if (h != null) {
if (w in 1..100) {
if (h in 1..100) {
println("Площадь = ${w * h}")
} else {
println("Высота вне диапазона.")
}
} else {
println("Ширина вне диапазона.")
}
} else {
println("Высота не число.")
}
} else {
println("Ширина не число.")
}
}
Код вроде правильный, но читать его — как разбирать матрёшку: пока не откроешь все уровни, не увидишь, где же там площадь.
Стало: guard clauses и ранний выход
Теперь перепишем так, чтобы основной смысл («посчитать площадь») был виден быстро, а проверки не создавали пирамиду.
fun rectangleAreaScenario() {
val w = readIntOrNull("Ширина (1..100): ") ?: run {
println("Ширина должна быть целым числом.")
return
}
if (w !in 1..100) {
println("Ширина вне диапазона 1..100.")
return
}
val h = readIntOrNull("Высота (1..100): ") ?: run {
println("Высота должна быть целым числом.")
return
}
if (h !in 1..100) {
println("Высота вне диапазона 1..100.")
return
}
println("Площадь = ${w * h}") // например: Площадь = 12
}
Заметьте, как приятно стало читать: если что-то не так — мы сразу выходим и объясняем. Если дошли до конца — значит, всё хорошо, и считаем площадь.
Как guard clauses сочетаются с …OrNull и require/check
Тут легко запутаться, поэтому зафиксируем мысль в виде маленькой схемы. Мы используем null и guard clauses, когда «не получилось получить корректное значение». Мы используем require/check, когда «функция вызвана/использована неправильно».
flowchart TD
A[Начало функции] --> B{Проблема ожидаемая?}
B -->|Пользовательский ввод, формат, диапазон| C[Вернуть null / выйти через guard clause]
B -->|Нарушены предусловия функции| D[require/check -> исключение]
C --> E[Сценарий решает, что печатать]
D --> F[Сразу видно баг в коде]
И да: fail-fast не отменяет аккуратную обработку null. Это два инструмента для разных типов проблем.
6. Встраиваем fail-fast в мини‑API: аккуратно, но уверенно
Сейчас мы сделаем маленький шаг в сторону более «взрослого» кода: наши утилиты будут не только возвращать null на ожидаемые проблемы, но и защищаться от неправильного использования. Мы по-прежнему остаёмся в пределах дня: без циклов повторного ввода и без сложной архитектуры — просто дисциплина.
Соберём мини‑набор функций (упрощённый, но связный) и используем его в main().
Нормальная композиция: чтение + валидация + guard clauses
fun readIntOrNull(prompt: String): Int? {
print(prompt)
return readln().toIntOrNull()
}
fun validateIntInRangeOrNull(x: Int, min: Int, max: Int): Int? {
require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }
if (x < min) return null
if (x > max) return null
return x
}
fun readIntInRangeOrNull(prompt: String, min: Int, max: Int): Int? {
require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }
val x = readIntOrNull(prompt) ?: return null
return validateIntInRangeOrNull(x, min, max)
}
Обратите внимание: мы сознательно дублируем require(min <= max) в двух функциях. Да, это повтор. Но это «страховка на месте вызова»: если вы решите поменять одну функцию или использовать другую отдельно, каждая будет защищена. На практике позже можно будет рефакторить, но сейчас важнее ясность.
Мини‑сценарий в main(): площадь
fun main() {
val w = readIntInRangeOrNull("Ширина (1..100): ", 1, 100) ?: run {
println("Некорректная ширина.")
return
}
val h = readIntInRangeOrNull("Высота (1..100): ", 1, 100) ?: run {
println("Некорректная высота.")
return
}
val area = w * h
println("Площадь = $area") // например: Площадь = 12
}
Это не супер‑приложение века, но оно идеально показывает стиль: утилиты «тихие», сценарий «говорящий», предусловия защищают нас от глупых вызовов.
Пример check: инициализация обязательна
Добавим к этому мини‑пример состояния. Представим, что мы хотим один раз настроить границы, а потом читать значения по этим границам. Мы не делаем здесь «настоящую конфигурацию», просто показываем идею check.
var configuredMin: Int? = null
var configuredMax: Int? = null
fun configureLimits(min: Int, max: Int) {
require(min <= max) { "Ожидали min <= max, но получили min=$min, max=$max" }
configuredMin = min
configuredMax = max
}
fun readConfiguredIntOrNull(prompt: String): Int? {
check(configuredMin != null && configuredMax != null) {
"Лимиты не настроены. Сначала вызовите configureLimits(min, max)."
}
val min = configuredMin!!
val max = configuredMax!!
return readIntInRangeOrNull(prompt, min, max)
}
Да, тут есть !!, и в идеальном мире мы бы его избегали. Но для текущего уровня важно другое: check гарантирует, что configuredMin и configuredMax уже заданы, иначе программа упадёт сразу с понятным сообщением. В учебных примерах это нормальная иллюстрация «состояния», а позже мы научимся проектировать так, чтобы !! требовался реже.
7. Типичные ошибки
Ошибка №1: использовать require/check для обычного неверного ввода пользователя.
Очень хочется написать require(x in 1..100), когда пользователь ввёл 200. Но тогда программа будет падать от любого «неправильного» ввода, и пользователь почувствует себя тестировщиком ваших нервов. Для ввода у нас стиль другой: …OrNull и обработка на уровне сценария.
Ошибка №2: «проглатывать» нарушение предусловий через null.
Если ваша функция должна вызываться только с корректными параметрами (например, min <= max), возвращать null — плохая идея. Вы превратите явную ошибку в тихую, и потом будете долго искать, почему «всё время возвращается null». Такие ситуации должны падать сразу через require или check.
Ошибка №3: писать require(condition) без сообщения.
Технически это работает, но диагностически — почти бесполезно. Сообщение должно объяснять ожидание и показывать фактические значения. Через неделю вы уже не вспомните, что именно проверяли, а вот min=$min, max=$max вспомнить поможет.
Ошибка №4: путать require и check и из-за этого терять смысл исключения.
Если вы проверяете корректность аргументов — это обычно require и IllegalArgumentException. Если вы проверяете внутреннее состояние — это обычно check и IllegalStateException. Kotlin прямо позиционирует эти функции как разные по смыслу и по типу исключения.
Ошибка №5: строить пирамиду из if вместо guard clauses и потом бояться трогать код.
Вложенные if быстро превращают функцию в лабиринт, где легко забыть, какая ветка к чему относится. Guard clauses с ранними return делают код линейным: «если не так — выйди, иначе работай дальше». Это улучшает читаемость и снижает шанс логических ошибок при правках.
Ошибка №6: делать проверки, но продолжать выполнение «на авось».
Иногда пишут что-то вроде if (x == null) println("Ошибка"), но не делают return, и дальше код работает с null или с некорректным значением. Guard clauses ценны именно тем, что после сообщения вы реально выходите, а не продолжаете программировать в режиме «ну вдруг повезёт».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ