Урок 16 Регулярные выражения

Модуль re: поиск, замена, группы, шаблоны

⏱ ~15 мин чтения· 📗 Средний

Что такое регулярные выражения

Регулярные выражения (regex, regexp) — это язык шаблонов для поиска и обработки текста. Вместо того чтобы писать сложный код с циклами и условиями, вы описываете шаблон, а движок регулярных выражений ищет совпадения за вас.

# Без regex: проверка, является ли строка номером телефона
def is_phone(s):
    if len(s) != 12:
        return False
    if s[0] != '+':
        return False
    for ch in s[1:]:
        if not ch.isdigit():
            return False
    return True

# С regex: один шаблон решает всё
import re
def is_phone(s):
    return bool(re.fullmatch(r'\+\d{11}', s))

В Python регулярные выражения доступны через встроенный модуль re.

Сырые строки и regex

Перед строками с шаблонами всегда ставьте r (raw string), чтобы обратный слеш \ не интерпретировался как escape-символ:

pattern = r'\d+\.\d+'    # Правильно: ищет число с плавающей точкой
pattern = '\\d+\\.\\d+'  # Без r пришлось бы экранировать каждый слеш

Базовые метасимволы

Метасимволы — это специальные знаки, которые не совпадают сами с собой, а задают правило поиска:

Символ Значение Пример
. Любой символ, кроме \n к.т → «кот», «кит»
^ Начало строки ^Привет → строка начинается с «Привет»
$ Конец строки конец$ → строка заканчивается на «конец»
* 0 или более повторений a* → '', 'a', 'aa', 'aaa'...
+ 1 или более повторений a+ → 'a', 'aa', 'aaa'...
? 0 или 1 повторение colou?r → 'color', 'colour'
{n} Ровно n повторений \d{3} → ровно 3 цифры
{n,m} От n до m повторений \d{2,4} → 2, 3 или 4 цифры
{n,} n или более повторений \d{2,} → 2 и более цифр
[] Один символ из набора [aeiou] → любая гласная
[^] Один символ НЕ из набора [^0-9] → не цифра
| ИЛИ (альтернатива) кот|пёс → 'кот' или 'пёс'
() Группировка и захват (ab)+ → 'ab', 'abab'...
\ Экранирование метасимвола \. → точка как символ

Символьные классы (shorthand)

Это сокращённые обозначения для часто используемых наборов символов:

Класс Значение Противоположность
\d Цифра [0-9] \D — не цифра
\w Буква, цифра или _ [a-zA-Z0-9_] \W — не буква/цифра
\s Пробельный символ (пробел, таб, перенос) \S — не пробельный
\b Граница слова \B — не граница слова
\A Начало строки (аналог ^) \Z — конец строки (аналог $)
import re

text = "Кот №42 съел 3 рыбки и рис."
print(re.findall(r'\d+', text))      # ['42', '3']
print(re.findall(r'\w+', text))      # ['Кот', '42', 'съел', '3', 'рыбки', 'рис']
print(re.findall(r'\b\w{3}\b', text))  # ['Кот', 'рис'] — слова из 3 букв

re.search() — первый найденный

Ищет первое совпадение шаблона в любом месте строки. Возвращает объект Match или None:

import re

text = "Мой номер: +7-999-123-45-67"

match = re.search(r'\+\d-\d{3}-\d{3}-\d{2}-\d{2}', text)
if match:
    print(match.group())   # +7-999-123-45-67
    print(match.start())   # 11 (индекс начала)
    print(match.end())     # 27 (индекс конца)
    print(match.span())    # (11, 27)

# Если не найдено — возвращает None
result = re.search(r'z{10}', "zzz")
print(result)              # None

re.match() — только с начала строки

Проверяет совпадение строго с начала строки. Не ищет в середине:

text = "Python — отличный язык"

print(re.match(r'Python', text))    # Match — начинается с Python
print(re.match(r'язык', text))      # None — 'язык' не в начале
print(re.search(r'язык', text))     # Match — search найдёт в любом месте

Фактически re.match(pat, text) эквивалентен re.search(r'^' + pat, text).

re.fullmatch() — полное совпадение

Вся строка должна соответствовать шаблону от начала до конца:

print(re.fullmatch(r'\d+', "12345"))   # Match
print(re.fullmatch(r'\d+', "123abc"))  # None — есть буквы
print(re.fullmatch(r'\d+', "123 45"))  # None — есть пробел

# fullmatch автоматически добавляет ^ в начале и $ в конце
# Это эквивалентно: re.search(r'^\d+$', "12345")

re.findall() — все совпадения

Возвращает список всех найденных совпадений:

text = "Цены: 100 руб, 250 руб, 50 руб."
prices = re.findall(r'\d+', text)
print(prices)                # ['100', '250', '50']

# Если в шаблоне есть группы, findall возвращает список кортежей
text = "Иван: 25 лет, Анна: 30 лет"
pairs = re.findall(r'(\w+): (\d+)', text)
print(pairs)                 # [('Иван', '25'), ('Анна', '30')]

re.finditer() — итератор по совпадениям

Возвращает итератор объектов Match. Полезно, когда нужны позиции или когда данных много:

text = "Первый, второй, третий"
for m in re.finditer(r'\w+', text):
    print(f"'{m.group()}' на позиции {m.start()}-{m.end()}")
# 'Первый' на позиции 0-6
# 'второй' на позиции 8-14
# 'третий' на позиции 16-22

re.sub() — замена по шаблону

Заменяет все совпадения на указанную строку. Четвёртый аргумент count ограничивает количество замен:

text = "Кот ловит мышь. Кот спит."

# Замена всех вхождений
print(re.sub(r'Кот', 'Пёс', text))
# Пёс ловит мышь. Пёс спит.

# Замена только первого вхождения
print(re.sub(r'Кот', 'Пёс', text, count=1))
# Пёс ловит мышь. Кот спит.

# Удаление всех цифр
print(re.sub(r'\d', '', "a1b2c3"))   # abc

# Удаление лишних пробелов
print(re.sub(r'\s+', ' ', "Много    лишних   пробелов"))
# Много лишних пробелов

Ссылки на группы в замене\1, \2 и т.д. позволяют использовать найденные группы:

# Меняем порядок слов
print(re.sub(r'(\w+) (\w+)', r'\2 \1', "Hello World"))
# World Hello

# Форматируем дату: ДД-ММ-ГГГГ → ГГГГ.ММ.ДД
date = "25-12-2023"
print(re.sub(r'(\d{2})-(\d{2})-(\d{4})', r'\3.\2.\1', date))
# 2023.12.25

# Маскируем середину номера телефона
phone = "+7-999-123-45-67"
print(re.sub(r'(\d{3}-\d{2})-\d{2}$', r'\1-XX', phone))
# +7-999-123-45-XX

Можно также передать функцию, которая будет вызвана для каждого совпадения:

def double(match):
    return str(int(match.group()) * 2)

text = "Было 5 яблок и 3 груши"
print(re.sub(r'\d+', double, text))
# Было 10 яблок и 6 груш

re.split() — разделение по шаблону

Разбивает строку там, где шаблон совпадает:

# Разбить по любому количеству пробелов и запятых
text = "яблоко,  груша,банан,  апельсин"
print(re.split(r'[,\s]+', text))
# ['яблоко', 'груша', 'банан', 'апельсин']

# Обычный split со сложным разделителем
text = "один; два: три, четыре"
print(re.split(r'[;:,]\s*', text))
# ['один', 'два', 'три', 'четыре']

# maxsplit ограничивает количество разбиений
print(re.split(r'[;:,]\s*', text, maxsplit=2))
# ['один', 'два', 'три, четыре']

Группы и захват

Круглые скобки () создают группу захвата. Содержимое, совпавшее с группой, можно извлечь:

text = "Email: ivan@mail.ru, Контакт: anna@gmail.com"

match = re.search(r'(\w+)@(\w+\.\w+)', text)
if match:
    print(match.group(0))    # ivan@mail.ru — всё совпадение
    print(match.group(1))    # ivan — первая группа (имя)
    print(match.group(2))    # mail.ru — вторая группа (домен)
    print(match.groups())    # ('ivan', 'mail.ru') — все группы

# Именованные группы: (?P<name>...)
match = re.search(r'(?P<user>\w+)@(?P<domain>\w+\.\w+)', text)
if match:
    print(match.group('user'))     # ivan
    print(match.group('domain'))   # mail.ru
    print(match.groupdict())       # {'user': 'ivan', 'domain': 'mail.ru'}

Незахватывающие группы (?:...)

Если скобки нужны только для группировки, но не для захвата — используйте (?:...):

# С захватом: findall вернёт только содержимое групп
print(re.findall(r'(кот|пёс) спит', "кот спит и пёс спит"))
# ['кот', 'пёс']

# Без захвата: findall вернёт полные совпадения
print(re.findall(r'(?:кот|пёс) спит', "кот спит и пёс спит"))
# ['кот спит', 'пёс спит']

Жадность и ленивость

По умолчанию квантификаторы *, +, ?, {}жадные (захватывают максимум). Добавление ? после квантификатора делает его ленивым (захватывает минимум):

text = "<h1>Заголовок</h1><p>Текст</p>"

# Жадный захват — от первого < до последнего >
print(re.findall(r'<.*>', text))
# ['<h1>Заголовок</h1><p>Текст</p>']

# Ленивый захват — от первого < до ближайшего >
print(re.findall(r'<.*?>', text))
# ['<h1>', '</h1>', '<p>', '</p>']

# Извлечение содержимого тегов
print(re.findall(r'<.*?>(.*?)</.*?>', text))
# ['Заголовок', 'Текст']

re.compile() — компиляция шаблона

Если шаблон используется многократно, скомпилируйте его один раз:

import re

# Компилируем один раз
email_pattern = re.compile(r'[\w.-]+@[\w.-]+\.\w+')

emails = ["ivan@mail.ru", "bad@@mail", "anna@gmail.com", "no-at"]

for e in emails:
    if email_pattern.fullmatch(e):
        print(f"{e} — корректный email")
    else:
        print(f"{e} — НЕ email")

# У скомпилированного шаблона есть те же методы:
# pattern.search(), pattern.match(), pattern.findall(), pattern.sub(), ...

Флаги (модификаторы)

Флаги меняют поведение регулярных выражений. Передаются вторым аргументом в функции re или в компиляцию:

import re

text = """Python — лучший язык.
PYTHON используют миллионы.
python прост в изучении."""

# re.IGNORECASE (или re.I) — игнорировать регистр
print(re.findall(r'python', text, re.IGNORECASE))
# ['Python', 'PYTHON', 'python']

# re.MULTILINE (или re.M) — ^ и $ работают с началом/концом каждой строки
# По умолчанию ^ и $ привязаны к началу/концу всего текста
lines = "строка1\nстрока2\nстрока3"
print(re.findall(r'^строка\d', lines, re.MULTILINE))
# ['строка1', 'строка2', 'строка3']

# re.DOTALL (или re.S) — точка . совпадает и с переносом строки \n
html = "<div>\n  <span>текст</span>\n</div>"
print(re.findall(r'<div>.*</div>', html))           # [] — . не проходит \n
print(re.findall(r'<div>.*</div>', html, re.DOTALL))  # ['<div>\n  <span>текст</span>\n</div>']

# re.VERBOSE (или re.X) — позволяет писать шаблоны с комментариями и отступами
phone_pattern = re.compile(r"""
    \+7          # код страны
    [-\s]?       # необязательный разделитель
    \d{3}        # код оператора
    [-\s]?       # необязательный разделитель
    \d{3}        # первая часть
    [-\s]?       # необязательный разделитель
    \d{2}        # вторая часть
    [-\s]?       # необязательный разделитель
    \d{2}        # третья часть
""", re.VERBOSE)

# Флаги можно комбинировать через |
pattern = re.compile(r'python', re.IGNORECASE | re.MULTILINE)

Lookahead и Lookbehind (опережающая и ретроспективная проверки)

Проверяют, что перед или после совпадения находится определённый шаблон, но не включают его в результат:

text = "100 руб, $50, 200 руб, $75"

# (?=...) — позитивный lookahead: после цифр идёт "руб"
print(re.findall(r'\d+(?=\s*руб)', text))
# ['100', '200']

# (?!...) — негативный lookahead: после цифр НЕ идёт "руб"
print(re.findall(r'\d+(?!\s*руб)', text))
# ['10', '0', '20', '0', '50', '75']

# (?<=...) — позитивный lookbehind: перед цифрами стоит "$"
print(re.findall(r'(?<=\$)\d+', text))
# ['50', '75']

# (?<!...) — негативный lookbehind: перед цифрами НЕ стоит "$"
# Полезно для исключения определённых совпадений

Практические примеры

Проверка email

import re

def is_valid_email(email):
    pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
    return bool(re.fullmatch(pattern, email))

print(is_valid_email("ivan@mail.ru"))     # True
print(is_valid_email("bad-email@"))       # False
print(is_valid_email("name@sub.dom.org")) # True

Извлечение URL из текста

text = "Сайты: https://python.org и http://example.com/page"

urls = re.findall(r'https?://[^\s,;]+', text)
print(urls)  # ['https://python.org', 'http://example.com/page']

Проверка надёжности пароля

def is_strong_password(password):
    """
    Пароль должен быть >= 8 символов,
    содержать хотя бы одну цифру, заглавную и строчную букву.
    """
    checks = [
        (r'.{8,}', 'Минимум 8 символов'),
        (r'\d', 'Хотя бы одна цифра'),
        (r'[A-ZА-ЯЁ]', 'Хотя бы одна заглавная буква'),
        (r'[a-zа-яё]', 'Хотя бы одна строчная буква'),
    ]
    for pattern, msg in checks:
        if not re.search(pattern, password):
            return False, msg
    return True, "Пароль надёжный"

print(is_strong_password("qwerty"))        # (False, 'Минимум 8 символов')
print(is_strong_password("Qwerty123"))     # (True, 'Пароль надёжный')

Очистка текста от HTML-тегов

def strip_html(text):
    return re.sub(r'<[^>]+>', '', text)

html = "<p>Это <b>важный</b> текст.</p>"
print(strip_html(html))  # Это важный текст.

Когда использовать regex, а когда — строковые методы

Регулярные выражения мощны, но не всегда нужны. Для простых операций строковые методы быстрее и читаемее:

text = "  Hello World  "

# Строковый метод (лучше для простого)
print(text.strip())              # Убрать пробелы по краям

# Regex (избыточен)
print(re.sub(r'^\s+|\s+$', '', text))

# Строковый метод
text = "раз,два,три"
parts = text.split(",")

# Regex (оправдан для сложного разделителя)
text = "раз; два, три: четыре"
parts = re.split(r'[;,:]\s*', text)

# Строковый метод
print("abc" in "abcdef")            # Проверить вхождение подстроки

# Regex (оправдан для поиска по шаблону)
print(bool(re.search(r'\d{3}-\d{2}-\d{2}', "Мой СНИЛС: 123-45-67")))

Итоги

  • Модуль re — встроенный инструмент Python для работы с регулярными выражениями
  • Сырые строки r'...' обязательны при написании шаблонов
  • re.search() — первое совпадение в любом месте; re.match() — совпадение с начала; re.fullmatch() — полное совпадение
  • re.findall() — список всех совпадений; re.finditer() — итератор с позициями
  • re.sub() — замена по шаблону; re.split() — разделение по шаблону
  • Группы (...) захватывают части совпадения; (?:...) — группировка без захвата
  • Флаги (re.IGNORECASE, re.MULTILINE, re.DOTALL, re.VERBOSE) управляют поведением
  • Компилируйте шаблоны через re.compile(), если используете их многократно
  • Не усложняйте: для простой проверки подстроки используйте in, а не regex