Регулярные выражения на практике: читаем, пишем, не боимся

Регулярное выражение (regex) — это не язык программирования и не «магическая строка», а способ описать шаблон текста и потом проверить, подходит ли под него данная строка, найти все такие места или заменить их. Один раз научившись читать шаблоны, вы перестанете гуглить «как проверить email» каждый раз и начнете видеть regex повсюду: в фильтре логов, в правиле nginx, в валидации формы на PHP и в поиске редактора. Разберём regex по делу: из чего он состоит, как его собрать под конкретную задачу, где он ломается и почему «работает в тесте» не значит «работает на юникоде».

Часть I. Анатомия одной регулярки

Начнём с того, что regex читается вслух почти как предложение. Возьмём шаблон /^[A-Z][a-z]+$/ и разберём слева направо:

СимволЧто значит
^«начало строки» — до этого символа ничего не должно быть
[A-Z]ровно один символ из диапазона A…Z (заглавная)
[a-z]+одна или много строчных букв подряд
$«конец строки» — после этого ничего

Вместе: «слово из одной заглавной и набора строчных» — то, что подходит под Codestack, но не под codestack или Codestack2024. Никакой логики, никаких условий — просто описание формы.

Три базовых строительных блока

1. Литералы — обычные символы, которые ищут самих себя: abc матчится к «abc». Специальные символы (. * + ? ( ) [ ] { } \ | ^ $) приходится экранировать слэшем, если вы хотите найти их буквально: \. — точка как знак препинания, а не «любой символ».

2. Классы символов — «один символ из набора»:

  • . — любой символ, кроме перевода строки (не «любая буква» — именно любой, включая пробел).
  • \d — цифра, \D — не цифра; \w — буква/цифра/подчёркивание, \W — наоборот; \s — пробельный, \S — непробельный.
  • [abc] — одна из трёх букв; [a-z] — диапазон; [^0-9] — «всё, кроме цифры» (карет внутри скобок = отрицание).

3. Квантификаторы — «сколько раз повторить предыдущий атом»:

  • * — 0 или больше; + — 1 или больше; ? — 0 или 1.
  • {n} — ровно n; {n,} — не меньше n; {n,m} — от n до m.

a{2,4} ловит aa, aaa, aaaa, но не a и не aaaaa.

Жадность: почему <.+> захватил весь файл

По умолчанию квантификаторы жадные: берут максимально длинное совпадение. Шаблон <.+>, применённый к строке <b>текст</b> <i>ещё</i>, захватит всё от первого < до последнего > — то есть и теги, и текст между ними. Причина: .+ тянет до конца строки, потом откатывается назад ровно до ближайшего >.

Лечение — ленивый (non-greedy) режим: .+?. Тогда движок берёт минимум и останавливается на первом подходящем >. А ещё точнее — не «что угодно между тегов», а «символы, не являющиеся >»: <[^>]+>. Это дешевле вычислительно и не перескакивает через закрытие тега.

Чек-лист части

  • Умею вслух прочитать простой шаблон символ за символом (`^`, класс, `+`, `$`).
  • Знаю, что `.` — это «любой символ», а не «точка», и что спец-символы экранируются `\.`.
  • Помню, что `*`/`+`/`?` жадные по умолчанию, а `?` после них и `[^>]` — способ это сдержать.
  • Могу отличить `\d` (цифра) от `[0-9]` (то же самое) и понять, когда удобнее класс-отрицание `[^…]`.

Часть II. Группы, границы и альтернативы — рабочий уровень

Одного «ищи такой шаблон» мало: обычно нужно ещё и вытащить часть совпадения, сопоставить варианты или привязаться к краю слова.

Группировка и захват

(...) делает две вещи: группирует (чтобы повесить квантификатор на кусок) и захватывает — сохраняет найденное в подгруппу, доступную потом как $1, \1 или .group(1).

(\d{3})-(\d{2})-(\d{2})

На 070-12-34 это даст группу 1 = 070, группу 2 = 12, группу 3 = 34. Так извлекают телефон, дату, ID из строки.

Если группировать нужно, но не захватывать (просто повесить ? или | на кусок) — берём non-capturing (?:…):

(?:https?|ftp)://

Это матчится к http://, https://, ftp://, но ничего не кладёт в $1 — экономит и память, и путаницу с номерами групп.

Именованные группы

(?<имя>…) даёт подгруппе имя вместо номера — код читается сам:

PHP
// PHP (PCRE): имя группы
$pattern = '/(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u';
if (preg_match($pattern, $date, $m)) {
    echo $m['year'];      // 2026 — по имени, а не по $m[1]
}
JavaScript
// JavaScript: именованные группы
const re = /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/u;
const m = '2026-10-20'.match(re);
console.log(m.groups.year); // 2026

Границы строки и слова

  • ^ / $ — начало / конец строки (или с флагом m — начало/конец каждой строки внутри многострочного текста).
  • \b — граница слова (переход «буква ↔ не-буква»). /cat\b/ матчится к cat, cats, the cat., но не к caterpillar — потому что после cat идёт e, а не граница. Это главный способ найти целое слово, а не его кусок внутри другого.

Альтернатива |

| означает «или», и работает на всю ширину группировки вокруг. /red|blue/ ловит red и blue. Но ^red|blue$ — это не «строка из red или blue», а «начинается с red» ИЛИ «заканчивается на blue». Чтобы ограничить выбор, группируют: ^(?:red|blue)$.

Разбор реальной валидации email по кусочкам

Часто просят «правильное regex для email». Честный ответ: полностью соответствующий RFC 5322 шаблон огромен и неподдерживаем, поэтому в прикладном коде берут практичный шаблон, покрывающий 99% реальных адресов:

^[\w.+-]+@[\w-]+\.[\w.-]+$

Читаем вслух:

  • ^ — с начала строки;
  • [\w.+-]+ — локальная часть: буква/цифра/_, точка, +, - (именно поэтому a.b+c@… пройдёт);
  • @ — обязательная собака;
  • [\w-]+ — имя домена первого уровня;
  • \. — буквальная точка (экранирована!);
  • [\w.-]+$ — доменная зона и её поддомены, до конца строки.

Он не ловит user@localhost (нет точки в домене) и не проверяет, что домен существует. Для формы регистрации этого достаточно; для «доставляем ли мы письмо» — только отправка подтверждением.

Чек-лист части

  • Знаю разницу `(…)`, `(?:…)` и `(?<name>…)` и не забываю не-захватывающую группу, когда она не нужна.
  • Использую `\b`, чтобы не найти `cat` внутри `category`.
  • Группирую альтернативу, если она должна быть в начале или в конце: `^(?:a|b)$`, а не `^a|b$`.
  • Экранирую спец-символы в литералах: `\.` для точки, `\/` для слэша внутри `/…/`.

Часть III. Где regex живёт в вашем стеке

Regex не «учебная тема». Он уже в вашем проекте — в четырёх местах.

PHP: preg_* с модификаторами

// Найти все совпадения
preg_match_all('/#(\w+)/u', $post, $m);   // $m[1] — массив хэштегов без решётки

// Заменить
$safe = preg_replace('/[^a-z0-9-]/u', '', strtolower($title));  // слаг из заголовка

preg_match возвращает 1/0, а не «истину» — и важно отличать false (ошибка, например битый шаблон) от 0 (просто нет совпадения). Модификатор /u включает режим UTF-8 — без него \w и классы работают по байтам, и кириллица ведёт себя неверно (об этом в Части IV). /i — регистронезависимо, /m — ^/$ построчно, /s — точка захватывает и перевод строки.

JavaScript: RegExp и флаги

// Флаг g — найти все, иначе только первое совпадение
const tags = (text.match(/#(\w+)/g) || []);

// matchAll + именованные группы (ES2020+)
for (const m of '2026-10-20'.matchAll(/(?<y>\d{4})-(?<mo>\d{2})/gu)) {
  console.log(m.groups.y);
}

Важно: regex с флагом g имеет состояние (lastIndex) — у метода .test()/.exec() на одном и том же объекте с g второй вызов начнёт не с нуля, а с продолжения. Частый баг «валидация то проходит, то нет». Для проверки «соответствует ли строка целиком» создавайте regex заново или сбрасывайте lastIndex.

Bash: grep -P, sed, поиск файлов

# найти строки, похожие на IPv4 (быстрый черновой фильтр)
grep -Po '\b(?:\d{1,3}\.){3}\d{1,3}\b' access.log

-E — расширенный синтаксис (+/?/| без слэшей), -P — PCRE (то самое движок-«взрослый»). Именно этот фильтр вы уже писали в статье про разбор логов и диагностику.

Nginx: location ~ — regex уже в проде

# обслуживать статические файлы по расширению — это regex-сопоставление
location ~* \.(?:css|js|png|jpg|jpeg|webp|svg|woff2)$ {
    expires 30d;
    access_log off;
}

~ — регистр важен, ~* — нет. Это тот же класс движка, что и в PHP (PCRE). Прочитать и починить такой конфиг целиком — задача отдельного инструмента-аудитора, который мы разбирали в статье про nginx. Регулярка — «секретный ингредиент», который вы уже трогали руками.

Чек-лист части

  • Помню, что в PHP без `/u` классы работают по байтам, а в JS regex с `g` имеет скрытое состояние.
  • Могу написать grep-фильтр логов через `-E`/`-P`.
  • Узнаю regex в `location ~` nginx и понимаю `~` против `~*`.
  • Знаю имена функций: `preg_match`/`preg_match_all`/`preg_replace`, `match`/`matchAll`.

Часть IV. Грабли: почему «работает в тесте» и ломается в проде

Повторяемые беды regex предсказуемы. Каждая со своим рецептом.

1. Катастрофический backtracking (самая дорогая)

Когда шаблон содержит вложенные квантификаторы над перекрывающимися символами — (a+)+, (\s*)*, (?:[\w.]+)+ — движок на несовпадающей длинной строке перебирает экспоненциальное число вариантов. Результат: CPU 100%, запрос висит, сайт лежит.

// Плохо: может улететь в экспоненциальный перебор на specially crafted вводе
$re = '/^(\w+\s?)*$/';

// Лучше: атомарная/обладательная скобка или простой безвложенный шаблон
$re = '/^[\w]+(?:[\s]+[\w]+)*$/u';

Защита: не вкладывать квантификатор в квантификатор над тем же классом символов; ограничивать длину входа до матчинга (регулярка не должна запускаться на megabyte-строку из формы); в PHP ставить pcre.backtrack_limit; для недоверенного ввода вообще не брать паттерны с вложенными звёздочками.

2. Юникод без /u — «успешно» ломает кириллицу

/\w+/ без /u матчится по байтам: буква й в UTF-8 — это два байта, и \w их не возьмёт. Кириллический текст «не совпадает», хотя глазами совпадает; . съедает байт и строка рвётся.

Решение: модификатор /u в PHP и флаг u в JS — всегда, когда данные UTF-8. Классы вроде [а-яё] пишите диапазоном, а не буквами поштучно, и помните, что ё часто забывают — [а-яё], а не [а-я].

3. Точка и слэш: два экранирования сразу

user.name@ — шаблон user.name матчится и к userXname, потому что . = «любой символ». Буквальную точку надо писать \..

А если ваш паттерн содержит / (URL-пути), то внутриDelimiter /…/ слэш надо экранировать \/ (или выбрать другой разделитель). Это не про синтаксис regex — про то, что в одном и том же паттерне иногда нужно и «экранировать спец-символ», и «не сломать разделитель».

4. Подмена: $1 против \1

Разные движки по-разному ссылается на группы при замене:

  • PHP preg_replace(': '$1' и '\\1' работают, но $1 — предпочтительно.
  • JS replace/replaceAll: $1, $<name> для именованных.
  • sed: \1.

Ошибка «хочу вставить группу, а получила литерал $1» случается, когда $ ещё и спец-символ в контексте (bash-строка в двойных кавычках). Пишитеreplacement в одинарных кавычках или экранируйте.

5. Многострочность: ^/$ не там, где думали

По умолчанию ^ = начало всей строки, а . не матчится к \n. Проверяете многострочное поле «каждая строка — это email» и получаете «совпало всё разом» — забыли /m (в JS m), а для «точка = любой символ, включая перевод» нужен /s (в JS — класс [\s\S], флага s мало в старых средах).

6. «Работает в тесте» ≠ «работает на всех данных»

Тест обычно содержит три аккуратные строки. Прод содержит null, пустую строку, строку из пробелов, эмодзи (surrogate pair в JS!), \r\n с Windows и однострочный SQL-инъекционный мусор. Валидный шаблон обязан проходить не только «что должно», но и «что не должно» — для этого и нужныпозитивные и негативные примеры из Части V.

Чек-лист граблей этой части

  • Никаких `(x+)+`, `(?:[…]+)+` над недоверенным вводом — защита от backtracking.
  • UTF-8 данные → `/u` (PHP) / `u`-флаг (JS) всегда.
  • Буквальные `.` и `/` — экранированы; `ё` включена в кириллический диапазон.
  • Помню про `/m` (построчные `^$`) и `/s` (точка через `\n`).
  • Ссылки на группы в заменях — в правильном синтаксисе движка (`$1`/`\1`).

Часть V. Конструирование по шагам

Хорошая регулярка почти никогда не пишется с одного удара. Она собирается итерацией, и вот как это делать быстро.

Метод четырёх вопросов

Задача: «найти в тексте все хэштеги вида #кодстек — слова, начинающиеся с решётки».

  1. Что такое «атом»? Одно слово после решётки: буквы/цифры/подчёркивание, в UTF-8 — [\p{L}\p{N}_]+ (буквы/цифры любой письменности + _).
  2. Что перед ним и за ним? Перед — #, после — пробел, конец или пунктуация → граница \b или просто «не слово» справа.
  3. Сколько раз? Ищем все → глобальный поиск, не якорим ^…$.
  4. Что НЕ должно ловиться? Одиночная #, # (пробел), 123 без решётки.

Черновик: /(?<tag>#[\p{L}\p{N}_]+)/u. Прогоняем позитивы и негативы (см. ниже), при необходимости добавляем ограничение «в слове есть хотя бы одна не-цифра» и т.д.

3 позитива и 3 негатива — обязательный минимум

Для любого шаблона проверяют не только «что должно совпасть», но и «что обязано НЕ совпасть». Для валидатора email из Части II:

Должно совпасть (позитив)Не должно совпасть (негатив)
anna@mail.ruanna@ (нет домена)
a.b+c@sub.example.org@mail.ru (пустая локальная часть)
ivan_2024@codestack.testa b@mail.ru (пробел)

Если негатив пролез — шаблон слишком широкий; если позитив не поймал — слишком узкий. Эта таблица из трёх+трёх строк ловит большинство ошибок валидации до того, какregex уехал в прод.

Инструмент, а не магия

Когда намерение уже сформулировано словами («слаг из заголовка», «только строки с IPv4», «email без поддомена») — это ровно то, что можно отдать генератору: он вернёт шаблон под нужный диалект (PCRE/JS/Python), а вам останется прогнать свои позитивы и негативы из таблицы выше. Главное — не принимать regex на веру без проверки: генератор снимает рутину «вспомнить синтаксис», но не снимает ответственность «понять, что он нашёл».

Чек-лист части

  • Иду по четырём вопросам: атом → окружение → сколько раз → что отсекаем.
  • Проверяю минимум 3 позитива и 3 негатива до того, как вставлю шаблон в код.
  • Формулирую намерение словами **до** того, как писать или генерировать regex.

Часть VI. Практика под WordPress

Свяжем всё с реальной задачей на сайте — три паттерна, которые встречаются в плагинах и темах.

1. Валидация и нормализация user-ввода

Slug из заголовка, который пишет пользователь:

function cs_slugify(string $title): string {
    $title = mb_strtolower($title, 'UTF-8');          // кириллица тоже
    $title = str_replace('ё', 'е', $title);           // ё → е
    // оставляем буквы/цифры/пробелы/дефис — остальное выкидываем
    $title = preg_replace('/[^\p{L}\p{N}\s-]+/u', '', $title);
    $title = preg_replace('/[\s-]+/u', '-', $title);  // пробелы/дефисы в один дефис
    return trim($title, '-');
}

\p{L} — категория «любая буква» из Unicode — единственно правильный способ для UTF-8 (кириллица, греческий, эмодзи-модификаторы). Без /u и без \p{…} кириллический заголовок схлопнется в пустую строку.

2. Вытащить данные из лога (смычка к диагностике)

В статье про мониторинг уровня ОС и диагностику мы читали journalctl. Regex — то, чем вы потом фильтруете этот вывод:

# ошибки PHP-FPM: строки с "[ERROR] … pid ..." → вытащить pid и сообщение
journalctl -u php8.2-fpm --no-pager \
  | grep -Po '\[ERROR\].*?(pid \d+)'

.*? здесь — ленивый, чтобы не захватить через несколько сообщений на одной длинной строке; \d+ — цифры pid. Тот же приём, что в Части I про <.+>, только наоборот полезный.

3. Разрешить/запретить путь в правиле nginx

# закрыть служебные файлы WordPress от внешнего доступа
location ~ /\. (?:ht|git|env) {
    deny all;
}

~ — регистрозависимый regex, \. — буквальная точка, (?:ht|git|env) — не-захватывающая альтернатива. Обратите внимание: вы пишете то же самое, что в PHP и в grep — просто в другом файле. Поняв regex один раз, вы переносите навык между всеми четырьмя стеками из Части III.

Чек-лист части

  • Для user-ввода нормализую через `preg_replace` с `\p{L}\p{N}` и `/u`, а не через `str_replace` букв поштучно.
  • Фильтрую логи через `grep -E`/`-P` и умею извлечь поля группами.
  • Узнаю regex в nginx-правиле и понимаю, почему там `\.` и `(?:…)`.

Что дальше

Регулярки дают вам один суперскилл: надежно сопоставлять и извлекать текст — от слага в WordPress до фильтра логов и правила в nginx. Это мост сразу к двум темам серии. Первая — юнит-тесты: теперь, когда вы проверяете шаблон на 3 позитивах и 3 негативах (Часть V), следующий логичный шаг — оформить такие проверки как тесты и гонять их автоматически при каждом изменении кода. Вторая — разбор диффов и кодревью:regex часто чинят не «на коленке», а ревьюя чужую регулярку в PR — там свои блокеры (backtracking, молчаливые расхождения движков).

Материал написан простым языком; любой англоязычный термин (regex, движок, матч, литерал, класс, квантификатор, жадность/backtracking, подгруппа, якорь/граница, диалект/flavor, PCRE, модификатор/флаг) объясняется при первом употреблении. Примеры — для PHP 8 (PCRE), современных браузерах (ECMAScript 2020+), GNU grep и nginx. Имена юнитов (php8.2-fpm против php8.3-fpm), домены и пути (example.test, /var/www/example.test) условные — подставляйте свои. Полная email-валидация по RFC 5322 сознательно не приводится: практичный шаблон Части II покрывает реальные адреса, а не весь стандарт. Тонкие механизмы (lookahead/lookbehind, атомарные и possessive-скобки, \K, backreferences в substitution) упомянуты на уровне «существуют» — это следующий слой, когда базовые шаблоны уже собираются уверенно.

Инструменты под задачу

Сформулировали намерение словами («найти все IPv4», « slug из заголовка», «только строки с email») — дайте его генератору: он соберёт regex под нужный диалект (PCRE / JavaScript / Python) и объяснит каждую группу. А если чужая регулярка непонятна или «почему-то не матчит» — вставьте её в режим «Объяснить», и движок разберёт её вслух и покажет, где именно ломается.

ИИ-генератор регулярных выражений
Гость
Токены: 0 / 50 000
Гостевой лимит — 50 000 токенов. Зарегистрируйтесь, для пользователей лимит + 200 000 токенов.

Генератор не заменяет понимание из Части IV: catastrophic backtracking и «юникод без /u» он тоже ловит, но вы отвечаете за то, чтобы прогнать 3 позитива и 3 негатива из Части V, прежде чем вставлять шаблон в прод.

👤 Зарегистрируйтесь, чтобы добавить материал в закладки.