JavaRush /Курси /Kotlin SELF /Regex мінімум — Regex(...) і replace для нормалізації тек...

Regex мінімум — Regex(...) і replace для нормалізації тексту

Kotlin SELF
Рівень 27 , Лекція 3
Відкрита

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», це «мінімум, щоб робити нормалізацію».

Елемент шаблону Зміст Приклад патерна Що знайде
\s
один пробільний символ (пробіл, таб, переведення рядка…)
\s
будь-який один «пробіл»
\d
одна цифра
\d
0…9
+
«один або більше разів підряд» для попереднього елемента
\s+
блок із пробілів/табів
[ ... ]
набір символів («один із перелічених»)
[-_]
один
-
або
_

Цього вже вистачає, щоб 80% «людського введення» привести до нормального вигляду.

6. Нормалізація роздільників: зводимо пробіли, __ і --- до одного -

Тепер приклад із життя. У нашому CLI-проєкті ми часто використовуємо категорії витрат. Користувач може ввести:

Fast Food, fast-food, FAST__FOOD, fast food

А ми хочемо зберігати категорію в одному стилі, наприклад: fast-food.

Зробімо нормалізатор, який:

  1. прибирає пробіли на краях
  2. перетворює на нижній регістр
  3. згортає будь-які послідовності з пробілів, дефісів і підкреслень в один дефіс
  4. прибирає дефіси на краях (щоб не було -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 поруч із функціями нормалізації й перевикористовувати. У нашому навчальному проєкті це ще не питання продуктивності, але це питання дисципліни й чистоти коду.

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