Работа с текстом — повседневная задача для многих Python-разработчиков. Логи, пользовательский ввод, парсинг веб-страниц, конфигурационные файлы... Текстовые данные повсюду, и часто они далеки от идеального структурированного вида. Как найти нужную информацию, проверить формат или заменить части строк по сложным правилам? На помощь приходят регулярные выражения!
Основы регулярных выражений
Регулярные выражения, или regex, — это не язык программирования, а скорее мини-язык для описания текстовых шаблонов. По сути это просто последовательности символов, определяющие шаблоны для поиска, замены или валидации текста.
Давайте рассмотрим основные компоненты этого языка.
Литералы
Литералы — это простейший вид шаблонов. Они ищут точное совпадение символов в тексте. Хотите найти слово "привет" в строке "привет, мир!"? Шаблон будет просто `"привет"`.
Метасимволы
Метасимволы — это специальные символы с особым значением. Они b делают регулярные выражения гибкими. Вот основные:
- `.` — соответствует любому символу, кроме переноса строки.
- `^` — соответствует началу строки (`^Привет` найдёт "Привет" только в начале)
- `$` — соответствует концу строки.
- `*` — соответствует 0 или более повторений предыдущего элемента. `a*` найдет "", "a", "aa", "aaa" и т.д.
- `+` — соответствует 1 или более повторений предыдущего элемента. `a+` найдет "a", "aa", "aaa", но не пустую строку.
- `?` — соответствует 0 или 1 повторению предыдущего элемента. `a?` найдет "" или "a". Часто используется, чтобы сделать часть шаблона необязательной.
- `\` — экранирует специальный символ. Если вы хотите найти точку буквально, а не любой символ, используйте `\.`. Если хотите найти сам бэкслеш, используйте `\\`.
- `|` — работает как "ИЛИ". `кот|пес` найдет "кот" или "пес".
Пример: шаблон `кот.*` найдёт "кот", "котик" или даже "кот в сапогах" — потому что `.*` соответствует любому количеству любых символов.
Классы символов
Классы символов соответствуют любому символу внутри заданного набора. Например:
- `\d` — любая цифра (эквивалентно `[0-9]`). Пример: `\d\d` найдёт двузначное число, например "42".
- `\D` — любой НЕ цифровой символ.
- `\w` — любой "словесный" символ: буквы (включая кириллицу), цифры и знак подчеркивания `_`. Эквивалентно `[a-zA-Z0-9_]` плюс кириллица и другие алфавиты в зависимости от настроек локали или флагов.
- `\W` — любой НЕ словесный символ.
- `\s` — любой пробельный символ (пробел, таб `\t`, новая строка `\n` и т.д.).
- `\S` — любой НЕ пробельный символ.
- `\b` — граница слова. Полезно, чтобы найти целое слово, а не его часть (например, `\bкот\b` найдет "кот" в "это кот", но не в "котопёс").
Квантификаторы
Квантификаторы указывают количество вхождений символа или группы:
- `*` — 0 или более.
- `+` — 1 или более.
- `?` — 0 или 1.
- `{n}` — ровно `n` раз.
- `{n,}` — `n` или более раз.
- `{n,m}` — от `n` до `m` раз включительно.
Основные элементы мы рассмотрели. Не пугайтесь, если сразу все не запомнили — понимание приходит с практикой. А теперь давайте посмотрим, как использовать всё это в Python.
Модуль re в Python
В Python для работы с регулярными выражениями используется модуль re. Он входит в стандартную библиотеку, так что ничего дополнительно устанавливать не нужно. Просто `import re` — и всё готово.
Рассмотрим наиболее часто используемые функции.
re.search(pattern, string): поиск первого совпадения
Эта функция ищет первое место в строке `string`, где есть совпадение по шаблону `pattern`. Если совпадение найдено, возвращается специальный Match-объект, иначе — `None`.
Например, вот как можно извлечь любые числа из строки:
import re
text = "В тексте есть номер заказа 12345 и еще 678."
pattern = r"\d+" # Ищем одну или более цифр подряд
match = re.search(pattern, text)
if match:
print(f"Найдено первое число: {match.group()}")
print(f"Оно начинается на позиции: {match.start()}")
print(f"И заканчивается на позиции: {match.end()}")
else:
print("Числа не найдены.")
# Найдено первое число: 12345
# Оно начинается на позиции: 26
# И заканчивается на позиции: 31
Разберем шаблон `pattern = r"\d+"`:
Если совпадение найдено, метод `group()` возвращает найденное значение.
- Префикс `r` перед строкой означает "raw string" (сырая строка). В таких строках обратный слеш `\` не интерпретируется как специальный символ Python (например, `\n` как новая строка). Это крайне рекомендуется при написании regex-шаблонов, чтобы избежать путаницы с экранированием Python и экранированием regex.
- `\d` соответствует любой цифре (эквивалентно `[0-9]`).
- `+` указывает, что предыдущий шаблон (цифры) должен встречаться один или более раз.
Если совпадение найдено, метод `group()` возвращает найденное значение.
re.match(pattern, string): поиск совпадения только в начале строки
В отличие от `search()`, функция `match()` проверяет совпадение шаблона `pattern` только в самом начале строки `string`. Если начало строки не совпадает с шаблоном, она сразу возвращает `None`, даже если совпадение есть где-то дальше. Например, если мы хотим найти числовое совпадение в начале ранее определенной строки, мы можем написать:
text1 = "Заказ 9876 обработан."
text2 = "Обработан заказ 9876."
pattern = r"Заказ \d+" # Ищем "Заказ", пробел, затем одну или более цифр
match1 = re.match(pattern, text1)
match2 = re.match(pattern, text2)
print(f"Результат для text1: {match1.group() if match1 else None}")
# Результат для text1: Заказ 9876
print(f"Результат для text2: {match2.group() if match2 else None}")
# Результат для text2: None
В данном случае совпадений нет, так как текст не начинается с цифр.
Можем проверить, начинается ли строка с даты:
Можем проверить, начинается ли строка с даты:
is_date = re.match(r'\d{2}.\d{2}.\d{4}', '15.07.2023: Заметка')
print(bool(is_date)) # True
re.findall(pattern, string): поиск всех совпадений
Эта функция находит все непересекающиеся совпадения шаблона `pattern` в строке `string` и возвращает их в виде списка строк. Найдём номера телефонов (используем упрощённый шаблон, далее разберём более сложный):
text = "Контакты: +7 (123) 456-78-90, email@example.com, 8-800-555-35-35."
pattern = r"[\d()-]+" # Ищем последовательности цифр, скобок и дефисов
phones = re.findall(pattern, text)
print(f"Найденные номера: {phones}")
# Найденные номера (упрощенно): ['7', '123', '456-78-90', '8-800-555-35-35']
Теперь найдём все числа, слова и emailы:
pattern_words = r"\b\w+@\w+\.\w+\b|\b\w+\b" # Email ИЛИ слово
words_and_emails = re.findall(pattern_words, text)
print(f"Найденные слова и email: {words_and_emails}")
# Найденные слова и email: ['Контакты', '7', '123', '456', '78', '90', 'email@example.com', '8', '800', '555', '35', '35']
re.sub(pattern, repl, string, count=0): поиск и замена
Эта функция заменяет все (или первые `count`) совпадения шаблона `pattern` в строке `string` на строку, соответствующую шаблону `repl`.
Например, замена "123" на "много":
pattern = r"\d+"
text = "В нашем магазине 123 товара."
replaced_text = re.sub(pattern, "много", text)
print(replaced_text)
# В нашем магазине много товара.
Теперь "спрячем" цены и скидки в тексте:
text = "Цена: 500 руб. Скидка: 100 руб."
pattern = r"\d+" # Ищем числа
replacement = "XXX"
new_text = re.sub(pattern, replacement, text)
print(f"Замаскированный текст: {new_text}")
# Замаскированный текст: Цена: XXX руб. Скидка: XXX руб.
# Заменить только первое совпадение
new_text_first = re.sub(pattern, replacement, text, count=1)
print(f"Замаскировано только первое число: {new_text_first}")
# Замаскировано только первое число: Цена: XXX руб. Скидка: 100 руб.
Когда какую функцию использовать?
Давайте подытожим, когда какую функцию использовать:
- `re.search()`: нужно найти первое вхождение шаблона где угодно в строке.
- `re.match()`: нужно проверить, начинается ли строка с заданного шаблона.
- `re.findall()`: нужно извлечь все вхождения шаблона из строки в виде списка.
- `re.sub()`: нужно заменить вхождения шаблона на другую строку.
Продвинутые техники регулярных выражений
Освоили основы, теперь можно переходить к чуть более изощренным приемам.
Заглядывания вперёд и назад (lookarounds)
Такая техника позволяет проверить наличие шаблона до или после текущей позиции, но не включая этот шаблон в само совпадение.
- `(?=...)` — позитивное заглядывание вперед (positive lookahead): совпадение будет найдено, только если сразу после него идёт указанный шаблон.
- `(?!..).` — негативное заглядывание вперед (negative lookahead): совпадение будет найдено, только если сразу после него НЕ идет указанный шаблон.
- `(?<=...)` — позитивное заглядывание назад (positive lookbehind): совпадение будет найдено, только если сразу перед ним идет указанный шаблон.
- `(?<!..).` — негативное заглядывание назад (negative lookbehind): совпадение будет найдено, только если сразу перед ним НЕ идет указанный шаблон.
Пример lookahead: найдём все суммы в рублях, но извлечём только число.
text = "Товар А стоит 1500 руб., Товар Б - 2500 руб., доставка 300р."
pattern = r"\d+(?=\s*руб|\s*р)" # Число, за которым следуют пробелы (0+) и "руб" или "р"
prices = re.findall(pattern, text)
print(f"Найденные цены (только числа): {prices}")
# Найденные цены (только числа): ['1500', '2500', '300']
Видим, что "руб" и "р" не попали в результат благодаря lookahead.
Пример lookbehind: найдём слова, которым предшествует знак `@`.
text = "Пишите на support@example.com или звоните @tech_support_bot"
pattern = r"(?<=@)\w+" # Слово (\w+), которому предшествует @
handles = re.findall(pattern, text)
print(f"Найденные 'хэндлы': {handles}")
# Найденные 'хэндлы': ['example', 'tech_support_bot']
Обратим внимание, что `example.com` не подошло целиком, т.к. `.` не входит в `\w`.
Для извлечения полного домена потребовался бы более сложный шаблон.
Для извлечения полного домена потребовался бы более сложный шаблон.
Незахватывающие группы (non-capturing groups)
Незахватывающие группы (`(?:...)`) группируют части шаблона, но не сохраняют их для дальнейшего использования. Это полезно, если нам нужно применить квантификатор к группе, но не нужно её содержимое для дальнейшего использования.
Например, нам надо извлечь код города из телефонного номера формата `+7-XXX-YYY-ZZ-ZZ`, где код города `XXX` нам нужен, а `+7` — нет, но является обязательной частью шаблона.
text = "Номер: +7-916-123-45-67"
# Шаблон: +7, дефис (не захватываем), (код города 3 цифры - захватываем), остальное
pattern = r"(?:\+7-)(\d{3})-\d{3}-\d{2}-\d{2}"
match = re.search(pattern, text)
if match:
# group(0) - всё совпадение
print(f"Полное совпадение: {match.group(0)}")
# group(1) - первая (и единственная) захватывающая группа (\d{3})
print(f"Код города: {match.group(1)}")
else:
print("Номер не найден или формат неверный.")
# Полное совпадение: +7-916-123-45-67
# Код города: 916
Без `?:` группа `+7`- была бы `group(1)`, а код города — `group(2)`.
Дополнительные примеры
Давайте посмотрим на пару реалистичных сценариев применения регулярок.
Валидация данных
Регулярные выражения часто используются для проверки форматов данных, таких как адреса электронной почты, номера телефонов и даты.
Давайте проверим формат `+7 XXX YYY-ZZ-ZZ` или `8 XXX YYY-ZZ-ZZ` (с вариациями пробелов/скобок/дефисов). Шаблон уже будет сложнее по сравнению с тем, что использовали до этого.
def is_valid_ru_phone(phone):
# Шаблон: +7 или 8, потом необязательные пробелы/скобки/дефисы,
# 3 цифры, необязательные разделители, 3 цифры, разделители, 2 цифры, разделители, 2 цифры.
pattern = r"^(?:\+7|8)[\s(-]*\d{3}[\s)-]*\d{3}[\s-]*\d{2}[\s-]*\d{2}$"
return bool(re.match(pattern, phone))
print(f"+7 (916) 123-45-67: {is_valid_ru_phone('+7 (916) 123-45-67')}")
# True
print(f"8-926-9876543: {is_valid_ru_phone('8-926-9876543')}")
# True
print(f"89031112233: {is_valid_ru_phone('89031112233')}")
# True
print(f"7 123 456 78 90: {is_valid_ru_phone('7 123 456 78 90')}") # Неверный префикс
#False
print(f"911: {is_valid_ru_phone('911')}")
# False
Этот шаблон учитывает различные форматы записи номера, включая пробелы, скобки и дефисы.
Теперь проверим, похожа ли строка на адрес электронной почты (важно понимать, что 100% точный regex для email по RFC почти невозможен и избыточен, обычно используют упрощенные проверки).
def is_valid_email(email):
# Упрощенный шаблон: символы@символы.символы
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
return bool(re.match(pattern, email))
print(f"test@example.com: {is_valid_email('test@example.com')}")
# True
print(f"мой.ящик+tag@домен.рф: {is_valid_email('мой.ящик+tag@домен.рф')}")
# True
print(f"просто текст: {is_valid_email('просто текст')}")
# False
print(f"test@example: {is_valid_email('test@example')}")
# False
Извлечение IP-адресов
Еще один типичный случай использования — извлечение IP-адреса из логов. В Python мы можем создать это так
log_line = "2023-10-27 15:30:01 INFO: Пользователь admin вошел с IP 192.168.1.101 успешно."
# Шаблон: граница слова, 1-3 цифры, точка, 1-3 цифры, точка, ... , граница слова
pattern = r"\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b"
ip_match = re.search(pattern, log_line)
if ip_match:
print(f"Найден IP-адрес: {ip_match.group()}")
else:
print("IP-адрес не найден.")
# Найден IP-адрес: 192.168.1.101
Объясним шаблон:
- `\b` утверждает границу слова, обеспечивая, что IP-адрес не является частью большей строки цифр.
- `\d{1,3}` соответствует от 1 до 3 цифр.
- `\.` соответствует буквальной точке.
Оптимизация регулярок
Регулярные выражения могут быть ресурсоемкими, особенно на больших объемах текста или со сложными шаблонами. Вот несколько советов по оптимизации 👇🏻
Совет 1: компилируйте шаблоны, используемые многократно
Если мы используем один и тот же шаблон много раз (например, в цикле), то лучше скомпилировать его с помощью `re.compile()`. Это создает объект шаблона, который работает быстрее при последующих вызовах `search()`, `match()`, `findall()` и т.д.
import time
texts = ["Номер 123", "Текст без цифр", "Еще 456 и 789"] * 10000
pattern_str = r"\d+"
# Без компиляции
start_time = time.time()
for text in texts:
re.search(pattern_str, text)
time_no_compile = time.time() - start_time
# С компиляцией
compiled_pattern = re.compile(pattern_str)
start_time = time.time()
for text in texts:
compiled_pattern.search(text)
time_with_compile = time.time() - start_time
print(f"Время без компиляции: {time_no_compile:.4f} сек")
print(f"Время с компиляцией: {time_with_compile:.4f} сек")
# Обычно время с компиляцией заметно меньше на больших объемах.
В этом примере, каждый раз, когда код перебирает список, шаблон перекомпилируется, если вы не используете функцию `re.compile()`, что увеличивает время выполнения.
Совет 2: используйте конкретные шаблоны
Чем точнее задан шаблон, тем быстрее он работает. Избегайте слишком общих шаблонов вроде `.*`, если можно указать что-то более конкретное. Например, использование `\b` (границы слова) или `^`, `$` (начало/конец строки) сильно сужает область поиска для движка regex.
Посмотрим на такой пример:
Посмотрим на такой пример:
texts = ["abc123xyz", "123", "a123b", "x123y", "безномера", "123", "тест"] * 50000
# Менее эффективный шаблон
pattern1 = r".*123.*"
start_time = time.time()
for text in texts:
match = re.search(pattern1, text)
end_time = time.time()
time_with_less_efficient_pattern = end_time - start_time
# Более эффективный шаблон
pattern2 = r"\b123\b"
start_time = time.time()
for text in texts:
match = re.search(pattern2, text)
end_time = time.time()
time_with_more_efficient_pattern = end_time - start_time
print(f"Время с неэффективным шаблоном: {time_with_less_efficient_pattern:.3f} сек, время с эффективным шаблоном: {time_with_more_efficient_pattern:.3f} сек")
# На исполнее более эффективного шаблона нужно меньше времени
Тут у нас два варианта:
- `.*123.*` менее эффективен, потому что `.*` соответствует любому символу (кроме новой строки) 0 или более раз, как до, так и после "123". Движок regex должен рассмотреть большое количество потенциальных совпадений.
- `\b123\b` более эффективен, потому что он более конкретен. `\b` утверждает границу слова, обеспечивая, что "123" сопоставляется только как целое слово. Это уменьшает количество возможных совпадений, которые движок regex должен оценить.
Совет 3: всегда используйте сырые строки
Всегда используйте raw-строки (префикс `r`), чтобы избежать ошибок, связанных с экранированием символов (например, `\b` в обычной строке это backspace, а не граница слова). И это не только предотвращает ошибки из-за конфликта экранирования Python и regex , но в некоторых случаях может дать и небольшой прирост производительности, так как Python не тратит время на интерпретацию escape-последовательностей.
Вот пример, показывающий, что производительность лучше с сырыми строками:
Вот пример, показывающий, что производительность лучше с сырыми строками:
# Определяем шаблон regex без использования сырой строки
pattern_without_raw = "\d+"
# Определяем тот же шаблон regex, используя сырую строку
pattern_with_raw = r"\d+"
text = "123 456 789 012" * 3000000
# Поиск с использованием шаблона без сырой строки
start_time = time.time()
matches_without_raw = re.findall(pattern_without_raw, text)
end_time = time.time()
time_without_raw = end_time - start_time
# Поиск с использованием шаблона с сырой строкой
start_time = time.time()
matches_with_raw = re.findall(pattern_with_raw, text)
end_time = time.time()
time_with_raw = end_time - start_time
print(f"Время без сырой строки: {time_without_raw:.3f} сек")
print(f"Время с сырой строкой: {time_with_raw:.3f} сек")
# с raw-строкой будет точно быстрее
А вот пример с ошибкой
# Определяем шаблон regex для сопоставления адресов электронной почты
pattern_without_raw = "\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
text = "Свяжитесь с нами по адресу email@example.com или посетите наш сайт www.example.com"
# Пытаемся найти адреса электронной почты, используя шаблон без сырой строки
matches_without_raw = re.findall(pattern_without_raw, text)
print(f"Совпадения без сырой строки: {matches_without_raw}")
# []
Мы не использовали сырую строку, поэтому Python обрабатывает обратные слеши в шаблоне как escape-символы. Это приводит к непреднамеренному поведению: в частности, `\b` интерпретируется как символ возврата на один символ, а не как граница слова, и шаблон не может правильно сопоставить адреса электронной почты.
Правильный шаблон для избежания этого непреднамеренного поведения:
Правильный шаблон для избежания этого непреднамеренного поведения:
pattern = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b"
Заключение
В этой статье мы рассмотрели основы, продвинутые техники, практические примеры и стратегии оптимизации производительности регулярных выражений в Python.
Конечно, мир regex гораздо глубже, существуют и другие инструменты в рамках (жадные/ленивые квантификаторы, флаги компиляции, именованные группы и т.д.), но этого базиса уже точно хватит для решения большинства задач.
Конечно, мир regex гораздо глубже, существуют и другие инструменты в рамках (жадные/ленивые квантификаторы, флаги компиляции, именованные группы и т.д.), но этого базиса уже точно хватит для решения большинства задач.
Для закрепления и расширения знаний, рекомендую подборку бесплатных тренажёров по регуляркам, которую можно найти здесь.