1. Нормалізація перед розбором
Коли ми читаємо рядок через readln(), здається, ніби отримали «команду» або «дані». Насправді ж це сирий текст: із пробілами, різним регістром, випадковими комами, зайвими роздільниками й інколи — з введенням у стилі «я натиснув пробіл пʼять разів, бо можу». Нормалізація — це крок, на якому ми приводимо ввід до зрозумілого стандарту, щоб розбір (парсинг) був передбачуваним.
Хороша стратегія тут — як і в минулій лекції, де ми діяли за схемою «прочитав → підготував → розпарсив». Тепер просто «підготовка» стала ґрунтовнішою:
flowchart TD
A["readln(): сирий ввід"] --> B["trim(): чистимо краї"]
B --> C["нормалізуємо пробіли/роздільники"]
C --> D["нормалізуємо регістр (за потреби)"]
D --> E["split(): ріжемо на частини"]
E --> F["перевіряємо size і типи частин"]
Зверніть увагу: ми не доводимо рядок до ідеалу. Ми робимо рівно стільки, щоб далі логіка працювала стабільно. І так, це нормально, якщо нормалізація займає більше рядків коду, ніж сама «корисна робота»: програма — це не лише «зробити», а й «зробити правильно».
Зводимо регістр: lowercase() і uppercase()
Ще одна типова проблема — регістр. Один користувач введе Add, інший — ADD, третій — add, а четвертий узагалі напише aDd (і буде задоволений собою). Якщо порівнювати рядки напряму, то всі ці варіанти різні. Тому, коли ми хочемо розпізнати «команду» або «тип дії», зручно зводити ввід до одного регістру.
Важливо розрізняти: команда й дані — не одне й те саме. Команду ми зазвичай нормалізуємо (наприклад, до нижнього регістру), а «назву товару» або «коментар» інколи краще залишити як є, щоб не перетворювати iPhone на iphone (хоча інколи це припустимо — залежить від задачі).
Приклад: нормалізуємо команду, але залишаємо аргумент як є:
fun main() {
val rawLine = " AdD Milk "
val line = rawLine.trim()
val parts = line.split(" ")
val command = parts[0].lowercase()
val item = if (parts.size >= 2) parts[1] else ""
println("команда='$command', товар='$item'") // команда='add', товар='Milk'
}
Тут ми вже використали split(), але поки що без нормалізації пробілів — і це нам ще відгукнеться. Поки просто зафіксуємо ідею: lowercase() і uppercase() допомагають зробити порівняння стійким.
2. Прибираємо пробіли по краях: trim*
Майже будь-який користувацький ввід починається й закінчується пробілами частіше, ніж хотілося б. Це не тому, що люди «погані», — просто клавіатура так влаштована, а звички в усіх різні. Тому перше, що ми зазвичай робимо з рядком перед розбором, — прибираємо пробіли по краях. Для цього в Kotlin є trim(), а якщо потрібно точніше — trimStart() і trimEnd().
- trim() — видаляє пробіли з обох кінців рядка
- trimStart() — видаляє пробіли з початку рядка
- trimEnd() — видаляє пробіли з кінця рядка
Важливо памʼятати одну річ: рядки в Kotlin є «незмінними» (у сенсі, що методи повертають новий рядок). Тому raw.trim() не змінює raw, а повертає новий рядок. Це часта причина коду, який «не працює».
Приклад — подивімося різницю між трьома методами:
fun main() {
val raw = " hello "
println(">" + raw.trim() + "<") // >hello<
println(">" + raw.trimStart() + "<") // >hello <
println(">" + raw.trimEnd() + "<") // > hello<
}
Тут можна помітити корисний прийом: я спеціально додав > і <, щоб візуально побачити пробіли. Це маленьке «налагодження очима», яке економить багато часу.
Якщо ви очікуєте ввід виду «команда + аргументи», то trim() майже завжди має бути першим кроком. Інакше раптом перестають збігатися рядки під час порівняння: "add" і " add" — це різні рядки.
3. Ріжемо рядок на частини: split()
Коли рядок став «чистим» (хоча б більш-менш), ми можемо розрізати його на частини. Для цього є split(...). У базовому сценарії дуже часто трапляється split(" ") — «розбити за пробілами».
Результат split() — це список частин (з типом List<String>). Ми поки не заглиблюємося в колекції (це буде значно пізніше), але нам достатньо двох фактів: у результату є size, і до елементів можна звертатися за індексом parts[i]. У списків індексація починається з нуля: перший елемент має індекс 0, а останній — size - 1.
Чому не можна одразу робити parts[1]
Дуже хочеться написати так:
val parts = line.split(" ")
val second = parts[1] // «ну точно ж є!»
Але користувач — істота непередбачувана. Він може ввести лише одне слово. І тоді parts[1] — це вихід за межі, і програма падає.
Правильний стиль на нашому поточному рівні — перевіряти size перед доступом:
fun main() {
val line = "add"
val parts = line.split(" ")
val cmd = parts[0]
val arg = if (parts.size >= 2) parts[1] else "(немає аргументу)"
println("cmd='$cmd', arg='$arg'") // cmd='add', arg='(немає аргументу)'
}
Це виглядає трохи довше, зате не ламається на рівному місці. А «не ламається на рівному місці» — один із найкращих компліментів для коду.
4. Точкові заміни: replace() і replaceFirst()
Коли пробіли по краях прибрали, регістр узгодили, лишається купа «дрібного сміття»: зайві коми, подвійні пробіли, випадкові крапки або ситуація, коли користувач замість пробілу поставив , (бо «так звичніше»). Тут на сцену виходить replace(old, new).
replace(old, new) замінює усі входження old на new. А replaceFirst(old, new) замінює лише перше входження. Це важливо, бо інколи ви хочете «поправити формат» один раз, а інколи — справді замінити все.
Мініприклад, щоб відчути різницю:
fun main() {
val s = "one-two-two"
println(s.replace("two", "X")) // one-X-X
println(s.replaceFirst("two", "X")) // one-X-two
}
Нормалізація множинних пробілів без regex
Типовий біль програміста: користувач вводить кілька пробілів поспіль, а split(" ") починає поводитися «не так, як ви очікували». Найпростіша стратегія без регулярних виразів (їх ми сьогодні не чіпаємо) — «стискати» повторювані пробіли циклом:
fun normalizeSpaces(text: String): String {
var s = text.trim()
while (s.contains(" ")) {
s = s.replace(" ", " ")
}
return s
}
fun main() {
val raw = " milk 2 30 "
println(normalizeSpaces(raw)) // milk 2 30
}
Так, це не найшвидший спосіб на планеті. Але для навчальних прикладів і для звичайного «користувацького вводу одного рядка» — більш ніж достатньо. А головне — він зрозумілий. Коли код зрозумілий, його легше лагодити, ніж «магічну оптимізацію».
5. Розбір покупки
Тепер давайте звʼяжемо все в єдиний невеликий сценарій і продовжимо розвивати наш навчальний консольний застосунок. Нехай він поки що вміє розбирати один рядок «запис про покупку» у форматі:
<назва> <к-ть> <ціна>
Наприклад:
milk 2 30
Ми хочемо, щоб застосунок був «терплячим» і приймав ввід на кшталт:
Milk, 2 30
Тобто: зайві пробіли, кома після назви, різний регістр.
Крок 1: нормалізація «як домовимося»
Зробімо функцію, яка приводить рядок до стабільнішого вигляду:
fun normalizePurchaseLine(raw: String): String {
var s = raw.trim()
// Дозволимо користувачу ставити коми: замінимо їх на пробіли
s = s.replace(",", " ")
// Стиснемо множинні пробіли до одного
while (s.contains(" ")) {
s = s.replace(" ", " ")
}
return s
}
fun main() {
val raw = " Milk, 2 30 "
println(normalizePurchaseLine(raw)) // Milk 2 30
}
Зверніть увагу: регістр тут навмисно не чіпаємо. Назву товару залишаємо такою, як її ввів користувач. Якщо нам пізніше знадобиться порівнювати назви (наприклад, milk і Milk вважати одним і тим самим), тоді окремо вирішимо, що саме й де зводити до lowercase().
Крок 2: розбір рядка на частини та перевірка форми
Тепер напишемо функцію, яка бере нормалізований рядок, ділить його на частини та перевіряє, що частин рівно три:
fun splitPurchase(line: String): List<String> {
val normalized = normalizePurchaseLine(line)
return normalized.split(" ")
}
fun main() {
val parts = splitPurchase(" Milk, 2 30 ")
println("parts.size=${parts.size}") // parts.size=3
println("parts[0]='${parts[0]}'") // parts[0]='Milk'
}
Ми користуємося тим, що результат split() — це список, у якого є size та індексний доступ.
Крок 3: перетворюємо частини на числа і формуємо відповідь
Кількість і ціна — це числа. У нас уже був досвід із toIntOrNull() та перевіркою на null. Не будемо ускладнювати: якщо число не розпарсилося, чесно повернемо повідомлення про помилку.
Зробімо функцію, яка повертає готове повідомлення (успіх або помилка). Це зручне рішення на поточному рівні: один рядок результату — і main просто його друкує.
fun parsePurchaseToMessage(line: String): String {
val parts = splitPurchase(line)
if (parts.size != 3) {
return "Помилка: очікую формат '<item> <qty> <price>'"
}
val item = parts[0]
val qty = parts[1].toIntOrNull()
val price = parts[2].toIntOrNull()
if (qty == null || price == null) {
return "Помилка: qty і price мають бути цілими числами"
}
val total = qty * price
return "Покупка: $item, qty=$qty, price=$price, total=$total"
}
fun main() {
println(parsePurchaseToMessage(" Milk, 2 30 "))
// Покупка: Milk, qty=2, price=30, total=60
}
Зауважте, наскільки спокійнішим стало життя: main() більше не займається дрібницями. Він не «пиляє рядок» і не ловить помилки формату. Натомість делегує це функції, а сам просто друкує результат.
Крок 4: нормалізація регістру там, де вона потрібна
Часте питання: «А можна зробити так, щоб MILK, Milk і milk вважалися однаковими?». Можна. Але краще робити це усвідомлено: ми нормалізуємо не «все підряд», а лише те, що збираємося порівнювати.
Наприклад, заведемо «канонічне імʼя товару» для внутрішніх розрахунків (поки що просто покажемо ідею у виводі):
fun main() {
val item = "Milk"
val canonical = item.lowercase()
println("item='$item', canonical='$canonical'") // item='Milk', canonical='milk'
}
Ця ідея пізніше дуже знадобиться для команд, категорій, ідентифікаторів — для всього, що має збігатися незалежно від введення. Важливо лише памʼятати: не завжди потрібно зводити користувацький текст до нижнього регістру. Інколи це псує «смисловий вигляд» даних.
6. Типові помилки
Помилка №1: викликати trim() і забути зберегти результат.
Дуже поширений сценарій: написали line.trim() і далі продовжили працювати з line. Але trim() повертає новий рядок, а початковий лишається без змін. Тому або пишемо val cleaned = line.trim(), або (якщо потрібно змінювати змінну) var line = line.trim().
Помилка №2: очікувати, що trim() прибирає пробіли всередині рядка.
trim() працює лише по краях. Якщо у вас рядок "milk 2 30", то після trim() пробіли всередині залишаться. Для внутрішніх пробілів потрібна окрема логіка (наприклад, цикл while (s.contains(" ")) s = s.replace(" ", " ")), як ми робили вище.
Помилка №3: використовувати split(" ") на «брудному» рядку й дивуватися порожнім частинам.
Якщо в рядку багато пробілів поспіль, розбиття за одним пробілом може дати неочікувані результати (аж до «порожніх» елементів), і ваш парсер починає плутатися. Найпростіший вихід на поточному рівні — спочатку нормалізувати пробіли, а вже потім робити split().
Помилка №4: брати parts[1] і parts[2] без перевірки size.
Навіть якщо «за завданням» користувач має ввести три частини, у реальному вводі він може ввести одну або дві. Без перевірки size ви отримаєте падіння через вихід за межі. Списки індексуються з нуля, а останній індекс — size - 1.
Помилка №5: порівнювати команди без нормалізації регістру.
Якщо ви десь перевіряєте на "add", а користувач вводить "Add", умова не спрацює. Тому команди майже завжди варто зводити до lowercase() (або uppercase()), а потім порівнювати вже нормалізований рядок.
Помилка №6: переплутати replace і replaceFirst.
Інколи потрібно замінити лише перше входження (наприклад, поправити «один роздільник»), а інколи — усі. Якщо ви використовуєте replace(), думаючи, що він замінить лише один раз, можна непомітно зіпсувати дані: рядок зміниться «надто сильно», і помилка буде неочевидною.
Помилка №7: змішувати «нормалізацію» і «розбір» в один нечитабельний клубок.
Технічно можна написати монстра на кшталт line.trim().lowercase().replace(...).split(...)..., але читати й налагоджувати це боляче. Значно спокійніше зробити так: окрема функція нормалізації, далі — окремий крок split(), а потім перевірки та парсинг. Такий код простіше підтримувати й простіше розширювати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ