1. Идея и принципы мини‑API ввода
Когда вы пишете первые консольные программы, кажется, что копировать 5–7 строк ввода — это нормально: «ну я же просто ещё раз спросил число». Но проходит буквально полчаса, и внезапно у вас десять мест, где вы забыли trim(), три места, где случайно использовали toInt() вместо toIntOrNull(), и одно место, где вы печатаете подсказку без пробела, поэтому пользователь вводит число прямо вплотную к тексту. И всё это — разные баги одной природы: логика повторяется, значит она будет расходиться.
Мини‑API — это не «большой фреймворк ввода», а маленький набор функций, которые вы договорились использовать всегда. Наша цель — убрать повторяющиеся куски (печать prompt, нормализация, безопасный парсинг, диапазон) и оставить в main() только то, что относится к сценарию программы: какие именно данные мы спрашиваем и что делаем дальше.
Соглашения об именах
Если функции назвать как попало, мини‑API быстро превращается в набор случайных заклинаний: «а readNumber кидает исключение или возвращает null?», «а getInt триммит или нет?». Поэтому мы вводим простые соглашения и держимся их. Это как договориться в команде, что «чай с бергамотом» — это не “кофе латте” (иначе будут слёзы, обиды и внезапные null).
Договоримся о следующем стиле именования:
| Префикс / суффикс | Пример | Смысл (обещание функции) |
|---|---|---|
|
|
Функция читает из консоли ( внутри) |
|
|
Функция не читает и не печатает, только разбирает строку |
|
|
Функция проверяет правила для уже готового значения |
|
|
Может вернуть , если «не получилось получить корректное значение» |
без |
|
Возвращает значение гарантированно (или это просто «сырая строка», или функция “не может не вернуть”) |
Отдельно важная мысль про require и check: это не «про неверный ввод пользователя», а про неверное использование функции программистом. Они бросают исключения и применяются как проверки предусловий.
Композиция без дублей
Чтобы мини‑API не разрасталось в «комбайн на все случаи жизни», полезно держать в голове простую схему уровней. Представьте конвейер: на входе сырая строка пользователя, на выходе либо корректное значение, либо null, означающий «на этом шаге не получилось».
Ниже схема, к которой мы будем стремиться (и которую легко расширять, не копируя код):
flowchart TD
A["prompt + readln()"] --> B[нормализация: trim/lowercase]
B --> C[parse...OrNull: String -> T?]
C --> D[validate...OrNull: T -> T?]
D --> E[read...OrNull уровня сценария]
Тут важно, что «кирпичики» маленькие и честные. parseIntOrNull() не печатает «Вы ввели не число», потому что он понятия не имеет, как вы хотите общаться с пользователем. Он просто говорит: «могу распарсить — держи Int, не могу — держи null». А вот сценарий (обычно main) решает, что делать с null: завершиться, попросить повторить (циклы вы уже знаете), показать подсказку, или выбрать значение по умолчанию.
2. Утилиты ввода в InputUtils.kt
Хочется сразу написать одну функцию readIntInRangeWithRetriesAndAngryMessages(...), но это прямой путь к монстру на 60 строк. Мы пойдём другим путём: соберём файл (или просто блок в одном .kt), где каждая функция делает один понятный шаг. Потом из этих шагов соберём более удобные «комбинированные» утилиты.
Чтение строки с prompt
Начнём с безопасного и предсказуемого «нулевого уровня»: печатаем подсказку и читаем строку. Да, это банально. Именно поэтому это стоит вынести: банальности в коде лучше централизовать.
fun readLinePrompt(prompt: String): String {
print(prompt)
return readln()
}
Здесь мы сознательно возвращаем String, а не String?: в наших сценариях ввода из консоли мы считаем, что пользователь всё-таки что-то вводит. (Конец потока ввода — отдельная тема; сейчас нам важнее общий паттерн.)
Нормализация ввода
Нормализация — это когда мы делаем из « Да » нормальное «да», а из « 10 » нормальное «10». Это не «валидация», потому что мы ничего не запрещаем, мы просто убираем шум.
fun normalizeTrim(raw: String): String {
return raw.trim()
}
fun normalizeCommand(raw: String): String {
return raw.trim().lowercase()
}
Если вам кажется, что это слишком мелко — отлично. Именно такие мелочи потом экономят вам десятки строк, потому что вы перестаёте разбрасывать trim() по всему коду как конфетти.
Чистый парсинг
Теперь напишем парсеры. Они не читают из консоли и не печатают. Они умеют аккуратно обработать пустую строку.
fun parseIntOrNull(raw: String): Int? {
val s = normalizeTrim(raw)
if (s.isEmpty()) return null
return s.toIntOrNull()
}
fun parseDoubleOrNull(raw: String): Double? {
val s = normalizeTrim(raw)
if (s.isEmpty()) return null
return s.toDoubleOrNull()
}
Да, toIntOrNull() и так вернёт null на пустой строке, но явная проверка здесь повышает читабельность для новичка: видно, что пустота — это нормальный вариант «не получилось».
Теперь парсер «да/нет». Мы хотим различать «нет» и «я не понял». Поэтому возвращаем Boolean?, а не Boolean.
fun parseYesNoOrNull(raw: String): Boolean? {
return when (normalizeCommand(raw)) {
"yes", "y", "да", "д" -> true
"no", "n", "нет", "н" -> false
else -> null
}
}
Валидация
Теперь валидаторы. Они принимают уже нормальный тип (Int, Double, String) и проверяют правила.
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
}
Здесь require(min <= max) — это проверка предусловий: если программист передал неправильные границы, продолжать «как будто всё ок» нельзя. require именно для этого и существует, и при провале бросает IllegalArgumentException.
Для строк сделаем валидатор «не пусто после trim()»:
fun validateNonEmptyOrNull(raw: String): String? {
val s = normalizeTrim(raw)
if (s.isEmpty()) return null
return s
}
И пример валидации числа «строго положительное» для Double (полезно для цен, коэффициентов и т.п.):
fun validatePositiveDoubleOrNull(x: Double): Double? {
if (x <= 0.0) return null
return x
}
Комбинированные read…OrNull для сценария
Вот теперь можно собирать функции, которые читают из консоли и внутри используют наши кирпичики. Главная цель — чтобы внутри не было дублей, и чтобы одинаковые вещи выглядели одинаково.
fun readIntOrNull(prompt: String): Int? {
val raw = readLinePrompt(prompt)
return parseIntOrNull(raw)
}
fun readDoubleOrNull(prompt: String): Double? {
val raw = readLinePrompt(prompt)
return parseDoubleOrNull(raw)
}
Сделаем «непустую строку»:
fun readNonEmptyLineOrNull(prompt: String): String? {
val raw = readLinePrompt(prompt)
return validateNonEmptyOrNull(raw)
}
Сделаем «да/нет»:
fun readYesNoOrNull(prompt: String): Boolean? {
val raw = readLinePrompt(prompt)
return parseYesNoOrNull(raw)
}
И наконец самая «вкусная» утилита: число в диапазоне. Важно, что тут нет своего парсинга и нет своей логики диапазона — мы переиспользуем уже написанное.
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)
}
Обратите внимание на эффект: если завтра вы решите, что readIntOrNull должен, например, делать trim() по-другому — вы поменяете это в одном месте, и это автоматически улучшит все более высокоуровневые функции.
3. Читаемый main() как сценарий
Сейчас мы сделаем самое важное упражнение этой лекции: напишем main() так, чтобы он выглядел как сценарий общения с пользователем. То есть: сначала спросили название комнаты, потом размеры, потом цену, потом вывод. Без каши из trim() и toDoubleOrNull() на каждом шаге. Если вы когда-нибудь читали чужой код и чувствовали, как мозг пытается выпрыгнуть в окно — это как раз про отсутствие таких утилит.
Ниже — небольшой пример приложения, которое считает площадь стен (и опционально потолка) и оценивает стоимость покраски. Мы не используем коллекции, файлы и «сложную архитектуру» — только то, что уже доступно к уровню 14.
fun main() {
println("Калькулятор покраски комнаты v0")
val roomName = readNonEmptyLineOrNull("Название комнаты: ")
if (roomName == null) {
println("Название не должно быть пустым.")
return
}
val width = readIntInRangeOrNull("Ширина (1..50), м: ", 1, 50)
if (width == null) {
println("Ширина введена некорректно.")
return
}
val length = readIntInRangeOrNull("Длина (1..50), м: ", 1, 50)
if (length == null) {
println("Длина введена некорректно.")
return
}
val height = readIntInRangeOrNull("Высота (2..10), м: ", 2, 10)
if (height == null) {
println("Высота введена некорректно.")
return
}
val priceRaw = readDoubleOrNull("Цена покраски за 1 м² (например 12.5): ")
val pricePerSqm = if (priceRaw == null) null else validatePositiveDoubleOrNull(priceRaw)
if (pricePerSqm == null) {
println("Цена должна быть числом больше 0.")
return
}
val includeCeiling = readYesNoOrNull("Красим потолок? (yes/no): ")
if (includeCeiling == null) {
println("Не понял ответ. Введите yes/no.")
return
}
val wallsArea = 2.0 * (width + length) * height
val ceilingArea = width.toDouble() * length.toDouble()
val totalArea = if (includeCeiling) wallsArea + ceilingArea else wallsArea
val totalCost = totalArea * pricePerSqm
println()
println("Комната: $roomName")
println("Площадь стен: ${"%.2f".format(wallsArea)} м²")
println("Итого площадь: ${"%.2f".format(totalArea)} м²")
println("Стоимость: ${"%.2f".format(totalCost)}")
}
Да, тут есть повторяющийся шаблон:
val x = read...
if (x == null) { println(...); return }
Но важное отличие в том, что это повторение — уже «уровня сценария». Оно описывает бизнес-решение: «если не удалось получить корректные данные, завершаем программу». А низкоуровневые повторы (прочитал строку, trim(), распарсил) мы убрали.
Если вам захочется в дальнейшем сделать поведение «не завершаться, а просить повторить», вы сможете поменять только сценарий, не трогая парсеры и валидаторы. (Циклы у вас уже есть, но сегодня мы сознательно не делаем ввод «с повтором», чтобы не смешивать темы.)
4. Как удержать мини‑API маленьким
Очень легко, почувствовав вкус к утилитам, начать писать «всё более удобные» функции, которые в итоге будут делать всё сразу: читать, валидировать, печатать ошибки, повторять ввод, форматировать подсказки, и ещё обижаться на пользователя за неправильную запятую. Это весело первые пять минут, а потом вы понимаете, что у вас нет мини‑API — у вас есть второй main, просто спрятанный в функцию.
Хороший баланс обычно выглядит так: у вас есть несколько базовых функций (readLinePrompt, parseIntOrNull, validateIntInRangeOrNull), и несколько удобных «комбинированных» функций, которые реально часто нужны (readIntInRangeOrNull, readNonEmptyLineOrNull, readYesNoOrNull). Если вы видите, что очередная функция получилась с шестью параметрами и фразой «на всякий случай» — это почти всегда сигнал, что вы смешали уровни ответственности.
Отдельно стоит держать в голове различие между «ошибкой пользователя» и «ошибкой программиста». Пользователь может ввести сорок два вместо 42 — это ожидаемо, и тогда …OrNull возвращает null. А вот если программист вызывает readIntInRangeOrNull(prompt, 10, 1), это уже нарушение предусловий, и его лучше ловить сразу через require(min <= max). require и check как раз предназначены для такого fail‑fast поведения и бросают исключения, чтобы проблема была заметной, а не превращалась в «тихий странный баг».
5. Типичные ошибки при сборке мини‑API утилит
Ошибка №1: функция называется как «не nullable», но возвращает null.
Если вы назвали функцию readInt(...), а она возвращает Int?, вы заставляете читателя кода заниматься археологией: «а где же тут может быть null?». Суффикс OrNull — это честный сигнал: «обрабатывай null, это часть контракта».
Ошибка №2: утилиты печатают ошибки внутри себя, а потом main() печатает ещё раз.
Поначалу кажется удобным: «пусть readIntOrNull сам пишет “Вы ввели не число”». Но как только у вас появляется два сценария (например, в одном месте вы хотите “не число”, а в другом — “ожидал возраст”), утилита начинает мешать. Обычно лучше, чтобы низкий уровень возвращал null, а уровень сценария решал, что и как говорить пользователю.
Ошибка №3: смешивание парсинга и валидации в одной длинной функции.
Когда в одной функции одновременно trim(), потом toIntOrNull(), потом проверка диапазона, потом печать ошибок — её сложно переиспользовать и сложно тестировать «в голове». Разделение на parse…OrNull и validate…OrNull делает код линейным и позволяет собирать разные комбинации без копипасты.
Ошибка №4: возврат «магических значений» вместо null.
Иногда хочется вернуть -1, 0 или Int.MIN_VALUE, чтобы «обозначить ошибку». Это почти всегда обман: -1 может быть реальным значением (например, температура), 0 может быть допустимым (например, скидка), и вы начинаете плодить дополнительные условия. null в Kotlin для таких ситуаций намного честнее: «значение не получено / не прошло проверку».
Ошибка №5: отсутствие проверок предусловий там, где они реально нужны.
Если функция принимает min и max, и вы не проверяете, что min <= max, то где-нибудь в будущем вы получите странное поведение (например, «диапазон ничего не принимает») и потратите время на отладку того, что можно было поймать сразу. В таких местах уместен require(...), потому что это ошибка использования функции, а не ошибка пользователя.
Ошибка №6: раздувание сигнатур “на всякий случай”.
Мини‑API перестаёт быть мини‑API, когда у вас появляются функции вида readIntInRangeOrNull(prompt, min, max, retries, defaultValue, errorMessage, allowEmpty, ...). Как правило, это означает, что вы смешали логику сценария (что делать при ошибке) и логику утилиты (как безопасно получить значение). Лучше оставить утилиты простыми, а сложное поведение собирать снаружи, в main() или в отдельных функциях сценария.
Ошибка №7: хаотичная нормализация в разных местах.
Если один парсер делает trim(), другой нет, а где-то ещё вы вручную делаете lowercase() в main(), вы неизбежно получите «странные» баги вида “YES не распознали, а yes распознали”. Нормализация должна быть согласованной: лучше иметь 1–2 маленькие функции вроде normalizeTrim и normalizeCommand и использовать их везде одинаково.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ