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. Маленькі функції легше перевіряти, пояснювати й перевикористовувати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ