JavaRush /Курсы /Kotlin SELF /Сборка мини‑API без дублей

Сборка мини‑API без дублей

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

1. Идея и принципы мини‑API ввода

Когда вы пишете первые консольные программы, кажется, что копировать 5–7 строк ввода — это нормально: «ну я же просто ещё раз спросил число». Но проходит буквально полчаса, и внезапно у вас десять мест, где вы забыли trim(), три места, где случайно использовали toInt() вместо toIntOrNull(), и одно место, где вы печатаете подсказку без пробела, поэтому пользователь вводит число прямо вплотную к тексту. И всё это — разные баги одной природы: логика повторяется, значит она будет расходиться.

Мини‑API — это не «большой фреймворк ввода», а маленький набор функций, которые вы договорились использовать всегда. Наша цель — убрать повторяющиеся куски (печать prompt, нормализация, безопасный парсинг, диапазон) и оставить в main() только то, что относится к сценарию программы: какие именно данные мы спрашиваем и что делаем дальше.

Соглашения об именах

Если функции назвать как попало, мини‑API быстро превращается в набор случайных заклинаний: «а readNumber кидает исключение или возвращает null?», «а getInt триммит или нет?». Поэтому мы вводим простые соглашения и держимся их. Это как договориться в команде, что «чай с бергамотом» — это не “кофе латте” (иначе будут слёзы, обиды и внезапные null).

Договоримся о следующем стиле именования:

Префикс / суффикс Пример Смысл (обещание функции)
read…
readIntOrNull(prompt)
Функция читает из консоли (
readln()
внутри)
parse…
parseIntOrNull(raw)
Функция не читает и не печатает, только разбирает строку
validate…
validateIntInRangeOrNull(x, min, max)
Функция проверяет правила для уже готового значения
…OrNull
readNonEmptyLineOrNull(...)
Может вернуть
null
, если «не получилось получить корректное значение»
без
OrNull
readLinePrompt(...)
Возвращает значение гарантированно (или это просто «сырая строка», или функция “не может не вернуть”)

Отдельно важная мысль про 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 и использовать их везде одинаково.

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