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. Маленькі функції легше перевіряти, пояснювати й перевикористовувати.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ