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. приводит к lowercase
  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 рядом с функциями нормализации и переиспользовать. В нашем учебном проекте это ещё не вопрос производительности, но это вопрос дисциплины и чистоты кода.

1
Задача
Kotlin SELF, 27 уровень, 3 лекция
Недоступна
Чистый поиск
Чистый поиск
1
Задача
Kotlin SELF, 27 уровень, 3 лекция
Недоступна
Категория для URL
Категория для URL
1
Задача
Kotlin SELF, 27 уровень, 3 лекция
Недоступна
Разделение на слова
Разделение на слова
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ