JavaRush /Курсы /Kotlin SELF /Чистый парсинг без ввода: parse…OrNull

Чистый парсинг без ввода: parse…OrNull

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

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
"10"
"10"
10
" 10 "
"10"
10
""
""
null
"   "
""
null
"10.5"
"10.5"
null
"abc"
"abc"
null

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. Маленькие функции легче проверять, объяснять и переиспользовать.

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