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.
Сделаем нормализатор, который:
- убирает пробелы по краям
- приводит к lowercase
- схлопывает любые последовательности из пробелов, дефисов и подчёркиваний в один дефис
- убирает дефисы по краям (чтобы не было -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 рядом с функциями нормализации и переиспользовать. В нашем учебном проекте это ещё не вопрос производительности, но это вопрос дисциплины и чистоты кода.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ