Регулярные выражения — очень мощный инструмент, однако согласно общепринятому мнению, после того как они написаны, их очень трудно понять, поэтому их поддержка — не самое приятное занятие. Здесь собраны советы, которые помогут сделать из более читаемыми.

PHP использует диалект регулярных выражений PCRE — до версии PHP 7.3, и PCRE2 — в более новых версиях. Поэтому в PHP можно использовать различные продвинутые приемы, помогающие писать читаемые, самодокументируемые и поддерживаемые регулярные выражения. При этом не надо также забывать и о наличии в PHP функций фильтрации переменных, а также семейства функций ctype*, позволяющих валидировать такие распространенные значения как URL-ссылки, адреса электронной почты и строки из букв и цифр — вообще без использований регулярный выражений. Во многих IDE есть подсветка регулярных выражений, помогающая их читать, а иногда даже и проверка выражений, с подсказками по их улучшению.

Но все же, для долговременной поддержки регулярных выражений лучше всего с самого начала писать их так, чтобы они были понятными и самодокументируемыми. Описанные ниже советы помогут в этом. Однако обратите внимание на то, что какие-то из них могут не сработать в старых версиях PHP (старше PHP 7.3). Кроме того, использование показанных здесь приемов может привести к ухудшению переносимости регулярных выражений под другие языки программирования. Например, именованные группы поддерживаются даже в старых версиях PHP, а вот в JavaScript они добавлены только, начиная с версии ES2018.

Вот какие приемы могут улучшить читаемость регулярных выражений:

  • выбор подходящих символов-ограничителей;

  • устранение избыточного экранирования символов;

  • незахватывающие группы;

  • именованные захватывающие группы;

  • использование комментариев;

  • именованные классы символов.

Выбор подходящих символов-ограничителей

Любое регулярное выражение состоит из двух частей — собственно регулярного выражения и его флагов. Выражение расположено между двух специально выбранных символов-ограничителей, а флаги — опционально следуют за замыкающим ограничителем. Например:

/(foo|bar)/i

Здесь (foo|bar) — регулярное выражение, i — флаг, задающий его поведение, а символы / — ограничители. Прямой слэш / очень часто используют как ограничитель, однако эту роль может выполнять и другой символ. Например, это могут быть символы ~, !, @, #, $ и другие. А вот цифры, буквы, эмоджи и обратный слэш \ — не могут ими быть. Ограничителями могут быть и парные скобки различных видов: {}, (), [], и <> Нередко с ограничителями в виде скобок регулярные выражения могут быть записаны намного понятнее, чем с использованием других символов ограничителей. Хотя, конечно, это, зависит и от содержания выражения. Выбор подходящего символа-ограничителя регулярного выражения важен потому, что все упоминания этого символа в контексте регулярного выражении должны быть экранированы. И, чем меньше экранирований будет в выражении, тем лучше оно будет читаться. Выбор ограничивающего символа не являющегося метасимволом языка регулярных выражений (таким, как ^, $, скобки, и другие), поможет уменьшить количество экранированных символов в выражении. Также, лучше не использовать в качестве ограничителя и те символы, которые часто встречается в регулярном, выражении. Скажем, прямой слэш / очень часто используется в качестве ограничителя, но иногда это — не лучший выбор, например, когда выражение содержит URL-ссылки. Например: 

preg_match('/^https:\/\/example\.com\/path/i', $uri);

Если же поменять ограничитель на “#”, регулярное выражение станет гораздо понятнее, так как в нем теперь будет только один экранированный символ:

preg_match('#^https://example\.com/path#i', $uri);

Уменьшение количества экранированных символов

Кроме выбора подходящего символа-ограничителя есть и другие способы уменьшить количество экранирований в регулярном выражении. Некоторые метасимволы языка регулярных выражений не являются таковыми при использовании в задаваемом диапазоне символов в квадратных скобках. Например, это символы ., *, +, $. Поэтому, в выражении /Username: @[a-z\.0-9]/ нет необходимости экранировать символ “.” , потому что в квадратных скобках он не работает как метасимвол. 

Более того, некоторые метасимволы не требуют экранирования, если не являются частью выражения задания диапазона. В частности, это символ дефиса -. В позиции между двумя символами он является метасимволом, и указывает на то, что задается диапазон символов, в другом же контексте он перестает им быть, и становится обычным символом.

Например, в регулярном выражении /[A-Z]/, дефис означает диапазон символов между A и Z. Если же он экранирован (/[A\-Z]/), то дефис — это просто символ для поиска, и такое выражение ищет символы A, Z и дефис. Таким образом, чтобы избавиться от необходимости экранирования дефиса, достаточно в выражении переместить его в конец набора символов, заданного в квадратных скобках. Например, выражение /[AZ-]/ делает то же самое, что и /[A\-Z]/, но короче и понятнее.

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

/Price: [0-9\-\$\.\+]+/

и

/Price: [0-9$.+-]+/

Существует флаг X, выбрасывающий ошибку в том случае, если в регулярном выражении заэкранирован символ, не имеющий специального значения. К сожалению, он не учитывает контекста и не вызывает ошибку, например, в случае экранирования метасимвола при задании диапазона символов в квадратных скобках, где метасимвол перестает быть таковым. Вот пример использования этого флага — выполнение кода:

preg_match('/x\yz/X', ''); // символ "y" — заэкранирован, хотя он — не специальный

вызывает предупреждение:

Warning: preg_match(): Compilation failed: unrecognized character follows \ at offset 2 in ... on line ...

Незахватывающие группы

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

Рассмотрим код, получающий цену из строки вида “Price: €24”.

$pattern = '/Price: (?|€)(\d+)/';
$text    = 'Price: €24';
preg_match($pattern, $text, $matches);

Здесь 2 захватывающие группы, первая получает символ валюты, в которой указана цена ((?|€)), вторая — получает численное значение цены. При запуске такого кода, в переменной $matches окажутся соответствующая выражению строка, а также подстроки, соответствующим двум группам в круглых скобках:

var_dump($matches);

array(3) {
  [0]=> string(12) "Price: €24"
  [1]=> string(3) "€"
  [2]=> string(2) "24"
}

Подобные группы можно применить и для повышения читаемости регулярного выражения, в котором не требуется получения подстрок. Для этого надо использовать незахватывающие группы, которые задаются все теми же круглыми скобками, но после открывающей скобкой должно идти сочетание символов ?:. Такие группы учитываются компилятором регулярных выражений, но соответствующие им подстроки не захватываются, то есть не возвращаются в результате. Например, если в написанном выше выражении интересует только числовое значение цены, то первую группу (?|€), захватывающую соответствующее ей значение, нужно заменить на незахватывающую: (?:?|€).

$pattern = '/Price: (?:?|€)(\d+)/';
$text    = 'Price: €24';
preg_match($pattern, $text, $matches);
var_dump($matches);

В результате работы такого кода в $matches будет содержаться только 1 подстрока — значение цены:

array(2) {
  [0]=> string(12) "Price: €24"
  [1]=> string(2) "24"
}

Таким образом, в регулярных выражениях с несколькими захватывающими группами, те группы, результаты которых далее не используются, имеет смысл задавать как незахватывающие, это устранит избыточные данные в результате выполнения выражения.

Именованные захватывающие группы

Именованные захватывающие группы позволяют не только получить в результате соответствующие им подстроки, но и задать имена содержащим эти подстроки элементам результирующего массива. Что полезно не только с точки зрения получения именованных результатов, но и потому, что части регулярного выражения получат понятное название, что существенно улучшит его читаемость 

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

/Price: (?<currency>?|€)(?<price>\d+)/

Как можно видеть, именованная захватывающая группа открывается символами (?, за которыми в угловых скобках следует имя группы, и закрывается все той же круглой скобкой. В примере выше, (?<currency>?|€)  — именованная группа currency, а (?<price>\d+) — группа под названием price. Такое написание с одной стороны позволяет читающему код регулярного выражения программисту понять, что делает каждая именованная группа, с другой — понятным образом именует элементы массива с результатами работы выражения. Например, выполнив код:

$pattern = '/Price: (?<currency>?|€)(?<price>\d+)/';
$text    = 'Price: €24';
preg_match($pattern, $text, $matches);
var_dump($matches);

Получим:

array(5) {
 [0]=> string(12) "Price: €24"
["currency"]=> string(3) "€"
[1]=> string(3) "€"
["price"]=> string(2) "24"
[2]=> string(2) "24"
}

Таким образом, массив $matches теперь содержит подстроки, полученные захватывающими группами не только по индексам в порядке появления, эти значения также есть и в элементах ассоциативного массива именованных в соответствии с названиями групп.

 Согласитесь, гораздо легче понять, чему соответствует элемент ["currency"]=> "€", чем [1]=> "€".

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

Warning: preg_match(): Compilation failed: two named subpatterns have the same name (PCRE2_DUPNAMES not set) at offset ... in ... on line ....

Однако их можно разрешить, используя модификатор J (UPD: как модификатор поддерживается, начиная с PHP 7.2.0, в более старших версиях для того же эффекта нужно было добавлять в начало регулярного выражения костыльную опцию?J):

/Price: (?<currency>?|€)?(?<price>\d+)(?<currency>?|€)?/J

Здесь мы видим 2 захватывающие группы с одинаковым названием currency, и такое регулярное выражение не вызовет ошибки, потому что в нем использован модификатор J. В результате работы такого выражения, в именованном элементе массива currency будет возвращена подстрока, соответствующая самой последней группе с этим названием. При этом, в индексированных элементах массива будут подстроки, соответствующие каждой группе:

$pattern = '/Price: (?<currency>?|€)?(?<price>\d+)(?<currency>?|€)?/J';
$text    = 'Price: €24?';
preg_match($pattern, $text, $matches);
var_dump($matches);

array(6) {
  [0]=> string(14) "Price: €24?"
  ["currency"]=> string(2) "?"
  [1]=> string(3) "€"
  ["price"]=> string(2) "24"
  [2]=> string(2) "24"
  [3]=> string(2) "?"
}

Использование комментариев

Нередко регулярные выражения становятся длинными, и разрастаются на много строк. Тогда, чтобы они не стали простыней нечитаемого текста, их разбивают на отдельные PHP-строки, содержащие самодостаточные части выражения, и пишут к ним комментарии. А при компиляции регулярного выражении — соединяют их в единую строку. Это улучшает читаемость и позволяет после правки таких выражений получить более лаконичные списки изменений кода в коммитах системы контроля версий.

Например, вместо:

$pattern  = '/Price: (?<currency>?|€)(?<price>\d+)/i';

Можно написать:

$pattern  = '/Price: ';
$pattern .= '(?<currency>?|€)'; // Capture currency symbols ? or €
$pattern .= '(?<price>\d+)'; // Capture price without decimals.
$pattern .= '/i'; // Flags: Case-insensitive

Комментарии можно добавлять и в сами регулярные выражения. Существует флаг x, с которым компилятор регулярных выражений начинает игнорировать в выражении все пробельные символы, в том числе и переносы строк. Это позволяет визуально разграничить части выражения и даже разбить его на несколько строк, не меняя его смысла. Сравните:

/Price: (?<currency>?|€)(?<price>\d+)/i

и

/Price:  \s  (?<currency>?|€)  (?<price>\d+)  /ix

Во втором случае, пробелы позволяет повысить читаемость выражения, при этом компилятор регулярных выражений их игнорирует за счет использования флага x. Пробелами можно выделить логические последовательности подшаблонов регулярного выражения, сделав, таким образом, его более понятным. Если же нужно, чтобы выражение искало пробелы, то нужно применить в нем специальную последовательность символов \s.

Кроме игнорирования пробелов флаг x также включает возможность добавления в тело регулярного выражения комментариев, начинающихся с символа #, и по синтаксису похожих на комментарии в PHP коде. Выражение можно сделать еще более читаемым, разбив его на несколько строк и добавив к ним комментарии. Например, вместо:

/Price: (?<currency>?|€)(?<price>\d+)/i

Можно написать:

/Price:           # Check for the label "Price:"
\s                # Ensure a white-space after.
(?<currency>?|€)  # Capture currency symbols ? or €
(?<price>\d+)     # Capture price without decimals.
/ix

Для записи отформатированного в таком виде выражения в виде строки PHP, лучше всего использовать Heredoc или Nowdoc синтаксис. Таким образом, получим:

$pattern = <<<PATTERN
  /Price:           # Check for the label "Price:"
  \s                # Ensure a white-space after.
  (?<currency>?|€)  # Capture currency symbols ? or €
  (?<price>\d+)     # Capture price without decimals.
  /ix               # Flags: Case-insensitive
PATTERN;

preg_match($pattern, 'Price: ?42', $matches);

Именованные классы символов

Регулярные выражения поддерживают предопределенные классы символов, которые облегчают написание выражений, в то же время, делая их более читаемыми. Вероятно, самый часто используемый именованный диапазон символов — это \d включающий в себя все цифровые символы и, при отключенном режиме Юникода, равнозначный диапазону символов [0-9]. Класс \D означает обратное, то есть — любой нецифровой символ, и равнозначен диапазону [^0-9]. Таким образом, чтобы, например, регулярное выражение искало цифру, за которой идет нецифровой символ, вместо: 

/Number: [0-9][^0-9]/

можно написать:

/Number: \d\D/

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

  • \w — все символы, из которых обычно составлены слова, равнозначно диапазону [A-Za-z0-9_],

Вместо 

/[A-Za-z0-9_]/

можно написать лаконичное:

/\w/
  • [:xdigit:] — символы встречающиеся в написании шестнадцатеричных символов, то есть диапазон [A-Fa-f0-9],

Тогда вместо

/[a-fA-F0-9]/

пишем:

/[[:xdigit:]]/
  • \s — все пробельные символы, диапазон [ \t\r\n\v\f],

Вместо

/ \t\r\n\v\f/

Пишем гораздо более короткое и ясное

/\s/

Если регулярное выражение используется с флагом /u, включающим режим Юникода, то появляются дополнительные именованные классы символов Юникода. Они задаются в формате \p{КЛАСС_СИМВОЛОВ}, где КЛАСС_СИМВОЛОВ — соответственно название класса. Если же в \p буква "p" написана в верхнем регистре, например \P{FOO}, то такой диапазон наоборот — задает все остальные символы, без включения означенного класса. Среди дополнительных именованных классов есть и такие полезные, как, например, \p{Sc}, задающий весь диапазон символов для обозначения валюты, причем, как существующих, так и тех, что будут добавлены в таблицу символов в будущем. Кстати, есть и другой формат задания этого класса: \p{Currency_Symbol}, но он пока не поддерживается в регулярных выражениях PHP.

Вот пример выражения получающего из строки сумму и валюту:

$pattern = '/Price: \p{Sc}\d+/u';

На выходе получим:

$text = 'Price: ?42';

Именованные классы символов позволяют задавать выражения для поиска и захвата подстроки, даже не зная конкретных символов для поиска. Например, как уже упоминалось выше, приведенное выражение для поиска валют в будущем сможет искать также и символы новых появившихся за это время валют, когда они будут добавлены в Юникод. Классы символов для Юникода включают в себя также и очень полезный набор классов, задающих диапазоны символов письменностей различных языков человечества. Например, выражение \p{Sinhala} соответствует диапазону символов письменности Сингальского языка, то есть диапазону \x{0D80}-\x{0DFF}. Согласитесь, что:

$pattern = '/[\x{0D80}-\x{0DFF}]/u';

Намного менее понятно, чем:

$pattern = '/\p{Sinhala}/u';

Применив это выражение к строке на сингальском языке,

$text = '???????.????`;
$contains_sinhala = preg_match($pattern, $text);

мы видим, что даже, имея дело с незнакомым и экзотическим языком, мы можем с пользой применить регулярные выражения!

P.S. В процессе перевода статьи нашел в оригинале пару ошибок в примерах регулярных выражений — трудно все-таки они читаются. Написал автору, пусть исправит.