1. Навіщо потрібен Regex, якщо є replace?
Якщо ви вже вмієте робити text.replace("old", "new"), може виникнути логічне запитання: «А навіщо мені ще одна штука з назвою „регулярний вираз“, від якої в половини програмістів нервовий тік?». І справді: для простої заміни конкретного підрядка звичайного replace часто достатньо.
Проблема починається тоді, коли потрібно замінити не конкретний текст, а цілий клас схожих ситуацій: «будь-яку кількість пробілів», «будь-які цифри», «будь-який із символів - або _», «будь-яку табуляцію» тощо.
Уявіть, що ви пишете консольний застосунок (наш навчальний CLI), і користувач вводить команду:
add 120 food coffee
Якщо ви спробуєте «вручну» привести це до нормального вигляду (прибрати зайві пробіли, таби, переноси рядків), ви швидко почнете писати код, який підозріло нагадує мініпарсер. А можна зробити простіше: один раз описати правило «усі пробільні символи підряд замінити на один пробіл» — і отримати стабільне введення.
2. Що таке Regex у Kotlin
Regex як обʼєкт і шаблон як рядок
У цій лекції ми будемо сприймати Regex максимально приземлено. Не як «мову для виклику демонів», а як шаблон, що вміє знаходити в тексті щось за правилом.
Кілька важливих моментів «по-людськи»:
Regex у Kotlin — це обʼєкт. Його можна створити, покласти в val і перевикористовувати. Шаблон усередині Regex(...) — це рядок зі спеціальним синтаксисом. Там деякі символи означають не себе, а «правило».
Наприклад, \s — «пробільний символ», а + — «один або більше разів підряд». Тому \s+ читається як «один або більше пробільних символів підряд».
Kotlin дає кілька способів отримати регулярний вираз. Іноді ви побачите "...".toRegex() у чужому коді. Наприклад, у документації Kotlin трапляється заміна пробілів через input.replace("\\s".toRegex(), ""). Але сьогодні ми тримаємо фокус на формі Regex("..."), щоб усе було максимально явно: «ось обʼєкт регулярного виразу, ось його шаблон».
Екранування: звичайний рядок і raw string
Із регулярними виразами є одне побутове (і дуже часте) джерело болю: екранування.
У Kotlin рядок "\\s+" містить два символи \ і s та + (бо \\ усередині звичайного рядка означає «буквальний зворотний слеш»). А "\s+" — так писати не можна, бо \s буде спробою інтерпретувати \ як початок escape-послідовності в рядку.
Тому правило просте й дружнє:
- якщо пишете regex-патерн у звичайному рядку, майже завжди буде багато \\
- якщо пишете regex-патерн у raw string """...""", зворотні слеші можна писати як є
Порівняймо на одному прикладі:
fun main() {
val r1 = Regex("\\s+") // звичайний рядок: потрібно \\s
val r2 = Regex("""\s+""") // raw string: пишемо \s як є
println(r1.pattern) // \s+
println(r2.pattern) // \s+
}
Зазвичай для регулярних виразів raw string — це просто «режим безболісної розробки». Не панацея від усіх бід, але точно мінус одна причина страждати.
3. Regex.replace(...): знайшли все потрібне й замінили
Тепер найприкладніше: в обʼєкта Regex є метод replace, який приймає текст і рядок-заміну. Він замінює усі збіги (тобто це «replace all», а не «replace first»). Нам це ідеально підходить для нормалізації.
Базова форма виглядає так:
val regex = Regex("""...""")
val result = regex.replace(text, "заміна")
І так, часто ви побачите й другий стиль: text.replace(regex, "заміна"). Він теж працює. Але сьогодні ми дотримуємося форми regex.replace(...), бо її легше читати як «ось правило, застосуймо його до тексту».
4. Нормалізація пробілів: Regex("""\s+""")
У реальному введенні пробіли бувають різні. Це не лише ' ' (звичайний пробіл), а й \t (таб), і перенос рядка \n. Для людини це «якась порожнеча», а для парсингу команди — потенційна проблема.
Шаблон \s означає «будь-який пробільний символ», а + означає «один або більше разів підряд». Тому \s+ — це «будь-який блок пробільних символів».
Зробімо функцію, яка приводить будь-яке «брудне» введення до вигляду «слова розділені одним пробілом»:
fun normalizeSpaces(text: String): String {
val spaceRegex = Regex("""\s+""")
return spaceRegex.replace(text.trim(), " ")
}
fun main() {
val raw = " add\t\t120 food \n coffee "
val clean = normalizeSpaces(raw)
println(clean) // add 120 food coffee
}
Зверніть увагу на trim(). Регулярний вираз \s+ чудово згортає пробіли всередині, але якщо в рядка на краях є «сміттєві пробіли», їх простіше й читабельніше прибрати через уже знайомий trim().
5. Мінішпаргалка з елементів regex
Щоб не перетворювати лекцію на «вступ у нескінченність», зафіксуймо лише ті елементи regex, які нам сьогодні потрібні. Важливо: це не «все про regex», це «мінімум, щоб робити нормалізацію».
| Елемент шаблону | Зміст | Приклад патерна | Що знайде |
|---|---|---|---|
|
один пробільний символ (пробіл, таб, переведення рядка…) | |
будь-який один «пробіл» |
|
одна цифра | |
|
|
«один або більше разів підряд» для попереднього елемента | |
блок із пробілів/табів |
|
набір символів («один із перелічених») | |
один або |
Цього вже вистачає, щоб 80% «людського введення» привести до нормального вигляду.
6. Нормалізація роздільників: зводимо пробіли, __ і --- до одного -
Тепер приклад із життя. У нашому CLI-проєкті ми часто використовуємо категорії витрат. Користувач може ввести:
Fast Food, fast-food, FAST__FOOD, fast food
А ми хочемо зберігати категорію в одному стилі, наприклад: fast-food.
Зробімо нормалізатор, який:
- прибирає пробіли на краях
- перетворює на нижній регістр
- згортає будь-які послідовності з пробілів, дефісів і підкреслень в один дефіс
- прибирає дефіси на краях (щоб не було -food-)
fun normalizeCategory(raw: String): String {
val sep = Regex("""[-_\s]+""") // дефіс або _ або пробіли
val lowered = raw.trim().lowercase()
val dashed = sep.replace(lowered, "-")
return dashed.trim('-')
}
fun main() {
println(normalizeCategory(" FAST__FOOD ")) // fast-food
println(normalizeCategory("fast food")) // fast-food
println(normalizeCategory("__food__")) // food
}
Зауважте важливий момент: ми не робимо regex «надто розумним». Ми не намагаємося перевіряти «правильність категорії» до останньої літери. Ми просто приводимо введення до єдиного вигляду. Це і є нормалізація: спочатку робимо дані однаковими, а вже потім зручно порівнюємо й групуємо.
7. Маскування даних: замінюємо цифри через \d
Ще один дуже прикладний випадок — «замаскувати» дані, наприклад номер картки чи телефону в логах або у звіті. Так, у нашому навчальному CLI ми зазвичай не пишемо банківське ПЗ, але навичка корисна.
Патерн \d знаходить кожну цифру, а replace(..., "*") замінює кожну цифру на *.
fun maskDigits(text: String): String {
val digit = Regex("""\d""")
return digit.replace(text, "*")
}
fun main() {
val raw = "Order #2048, phone: +1 555 120-77-33"
println(maskDigits(raw))
// Order #****, phone: +* *** ***-**-**
}
Це простий приклад, але він добре показує спосіб мислення: regex — це не обовʼязково «парсити все». Іноді це просто «швидко знайти клас символів і замінити».
8. Вбудовуємо в наш CLI-застосунок: стійке введення команд
Зараз поєднаємо все з нашим консольним проєктом (умовно назвемо його BudgetCLI). Нагадаю загальний стиль: ми читаємо рядок, нормалізуємо його, розбираємо команду й оновлюємо колекції.
Нехай запис витрати в нас поки зберігається як Triple<String, Int, String>:
- first — категорія (вже нормалізована)
- second — сума
- third — нотатка (як текст)
Зробімо маленький, але показовий шматок: обробку команди add.
Нормалізація рядка команди
Спочатку приведімо введення до формату «один пробіл між токенами». Це сильно спрощує split(" ").
fun normalizeCommandLine(raw: String): String {
val spaces = Regex("""\s+""")
return spaces.replace(raw.trim(), " ")
}
fun main() {
val line = " add\t 120 fast__food coffee "
println(normalizeCommandLine(line)) // add 120 fast__food coffee
}
Тепер команда стає передбачуваною: ви можете спокійно робити split(" ") і не боятися порожніх токенів через пʼять пробілів підряд.
Мініобробник add з нормалізацією категорії
Додамо ще один крок: нормалізуємо категорію через normalizeCategory(...), а нотатку зберемо назад у рядок.
fun handleAdd(expenses: MutableList<Triple<String, Int, String>>, rawLine: String) {
val line = normalizeCommandLine(rawLine)
val parts = line.split(" ")
val amount = parts.getOrNull(1)?.toIntOrNull() ?: return
val category = normalizeCategory(parts.getOrNull(2) ?: return)
val note = parts.drop(3).joinToString(" ")
expenses.add(Triple(category, amount, note))
}
fun main() {
val expenses = mutableListOf<Triple<String, Int, String>>()
handleAdd(expenses, "add 120 FAST__FOOD coffee to go")
println(expenses) // [(fast-food, 120, coffee to go)]
}
Тут ми не намагаємося робити ідеальну обробку помилок (це ви вже вмієте робити через if, when, try/catch і валідацію). Але важливе інше: завдяки Regex.replace нормалізація відокремлена від логіки команд. Ви не розмазуєте «боротьбу з пробілами» по всьому коду.
Конвеєр: сире введення → нормальне введення → розбір
Щоб мозку було простіше, сприймайте це як маленький конвеєр:
flowchart LR
A["raw input
рядок від користувача"] --> B["normalize
Regex.replace + trim"]
B --> C["parse
split + toIntOrNull"]
C --> D["domain update
додали в колекцію"]
Що акуратніше ви розділяєте ці кроки, то менше у вас «магії» в одному місці. А що менше магії — то менше нічних розмов із винятками.
9. Типові помилки під час роботи з Regex.replace
Помилка № 1: плутати «звичайний replace» і «regex replace».
text.replace(".", "-") замінює крапку як символ (якщо це рядок), а в regex . означає «будь-який символ». Якщо ви випадково думаєте, що працюєте зі звичайною рядковою заміною, а насправді використовуєте регулярний вираз, можна замінити «надто багато». Тому в цій лекції ми тримаємо стиль val r = Regex(...) і потім r.replace(...): так одразу видно, що це regex-логіка.
Помилка № 2: забути про екранування у звичайному рядку.
Regex("\s+") не скомпілюється так, як ви очікуєте, бо \s усередині звичайного рядка — це не «буквальні символи», а спроба escape. Або пишіть Regex("\\s+"), або (частіше зручніше) використовуйте raw string Regex("""\s+""").
Помилка № 3: намагатися regexʼом «валідувати світ», коли потрібна нормалізація.
Дуже часта пастка новачка: замість того щоб привести пробіли до одного пробілу, людина починає будувати регулярний вираз, який «дозволяє лише правильне введення». У результаті шаблон стає довшим за вашу програму, а баги — цікавішими. Для нормалізації краще прості правила: згорнули пробіли, привели регістр, замінили роздільники.
Помилка № 4: забути про trim() після нормалізації пробілів.
Навіть якщо ви замінили \s+ на " ", на краях рядка все одно може залишитися один пробіл. Це не смертельно, але потім раптово ламає порівняння рядків або виведення. Комбінація replace(...).trim() зазвичай дає найпередбачуваніший результат.
Помилка № 5: створювати Regex заново в кожному місці без потреби.
Технічно Regex("""\s+""") можна писати хоч у кожній функції. Але якщо ви робите це всередині «гарячого» місця (наприклад, у циклі обробки тисяч рядків), ви створюєте зайві обʼєкти. Краще винести регулярні вирази в val поруч із функціями нормалізації й перевикористовувати. У нашому навчальному проєкті це ще не питання продуктивності, але це питання дисципліни й чистоти коду.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ