1. Зачем отделять парсинг от ввода
Когда вы пишете маленький учебный main, очень легко смешать всё в одну кашу: “распечатали подсказку → прочитали строку → тут же распарсили → тут же поругались → тут же решили, что делать дальше”. На 10 строках это кажется нормальным. На 30 — уже похоже на лапшу быстрого приготовления: вроде есть можно, но стыдно показывать людям.
Проблема не в том, что ввод “плохой”. Проблема в том, что ввод — это про общение с человеком, а парсинг — это про преобразование текста в значение. Если эти две задачи живут в одном месте, вы теряете переиспользование: завтра вам нужно будет распарсить строку не из консоли, а, например, из переменной (или теста), и вы внезапно обнаружите, что ваш “парсер” печатает подсказки. Неловко.
Ещё одна причина — отладка. “Чистый” парсер можно проверить на конкретных строках: "10", " 10 ", "", "abc". Для этого даже не нужен пользователь, а значит, меньше случайностей и больше контроля (и меньше шансов спорить с клавиатурой, почему она снова ввела не то).
Что такое “чистый парсер” и почему он должен быть “тихим”
Под “чистым парсером” в рамках этой лекции мы будем понимать простую вещь: это функция, которая принимает String и возвращает T?, при этом она ничего не читает и ничего не печатает. Она работает как маленький преобразователь: дали строку → получили значение или null, если строка “не того формата”.
Такой парсер иногда называют “тихим”: он не ругается в консоль и не объясняет, кто виноват. И это не потому, что мы бесчувственные. Это потому, что парсер не должен решать, как именно реагировать. Реакция зависит от сценария: где-то мы хотим попросить пользователя ввести ещё раз, где-то — завершить программу, где-то — принять значение по умолчанию. Парсер об этом не знает и не должен знать.
Обратите внимание на соглашение имён: суффикс OrNull в Kotlin часто означает “вернём null, если не получилось”. Стандартная библиотека в разных местах следует этой идее (например, у некоторых операций есть версии *OrNull, которые возвращают null вместо падения). Мы делаем то же самое, но для парсинга пользовательского ввода.
2. Нормализация строк
Почти любой пользовательский ввод нуждается в небольшой “подготовке”. Человек может поставить пробелы, случайно нажать пробел в конце, написать “YES” капсом или “Да” с разным регистром. Компьютер — существо прямолинейное: он не “понимает, что вы имели в виду”, если вы это явно не запрограммировали. Поэтому мы вводим промежуточный шаг — нормализацию.
Нормализация — это не “валидация правил” (это следующая лекция). Это именно приведение текста к виду, с которым удобно работать: убрать пробелы по краям, при необходимости привести к нижнему регистру. Наша цель — чтобы разные “по смыслу одинаковые” варианты стали одинаковыми строками.
Ниже — две маленькие функции, которые мы будем использовать как строительные блоки:
fun normalizeTrim(raw: String): String {
return raw.trim()
}
fun normalizeCommand(raw: String): String {
return raw.trim().lowercase()
}
Здесь есть важная мелочь: trim() убирает пробелы по краям, но не трогает пробелы внутри. То есть " Hello world ".trim() превратится в "Hello world". И это нормально: если человек реально ввёл два пробела внутри — возможно, это часть текста.
Чтобы увидеть, как это работает “вживую”, можно сделать маленький main:
fun main() {
val raw = " YeS "
val t = normalizeTrim(raw)
val c = normalizeCommand(raw)
println("raw='$raw'") // raw=' YeS '
println("trim='$t'") // trim='YeS'
println("cmd='$c'") // cmd='yes'
}
Обратите внимание: пока мы не спорим, “хороший” ли это ввод. Мы просто приводим его к удобному виду.
Мини-схема пайплайна “ввод → нормализация → парсинг”
flowchart LR
A["raw String
как ввёл пользователь"] --> B["normalize
trim/lowercase"]
B --> C["parse…OrNull
String → T?"]
C --> D["сценарий решает
что делать с null"]
3. Парсеры чисел: Int и Double
Когда мы раньше писали readIntOrNull(prompt), мы делали три дела сразу: печатали подсказку, читали строку, парсили. Теперь мы разлепляем эти задачи.
parseIntOrNull: парсим число без консоли
Нам нужен парсер, который делает две вещи: сначала нормализует строку (хотя бы trim()), затем пытается распарсить. Если строка пустая после trim() — возвращаем null (потому что “пусто” точно не число, и это лучше, чем надеяться на чудо).
fun parseIntOrNull(raw: String): Int? {
val s = raw.trim()
if (s.isEmpty()) return null
return s.toIntOrNull()
}
Обратите внимание: здесь нет println, нет readln(). Это чистая функция: одинаковый raw → одинаковый результат. А значит, её легко использовать где угодно.
Проверим на разных входах:
fun main() {
println(parseIntOrNull("10")) // 10
println(parseIntOrNull(" 10 ")) // 10
println(parseIntOrNull(" ")) // null
println(parseIntOrNull("10 лет")) // null
}
И здесь очень полезно мысленно различать ситуации: " " — это не “ошибка программы”, это нормальная ситуация “данные не подходят”. Мы возвращаем null и даём сценарию решать, что делать дальше.
Табличка “что было → что стало”
| raw (ввод) | после trim() | результат parseIntOrNull |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
parseDoubleOrNull: числа с точкой
С Double история почти такая же, только метод называется toDoubleOrNull(). И тут есть тонкость человеческого мира: кто-то вводит "3.14", а кто-то по привычке пишет "3,14". Kotlin (и JVM) по умолчанию ожидают точку. Мы пока что не будем “чинить мир”, иначе незаметно уедем в тему нормализации посложнее. На этом этапе достаточно честного контракта: точка — значит число, запятая — значит null.
fun parseDoubleOrNull(raw: String): Double? {
val s = raw.trim()
if (s.isEmpty()) return null
return s.toDoubleOrNull()
}
Мини-проверка:
fun main() {
println(parseDoubleOrNull("2.5")) // 2.5
println(parseDoubleOrNull(" 2.5 ")) // 2.5
println(parseDoubleOrNull("2,5")) // null
println(parseDoubleOrNull("")) // null
}
Если вам кажется, что "2,5" хотелось бы принять как "2.5", вы мыслите в правильную сторону. Просто сегодня наша задача — отделить парсинг от ввода и ввести базовую нормализацию (trim, lowercase). Более “смелые” нормализации лучше делать осознанно, иначе можно незаметно сломать ввод там, где запятая — часть текста.
4. Парсинг команд: parseYesNoOrNull
Числа — это хорошо, но консольные программы часто спрашивают “да/нет”, “y/n”, “ок/не ок”. И вот здесь lowercase() становится нашим лучшим другом: вы не хотите расписывать в when варианты "YES", "Yes", "yEs", потому что так можно случайно изобрести новый алфавит.
Сделаем парсер, который принимает строку и возвращает Boolean?: true для “да”, false для “нет”, null — если ответ непонятный.
fun parseYesNoOrNull(raw: String): Boolean? {
val s = raw.trim().lowercase()
return when (s) {
"yes", "y", "да", "д" -> true
"no", "n", "нет", "н" -> false
else -> null
}
}
Почему Boolean?, а не просто Boolean? Потому что иначе нам пришлось бы “назначить” какой-то ответ по умолчанию. А это уже решение сценария. Парсер должен честно сказать: “я не понял”.
Проверка:
fun main() {
println(parseYesNoOrNull(" YES ")) // true
println(parseYesNoOrNull("н")) // false
println(parseYesNoOrNull("ага")) // null
}
Обратите внимание, насколько аккуратно здесь “сидит” нормализация: мы не расписываем 20 вариантов регистра, потому что приводим строку к нижнему регистру один раз.
5. Как соединить ввод и парсинг
Сейчас у нас есть “чистые” парсеры. Но консольная программа всё ещё должна читать ввод. И вот тут появляется очень приятная композиция: утилита ввода остаётся маленькой и тупой (в хорошем смысле), а логика преобразования переезжает в parse…OrNull.
Напомню “нулевой уровень” из прошлой лекции (в одном из возможных вариантов):
fun readLine(prompt: String): String {
print(prompt)
return readln()
}
readln() читает целую строку из стандартного ввода. А дальше мы можем сделать “обёртки” ввода, которые уже используют наши парсеры — и это будет выглядеть очень ровно и однотипно:
fun readIntOrNull(prompt: String): Int? {
val raw = readLine(prompt)
return parseIntOrNull(raw)
}
fun readDoubleOrNull(prompt: String): Double? {
val raw = readLine(prompt)
return parseDoubleOrNull(raw)
}
Это кажется “лишним шагом”, но на практике выигрывает сразу в нескольких местах: вы можете менять parseIntOrNull, не трогая ввод, и наоборот. А ещё вы можете парсить строки, которые пришли не из консоли, используя те же функции.
Маленькая демонстрация “парсер без консоли”
fun main() {
val textFromSomewhere = " 42 "
val x = parseIntOrNull(textFromSomewhere)
println("x = $x") // x = 42
}
Сценарий не читает ввод, не печатает подсказки, но переиспользует тот же парсер. Это и есть основная идея лекции: парсинг становится отдельным “кирпичиком”.
6. Мини-пример: анкета пользователя
Чтобы не оставлять всё на уровне абстракций, возьмём маленький сценарий, который мы будем развивать в течение дня: консольная “анкета”, которая спрашивает имя, возраст и “согласны ли продолжить”. Сегодня мы не делаем повторный ввод и не вводим отдельный слой валидации правил (типа “возраст 0..150”) — это будет следующая лекция. Мы лишь аккуратно читаем и безопасно парсим.
Вот как может выглядеть main, который использует наши функции:
fun main() {
val nameRaw = readLine("Имя: ")
val name = nameRaw.trim()
val age = readIntOrNull("Возраст: ")
val agree = parseYesNoOrNull(readLine("Продолжить? (yes/no): "))
println("name='$name' age=$age agree=$agree")
// Пример: name='Ира' age=20 agree=true
}
Заметьте интересную вещь: name мы пока не делаем ...OrNull. Мы просто делаем trim(), потому что “непустое имя” — это уже правило (валидация), а не парсинг. Сегодня наша цель — научиться не смешивать слои.
Если хочется сделать то же самое более “похоже на API”, можно вынести чтение ответа “да/нет” в функцию, которая использует парсер:
fun readYesNoOrNull(prompt: String): Boolean? {
val raw = readLine(prompt)
return parseYesNoOrNull(raw)
}
И тогда main станет чуть более “историей действий”, а не набором технических деталей:
fun main() {
val name = readLine("Имя: ").trim()
val age = readIntOrNull("Возраст: ")
val agree = readYesNoOrNull("Продолжить? (yes/no): ")
println("name='$name' age=$age agree=$agree")
}
Тут есть маленькая самоирония: да, мы написали несколько функций ради того, чтобы main стал короче на 3 строки. Но это как мыть посуду: сначала кажется, что “да ладно, потом”, а потом внезапно кухня кончилась.
7. Типичные ошибки
Когда впервые отделяешь парсинг от ввода, мозг часто сопротивляется: “зачем так сложно, я же могу прямо в main сделать toIntOrNull()”. Это нормальная стадия, как отрицание перед принятием. Ниже — самые частые грабли, на которые наступают именно в этой теме.
Ошибка №1: парсер начинает печатать в консоль.
Очень соблазнительно сделать внутри parseIntOrNull что-то вроде println("Введите число!"). Но тогда функция перестаёт быть универсальной: вы захотите использовать её в другом месте (например, в обработке строки-команды), а она вдруг начнёт разговаривать с пользователем. Держите парсер “тихим”: он возвращает T?, а сообщения — задача сценария.
Ошибка №2: забыли trim() и получили “волшебный null”.
Новички часто удивляются: “я ввёл 10, почему null?”. А потом выясняется, что было "10 " (пробел в конце) или " 10" (пробел в начале). Без trim() такие случаи превращаются в микробаги, которые раздражают сильнее, чем большие ошибки, потому что выглядят “случайно”. Делайте trim() внутри парсера, и ввод станет заметно человечнее.
Ошибка №3: путают парсинг и валидацию, и парсер разрастается в монстра.
Если вы прямо в parseIntOrNull начнёте проверять диапазоны, правила “не меньше 1”, “не больше 100” и прочие бизнес-условия, функция быстро станет слишком конкретной. Сегодня парсер отвечает на вопрос “это вообще целое число?”. Вопрос “подходит ли оно по правилам?” лучше вынести в отдельный слой (и мы как раз идём туда по плану дня).
Ошибка №4: возвращают false вместо null в parseYesNoOrNull.
Логика “если непонятно, значит нет” кажется удобной, но она смешивает два разных смысла: “пользователь сказал нет” и “мы не поняли, что он сказал”. В результате сценарий теряет возможность корректно отреагировать на непонятный ввод. Boolean? здесь честнее: true/false/null явно кодируют три разных состояния.
Ошибка №5: создают “универсальный парсер на всё” с кучей флагов.
Иногда появляется желание написать одну функцию типа parseNumberOrNull(raw, allowDouble, allowComma, defaultValue, ...). Это выглядит мощно, но читается тяжело и почти всегда приводит к путанице параметров. На уровне нашего курса лучше держать несколько маленьких, очевидных функций: parseIntOrNull, parseDoubleOrNull, parseYesNoOrNull. Маленькие функции легче проверять, объяснять и переиспользовать.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ